| | | 1 | | using Microsoft.Extensions.DependencyInjection; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using System.Diagnostics; |
| | | 4 | | using System.Runtime.ExceptionServices; |
| | | 5 | | using System.Text.Json; |
| | | 6 | | |
| | | 7 | | namespace AsyncResponse; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// Result of a lost-subscriber dispatch attempt. |
| | | 11 | | /// </summary> |
| | | 12 | | /// <param name="Action"> |
| | | 13 | | /// The recovery route the payload reported (<see cref="IAsyncResponsePayload.OnRecovery"/>): |
| | | 14 | | /// <see cref="RecoveryAction.Resume"/>, <see cref="RecoveryAction.Fail"/>, |
| | | 15 | | /// <see cref="RecoveryAction.KeepWaiting"/> (non-terminal checkpoint — nothing invoked, the |
| | | 16 | | /// registration stays armed), or <c>null</c> when it could not be classified (no recovery state, |
| | | 17 | | /// missing payload type, null payload, conversion failure) — treated as "do not resume". |
| | | 18 | | /// </param> |
| | | 19 | | /// <param name="CallbackInvoked"> |
| | | 20 | | /// <c>true</c> when a callback was invoked successfully — the recovery state is consumed and the |
| | | 21 | | /// caller should delete it. |
| | | 22 | | /// </param> |
| | | 23 | | internal readonly record struct LostSubscriberDispatchResult(RecoveryAction? Action, bool CallbackInvoked) |
| | | 24 | | { |
| | | 25 | | /// <summary> |
| | | 26 | | /// <c>true</c> when a live subscriber re-appeared between the caller's empty snapshot and the |
| | | 27 | | /// recovery-state read. No callback was invoked and no state was consumed — the caller should |
| | | 28 | | /// re-snapshot and dispatch live. |
| | | 29 | | /// </summary> |
| | | 30 | | public bool RetryLive { get; init; } |
| | | 31 | | |
| | | 32 | | /// <summary> |
| | | 33 | | /// <c>true</c> when shared-correlation registrations legitimately took DIFFERENT routes in |
| | | 34 | | /// this one dispatch (each registration classifies as the payload type IT registered). |
| | | 35 | | /// <see cref="Action"/> stays <c>null</c> — no single action describes the aggregate — but |
| | | 36 | | /// diagnostics report the route as <c>mixed</c> rather than <c>unclassified</c>; each |
| | | 37 | | /// registration's own dispatch activity carries its true route. |
| | | 38 | | /// </summary> |
| | | 39 | | public bool RouteMixed { get; init; } |
| | | 40 | | } |
| | | 41 | | |
| | | 42 | | /// <summary> |
| | | 43 | | /// The single decision point of the lost-subscriber fallback: when an async response is published |
| | | 44 | | /// and no subscriber is listening (the original waiter died, e.g. with a redeploy/restart), this |
| | | 45 | | /// dispatcher chooses and invokes the callback persisted in the <see cref="RecoveryState"/>. |
| | | 46 | | /// <para> |
| | | 47 | | /// For payload envelopes (<c>SetResponse</c>) the payload's |
| | | 48 | | /// <see cref="IAsyncResponsePayload.OnRecovery"/> decides the route: |
| | | 49 | | /// <see cref="RecoveryAction.Resume"/> goes to the resume callback; |
| | | 50 | | /// <see cref="RecoveryAction.Fail"/> (and any unclassifiable payload, conservatively) goes to the |
| | | 51 | | /// failure callback wrapped in an <see cref="AsyncResponseDomainFailureException"/>; and |
| | | 52 | | /// <see cref="RecoveryAction.KeepWaiting"/> — a non-terminal checkpoint — invokes nothing and |
| | | 53 | | /// leaves the registration armed for a later response. For exception envelopes |
| | | 54 | | /// (<c>SetException</c>) the failure callback is always used. |
| | | 55 | | /// </para> |
| | | 56 | | /// <para> |
| | | 57 | | /// The chosen callback receives the <em>materialized</em> payload (the registered payload type), |
| | | 58 | | /// not the raw broker JSON — an <c>object</c>-/interface-/base-typed callback parameter must get |
| | | 59 | | /// the concrete instance, or every type guard in the consuming flow silently fails. |
| | | 60 | | /// </para> |
| | | 61 | | /// <para> |
| | | 62 | | /// The publisher stays a plain transport: it only reports "published, but nobody was listening" and |
| | | 63 | | /// hands over to this dispatcher. This decision is independent of the live waiter's <c>Until</c> |
| | | 64 | | /// predicate, which no longer exists once the waiter is lost. |
| | | 65 | | /// </para> |
| | | 66 | | /// </summary> |
| | 3667 | 67 | | internal sealed class LostSubscriberCallbackDispatcher( |
| | 3667 | 68 | | IServiceScopeFactory _scopeFactory, |
| | 3667 | 69 | | AsyncResponseContextPropagation _propagation, |
| | 3667 | 70 | | ILogger _logger, |
| | 3667 | 71 | | TimeProvider? _timeProvider = null) |
| | | 72 | | { |
| | | 73 | | /// <summary> |
| | | 74 | | /// Loads every recovery registration for <paramref name="correlationId"/> and dispatches a lost |
| | | 75 | | /// response to each registration's resume/failure callback. |
| | | 76 | | /// </summary> |
| | | 77 | | public async Task<LostSubscriberDispatchResult> DispatchLostResponses<T>( |
| | | 78 | | IRecoveryStateStore recoveryStateStore, |
| | | 79 | | string correlationId, |
| | | 80 | | T response, |
| | | 81 | | string channel, |
| | | 82 | | CancellationToken cancellationToken, |
| | | 83 | | Func<ValueTask<bool>>? hasLiveSubscriber = null) |
| | | 84 | | { |
| | 335 | 85 | | var recoveryStates = await recoveryStateStore.GetAllAsync(correlationId, cancellationToken).ConfigureAwait(false |
| | | 86 | | |
| | | 87 | | // A waiter registers its subscription before saving its recovery state, so the snapshot |
| | | 88 | | // race has two shapes — and the re-check must run before the empty-state early return: |
| | | 89 | | // a recovery state visible here implies its subscription is visible too, and, inversely, a |
| | | 90 | | // freshly registered subscription may not have saved its state yet, in which case |
| | | 91 | | // recoveryStates is empty precisely because the waiter is about to go live. Either way a |
| | | 92 | | // live subscriber means the "nobody listening" premise was stale — hand the response back |
| | | 93 | | // for live delivery instead of consuming registrations or dropping it unrecoverably. |
| | 331 | 94 | | if (hasLiveSubscriber is not null && await hasLiveSubscriber().ConfigureAwait(false)) |
| | 38 | 95 | | return new LostSubscriberDispatchResult(null, false) { RetryLive = true }; |
| | | 96 | | |
| | | 97 | | // Classification and callbacks must see the payload exactly as a broker delivery would |
| | | 98 | | // have carried it, whichever process publishes. |
| | 287 | 99 | | var wirePayload = WirePayload(response); |
| | | 100 | | |
| | 287 | 101 | | if (recoveryStates.Count == 0) |
| | 67 | 102 | | return await DispatchLostResponse(null, wirePayload, channel).ConfigureAwait(false); |
| | | 103 | | |
| | 220 | 104 | | var callbackInvoked = false; |
| | 220 | 105 | | RecoveryAction? action = null; |
| | 220 | 106 | | var routeSet = false; |
| | 220 | 107 | | var routeMixed = false; |
| | 220 | 108 | | List<ExceptionDispatchInfo>? failures = null; |
| | | 109 | | |
| | 972 | 110 | | foreach (var recoveryState in recoveryStates) |
| | | 111 | | { |
| | | 112 | | try |
| | | 113 | | { |
| | 266 | 114 | | var result = await DispatchLostResponse(recoveryState, wirePayload, channel).ConfigureAwait(false); |
| | 216 | 115 | | if (!routeSet) |
| | | 116 | | { |
| | 206 | 117 | | action = result.Action; |
| | 206 | 118 | | routeSet = true; |
| | | 119 | | } |
| | 10 | 120 | | else if (action != result.Action) |
| | | 121 | | { |
| | 8 | 122 | | routeMixed = true; |
| | | 123 | | } |
| | | 124 | | |
| | 216 | 125 | | if (!result.CallbackInvoked) |
| | 58 | 126 | | continue; |
| | | 127 | | |
| | 158 | 128 | | callbackInvoked = true; |
| | 158 | 129 | | await DeleteConsumedRegistrationAsync(recoveryStateStore, correlationId, recoveryState.RegistrationId, c |
| | 158 | 130 | | } |
| | 50 | 131 | | catch (Exception ex) |
| | | 132 | | { |
| | 50 | 133 | | if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested) |
| | 0 | 134 | | throw; |
| | | 135 | | |
| | | 136 | | // Capture rather than re-throw a bare variable so the original throw site's stack |
| | | 137 | | // trace survives the dispatch to the remaining registrations. EVERY failure is |
| | | 138 | | // kept: settlement below classifies the whole set, not the first one. |
| | 50 | 139 | | (failures ??= []).Add(ExceptionDispatchInfo.Capture(ex)); |
| | 50 | 140 | | } |
| | 208 | 141 | | } |
| | | 142 | | |
| | 220 | 143 | | if (failures is not null) |
| | | 144 | | { |
| | 34 | 145 | | if (!callbackInvoked) |
| | 14 | 146 | | ThrowUnsettled(failures, correlationId); |
| | | 147 | | |
| | 20 | 148 | | SettleResidualFailures(failures, correlationId, channel, "response"); |
| | | 149 | | } |
| | | 150 | | |
| | 190 | 151 | | return new LostSubscriberDispatchResult(routeMixed ? null : action, callbackInvoked) { RouteMixed = routeMixed } |
| | 295 | 152 | | } |
| | | 153 | | |
| | | 154 | | /// <summary> |
| | | 155 | | /// Loads every recovery registration for <paramref name="correlationId"/> and dispatches a lost |
| | | 156 | | /// exception to each registration's failure callback. |
| | | 157 | | /// </summary> |
| | | 158 | | public async Task<LostSubscriberDispatchResult> DispatchLostExceptions( |
| | | 159 | | IRecoveryStateStore recoveryStateStore, |
| | | 160 | | string correlationId, |
| | | 161 | | Exception exception, |
| | | 162 | | string channel, |
| | | 163 | | CancellationToken cancellationToken, |
| | | 164 | | Func<ValueTask<bool>>? hasLiveSubscriber = null) |
| | | 165 | | { |
| | 88 | 166 | | var recoveryStates = await recoveryStateStore.GetAllAsync(correlationId, cancellationToken).ConfigureAwait(false |
| | | 167 | | |
| | | 168 | | // Same snapshot-race re-check as DispatchLostResponses, and for the same reason it must |
| | | 169 | | // precede the empty-state early return: an empty snapshot may mean the waiter registered |
| | | 170 | | // its subscription but has not saved its recovery state yet. A live subscriber means the |
| | | 171 | | // exception should be delivered live instead of consumed here. |
| | 86 | 172 | | if (hasLiveSubscriber is not null && await hasLiveSubscriber().ConfigureAwait(false)) |
| | 20 | 173 | | return new LostSubscriberDispatchResult(RecoveryAction.Fail, false) { RetryLive = true }; |
| | | 174 | | |
| | 64 | 175 | | if (recoveryStates.Count == 0) |
| | 15 | 176 | | return new LostSubscriberDispatchResult(RecoveryAction.Fail, await DispatchLostException(null, exception, ch |
| | | 177 | | |
| | 49 | 178 | | var callbackInvoked = false; |
| | 49 | 179 | | List<ExceptionDispatchInfo>? failures = null; |
| | | 180 | | |
| | 252 | 181 | | foreach (var recoveryState in recoveryStates) |
| | | 182 | | { |
| | | 183 | | try |
| | | 184 | | { |
| | 77 | 185 | | if (!await DispatchLostException(recoveryState, exception, channel).ConfigureAwait(false)) |
| | 4 | 186 | | continue; |
| | | 187 | | |
| | 43 | 188 | | callbackInvoked = true; |
| | 43 | 189 | | await DeleteConsumedRegistrationAsync(recoveryStateStore, correlationId, recoveryState.RegistrationId, c |
| | 43 | 190 | | } |
| | 30 | 191 | | catch (Exception ex) |
| | | 192 | | { |
| | 30 | 193 | | if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested) |
| | 0 | 194 | | throw; |
| | | 195 | | |
| | | 196 | | // Capture rather than re-throw a bare variable so the original throw site's stack |
| | | 197 | | // trace survives the dispatch to the remaining registrations. EVERY failure is |
| | | 198 | | // kept: settlement below classifies the whole set, not the first one. |
| | 30 | 199 | | (failures ??= []).Add(ExceptionDispatchInfo.Capture(ex)); |
| | 30 | 200 | | } |
| | 73 | 201 | | } |
| | | 202 | | |
| | 49 | 203 | | if (failures is not null) |
| | | 204 | | { |
| | 18 | 205 | | if (!callbackInvoked) |
| | 4 | 206 | | ThrowUnsettled(failures, correlationId); |
| | | 207 | | |
| | 14 | 208 | | SettleResidualFailures(failures, correlationId, channel, "exception"); |
| | | 209 | | } |
| | | 210 | | |
| | | 211 | | // Exception envelopes always take the failure route, so the action is fixed at Fail. |
| | 31 | 212 | | return new LostSubscriberDispatchResult(RecoveryAction.Fail, callbackInvoked); |
| | 66 | 213 | | } |
| | | 214 | | |
| | | 215 | | /// <summary> |
| | | 216 | | /// Rethrows for a fan-out in which NO registration's callback succeeded. A sibling whose |
| | | 217 | | /// failure-callback ladder was already exhausted (<see cref="RecoveryCallbackFailedException"/>) |
| | | 218 | | /// wins whatever its position: the ingress passes that shape through untouched, so the |
| | | 219 | | /// transport redelivers the still-unacknowledged signal to every registration — instead of |
| | | 220 | | /// the ingress burning its own retry ladder on an earlier sibling's failure and then escalating |
| | | 221 | | /// through <c>SetException</c>, which re-invokes the very failure callback that just gave up. |
| | | 222 | | /// Any other transient failure is wrapped for the same redelivery path. Only an entirely |
| | | 223 | | /// deterministic set retains the ingress's retry-then-escalate handling. |
| | | 224 | | /// </summary> |
| | | 225 | | private static void ThrowUnsettled(List<ExceptionDispatchInfo> failures, string correlationId) |
| | | 226 | | { |
| | 72 | 227 | | foreach (var failure in failures) |
| | | 228 | | { |
| | 22 | 229 | | if (failure.SourceException is RecoveryCallbackFailedException) |
| | 8 | 230 | | failure.Throw(); |
| | | 231 | | } |
| | | 232 | | |
| | | 233 | | // No successful sibling does not make a transient resume failure a business failure. |
| | | 234 | | // In particular, RecoverAsync may have checkpointed the response before its wake-up |
| | | 235 | | // publish failed. Escalating through SetException then consumes its registration without |
| | | 236 | | // publishing that wake-up. Preserve the original signal for transport redelivery. |
| | 34 | 237 | | foreach (var failure in failures) |
| | | 238 | | { |
| | 12 | 239 | | if (!IsPermanentCallbackFailure(failure.SourceException)) |
| | 10 | 240 | | throw new RecoveryCallbackFailedException(correlationId, attempts: 1, failure.SourceException); |
| | | 241 | | } |
| | | 242 | | |
| | 0 | 243 | | failures[0].Throw(); |
| | 0 | 244 | | } |
| | | 245 | | |
| | | 246 | | /// <summary> |
| | | 247 | | /// Settles a fan-out dispatch in which at least one registration's callback succeeded (and |
| | | 248 | | /// was consumed) while one or more others failed. Shared-correlation registrations are an |
| | | 249 | | /// expected shape — a worker that died mid-await leaves its registration beside the |
| | | 250 | | /// replacement's — and each carries its own delivery guarantee, so the verdict is taken over |
| | | 251 | | /// the WHOLE set of failures, never the first one alone. |
| | | 252 | | /// <para> |
| | | 253 | | /// A <b>deterministic</b> failure (the target is unauthorized, unresolvable, not registered, |
| | | 254 | | /// or no longer binds) is logged: redelivery cannot fix it and its registration stays for the |
| | | 255 | | /// watchdog to surface. A <b>transient</b> one — any failure that is not deterministic — |
| | | 256 | | /// propagates as <see cref="RecoveryCallbackFailedException"/>, which the ingress passes |
| | | 257 | | /// through untouched (no second retry ladder, no <c>SetException</c> escalation that would |
| | | 258 | | /// invoke the FAILURE callbacks of registrations whose resume merely blipped), so the |
| | | 259 | | /// transport redelivers the terminal signal; the consumed registrations are already deleted, |
| | | 260 | | /// so the redelivery reaches only the registrations that failed. The message is acknowledged |
| | | 261 | | /// only when EVERY failure was deterministic. Before round 39 the verdict was taken from the |
| | | 262 | | /// first failure alone: a deterministic failure first in the set hid a transient sibling |
| | | 263 | | /// behind it, the message was acknowledged, and the transient registration — a valid waiter |
| | | 264 | | /// whose dependency was briefly down — lost the only copy of its payload, with the outcome |
| | | 265 | | /// depending on the order the store returned the registrations in. |
| | | 266 | | /// </para> |
| | | 267 | | /// </summary> |
| | | 268 | | private void SettleResidualFailures(List<ExceptionDispatchInfo> failures, string correlationId, string channel, stri |
| | | 269 | | { |
| | 34 | 270 | | ExceptionDispatchInfo? exhausted = null; |
| | 34 | 271 | | Exception? transient = null; |
| | 184 | 272 | | foreach (var failure in failures) |
| | | 273 | | { |
| | 58 | 274 | | var residual = failure.SourceException; |
| | | 275 | | |
| | | 276 | | // Already the propagating shape (a sibling's failure-callback ladder was exhausted); |
| | | 277 | | // its own ladder logged it. |
| | 58 | 278 | | if (residual is RecoveryCallbackFailedException) |
| | | 279 | | { |
| | 0 | 280 | | exhausted ??= failure; |
| | 0 | 281 | | continue; |
| | | 282 | | } |
| | | 283 | | |
| | 58 | 284 | | if (IsPermanentCallbackFailure(residual)) |
| | | 285 | | { |
| | 28 | 286 | | _logger.LogError( |
| | 28 | 287 | | residual, |
| | 28 | 288 | | "Lost-{Kind} dispatch for correlationId {CorrelationId} on {Channel} failed with a deterministic fau |
| | 28 | 289 | | kind, |
| | 28 | 290 | | correlationId, |
| | 28 | 291 | | channel); |
| | 28 | 292 | | continue; |
| | | 293 | | } |
| | | 294 | | |
| | 30 | 295 | | transient ??= residual; |
| | | 296 | | } |
| | | 297 | | |
| | 34 | 298 | | if (exhausted is not null) |
| | 0 | 299 | | exhausted.Throw(); |
| | | 300 | | |
| | 34 | 301 | | if (transient is null) |
| | | 302 | | { |
| | 4 | 303 | | _logger.LogError( |
| | 4 | 304 | | "Lost-{Kind} dispatch for correlationId {CorrelationId} on {Channel} partially failed with deterministic |
| | 4 | 305 | | kind, |
| | 4 | 306 | | correlationId, |
| | 4 | 307 | | channel); |
| | 4 | 308 | | return; |
| | | 309 | | } |
| | | 310 | | |
| | 30 | 311 | | _logger.LogError( |
| | 30 | 312 | | transient, |
| | 30 | 313 | | "Lost-{Kind} dispatch for correlationId {CorrelationId} on {Channel} partially failed transiently after anot |
| | 30 | 314 | | kind, |
| | 30 | 315 | | correlationId, |
| | 30 | 316 | | channel); |
| | 30 | 317 | | throw new RecoveryCallbackFailedException(correlationId, attempts: 1, transient); |
| | | 318 | | } |
| | | 319 | | |
| | | 320 | | /// <summary>Dispatches a successfully published payload that no subscriber received.</summary> |
| | | 321 | | public async Task<LostSubscriberDispatchResult> DispatchLostResponse<T>(RecoveryState? recoveryState, T response, st |
| | | 322 | | { |
| | 333 | 323 | | using var activity = AsyncResponseDiagnostics.StartActivity( |
| | 333 | 324 | | "asyncresponse.lost_subscriber.dispatch", |
| | 333 | 325 | | correlationId: recoveryState?.CorrelationId); |
| | 333 | 326 | | activity?.SetTag("asyncresponse.lost_subscriber.kind", "response"); |
| | 333 | 327 | | activity?.SetTag("asyncresponse.channel_name", channel); |
| | 333 | 328 | | if (response is not null) |
| | 331 | 329 | | AsyncResponseDiagnostics.SetPayloadType(activity, response.GetType()); |
| | | 330 | | |
| | | 331 | | try |
| | | 332 | | { |
| | | 333 | | // The recovering process has no live Until predicate — the payload itself decides |
| | | 334 | | // whether this late response resumes the flow, fails it, or is a non-terminal |
| | | 335 | | // checkpoint to wait past. Classification also MATERIALIZES the payload as the |
| | | 336 | | // registered type; the chosen callback must receive that instance, never the raw |
| | | 337 | | // broker JSON (an object-typed parameter otherwise gets a JsonElement and every type |
| | | 338 | | // guard in the consuming flow silently fails). A null (unclassifiable) verdict is |
| | | 339 | | // treated conservatively as "do not resume", so a payload that cannot be understood |
| | | 340 | | // never takes the happy path. |
| | | 341 | | // |
| | | 342 | | // A payload type name past the resolution limits is never parsed (it can overflow the |
| | | 343 | | // stack inside the CLR's type-name parser — see IsWithinResolutionLimits), so it |
| | | 344 | | // classifies as unresolvable and takes the same conservative route. Said out loud, |
| | | 345 | | // because unlike a renamed type this is a recovery row nobody's code wrote. |
| | 333 | 346 | | if (recoveryState?.PayloadTypeFullName is { } payloadTypeName |
| | 333 | 347 | | && !AsyncResponseTypeResolution.IsWithinResolutionLimits(payloadTypeName)) |
| | | 348 | | { |
| | 0 | 349 | | _logger.LogError( |
| | 0 | 350 | | "The recovery registration for channel {Channel} names a payload type that is not resolved: {Payload |
| | 0 | 351 | | channel, |
| | 0 | 352 | | AsyncResponseTypeResolution.DescribeForDiagnostics(payloadTypeName)); |
| | | 353 | | } |
| | | 354 | | |
| | 333 | 355 | | var classification = recoveryState is null |
| | 333 | 356 | | ? default |
| | 333 | 357 | | : PayloadRecoveryClassifier.Classify(response, recoveryState.PayloadTypeFullName); |
| | 333 | 358 | | var action = classification.Action; |
| | 333 | 359 | | var callbackPayload = classification.MaterializedPayload ?? (object?)response; |
| | 333 | 360 | | AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, action); |
| | | 361 | | |
| | 333 | 362 | | if (action == RecoveryAction.KeepWaiting) |
| | | 363 | | { |
| | | 364 | | // A non-terminal checkpoint (progress report) with nobody listening: invoking the |
| | | 365 | | // resume callback here would spawn a worker per checkpoint, and the failure route |
| | | 366 | | // would fail a flow that is still running — both consume the registration and |
| | | 367 | | // leave the REAL terminal response with nothing to route against (the flow then |
| | | 368 | | // deadlocks re-attached to a correlation id nothing can answer). Invoke nothing; |
| | | 369 | | // the caller keeps the registration armed, bounded by its TTL and visible to the |
| | | 370 | | // watchdog. |
| | 44 | 371 | | _logger.LogInformation( |
| | 44 | 372 | | "No subscribers for channel {Channel}; payload is a non-terminal checkpoint (KeepWaiting) — recovery |
| | 44 | 373 | | channel); |
| | 44 | 374 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", false); |
| | 44 | 375 | | return new LostSubscriberDispatchResult(action, false); |
| | | 376 | | } |
| | | 377 | | |
| | 289 | 378 | | if (action != RecoveryAction.Resume) |
| | | 379 | | { |
| | 120 | 380 | | if (recoveryState is null) |
| | | 381 | | { |
| | 67 | 382 | | _logger.LogWarning("No subscribers and no recovery state for channel {Channel}.", channel); |
| | 67 | 383 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", false); |
| | 67 | 384 | | return new LostSubscriberDispatchResult(action, false); |
| | | 385 | | } |
| | | 386 | | |
| | 53 | 387 | | var invoked = await DispatchToFailureCallback(recoveryState, callbackPayload, channel, activity).Configu |
| | 47 | 388 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", invoked); |
| | 47 | 389 | | return new LostSubscriberDispatchResult(action, invoked); |
| | | 390 | | } |
| | | 391 | | |
| | | 392 | | // action == Resume implies recoveryState is non-null (the verdict is null otherwise). |
| | 169 | 393 | | if (recoveryState!.ResumeCallback == null) |
| | | 394 | | { |
| | | 395 | | // A resumable response with no resume callback registered: the flow cannot |
| | | 396 | | // proceed, which is exactly what the failure route reports — engage the armed |
| | | 397 | | // failure callback rather than repeatedly discarding the terminal signal until |
| | | 398 | | // the registration's TTL. Mirrors the unclassifiable route's conservatism; a |
| | | 399 | | // registration with NEITHER callback keeps the old warn-and-retain behavior. |
| | 8 | 400 | | if (recoveryState.FailureCallback != null) |
| | | 401 | | { |
| | 4 | 402 | | _logger.LogWarning("No subscribers for channel {Channel}; payload is resumable but no resume callbac |
| | 4 | 403 | | var fallbackInvoked = await DispatchToFailureCallback(recoveryState, callbackPayload, channel, activ |
| | 2 | 404 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", fallbackInvoked); |
| | 2 | 405 | | return new LostSubscriberDispatchResult(action, fallbackInvoked); |
| | | 406 | | } |
| | | 407 | | |
| | 4 | 408 | | _logger.LogWarning("No subscribers for channel {Channel}; no resume callback available.", channel); |
| | 4 | 409 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", false); |
| | 4 | 410 | | return new LostSubscriberDispatchResult(action, false); |
| | | 411 | | } |
| | | 412 | | |
| | 161 | 413 | | _logger.LogWarning("No subscribers for channel {Channel}; invoking resume callback.", channel); |
| | | 414 | | |
| | 161 | 415 | | var invocation = ReflectionExtensions.ResolveCallback( |
| | 161 | 416 | | recoveryState.ResumeCallback, |
| | 161 | 417 | | payload: callbackPayload, |
| | 161 | 418 | | exception: null, |
| | 161 | 419 | | correlationId: recoveryState.CorrelationId |
| | 161 | 420 | | ); |
| | | 421 | | |
| | | 422 | | // The outer fan-out settlement wraps transient failures for transport redelivery, |
| | | 423 | | // including a single failed resume. Infrastructure failure must not become a |
| | | 424 | | // business-failure callback. This catch only marks the activity before rethrowing. |
| | 161 | 425 | | await InvokeAsync(invocation, recoveryState.Context).ConfigureAwait(false); |
| | | 426 | | |
| | 119 | 427 | | _logger.LogInformation("Resume callback invoked for channel {Channel}.", channel); |
| | 119 | 428 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", true); |
| | | 429 | | |
| | 119 | 430 | | return new LostSubscriberDispatchResult(action, true); |
| | | 431 | | } |
| | 50 | 432 | | catch (Exception ex) |
| | | 433 | | { |
| | 50 | 434 | | AsyncResponseDiagnostics.SetError(activity, ex); |
| | 50 | 435 | | throw; |
| | | 436 | | } |
| | 283 | 437 | | } |
| | | 438 | | |
| | | 439 | | /// <summary>Dispatches an exception envelope that no subscriber received.</summary> |
| | | 440 | | public async Task<bool> DispatchLostException(RecoveryState? recoveryState, Exception exception, string channel) |
| | | 441 | | { |
| | 92 | 442 | | using var activity = AsyncResponseDiagnostics.StartActivity( |
| | 92 | 443 | | "asyncresponse.lost_subscriber.dispatch", |
| | 92 | 444 | | correlationId: recoveryState?.CorrelationId); |
| | 92 | 445 | | activity?.SetTag("asyncresponse.lost_subscriber.kind", "exception"); |
| | 92 | 446 | | activity?.SetTag("asyncresponse.channel_name", channel); |
| | 92 | 447 | | activity?.SetTag("asyncresponse.exception_type", exception.GetType().FullName ?? exception.GetType().Name); |
| | 92 | 448 | | AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, RecoveryAction.Fail); |
| | | 449 | | |
| | | 450 | | try |
| | | 451 | | { |
| | 92 | 452 | | if (recoveryState?.FailureCallback == null) |
| | | 453 | | { |
| | 19 | 454 | | _logger.LogWarning("No subscribers for channel {Channel}; no failure callback available.", channel); |
| | 19 | 455 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", false); |
| | 19 | 456 | | return false; |
| | | 457 | | } |
| | | 458 | | |
| | 73 | 459 | | _logger.LogWarning("No subscribers for channel {Channel}; invoking failure callback.", channel); |
| | | 460 | | |
| | 73 | 461 | | var invocation = ReflectionExtensions.ResolveCallback( |
| | 73 | 462 | | recoveryState.FailureCallback, |
| | 73 | 463 | | payload: null, |
| | 73 | 464 | | exception: exception, |
| | 73 | 465 | | correlationId: recoveryState.CorrelationId |
| | 73 | 466 | | ); |
| | | 467 | | |
| | 73 | 468 | | await InvokeAsync(invocation, recoveryState.Context).ConfigureAwait(false); |
| | | 469 | | |
| | 43 | 470 | | _logger.LogInformation("Failure callback invoked for channel {Channel}.", channel); |
| | 43 | 471 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", true); |
| | | 472 | | |
| | 43 | 473 | | return true; |
| | | 474 | | } |
| | 30 | 475 | | catch (Exception ex) |
| | | 476 | | { |
| | 30 | 477 | | AsyncResponseDiagnostics.SetError(activity, ex); |
| | 30 | 478 | | throw; |
| | | 479 | | } |
| | 62 | 480 | | } |
| | | 481 | | |
| | | 482 | | /// <summary> |
| | | 483 | | /// Routes a payload that declined to resume (<see cref="IAsyncResponsePayload.OnRecovery"/> |
| | | 484 | | /// returned <see cref="RecoveryAction.Fail"/>, or it could not be classified) to the failure |
| | | 485 | | /// callback, wrapped in an <see cref="AsyncResponseDomainFailureException"/> — so it takes the |
| | | 486 | | /// same path as a technical <c>SetException</c>. <paramref name="response"/> is the |
| | | 487 | | /// materialized payload when classification succeeded, the raw one otherwise. |
| | | 488 | | /// </summary> |
| | | 489 | | private async Task<bool> DispatchToFailureCallback(RecoveryState recoveryState, object? response, string channel, Ac |
| | | 490 | | { |
| | 57 | 491 | | string? payloadJson = null; |
| | | 492 | | try |
| | | 493 | | { |
| | | 494 | | // The payload arrives as its wire representation (declared-type-normalized JSON or |
| | | 495 | | // raw ingress JSON); reuse it verbatim for diagnostics rather than re-serializing. |
| | 57 | 496 | | payloadJson = response switch |
| | 57 | 497 | | { |
| | 0 | 498 | | string s => s, |
| | 6 | 499 | | JsonElement je => je.GetRawText(), |
| | 2 | 500 | | null => AsyncResponseJson.Serialize(response), |
| | 49 | 501 | | _ => AsyncResponseJson.Serialize(response, response.GetType()) |
| | 57 | 502 | | }; |
| | 55 | 503 | | } |
| | 2 | 504 | | catch (Exception) |
| | | 505 | | { |
| | | 506 | | // Ignore serialization failure here; the payload is only attached for diagnostics. |
| | | 507 | | // Under trimmed/AOT deployments this also covers payload types without registered |
| | | 508 | | // JSON metadata — the callback still fires, just without the diagnostic JSON. |
| | 2 | 509 | | } |
| | | 510 | | |
| | | 511 | | // Size only, never the JSON itself: these run at Error/Warning in production, and the |
| | | 512 | | // payload body is business data (the same rule the ingress applies — no content, not even |
| | | 513 | | // a hash). The full JSON still travels on AsyncResponseDomainFailureException.PayloadJson, |
| | | 514 | | // which deliberately keeps it out of Exception.Message and therefore out of generic |
| | | 515 | | // exception logging. |
| | 57 | 516 | | if (recoveryState.FailureCallback == null) |
| | | 517 | | { |
| | 6 | 518 | | _logger.LogError("No subscribers for channel {Channel} and the response declined to resume, but no failure c |
| | 6 | 519 | | return false; |
| | | 520 | | } |
| | | 521 | | |
| | 51 | 522 | | _logger.LogWarning("No subscribers for channel {Channel}; response declined to resume, invoking failure callback |
| | | 523 | | |
| | | 524 | | // The type name goes into the exception's MESSAGE, which generic exception logging |
| | | 525 | | // sweeps up: bounded and escaped like every other quote of a persisted name. An ordinary |
| | | 526 | | // name passes through unchanged. |
| | 51 | 527 | | var domainFailure = new AsyncResponseDomainFailureException( |
| | 51 | 528 | | recoveryState.CorrelationId, |
| | 51 | 529 | | recoveryState.PayloadTypeFullName is { } registeredTypeName |
| | 51 | 530 | | ? AsyncResponseTypeResolution.DescribeForDiagnostics(registeredTypeName) |
| | 51 | 531 | | : null, |
| | 51 | 532 | | payloadJson); |
| | | 533 | | |
| | 51 | 534 | | var invocation = ReflectionExtensions.ResolveCallback( |
| | 51 | 535 | | recoveryState.FailureCallback, |
| | 51 | 536 | | payload: response, |
| | 51 | 537 | | exception: domainFailure, |
| | 51 | 538 | | correlationId: recoveryState.CorrelationId |
| | 51 | 539 | | ); |
| | | 540 | | |
| | | 541 | | try |
| | | 542 | | { |
| | | 543 | | // Bounded in-process retry, mirroring the ingress's transient-fault policy: a failure |
| | | 544 | | // callback is re-invocable by contract (broker redelivery re-invokes it the same way), |
| | | 545 | | // and a one-shot invoke turned a transient dependency blip into a silently dropped |
| | | 546 | | // domain-failure signal that nothing ever revisited (the watchdog is report-only). |
| | | 547 | | // |
| | | 548 | | // Retry only what a retry can fix. A blanket "everything is transient" spent the full |
| | | 549 | | // ~1.75s backoff ladder on faults that are deterministic by construction — a callback |
| | | 550 | | // whose target is not registered in DI, or whose persisted type/method no longer |
| | | 551 | | // resolves, fails identically on attempt 4 — so a single misconfiguration taxed EVERY |
| | | 552 | | // lost-response dispatch on the publisher's thread and compounded with the transport's |
| | | 553 | | // own redelivery. Those cases now fail fast and loudly on the first attempt; genuine |
| | | 554 | | // dependency blips keep the full ladder. |
| | 51 | 555 | | await AsyncResponseRetry.ExecuteAsync( |
| | 51 | 556 | | async _ => |
| | 51 | 557 | | { |
| | 77 | 558 | | await InvokeAsync(invocation, recoveryState.Context).ConfigureAwait(false); |
| | 39 | 559 | | return true; |
| | 39 | 560 | | }, |
| | 30 | 561 | | isTransient: static ex => !IsPermanentCallbackFailure(ex), |
| | 51 | 562 | | maxAttempts: FailureCallbackAttempts, |
| | 51 | 563 | | baseDelay: TimeSpan.FromMilliseconds(250), |
| | 51 | 564 | | maxDelay: TimeSpan.FromSeconds(2), |
| | 51 | 565 | | CancellationToken.None, |
| | 51 | 566 | | _timeProvider).ConfigureAwait(false); |
| | | 567 | | |
| | 39 | 568 | | _logger.LogInformation("Failure callback invoked for channel {Channel}.", channel); |
| | | 569 | | |
| | 39 | 570 | | return true; |
| | | 571 | | } |
| | 12 | 572 | | catch (Exception ex) |
| | | 573 | | { |
| | 12 | 574 | | AsyncResponseDiagnostics.SetError(activity, ex); |
| | | 575 | | |
| | 12 | 576 | | if (IsPermanentCallbackFailure(ex)) |
| | | 577 | | { |
| | | 578 | | // Deterministic by construction: the same call fails the same way on every |
| | | 579 | | // delivery, so redelivery would only burn the transport's attempts (or hot-loop |
| | | 580 | | // on RabbitMQ's unbounded default). Swallow: the message is acknowledged, the |
| | | 581 | | // kept recovery row is surfaced by the watchdog's staleness report, and the |
| | | 582 | | // error log names the misconfiguration to fix. |
| | 4 | 583 | | _logger.LogError(ex, "Failure callback for channel {Channel} cannot be invoked (deterministic fault); th |
| | 4 | 584 | | return false; |
| | | 585 | | } |
| | | 586 | | |
| | | 587 | | // Transient and exhausted: the response is a TERMINAL signal that, once acknowledged, |
| | | 588 | | // exists nowhere (the recovery row keeps the callback, not the payload; the watchdog |
| | | 589 | | // only reports). Propagate as a dedicated type the ingress passes through untouched — |
| | | 590 | | // no retry (the ladder above already ran) and no SetException escalation (that would |
| | | 591 | | // only re-invoke this same callback) — so the transport keeps the message for its own |
| | | 592 | | // bounded redelivery and dead-letter policy. On RabbitMQ's default MaxDeliveryAttempts |
| | | 593 | | // = 0 that is the documented unlimited requeue any failing handler gets; configure a |
| | | 594 | | // cap there as for worker jobs. |
| | 8 | 595 | | _logger.LogError( |
| | 8 | 596 | | ex, |
| | 8 | 597 | | "Failure callback for channel {Channel} failed on all {Attempts} attempts; the message is left unacknowl |
| | 8 | 598 | | channel, |
| | 8 | 599 | | FailureCallbackAttempts); |
| | 8 | 600 | | throw new RecoveryCallbackFailedException(recoveryState.CorrelationId ?? string.Empty, FailureCallbackAttemp |
| | | 601 | | } |
| | 49 | 602 | | } |
| | | 603 | | |
| | | 604 | | /// <summary>In-process invocations of a failure callback per delivery before the delivery is handed back to the tra |
| | | 605 | | internal const int FailureCallbackAttempts = 4; |
| | | 606 | | |
| | | 607 | | /// <summary> |
| | | 608 | | /// Whether a failed callback invocation is deterministic — the same call will fail the same |
| | | 609 | | /// way on every attempt, so retrying only burns the backoff ladder on the publish path. |
| | | 610 | | /// <para> |
| | | 611 | | /// Narrow on purpose: only faults raised while WIRING UP the call qualify — the target is |
| | | 612 | | /// unauthorized, its persisted type no longer resolves, its service is not registered |
| | | 613 | | /// (<see cref="CallbackTargetUnresolvableException"/>), or its method/arguments no longer bind |
| | | 614 | | /// (<see cref="MissingMethodException"/>, <see cref="TypeLoadException"/>). A failure thrown by |
| | | 615 | | /// the callback BODY is never classified here, whatever its type: a handler that throws |
| | | 616 | | /// <see cref="InvalidOperationException"/> for a transient reason is ordinary application code |
| | | 617 | | /// and keeps the full retry ladder, which is why the marker type exists rather than a plain |
| | | 618 | | /// <c>is InvalidOperationException</c> test. |
| | | 619 | | /// </para> |
| | | 620 | | /// </summary> |
| | | 621 | | private static bool IsPermanentCallbackFailure(Exception exception) |
| | 112 | 622 | | => exception is CallbackTargetUnresolvableException |
| | 112 | 623 | | or MissingMethodException |
| | 112 | 624 | | or MissingMemberException |
| | 112 | 625 | | or TypeLoadException; |
| | | 626 | | |
| | | 627 | | /// <summary> |
| | | 628 | | /// Deletes a registration whose callback has already been invoked successfully. Best-effort by |
| | | 629 | | /// contract: the callback IS the outcome, and a cleanup fault must never be reinterpreted as a |
| | | 630 | | /// failed response — rethrowing here made the ingress retry the whole delivery (re-invoking |
| | | 631 | | /// the callback) and then publish the CLEANUP exception through SetException, invoking the |
| | | 632 | | /// failure callback for a flow whose resume had already succeeded. A failed delete leaves the |
| | | 633 | | /// registration to its TTL and the watchdog; recovery is at-least-once, so a later delivery |
| | | 634 | | /// re-invoking the callback is within contract. |
| | | 635 | | /// </summary> |
| | | 636 | | private async Task DeleteConsumedRegistrationAsync( |
| | | 637 | | IRecoveryStateStore recoveryStateStore, |
| | | 638 | | string correlationId, |
| | | 639 | | Guid registrationId, |
| | | 640 | | CancellationToken cancellationToken) |
| | | 641 | | { |
| | | 642 | | try |
| | | 643 | | { |
| | 201 | 644 | | await recoveryStateStore.TryDeleteAsync(correlationId, registrationId, cancellationToken).ConfigureAwait(fal |
| | 197 | 645 | | } |
| | 4 | 646 | | catch (Exception ex) |
| | | 647 | | { |
| | 4 | 648 | | _logger.LogWarning( |
| | 4 | 649 | | ex, |
| | 4 | 650 | | "Recovery callback for correlationId {CorrelationId} succeeded but deleting its registration {Registrati |
| | 4 | 651 | | correlationId, |
| | 4 | 652 | | registrationId); |
| | 4 | 653 | | } |
| | 201 | 654 | | } |
| | | 655 | | |
| | | 656 | | /// <summary> |
| | | 657 | | /// Normalizes a typed payload to its WIRE representation — serialized as the publisher's |
| | | 658 | | /// DECLARED <typeparamref name="T"/>, exactly as <c>AsyncResponseEnvelope<T></c> writes |
| | | 659 | | /// it. Reusing the live instance leaked state that never crosses the wire |
| | | 660 | | /// (<c>[JsonIgnore]</c>) into recovery routing, and serializing the RUNTIME type dropped the |
| | | 661 | | /// polymorphic discriminators only the declared-type contract emits — either way the verdict |
| | | 662 | | /// depended on which side of a serialization boundary the publisher sat. Raw ingress payloads |
| | | 663 | | /// (<see cref="JsonElement"/> / JSON string) already are wire representations. |
| | | 664 | | /// </summary> |
| | | 665 | | private static object? WirePayload<T>(T response) |
| | | 666 | | { |
| | 287 | 667 | | if (response is null or JsonElement or string) |
| | 158 | 668 | | return response; |
| | | 669 | | |
| | | 670 | | try |
| | | 671 | | { |
| | 129 | 672 | | return AsyncResponseJson.Serialize(response); |
| | | 673 | | } |
| | 2 | 674 | | catch |
| | | 675 | | { |
| | | 676 | | // No wire representation exists (unserializable payload — cycles, unregistered AOT |
| | | 677 | | // metadata). Hand the instance through: the wire-only classifier treats it as |
| | | 678 | | // unclassifiable, so it takes the conservative failure route with the instance |
| | | 679 | | // attached — a payload that could never have crossed the wire never resumes a flow. |
| | 2 | 680 | | return response; |
| | | 681 | | } |
| | 129 | 682 | | } |
| | | 683 | | |
| | | 684 | | private async Task InvokeAsync(ReflectionInvocationDto invocation, IReadOnlyDictionary<string, string>? context) |
| | | 685 | | { |
| | 311 | 686 | | await using var serviceScope = _scopeFactory.CreateAsyncScope(); |
| | | 687 | | |
| | | 688 | | // Authorize first, on the raw persisted descriptor. Both the callback target and the |
| | | 689 | | // context carrier come out of the recovery store, so anyone who can write a row there |
| | | 690 | | // supplies both — and restoring the context first would let the row pick the ambient |
| | | 691 | | // tenant/principal an authorizer consults to decide whether that same row's target may |
| | | 692 | | // run. The scope exists by now only to resolve the authorizer; nothing has been invoked. |
| | 311 | 693 | | ReflectionExtensions.ThrowIfNotAuthorized( |
| | 311 | 694 | | serviceScope.ServiceProvider.GetService<IAsyncResponseCallbackAuthorizer>(), |
| | 311 | 695 | | invocation.ServiceInterfaceFullName, |
| | 311 | 696 | | invocation.MethodName); |
| | | 697 | | |
| | | 698 | | // The recovery callback may run in a different deployment than the original waiter, so |
| | | 699 | | // restore any ambient context captured at registration before resolving and invoking it. |
| | 311 | 700 | | using var contextScope = _propagation.Restore(context); |
| | 311 | 701 | | await serviceScope.ServiceProvider.InvokeAsync(invocation).ConfigureAwait(false); |
| | 201 | 702 | | } |
| | | 703 | | |
| | | 704 | | } |