| | | 1 | | namespace AsyncResponse; |
| | | 2 | | |
| | | 3 | | /// <summary> |
| | | 4 | | /// A durable multi-step flow: plain sequential C# whose steps are checkpointed, so the flow can be |
| | | 5 | | /// killed at any point (crash, redeploy, redelivery) and re-run safely — completed steps are |
| | | 6 | | /// skipped, the in-flight awaited step is re-attached, and everything after continues. |
| | | 7 | | /// <para> |
| | | 8 | | /// Implement the flow as ordinary code: conditionals, loops, and data flow are all allowed. The |
| | | 9 | | /// only rules are that each step has a stable unique name (names are persisted in the flow state) |
| | | 10 | | /// and that step bodies are safe to re-execute when the library cannot prove they completed |
| | | 11 | | /// (at-least-once, like every other delivery guarantee in AsyncResponse). |
| | | 12 | | /// </para> |
| | | 13 | | /// <para> |
| | | 14 | | /// Register the implementation in DI (e.g. <c>services.AddScoped<MyFlow>()</c>) and start it with |
| | | 15 | | /// <see cref="IDurableFlows.StartAsync{TFlow,TInput}"/>. The flow class name is persisted in the |
| | | 16 | | /// flow state and resolved when the flow is executed or resumed — treat the class name as a wire |
| | | 17 | | /// contract (rename with a forwarding type, like recovery-callback names). |
| | | 18 | | /// </para> |
| | | 19 | | /// </summary> |
| | | 20 | | /// <typeparam name="TInput"> |
| | | 21 | | /// The flow's input, persisted as JSON with the flow state and handed to every (re-)execution. |
| | | 22 | | /// Use one serializable record; capture nothing through closures. |
| | | 23 | | /// </typeparam> |
| | | 24 | | public interface IDurableFlow<in TInput> |
| | | 25 | | { |
| | | 26 | | /// <summary> |
| | | 27 | | /// The flow body. Invoked on start and on every resume/redelivery — always from the top, with |
| | | 28 | | /// completed steps skipping via their checkpoints. Must therefore be safe to call repeatedly. |
| | | 29 | | /// </summary> |
| | | 30 | | /// <param name="flow">The step context: checkpointed steps, awaited steps, progress, values.</param> |
| | | 31 | | /// <param name="input">The input the flow was started with, rehydrated from the flow state.</param> |
| | | 32 | | Task ExecuteAsync(IDurableFlowContext flow, TInput input); |
| | | 33 | | } |
| | | 34 | | |
| | | 35 | | /// <summary> |
| | | 36 | | /// Terminates a durable flow run as <see cref="FlowRunStatus.Failed"/> without transport |
| | | 37 | | /// redelivery. Any other exception thrown from a flow is treated as retriable: it propagates to |
| | | 38 | | /// the worker transport, which redelivers the flow run with bounded attempts and dead-letters it |
| | | 39 | | /// when they are exhausted. |
| | | 40 | | /// </summary> |
| | | 41 | | public sealed class DurableFlowFailedException : Exception |
| | | 42 | | { |
| | | 43 | | /// <summary>Creates a terminal flow failure with an operator-facing message.</summary> |
| | | 44 | | public DurableFlowFailedException(string message) : base(message) |
| | | 45 | | { |
| | | 46 | | } |
| | | 47 | | |
| | | 48 | | /// <summary>Creates a terminal flow failure wrapping the causing exception.</summary> |
| | | 49 | | public DurableFlowFailedException(string message, Exception innerException) : base(message, innerException) |
| | | 50 | | { |
| | | 51 | | } |
| | | 52 | | } |
| | | 53 | | |
| | | 54 | | /// <summary> |
| | | 55 | | /// Thrown by <c>IDurableFlows.StartAsync</c> when the requested flow id already exists bound to a |
| | | 56 | | /// different flow type or a semantically different input — the idempotent-start contract accepts |
| | | 57 | | /// only exact retries. Derives from <see cref="InvalidOperationException"/> for compatibility with |
| | | 58 | | /// callers that catch that. The distinct type exists so callers running deterministic-id races |
| | | 59 | | /// (the scheduled-flow service, outbox-style starters) can tell <em>this id is taken with other |
| | | 60 | | /// data</em> apart from every other <see cref="InvalidOperationException"/> a start can throw — |
| | | 61 | | /// treating, say, an input-factory failure as a benign duplicate would report an occurrence as |
| | | 62 | | /// having run when nothing ran. |
| | | 63 | | /// </summary> |
| | | 64 | | public sealed class DurableFlowIdConflictException : InvalidOperationException |
| | | 65 | | { |
| | | 66 | | /// <summary>Creates the conflict with an operator-facing message naming the flow id.</summary> |
| | 16 | 67 | | public DurableFlowIdConflictException(string message) : base(message) |
| | | 68 | | { |
| | 16 | 69 | | } |
| | | 70 | | } |
| | | 71 | | |
| | | 72 | | /// <summary> |
| | | 73 | | /// Thrown by <c>IDurableFlows.StartAsync</c> when the flow's start job could not be published to the |
| | | 74 | | /// worker transport after retries. <b>Nothing was persisted</b>: the publish is the start's commit |
| | | 75 | | /// point (the job carries the initial ledger and its execution creates the run), so a failed |
| | | 76 | | /// publish leaves no orphaned <c>Running</c> ledger behind — the caller simply retries the start. |
| | | 77 | | /// <see cref="FlowId"/> carries the id the start would have used, including a generated one, so a |
| | | 78 | | /// retry can reuse it and stay idempotent: an identical start of an id that already exists |
| | | 79 | | /// re-enqueues the existing run rather than creating a second one. |
| | | 80 | | /// <para> |
| | | 81 | | /// Publication is retried before this surfaces, so it means the transport stayed unavailable, not |
| | | 82 | | /// that it blinked. The ambiguous case is deliberately included: a publish that may or may not |
| | | 83 | | /// have landed also throws here. If it did land, the job creates and runs the flow on its own; a |
| | | 84 | | /// retried start with the SAME id then dedupes against that run, while a retry with a fresh |
| | | 85 | | /// generated id starts a second, independent run — supply deterministic ids where callers retry. |
| | | 86 | | /// </para> |
| | | 87 | | /// </summary> |
| | | 88 | | public sealed class DurableFlowNotDispatchedException : InvalidOperationException |
| | | 89 | | { |
| | | 90 | | /// <summary>Creates the failure for <paramref name="flowId"/>.</summary> |
| | | 91 | | public DurableFlowNotDispatchedException(string flowId, Exception? innerException = null) |
| | | 92 | | : base( |
| | | 93 | | $"Durable flow '{flowId}' could not be started: its worker job was not published, so nothing " + |
| | | 94 | | $"was persisted and nothing is scheduled to execute it. Retry the start with this same flow id — an " + |
| | | 95 | | $"identical start is idempotent, so a job that did land is not duplicated.", |
| | | 96 | | innerException) |
| | | 97 | | => FlowId = flowId; |
| | | 98 | | |
| | | 99 | | /// <summary>The id the start would have used, so a caller can retry idempotently with it.</summary> |
| | | 100 | | public string FlowId { get; } |
| | | 101 | | } |