Skip to content

Commit 18c63ec

Browse files
Add MapAsync for concurrent collection processing (#2408)
1 parent 194931a commit 18c63ec

41 files changed

Lines changed: 2686 additions & 740 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"Projects": [
3+
{
4+
"Name": "Amazon.Lambda.DurableExecution",
5+
"Type": "Patch",
6+
"ChangelogMessages": [
7+
"Add `MapAsync` to `IDurableContext` for processing a collection in parallel with one child context per item and automatic checkpointing.",
8+
"Breaking: renamed the concurrent-operation parent checkpoint payload field from `Branches` (shipped `ParallelAsync` format) to `Units`. The deserializer only binds `Units`, so durable executions checkpointed by earlier SDK versions will deserialize with an empty unit list and lose their per-unit names/statuses (including drift detection) on replay. In-flight workflows started on an earlier version should be drained before upgrading."
9+
]
10+
}
11+
]
12+
}

‎Docs/durable-execution-design.md‎

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,7 +1162,7 @@ public interface IDurableContext
11621162
/// </summary>
11631163
Task<IBatchResult<TResult>> MapAsync<TItem, TResult>(
11641164
IReadOnlyList<TItem> items,
1165-
Func<IDurableContext, TItem, int, IReadOnlyList<TItem>, Task<TResult>> func,
1165+
Func<IDurableContext, TItem, int, IReadOnlyList<TItem>, CancellationToken, Task<TResult>> func,
11661166
string? name = null,
11671167
MapConfig? config = null,
11681168
CancellationToken cancellationToken = default);
@@ -1443,22 +1443,21 @@ public class MapConfig
14431443
public int? MaxConcurrency { get; set; }
14441444

14451445
/// <summary>
1446-
/// When to consider the operation complete.
1446+
/// When to consider the operation complete. Defaults to AllCompleted() —
1447+
/// every item runs regardless of per-item failures, which surface via
1448+
/// IBatchResult&lt;T&gt;.Failed rather than throwing. This permissive default
1449+
/// matches the Python and Java SDKs' map operation. It differs intentionally
1450+
/// from ParallelConfig.CompletionConfig, which defaults to AllSuccessful()
1451+
/// (fail-fast). For fail-fast map behavior, set this to
1452+
/// CompletionConfig.AllSuccessful() or call IBatchResult&lt;T&gt;.ThrowIfError().
14471453
/// </summary>
1448-
public CompletionConfig CompletionConfig { get; set; } = CompletionConfig.AllSuccessful();
1454+
public CompletionConfig CompletionConfig { get; set; } = CompletionConfig.AllCompleted();
14491455

14501456
/// <summary>
14511457
/// How item branches are represented in the checkpoint graph.
14521458
/// </summary>
14531459
public NestingType NestingType { get; set; } = NestingType.Nested;
14541460

1455-
/// <summary>
1456-
/// Optional batching configuration for grouping items before processing.
1457-
/// When set, items are grouped into batches and each batch is processed as a unit.
1458-
/// Reduces checkpoint overhead for large collections.
1459-
/// </summary>
1460-
public ItemBatcher? Batcher { get; set; }
1461-
14621461
/// <summary>
14631462
/// Optional function to generate a custom name for each item's branch.
14641463
/// Improves observability in execution traces. Receives the item and its index.
@@ -1467,23 +1466,6 @@ public class MapConfig
14671466
public Func<object, int, string>? ItemNamer { get; set; }
14681467
}
14691468

1470-
/// <summary>
1471-
/// Groups items into batches for map operations to reduce checkpoint overhead.
1472-
/// At least one of MaxItemsPerBatch or MaxBytesPerBatch must be set.
1473-
/// </summary>
1474-
public class ItemBatcher
1475-
{
1476-
/// <summary>
1477-
/// Maximum number of items per batch. Null = no count limit.
1478-
/// </summary>
1479-
public int? MaxItemsPerBatch { get; set; }
1480-
1481-
/// <summary>
1482-
/// Maximum serialized size (bytes) per batch. Null = no size limit.
1483-
/// </summary>
1484-
public int? MaxBytesPerBatch { get; set; }
1485-
}
1486-
14871469
/// <summary>
14881470
/// Defines completion criteria for parallel/map operations.
14891471
/// </summary>
@@ -2222,7 +2204,6 @@ All four SDKs expose the same core operations. The differences are naming conven
22222204
| Jitter strategy | `JitterStrategy` enum on `Exponential()` | `jitter_strategy` on `RetryStrategyConfig` | `jitter` on `createRetryStrategy()` |
22232205
| Retry presets | `RetryStrategy.None/Default/Transient` | `RetryPresets.none()/default()/transient()` | `retryPresets.default/linear/noRetry` |
22242206
| Nesting type | `NestingType` on `ParallelConfig`/`MapConfig` | `NestingType` on parallel/map config | `NestingType` on parallel/map config |
2225-
| Item batching | `ItemBatcher` on `MapConfig` | `ItemBatcher` on `MapConfig` | *(checkpoint manager handles batching)* |
22262207
| Item namer | `ItemNamer` on `MapConfig` | Item naming function on `MapConfig` | `itemNamer` on `MapConfig` |
22272208
| Error mapping | `ErrorMapping` on `ChildContextConfig` | *(typed exception wrapping)* | `errorMapping` on child context config |
22282209
| Message-based retry filter | `retryableMessagePatterns` (regex) | `retryable_errors` (regex) | `retryableErrors` (RegExp[]) |

