Skip to content

Commit fe53e3a

Browse files
authored
fix(idempotency): ensure unique idempotency keys for multiple methods with same key (#1124)
1 parent 4d3bb9a commit fe53e3a

File tree

4 files changed

+167
-33
lines changed

4 files changed

+167
-33
lines changed

libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs

Lines changed: 65 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Security.Cryptography;
33
using System.Text;
44
using System.Text.Json;
5+
using System.Threading;
56
using System.Threading.Tasks;
67
using AWS.Lambda.Powertools.Common;
78
using AWS.Lambda.Powertools.Idempotency.Exceptions;
@@ -34,9 +35,15 @@ public abstract class BasePersistenceStore : IPersistenceStore
3435
private IdempotencyOptions _idempotencyOptions = null!;
3536

3637
/// <summary>
37-
/// Function name
38+
/// Base function name (Lambda function name from environment or "testFunction")
3839
/// </summary>
39-
private string _functionName;
40+
private string _baseFunctionName = null!;
41+
42+
/// <summary>
43+
/// Full function name including method name (used for key generation)
44+
/// This is stored per-call via AsyncLocal to support multiple idempotent methods
45+
/// </summary>
46+
private readonly AsyncLocal<string> _fullFunctionName = new();
4047

4148
/// <summary>
4249
/// Boolean to indicate whether or not payload validation is enabled
@@ -58,27 +65,26 @@ public abstract class BasePersistenceStore : IPersistenceStore
5865
public void Configure(IdempotencyOptions idempotencyOptions, string functionName, string keyPrefix)
5966
{
6067
// Fast path - already configured
61-
if (_isConfigured) return;
68+
if (_isConfigured)
69+
{
70+
// Even if already configured, we need to set the full function name for this call
71+
// This supports multiple idempotent methods in the same Lambda
72+
SetFullFunctionName(functionName, keyPrefix);
73+
return;
74+
}
6275

6376
lock (_configureLock)
6477
{
6578
// Double-check pattern
66-
if (_isConfigured) return;
67-
68-
if (!string.IsNullOrEmpty(keyPrefix))
69-
{
70-
_functionName = keyPrefix;
71-
}
72-
else
79+
if (_isConfigured)
7380
{
74-
var funcEnv = Environment.GetEnvironmentVariable(Constants.LambdaFunctionNameEnv);
75-
76-
_functionName = funcEnv ?? "testFunction";
77-
if (!string.IsNullOrWhiteSpace(functionName))
78-
{
79-
_functionName += "." + functionName;
80-
}
81+
SetFullFunctionName(functionName, keyPrefix);
82+
return;
8183
}
84+
85+
// Set the base function name (Lambda function name from environment)
86+
var funcEnv = Environment.GetEnvironmentVariable(Constants.LambdaFunctionNameEnv);
87+
_baseFunctionName = funcEnv ?? "testFunction";
8288

8389
_idempotencyOptions = idempotencyOptions;
8490

@@ -94,6 +100,33 @@ public void Configure(IdempotencyOptions idempotencyOptions, string functionName
94100
}
95101

96102
_isConfigured = true;
103+
104+
// Set the full function name for this call
105+
SetFullFunctionName(functionName, keyPrefix);
106+
}
107+
}
108+
109+
/// <summary>
110+
/// Sets the full function name for the current call context.
111+
/// This method is called for each idempotent method invocation to ensure
112+
/// the correct method name is used in the idempotency key.
113+
/// </summary>
114+
/// <param name="functionName">The name of the decorated method</param>
115+
/// <param name="keyPrefix">Optional custom key prefix</param>
116+
private void SetFullFunctionName(string functionName, string keyPrefix)
117+
{
118+
if (!string.IsNullOrEmpty(keyPrefix))
119+
{
120+
_fullFunctionName.Value = keyPrefix;
121+
}
122+
else
123+
{
124+
var fullName = _baseFunctionName;
125+
if (!string.IsNullOrWhiteSpace(functionName))
126+
{
127+
fullName += "." + functionName;
128+
}
129+
_fullFunctionName.Value = fullName;
97130
}
98131
}
99132

@@ -105,27 +138,24 @@ internal void Configure(IdempotencyOptions options, string functionName, string
105138
LRUCache<string, DataRecord> cache)
106139
{
107140
// Fast path - already configured
108-
if (_isConfigured) return;
141+
if (_isConfigured)
142+
{
143+
SetFullFunctionName(functionName, keyPrefix);
144+
return;
145+
}
109146

110147
lock (_configureLock)
111148
{
112149
// Double-check pattern
113-
if (_isConfigured) return;
114-
115-
if (!string.IsNullOrEmpty(keyPrefix))
116-
{
117-
_functionName = keyPrefix;
118-
}
119-
else
150+
if (_isConfigured)
120151
{
121-
var funcEnv = Environment.GetEnvironmentVariable(Constants.LambdaFunctionNameEnv);
122-
123-
_functionName = funcEnv ?? "testFunction";
124-
if (!string.IsNullOrWhiteSpace(functionName))
125-
{
126-
_functionName += "." + functionName;
127-
}
152+
SetFullFunctionName(functionName, keyPrefix);
153+
return;
128154
}
155+
156+
// Set the base function name (Lambda function name from environment)
157+
var funcEnv = Environment.GetEnvironmentVariable(Constants.LambdaFunctionNameEnv);
158+
_baseFunctionName = funcEnv ?? "testFunction";
129159

130160
_idempotencyOptions = options;
131161

@@ -137,6 +167,8 @@ internal void Configure(IdempotencyOptions options, string functionName, string
137167
_cache = cache;
138168

139169
_isConfigured = true;
170+
171+
SetFullFunctionName(functionName, keyPrefix);
140172
}
141173
}
142174

@@ -356,7 +388,7 @@ private string GetHashedIdempotencyKey(JsonDocument data)
356388
}
357389

358390
var hash = GenerateHash(node);
359-
return _functionName + "#" + hash;
391+
return _fullFunctionName.Value + "#" + hash;
360392
}
361393

