Skip to content

Commit 309386b

Browse files
committed
refactor(durable)!: relax handler signature validation
1 parent 1119938 commit 309386b

31 files changed

Lines changed: 157 additions & 2813 deletions

File tree

decisions/ADR-004-durable-handler-signature-and-diagnostics-contract.md

Lines changed: 18 additions & 445 deletions
Large diffs are not rendered by default.

docs/features/durable-execution.md

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,20 +90,20 @@ internal partial class DurableExampleJsonContext : JsonSerializerContext;
9090

9191
## Handler contract
9292

93-
Durable handlers support two exact return forms:
93+
Durable handlers support two return forms:
9494

95-
| Form | Purpose | Required explicit serializer roots |
96-
| --------------- | ----------------------------- | ---------------------------------------------- |
97-
| `Task` | Workflow with no typed result | outer input, outer output, `TInput` |
98-
| `Task<TOutput>` | Workflow with typed result | outer input, outer output, `TInput`, `TOutput` |
95+
| Form | Purpose |
96+
| --------------- | ----------------------------- |
97+
| `Task` | Workflow with no typed result |
98+
| `Task<TOutput>` | Workflow with typed result |
9999

100-
Both forms require exactly one `[FromEvent] TInput` and exactly one exact AWS `IDurableContext`. Input is never inferred, and parameter order is unrestricted. Additional parameters may be `ILambdaContext`, `ILambdaInvocationContext`, ordinary DI, keyed DI, or optional DI. See [Dependency Injection](../guides/dependency-injection.md) for service lifetimes.
100+
`[FromEvent] TInput` and AWS `IDurableContext` are optional; a handler may use either, both, or neither. Parameter order is unrestricted. Other parameters use the normal handler binding rules. See [Dependency Injection](../guides/dependency-injection.md) for service lifetimes.
101101

102102
Do not expose `DurableExecutionInvocationInput`, `DurableExecutionInvocationOutput`, streams, or AWS client in high-level handler. MinimalLambda directly deserializes and serializes hidden outer envelopes through its configured Lambda serializer; durable envelopes are not available through `IEventFeature` or `IResponseFeature`. It also owns raw-stream hosting, middleware, DI scope, and physical invocation context. AWS runtime owns workflow payloads, `IDurableContext`, checkpoints, replay, suspension, waits, and durable status/result mapping.
103103

104104
### Cancellation
105105

106-
Never add root `CancellationToken` to durable handler. Generator reports `LH0009` because near-timeout root cancellation could turn retryable physical invocation into terminal `FAILED` workflow. AWS operation callbacks already receive SDK-linked cancellation tokens, while `WrapAsync` exposes no lifecycle-token hook. Automatically linking MinimalLambda physical-invocation cancellation would couple replay to Lambda timeout. Use AWS-supplied token inside step, callback, child workflow, map, parallel, and other operation callbacks, as sample does.
106+
AWS operation callbacks receive SDK-linked cancellation tokens. If a durable handler also reads `ILambdaInvocationContext.CancellationToken`, it owns the resulting physical-invocation failure and retry behavior.
107107

108108
Reading `ILambdaInvocationContext.CancellationToken` explicitly means accepting root failure/retry consequences.
109109

@@ -132,12 +132,12 @@ One registered `ILambdaSerializer` handles MinimalLambda outer envelopes and AWS
132132
- [ ] Register context with `AddLambdaSerializerWithContext<TContext>()`.
133133
- [ ] Add `DurableExecutionInvocationInput` root.
134134
- [ ] Add `DurableExecutionInvocationOutput` root.
135-
- [ ] Add workflow `TInput` root.
136-
- [ ] For `Task<TOutput>`, add `TOutput` root. `Task` handlers therefore need three signature roots; `Task<TOutput>` handlers need four.
135+
- [ ] Add the event type if the handler declares one.
136+
- [ ] For `Task<TOutput>`, add `TOutput` when required by the configured serializer.
137137
- [ ] Add every operation payload, result, and state root used by steps, callbacks, invokes, child workflows, waits, maps, or parallel branches.
138138
- [ ] Publish intended runtime and architecture with NativeAOT enabled; restore/build alone does not compile native code.
139139