‎Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,41 @@ private Task<IBatchResult<T>> RunParallel<T>(
260260
return op.ExecuteAsync(cancellationToken);
261261
}
262262

263+
public Task<IBatchResult<TResult>> MapAsync<TItem, TResult>(
264+
IReadOnlyList<TItem> items,
265+
Func<IDurableContext, TItem, int, IReadOnlyList<TItem>, CancellationToken, Task<TResult>> func,
266+
string? name = null,
267+
MapConfig? config = null,
268+
CancellationToken cancellationToken = default)
269+
=> RunMap(items, func, name, config, cancellationToken);
270+
271+
private Task<IBatchResult<TResult>> RunMap<TItem, TResult>(
272+
IReadOnlyList<TItem> items,
273+
Func<IDurableContext, TItem, int, IReadOnlyList<TItem>, CancellationToken, Task<TResult>> func,
274+
string? name,
275+
MapConfig? config,
276+
CancellationToken cancellationToken)
277+
{
278+
if (items == null) throw new ArgumentNullException(nameof(items));
279+
if (func == null) throw new ArgumentNullException(nameof(func));
280+
281+
var effectiveConfig = config ?? new MapConfig();
282+
if (effectiveConfig.NestingType == NestingType.Flat)
283+
{
284+
throw new NotSupportedException(
285+
"NestingType.Flat is not yet supported in the .NET Durable Execution SDK. " +
286+
"Use NestingType.Nested (the default) for now.");
287+
}
288+
289+
var serializer = LambdaSerializerHelper.GetRequired(LambdaContext);
290+
291+
var operationId = _idGenerator.NextId();
292+
var op = new Internal.MapOperation<TItem, TResult>(
293+
operationId, name, _idGenerator.ParentId, items, func, effectiveConfig, serializer, MakeChildFactory(),
294+
_state, _terminationManager, _workflowCancellation, _durableExecutionArn, _batcher);
295+
return op.ExecuteAsync(cancellationToken);
296+
}
297+
263298
public Task<T> WaitForCallbackAsync<T>(
264299
Func<string, IWaitForCallbackContext, CancellationToken, Task> submitter,
265300
string? name = null,

‎Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionException.cs‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,36 @@ public ParallelException(string message) : base(message) { }
131131
/// <summary>Creates a <see cref="ParallelException"/> wrapping an inner exception.</summary>
132132
public ParallelException(string message, Exception innerException) : base(message, innerException) { }
133133
}
134+
135+
/// <summary>
136+
/// Thrown when a map operation resolves with
137+
/// <see cref="CompletionReason.FailureToleranceExceeded"/>. The aggregate
138+
/// <see cref="IBatchResult"/> is preserved on <see cref="Result"/> so callers
139+
/// can inspect per-item outcomes.
140+
/// </summary>
141+
/// <remarks>
142+
/// This is the base type for map failures. Subclasses may be added in future
143+
/// releases; catching <see cref="MapException"/> remains forward-compatible.
144+
/// A dedicated type (rather than reusing <see cref="ParallelException"/>) lets
145+
/// callers pattern-match which concurrent operation failed.
146+
/// </remarks>
147+
public class MapException : DurableExecutionException
148+
{
149+
/// <summary>
150+
/// The aggregate result of the map operation. Type-erased — cast to
151+
/// <c>IBatchResult&lt;T&gt;</c> if the per-item result type is known.
152+
/// </summary>
153+
public IBatchResult? Result { get; init; }
154+
155+
/// <summary>
156+
/// Why the map operation resolved.
157+
/// </summary>
158+
public CompletionReason CompletionReason { get; init; }
159+
160+
/// <summary>Creates an empty <see cref="MapException"/>.</summary>
161+
public MapException() { }
162+
/// <summary>Creates a <see cref="MapException"/> with the given message.</summary>
163+
public MapException(string message) : base(message) { }
164+
/// <summary>Creates a <see cref="MapException"/> wrapping an inner exception.</summary>
165+
public MapException(string message, Exception innerException) : base(message, innerException) { }
166+
}