362394
/// <summary>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
using Amazon.Lambda.Core;
17+
using AWS.Lambda.Powertools.Idempotency.Tests.Model;
18+
19+
namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers;
20+
21+
/// <summary>
22+
/// Lambda function with multiple idempotent methods to test cross-method key isolation.
23+
/// This tests the scenario where two different methods are decorated with [Idempotent]
24+
/// and called with the same IdempotencyKey value - they should create separate entries
25+
/// in the persistence store.
26+
/// </summary>
27+
public class IdempotencyMultipleMethodsFunction
28+
{
29+
public bool Method1Called { get; private set; }
30+
public bool Method2Called { get; private set; }
31+
32+
public (Basket, Basket) HandleRequest([IdempotencyKey] string key, ILambdaContext context)
33+
{
34+
Idempotency.RegisterLambdaContext(context);
35+
36+
// Call both methods with the same key - they should each create their own idempotency record
37+
var result1 = Method1(key);
38+
var result2 = Method2(key);
39+
40+
return (result1, result2);
41+
}
42+
43+
[Idempotent]
44+
private Basket Method1([IdempotencyKey] string key)
45+
{
46+
Method1Called = true;
47+
return new Basket(new Product(1, "Product from Method1", 10.0));
48+
}
49+
50+
[Idempotent]
51+
private Basket Method2([IdempotencyKey] string key)
52+
{
53+
Method2Called = true;
54+
return new Basket(new Product(2, "Product from Method2", 20.0));
55+
}
56+
}

libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotentAspectTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,45 @@ public async Task WhenIdempotency_Custom_Prefix_Key_Method()
359359
Assert.NotNull(record);
360360
}
361361

362+
[Fact]
363+
public async Task Handle_WhenMultipleIdempotentMethods_WithSameKey_ShouldCreateSeparateRecords()
364+
{
365+
// Arrange
366+
// This test validates the bug where two different methods decorated with [Idempotent]
367+
// and called with the same IdempotencyKey value should create separate entries
368+
// in the persistence store, but currently Method2 incorrectly uses Method1's cached result.
369+
var store = new InMemoryPersistenceStore();
370+
Idempotency.Configure(builder => builder.WithPersistenceStore(store));
371+
372+
var context = new TestLambdaContext
373+
{
374+
RemainingTime = TimeSpan.FromSeconds(30)
375+
};
376+
377+
// Act
378+
var function = new IdempotencyMultipleMethodsFunction();
379+
var (result1, result2) = function.HandleRequest("same-key", context);
380+
381+
// Assert
382+
// Both methods should have been called
383+
function.Method1Called.Should().BeTrue("Method1 should be called on first invocation");
384+
function.Method2Called.Should().BeTrue("Method2 should be called - it's a different method even with same key");
385+
386+
// Results should be different - each method returns its own product
387+
result1.Products[0].Name.Should().Be("Product from Method1");
388+
result2.Products[0].Name.Should().Be("Product from Method2");
389+
390+
// Debug: Show what keys were actually created
391+
var allKeys = store.GetAllKeys().ToList();
392+
393+
// Verify separate records exist in the store - should have 2 records with different method names
394+
allKeys.Should().HaveCount(2, $"Expected 2 records but found: [{string.Join(", ", allKeys)}]");
395+
396+
// Verify the keys contain the correct method names
397+
allKeys.Should().Contain(k => k.StartsWith("testFunction.Method1#"), "Should have a record for Method1");
398+
allKeys.Should().Contain(k => k.StartsWith("testFunction.Method2#"), "Should have a record for Method2");
399+
}
400+
362401
[Fact]
363402
public void Handle_WhenIdempotencyOnSubMethodNotAnnotated_ShouldThrowException()
364403
{

libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/InMemoryPersistenceStore.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
using System;
1717
using System.Collections.Generic;
18+
using System.Linq;
1819
using System.Threading.Tasks;
1920
using AWS.Lambda.Powertools.Idempotency.Persistence;
2021

@@ -23,6 +24,12 @@ namespace AWS.Lambda.Powertools.Idempotency.Tests.Internal;
2324
public class InMemoryPersistenceStore: BasePersistenceStore
2425
{
2526
private readonly Dictionary<string, DataRecord> _records = new();
27+
28+
/// <summary>
29+
/// Gets all keys currently stored - useful for debugging tests
30+
/// </summary>
31+
public IEnumerable<string> GetAllKeys() => _records.Keys;
32+
2633
public override Task<DataRecord> GetRecord(string idempotencyKey)
2734
{
2835
return Task.FromResult(_records.ContainsKey(idempotencyKey) ? _records[idempotencyKey] : null);

0 commit comments

Comments
 (0)