| | | 1 | | namespace AsyncResponse; |
| | | 2 | | |
| | | 3 | | /// <summary> |
| | | 4 | | /// Shared options for an async-response channel (the response/recovery substrate). Concrete channels |
| | | 5 | | /// extend this with their own transport-specific settings (key/subject prefixes, bucket names, …). |
| | | 6 | | /// </summary> |
| | | 7 | | public abstract class AsyncResponseChannelOptions |
| | | 8 | | { |
| | | 9 | | /// <summary> |
| | | 10 | | /// How long persisted <see cref="RecoveryState"/> entries live, bounding how long after a |
| | | 11 | | /// crash/redeploy a late response can still trigger the lost-subscriber callbacks. Set it |
| | | 12 | | /// comfortably above your longest-running flow. (For the in-memory channel this is process-local |
| | | 13 | | /// and lost on exit.) |
| | | 14 | | /// </summary> |
| | | 15 | | public TimeSpan RecoveryStateExpiry { get; set; } = TimeSpan.FromDays(7); |
| | | 16 | | |
| | | 17 | | /// <summary> |
| | | 18 | | /// Default timeout applied to waiters that do not specify <c>WithTimeout</c>. When <c>null</c> |
| | | 19 | | /// (the default), <see cref="RecoveryStateExpiry"/> is used — waits are never infinite, so a |
| | | 20 | | /// response that never arrives faults the waiter with a <see cref="TimeoutException"/> instead of |
| | | 21 | | /// hanging forever. |
| | | 22 | | /// </summary> |
| | | 23 | | public TimeSpan? DefaultTimeout { get; set; } |
| | | 24 | | |
| | | 25 | | /// <summary> |
| | | 26 | | /// How long disposing a waiter may DRAIN an in-flight delivery before abandoning it. Disposal |
| | | 27 | | /// settles the response task only after a dispatch that already claimed a message — an |
| | | 28 | | /// <c>Until</c> predicate running user code — has finished, so a delivered response is never |
| | | 29 | | /// reported as canceled. If that dispatch is still running when this budget lapses, the |
| | | 30 | | /// response task is faulted with <see cref="AsyncResponseIndeterminateDeliveryException"/> |
| | | 31 | | /// rather than canceled: the delivery outcome is unknown, and a cancellation would invite |
| | | 32 | | /// re-attaching to a correlation id whose response may already be consumed (durable flows |
| | | 33 | | /// instead restart the idempotent step fresh). Default: 30 seconds — comfortably above any |
| | | 34 | | /// healthy predicate, well below a stuck one holding host shutdown hostage. |
| | | 35 | | /// </summary> |
| | | 36 | | public TimeSpan DisposalDrainTimeout { get; set; } = TimeSpan.FromSeconds(30); |
| | | 37 | | |
| | | 38 | | /// <summary> |
| | | 39 | | /// The largest delay the BCL's timer plumbing accepts (<c>uint.MaxValue - 1</c> ms, ~49.7 |
| | | 40 | | /// days): CancellationTokenSource timers, <see cref="Task.Delay(TimeSpan)"/>, and |
| | | 41 | | /// <c>Task.WaitAsync</c> all reject longer values at arming — which, for a waiter, is AFTER |
| | | 42 | | /// the subscription and recovery state already exist. Timer-armed values are therefore |
| | | 43 | | /// bounded here and at waiter creation instead. |
| | | 44 | | /// </summary> |
| | | 45 | | internal static readonly TimeSpan MaxTimerBackedTimeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1); |
| | | 46 | | |
| | | 47 | | /// <summary> |
| | | 48 | | /// Upper bound for persisted-TTL knobs (10 years): far beyond any practical retention, small |
| | | 49 | | /// enough that the "now + TTL" expiry stamp every store computes can never overflow |
| | | 50 | | /// <see cref="DateTime"/>/<see cref="DateTimeOffset"/> arithmetic — a |
| | | 51 | | /// <see cref="TimeSpan.MaxValue"/> expiry used to pass validation and then throw |
| | | 52 | | /// <see cref="ArgumentOutOfRangeException"/> at the first recovery-state save. |
| | | 53 | | /// </summary> |
| | | 54 | | internal static readonly TimeSpan MaxPersistenceTtl = TimeSpan.FromDays(3650); |
| | | 55 | | |
| | | 56 | | /// <summary> |
| | | 57 | | /// Guards a RESOLVED per-waiter timeout (explicit, <see cref="DefaultTimeout"/>, or the |
| | | 58 | | /// <see cref="RecoveryStateExpiry"/> fallback) before any subscribe/persist side effect: |
| | | 59 | | /// positive (one rule for every channel — zero and the never-firing -1 ms sentinel included) |
| | | 60 | | /// and under the timer ceiling. |
| | | 61 | | /// </summary> |
| | | 62 | | internal static void EnsureWaiterTimeoutSupported(TimeSpan timeout) |
| | | 63 | | { |
| | | 64 | | if (timeout <= TimeSpan.Zero || timeout > MaxTimerBackedTimeout) |
| | | 65 | | throw new ArgumentOutOfRangeException( |
| | | 66 | | nameof(timeout), |
| | | 67 | | timeout, |
| | | 68 | | $"Waiter timeout must be positive and at most {MaxTimerBackedTimeout.TotalDays:0.#} days (the .NET timer |
| | | 69 | | } |
| | | 70 | | |
| | | 71 | | /// <summary> |
| | | 72 | | /// Validates the shared channel settings, throwing an actionable |
| | | 73 | | /// <see cref="InvalidOperationException"/> on misconfiguration. Concrete channels call this from |
| | | 74 | | /// their own <c>Validate()</c> so every channel fails fast at registration/startup instead of |
| | | 75 | | /// misbehaving at the first wait (a non-positive expiry silently disables recovery; a bad |
| | | 76 | | /// timer-armed timeout otherwise surfaces only after waiter-registration side effects). |
| | | 77 | | /// </summary> |
| | | 78 | | internal void ValidateShared(string optionsName) |
| | | 79 | | { |
| | | 80 | | static string CeilingRule(string optionsName, string knob) |
| | | 81 | | => $"{optionsName}.{knob} must be positive and at most {MaxTimerBackedTimeout.TotalDays:0.#} days (the .NET |
| | | 82 | | |
| | | 83 | | if (RecoveryStateExpiry <= TimeSpan.Zero) |
| | | 84 | | throw new InvalidOperationException($"{optionsName}.{nameof(RecoveryStateExpiry)} must be positive."); |
| | | 85 | | if (RecoveryStateExpiry > MaxPersistenceTtl) |
| | | 86 | | throw new InvalidOperationException( |
| | | 87 | | $"{optionsName}.{nameof(RecoveryStateExpiry)} must be at most {MaxPersistenceTtl.TotalDays:0} days — " + |
| | | 88 | | "expiry stamps are computed as \"now + expiry\", and larger values overflow at the first save."); |
| | | 89 | | |
| | | 90 | | // The ceiling applies to the expiry only in its TIMER-ARMED role — the waiter-timeout |
| | | 91 | | // fallback when DefaultTimeout is not configured. As a pure persistence TTL (with a |
| | | 92 | | // DefaultTimeout set) it may legitimately exceed it: e.g. 90-day recovery retention with |
| | | 93 | | // a 12-hour default timeout. |
| | | 94 | | if (DefaultTimeout is null && RecoveryStateExpiry > MaxTimerBackedTimeout) |
| | | 95 | | throw new InvalidOperationException( |
| | | 96 | | $"{optionsName}.{nameof(RecoveryStateExpiry)} is the waiter-timeout fallback while {nameof(DefaultTimeou |
| | | 97 | | $"not configured, and must then be at most {MaxTimerBackedTimeout.TotalDays:0.#} days (the .NET timer ce |
| | | 98 | | $"Configure {nameof(DefaultTimeout)} to keep a longer recovery retention."); |
| | | 99 | | |
| | | 100 | | if (DefaultTimeout is { } defaultTimeout && (defaultTimeout <= TimeSpan.Zero || defaultTimeout > MaxTimerBackedT |
| | | 101 | | throw new InvalidOperationException(CeilingRule(optionsName, nameof(DefaultTimeout))); |
| | | 102 | | if (DisposalDrainTimeout <= TimeSpan.Zero || DisposalDrainTimeout > MaxTimerBackedTimeout) |
| | | 103 | | throw new InvalidOperationException(CeilingRule(optionsName, nameof(DisposalDrainTimeout))); |
| | | 104 | | } |
| | | 105 | | } |
| | | 106 | | |
| | | 107 | | /// <summary> |
| | | 108 | | /// Shared options for a <em>durable</em> async-response channel that serializes failures onto the |
| | | 109 | | /// wire (Redis, NATS). Adds the remote stack-trace policy on top of |
| | | 110 | | /// <see cref="AsyncResponseChannelOptions"/>. |
| | | 111 | | /// </summary> |
| | | 112 | | public abstract class DurableAsyncResponseChannelOptions : AsyncResponseChannelOptions |
| | | 113 | | { |
| | | 114 | | /// <summary> |
| | | 115 | | /// Whether a failed response envelope carries the remote exception's stack trace on the wire |
| | | 116 | | /// (surfaced to the waiter via <c>Exception.Data["RemoteStackTrace"]</c>). Stack traces aid |
| | | 117 | | /// debugging but can carry file paths; set to <c>false</c> to omit them. Default: <c>true</c>. |
| | | 118 | | /// </summary> |
| | 3 | 119 | | public bool IncludeRemoteStackTrace { get; set; } = true; |
| | | 120 | | |
| | | 121 | | /// <summary> |
| | | 122 | | /// Maximum length, in characters, of a remote stack trace placed on the wire and restored on the |
| | | 123 | | /// waiter side; longer traces are truncated with a marker. Bounds what a buggy or hostile remote |
| | | 124 | | /// can push into logs (a multi-megabyte trace). Applied on both publish and receive. Default: 16384. |
| | | 125 | | /// </summary> |
| | 3 | 126 | | public int MaxRemoteStackTraceLength { get; set; } = 16 * 1024; |
| | | 127 | | } |