‎Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,38 @@ Task<IBatchResult<T>> ParallelAsync<T>(
444444
string? name = null,
445445
ParallelConfig? config = null,
446446
CancellationToken cancellationToken = default);
447+
448+
/// <summary>
449+
/// Process a collection of items concurrently, running <paramref name="func"/>
450+
/// once per item. Each item runs inside its own child context; per-item
451+
/// results are aggregated into an <see cref="IBatchResult{TResult}"/>. Items
452+
/// are dispatched up to <see cref="MapConfig.MaxConcurrency"/>; the aggregate
453+
/// resolves according to <see cref="MapConfig.CompletionConfig"/>.
454+
/// </summary>
455+
/// <remarks>
456+
/// The per-item function receives the durable context, the item, its
457+
/// zero-based index, the full source list, and a
458+
/// <see cref="CancellationToken"/> linking the
459+
/// caller-supplied token with the SDK's workflow-shutdown signal (it is also
460+
/// tripped cooperatively when a sibling item satisfies the
461+
/// <see cref="MapConfig.CompletionConfig"/> and the map short-circuits). On
462+
/// per-item failure (the user function throws), the failure is captured on
463+
/// the corresponding <see cref="IBatchItem{TResult}"/> instead of aborting
464+
/// the map. By default (<see cref="CompletionConfig.AllCompleted"/>) every
465+
/// item runs and failures surface via <see cref="IBatchResult{TResult}.Failed"/>;
466+
/// the map throws <see cref="MapException"/> only when
467+
/// <see cref="CompletionConfig"/> criteria are violated. Use
468+
/// <see cref="IBatchResult{TResult}.ThrowIfError"/> for explicit
469+
/// strict-success semantics. Per-item results are serialized to checkpoints
470+
/// using the <see cref="ILambdaSerializer"/> registered on
471+
/// <see cref="ILambdaContext.Serializer"/>.
472+
/// </remarks>
473+
Task<IBatchResult<TResult>> MapAsync<TItem, TResult>(
474+
IReadOnlyList<TItem> items,
475+
Func<IDurableContext, TItem, int, IReadOnlyList<TItem>, CancellationToken, Task<TResult>> func,
476+
string? name = null,
477+
MapConfig? config = null,
478+
CancellationToken cancellationToken = default);
447479
}
448480

449481
/// <summary>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Amazon.Lambda.DurableExecution.Internal;
4+
5+
/// <summary>
6+
/// AOT-friendly <see cref="JsonSerializerContext"/> for the internal
7+
/// <see cref="BatchSummary"/> payload stored on a concurrent operation's parent
8+
/// CONTEXT checkpoint (parallel or map). Only this internal type — never user T —
9+
/// flows through here, so the source-generated metadata is sufficient.
10+
/// </summary>
11+
[JsonSerializable(typeof(BatchSummary))]
12+
[JsonSerializable(typeof(BatchUnitSummary))]
13+
internal sealed partial class BatchJsonContext : JsonSerializerContext
14+
{
15+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Amazon.Lambda.DurableExecution.Internal;
4+
5+
/// <summary>
6+
/// Internal payload shape stored on a concurrent operation's parent CONTEXT
7+
/// checkpoint (as <c>ContextDetails.Result</c>) and reconstructed on replay.
8+
/// Shared by both <see cref="ParallelOperation{T}"/> and
9+
/// <see cref="MapOperation{TItem, TResult}"/>: carries the completion reason and
10+
/// the per-unit index → status map so the <see cref="IBatchResult{T}"/> can be
11+
/// rebuilt without depending on user T shape — per-unit results live on the
12+
/// children's own checkpoints.
13+
/// </summary>
14+
internal sealed class BatchSummary
15+
{
16+
[JsonPropertyName("CompletionReason")]
17+
public string? CompletionReason { get; set; }
18+
19+
[JsonPropertyName("Units")]
20+
public IList<BatchUnitSummary> Units { get; set; } = new List<BatchUnitSummary>();
21+
}
22+
23+
internal sealed class BatchUnitSummary
24+
{
25+
[JsonPropertyName("Index")]
26+
public int Index { get; set; }
27+
28+
[JsonPropertyName("Name")]
29+
public string? Name { get; set; }
30+
31+
[JsonPropertyName("Status")]
32+
public string? Status { get; set; }
33+
}

0 commit comments

Comments
 (0)