140-
Generator cannot discover operation types hidden inside method bodies or referenced libraries. `LH0011` checks only explicit outer input/output and handler input/output declarations. It does not inspect member graphs or hidden operation types, and warning absence does not prove serializer completeness. `LH0011` does not suppress generation, so missing runtime metadata can still fail later.
140+
The generator does not inspect serializer contexts. Ensure that the context registered for the Lambda serializer includes the outer envelopes and every payload, result, and state type your workflow uses.
141141

142142
Local source-based `net10.0` NativeAOT publish passes. This proves local publishing only. Managed cloud integration remains unverified, and Durable Execution NativeAOT support remains experimental. Successful local publish is not evidence of deployment, IAM, replay, or managed-service behavior. See [AWS NativeAOT guidance](https://docs.aws.amazon.com/lambda/latest/dg/dotnet-native-aot.html) and project [support matrix](https://github.com/LayeredCraft/minimal-lambda/blob/main/decisions/durable-dependency-support-matrix.md).
143143

@@ -171,11 +171,7 @@ Split tests by ownership:
171171

172172
| Symptom | Fix |
173173
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
174-
| `LH0007` | Declare exactly one `[FromEvent] TInput`; remove missing or duplicate event inputs. |
175-
| `LH0008` | Declare exactly one exact `Amazon.Lambda.DurableExecution.IDurableContext`. |
176-
| `LH0009` | Remove unsupported parameter. Common cases: root `CancellationToken`, stream/outer envelope, conflicting binding attribute, or `ref`/`in`/`out`. Use operation callback token or raw path where needed. |
177-
| `LH0010` | Return exact `Task` or `Task<TOutput>`. Sync, `ValueTask`, custom awaitable, stream, and envelope outputs are unsupported. |
178-
| `LH0011` | Add explicit `[JsonSerializable(typeof(...))]` signature root. Then audit hidden operation roots manually; warning checks only signature roots and does not block emission. |
174+
| `LH0007` | Return `Task` or `Task<TOutput>`. |
179175
| Runtime `InvalidOperationException` at `MapDurableHandler` | Compile-time interceptor did not replace fallback stub. Keep direct `MinimalLambda` reference and project interceptor/source-generator configuration. |
180176
| AWS DE001-DE004 absent | Add direct `Amazon.Lambda.DurableExecution` reference; analyzer assets are not transitive. |
181177
| Duplicate logs, metrics, or DI work | Replay caused another physical invocation. Use AWS replay-aware logger for workflow logs and make invocation observation replay-safe. |

src/MinimalLambda.DurableExecution/MapDurableHandlerLambdaApplicationExtensions.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,10 @@ public static class MapDurableHandlerLambdaApplicationExtensions
1919
/// <remarks>
2020
/// <para>
2121
/// Source generation creates wiring code that resolves handler dependencies and adapts the
22-
/// handler to the AWS Lambda Durable Execution protocol. A supported handler declares exactly
23-
/// one <see cref="FromEventAttribute" /> workflow input and one exact AWS
24-
/// <c>IDurableContext</c>, and returns exactly <see cref="Task" /> or
25-
/// <see cref="Task{TResult}" />. Invocation contexts and dependency-injection services can be
26-
/// additional parameters; a root <see cref="CancellationToken" /> is not supported.
22+
/// handler to the AWS Lambda Durable Execution protocol. A handler can optionally declare a
23+
/// <see cref="FromEventAttribute" /> workflow input and an AWS <c>IDurableContext</c>.
24+
/// It returns <see cref="Task" /> or <see cref="Task{TResult}" />. Invocation contexts and
25+
/// dependency-injection services can be additional parameters.
2726
/// </para>
2827
/// <para>
2928
/// Invocation contexts, dependency-injection scopes, and middleware belong to one physical

src/MinimalLambda.DurableExecution/README.md

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ internal sealed class OrderService : IOrderService
9191
internal partial class DurableJsonContext : JsonSerializerContext;
9292
```
9393

94-
First four serializer roots are required for outer input/output envelopes and typed workflow
95-
input/output. Explicitly add every payload, result, or state type used by steps, callbacks, invokes,
94+
Register the serializer roots required by your configured serializer, including the outer input/output
95+
envelopes and every payload, result, or state type used by steps, callbacks, invokes,
9696
child workflows, waits, maps, and parallel branches; `ProcessOrderStepResult` is one representative
9797
step root. Generator cannot discover types hidden inside operation bodies or referenced libraries.
9898
Same registered `ILambdaSerializer` handles MinimalLambda outer envelopes and AWS inner values.
@@ -101,16 +101,14 @@ provide exactly-once execution.
101101

102102
## Handler, cancellation, and context contract
103103

104-
Durable handler must declare exactly one `[FromEvent] TInput`, exactly one exact AWS
105-
`IDurableContext`, and return exact `Task` or `Task<TOutput>`. Parameter order is unrestricted.
106-
Optional additional parameters are `ILambdaContext`, `ILambdaInvocationContext`, ordinary DI, keyed
107-
DI, or optional DI. Input is never inferred. Synchronous, `ValueTask`, custom-awaitable, `Stream`,
108-
outer durable envelope, and root `CancellationToken` forms are rejected by generator diagnostics.
104+
Durable handlers return `Task` or `Task<TOutput>`. `[FromEvent] TInput` and AWS `IDurableContext`
105+
are optional; a handler may use either, both, or neither. Parameter order is unrestricted. Other
106+
parameters are resolved using the normal handler binding rules. The generator does not validate
107+
serializer roots or impose a durable-specific payload-shape policy.
109108

110-
Do not put `CancellationToken` on root handler. Near-timeout cancellation could fault workflow into
111-
terminal `FAILED` state instead of letting physical invocation retry. AWS operation callbacks already
112-
receive SDK-linked cancellation tokens, and `WrapAsync` exposes no lifecycle-token hook. Reading
113-
`ILambdaInvocationContext.CancellationToken` explicitly means owning those failure/retry consequences.
109+
If a handler needs physical-invocation cancellation, read it through
110+
`ILambdaInvocationContext.CancellationToken` and own the resulting failure/retry consequences. AWS
111+
operation callbacks already receive SDK-linked cancellation tokens.
114112

115113
Inject `ILambdaInvocationContext` as above, or recover exact physical invocation context carried by
116114
AWS durable context:

src/MinimalLambda.SourceGenerators/AnalyzerReleases.Unshipped.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,3 @@
33
Rule ID | Category | Severity | Notes
44
--------|----------|----------|------
55
LH0007 | MinimalLambda.Usage | Error | Diagnostics
6-
LH0008 | MinimalLambda.Usage | Error | Diagnostics
7-
LH0009 | MinimalLambda.Usage | Error | Diagnostics
8-
LH0010 | MinimalLambda.Usage | Error | Diagnostics
9-
LH0011 | MinimalLambda.Configuration | Warning | Diagnostics

src/MinimalLambda.SourceGenerators/Diagnostics/DiagnosticInfo.cs

Lines changed: 5 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -10,86 +10,12 @@ internal sealed record DiagnosticInfo(
1010
LocationInfo? LocationInfo = null,
1111
params object?[] MessageArgs)
1212
{
13-
internal int TreeOrdinal { get; init; } = int.MaxValue;
14-
internal int SubKey { get; init; }
13+
public bool Equals(DiagnosticInfo? other) =>
14+
other is not null
15+
&& Equals(DiagnosticDescriptor.Id, other.DiagnosticDescriptor.Id)
16+
&& Equals(LocationInfo, other.LocationInfo);
1517

16-
public bool Equals(DiagnosticInfo? other)
17-
{
18-
if (other is null
19-
|| !string.Equals(
20-
DiagnosticDescriptor.Id,
21-
other.DiagnosticDescriptor.Id,
22-
StringComparison.Ordinal)
23-
|| !Equals(LocationInfo, other.LocationInfo)
24-
|| MessageArgs.Length != other.MessageArgs.Length)
25-
return false;
26-
27-
for (var i = 0; i < MessageArgs.Length; i++)
28-
if (!Equals(MessageArgs[i], other.MessageArgs[i]))
29-
return false;
30-
31-
return true;
32-
}
33-
34-
public override int GetHashCode()
35-
{
36-
var hash = HashCode.Combine(DiagnosticDescriptor.Id, LocationInfo);
37-
foreach (var argument in MessageArgs)
38-
hash = HashCode.Combine(hash, argument);
39-
return hash;
40-
}
41-
}
42-
43-
internal sealed class DiagnosticInfoOrderComparer : IComparer<DiagnosticInfo>
44-
{
45-
internal static readonly DiagnosticInfoOrderComparer Instance = new();
46-
47-
public int Compare(DiagnosticInfo? x, DiagnosticInfo? y)
48-
{
49-
if (ReferenceEquals(x, y))
50-
return 0;
51-
if (x is null)
52-
return -1;
53-
if (y is null)
54-
return 1;
55-
56-
var result = x.TreeOrdinal.CompareTo(y.TreeOrdinal);
57-
if (result != 0)
58-
return result;
59-
result = (x.LocationInfo?.TextSpan.Start ?? int.MaxValue).CompareTo(
60-
y.LocationInfo?.TextSpan.Start ?? int.MaxValue);
61-
if (result != 0)
62-
return result;
63-
result = string.CompareOrdinal(x.DiagnosticDescriptor.Id, y.DiagnosticDescriptor.Id);
64-
if (result != 0)
65-
return result;
66-
result = x.SubKey.CompareTo(y.SubKey);
67-
if (result != 0)
68-
return result;
69-
70-
var count = Math.Min(x.MessageArgs.Length, y.MessageArgs.Length);
71-
for (var i = 0; i < count; i++)
72-
{
73-
result = CompareArgument(x.MessageArgs[i], y.MessageArgs[i]);
74-
if (result != 0)
75-
return result;
76-
}
77-
78-
return x.MessageArgs.Length.CompareTo(y.MessageArgs.Length);
79-
}
80-
81-
private static int CompareArgument(object? left, object? right)
82-
{
83-
if (ReferenceEquals(left, right))
84-
return 0;
85-
if (left is null)
86-
return -1;
87-
if (right is null)
88-
return 1;
89-
if (left is int leftInt && right is int rightInt)
90-
return leftInt.CompareTo(rightInt);
91-
return string.CompareOrdinal(left.ToString(), right.ToString());
92-
}
18+
public override int GetHashCode() => HashCode.Combine(DiagnosticDescriptor, LocationInfo);
9319
}
9420

9521
internal static class DiagnosticInfoExtensions

src/MinimalLambda.SourceGenerators/Diagnostics/Diagnostics.cs

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -50,43 +50,11 @@ internal static class Diagnostics
5050
DiagnosticSeverity.Error,
5151
true);
5252

53-
internal static readonly DiagnosticDescriptor InvalidDurableInputCardinality = new(
54-
"LH0007",
55-
"Invalid durable workflow input cardinality",
56-
"Durable handler must declare exactly one event input using '[FromEvent]'; found {0}.",
57-
UsageCategory,
58-
DiagnosticSeverity.Error,
59-
true);
60-
61-
internal static readonly DiagnosticDescriptor InvalidDurableContextCardinality = new(
62-
"LH0008",
63-
"Invalid durable context cardinality",
64-
"Durable handler must declare exactly one 'Amazon.Lambda.DurableExecution.IDurableContext' parameter; found {0}.",
65-
UsageCategory,
66-
DiagnosticSeverity.Error,
67-
true);
68-
69-
internal static readonly DiagnosticDescriptor UnsupportedDurableParameter = new(
70-
"LH0009",
71-
"Unsupported durable handler parameter",
72-
"Durable handler parameter '{0}' of type '{1}' is not supported: {2}.",
73-
UsageCategory,
74-
DiagnosticSeverity.Error,
75-
true);
76-
7753
internal static readonly DiagnosticDescriptor UnsupportedDurableReturnType = new(
78-
"LH0010",
54+
"LH0007",
7955
"Unsupported durable handler return type",
80-
"Durable handler return type '{0}' is not supported; use 'Task' or 'Task<TOutput>' with a closed, nameable, accessible, non-transport output type.",
56+
"Durable handler return type '{0}' is not supported; use 'Task' or 'Task<TOutput>'.",
8157
UsageCategory,
8258
DiagnosticSeverity.Error,
8359
true);
84-
85-
internal static readonly DiagnosticDescriptor MissingDurableSerializerRoot = new(
86-
"LH0011",
87-
"Durable serializer root is not explicitly declared",
88-
"Source-generated serializer context '{0}' does not explicitly declare durable serialization root '{1}'. Add [JsonSerializable(typeof({1}))] to that context or a base context declaration.",
89-
ConfigurationCategory,
90-
DiagnosticSeverity.Warning,
91-
true);
9260
}

0 commit comments

Comments
 (0)