diff --git a/.autover/changes/durable-execution-annotations-integration.json b/.autover/changes/durable-execution-annotations-integration.json new file mode 100644 index 000000000..16ea4c352 --- /dev/null +++ b/.autover/changes/durable-execution-annotations-integration.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Add [DurableExecution] attribute and source generator support for durable execution functions. A method annotated with [LambdaFunction] and [DurableExecution] generates a handler wrapper that delegates to Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync, and emits a DurableConfig block plus the lambda:CheckpointDurableExecution / lambda:GetDurableExecutionState IAM permissions in the generated CloudFormation/SAM template. Validates that durable functions are executable, Zip-packaged, and have the (TInput, IDurableContext) -> Task/Task signature (AWSLambda0140-AWSLambda0143). Preview." + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj index 3bd7d98f4..5cec502a1 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj @@ -108,6 +108,10 @@ TextTemplatingFilePreprocessor AuthorizerInvoke.cs + + TextTemplatingFilePreprocessor + DurableExecutionInvoke.cs + @@ -151,6 +155,11 @@ True AuthorizerInvoke.tt + + True + True + DurableExecutionInvoke.tt + diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md index 01f6e1693..1553a0141 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md @@ -24,3 +24,7 @@ AWSLambda0136 | AWSLambdaCSharpGenerator | Error | Invalid S3EventAttribute AWSLambda0137 | AWSLambdaCSharpGenerator | Error | Invalid DynamoDBEventAttribute AWSLambda0138 | AWSLambdaCSharpGenerator | Error | Invalid SNSEventAttribute AWSLambda0139 | AWSLambdaCSharpGenerator | Error | Invalid ScheduleEventAttribute +AWSLambda0140 | AWSLambdaCSharpGenerator | Error | DurableExecution requires an executable project +AWSLambda0141 | AWSLambdaCSharpGenerator | Error | DurableExecution requires Zip packaging +AWSLambda0142 | AWSLambdaCSharpGenerator | Error | Invalid DurableExecution method signature +AWSLambda0143 | AWSLambdaCSharpGenerator | Info | DurableExecution function with explicit Role needs checkpoint permissions diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index 6de39018b..819ed4079 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -302,5 +302,33 @@ public static class DiagnosticDescriptors category: "AWSLambdaCSharpGenerator", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor DurableExecutionRequiresExecutable = new DiagnosticDescriptor(id: "AWSLambda0140", + title: "DurableExecution requires an executable project", + messageFormat: "A method annotated with [DurableExecution] requires the project to output an executable (set 'OutputType' to 'Exe' in the .csproj). Class library handlers are not supported for durable functions in preview.", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor DurableExecutionZipOnly = new DiagnosticDescriptor(id: "AWSLambda0141", + title: "DurableExecution requires Zip packaging", + messageFormat: "A method annotated with [DurableExecution] requires PackageType to be Zip. Image (container) packaging is not supported for durable functions.", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor DurableExecutionInvalidSignature = new DiagnosticDescriptor(id: "AWSLambda0142", + title: "Invalid DurableExecution method signature", + messageFormat: "A method annotated with [DurableExecution] must have the signature (TInput, Amazon.Lambda.DurableExecution.IDurableContext) returning Task or Task: {0}", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor DurableExecutionExplicitRoleNeedsCheckpointPolicy = new DiagnosticDescriptor(id: "AWSLambda0143", + title: "DurableExecution function with explicit Role needs checkpoint permissions", + messageFormat: "The [DurableExecution] function uses an explicit Role, so the generator will not add the durable checkpoint policy. Attach the 'lambda:CheckpointDurableExecution' and 'lambda:GetDurableExecutionState' actions to the role manually.", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Info, + isEnabledByDefault: true); } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs index 4a8e08c0c..82ade7332 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -143,6 +143,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.DurableExecutionAttribute), SymbolEqualityComparer.Default)) + { + var data = DurableExecutionAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.HttpApiAuthorizerAttribute), SymbolEqualityComparer.Default)) { var data = HttpApiAuthorizerAttributeBuilder.Build(att); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DurableExecutionAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DurableExecutionAttributeBuilder.cs new file mode 100644 index 000000000..69b638b74 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DurableExecutionAttributeBuilder.cs @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; +using Microsoft.CodeAnalysis; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . Reads named arguments from the + /// ; assigning each property also sets its corresponding + /// IsXxxSet flag so unset values can be omitted from the generated template. + /// + public class DurableExecutionAttributeBuilder + { + public static DurableExecutionAttribute Build(AttributeData att) + { + var data = new DurableExecutionAttribute(); + + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.RetentionPeriodInDays) && pair.Value.Value is int retentionPeriodInDays) + { + data.RetentionPeriodInDays = retentionPeriodInDays; + } + else if (pair.Key == nameof(data.ExecutionTimeout) && pair.Value.Value is int executionTimeout) + { + data.ExecutionTimeout = executionTimeout; + } + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs index 15eea9db4..f7a7e4039 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs @@ -13,6 +13,7 @@ public enum EventType DynamoDB, Schedule, Authorizer, - ALB + ALB, + DurableExecution } } \ No newline at end of file diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 4912a97a4..2e7f4e6b1 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -46,6 +46,10 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, { events.Add(EventType.Schedule); } + else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.DurableExecutionAttribute) + { + events.Add(EventType.DurableExecution); + } else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAuthorizerAttribute || attribute.AttributeClass.ToDisplayString() == TypeFullNames.RestApiAuthorizerAttribute) { diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs index e3c6a020e..c07d2e23e 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs @@ -63,6 +63,11 @@ private static IList BuildUsings(LambdaMethodModel lambdaMethodModel, namespaces.Add("Amazon.Lambda.Annotations.APIGateway"); } + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.DurableExecutionAttribute)) + { + namespaces.Add("Amazon.Lambda.DurableExecution"); + } + return namespaces; } @@ -71,6 +76,15 @@ private static TypeModel BuildResponseType(IMethodSymbol lambdaMethodSymbol, { var task = context.Compilation.GetTypeByMetadataName(TypeFullNames.Task1); + // Durable functions always produce Task; the generated + // wrapper delegates to DurableFunction.WrapAsync, which returns that envelope. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.DurableExecutionAttribute)) + { + var outputType = context.Compilation.GetTypeByMetadataName(TypeFullNames.DurableExecutionInvocationOutput); + var genericTask = task.Construct(outputType); + return TypeModelBuilder.Build(genericTask, context); + } + if (lambdaMethodModel.ReturnsIHttpResults) { var typeStream = context.Compilation.GetTypeByMetadataName(TypeFullNames.Stream); @@ -217,7 +231,22 @@ private static IList BuildParameters(IMethodSymbol lambdaMethodS Documentation = "The ILambdaContext that provides methods for logging and describing the Lambda environment." }; - if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAuthorizerAttribute)) + // Durable functions receive the service envelope (DurableExecutionInvocationInput); the + // generated wrapper passes it straight to DurableFunction.WrapAsync along with the context. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.DurableExecutionAttribute)) + { + var symbol = context.Compilation.GetTypeByMetadataName(TypeFullNames.DurableExecutionInvocationInput); + var type = TypeModelBuilder.Build(symbol, context); + var requestParameter = new ParameterModel + { + Name = "__request__", + Type = type, + Documentation = "The durable execution service envelope that will be processed by the Lambda function handler." + }; + parameters.Add(requestParameter); + parameters.Add(contextParameter); + } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAuthorizerAttribute)) { // For HTTP API authorizer functions, the generated handler accepts the authorizer request type var authorizerAttribute = lambdaMethodSymbol.GetAttributeData(context, TypeFullNames.HttpApiAuthorizerAttribute); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/DurableExecutionInvoke.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/DurableExecutionInvoke.cs new file mode 100644 index 000000000..1c500cea3 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/DurableExecutionInvoke.cs @@ -0,0 +1,318 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version: 17.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +namespace Amazon.Lambda.Annotations.SourceGenerator.Templates +{ + using System.Linq; + using System.Text; + using System.Collections.Generic; + using Amazon.Lambda.Annotations.SourceGenerator.Extensions; + using System; + + /// + /// Class to produce the template output + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "17.0.0.0")] + public partial class DurableExecutionInvoke : DurableExecutionInvokeBase + { + /// + /// Create the template output + /// + public virtual string TransformText() + { + var instance = _model.LambdaMethod.ContainingType.Name.ToCamelCase(); + var inputType = _model.LambdaMethod.Parameters[0].Type.FullName; + // Explicit generics required: method-group args cannot infer TInput/TOutput (CS0411). + var typeArguments = _model.LambdaMethod.ReturnsGenericTask + ? "<" + inputType + ", " + _model.LambdaMethod.ReturnType.TaskTypeArgument + ">" + : "<" + inputType + ">"; + this.Write(" return await Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync"); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArguments)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(instance)); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write(", __request__, __context__);\r\n"); + return this.GenerationEnvironment.ToString(); + } + } + + #region Base class + /// + /// Base class for this transformation + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "17.0.0.0")] + public class DurableExecutionInvokeBase + { + #region Fields + private global::System.Text.StringBuilder generationEnvironmentField; + private global::System.CodeDom.Compiler.CompilerErrorCollection errorsField; + private global::System.Collections.Generic.List indentLengthsField; + private string currentIndentField = ""; + private bool endsWithNewline; + private global::System.Collections.Generic.IDictionary sessionField; + #endregion + #region Properties + /// + /// The string builder that generation-time code is using to assemble generated output + /// + protected System.Text.StringBuilder GenerationEnvironment + { + get + { + if ((this.generationEnvironmentField == null)) + { + this.generationEnvironmentField = new global::System.Text.StringBuilder(); + } + return this.generationEnvironmentField; + } + set + { + this.generationEnvironmentField = value; + } + } + /// + /// The error collection for the generation process + /// + public System.CodeDom.Compiler.CompilerErrorCollection Errors + { + get + { + if ((this.errorsField == null)) + { + this.errorsField = new global::System.CodeDom.Compiler.CompilerErrorCollection(); + } + return this.errorsField; + } + } + /// + /// A list of the lengths of each indent that was added with PushIndent + /// + private System.Collections.Generic.List indentLengths + { + get + { + if ((this.indentLengthsField == null)) + { + this.indentLengthsField = new global::System.Collections.Generic.List(); + } + return this.indentLengthsField; + } + } + /// + /// Gets the current indent we use when adding lines to the output + /// + public string CurrentIndent + { + get + { + return this.currentIndentField; + } + } + /// + /// Current transformation session + /// + public virtual global::System.Collections.Generic.IDictionary Session + { + get + { + return this.sessionField; + } + set + { + this.sessionField = value; + } + } + #endregion + #region Transform-time helpers + /// + /// Write text directly into the generated output + /// + public void Write(string textToAppend) + { + if (string.IsNullOrEmpty(textToAppend)) + { + return; + } + // If we're starting off, or if the previous text ended with a newline, + // we have to append the current indent first. + if (((this.GenerationEnvironment.Length == 0) + || this.endsWithNewline)) + { + this.GenerationEnvironment.Append(this.currentIndentField); + this.endsWithNewline = false; + } + // Check if the current text ends with a newline + if (textToAppend.EndsWith(global::System.Environment.NewLine, global::System.StringComparison.CurrentCulture)) + { + this.endsWithNewline = true; + } + // This is an optimization. If the current indent is "", then we don't have to do any + // of the more complex stuff further down. + if ((this.currentIndentField.Length == 0)) + { + this.GenerationEnvironment.Append(textToAppend); + return; + } + // Everywhere there is a newline in the text, add an indent after it + textToAppend = textToAppend.Replace(global::System.Environment.NewLine, (global::System.Environment.NewLine + this.currentIndentField)); + // If the text ends with a newline, then we should strip off the indent added at the very end + // because the appropriate indent will be added when the next time Write() is called + if (this.endsWithNewline) + { + this.GenerationEnvironment.Append(textToAppend, 0, (textToAppend.Length - this.currentIndentField.Length)); + } + else + { + this.GenerationEnvironment.Append(textToAppend); + } + } + /// + /// Write text directly into the generated output + /// + public void WriteLine(string textToAppend) + { + this.Write(textToAppend); + this.GenerationEnvironment.AppendLine(); + this.endsWithNewline = true; + } + /// + /// Write formatted text directly into the generated output + /// + public void Write(string format, params object[] args) + { + this.Write(string.Format(global::System.Globalization.CultureInfo.CurrentCulture, format, args)); + } + /// + /// Write formatted text directly into the generated output + /// + public void WriteLine(string format, params object[] args) + { + this.WriteLine(string.Format(global::System.Globalization.CultureInfo.CurrentCulture, format, args)); + } + /// + /// Raise an error + /// + public void Error(string message) + { + System.CodeDom.Compiler.CompilerError error = new global::System.CodeDom.Compiler.CompilerError(); + error.ErrorText = message; + this.Errors.Add(error); + } + /// + /// Raise a warning + /// + public void Warning(string message) + { + System.CodeDom.Compiler.CompilerError error = new global::System.CodeDom.Compiler.CompilerError(); + error.ErrorText = message; + error.IsWarning = true; + this.Errors.Add(error); + } + /// + /// Increase the indent + /// + public void PushIndent(string indent) + { + if ((indent == null)) + { + throw new global::System.ArgumentNullException("indent"); + } + this.currentIndentField = (this.currentIndentField + indent); + this.indentLengths.Add(indent.Length); + } + /// + /// Remove the last indent that was added with PushIndent + /// + public string PopIndent() + { + string returnValue = ""; + if ((this.indentLengths.Count > 0)) + { + int indentLength = this.indentLengths[(this.indentLengths.Count - 1)]; + this.indentLengths.RemoveAt((this.indentLengths.Count - 1)); + if ((indentLength > 0)) + { + returnValue = this.currentIndentField.Substring((this.currentIndentField.Length - indentLength)); + this.currentIndentField = this.currentIndentField.Remove((this.currentIndentField.Length - indentLength)); + } + } + return returnValue; + } + /// + /// Remove any indentation + /// + public void ClearIndent() + { + this.indentLengths.Clear(); + this.currentIndentField = ""; + } + #endregion + #region ToString Helpers + /// + /// Utility class to produce culture-oriented representation of an object as a string. + /// + public class ToStringInstanceHelper + { + private System.IFormatProvider formatProviderField = global::System.Globalization.CultureInfo.InvariantCulture; + /// + /// Gets or sets format provider to be used by ToStringWithCulture method. + /// + public System.IFormatProvider FormatProvider + { + get + { + return this.formatProviderField ; + } + set + { + if ((value != null)) + { + this.formatProviderField = value; + } + } + } + /// + /// This is called from the compile/run appdomain to convert objects within an expression block to a string + /// + public string ToStringWithCulture(object objectToConvert) + { + if ((objectToConvert == null)) + { + throw new global::System.ArgumentNullException("objectToConvert"); + } + System.Type t = objectToConvert.GetType(); + System.Reflection.MethodInfo method = t.GetMethod("ToString", new System.Type[] { + typeof(System.IFormatProvider)}); + if ((method == null)) + { + return objectToConvert.ToString(); + } + else + { + return ((string)(method.Invoke(objectToConvert, new object[] { + this.formatProviderField }))); + } + } + } + private ToStringInstanceHelper toStringHelperField = new ToStringInstanceHelper(); + /// + /// Helper to produce culture-oriented representation of an object as a string + /// + public ToStringInstanceHelper ToStringHelper + { + get + { + return this.toStringHelperField; + } + } + #endregion + } + #endregion +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/DurableExecutionInvoke.tt b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/DurableExecutionInvoke.tt new file mode 100644 index 000000000..8d23675de --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/DurableExecutionInvoke.tt @@ -0,0 +1,22 @@ +<#@ template language="C#" #> +<#@ assembly name="System.Core" #> +<#@ import namespace="System.Linq" #> +<#@ import namespace="System.Text" #> +<#@ import namespace="System.Collections.Generic" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Extensions" #> +<# + // The user's workflow method is (TInput, IDurableContext) -> Task / Task. The generated + // wrapper hands that method group to DurableFunction.WrapAsync, which drives the durable engine and + // returns a DurableExecutionInvocationOutput. The serializer used to (de)serialize the envelope is + // taken from the ILambdaContext by WrapAsync, so the wrapper does not touch a serializer field. + // + // Explicit generic arguments are REQUIRED: C# cannot infer TInput/TOutput from a method-group argument + // bound to a Func<,,> parameter (CS0411), so the wrapper always spells out the type arguments. TInput is + // the user method's first parameter; TOutput is the Task argument (omitted for a void Task). + var instance = _model.LambdaMethod.ContainingType.Name.ToCamelCase(); + var inputType = _model.LambdaMethod.Parameters[0].Type.FullName; + var typeArguments = _model.LambdaMethod.ReturnsGenericTask + ? $"<{inputType}, {_model.LambdaMethod.ReturnType.TaskTypeArgument}>" + : $"<{inputType}>"; +#> + return await Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync<#= typeArguments #>(<#= instance #>.<#= _model.LambdaMethod.Name #>, __request__, __context__); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/DurableExecutionInvokeCode.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/DurableExecutionInvokeCode.cs new file mode 100644 index 000000000..a139afe60 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/DurableExecutionInvokeCode.cs @@ -0,0 +1,14 @@ +using Amazon.Lambda.Annotations.SourceGenerator.Models; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Templates +{ + public partial class DurableExecutionInvoke + { + private readonly LambdaFunctionModel _model; + + public DurableExecutionInvoke(LambdaFunctionModel model) + { + _model = model; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs index 6e4a30347..1f892d0ef 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs @@ -176,7 +176,11 @@ public virtual string TransformText() } - if (_model.LambdaMethod.Events.Contains(EventType.Authorizer)) + if (_model.LambdaMethod.Events.Contains(EventType.DurableExecution)) + { + this.Write(new DurableExecutionInvoke(_model).TransformText()); + } + else if (_model.LambdaMethod.Events.Contains(EventType.Authorizer)) { var authorizerParameters = new AuthorizerSetupParameters(_model); this.Write(authorizerParameters.TransformText()); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt index aa3c3ab18..21cba915b 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt @@ -54,7 +54,11 @@ this.Write(new FieldsAndConstructor(_model).TransformText()); <# } - if (_model.LambdaMethod.Events.Contains(EventType.Authorizer)) + if (_model.LambdaMethod.Events.Contains(EventType.DurableExecution)) + { + this.Write(new DurableExecutionInvoke(_model).TransformText()); + } + else if (_model.LambdaMethod.Events.Contains(EventType.Authorizer)) { var authorizerParameters = new AuthorizerSetupParameters(_model); this.Write(authorizerParameters.TransformText()); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 0e7e76a89..59244ae83 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -71,6 +71,12 @@ public static class TypeFullNames public const string ScheduledEvent = "Amazon.Lambda.CloudWatchEvents.ScheduledEvents.ScheduledEvent"; public const string ScheduleEventAttribute = "Amazon.Lambda.Annotations.Schedule.ScheduleEventAttribute"; + public const string DurableExecutionAttribute = "Amazon.Lambda.Annotations.DurableExecutionAttribute"; + public const string DurableExecutionInvocationInput = "Amazon.Lambda.DurableExecution.DurableExecutionInvocationInput"; + public const string DurableExecutionInvocationOutput = "Amazon.Lambda.DurableExecution.DurableExecutionInvocationOutput"; + public const string DurableFunction = "Amazon.Lambda.DurableExecution.DurableFunction"; + public const string IDurableContext = "Amazon.Lambda.DurableExecution.IDurableContext"; + public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute"; public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer"; @@ -103,7 +109,8 @@ public static class TypeFullNames S3EventAttribute, DynamoDBEventAttribute, SNSEventAttribute, - ScheduleEventAttribute + ScheduleEventAttribute, + DurableExecutionAttribute }; } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 25e9d486d..29e0ef81c 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -72,10 +72,72 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod ValidateScheduleEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateAlbEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateS3Events(lambdaFunctionModel, methodLocation, diagnostics); + ValidateDurableExecution(context, lambdaMethodSymbol, lambdaFunctionModel, methodLocation, diagnostics); return ReportDiagnostics(diagnosticReporter, diagnostics); } + private static void ValidateDurableExecution(GeneratorExecutionContext context, IMethodSymbol lambdaMethodSymbol, LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.DurableExecution)) + { + return; + } + + // Durable functions require the executable programming model in preview. The generated wrapper + // delegates to DurableFunction.WrapAsync, which reads the serializer off the ILambdaContext that + // the executable's bootstrap loop populates; a class library handler has no such bootstrap. + // Gate on OutputKind (is this an executable at all), NOT on whether the generator emits Main - + // the manual-bootstrap model is a valid executable where the user writes their own Main. + if (context.Compilation.Options.OutputKind != OutputKind.ConsoleApplication) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.DurableExecutionRequiresExecutable, methodLocation)); + } + + // Image packaging strips Handler/Runtime from the function resource, which the durable + // executable model depends on. Durable functions must be packaged as Zip. + if (lambdaFunctionModel.PackageType == LambdaPackageType.Image) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.DurableExecutionZipOnly, methodLocation)); + } + + // The generated wrapper hands the user method group to DurableFunction.WrapAsync, whose overloads + // accept Func or Func>. A + // mismatched signature would produce a C# error in the generated code, so reject it up front with + // a diagnostic pointing at the user's method instead. + ValidateDurableExecutionSignature(lambdaMethodSymbol, lambdaFunctionModel, methodLocation, diagnostics); + + // When the user supplies an explicit Role, the generator does not manage the function's Policies, + // so it cannot inject the checkpoint policy. Inform the user to attach the actions themselves. + if (!string.IsNullOrEmpty(lambdaFunctionModel.Role)) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.DurableExecutionExplicitRoleNeedsCheckpointPolicy, methodLocation)); + } + } + + private static void ValidateDurableExecutionSignature(IMethodSymbol lambdaMethodSymbol, LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + void AddSignatureError(string detail) => + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.DurableExecutionInvalidSignature, methodLocation, detail)); + + // Must be exactly (TInput, IDurableContext). + if (lambdaMethodSymbol.Parameters.Length != 2) + { + AddSignatureError($"The method has {lambdaMethodSymbol.Parameters.Length} parameter(s)."); + } + else if (lambdaMethodSymbol.Parameters[1].Type.ToDisplayString() != TypeFullNames.IDurableContext) + { + AddSignatureError($"The second parameter must be '{TypeFullNames.IDurableContext}'."); + } + + // Must return Task or Task. ValueTask, void, or a bare value are not accepted by WrapAsync. + // Reuse the model's return-type classification (computed with SymbolEqualityComparer in the builder). + if (!lambdaFunctionModel.LambdaMethod.ReturnsVoidOrGenericTask) + { + AddSignatureError($"The return type must be Task or Task but was '{lambdaMethodSymbol.ReturnType.ToDisplayString()}'."); + } + } + internal static bool ValidateDependencies(GeneratorExecutionContext context, IMethodSymbol lambdaMethodSymbol, Location methodLocation, DiagnosticReporter diagnosticReporter) { // Check for references to "Amazon.Lambda.APIGatewayEvents" if the Lambda method is annotated with RestApi, HttpApi, or authorizer attributes. diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index bb8e3733c..6dee4f20e 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -216,12 +216,19 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la var currentSyncedEventProperties = new Dictionary>(); var currentAlbResources = new List(); var hasFunctionUrl = false; + var hasDurableExecution = false; foreach (var attributeModel in lambdaFunction.Attributes) { string eventName; switch (attributeModel) { + case AttributeModel durableExecutionAttributeModel: + ProcessDurableExecutionAttribute(lambdaFunction, durableExecutionAttributeModel.Data); + hasDurableExecution = true; + // Durable execution is a function property + IAM concern, not an event source, + // so it is intentionally not added to currentSyncedEvents. + break; case AttributeModel httpApiAttributeModel: eventName = ProcessHttpApiAttribute(lambdaFunction, httpApiAttributeModel.Data, currentSyncedEventProperties, authorizerLookup); currentSyncedEvents.Add(eventName); @@ -274,6 +281,25 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la } } + // Remove DurableConfig and the injected checkpoint policy only if they were previously created by + // Annotations (tracked via metadata). This preserves anything manually added by the user. + if (!hasDurableExecution) + { + var syncedDurableConfigPath = $"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedDurableConfig"; + if (_templateWriter.GetToken(syncedDurableConfigPath, false)) + { + _templateWriter.RemoveToken($"Resources.{lambdaFunction.ResourceName}.Properties.DurableConfig"); + _templateWriter.RemoveToken(syncedDurableConfigPath); + } + + var syncedDurablePolicyPath = $"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedDurablePolicy"; + if (_templateWriter.GetToken(syncedDurablePolicyPath, false)) + { + RemoveDurableCheckpointPolicy(lambdaFunction); + _templateWriter.RemoveToken(syncedDurablePolicyPath); + } + } + SynchronizeEventsAndProperties(currentSyncedEvents, currentSyncedEventProperties, lambdaFunction); SynchronizeAlbResources(currentAlbResources, lambdaFunction); } @@ -385,6 +411,112 @@ private void ProcessFunctionUrlAttribute(ILambdaFunctionSerializable lambdaFunct } } + // IAM actions a durable function needs to call the checkpoint APIs. + private static readonly List DurableCheckpointActions = new List + { + "lambda:CheckpointDurableExecution", + "lambda:GetDurableExecutionState" + }; + + /// + /// Writes the configuration to the serverless template. + /// Like FunctionUrl, durable execution is configured as a property on the function resource + /// (a DurableConfig block) rather than as an event source. It also injects the checkpoint-API + /// IAM permissions the function needs (unless the user supplied an explicit Role). + /// + private void ProcessDurableExecutionAttribute(ILambdaFunctionSerializable lambdaFunction, DurableExecutionAttribute durableExecutionAttribute) + { + var propertiesPath = $"Resources.{lambdaFunction.ResourceName}.Properties"; + var durableConfigPath = $"{propertiesPath}.DurableConfig"; + + // Always clear any previously-synced DurableConfig first, then re-emit only the values currently + // set on the attribute, so stale properties from an earlier generation pass do not linger. + _templateWriter.RemoveToken(durableConfigPath); + + if (durableExecutionAttribute.IsRetentionPeriodInDaysSet) + _templateWriter.SetToken($"{durableConfigPath}.RetentionPeriodInDays", durableExecutionAttribute.RetentionPeriodInDays); + + if (durableExecutionAttribute.IsExecutionTimeoutSet) + _templateWriter.SetToken($"{durableConfigPath}.ExecutionTimeout", durableExecutionAttribute.ExecutionTimeout); + + // DurableConfig may have no properties if the user set neither; create an empty block so the + // function is still marked as durable in the template. + if (!_templateWriter.Exists(durableConfigPath)) + _templateWriter.SetToken(durableConfigPath, new Dictionary(), TokenType.Object); + + _templateWriter.SetToken($"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedDurableConfig", true); + + // When the user supplies an explicit Role, ProcessLambdaFunctionProperties has already removed the + // Policies array (Role and Policies are mutually exclusive), so there is nothing to attach the + // checkpoint policy to. A diagnostic (AWSLambda0143) tells the user to add the actions manually. + if (string.IsNullOrEmpty(lambdaFunction.Role)) + { + AddDurableCheckpointPolicy(lambdaFunction); + _templateWriter.SetToken($"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedDurablePolicy", true); + } + } + + /// + /// Appends an inline IAM policy statement granting the durable checkpoint actions to the function's + /// Policies array. The resulting array mixes the managed-policy string (e.g. "AWSLambdaBasicExecutionRole") + /// with an inline statement object, which SAM transforms into the function's generated role. + /// + private void AddDurableCheckpointPolicy(ILambdaFunctionSerializable lambdaFunction) + { + var policiesPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Policies"; + + var policies = _templateWriter.Exists(policiesPath) + ? _templateWriter.GetToken>(policiesPath) + : new List(); + + if (!policies.Any(p => IsDurableCheckpointStatement(p))) + { + policies.Add(BuildDurableCheckpointStatement()); + } + + _templateWriter.SetToken(policiesPath, policies, TokenType.List); + } + + /// + /// Removes the inline durable checkpoint statement from the function's Policies array, leaving any + /// other policies (e.g. AWSLambdaBasicExecutionRole) intact. + /// + private void RemoveDurableCheckpointPolicy(ILambdaFunctionSerializable lambdaFunction) + { + var policiesPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Policies"; + if (!_templateWriter.Exists(policiesPath)) + return; + + var policies = _templateWriter.GetToken>(policiesPath); + var remaining = policies.Where(p => !IsDurableCheckpointStatement(p)).ToList(); + _templateWriter.SetToken(policiesPath, remaining, TokenType.List); + } + + private static Dictionary BuildDurableCheckpointStatement() + { + return new Dictionary + { + ["Statement"] = new List + { + new Dictionary + { + ["Effect"] = "Allow", + ["Action"] = new List(DurableCheckpointActions), + ["Resource"] = "*" + } + } + }; + } + + // Recognizes a previously-injected checkpoint statement by its Action list, so regeneration is + // idempotent and orphan removal can strip exactly the statement Annotations added. + private static bool IsDurableCheckpointStatement(object policy) + { + var serialized = Newtonsoft.Json.JsonConvert.SerializeObject(policy); + return serialized.Contains("lambda:CheckpointDurableExecution") + && serialized.Contains("lambda:GetDurableExecutionState"); + } + /// /// Processes all authorizers and writes them to the serverless template as inline authorizers within the API resources. /// AWS SAM expects authorizers to be defined within the Auth.Authorizers property of AWS::Serverless::HttpApi or AWS::Serverless::Api resources. diff --git a/Libraries/src/Amazon.Lambda.Annotations/DurableExecutionAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/DurableExecutionAttribute.cs new file mode 100644 index 000000000..af6180778 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/DurableExecutionAttribute.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; + +namespace Amazon.Lambda.Annotations +{ + /// + /// Marks a Lambda function method (also annotated with ) as a + /// durable execution workflow. The Amazon.Lambda.Annotations source generator recognizes this + /// attribute and generates a handler wrapper that delegates to + /// Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync, along with the corresponding + /// DurableConfig and checkpoint-API IAM permissions in the generated CloudFormation/SAM template. + /// + /// + /// The annotated method must have the signature (TInput, IDurableContext) -> Task or + /// (TInput, IDurableContext) -> Task<TOutput>. During preview, durable functions are + /// only supported in the executable programming model (the project must output an executable). + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class DurableExecutionAttribute : Attribute + { + private int _retentionPeriodInDays; + + /// + /// The number of days the durable execution's history is retained after completion. + /// Maps to DurableConfig.RetentionPeriodInDays on the generated function resource. + /// When unset, the property is omitted from the template and the service default applies. + /// + public int RetentionPeriodInDays + { + get => _retentionPeriodInDays; + set + { + _retentionPeriodInDays = value; + IsRetentionPeriodInDaysSet = true; + } + } + + /// + /// Indicates whether was explicitly set. + /// + internal bool IsRetentionPeriodInDaysSet { get; private set; } + + private int _executionTimeout; + + /// + /// The maximum duration, in seconds, that a single durable execution may run. + /// Maps to DurableConfig.ExecutionTimeout on the generated function resource. + /// When unset, the property is omitted from the template and the service default applies. + /// + public int ExecutionTimeout + { + get => _executionTimeout; + set + { + _executionTimeout = value; + IsExecutionTimeoutSet = true; + } + } + + /// + /// Indicates whether was explicitly set. + /// + internal bool IsExecutionTimeoutSet { get; private set; } + + /// + /// Validates the attribute's property values. + /// + /// A list of validation error messages; empty when the attribute is valid. + internal List Validate() + { + var validationErrors = new List(); + + if (IsRetentionPeriodInDaysSet && RetentionPeriodInDays <= 0) + { + validationErrors.Add($"{nameof(RetentionPeriodInDays)} = {RetentionPeriodInDays}. It must be a positive integer."); + } + + if (IsExecutionTimeoutSet && ExecutionTimeout <= 0) + { + validationErrors.Add($"{nameof(ExecutionTimeout)} = {ExecutionTimeout}. It must be a positive integer."); + } + + return validationErrors; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/README.md b/Libraries/src/Amazon.Lambda.DurableExecution/README.md index 264703397..cba74f0a5 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/README.md +++ b/Libraries/src/Amazon.Lambda.DurableExecution/README.md @@ -88,6 +88,31 @@ public record OrderResult(string OrderId, string TrackingNumber); For AOT or trim-friendly serialization, swap `DefaultLambdaJsonSerializer` for `SourceGeneratorLambdaJsonSerializer` and register your `JsonSerializerContext`. +### Using Lambda Annotations + +If you use [Amazon.Lambda.Annotations](../Amazon.Lambda.Annotations/README.md), you can skip the handler/`WrapAsync` boilerplate. Annotate your workflow method with both `[LambdaFunction]` and `[DurableExecution]` — the source generator emits the handler wrapper that calls `DurableFunction.WrapAsync`, and adds the `DurableConfig` block and checkpoint-API IAM permissions to the generated `serverless.template`. + +```csharp +using Amazon.Lambda.Annotations; +using Amazon.Lambda.DurableExecution; + +public class OrderProcessor +{ + [LambdaFunction] + [DurableExecution(RetentionPeriodInDays = 7, ExecutionTimeout = 300)] + public async Task Workflow(Order order, IDurableContext ctx) + { + var reservation = await ctx.StepAsync( + async _ => await InventoryService.ReserveAsync(order.Items), + name: "reserve-inventory"); + // ... remaining steps ... + return new OrderResult(order.Id, /* ... */ "tracking"); + } +} +``` + +The same preview constraint applies: the project must build as an executable (`OutputType=Exe`). The generator validates this (and the `(TInput, IDurableContext) -> Task`/`Task` signature) and reports a diagnostic otherwise. + ## Documentation **Core operations** diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/docs/design/annotations-integration-plan.md b/Libraries/src/Amazon.Lambda.DurableExecution/docs/design/annotations-integration-plan.md new file mode 100644 index 000000000..a45478bdf --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/docs/design/annotations-integration-plan.md @@ -0,0 +1,378 @@ +# Implementation Plan: Integrating `[DurableExecution]` with the Amazon.Lambda.Annotations Source Generator + +> Status: **Ready with must-fixes.** This plan folds in every adversarial-reviewer blocker. Items that depend on undefined infrastructure (the runtime string, the IAM-shape decision) are flagged inline and gated behind explicit pre-merge confirmations rather than buried. + +## Verified ground truth + +All load-bearing claims confirmed against the codebase: + +- IAM action names `lambda:CheckpointDurableExecution` and `lambda:GetDurableExecutionState` verified in the reference template (lines 52-53). Note the reference uses an inline `PolicyName: DurableExecutionPolicy` role-attached policy, not a SAM `Policies` array entry — relevant to the IAM section. +- README line 35 states the executable-only constraint is a preview limitation pending RuntimeSupport changes (resolves the "temporary vs permanent" contradiction). +- README line 37 shows `dotnet10` in its example, but durable functions run on **either `dotnet8` or `dotnet10`** (user-confirmed 2026-06-08) — the generator does not force a runtime. Line 53 confirms the `HandlerWrapper.GetHandlerWrapper` typed contract. +- Package multi-targets net8.0 + net10.0. + +--- + +## 1. Goal & Scope + +### Goal +Let a developer annotate a method with `[DurableExecution]` (alongside `[LambdaFunction]`) and have the Amazon.Lambda.Annotations source generator emit: +1. A **typed-envelope handler wrapper** that delegates to `Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync`. +2. A `serverless.template` resource carrying durable-specific config (`DurableConfig`) and the IAM permissions the function needs to call the checkpoint APIs. + +### In scope +- New public attribute `Amazon.Lambda.Annotations.DurableExecutionAttribute` (in the Annotations package). +- Source-generator recognition (TypeFullNames, EventType, builders). +- Generated wrapper shape (typed in/typed out). +- CloudFormation/SAM `DurableConfig` + inline checkpoint IAM policy emission with orphan removal. +- Diagnostics, snapshot tests, change file, docs. + +### Out of scope +- Changes to `Amazon.Lambda.DurableExecution` runtime behavior (`DurableFunction`, `DurableContext`, the wire format). These ship independently; this work consumes them. +- Scoped (least-privilege) checkpoint ARNs — deferred until the service publishes a scopable ARN format (see Risks). + +### The executable-only constraint (VERIFIED, and its sharp edge) +`Amazon.Lambda.DurableExecution/README.md` line 35 states the preview **only supports the executable programming model** — the function is an executable assembly hosting its own bootstrap loop and passing the serializer to the runtime in code. Class-library/managed-runtime support lands only after RuntimeSupport changes are deployed. So the constraint is **temporary-but-real for preview**: `[DurableExecution]` requires `OutputType=Exe` today. + +**MUST-FIX (reviewer blocker — enforcement is post-hoc, not preventive).** `LambdaFunctionModelBuilder.BuildAndValidate` (verified line 17) receives `isExecutable` as a **caller-supplied parameter** from the generator driver; it is not derived from the attribute. A diagnostic can *report* `[DurableExecution]` on a non-executable project, but the framework does not abort generation on diagnostic severity alone. Therefore the plan must make `IsValid=false` the gate: +- `LambdaFunctionValidator.ValidateFunction` (called at line 26) returns the model's `IsValid`. When `[DurableExecution]` is present and `isExecutable == false`, emit `DurableExecutionRequiresExecutable` (Error) **and** force `IsValid=false` so no wrapper is generated. This is the only mechanism in the existing framework that actually halts emission for a function. + +--- + +## 2. The `[DurableExecution]` Attribute Design + +**Placement (REVISED 2026-06-08): `Amazon.Lambda.Annotations` package, top-level namespace `Amazon.Lambda.Annotations`** — file `Libraries/src/Amazon.Lambda.Annotations/DurableExecutionAttribute.cs`. This matches every other annotation attribute (`LambdaFunctionAttribute`, `ScheduleEventAttribute`, …) and lets the generator use the standard strongly-typed `AttributeModel` pattern (the generator already references `Amazon.Lambda.Annotations` and reaches its internals via `InternalsVisibleTo`, so it can call `Validate()`/`IsXxxSet` directly). The attribute holds only `int` values, so this adds no dependency from `Amazon.Lambda.Annotations` onto the DurableExecution SDK. + +> **Superseded earlier design:** an initial draft placed the attribute in the `Amazon.Lambda.DurableExecution` package. That was wrong — the generator must target `netstandard2.0` and cannot reference that package (net8/net10 + AWSSDK.Lambda), which made the generic `AttributeModel` pattern impossible and forced an awkward string-keyed POCO workaround. Moving the attribute to `Amazon.Lambda.Annotations` removes the problem entirely. The matching style follows `LambdaFunctionAttribute` (block namespace, no nullable), not the file-scoped style of the DurableExecution package. + +Implemented shape (matches `LambdaFunctionAttribute`'s block-namespace, non-nullable style): + +```csharp +using System; +using System.Collections.Generic; + +namespace Amazon.Lambda.Annotations +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class DurableExecutionAttribute : Attribute + { + private int _retentionPeriodInDays; + public int RetentionPeriodInDays + { + get => _retentionPeriodInDays; + set { _retentionPeriodInDays = value; IsRetentionPeriodInDaysSet = true; } + } + internal bool IsRetentionPeriodInDaysSet { get; private set; } + + private int _executionTimeout; // seconds + public int ExecutionTimeout + { + get => _executionTimeout; + set { _executionTimeout = value; IsExecutionTimeoutSet = true; } + } + internal bool IsExecutionTimeoutSet { get; private set; } + + internal List Validate() + { + var validationErrors = new List(); + if (IsRetentionPeriodInDaysSet && RetentionPeriodInDays <= 0) + validationErrors.Add($"{nameof(RetentionPeriodInDays)} = {RetentionPeriodInDays}. It must be a positive integer."); + if (IsExecutionTimeoutSet && ExecutionTimeout <= 0) + validationErrors.Add($"{nameof(ExecutionTimeout)} = {ExecutionTimeout}. It must be a positive integer."); + return validationErrors; + } + } +} +``` + +Design notes: +- **Parameterless** — `[DurableExecution]` with no args is valid (unlike `[SQSEvent]`'s required queue arg). +- **`IsXxxSet` flags are `internal`** (consumed by the generator via `InternalsVisibleTo`), following the `ScheduleEventAttribute` convention so unset values are omitted from CFN. +- **No `WorkflowName`/`Input`/`ResourceName` argument.** Input is carried by the durable envelope (the EXECUTION op — verified in `DurableFunction.ExtractUserPayload`, lines 200-221); the function name derives from `[LambdaFunction]`. A second name source would create a duplicate-key hazard. +- **No signature change** to the user method. The user method stays `(TInput, IDurableContext) -> Task` or `(TInput, IDurableContext) -> Task`, enforced by `DurableExecutionInvalidSignature`. +- Validate rejects `<= 0` now; exact upper bounds are a follow-up once service limits are confirmed. + +--- + +## 3. Source-Generator Recognition (Models, TypeFullNames) + +**MUST-FIX (reviewer): exact namespace match or silent skip.** The string below must match the attribute's real namespace exactly, or `EventTypeBuilder`/`AttributeModelBuilder` silently skip it and the method routes to `NoEventMethodBody`. A dedicated test (Component H) covers discovery. + +1. **`TypeFullNames.cs`** — add four constants (note the attribute is now in the Annotations namespace; the invocation envelopes + `DurableFunction` remain in the SDK namespace because the **user's** compilation references them and the generator only matches them by string): + - `DurableExecutionAttribute = "Amazon.Lambda.Annotations.DurableExecutionAttribute"` + - `DurableExecutionInvocationInput = "Amazon.Lambda.DurableExecution.DurableExecutionInvocationInput"` + - `DurableExecutionInvocationOutput = "Amazon.Lambda.DurableExecution.DurableExecutionInvocationOutput"` + - `DurableFunction = "Amazon.Lambda.DurableExecution.DurableFunction"` + +2. **`Models/EventType.cs`** — add `DurableExecution` enum member. + +3. **`Models/EventTypeBuilder.cs`** — add `else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.DurableExecutionAttribute) events.Add(EventType.DurableExecution);`. + +4. **`Models/Attributes/AttributeModelBuilder.cs`** (IMPLEMENTED) — add an `else if` case (`SymbolEqualityComparer` against `GetTypeByMetadataName(TypeFullNames.DurableExecutionAttribute)`) constructing the standard strongly-typed `AttributeModel` via `DurableExecutionAttributeBuilder.Build`. Because the attribute now lives in `Amazon.Lambda.Annotations` (which the generator references), this is the same generic pattern every other attribute uses — no workaround needed. + +5. **`Models/Attributes/DurableExecutionAttributeBuilder.cs` (NEW, IMPLEMENTED):** returns a real `DurableExecutionAttribute`, reading `att.NamedArguments` by `nameof` (`RetentionPeriodInDays` / `ExecutionTimeout`); assigning each property also flips its `IsXxxSet` flag (so unset values are omitted from the template). Mirrors `ScheduleEventAttributeBuilder` but with no constructor args (the attribute is parameterless). + +6. **`Models/GeneratedMethodModelBuilder.cs`** — early branches gated on `Events.Contains(EventType.DurableExecution)`, placed **BEFORE** the API/HttpApi/ALB branches: + - `BuildParameters` → exactly `[ __request__ : DurableExecutionInvocationInput, __context__ : ILambdaContext ]` + - `BuildResponseType` → `Task` (auto-async) + - `BuildUsings` → conditionally add `Amazon.Lambda.DurableExecution`. + - The wrapper DOES need `TInput`/`TOutput` to emit **explicit** generic arguments (see Section 4 correction) — read from `LambdaMethod.Parameters[0].Type.FullName` and `LambdaMethod.ReturnType.TaskTypeArgument`. No new model fields are required; the existing model already carries these. + +**Branch-ordering is load-bearing** (reviewer): if these run after the API/ALB checks, a method routes to the wrong template. A test must assert a file containing both a durable and an API method produces the durable wrapper for the durable method. + +--- + +## 4. Generated Handler Wrapper + +The wrapper is a **typed-envelope** method (matches README line 53's `HandlerWrapper.GetHandlerWrapper` contract), **NOT** Stream→Stream. + +**Why typed, not Stream→Stream (VERIFIED dual-serializer hazard):** `DurableFunction.WrapAsyncCore` (verified line 79) reads the serializer off the **context** via `LambdaSerializerHelper.GetRequired(lambdaContext)`, not off any wrapper field. A Stream→Stream wrapper that deserialized with its own `serializer` field (a different instance than the one the bootstrap attaches to the context) would be a real bug. So the wrapper does typed in/typed out and lets the runtime `HandlerWrapper` do envelope (de)serialization. + +**Generated signature:** +```csharp +public async Task ( + Amazon.Lambda.DurableExecution.DurableExecutionInvocationInput __request__, + ILambdaContext __context__) +``` + +**Generated body (single delegation, bound method-group):** +```csharp +return await Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync( + ., __request__, __context__); +``` +- `` = the `containingType` field (non-DI) or `scope.ServiceProvider.GetRequiredService()` (DI). Both resolution paths already exist in `FieldsAndConstructor`. +- **Which overload (VERIFIED, four exist — DurableFunction.cs lines 36-71):** the wrapper uses the **three-argument** (no explicit client) overloads — `WrapAsync(Func>, …)` for a typed-returning method or `WrapAsync(Func, …)` for a void method. The lazy `_cachedLambdaClient` (line 30) backs the no-client path — correct for the generated case. +- **CORRECTION (2026-06-08, found by Component H): the wrapper MUST emit EXPLICIT generic type arguments.** The original plan said to emit none and rely on overload resolution — that is **wrong** and produces `CS0411` ("type arguments cannot be inferred"): C# cannot infer `TInput`/`TOutput` from a **method-group** argument bound to a `Func<,,>` parameter. Every real call site confirms this — README line 61 (`WrapAsync(Workflow, …)`) and all `DurableFunctionTests` use explicit generics. The generated wrapper therefore emits `WrapAsync(instance.Method, …)` for typed workflows and `WrapAsync(instance.Method, …)` for void (`Task`) workflows, where `TInput` = the user method's first parameter type and `TOutput` = the `Task` argument. Verified by a compile test that the explicit-generic call binds and the inference-free form fails with `CS0411`. +- The wrapper does **not** deserialize a Stream, does **not** touch its own `serializer` field, and does **not** reconstruct `[FromX]` params. + +**MUST-FIX (reviewer): signature constraint must be validated.** Method-group overload resolution assumes `Task` or `Task`. A `ValueTask`-returning or wrong-shape user method produces a C# compile error in generated code. `LambdaFunctionValidator.ValidateFunction` must add a durable-specific check: the user method must be exactly `(TInput, IDurableContext) -> Task` or `-> Task`; otherwise emit `DurableExecutionInvalidSignature` (Error) and set `IsValid=false`. + +**MUST-FIX (reviewer): runtime serializer contract.** `WrapAsyncCore` calls `LambdaSerializerHelper.GetRequired(__context__)` and throws if no serializer is on the context. The generated wrapper assumes the bootstrap populated `ILambdaContext.Serializer`. This is a runtime contract not exercisable in generator snapshot tests; the `DurableExecutionInvoke.tt` template must carry a code comment stating the serializer is expected from the context, and Component A must include a serializer round-trip unit test (Section 8). + +**Build note (IMPORTANT, discovered during Component C):** there is **no command-line T4 step**. The `TextTemplatingFilePreprocessor` entries are VS-design-time only; `dotnet build` compiles the **committed** `.cs` partials, not the `.tt`. So every template requires THREE checked-in files kept in sync: `X.tt` (source of truth), `X.cs` (the T4-style transform output — `TransformText()` + the generated boilerplate base class), and `XCode.cs` (the constructor partial holding `_model`). The durable body is a single delegation line, authored across all three for `DurableExecutionInvoke`. + +**Template wiring (IMPLEMENTED):** +- `LambdaFunctionTemplate.tt` **and** `LambdaFunctionTemplate.cs` — durable branch placed **FIRST** in the dispatch chain (`if (Events.Contains(EventType.DurableExecution)) Write(new DurableExecutionInvoke(_model)...)`), before Authorizer/API/ALB/else. Both files edited (the `.cs` is what compiles). +- `DurableExecutionInvoke.tt` + `.cs` + `Code.cs` (NEW) — emits `return await Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync(., __request__, __context__);` with **explicit** generic arguments (see Section 4 correction — `WrapAsync` for typed, `WrapAsync` for void). `` is the camel-cased containing-type field (non-DI) or the DI-resolved local that `LambdaFunctionTemplate`'s shared prologue already sets up. csproj registered the new `.tt`/`.cs` pair like its siblings. +- `GeneratedMethodModelBuilder` (IMPLEMENTED) — durable branches in `BuildResponseType` (→ `Task`), `BuildParameters` (→ `DurableExecutionInvocationInput __request__, ILambdaContext __context__`), and `BuildUsings` (adds `Amazon.Lambda.DurableExecution`). The durable check is placed before the API/Authorizer/ALB checks in each. + +**Original "separate template" wiring notes (superseded by the above):** +- `Templates/LambdaFunctionTemplate.tt` — add `else if (_model.LambdaMethod.Events.Contains(EventType.DurableExecution)) { Write(new DurableExecutionInvoke(_model).TransformText()); }` placed **FIRST**, before the Authorizer/API/ALB branches. The signature line already renders the forced params/return from `GeneratedMethod`, with `async` emitted because the return is a generic `Task`. +- `Templates/DurableExecutionInvoke.tt` (NEW, + checked-in `.cs` partial if the existing template convention requires one) — emits the single `WrapAsync` delegation, handling DI (`scope.ServiceProvider`) and non-DI (`containingType` field) resolution. **MUST-FIX: this template must be authored before snapshots can be produced.** +- `ExecutableAssembly.tt` — **no change.** Verified: it already emits `Func<{p.Type.FullName}, {ReturnType.FullName}>` generically and calls `LambdaBootstrapBuilder.Create(handler, new SerializerName())`. A regression test asserts no change is needed for durable return types. + +**DI lifetime (reviewer gap):** the DI scope is **per-invocation**, matching existing API-Gateway scope semantics — the scope is created and disposed around a single Lambda invocation, NOT held open across a multi-hour suspended workflow (the service re-invokes; each invocation gets a fresh scope). Document this in the template comment. + +--- + +## 5. CloudFormation / SAM Template Changes + +`DurableConfig` is a function **Properties** block (not a SAM `Events` entry), tracked via a `Metadata` marker, modeled exactly on the verified `SyncedFunctionUrlConfig` pattern (`CloudFormationWriter.cs` lines 245-249 write the marker; lines 267+ do orphan removal). + +In `ProcessLambdaFunctionEventAttributes` (verified switch at lines 220-262), add: +```csharp +case AttributeModel durableModel: + ProcessDurableExecutionAttribute(lambdaFunction, durableModel.Data); // Data is DurableExecutionAttribute + hasDurableExecution = true; // initialized = false near line 218 + break; // do NOT add to currentSyncedEvents — durable is not an event +``` + +`ProcessDurableExecutionAttribute` writes (only when the corresponding `IsXxxSet` flag is true): +- `Resources..Properties.DurableConfig.RetentionPeriodInDays` +- `Resources..Properties.DurableConfig.ExecutionTimeout` +- marker `Resources..Metadata.SyncedDurableConfig = true` + +**Expected JSON shape** (snapshot expectation, resolving the reviewer's ambiguity): +```json +"Properties": { + "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 } +} +``` +YAML equivalent under `Properties: DurableConfig:`. + +**Orphan removal** (mirroring the `FunctionUrl` block at lines 267+): when `!hasDurableExecution`, if `Metadata.SyncedDurableConfig` is true, `RemoveToken Properties.DurableConfig`, remove the injected checkpoint policy (Section 6), and remove the markers. + +**Runtime:** NOT set here. Forced at model-build time (Section 7), because `ProcessPackageTypeProperty` line 185 (`SetToken …Runtime = lambdaFunction.Runtime`) would clobber any writer-side injection in the Zip branch. + +**PackageType:** durable functions are Zip/executable only. The `Image` branch (verified lines 190-196) strips `Handler`/`Runtime`, so `PackageType.Image` is structurally unsupported → `DurableExecutionZipOnly` (Error, `IsValid=false`) at model-build. **MUST-FIX: this diagnostic must be Error and gate `IsValid`, not a warning** — otherwise the Image branch silently produces a broken template. + +**Tool guard:** the existing `Metadata.Tool = Amazon.Lambda.Annotations` guard is preserved (DurableConfig only written/refreshed for generator-owned functions). + +--- + +## 6. IAM Policy Statements for Checkpoint APIs + +**Action names (VERIFIED against the reference template, 2026-06-08):** attested snapshot from `C:\dev\repos\aws-durable-execution-sdk-python\packages\aws-durable-execution-sdk-python-examples\template.yaml` (the file is JSON despite the `.yaml` extension), `DurableFunctionRole.Properties.Policies[0]`, lines 43-60: +```json +"Policies": [ + { + "PolicyName": "DurableExecutionPolicy", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "lambda:CheckpointDurableExecution", + "lambda:GetDurableExecutionState" + ], + "Resource": "*" + } + ] + } + } +] +``` +So the two checkpoint actions are confirmed: `lambda:CheckpointDurableExecution`, `lambda:GetDurableExecutionState`. + +**FLAGGED — the reference IAM pattern diverges MORE than first assumed (corrected 2026-06-08 after reading the full reference template):** +- The reference does **not** put any IAM on the function resources at all. It defines a **single shared standalone `AWS::IAM::Role`** (`DurableFunctionRole`, lines 25-62) carrying `ManagedPolicyArns: [AWSLambdaBasicExecutionRole]` **plus** the inline `PolicyName: DurableExecutionPolicy` above, and **every `AWS::Serverless::Function` sets `Role: {Fn::GetAtt: [DurableFunctionRole, Arn]}`** (e.g. lines 69-74) — no function uses a SAM `Policies` array. +- **Consequence for this plan's design:** under the plan's own rule "when `lambdaFunction.Role` IS set, do NOT touch IAM," the reference pattern would never trigger the plan's injection — because in the reference, every function *does* set `Role`. The generator's auto-IAM path (no explicit `Role` → emit a SAM `Policies`-array inline statement) is therefore **a distinct, generator-idiomatic adaptation, not a reproduction of the reference**. The SAM transform expands a per-function `Policies` array into a generated per-function role, so it is functionally equivalent (each function gets the two actions), but the resulting template shape (N generated roles vs. one shared role) differs from the reference. +- **DECISION MADE (2026-06-08): Option 1 — per-function SAM `Policies` array.** Rationale (user): follow the same mechanism the generator already uses for IAM (it appends to the per-function `Policies` list it already manages for `AWSLambdaBasicExecutionRole`), rather than introducing standalone-role emission the writer does not do today. The two options considered were: + 1. **Per-function SAM `Policies` array** (CHOSEN): idiomatic to how the generator already emits `AWSLambdaBasicExecutionRole`; produces one role per function via the SAM transform. Mixed string/object array — see the round-trip risk below. + 2. ~~Shared standalone role (matches reference exactly): generator emits one `DurableFunctionRole` resource and points every durable function's `Role` at it. Larger change to the writer (it does not emit standalone roles today) and interacts with user-specified `Role`.~~ Not chosen. +- `Resource: "*"` is used because the DurableExecutionArn is allocated at runtime and is not knowable at template-synth time (matches the reference, line 55). Whether a scopable ARN will ever exist is **undefined** — flagged as a follow-up, not promised. + +When `[DurableExecution]` is present AND `lambdaFunction.Role` is NOT set, after `ProcessLambdaFunctionProperties` has run (so the `Policies` array exists from the line 161-166 split), read-modify-write `Properties.Policies` via `GetToken`/`SetToken(TokenType.List)`, appending one inline statement object: +```json +{ + "Statement": [ + { + "Effect": "Allow", + "Action": ["lambda:CheckpointDurableExecution", "lambda:GetDurableExecutionState"], + "Resource": "*" + } + ] +} +``` +Producing a mixed string/object array, e.g. `["AWSLambdaBasicExecutionRole", { "Statement": [ … ] }]`. Track via `Metadata.SyncedDurablePolicy = true` for idempotent regeneration; remove the injected statement + marker on orphan removal. + +**When `lambdaFunction.Role` IS set** (Role/Policies mutually exclusive — verified lines 155-166): do NOT touch IAM. Emit `DurableExecutionExplicitRoleNeedsCheckpointPolicy` (Info) instructing the user to attach the two actions manually. The diagnostic fires whenever both `[DurableExecution]` and `Role` are present at generation time. + +**MUST-FIX (highest regression risk):** the mixed string/object `Policies` array must round-trip through both `JsonWriter` and `YamlWriter`. A dedicated JSON+YAML round-trip snapshot test is mandatory (Section 8, Test G). If `SetToken(TokenType.List)` cannot preserve heterogeneous types, this approach is not viable and must be revisited before merge. + +--- + +## 7. Component-by-Component Implementation Steps (real file paths) + +All paths are absolute. `.tt` template changes require regenerating the corresponding `.cs` via the project's T4 step. + +### Component A — `DurableExecutionAttribute` (public API) +- **NEW** `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.DurableExecution\DurableExecutionAttribute.cs` — the attribute from Section 2. +- Add a serializer round-trip unit test (Section 8). + +### Component B — Attribute discovery + model wiring +- `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.Annotations.SourceGenerator\TypeFullNames.cs` — four constants. +- `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.Annotations.SourceGenerator\Models\EventType.cs` — `DurableExecution` member. +- `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.Annotations.SourceGenerator\Models\EventTypeBuilder.cs` — mapping `else if`. +- `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.Annotations.SourceGenerator\Models\Attributes\AttributeModelBuilder.cs` — `SymbolEqualityComparer` case + `using Amazon.Lambda.DurableExecution`. +- **NEW** `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.Annotations.SourceGenerator\Models\Attributes\DurableExecutionAttributeBuilder.cs` — copied from `ScheduleEventAttributeBuilder.cs`. + +### Component C — Generated wrapper shape +- `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.Annotations.SourceGenerator\Models\GeneratedMethodModelBuilder.cs` — early `BuildParameters`/`BuildResponseType`/`BuildUsings` branches, ordered before API/ALB. +- **NEW** `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.Annotations.SourceGenerator\Templates\DurableExecutionInvoke.tt` (+ generated `.cs`). +- `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.Annotations.SourceGenerator\Templates\LambdaFunctionTemplate.tt` — durable branch placed FIRST. +- Verify `ExecutableAssembly.tt` needs no change (regression test). + +### Component D — Package/model validation +- `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.Annotations.SourceGenerator\Models\LambdaFunctionModelBuilder.cs`. + +**Runtime: NO forcing (DECISION 2026-06-08).** Durable functions run on **either `dotnet8` or `dotnet10`**, so the generator does **not** force or override the runtime — the caller-supplied/default `runtime` flows through unchanged exactly like every other function. No `DurableRuntime` constant, no `model.Runtime` override. (This removes the former "MUST-FIX runtime contradiction" and BLOCKING risk #1 entirely.) + +- Run the durable validation pass (executable-only, Zip-only, exclusive-event, signature) and force `IsValid=false` on any Error-severity finding. This is the substance of Component D now that runtime forcing is gone. + +**IMPLEMENTED (2026-06-08, Components D+E):** added a `ValidateDurableExecution` method to `LambdaFunctionValidator` (called alongside the other `ValidateXxxEvents`), which adds Error diagnostics to the list — `ReportDiagnostics` already returns `IsValid=false` whenever any Error is present, so no separate gating wiring is needed. Checks: `OutputKind != ConsoleApplication` → 0140; `PackageType == Image` → 0141; signature (param count, second param `== IDurableContext`, return classified via the model's existing `ReturnsVoidOrGenericTask`) → 0142; explicit `Role` set → 0143 (Info). Added `TypeFullNames.IDurableContext`. Two build-system findings: (1) **RS1032** — a `messageFormat` ending in a `{0}` placeholder must use `: {0}` not `. {0}` (trailing-period rule); (2) the SourceGenerators.Tests project **cannot reference the DurableExecution package** (its AWSSDK.Core 4.x downgrades the test project's pinned 3.7.x → NU1605), so diagnostic tests supply minimal durable **stub types as source** (`IDurableContext` / the two envelopes) — the generator only needs them resolvable by metadata name. Diagnostic tests use the `VerifyCS.Test` harness with exact `WithSpan`/`WithArguments` (the framework demands precise locations and prints the expected `DiagnosticResult` on mismatch). + +### Component E — Diagnostics set +- `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.Annotations.SourceGenerator\Diagnostics\DiagnosticDescriptors.cs`. + +**RESOLVED (2026-06-08): concrete IDs allocated.** Verified against `DiagnosticDescriptors.cs`: the highest allocated id is `AWSLambda0139` (`InvalidScheduleEventAttribute`). (Note: `AWSLambda0126` is skipped in the existing file — 0125 jumps to 0127 — but the durable IDs continue cleanly from the top.) All descriptors use `category: "AWSLambdaCSharpGenerator"` and `isEnabledByDefault: true`, matching the file's convention. + +**REVISED (2026-06-08): only THREE new descriptors — `DurableExecutionExclusiveEvent` dropped (redundant).** Code verification: `LambdaFunctionValidator.ValidateFunction` (line 58) already emits `MultipleEventsNotSupported` (AWSLambda0102) and returns early with `IsValid=false` whenever `Events.Count > 1`. Component B added `DurableExecutionAttribute` to `TypeFullNames.Events` and `EventType.DurableExecution`, so `[DurableExecution] + [RestApi]` already produces `Events.Count == 2` → fires AWSLambda0102 → halts generation. No new exclusive-event diagnostic is needed; just add a **test** asserting the combination triggers AWSLambda0102 (locks in the dispatch-order behavior). The durable descriptors take **`AWSLambda0140`–`AWSLambda0143`**: + +| Name | Id | Severity | Gates generation? | Message (summary) | +|---|---|---|---|---| +| `DurableExecutionRequiresExecutable` | `AWSLambda0140` | Error | Yes (`IsValid=false`) | `[DurableExecution]` requires an executable (OutputType=Exe) project; class-library handlers are not supported in preview. | +| `DurableExecutionZipOnly` | `AWSLambda0141` | Error | Yes | `[DurableExecution]` requires PackageType=Zip; Image packaging is not supported. | +| `DurableExecutionInvalidSignature` | `AWSLambda0142` | Error | Yes | A `[DurableExecution]` method must be `(TInput, IDurableContext) -> Task` or `-> Task`. | +| `DurableExecutionExplicitRoleNeedsCheckpointPolicy` | `AWSLambda0143` | Info | No | Function uses an explicit Role; attach `lambda:CheckpointDurableExecution` and `lambda:GetDurableExecutionState` manually. | + +**Exclusive-event enforcement (RESOLVED):** handled by the existing `MultipleEventsNotSupported` (AWSLambda0102) — see above. No new diagnostic. + +**Executable detection (RESOLVED 2026-06-08 — gate kept, but key off `OutputKind`):** the generator's `isExecutable` flag (Generator.cs:129) is derived from the `GenerateMain` named arg on `[assembly: LambdaGlobalProperties]` — i.e. "generator should synthesize `Main`." That is the WRONG signal for the durable gate, because the README's quick-start uses the **manual** bootstrap model (`GenerateMain` is false, user writes their own `Main` + `LambdaBootstrap`) yet is still a valid executable. `DurableExecutionRequiresExecutable` must therefore gate on **`context.Compilation.Options.OutputKind != OutputKind.ConsoleApplication`** ("is this an executable project at all"), NOT on `isExecutable`. This correctly allows both the manual-bootstrap model (today) and a future generated-`Main` model, and only rejects true class-library projects. + +### Component F — CFN `DurableConfig` writer (IMPLEMENTED 2026-06-08) +- `CloudFormationWriter.cs` — added a `case AttributeModel` to the event-attribute switch that calls `ProcessDurableExecutionAttribute` and sets `hasDurableExecution = true` (and does NOT add to `currentSyncedEvents` — durable is a Properties/IAM concern, not an event). `ProcessDurableExecutionAttribute` clears any prior `DurableConfig`, re-emits `RetentionPeriodInDays`/`ExecutionTimeout` only when their `IsXxxSet` flags are true (creating an empty `DurableConfig` object via `TokenType.Object` when neither is set so the function is still marked durable), and sets the `Metadata.SyncedDurableConfig` marker. Orphan removal mirrors the verified `FunctionUrl` block. + +### Component G — CFN checkpoint IAM writer (IMPLEMENTED 2026-06-08) +- `CloudFormationWriter.cs` — kept inline (no separate writer class), matching `ProcessFunctionUrlAttribute` style. When `Role` is empty, `AddDurableCheckpointPolicy` reads the existing `Policies` via `GetToken>`, appends one inline statement object (`{Statement:[{Effect,Action:[2 actions],Resource:"*"}]}` built as nested `Dictionary`/`List`), and re-sets with `TokenType.List` — producing the mixed string/object array (`["AWSLambdaBasicExecutionRole", {Statement…}]`). Idempotency + orphan removal use `IsDurableCheckpointStatement` (recognizes the statement by its action names via JSON serialization). When `Role` is set, IAM is left untouched and `AWSLambda0143` (Info) is emitted in the validator. +- **HIGHEST-RISK ITEM RESOLVED:** the mixed string/object `Policies` array round-trips cleanly through **both** `JsonWriter` (JSON.NET `JToken`) and `YamlWriter` (`TokenType.List` → `YamlSequenceNode`). Verified by `DurableExecution_InjectsCheckpointPolicy_AsMixedArray` (JSON + YAML) plus idempotency and orphan-removal tests. `SetToken(TokenType.List)` handles heterogeneous types fine — the approach is viable. + +### Component H — End-to-end / compile tests (IMPLEMENTED 2026-06-08) +- `DurableExecutionWrapperCompilesTests.cs` — compiles the exact generated wrapper shape against realistic `WrapAsync` overloads. **This layer found a real bug:** the planned no-explicit-generics call fails with `CS0411` (see Section 4 correction). Tests assert the typed (`WrapAsync`) and void (`WrapAsync`) forms bind, and a guard test asserts the inference-free form fails with `CS0411`. +- Note on approach: a full `Microsoft.CodeAnalysis.Testing` snapshot E2E (committed `.g.cs` + `Program.g.cs` + RuntimeSupport sources) was attempted but is high-friction here (exact `AWSLambda0103` content match + the AWSSDK.Core 3.7.x/4.x conflict that blocks referencing the durable package). The compile-test approach covers the unique remaining risk (overload binding) without that friction; the wrapper *text* is pinned by Component C's template tests and the *template* output by F/G's writer tests. + +### Component I — Change file + docs (IMPLEMENTED 2026-06-08) +- `.autover/changes/durable-execution-annotations-integration.json` — single `Amazon.Lambda.Annotations` Minor entry (that autover project spans both the attributes csproj and the SourceGenerator csproj, so it covers everything added here). +- `Amazon.Lambda.DurableExecution/README.md` — added a "Using Lambda Annotations" subsection showing the `[LambdaFunction]` + `[DurableExecution]` model that removes the manual handler/`WrapAsync` boilerplate. +- **NEW** `C:\dev\repos\aws-lambda-dotnet\.autover\changes\.json` — increment **Minor**, projects `Amazon.Lambda.Annotations.SourceGenerator` + `Amazon.Lambda.DurableExecution`. Create via `autover change`. +- Update `C:\dev\repos\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.DurableExecution\README.md` to note that `[DurableExecution]` generates the bootstrap wiring for the executable model. + +--- + +## 8. Test Strategy (snapshot tests) + +Snapshot harness: `CSharpGeneratorDriver` against files in `Libraries\test\Amazon.Lambda.Annotations.SourceGenerators.Tests\Snapshots\`. CFN writer tests mirror `WriterTests\FunctionUrlTests.cs`, parameterized `[InlineData(CloudFormationTemplateFormat.Json)]` / `[InlineData(CloudFormationTemplateFormat.Yaml)]`. + +**Unit (Component A)** — `Libraries\test\Amazon.Lambda.DurableExecution.Tests\` (or the existing durable test project): constructor defaults, `IsXxxSet` tracking, `Validate()` rejects `<= 0`. **Serializer round-trip:** the default `ILambdaSerializer` deserializes `DurableExecutionInvocationInput` and serializes `DurableExecutionInvocationOutput` including `UpperSnakeCaseEnumConverter` on `InvocationStatus` (Succeeded/Failed/Pending), and a nested `InitialExecutionState`/`Operations` round-trips without loss. This must pass before the typed-envelope wrapper is relied upon. + +**Generated-wrapper snapshots (Component C):** +- A. Non-DI typed-output method → verify signature (`DurableExecutionInvocationInput`/`ILambdaContext` params, `Task` return) and single `WrapAsync(containingType.Method, __request__, __context__)` delegation, no Stream deserialization. +- B. DI variant → `scope.ServiceProvider.GetRequiredService()` resolution. +- C. Void user method (`Task` return) → confirms overload resolution compiles without explicit generic args. +- D. **Branch-ordering test:** one file with both a durable method and a `[RestApi]` method → durable method gets the durable wrapper. +- E. `ExecutableAssembly.tt` regression → executable assembly snapshot unchanged in shape for durable return types. + +**Diagnostics (Component E):** one test each for `DurableExecutionRequiresExecutable` (non-exe / class library), `DurableExecutionZipOnly` (Image), `DurableExecutionInvalidSignature` (ValueTask / wrong params), and `DurableExecutionExplicitRoleNeedsCheckpointPolicy` (explicit Role). Plus a test that `[DurableExecution]` + `[RestApi]` triggers the **existing** `MultipleEventsNotSupported` (AWSLambda0102). For the three durable Errors (and AWSLambda0102), also assert no wrapper is generated (`IsValid=false`). + +**CFN (Components F/G)** — `Libraries\test\Amazon.Lambda.Annotations.SourceGenerators.Tests\WriterTests\DurableExecutionTests.cs` (NEW): +- F1. `DurableConfig` with both props set (JSON + YAML); `Metadata.SyncedDurableConfig == true`. +- F2. Partial emit — only `RetentionPeriodInDays` set → `ExecutionTimeout` absent. +- F3. Orphan removal — attribute dropped → `DurableConfig` + marker removed. +- G. **Highest-risk:** mixed string/object `Policies` array round-trip (JSON + YAML), asserting `["AWSLambdaBasicExecutionRole", { "Statement": [ … checkpoint … ] }]` order preserved after write and re-parse. +- G2. Idempotency — regeneration does not duplicate the policy statement. +- G3. Role suppression — `Role` set → `Policies` untouched, Info diagnostic emitted. + +Snapshot fixtures with the exact JSON/YAML shapes (Sections 5 and 6) must be authored as part of this work, not deferred. + +--- + +## 9. Risks, Open Questions, and Must-Fix-First Items + +### BLOCKING (resolve before implementation starts) +1. ~~**Runtime string is undefined infra.**~~ **DROPPED (2026-06-08): not an issue.** Durable functions run on either `dotnet8` or `dotnet10`, so the generator does **not** force a runtime — it lets the user's normal runtime selection flow through. No `DurableRuntime` constant, no override. (Component D no longer touches runtime at all.) +2. ~~**IAM emission shape — role shape still a DECISION.**~~ **RESOLVED (2026-06-08): Option 1 — per-function SAM `Policies`-array inline statement**, matching how the generator already emits `AWSLambdaBasicExecutionRole`. Action names verified against the reference snapshot (`lambda:CheckpointDurableExecution`, `lambda:GetDurableExecutionState`; lines 51-54). The remaining risk here is purely mechanical — the mixed string/object `Policies` array round-trip (see item 7), not a shape decision. +3. ~~**Diagnostic IDs.**~~ **RESOLVED (2026-06-08): `AWSLambda0140`–`AWSLambda0143`** (highest existing is `AWSLambda0139`; `0126` is skipped in the file but the durable IDs continue cleanly from the top). Only three new descriptors — the exclusive-event case reuses the existing `AWSLambda0102`. See the Section 7 / Component E table. + +### REQUIRED-BEFORE-CODING (artifacts that gate the rest) +4. Author `DurableExecutionInvoke.tt` first — snapshots cannot exist without it. +5. Create `DurableExecutionAttributeBuilder.cs` by copying the real `ScheduleEventAttributeBuilder.cs`, not from prose. +6. Author the exact JSON/YAML snapshot fixtures for `DurableConfig` and the mixed `Policies` array. + +### Highest regression risk +7. **Mixed string/object `Policies` array** round-trip via `SetToken(TokenType.List)` through both `JsonWriter` and `YamlWriter`. Dedicated round-trip test mandatory; if `SetToken` cannot preserve heterogeneous types, the inline-policy approach is not viable and must be reconsidered. + +### Correctness gates (enforced via `IsValid=false`, not severity alone) +8. **Validation gates must set `IsValid=false`** (diagnostic severity alone does not halt generation). Applies to `DurableExecutionRequiresExecutable` (gate on `OutputKind != ConsoleApplication`), `DurableExecutionZipOnly`, and `DurableExecutionInvalidSignature`. The exclusive-event case is already handled by the existing `MultipleEventsNotSupported` (AWSLambda0102), which returns early with `IsValid=false`. +9. **Branch ordering** is load-bearing in two files (`GeneratedMethodModelBuilder` and `LambdaFunctionTemplate.tt`) — durable must be checked before API/HttpApi/ALB. Covered by Test D. +10. **Signature constraint** — `ValueTask`/non-`(TInput, IDurableContext)` returns produce generated-code compile errors. `ValidateFunction` must reject them. +11. **Runtime serializer contract** — `WrapAsyncCore` reads the serializer off `__context__` (verified line 79); the generated wrapper assumes the bootstrap populated it. Not testable in snapshots; covered by Component A's round-trip unit test + a template comment. + +### Accepted-for-preview (documented follow-ups, not promises) +12. `Resource: "*"` on the checkpoint statement is broad. Acceptable for preview per the reference; tightening depends on the service defining a scopable durable-execution ARN — **existence of such an ARN is undefined**, so this is flagged, not committed. +13. **Executable-only is a sharp edge** until managed-runtime support lands in RuntimeSupport (README line 35). Temporary-for-preview, not architectural. +14. **TypeFullNames must exactly match** `Amazon.Lambda.DurableExecution.DurableExecutionAttribute` or the attribute is silently skipped → routed to `NoEventMethodBody`. Covered by the discovery test. + +### Open questions deferred (non-blocking) +15. Upper bounds for `RetentionPeriodInDays`/`ExecutionTimeout` — `Validate()` only rejects `<= 0` now; tighten once service limits are published. +16. Whether, when a user adds an explicit `Role` to a function that previously had an auto-injected checkpoint policy, the old policy should be actively removed. The Role/Policies mutual-exclusivity (lines 155-166) clears `Policies` automatically in `ProcessLambdaFunctionProperties`, so the stale statement is removed as a side effect; verify this in the Role-suppression test and document it. diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionDiagnosticsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionDiagnosticsTests.cs new file mode 100644 index 000000000..49b0d751a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionDiagnosticsTests.cs @@ -0,0 +1,169 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.IO; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using VerifyCS = Amazon.Lambda.Annotations.SourceGenerators.Tests.CSharpSourceGeneratorVerifier; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class DurableExecutionDiagnosticsTests + { + // Minimal serializer registration so the generator does not also emit AWSLambda0108. + private const string AssemblyAttributes = + "[assembly: Amazon.Lambda.Core.LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]\n"; + + // Minimal durable SDK stubs. The real Amazon.Lambda.DurableExecution package cannot be referenced + // by this test project (its AWSSDK.Core 4.x conflicts with the 3.7.x pin required by the generator + // test framework), so the types the generator resolves by metadata name are supplied as source. + private const string DurableStubs = @" +namespace Amazon.Lambda.DurableExecution +{ + public interface IDurableContext { } + public sealed class DurableExecutionInvocationInput { } + public sealed class DurableExecutionInvocationOutput { } +} +"; + + private static async Task AnnotationsSource(string fileName) => + await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", fileName)); + + private static VerifyCS.Test NewTest(string userSource, OutputKind outputKind) + { + var test = new VerifyCS.Test + { + TestState = + { + OutputKind = outputKind, + Sources = + { + ("Workflow.cs", userSource), + ("AssemblyAttributes.cs", AssemblyAttributes), + ("DurableStubs.cs", DurableStubs), + }, + } + }; + return test; + } + + private static async Task AddAnnotationSourcesAsync(VerifyCS.Test test) + { + test.TestState.Sources.Add((Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await AnnotationsSource("LambdaFunctionAttribute.cs"))); + test.TestState.Sources.Add((Path.Combine("Amazon.Lambda.Annotations", "DurableExecutionAttribute.cs"), await AnnotationsSource("DurableExecutionAttribute.cs"))); + } + + private const string ValidWorkflow = @" +using System.Threading.Tasks; +using Amazon.Lambda.Annotations; +using Amazon.Lambda.DurableExecution; + +namespace MyApp +{ + public class Workflows + { + [LambdaFunction] + [DurableExecution] + public Task Run(string input, IDurableContext ctx) => Task.FromResult(input); + } +}"; + + [Fact] + public async Task RequiresExecutable_WhenClassLibrary_ReportsError() + { + // OutputKind = DynamicallyLinkedLibrary (class library) -> AWSLambda0140. + var test = NewTest(ValidWorkflow, OutputKind.DynamicallyLinkedLibrary); + await AddAnnotationSourcesAsync(test); + test.TestState.ExpectedDiagnostics.Add( + new DiagnosticResult("AWSLambda0140", DiagnosticSeverity.Error).WithSpan("Workflow.cs", 10, 9, 12, 94)); + test.CompilerDiagnostics = CompilerDiagnostics.None; + await test.RunAsync(); + } + + [Fact] + public async Task InvalidSignature_WhenWrongSecondParameter_ReportsError() + { + var source = @" +using System.Threading.Tasks; +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Core; + +namespace MyApp +{ + public class Workflows + { + [LambdaFunction] + [DurableExecution] + public Task Run(string input, ILambdaContext ctx) => Task.FromResult(input); + } +}"; + var test = NewTest(source, OutputKind.ConsoleApplication); + await AddAnnotationSourcesAsync(test); + test.TestState.ExpectedDiagnostics.Add( + new DiagnosticResult("AWSLambda0142", DiagnosticSeverity.Error) + .WithSpan("Workflow.cs", 10, 9, 12, 93) + .WithArguments("The second parameter must be 'Amazon.Lambda.DurableExecution.IDurableContext'.")); + test.CompilerDiagnostics = CompilerDiagnostics.None; + await test.RunAsync(); + } + + [Fact] + public async Task InvalidSignature_WhenReturnsValueTask_ReportsError() + { + var source = @" +using System.Threading.Tasks; +using Amazon.Lambda.Annotations; +using Amazon.Lambda.DurableExecution; + +namespace MyApp +{ + public class Workflows + { + [LambdaFunction] + [DurableExecution] + public ValueTask Run(string input, IDurableContext ctx) => new ValueTask(input); + } +}"; + var test = NewTest(source, OutputKind.ConsoleApplication); + await AddAnnotationSourcesAsync(test); + test.TestState.ExpectedDiagnostics.Add( + new DiagnosticResult("AWSLambda0142", DiagnosticSeverity.Error) + .WithSpan("Workflow.cs", 10, 9, 12, 105) + .WithArguments("The return type must be Task or Task but was 'System.Threading.Tasks.ValueTask'.")); + test.CompilerDiagnostics = CompilerDiagnostics.None; + await test.RunAsync(); + } + + [Fact] + public async Task ExclusiveEvent_WhenCombinedWithRestApi_ReportsMultipleEventsNotSupported() + { + // The existing AWSLambda0102 covers durable + another event attribute (no new diagnostic). + var source = @" +using System.Threading.Tasks; +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.DurableExecution; + +namespace MyApp +{ + public class Workflows + { + [LambdaFunction] + [DurableExecution] + [RestApi(LambdaHttpMethod.Get, ""/run"")] + public Task Run(string input, IDurableContext ctx) => Task.FromResult(input); + } +}"; + var test = NewTest(source, OutputKind.ConsoleApplication); + await AddAnnotationSourcesAsync(test); + test.TestState.Sources.Add((Path.Combine("Amazon.Lambda.Annotations", "APIGateway", "RestApiAttribute.cs"), await AnnotationsSource(Path.Combine("APIGateway", "RestApiAttribute.cs")))); + test.TestState.Sources.Add((Path.Combine("Amazon.Lambda.Annotations", "APIGateway", "HttpApiAttribute.cs"), await AnnotationsSource(Path.Combine("APIGateway", "HttpApiAttribute.cs")))); + test.TestState.ExpectedDiagnostics.Add( + new DiagnosticResult("AWSLambda0102", DiagnosticSeverity.Error).WithSpan("Workflow.cs", 11, 9, 14, 94)); + test.CompilerDiagnostics = CompilerDiagnostics.None; + await test.RunAsync(); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionModelTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionModelTests.cs new file mode 100644 index 000000000..421ba64b3 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionModelTests.cs @@ -0,0 +1,221 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.SourceGenerator; +using Amazon.Lambda.Annotations.SourceGenerator.Models; +using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class DurableExecutionModelTests + { + [Fact] + public void TypeFullNames_ContainsDurableExecutionConstants() + { + Assert.Equal("Amazon.Lambda.Annotations.DurableExecutionAttribute", TypeFullNames.DurableExecutionAttribute); + Assert.Equal("Amazon.Lambda.DurableExecution.DurableExecutionInvocationInput", TypeFullNames.DurableExecutionInvocationInput); + Assert.Equal("Amazon.Lambda.DurableExecution.DurableExecutionInvocationOutput", TypeFullNames.DurableExecutionInvocationOutput); + Assert.Equal("Amazon.Lambda.DurableExecution.DurableFunction", TypeFullNames.DurableFunction); + } + + [Fact] + public void TypeFullNames_Events_ContainsDurableExecutionAttribute() + { + Assert.Contains(TypeFullNames.DurableExecutionAttribute, TypeFullNames.Events); + } + + [Fact] + public void EventType_HasDurableExecutionValue() + { + Assert.NotEqual(EventType.API, EventType.DurableExecution); + Assert.NotEqual(EventType.Schedule, EventType.DurableExecution); + } + + // ===== Attribute unit tests (the attribute now lives in Amazon.Lambda.Annotations) ===== + + [Fact] + public void Attribute_Defaults_NothingSet() + { + var attr = new DurableExecutionAttribute(); + + Assert.False(attr.IsRetentionPeriodInDaysSet); + Assert.False(attr.IsExecutionTimeoutSet); + Assert.Empty(attr.Validate()); + } + + [Fact] + public void Attribute_SettingProperties_TracksIsSet() + { + var attr = new DurableExecutionAttribute { RetentionPeriodInDays = 7 }; + + Assert.True(attr.IsRetentionPeriodInDaysSet); + Assert.Equal(7, attr.RetentionPeriodInDays); + Assert.False(attr.IsExecutionTimeoutSet); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Attribute_Validate_RejectsNonPositiveRetentionPeriod(int value) + { + var attr = new DurableExecutionAttribute { RetentionPeriodInDays = value }; + + var errors = attr.Validate(); + + Assert.Single(errors); + Assert.Contains(nameof(DurableExecutionAttribute.RetentionPeriodInDays), errors[0]); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Attribute_Validate_RejectsNonPositiveExecutionTimeout(int value) + { + var attr = new DurableExecutionAttribute { ExecutionTimeout = value }; + + var errors = attr.Validate(); + + Assert.Single(errors); + Assert.Contains(nameof(DurableExecutionAttribute.ExecutionTimeout), errors[0]); + } + + [Fact] + public void Attribute_Validate_ReportsBothInvalidValues() + { + var attr = new DurableExecutionAttribute { RetentionPeriodInDays = 0, ExecutionTimeout = -5 }; + + Assert.Equal(2, attr.Validate().Count); + } + + // ===== Recognition + builder tests, driven through a real Roslyn compilation ===== + + // The attribute type must fully bind for Roslyn to surface its NamedArguments, which requires + // referencing the runtime assemblies (System.Runtime et al.), not just System.Private.CoreLib. + // Reference every trusted-platform assembly the test host already loaded, plus the Annotations + // assembly that defines DurableExecutionAttribute. + private static IReadOnlyList BuildReferences() + { + var references = new List(); + + var trustedAssemblies = (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty) + .Split(Path.PathSeparator); + foreach (var path in trustedAssemblies) + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + references.Add(MetadataReference.CreateFromFile(path)); + } + } + + references.Add(MetadataReference.CreateFromFile(typeof(DurableExecutionAttribute).Assembly.Location)); + return references; + } + + private static IMethodSymbol GetWorkflowMethod(string userSource) + { + var compilation = CSharpCompilation.Create( + "DurableExecutionModelTests", + new[] + { + CSharpSyntaxTree.ParseText(userSource) + }, + BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var workflowType = compilation.GetTypeByMetadataName("MyApp.Workflows"); + Assert.NotNull(workflowType); + return workflowType.GetMembers("Run").OfType().Single(); + } + + [Fact] + public void EventTypeBuilder_RecognizesDurableExecutionAttribute() + { + var method = GetWorkflowMethod(@" +namespace MyApp +{ + public class Workflows + { + [Amazon.Lambda.Annotations.DurableExecution] + public void Run() { } + } +}"); + + // EventTypeBuilder.Build matches by ToDisplayString() and does not use the context argument. + var events = EventTypeBuilder.Build(method, default); + + Assert.Contains(EventType.DurableExecution, events); + Assert.Single(events); + } + + [Fact] + public void DurableExecutionAttributeBuilder_ReadsNamedArguments() + { + var method = GetWorkflowMethod(@" +namespace MyApp +{ + public class Workflows + { + [Amazon.Lambda.Annotations.DurableExecution(RetentionPeriodInDays = 14, ExecutionTimeout = 600)] + public void Run() { } + } +}"); + + var att = method.GetAttributes().Single(); + var data = DurableExecutionAttributeBuilder.Build(att); + + Assert.True(data.IsRetentionPeriodInDaysSet); + Assert.Equal(14, data.RetentionPeriodInDays); + Assert.True(data.IsExecutionTimeoutSet); + Assert.Equal(600, data.ExecutionTimeout); + } + + [Fact] + public void DurableExecutionAttributeBuilder_OmitsUnsetArguments() + { + var method = GetWorkflowMethod(@" +namespace MyApp +{ + public class Workflows + { + [Amazon.Lambda.Annotations.DurableExecution(RetentionPeriodInDays = 7)] + public void Run() { } + } +}"); + + var att = method.GetAttributes().Single(); + var data = DurableExecutionAttributeBuilder.Build(att); + + Assert.True(data.IsRetentionPeriodInDaysSet); + Assert.Equal(7, data.RetentionPeriodInDays); + Assert.False(data.IsExecutionTimeoutSet); + } + + [Fact] + public void DurableExecutionAttributeBuilder_NoArguments_LeavesNothingSet() + { + var method = GetWorkflowMethod(@" +namespace MyApp +{ + public class Workflows + { + [Amazon.Lambda.Annotations.DurableExecution] + public void Run() { } + } +}"); + + var att = method.GetAttributes().Single(); + var data = DurableExecutionAttributeBuilder.Build(att); + + Assert.False(data.IsRetentionPeriodInDaysSet); + Assert.False(data.IsExecutionTimeoutSet); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionWrapperCompilesTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionWrapperCompilesTests.cs new file mode 100644 index 000000000..89006c649 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionWrapperCompilesTests.cs @@ -0,0 +1,159 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + // Compile-level verification of the generated durable wrapper. Component C asserts the exact wrapper + // TEXT the generator emits; this layer takes that emitted call shape and confirms it actually BINDS and + // compiles against realistic DurableFunction.WrapAsync overload signatures - the unique risk being that + // the no-explicit-generic method-group call resolves to the right overload (typed vs. void). + // + // We use the real overload set (typed + void) so overload resolution is genuinely exercised. The wrapper + // body mirrors exactly what DurableExecutionInvoke.tt produces: + // return await Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync(instance.Method, __request__, __context__); + public class DurableExecutionWrapperCompilesTests + { + private const string DurableSdk = @" +using System; +using System.Threading.Tasks; +using Amazon.Lambda.Core; + +namespace Amazon.Lambda.DurableExecution +{ + public interface IDurableContext { } + public sealed class DurableExecutionInvocationInput { } + public sealed class DurableExecutionInvocationOutput { } + + public static class DurableFunction + { + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext) => Task.FromResult(new DurableExecutionInvocationOutput()); + + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + object lambdaClient) => Task.FromResult(new DurableExecutionInvocationOutput()); + + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext) => Task.FromResult(new DurableExecutionInvocationOutput()); + + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + object lambdaClient) => Task.FromResult(new DurableExecutionInvocationOutput()); + } +} +"; + + // Mirrors the shape of DurableExecutionInvoke.tt + the generated method signature/usings from + // GeneratedMethodModelBuilder, for a user workflow and a generated wrapper class. + private static string WrapperProgram(string userReturnType, string userBody, string wrapperReturnExpression) => $@" +using System; +using System.Threading.Tasks; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +namespace MyApp +{{ + public class OrderProcessor + {{ + public {userReturnType} Process(string order, IDurableContext ctx) => {userBody}; + }} + + public class OrderProcessor_Process_Generated + {{ + private readonly OrderProcessor orderProcessor = new OrderProcessor(); + + public async Task Process( + Amazon.Lambda.DurableExecution.DurableExecutionInvocationInput __request__, + ILambdaContext __context__) + {{ + {wrapperReturnExpression} + }} + }} +}} +"; + + // Typed-output workflows need explicit ; void workflows need explicit . + // Method-group arguments cannot infer these (CS0411), which is why the generator spells them out. + private const string TypedWrapAsyncCall = + "return await Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync(orderProcessor.Process, __request__, __context__);"; + + private const string VoidWrapAsyncCall = + "return await Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync(orderProcessor.Process, __request__, __context__);"; + + private static IReadOnlyList BuildReferences() + { + var references = new List(); + var trusted = (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty) + .Split(Path.PathSeparator); + foreach (var path in trusted) + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + references.Add(MetadataReference.CreateFromFile(path)); + } + references.Add(MetadataReference.CreateFromFile(typeof(Amazon.Lambda.Core.ILambdaContext).Assembly.Location)); + return references; + } + + private static IReadOnlyList CompileErrors(string program) + { + var compilation = CSharpCompilation.Create( + "DurableWrapperCompiles", + new[] { CSharpSyntaxTree.ParseText(DurableSdk), CSharpSyntaxTree.ParseText(program) }, + BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + return compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + } + + [Fact] + public void GeneratedWrapper_BindsTypedOutputOverload() + { + // (string, IDurableContext) -> Task binds WrapAsync. + var program = WrapperProgram("Task", "Task.FromResult(order)", TypedWrapAsyncCall); + + var errors = CompileErrors(program); + + Assert.True(errors.Count == 0, "Unexpected compile errors:\n" + string.Join("\n", errors.Select(e => e.ToString()))); + } + + [Fact] + public void GeneratedWrapper_BindsVoidOverload() + { + // (string, IDurableContext) -> Task binds WrapAsync. + var program = WrapperProgram("Task", "Task.CompletedTask", VoidWrapAsyncCall); + + var errors = CompileErrors(program); + + Assert.True(errors.Count == 0, "Unexpected compile errors:\n" + string.Join("\n", errors.Select(e => e.ToString()))); + } + + [Fact] + public void MethodGroupCall_WithoutExplicitGenerics_FailsToCompile() + { + // Documents WHY the generator must emit explicit generics: the inference-free method-group form + // does not compile (CS0411). This guards against a regression back to the inferred form. + var inferredCall = "return await Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync(orderProcessor.Process, __request__, __context__);"; + var program = WrapperProgram("Task", "Task.FromResult(order)", inferredCall); + + var errors = CompileErrors(program); + + Assert.Contains(errors, e => e.Id == "CS0411"); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionWrapperTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionWrapperTests.cs new file mode 100644 index 000000000..f79da92b3 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionWrapperTests.cs @@ -0,0 +1,98 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using Amazon.Lambda.Annotations.SourceGenerator; +using Amazon.Lambda.Annotations.SourceGenerator.Models; +using Amazon.Lambda.Annotations.SourceGenerator.Templates; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class DurableExecutionWrapperTests + { + // Builds a LambdaFunctionModel with the pieces DurableExecutionInvoke reads: the containing type + // name, the user method name, the input parameter type, and the return type (Task vs Task). + private static LambdaFunctionModel BuildModel(string containingTypeName, string methodName, string inputType, string outputType) + { + TypeModel returnType; + bool returnsGenericTask; + if (outputType == null) + { + returnType = new TypeModel { FullName = "System.Threading.Tasks.Task", TypeArguments = new List() }; + returnsGenericTask = false; + } + else + { + returnType = new TypeModel + { + FullName = $"System.Threading.Tasks.Task<{outputType}>", + IsGenericType = true, + TypeArguments = new List { new TypeModel { FullName = outputType } } + }; + returnsGenericTask = true; + } + + return new LambdaFunctionModel + { + LambdaMethod = new LambdaMethodModel + { + Name = methodName, + ReturnsGenericTask = returnsGenericTask, + ReturnType = returnType, + Parameters = new List + { + new ParameterModel { Name = "input", Type = new TypeModel { FullName = inputType } }, + new ParameterModel { Name = "ctx", Type = new TypeModel { FullName = "Amazon.Lambda.DurableExecution.IDurableContext" } } + }, + ContainingType = new TypeModel + { + Name = containingTypeName, + FullName = $"MyApp.{containingTypeName}", + IsValueType = false + } + } + }; + } + + [Fact] + public void DurableExecutionInvoke_TypedOutput_EmitsExplicitGenerics() + { + var model = BuildModel("OrderProcessor", "Workflow", "MyApp.Order", "MyApp.OrderResult"); + + var body = new DurableExecutionInvoke(model).TransformText(); + + Assert.Equal( + " return await Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync(orderProcessor.Workflow, __request__, __context__);\r\n", + body); + } + + [Fact] + public void DurableExecutionInvoke_VoidWorkflow_EmitsSingleTypeArgument() + { + var model = BuildModel("OrderProcessor", "Workflow", "MyApp.Order", outputType: null); + + var body = new DurableExecutionInvoke(model).TransformText(); + + Assert.Equal( + " return await Amazon.Lambda.DurableExecution.DurableFunction.WrapAsync(orderProcessor.Workflow, __request__, __context__);\r\n", + body); + } + + [Fact] + public void DurableExecutionInvoke_UsesCamelCasedInstanceField() + { + var model = BuildModel("MyWorkflows", "Run", "string", "string"); + + var body = new DurableExecutionInvoke(model).TransformText(); + + // The instance is the same camel-cased field FieldsAndConstructor/NoEventMethodBody use. + Assert.Contains("myWorkflows.Run", body); + // Explicit generic args are required (method-group args cannot be inferred). + Assert.Contains("WrapAsync", body); + // Single delegation; the wrapper does not touch a serializer field or deserialize a stream. + Assert.DoesNotContain("serializer", body); + Assert.DoesNotContain("stream", body); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DurableExecutionWriterTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DurableExecutionWriterTests.cs new file mode 100644 index 000000000..502ef3547 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DurableExecutionWriterTests.cs @@ -0,0 +1,166 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Linq; +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.SourceGenerator; +using Amazon.Lambda.Annotations.SourceGenerator.Models; +using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.SourceGenerator.Writers; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests.WriterTests +{ + public partial class CloudFormationWriterTests + { + private static AttributeModel DurableAttribute(int? retentionDays = null, int? executionTimeout = null) + { + var data = new DurableExecutionAttribute(); + if (retentionDays.HasValue) data.RetentionPeriodInDays = retentionDays.Value; + if (executionTimeout.HasValue) data.ExecutionTimeout = executionTimeout.Value; + return new AttributeModel { Data = data }; + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void DurableExecution_WritesDurableConfig(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, "AWSLambdaBasicExecutionRole"); + lambdaFunctionModel.Attributes = new List { DurableAttribute(retentionDays: 7, executionTimeout: 300) }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(7, templateWriter.GetToken("Resources.TestMethod.Properties.DurableConfig.RetentionPeriodInDays")); + Assert.Equal(300, templateWriter.GetToken("Resources.TestMethod.Properties.DurableConfig.ExecutionTimeout")); + Assert.True(templateWriter.GetToken("Resources.TestMethod.Metadata.SyncedDurableConfig", false)); + // Durable is not an event source. + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.Events")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void DurableExecution_OmitsUnsetProperties(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, "AWSLambdaBasicExecutionRole"); + lambdaFunctionModel.Attributes = new List { DurableAttribute(retentionDays: 7) }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(7, templateWriter.GetToken("Resources.TestMethod.Properties.DurableConfig.RetentionPeriodInDays")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.DurableConfig.ExecutionTimeout")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void DurableExecution_InjectsCheckpointPolicy_AsMixedArray(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, "AWSLambdaBasicExecutionRole"); + lambdaFunctionModel.Attributes = new List { DurableAttribute(retentionDays: 7) }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + // The mixed string/object Policies array must round-trip through both writers. + var content = mockFileManager.ReadAllText(ServerlessTemplateFilePath); + templateWriter.Parse(content); + Assert.True(templateWriter.GetToken("Resources.TestMethod.Metadata.SyncedDurablePolicy", false)); + // The managed policy string is preserved alongside the injected statement object. + Assert.Contains("AWSLambdaBasicExecutionRole", content); + Assert.Contains("lambda:CheckpointDurableExecution", content); + Assert.Contains("lambda:GetDurableExecutionState", content); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void DurableExecution_PolicyInjectionIsIdempotent(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, "AWSLambdaBasicExecutionRole"); + lambdaFunctionModel.Attributes = new List { DurableAttribute(retentionDays: 7) }; + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + + // First pass. + GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter).ApplyReport(report); + // Second pass over the same (now-populated) template. + var report2 = GetAnnotationReport(new List { lambdaFunctionModel }); + GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter).ApplyReport(report2); + + var content = mockFileManager.ReadAllText(ServerlessTemplateFilePath); + // The checkpoint action should appear once, not duplicated across passes. + var occurrences = content.Split(new[] { "lambda:CheckpointDurableExecution" }, System.StringSplitOptions.None).Length - 1; + Assert.Equal(1, occurrences); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void DurableExecution_OrphanRemoval_StripsConfigAndPolicy(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + + // First pass: durable function. + var durableModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, "AWSLambdaBasicExecutionRole"); + durableModel.Attributes = new List { DurableAttribute(retentionDays: 7) }; + GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter) + .ApplyReport(GetAnnotationReport(new List { durableModel })); + + // Second pass: same function, durable attribute removed. + var plainModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, "AWSLambdaBasicExecutionRole"); + plainModel.Attributes = new List(); + GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter) + .ApplyReport(GetAnnotationReport(new List { plainModel })); + + var content = mockFileManager.ReadAllText(ServerlessTemplateFilePath); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + templateWriter.Parse(content); + + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.DurableConfig")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Metadata.SyncedDurableConfig")); + Assert.DoesNotContain("lambda:CheckpointDurableExecution", content); + // The managed policy that pre-existed must remain. + Assert.Contains("AWSLambdaBasicExecutionRole", content); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void DurableExecution_WithExplicitRole_DoesNotInjectPolicy(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + // Explicit Role -> ProcessLambdaFunctionProperties removes Policies; durable must not re-add them. + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, "arn:aws:iam::123456789012:role/MyRole", null); + lambdaFunctionModel.Attributes = new List { DurableAttribute(retentionDays: 7) }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + var content = mockFileManager.ReadAllText(ServerlessTemplateFilePath); + templateWriter.Parse(content); + // DurableConfig is still written, but no checkpoint policy and no policy marker. + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.DurableConfig")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.Policies")); + Assert.False(templateWriter.GetToken("Resources.TestMethod.Metadata.SyncedDurablePolicy", false)); + Assert.DoesNotContain("lambda:CheckpointDurableExecution", content); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableEnvelopeSerializationTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableEnvelopeSerializationTests.cs new file mode 100644 index 000000000..d26ae03d1 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableEnvelopeSerializationTests.cs @@ -0,0 +1,80 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.IO; +using System.Text; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.Serialization.SystemTextJson; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +// The [DurableExecution] attribute itself now lives in Amazon.Lambda.Annotations (its unit tests +// live with that package). These tests cover the durable invocation envelopes that the generated +// typed-envelope wrapper relies on: the runtime HandlerWrapper deserializes +// DurableExecutionInvocationInput and serializes DurableExecutionInvocationOutput using the +// ILambdaSerializer attached to the context. This verifies the default serializer round-trips both +// envelopes (including the UpperSnakeCaseEnumConverter on Status and a nested InitialExecutionState) +// without loss. +public class DurableEnvelopeSerializationTests +{ + [Fact] + public void DefaultSerializer_RoundTripsInvocationInput() + { + var serializer = new DefaultLambdaJsonSerializer(); + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:aws:lambda:us-west-2:123456789012:durable-execution:abc", + CheckpointToken = "token-1", + InitialExecutionState = new InitialExecutionState + { + NextMarker = "marker-2" + } + }; + + var roundTripped = RoundTrip(serializer, input); + + Assert.Equal(input.DurableExecutionArn, roundTripped.DurableExecutionArn); + Assert.Equal(input.CheckpointToken, roundTripped.CheckpointToken); + Assert.NotNull(roundTripped.InitialExecutionState); + Assert.Equal("marker-2", roundTripped.InitialExecutionState!.NextMarker); + } + + [Fact] + public void DefaultSerializer_RoundTripsInvocationOutput() + { + var serializer = new DefaultLambdaJsonSerializer(); + var output = new DurableExecutionInvocationOutput + { + Status = InvocationStatus.Pending, + Result = "\"some-result\"" + }; + + var roundTripped = RoundTrip(serializer, output); + + Assert.Equal(InvocationStatus.Pending, roundTripped.Status); + Assert.Equal("\"some-result\"", roundTripped.Result); + Assert.Null(roundTripped.Error); + } + + [Fact] + public void DefaultSerializer_SerializesStatusAsUpperSnakeCase() + { + var serializer = new DefaultLambdaJsonSerializer(); + var output = new DurableExecutionInvocationOutput { Status = InvocationStatus.Succeeded }; + + using var stream = new MemoryStream(); + serializer.Serialize(output, stream); + var json = Encoding.UTF8.GetString(stream.ToArray()); + + Assert.Contains("\"SUCCEEDED\"", json); + } + + private static T RoundTrip(DefaultLambdaJsonSerializer serializer, T value) + { + using var stream = new MemoryStream(); + serializer.Serialize(value, stream); + stream.Position = 0; + return serializer.Deserialize(stream); + } +}