Skip to content

feat: idempotent function #349

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/utilities/idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,38 @@ You can quickly start by configuring `Idempotency` and using it with the `Idempo
}
```

#### Idempotent attribute on another method

You can use the `Idempotent` attribute for any .NET function, not only the Lambda handlers.

When using `Idempotent` attribute on another method, you must tell which parameter in the method signature has the data we should use:

- If the method only has one parameter, it will be used by default.
- If there are 2 or more parameters, you must set the `IdempotencyKey` attribute on the parameter to use.

!!! info "The parameter must be serializable in JSON. We use `System.Text.Json` internally to (de)serialize objects"

```csharp
public class Function
{
public Function()
{
Idempotency.Configure(builder => builder.UseDynamoDb("idempotency_table"));
}

public Task<string> FunctionHandler(string input, ILambdaContext context)
{
dummpy("hello", "world")
return Task.FromResult(input.ToUpper());
}

[Idempotent]
private string dummy(string argOne, [IdempotencyKey] string argTwo) {
return "something";
}
}
```

### Choosing a payload subset for idempotency

!!! tip "Tip: Dealing with always changing payloads"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

using System;

namespace AWS.Lambda.Powertools.Idempotency;

/// <summary>
/// IdempotencyKey is used to signal that a method parameter is used as a key for idempotency.
/// Must be used in conjunction with the Idempotency attribute.
///
/// Example:
///
/// [Idempotent]
/// private Basket SubMethod([IdempotencyKey]string magicProduct, Product p) { ... }
/// Note: This annotation is not needed when the method only has one parameter.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter)]
public class IdempotencyKeyAttribute: Attribute
{

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@

using System;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;
using Amazon.Lambda.Core;
using AspectInjector.Broker;
using AWS.Lambda.Powertools.Common;
using AWS.Lambda.Powertools.Idempotency.Exceptions;
Expand Down Expand Up @@ -74,8 +76,12 @@ protected internal sealed override T WrapSync<T>(Func<object[], T> target, objec
{
return base.WrapSync(target, args, eventArgs);
}

var payload = args is not null && args.Any() ? JsonDocument.Parse(JsonSerializer.Serialize(args[0])) : null;
if (eventArgs.ReturnType == typeof(void))
{
throw new IdempotencyConfigurationException("The annotated method doesn't return anything. Unable to perform idempotency on void return type");
}

var payload = GetPayload<T>(eventArgs);
if (payload == null)
{
throw new IdempotencyConfigurationException("Unable to get payload from the method. Ensure there is at least one parameter or that you use @IdempotencyKey");
Expand Down Expand Up @@ -107,8 +113,13 @@ protected internal sealed override async Task<T> WrapAsync<T>(
{
return await base.WrapAsync(target, args, eventArgs);
}

if (eventArgs.ReturnType == typeof(void))
{
throw new IdempotencyConfigurationException("The annotated method doesn't return anything. Unable to perform idempotency on void return type");
}

var payload = args is not null && args.Any() ? JsonDocument.Parse(JsonSerializer.Serialize(args[0])) : null;
var payload = GetPayload<T>(eventArgs);
if (payload == null)
{
throw new IdempotencyConfigurationException("Unable to get payload from the method. Ensure there is at least one parameter or that you use @IdempotencyKey");
Expand All @@ -124,4 +135,41 @@ protected internal sealed override async Task<T> WrapAsync<T>(
var result = await idempotencyHandler.Handle();
return result;
}

/// <summary>
/// Retrieve the payload from the annotated method parameter
/// </summary>
/// <param name="eventArgs">The <see cref="AspectEventArgs" /> instance containing the event data.</param>
/// <typeparam name="T"></typeparam>
/// <returns>The payload</returns>
private static JsonDocument GetPayload<T>(AspectEventArgs eventArgs)
{
JsonDocument payload = null;
var eventArgsMethod = eventArgs.Method;
var args = eventArgs.Args;
var isPlacedOnRequestHandler = IsPlacedOnRequestHandler(eventArgsMethod);
// Use the first argument if IdempotentAttribute placed on handler or number of arguments is 1
if (isPlacedOnRequestHandler || args.Count == 1)
{
payload = args is not null && args.Any() ? JsonDocument.Parse(JsonSerializer.Serialize(args[0])) : null;
}
else
{
//Find the first parameter in eventArgsMethod with attribute IdempotencyKeyAttribute
var parameter = eventArgsMethod.GetParameters().FirstOrDefault(p => p.GetCustomAttribute<IdempotencyKeyAttribute>() != null);
if (parameter != null)
{
// set payload to the value of the parameter
payload = JsonDocument.Parse(JsonSerializer.Serialize(args[Array.IndexOf(eventArgsMethod.GetParameters(), parameter)]));
}
}

return payload;
}

private static bool IsPlacedOnRequestHandler(MethodBase method)
{
//Check if method has two arguments and the second one is of type ILambdaContext
return method.GetParameters().Length == 2 && method.GetParameters()[1].ParameterType == typeof(ILambdaContext);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small note: ILambdaContext is an optional argument, which will return false on IsPlacedOnRequestHandler, but from the looks of it the if there is only one argument it is also valid.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You got it right, either one parameter or 2 parameters where the second parameter is ILambdaContext

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ private string GetHashedIdempotencyKey(JsonDocument data)
node = JsonDocument.Parse(result).RootElement;
}

if (IsMissingIdemPotencyKey(node))
if (IsMissingIdempotencyKey(node))
{
if (_idempotencyOptions.ThrowOnNoIdempotencyKey)
{
Expand All @@ -308,7 +308,7 @@ private string GetHashedIdempotencyKey(JsonDocument data)
/// </summary>
/// <param name="data"></param>
/// <returns>True if the Idempotency key is missing</returns>
private static bool IsMissingIdemPotencyKey(JsonElement data)
private static bool IsMissingIdempotencyKey(JsonElement data)
{
return data.ValueKind == JsonValueKind.Null || data.ValueKind == JsonValueKind.Undefined
|| (data.ValueKind == JsonValueKind.String && data.ToString() == string.Empty);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

using Amazon.Lambda.Core;
using AWS.Lambda.Powertools.Idempotency.Tests.Model;

namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers;

/// <summary>
/// Simple Lambda function with Idempotent attribute on a sub method (not the Lambda handler one)
/// </summary>
public class IdempotencyInternalFunction
{
public Basket HandleRequest(Product input, ILambdaContext context)
{
return CreateBasket("fake", input);
}

[Idempotent]
private Basket CreateBasket([IdempotencyKey]string magicProduct, Product p)
{
IsSubMethodCalled = true;
Basket b = new Basket(p);
b.Add(new Product(0, magicProduct, 0));
return b;
}

public bool IsSubMethodCalled { get; private set; } = false;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

using Amazon.Lambda.Core;
using AWS.Lambda.Powertools.Idempotency.Tests.Model;

namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers;

/// <summary>
/// Simple Lambda function with Idempotent attribute on a sub method (not the Lambda handler one)
/// </summary>
public class IdempotencyInternalFunctionInternalKey
{
public Basket HandleRequest(Product input, ILambdaContext context)
{
return CreateBasket(input);
}

[Idempotent]
private Basket CreateBasket(Product p)
{
return new Basket(p);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

using Amazon.Lambda.Core;
using AWS.Lambda.Powertools.Idempotency.Tests.Model;

namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers;

/// <summary>
/// Simple Lambda function with Idempotent attribute on a sub method.
/// This one is invalid as there are two parameters and IdempotencyKey attribute
/// is not used to specify which one will be used as a key for persistence.
/// </summary>
public class IdempotencyInternalFunctionInvalid
{
public Basket HandleRequest(Product input, ILambdaContext context)
{
return CreateBasket("fake", input);
}

[Idempotent]
private Basket CreateBasket(string magicProduct, Product p)
{
Basket b = new Basket(p);
b.Add(new Product(0, magicProduct, 0));
return b;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

using Amazon.Lambda.Core;
using AWS.Lambda.Powertools.Idempotency.Tests.Model;

namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers;

/// <summary>
/// Simple Lambda function with Idempotent attribute a sub method.
/// This one is invalid because the annotated method return type is void, thus we cannot store any response.
/// </summary>
public class IdempotencyInternalFunctionVoid
{
public Basket HandleRequest(Product input, ILambdaContext context)
{
Basket b = new Basket(input);
AddProduct("fake", b);
return b;
}

[Idempotent]
private void AddProduct([IdempotencyKey] string productName, Basket b)
{
b.Add(new Product(0, productName, 0));
}

}
Loading