| | | 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="ShouldResume"> |
| | | 13 | | /// The recovery route the payload reported (<see cref="IAsyncResponsePayload.ShouldResumeOnRecovery"/>): |
| | | 14 | | /// <c>true</c> resume, <c>false</c> fail, or <c>null</c> when it could not be classified (no recovery |
| | | 15 | | /// state, missing payload type, null payload, conversion failure) — treated as "do not resume". |
| | | 16 | | /// </param> |
| | | 17 | | /// <param name="CallbackInvoked"> |
| | | 18 | | /// <c>true</c> when a callback was invoked successfully — the recovery state is consumed and the |
| | | 19 | | /// caller should delete it. |
| | | 20 | | /// </param> |
| | | 21 | | internal readonly record struct LostSubscriberDispatchResult(bool? ShouldResume, bool CallbackInvoked) |
| | | 22 | | { |
| | | 23 | | /// <summary> |
| | | 24 | | /// <c>true</c> when a live subscriber re-appeared between the caller's empty snapshot and the |
| | | 25 | | /// recovery-state read. No callback was invoked and no state was consumed — the caller should |
| | | 26 | | /// re-snapshot and dispatch live. |
| | | 27 | | /// </summary> |
| | | 28 | | public bool RetryLive { get; init; } |
| | | 29 | | } |
| | | 30 | | |
| | | 31 | | /// <summary> |
| | | 32 | | /// The single decision point of the lost-subscriber fallback: when an async response is published |
| | | 33 | | /// and no subscriber is listening (the original waiter died, e.g. with a redeploy/restart), this |
| | | 34 | | /// dispatcher chooses and invokes the callback persisted in the <see cref="RecoveryState"/>. |
| | | 35 | | /// <para> |
| | | 36 | | /// For payload envelopes (<c>SetResponse</c>) the payload's |
| | | 37 | | /// <see cref="IAsyncResponsePayload.ShouldResumeOnRecovery"/> decides the route: <c>true</c> goes to |
| | | 38 | | /// the resume callback; <c>false</c> (and any unclassifiable payload, conservatively) goes to the |
| | | 39 | | /// failure callback wrapped in an <see cref="AsyncResponseDomainFailureException"/>. For exception |
| | | 40 | | /// envelopes (<c>SetException</c>) the failure callback is always used. |
| | | 41 | | /// </para> |
| | | 42 | | /// <para> |
| | | 43 | | /// The publisher stays a plain transport: it only reports "published, but nobody was listening" and |
| | | 44 | | /// hands over to this dispatcher. This decision is independent of the live waiter's <c>Until</c> |
| | | 45 | | /// predicate, which no longer exists once the waiter is lost. |
| | | 46 | | /// </para> |
| | | 47 | | /// </summary> |
| | 3 | 48 | | internal sealed class LostSubscriberCallbackDispatcher( |
| | 3 | 49 | | IServiceScopeFactory _scopeFactory, |
| | 3 | 50 | | AsyncResponseContextPropagation _propagation, |
| | 3 | 51 | | ILogger _logger) |
| | | 52 | | { |
| | | 53 | | /// <summary> |
| | | 54 | | /// Loads every recovery registration for <paramref name="correlationId"/> and dispatches a lost |
| | | 55 | | /// response to each registration's resume/failure callback. |
| | | 56 | | /// </summary> |
| | | 57 | | public async Task<LostSubscriberDispatchResult> DispatchLostResponses<T>( |
| | | 58 | | IRecoveryStateStore recoveryStateStore, |
| | | 59 | | string correlationId, |
| | | 60 | | T response, |
| | | 61 | | string channel, |
| | | 62 | | CancellationToken cancellationToken, |
| | | 63 | | Func<ValueTask<bool>>? hasLiveSubscriber = null) |
| | | 64 | | { |
| | 3 | 65 | | var recoveryStates = await recoveryStateStore.GetAllAsync(correlationId, cancellationToken).ConfigureAwait(false |
| | | 66 | | |
| | | 67 | | // A waiter registers its subscription before saving its recovery state, so the snapshot |
| | | 68 | | // race has two shapes — and the re-check must run before the empty-state early return: |
| | | 69 | | // a recovery state visible here implies its subscription is visible too, and, inversely, a |
| | | 70 | | // freshly registered subscription may not have saved its state yet, in which case |
| | | 71 | | // recoveryStates is empty precisely because the waiter is about to go live. Either way a |
| | | 72 | | // live subscriber means the "nobody listening" premise was stale — hand the response back |
| | | 73 | | // for live delivery instead of consuming registrations or dropping it unrecoverably. |
| | 3 | 74 | | if (hasLiveSubscriber is not null && await hasLiveSubscriber().ConfigureAwait(false)) |
| | 3 | 75 | | return new LostSubscriberDispatchResult(null, false) { RetryLive = true }; |
| | | 76 | | |
| | 3 | 77 | | if (recoveryStates.Count == 0) |
| | 3 | 78 | | return await DispatchLostResponse(null, response, channel).ConfigureAwait(false); |
| | | 79 | | |
| | 3 | 80 | | var callbackInvoked = false; |
| | 3 | 81 | | bool? shouldResume = null; |
| | 3 | 82 | | var routeSet = false; |
| | 3 | 83 | | var routeMixed = false; |
| | 3 | 84 | | ExceptionDispatchInfo? firstException = null; |
| | | 85 | | |
| | 3 | 86 | | foreach (var recoveryState in recoveryStates) |
| | | 87 | | { |
| | | 88 | | try |
| | | 89 | | { |
| | 3 | 90 | | var result = await DispatchLostResponse(recoveryState, response, channel).ConfigureAwait(false); |
| | 3 | 91 | | if (!routeSet) |
| | | 92 | | { |
| | 3 | 93 | | shouldResume = result.ShouldResume; |
| | 3 | 94 | | routeSet = true; |
| | | 95 | | } |
| | 3 | 96 | | else if (shouldResume != result.ShouldResume) |
| | | 97 | | { |
| | 2 | 98 | | routeMixed = true; |
| | | 99 | | } |
| | | 100 | | |
| | 3 | 101 | | if (!result.CallbackInvoked) |
| | 3 | 102 | | continue; |
| | | 103 | | |
| | 3 | 104 | | callbackInvoked = true; |
| | 3 | 105 | | await recoveryStateStore.TryDeleteAsync(correlationId, recoveryState.RegistrationId, cancellationToken). |
| | 3 | 106 | | } |
| | 3 | 107 | | catch (Exception ex) |
| | | 108 | | { |
| | 3 | 109 | | if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested) |
| | 0 | 110 | | throw; |
| | | 111 | | |
| | | 112 | | // Capture rather than re-throw a bare variable so the original throw site's stack |
| | | 113 | | // trace survives the dispatch to the remaining registrations. |
| | 2 | 114 | | firstException ??= ExceptionDispatchInfo.Capture(ex); |
| | 2 | 115 | | } |
| | 3 | 116 | | } |
| | | 117 | | |
| | 3 | 118 | | firstException?.Throw(); |
| | | 119 | | |
| | 3 | 120 | | return new LostSubscriberDispatchResult(routeMixed ? null : shouldResume, callbackInvoked); |
| | 3 | 121 | | } |
| | | 122 | | |
| | | 123 | | /// <summary> |
| | | 124 | | /// Loads every recovery registration for <paramref name="correlationId"/> and dispatches a lost |
| | | 125 | | /// exception to each registration's failure callback. |
| | | 126 | | /// </summary> |
| | | 127 | | public async Task<LostSubscriberDispatchResult> DispatchLostExceptions( |
| | | 128 | | IRecoveryStateStore recoveryStateStore, |
| | | 129 | | string correlationId, |
| | | 130 | | Exception exception, |
| | | 131 | | string channel, |
| | | 132 | | CancellationToken cancellationToken, |
| | | 133 | | Func<ValueTask<bool>>? hasLiveSubscriber = null) |
| | | 134 | | { |
| | 3 | 135 | | var recoveryStates = await recoveryStateStore.GetAllAsync(correlationId, cancellationToken).ConfigureAwait(false |
| | | 136 | | |
| | | 137 | | // Same snapshot-race re-check as DispatchLostResponses, and for the same reason it must |
| | | 138 | | // precede the empty-state early return: an empty snapshot may mean the waiter registered |
| | | 139 | | // its subscription but has not saved its recovery state yet. A live subscriber means the |
| | | 140 | | // exception should be delivered live instead of consumed here. |
| | 3 | 141 | | if (hasLiveSubscriber is not null && await hasLiveSubscriber().ConfigureAwait(false)) |
| | 3 | 142 | | return new LostSubscriberDispatchResult(false, false) { RetryLive = true }; |
| | | 143 | | |
| | 3 | 144 | | if (recoveryStates.Count == 0) |
| | 3 | 145 | | return new LostSubscriberDispatchResult(false, await DispatchLostException(null, exception, channel).Configu |
| | | 146 | | |
| | 3 | 147 | | var callbackInvoked = false; |
| | 3 | 148 | | ExceptionDispatchInfo? firstException = null; |
| | | 149 | | |
| | 3 | 150 | | foreach (var recoveryState in recoveryStates) |
| | | 151 | | { |
| | | 152 | | try |
| | | 153 | | { |
| | 3 | 154 | | if (!await DispatchLostException(recoveryState, exception, channel).ConfigureAwait(false)) |
| | 3 | 155 | | continue; |
| | | 156 | | |
| | 3 | 157 | | callbackInvoked = true; |
| | 3 | 158 | | await recoveryStateStore.TryDeleteAsync(correlationId, recoveryState.RegistrationId, cancellationToken). |
| | 3 | 159 | | } |
| | 3 | 160 | | catch (Exception ex) |
| | | 161 | | { |
| | 3 | 162 | | if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested) |
| | 0 | 163 | | throw; |
| | | 164 | | |
| | | 165 | | // Capture rather than re-throw a bare variable so the original throw site's stack |
| | | 166 | | // trace survives the dispatch to the remaining registrations. |
| | 2 | 167 | | firstException ??= ExceptionDispatchInfo.Capture(ex); |
| | 2 | 168 | | } |
| | 3 | 169 | | } |
| | | 170 | | |
| | 3 | 171 | | firstException?.Throw(); |
| | | 172 | | |
| | | 173 | | // Exception envelopes always take the failure route, so ShouldResume is fixed at false. |
| | 3 | 174 | | return new LostSubscriberDispatchResult(false, callbackInvoked); |
| | 3 | 175 | | } |
| | | 176 | | |
| | | 177 | | /// <summary>Dispatches a successfully published payload that no subscriber received.</summary> |
| | | 178 | | public async Task<LostSubscriberDispatchResult> DispatchLostResponse<T>(RecoveryState? recoveryState, T response, st |
| | | 179 | | { |
| | 3 | 180 | | using var activity = AsyncResponseDiagnostics.StartActivity( |
| | 3 | 181 | | "asyncresponse.lost_subscriber.dispatch", |
| | 3 | 182 | | correlationId: recoveryState?.CorrelationId); |
| | 3 | 183 | | activity?.SetTag("asyncresponse.lost_subscriber.kind", "response"); |
| | 3 | 184 | | activity?.SetTag("asyncresponse.channel_name", channel); |
| | 3 | 185 | | if (response is not null) |
| | 3 | 186 | | AsyncResponseDiagnostics.SetPayloadType(activity, response.GetType()); |
| | | 187 | | |
| | | 188 | | try |
| | | 189 | | { |
| | | 190 | | // The recovering process has no live Until predicate — the payload itself decides whether |
| | | 191 | | // this late response resumes the flow or fails it. A null (unclassifiable) verdict is |
| | | 192 | | // treated conservatively as "do not resume", so a payload that cannot be understood never |
| | | 193 | | // takes the happy path. |
| | 3 | 194 | | var shouldResume = recoveryState is null |
| | 3 | 195 | | ? (bool?)null |
| | 3 | 196 | | : PayloadRecoveryClassifier.ShouldResume(response, recoveryState.PayloadTypeFullName); |
| | 3 | 197 | | AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, shouldResume); |
| | | 198 | | |
| | 3 | 199 | | if (shouldResume != true) |
| | | 200 | | { |
| | 3 | 201 | | if (recoveryState is null) |
| | | 202 | | { |
| | 3 | 203 | | _logger.LogWarning("No subscribers and no recovery state for channel {Channel}.", channel); |
| | 3 | 204 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", false); |
| | 3 | 205 | | return new LostSubscriberDispatchResult(shouldResume, false); |
| | | 206 | | } |
| | | 207 | | |
| | 3 | 208 | | var invoked = await DispatchToFailureCallback(recoveryState, response, channel, activity).ConfigureAwait |
| | 3 | 209 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", invoked); |
| | 3 | 210 | | return new LostSubscriberDispatchResult(shouldResume, invoked); |
| | | 211 | | } |
| | | 212 | | |
| | | 213 | | // shouldResume == true implies recoveryState is non-null (the verdict is null otherwise). |
| | 3 | 214 | | if (recoveryState!.ResumeCallback == null) |
| | | 215 | | { |
| | 3 | 216 | | _logger.LogWarning("No subscribers for channel {Channel}; no resume callback available.", channel); |
| | 2 | 217 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", false); |
| | 2 | 218 | | return new LostSubscriberDispatchResult(shouldResume, false); |
| | | 219 | | } |
| | | 220 | | |
| | 3 | 221 | | _logger.LogWarning("No subscribers for channel {Channel}; invoking resume callback.", channel); |
| | | 222 | | |
| | 3 | 223 | | var invocation = ReflectionExtensions.ResolveCallback( |
| | 3 | 224 | | recoveryState.ResumeCallback, |
| | 3 | 225 | | payload: response, |
| | 3 | 226 | | exception: null, |
| | 3 | 227 | | correlationId: recoveryState.CorrelationId |
| | 3 | 228 | | ); |
| | | 229 | | |
| | | 230 | | // Deliberately not swallowed: a failing resume propagates to the publisher's caller, |
| | | 231 | | // which can escalate it through SetException to the failure callback (the ingress does |
| | | 232 | | // exactly that). The catch below only marks the activity before rethrowing. |
| | 3 | 233 | | await InvokeAsync(invocation, recoveryState.Context).ConfigureAwait(false); |
| | | 234 | | |
| | 3 | 235 | | _logger.LogInformation("Resume callback invoked for channel {Channel}.", channel); |
| | 3 | 236 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", true); |
| | | 237 | | |
| | 3 | 238 | | return new LostSubscriberDispatchResult(shouldResume, true); |
| | | 239 | | } |
| | 3 | 240 | | catch (Exception ex) |
| | | 241 | | { |
| | 3 | 242 | | AsyncResponseDiagnostics.SetError(activity, ex); |
| | 2 | 243 | | throw; |
| | | 244 | | } |
| | 3 | 245 | | } |
| | | 246 | | |
| | | 247 | | /// <summary>Dispatches an exception envelope that no subscriber received.</summary> |
| | | 248 | | public async Task<bool> DispatchLostException(RecoveryState? recoveryState, Exception exception, string channel) |
| | | 249 | | { |
| | 3 | 250 | | using var activity = AsyncResponseDiagnostics.StartActivity( |
| | 3 | 251 | | "asyncresponse.lost_subscriber.dispatch", |
| | 3 | 252 | | correlationId: recoveryState?.CorrelationId); |
| | 3 | 253 | | activity?.SetTag("asyncresponse.lost_subscriber.kind", "exception"); |
| | 3 | 254 | | activity?.SetTag("asyncresponse.channel_name", channel); |
| | 3 | 255 | | activity?.SetTag("asyncresponse.exception_type", exception.GetType().FullName ?? exception.GetType().Name); |
| | 3 | 256 | | AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, false); |
| | | 257 | | |
| | | 258 | | try |
| | | 259 | | { |
| | 3 | 260 | | if (recoveryState?.FailureCallback == null) |
| | | 261 | | { |
| | 3 | 262 | | _logger.LogWarning("No subscribers for channel {Channel}; no failure callback available.", channel); |
| | 3 | 263 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", false); |
| | 2 | 264 | | return false; |
| | | 265 | | } |
| | | 266 | | |
| | 3 | 267 | | _logger.LogWarning("No subscribers for channel {Channel}; invoking failure callback.", channel); |
| | | 268 | | |
| | 3 | 269 | | var invocation = ReflectionExtensions.ResolveCallback( |
| | 3 | 270 | | recoveryState.FailureCallback, |
| | 3 | 271 | | payload: null, |
| | 3 | 272 | | exception: exception, |
| | 3 | 273 | | correlationId: recoveryState.CorrelationId |
| | 3 | 274 | | ); |
| | | 275 | | |
| | 3 | 276 | | await InvokeAsync(invocation, recoveryState.Context).ConfigureAwait(false); |
| | | 277 | | |
| | 3 | 278 | | _logger.LogInformation("Failure callback invoked for channel {Channel}.", channel); |
| | 3 | 279 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", true); |
| | | 280 | | |
| | 3 | 281 | | return true; |
| | | 282 | | } |
| | 3 | 283 | | catch (Exception ex) |
| | | 284 | | { |
| | 3 | 285 | | AsyncResponseDiagnostics.SetError(activity, ex); |
| | 2 | 286 | | throw; |
| | | 287 | | } |
| | 3 | 288 | | } |
| | | 289 | | |
| | | 290 | | /// <summary> |
| | | 291 | | /// Routes a payload that declined to resume (<see cref="IAsyncResponsePayload.ShouldResumeOnRecovery"/> |
| | | 292 | | /// returned <c>false</c>, or it could not be classified) to the failure callback, wrapped in an |
| | | 293 | | /// <see cref="AsyncResponseDomainFailureException"/> — so it takes the same path as a technical |
| | | 294 | | /// <c>SetException</c>. |
| | | 295 | | /// </summary> |
| | | 296 | | private async Task<bool> DispatchToFailureCallback<T>(RecoveryState recoveryState, T response, string channel, Activ |
| | | 297 | | { |
| | 3 | 298 | | string? payloadJson = null; |
| | | 299 | | try |
| | | 300 | | { |
| | 3 | 301 | | payloadJson = AsyncResponseJson.Serialize(response); |
| | 3 | 302 | | } |
| | 3 | 303 | | catch (Exception) |
| | | 304 | | { |
| | | 305 | | // Ignore serialization failure here; the payload is only attached for diagnostics. |
| | | 306 | | // Under trimmed/AOT deployments this also covers payload types without registered |
| | | 307 | | // JSON metadata — the callback still fires, just without the diagnostic JSON. |
| | 3 | 308 | | } |
| | | 309 | | |
| | 3 | 310 | | if (recoveryState.FailureCallback == null) |
| | | 311 | | { |
| | 3 | 312 | | _logger.LogError("No subscribers for channel {Channel} and the response declined to resume, but no failure c |
| | 2 | 313 | | return false; |
| | | 314 | | } |
| | | 315 | | |
| | 3 | 316 | | _logger.LogWarning("No subscribers for channel {Channel}; response declined to resume, invoking failure callback |
| | | 317 | | |
| | 3 | 318 | | var domainFailure = new AsyncResponseDomainFailureException( |
| | 3 | 319 | | recoveryState.CorrelationId, |
| | 3 | 320 | | recoveryState.PayloadTypeFullName, |
| | 3 | 321 | | payloadJson); |
| | | 322 | | |
| | 3 | 323 | | var invocation = ReflectionExtensions.ResolveCallback( |
| | 3 | 324 | | recoveryState.FailureCallback, |
| | 3 | 325 | | payload: response, |
| | 3 | 326 | | exception: domainFailure, |
| | 3 | 327 | | correlationId: recoveryState.CorrelationId |
| | 3 | 328 | | ); |
| | | 329 | | |
| | | 330 | | try |
| | | 331 | | { |
| | | 332 | | // Bounded in-process retry, mirroring the ingress's transient-fault policy: a failure |
| | | 333 | | // callback is re-invocable by contract (broker redelivery re-invokes it the same way), |
| | | 334 | | // and a one-shot invoke turned a transient dependency blip into a silently dropped |
| | | 335 | | // domain-failure signal that nothing ever revisited (the watchdog is report-only). |
| | 3 | 336 | | await AsyncResponseRetry.ExecuteAsync( |
| | 3 | 337 | | async _ => |
| | 3 | 338 | | { |
| | 3 | 339 | | await InvokeAsync(invocation, recoveryState.Context).ConfigureAwait(false); |
| | 3 | 340 | | return true; |
| | 3 | 341 | | }, |
| | 2 | 342 | | isTransient: static _ => true, |
| | 3 | 343 | | maxAttempts: 4, |
| | 3 | 344 | | baseDelay: TimeSpan.FromMilliseconds(250), |
| | 3 | 345 | | maxDelay: TimeSpan.FromSeconds(2), |
| | 3 | 346 | | CancellationToken.None).ConfigureAwait(false); |
| | | 347 | | |
| | 3 | 348 | | _logger.LogInformation("Failure callback invoked for channel {Channel}.", channel); |
| | | 349 | | |
| | 3 | 350 | | return true; |
| | | 351 | | } |
| | 3 | 352 | | catch (Exception ex) |
| | | 353 | | { |
| | | 354 | | // Deliberately not rethrown once the retries are exhausted — keep the swallow. An |
| | | 355 | | // exception would bubble up to the broker ingress, which reacts with SetException and |
| | | 356 | | // would invoke this same failure callback a second time; routing it to transport |
| | | 357 | | // redelivery instead would hot-loop a permanently-throwing callback on RabbitMQ's |
| | | 358 | | // unbounded default. The domain failure has already been dispatched (and retried |
| | | 359 | | // above), and the kept recovery row is surfaced by the watchdog's staleness report, |
| | | 360 | | // so the drop is operator-visible rather than silent. |
| | 3 | 361 | | AsyncResponseDiagnostics.SetError(activity, ex); |
| | 2 | 362 | | _logger.LogError(ex, "Failure callback failed for channel {Channel}.", channel); |
| | 2 | 363 | | return false; |
| | | 364 | | } |
| | 3 | 365 | | } |
| | | 366 | | |
| | | 367 | | private async Task InvokeAsync(ReflectionInvocationDto invocation, IReadOnlyDictionary<string, string>? context) |
| | | 368 | | { |
| | | 369 | | // The recovery callback may run in a different deployment than the original waiter, so |
| | | 370 | | // restore any ambient context captured at registration before resolving and invoking it. |
| | 3 | 371 | | using var contextScope = _propagation.Restore(context); |
| | 3 | 372 | | await using var serviceScope = _scopeFactory.CreateAsyncScope(); |
| | 3 | 373 | | await serviceScope.ServiceProvider.InvokeAsync(invocation).ConfigureAwait(false); |
| | 3 | 374 | | } |
| | | 375 | | |
| | | 376 | | } |