| | | 1 | | using Microsoft.Extensions.DependencyInjection; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using Microsoft.Extensions.Options; |
| | | 4 | | using StackExchange.Redis; |
| | | 5 | | using System.Buffers; |
| | | 6 | | using System.Collections.Concurrent; |
| | | 7 | | using System.Diagnostics; |
| | | 8 | | using System.Text; |
| | | 9 | | using System.Text.Json; |
| | | 10 | | |
| | | 11 | | namespace AsyncResponse.Channels.Redis; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// Redis-backed response channel: |
| | | 15 | | /// <list type="bullet"> |
| | | 16 | | /// <item><description>Publishes responses to Redis pub/sub channels keyed by correlation id.</description></item> |
| | | 17 | | /// <item><description>Subscribes waiters to those channels with per-channel serialized handling.</description></item> |
| | | 18 | | /// <item><description>Persists <see cref="RecoveryState"/> so responses arriving after the waiter |
| | | 19 | | /// died (e.g. a redeploy) are routed through the lost-subscriber dispatcher, which asks the payload's |
| | | 20 | | /// OnRecovery and invokes the resume or failure callback with the materialized payload |
| | | 21 | | /// (or keeps the registration armed for a checkpoint).</description></item> |
| | | 22 | | /// </list> |
| | | 23 | | /// </summary> |
| | | 24 | | internal sealed class RedisAsyncResponseChannel : IAsyncResponsePublisher, IRawAsyncResponsePublisher, IRecoverableAsync |
| | | 25 | | { |
| | | 26 | | |
| | | 27 | | private readonly ISubscriber _subscriber; |
| | | 28 | | private readonly IRedisChannelSubscriber _channelSubscriber; |
| | | 29 | | private readonly IConnectionMultiplexer _multiplexer; |
| | | 30 | | private readonly IRecoveryStateStore _recoveryStateStore; |
| | | 31 | | private readonly AsyncResponseContextPropagation _propagation; |
| | | 32 | | private readonly LostSubscriberCallbackDispatcher _lostSubscriberDispatcher; |
| | | 33 | | private readonly RedisKeySchema _keys; |
| | | 34 | | private readonly RedisAsyncResponseOptions _options; |
| | | 35 | | private readonly ILogger<RedisAsyncResponseChannel> _logger; |
| | | 36 | | private readonly TimeProvider _timeProvider; |
| | | 37 | | |
| | | 38 | | private readonly SerialExecutorRegistry _executors; |
| | | 39 | | |
| | | 40 | | /// <summary>Creates a Redis-backed async-response channel.</summary> |
| | 552 | 41 | | public RedisAsyncResponseChannel( |
| | 552 | 42 | | IServiceScopeFactory scopeFactory, |
| | 552 | 43 | | IConnectionMultiplexer multiplexer, |
| | 552 | 44 | | IRecoveryStateStore recoveryStateStore, |
| | 552 | 45 | | IOptions<RedisAsyncResponseOptions> options, |
| | 552 | 46 | | AsyncResponseContextPropagation propagation, |
| | 552 | 47 | | ILogger<RedisAsyncResponseChannel> logger, |
| | 552 | 48 | | IRedisChannelSubscriber? channelSubscriber = null, |
| | 552 | 49 | | TimeProvider? timeProvider = null) |
| | | 50 | | { |
| | 552 | 51 | | _timeProvider = timeProvider ?? TimeProvider.System; |
| | 552 | 52 | | _subscriber = multiplexer.GetSubscriber(); |
| | 552 | 53 | | _channelSubscriber = channelSubscriber ?? new RedisChannelMessageQueueSubscriber(_subscriber); |
| | 552 | 54 | | _multiplexer = multiplexer; |
| | 552 | 55 | | _recoveryStateStore = recoveryStateStore; |
| | 552 | 56 | | _propagation = propagation; |
| | 552 | 57 | | _options = options.Value; |
| | 552 | 58 | | _options.Validate(); |
| | 552 | 59 | | _keys = new RedisKeySchema(_options.KeyPrefix); |
| | 552 | 60 | | _logger = logger; |
| | 552 | 61 | | _lostSubscriberDispatcher = new LostSubscriberCallbackDispatcher(scopeFactory, propagation, logger, _timeProvide |
| | 552 | 62 | | _executors = new SerialExecutorRegistry(logger, timeProvider: _timeProvider); |
| | 552 | 63 | | } |
| | | 64 | | |
| | | 65 | | // Executor retirements scheduled off the cleanup path (see CleanupCoreAsync). TRACKED, as |
| | | 66 | | // DbChannelShared does: untracked, a retirement could still be inside its drain budget — a |
| | | 67 | | // user completion predicate mid-flight — when the host tore down the logger and Main |
| | | 68 | | // returned, and the pool thread running it was killed with the predicate's side effects |
| | | 69 | | // half-applied. Keyed by the task itself and self-evicting. |
| | 552 | 70 | | private readonly ConcurrentDictionary<Task, byte> _pendingRetirements = new(); |
| | | 71 | | |
| | | 72 | | private void TrackRetirement(Task retirement) |
| | | 73 | | { |
| | 429 | 74 | | _pendingRetirements[retirement] = 0; |
| | 429 | 75 | | _ = retirement.ContinueWith( |
| | 429 | 76 | | static (completed, state) => ((ConcurrentDictionary<Task, byte>)state!).TryRemove(completed, out _), |
| | 429 | 77 | | _pendingRetirements, |
| | 429 | 78 | | CancellationToken.None, |
| | 429 | 79 | | TaskContinuationOptions.ExecuteSynchronously, |
| | 429 | 80 | | TaskScheduler.Default); |
| | 429 | 81 | | } |
| | | 82 | | |
| | | 83 | | /// <summary> |
| | | 84 | | /// Joins every executor retirement still in flight, so container disposal at host shutdown |
| | | 85 | | /// means "every executor is retired" rather than "every retirement was started". The bodies |
| | | 86 | | /// swallow, so this cannot throw; the drain budgets inside RemoveAsync bound how long it takes. |
| | | 87 | | /// </summary> |
| | | 88 | | public async ValueTask DisposeAsync() |
| | | 89 | | { |
| | 2062 | 90 | | var retirements = _pendingRetirements.Keys.ToArray(); |
| | 2062 | 91 | | if (retirements.Length > 0) |
| | 25 | 92 | | await Task.WhenAll(retirements).ConfigureAwait(false); |
| | 2062 | 93 | | } |
| | | 94 | | |
| | | 95 | | // --------------------------------------------------------------------------------------- |
| | | 96 | | // IAsyncResponseSubscriber / IRecoverableAsyncResponseSubscriber |
| | | 97 | | |
| | | 98 | | /// <inheritdoc/> |
| | | 99 | | public Task<IAsyncResponseWaiter<T>> CreateResponseWaiter<T>( |
| | | 100 | | string correlationId, |
| | | 101 | | Func<T, ValueTask<bool>>? completionPredicate = null, |
| | | 102 | | TimeSpan? timeout = null) where T : IAsyncResponsePayload |
| | 215 | 103 | | => CreateResponseWaiterCore( |
| | 215 | 104 | | correlationId, |
| | 215 | 105 | | resumeCallback: null, |
| | 215 | 106 | | failureCallback: null, |
| | 215 | 107 | | completionPredicate, |
| | 215 | 108 | | timeout); |
| | | 109 | | |
| | | 110 | | /// <inheritdoc/> |
| | | 111 | | public Task<IAsyncResponseWaiter<T>> CreateRecoverableResponseWaiter<T>( |
| | | 112 | | string correlationId, |
| | | 113 | | ReflectionCallDto? resumeCallback = null, |
| | | 114 | | ReflectionCallDto? failureCallback = null, |
| | | 115 | | Func<T, ValueTask<bool>>? completionPredicate = null, |
| | | 116 | | TimeSpan? timeout = null) where T : IAsyncResponsePayload |
| | 226 | 117 | | => CreateResponseWaiterCore( |
| | 226 | 118 | | correlationId, |
| | 226 | 119 | | resumeCallback, |
| | 226 | 120 | | failureCallback, |
| | 226 | 121 | | completionPredicate, |
| | 226 | 122 | | timeout); |
| | | 123 | | |
| | | 124 | | private async Task<IAsyncResponseWaiter<T>> CreateResponseWaiterCore<T>( |
| | | 125 | | string correlationId, |
| | | 126 | | ReflectionCallDto? resumeCallback, |
| | | 127 | | ReflectionCallDto? failureCallback, |
| | | 128 | | Func<T, ValueTask<bool>>? completionPredicate, |
| | | 129 | | TimeSpan? timeout) where T : IAsyncResponsePayload |
| | | 130 | | { |
| | 441 | 131 | | CorrelationIdGuard.ThrowIfUnusable(correlationId); |
| | | 132 | | |
| | | 133 | | // Recovery callbacks only make sense if the payload can say whether a late response should |
| | | 134 | | // resume or fail the flow. On this durable channel that decision is real (it survives a |
| | | 135 | | // redeploy), so require the override rather than letting the conservative default silently |
| | | 136 | | // route every recovered response to the failure callback. The in-memory channel, which |
| | | 137 | | // cannot recover across a process restart, is deliberately not subject to this check. |
| | 435 | 138 | | if ((resumeCallback is not null || failureCallback is not null) |
| | 435 | 139 | | && !AsyncResponsePayloadReflection.OverridesOnRecovery(typeof(T))) |
| | | 140 | | { |
| | 4 | 141 | | throw new InvalidOperationException( |
| | 4 | 142 | | $"Payload type '{typeof(T)}' registers lost-subscriber recovery callbacks on the Redis channel " + |
| | 4 | 143 | | $"but does not override {nameof(IAsyncResponsePayload)}.{nameof(IAsyncResponsePayload.OnRecovery)}(). " |
| | 4 | 144 | | "Override it to declare what each response does to the flow — RecoveryAction.Resume, " + |
| | 4 | 145 | | "RecoveryAction.Fail, or RecoveryAction.KeepWaiting for non-terminal checkpoints; the durable " + |
| | 4 | 146 | | "channel needs this to route a response that arrives after the waiter was lost."); |
| | | 147 | | } |
| | | 148 | | |
| | | 149 | | // default: first envelope completes the wait |
| | 583 | 150 | | completionPredicate ??= _ => new ValueTask<bool>(true); |
| | | 151 | | |
| | | 152 | | // Default timeout aligned with the recovery-state expiry: an infinite wait is never |
| | | 153 | | // meaningful, because once the recovery state expires the correlation id has no recovery |
| | | 154 | | // anyway. Timing out routes the flow through its normal failure handling instead of |
| | | 155 | | // leaving it stuck forever. |
| | 431 | 156 | | timeout ??= _options.DefaultTimeout ?? _options.RecoveryStateExpiry; |
| | | 157 | | // BEFORE any side effect: an unsupported resolved timeout (non-positive, or past the |
| | | 158 | | // ~49.7-day BCL timer ceiling) used to throw only at timer arming — after the |
| | | 159 | | // subscription and recovery state existed, leaking both — and zero used to slip through |
| | | 160 | | // on some channels entirely, insta-timing-out a fully registered waiter. |
| | 431 | 161 | | AsyncResponseChannelOptions.EnsureWaiterTimeoutSupported(timeout.Value); |
| | | 162 | | |
| | | 163 | | // Capture the subscribe-time ExecutionContext so app AsyncLocals (trace, principal, logging |
| | | 164 | | // scope) flow into the message handler, which runs on a foreign Redis subscriber thread. |
| | 429 | 165 | | var capturedContext = ExecutionContext.Capture(); |
| | 429 | 166 | | var channel = _keys.Channel(correlationId); |
| | | 167 | | |
| | 429 | 168 | | var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.wait", correlationId: correlationId); |
| | 429 | 169 | | activity?.SetTag("asyncresponse.channel", "redis"); |
| | 429 | 170 | | AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T)); |
| | 429 | 171 | | activity?.SetTag("asyncresponse.timeout_seconds", timeout.Value.TotalSeconds); |
| | | 172 | | |
| | 429 | 173 | | if (_logger.IsEnabled(LogLevel.Debug)) |
| | 6 | 174 | | _logger.LogDebug("Waiting for response on correlationId {CorrelationId} with timeout {Timeout}.", correlatio |
| | | 175 | | |
| | 429 | 176 | | var subscription = new RedisSubscription<T>( |
| | 429 | 177 | | this, |
| | 429 | 178 | | correlationId, |
| | 429 | 179 | | channel, |
| | 429 | 180 | | registrationId: Guid.NewGuid(), |
| | 429 | 181 | | completionPredicate, |
| | 429 | 182 | | capturedContext, |
| | 429 | 183 | | activity); |
| | 429 | 184 | | var channelName = subscription.ChannelName; |
| | | 185 | | |
| | | 186 | | // Single-use cancellation token implementing the timeout. The timer is armed only |
| | | 187 | | // after subscribe + recovery-state save succeeds, but the callback is registered before |
| | | 188 | | // subscribing so a very fast terminal message can still clean up safely. |
| | 429 | 189 | | subscription.RegisterTimeoutCallback(); |
| | | 190 | | |
| | | 191 | | try |
| | | 192 | | { |
| | | 193 | | // Register the executor channel BEFORE the server-side SUBSCRIBE completes: the |
| | | 194 | | // subscriber attaches its message pump inside SubscribeAsync, so deliveries can start |
| | | 195 | | // before it returns, and on a correlation id reused within the tombstone lifetime the |
| | | 196 | | // registry would silently drop them as retirement stragglers until this registration |
| | | 197 | | // is visible. The subscribe-failure path below retires it again. |
| | 429 | 198 | | _executors.OnSubscriptionRegistered(channelName); |
| | 429 | 199 | | subscription.ExecutorRegistered = true; |
| | 429 | 200 | | subscription.Subscription = await _channelSubscriber.SubscribeAsync(channel, subscription.HandleMessageAsync |
| | 427 | 201 | | if (subscription.CleanupStarted) |
| | | 202 | | { |
| | | 203 | | // A message pumped inside SubscribeAsync completed the waiter and ran cleanup to |
| | | 204 | | // the end before this assignment existed — its unsubscribe saw a null |
| | | 205 | | // subscription and the latched cleanup never re-runs. Compensate here, or the |
| | | 206 | | // server-side subscription outlives the waiter: NUMSUB and publish keep counting |
| | | 207 | | // a live waiter, and lost-subscriber recovery is suppressed for this correlation |
| | | 208 | | // id until process exit. Best-effort: the waiter already holds its response, so a |
| | | 209 | | // teardown fault must not fail the create. |
| | | 210 | | try |
| | | 211 | | { |
| | 0 | 212 | | await subscription.UnsubscribeQuietlyAsync(subscription.Subscription).WaitAsync(_options.DisposalDra |
| | 0 | 213 | | } |
| | 0 | 214 | | catch (Exception ex) |
| | | 215 | | { |
| | 0 | 216 | | _logger.LogError(ex, "Post-registration unsubscribe for channel {Channel} failed; the subscription m |
| | 0 | 217 | | } |
| | | 218 | | } |
| | | 219 | | |
| | 427 | 220 | | var recoveryState = new RecoveryState |
| | 427 | 221 | | { |
| | 427 | 222 | | RegistrationId = subscription.Id, |
| | 427 | 223 | | ResumeCallback = resumeCallback, |
| | 427 | 224 | | FailureCallback = failureCallback, |
| | 427 | 225 | | CorrelationId = correlationId, |
| | 427 | 226 | | PayloadTypeFullName = typeof(T).FullName, |
| | 427 | 227 | | // The engine's clock, not the ambient one. The watchdog judges staleness as |
| | 427 | 228 | | // "utcNow - RegisteredAtUtc" from whichever host scans, so an unsubstitutable |
| | 427 | 229 | | // app-clock stamp made a skewed host's registrations either never age (skew ahead: |
| | 427 | 230 | | // a genuinely stuck flow stays invisible and the health check stays green) or age |
| | 427 | 231 | | // instantly (skew behind: healthy waits page the operator every scan). The DB |
| | 427 | 232 | | // channels stamp the SERVER clock for exactly this reason; this at least puts the |
| | 427 | 233 | | // stamp and the watchdog's "now" on one substitutable clock, and matches the |
| | 427 | 234 | | // ExpiresAtUtc the recovery store writes for the same registration. |
| | 427 | 235 | | RegisteredAtUtc = _timeProvider.GetUtcNow().UtcDateTime, |
| | 427 | 236 | | Context = _propagation.Capture() |
| | 427 | 237 | | }; |
| | 427 | 238 | | await _recoveryStateStore.SaveAsync(correlationId, recoveryState, _options.RecoveryStateExpiry).ConfigureAwa |
| | 425 | 239 | | if (subscription.CleanupStarted) |
| | | 240 | | { |
| | | 241 | | // A terminal delivery started cleanup while this registration was still being |
| | | 242 | | // written: cleanup's delete ran before the save committed, so the save just |
| | | 243 | | // orphaned a callback-armed registration that would resurrect recovery for a wait |
| | | 244 | | // that already reached a terminal state. Compensate with a second delete |
| | | 245 | | // (mirrors the in-memory channel's post-save check). Best-effort: TTL and the |
| | | 246 | | // watchdog back a failed delete. |
| | | 247 | | try |
| | | 248 | | { |
| | 2 | 249 | | await _recoveryStateStore.TryDeleteAsync(correlationId, subscription.Id).ConfigureAwait(false); |
| | 2 | 250 | | } |
| | 0 | 251 | | catch (Exception ex) |
| | | 252 | | { |
| | 0 | 253 | | _logger.LogError(ex, "Post-save recovery-state compensation delete failed for correlationId {Correla |
| | 0 | 254 | | } |
| | | 255 | | } |
| | | 256 | | |
| | 425 | 257 | | _logger.LogDebug("Subscribed to channel {Channel} for correlationId {CorrelationId}.", channelName, correlat |
| | 425 | 258 | | } |
| | 4 | 259 | | catch (Exception ex) when (subscription.ResponseTask.IsCompletedSuccessfully || subscription.ResponseTask.IsFaul |
| | | 260 | | { |
| | | 261 | | // The wait already settled: a delivery completed the waiter while this registration |
| | | 262 | | // step was still in flight (cleanup marks cleanupStarted just after setting the task, |
| | | 263 | | // so the task is the race-free signal), and the step — the recovery-state save — then |
| | | 264 | | // failed. The response in hand outranks the builder's "throw so the trigger never |
| | | 265 | | // fires" contract: rethrowing would discard a delivered response, the exact loss this |
| | | 266 | | // library exists to prevent, and the success path for this same interleaving already |
| | | 267 | | // returns the completed waiter. Cleanup runs on the delivery path, so nothing is |
| | | 268 | | // leaked; a save that still committed is compensated above or expires via TTL, with |
| | | 269 | | // the recovery watchdog behind it. The filter demands an actual settlement (result |
| | | 270 | | // or fault): a canceled task means NO response was delivered — e.g. a future |
| | | 271 | | // channel-wide teardown canceling in-flight registrations — and takes the rethrow |
| | | 272 | | // path below. |
| | 2 | 273 | | _logger.LogWarning(ex, |
| | 2 | 274 | | "Registration step failed after a delivery settled correlationId {CorrelationId}; returning the complete |
| | 2 | 275 | | correlationId); |
| | 2 | 276 | | } |
| | 2 | 277 | | catch (Exception ex) |
| | | 278 | | { |
| | 2 | 279 | | _logger.LogError(ex, "Failed to subscribe to channel {Channel} for correlationId {CorrelationId}.", channelN |
| | 2 | 280 | | AsyncResponseDiagnostics.SetError(activity, "subscribe_failure", ex.Message); |
| | 2 | 281 | | await subscription.DrainThenCleanupAsync().ConfigureAwait(false); |
| | | 282 | | |
| | | 283 | | // Rethrow instead of returning a pre-faulted waiter: the builder's contract is that |
| | | 284 | | // the trigger runs only once the subscription AND recovery state exist. A returned |
| | | 285 | | // waiter would still let the trigger fire the remote operation with no registration |
| | | 286 | | // left to receive (or recover) its response. Cleanup leaves nothing behind and cancels |
| | | 287 | | // the response task rather than faulting it, so no unobserved fault lingers. |
| | 2 | 288 | | throw; |
| | | 289 | | } |
| | | 290 | | |
| | 427 | 291 | | subscription.ArmTimeout(timeout.Value); |
| | | 292 | | |
| | 854 | 293 | | return new RedisAsyncResponseWaiter<T>(subscription.ResponseTask, () => subscription.DrainThenCleanupAsync()); |
| | 427 | 294 | | } |
| | | 295 | | |
| | | 296 | | /// <summary> |
| | | 297 | | /// Per-waiter subscription state and lifecycle. A concrete class rather than closures over the |
| | | 298 | | /// creating method: the message handler and timeout callback live for the whole wait — days |
| | | 299 | | /// for a durable-flow await — and must retain only these fields, not a display class holding |
| | | 300 | | /// every local of the registration scope. The channel-name string is rendered once here; |
| | | 301 | | /// dispatch and cleanup key the executor registry with it instead of re-rendering the |
| | | 302 | | /// <see cref="RedisChannel"/> per message. |
| | | 303 | | /// </summary> |
| | | 304 | | private sealed class RedisSubscription<T> where T : IAsyncResponsePayload |
| | | 305 | | { |
| | | 306 | | private readonly RedisAsyncResponseChannel _owner; |
| | | 307 | | private readonly string _correlationId; |
| | | 308 | | private readonly Func<T, ValueTask<bool>> _completionPredicate; |
| | | 309 | | private readonly ExecutionContext? _capturedContext; |
| | | 310 | | private readonly Activity? _activity; |
| | 429 | 311 | | private readonly TaskCompletionSource<T> _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); |
| | | 312 | | |
| | | 313 | | // Single-use cancellation token implementing the waiter timeout. Clock-injected |
| | | 314 | | // (DbChannelShared parity): CancelAfter on a default CTS is bound to the system clock, |
| | | 315 | | // so a virtual clock could never fire a production-sized waiter timeout on this channel. |
| | | 316 | | private readonly CancellationTokenSource _cancellationTokenSource; |
| | | 317 | | private CancellationTokenRegistration _timeoutRegistration; |
| | | 318 | | |
| | | 319 | | // Ensures unsubscribe, recovery-state delete, timeout disposal, and executor cleanup |
| | | 320 | | // happen once no matter whether completion, timeout, or waiter disposal got there first. |
| | | 321 | | private int _cleanupStarted; |
| | | 322 | | |
| | | 323 | | // Set by the overload fault: every message still queued behind it is skipped unprocessed. |
| | | 324 | | private int _overloaded; |
| | 429 | 325 | | private readonly object _cleanupGate = new(); |
| | | 326 | | private Task? _cleanupTask; |
| | | 327 | | |
| | 429 | 328 | | public RedisSubscription( |
| | 429 | 329 | | RedisAsyncResponseChannel owner, |
| | 429 | 330 | | string correlationId, |
| | 429 | 331 | | RedisChannel channel, |
| | 429 | 332 | | Guid registrationId, |
| | 429 | 333 | | Func<T, ValueTask<bool>> completionPredicate, |
| | 429 | 334 | | ExecutionContext? capturedContext, |
| | 429 | 335 | | Activity? activity) |
| | | 336 | | { |
| | 429 | 337 | | _owner = owner; |
| | 429 | 338 | | _cancellationTokenSource = new CancellationTokenSource(Timeout.InfiniteTimeSpan, owner._timeProvider); |
| | 429 | 339 | | _correlationId = correlationId; |
| | 429 | 340 | | ChannelName = channel.ToString()!; |
| | 429 | 341 | | Id = registrationId; |
| | 429 | 342 | | _completionPredicate = completionPredicate; |
| | 429 | 343 | | _capturedContext = capturedContext; |
| | 429 | 344 | | _activity = activity; |
| | 429 | 345 | | } |
| | | 346 | | |
| | | 347 | | /// <summary>Per-waiter registration id used for recovery-state cleanup.</summary> |
| | 858 | 348 | | public Guid Id { get; } |
| | 4871 | 349 | | public string ChannelName { get; } |
| | 433 | 350 | | public Task<T> ResponseTask => _tcs.Task; |
| | 852 | 351 | | public bool CleanupStarted => Volatile.Read(ref _cleanupStarted) != 0; |
| | 1283 | 352 | | public IRedisChannelSubscription? Subscription { get; set; } |
| | 889 | 353 | | public bool ExecutorRegistered { get; set; } |
| | | 354 | | |
| | | 355 | | /// <summary> |
| | | 356 | | /// Registers the timeout callback on the (not yet armed) token; the creator registers it |
| | | 357 | | /// before subscribing so a very fast terminal message can still clean up safely. |
| | | 358 | | /// </summary> |
| | | 359 | | public void RegisterTimeoutCallback() |
| | 429 | 360 | | => _timeoutRegistration = _cancellationTokenSource.Token.Register( |
| | 439 | 361 | | static state => ((RedisSubscription<T>)state!).OnTimeout(), this); |
| | | 362 | | |
| | | 363 | | /// <summary>Arms the timeout once registration has succeeded; a no-op after cleanup started.</summary> |
| | | 364 | | public void ArmTimeout(TimeSpan timeout) |
| | | 365 | | { |
| | | 366 | | try |
| | | 367 | | { |
| | 427 | 368 | | if (Volatile.Read(ref _cleanupStarted) == 0) |
| | 423 | 369 | | _cancellationTokenSource.CancelAfter(timeout); |
| | 427 | 370 | | } |
| | 0 | 371 | | catch (ObjectDisposedException) |
| | | 372 | | { |
| | | 373 | | // A response completed and cleaned up between the check and CancelAfter. |
| | 0 | 374 | | } |
| | 427 | 375 | | } |
| | | 376 | | |
| | | 377 | | private void OnTimeout() |
| | 10 | 378 | | => _ = Task.Run(async () => |
| | 10 | 379 | | { |
| | 10 | 380 | | try |
| | 10 | 381 | | { |
| | 10 | 382 | | _owner._logger.LogWarning("Timed out waiting for response for correlationId {CorrelationId}.", _corr |
| | 8 | 383 | | AsyncResponseDiagnostics.SetError(_activity, "timeout", $"Timed out waiting for response for correla |
| | 8 | 384 | | AsyncResponseDiagnostics.RecordWaiterTimeout("redis"); |
| | 8 | 385 | | await DrainThenCleanupAsync( |
| | 8 | 386 | | new TimeoutException($"Timed out waiting for response for correlationId {_correlationId}.")) |
| | 8 | 387 | | .ConfigureAwait(false); |
| | 8 | 388 | | } |
| | 2 | 389 | | catch (Exception ex) |
| | 10 | 390 | | { |
| | 10 | 391 | | // Fire-and-forget: nothing awaits this task, so an escaped fault would vanish. |
| | 2 | 392 | | _owner._logger.LogError(ex, "Error handling waiter timeout for correlationId {CorrelationId}.", _cor |
| | 2 | 393 | | } |
| | 20 | 394 | | }); |
| | | 395 | | |
| | | 396 | | /// <summary> |
| | | 397 | | /// Receives pub/sub messages from the async subscription and admits them to the |
| | | 398 | | /// per-channel serial executor WITHOUT waiting for capacity. Redis pub/sub is |
| | | 399 | | /// fire-and-forget: the publisher is never backpressured, and the SDK's |
| | | 400 | | /// <c>ChannelMessageQueue</c> behind this callback is unbounded — so an earlier version |
| | | 401 | | /// that awaited executor admission here did not slow anything down, it only moved the |
| | | 402 | | /// backlog from the bounded executor into that unbounded SDK queue, where a progress-message |
| | | 403 | | /// burst behind a slow <c>Until</c> predicate could grow process memory until failure. The |
| | | 404 | | /// executor's capacity (<see cref="ChannelSerialExecutor.DefaultCapacity"/> messages per |
| | | 405 | | /// correlation id) is now the whole buffer: a message that finds it full faults the wait |
| | | 406 | | /// as indeterminate (<see cref="OnOverloadedAsync"/>) instead of being buffered without |
| | | 407 | | /// bound — and never silently dropped, since a terminal response may be among the queued ones. |
| | | 408 | | /// </summary> |
| | | 409 | | public Task HandleMessageAsync(RedisChannel messageChannel, RedisValue messageValue) |
| | | 410 | | { |
| | | 411 | | // The registry coordinates create/enqueue/retire under one lock, so the message is never |
| | | 412 | | // enqueued onto an executor that is concurrently being torn down (no lost messages) and a |
| | | 413 | | // correlation-id reused mid-drain never produces two live executors for one channel. |
| | 5166 | 414 | | return _owner._executors.TryEnqueue(ChannelName, () => ProcessUnderCapturedContextAsync(messageValue)) switc |
| | 2584 | 415 | | { |
| | 2584 | 416 | | // Suppressed = a tombstoned channel with no registration left: the wait is gone and |
| | 2584 | 417 | | // the message would run against nobody (EnqueueAsync dropped these the same way). |
| | 2584 | 418 | | SerialExecutorRegistry.TryEnqueueOutcome.Accepted or SerialExecutorRegistry.TryEnqueueOutcome.Suppressed |
| | 2582 | 419 | | => Task.CompletedTask, |
| | 2 | 420 | | _ => OnOverloadedAsync() |
| | 2584 | 421 | | }; |
| | | 422 | | } |
| | | 423 | | |
| | | 424 | | /// <summary> |
| | | 425 | | /// The overload outcome: the bounded per-correlation-id buffer is full and the next response |
| | | 426 | | /// cannot be admitted. Faults the wait with the explicit indeterminate contract (a terminal |
| | | 427 | | /// response may be queued or may be the one refused) and tears the subscription down so the |
| | | 428 | | /// flood stops here. Deliberately <see cref="CleanupOnceAsync"/> rather than the drain: the |
| | | 429 | | /// executor is full, and parking on a drain marker would block the subscriber's message |
| | | 430 | | /// loop — exactly the unbounded buffering this refuses. A full executor that is merely |
| | | 431 | | /// mid-retirement means cleanup already settled the task, and the message is a straggler. |
| | | 432 | | /// </summary> |
| | | 433 | | private async Task OnOverloadedAsync() |
| | | 434 | | { |
| | 2 | 435 | | var overload = new AsyncResponseIndeterminateDeliveryException(_correlationId, ChannelSerialExecutor.Default |
| | 2 | 436 | | Interlocked.Exchange(ref _overloaded, 1); |
| | 2 | 437 | | if (!_tcs.TrySetException(overload)) |
| | | 438 | | { |
| | 0 | 439 | | if (_owner._logger.IsEnabled(LogLevel.Debug)) |
| | 0 | 440 | | _owner._logger.LogDebug("Dropped a late message on channel {Channel}: the wait for correlationId {Co |
| | 0 | 441 | | return; |
| | | 442 | | } |
| | | 443 | | |
| | 2 | 444 | | _owner._logger.LogError( |
| | 2 | 445 | | "Wait for correlationId {CorrelationId} is overloaded: {Buffered} responses are queued behind its serial |
| | 2 | 446 | | _correlationId, |
| | 2 | 447 | | ChannelSerialExecutor.DefaultCapacity); |
| | 2 | 448 | | AsyncResponseDiagnostics.SetError(_activity, "overloaded", "The wait's bounded response buffer overflowed.") |
| | 2 | 449 | | AsyncResponseDiagnostics.RecordWaiterOverload("redis"); |
| | 2 | 450 | | await CleanupOnceAsync().ConfigureAwait(false); |
| | 2 | 451 | | } |
| | | 452 | | |
| | | 453 | | /// <summary> |
| | | 454 | | /// Restores the waiter's subscribe-time ExecutionContext (app AsyncLocals: trace, principal, |
| | | 455 | | /// logging scope) plus the correlation id before processing — the Redis subscriber callback |
| | | 456 | | /// runs on a foreign thread-pool thread that never had them. |
| | | 457 | | /// </summary> |
| | | 458 | | private Task ProcessUnderCapturedContextAsync(RedisValue messageValue) |
| | | 459 | | { |
| | | 460 | | async Task ProcessAsync() |
| | | 461 | | { |
| | 2582 | 462 | | using var correlationScope = AsyncResponseContext.PushCorrelationId(_correlationId); |
| | 2582 | 463 | | await ProcessMessageAsync(messageValue).ConfigureAwait(false); |
| | 2582 | 464 | | } |
| | | 465 | | |
| | 2582 | 466 | | if (_capturedContext is null) |
| | 2 | 467 | | return ProcessAsync(); |
| | | 468 | | |
| | 2580 | 469 | | Task? task = null; |
| | 5160 | 470 | | ExecutionContext.Run(_capturedContext, _ => task = ProcessAsync(), null); |
| | 2580 | 471 | | return task!; |
| | | 472 | | } |
| | | 473 | | |
| | | 474 | | /// <summary>Deserializes and handles a single incoming envelope, completes the TCS when terminal.</summary> |
| | | 475 | | private async Task ProcessMessageAsync(RedisValue messageValue) |
| | | 476 | | { |
| | 2582 | 477 | | if (Volatile.Read(ref _overloaded) != 0) |
| | | 478 | | { |
| | | 479 | | // Queued behind the overload fault: the wait is settled as indeterminate and the |
| | | 480 | | // subscription torn down, so running the predicate would spend user code — up to a |
| | | 481 | | // full executor's worth of it — on an outcome that cannot change. Only the overload |
| | | 482 | | // skips: a message admitted ahead of an ordinary terminal settlement still runs, as |
| | | 483 | | // the retirement drain expects. |
| | 2048 | 484 | | if (_owner._logger.IsEnabled(LogLevel.Debug)) |
| | 0 | 485 | | _owner._logger.LogDebug("Dropped a queued message on channel {Channel}: the wait for correlationId { |
| | 2048 | 486 | | return; |
| | | 487 | | } |
| | | 488 | | |
| | 534 | 489 | | _owner._logger.LogDebug("Received message on channel {Channel}.", ChannelName); |
| | | 490 | | |
| | 534 | 491 | | bool finished = false; |
| | | 492 | | try |
| | | 493 | | { |
| | | 494 | | // The delivered value is UTF-8 bytes; deserializing them directly avoids the |
| | | 495 | | // ToString() detour, which paid a payload-sized UTF-16 allocation plus a |
| | | 496 | | // transcode both ways on every message. Through JsonSafety, not the raw reader: |
| | | 497 | | // a parse failure lands in the catch below, which logs it AND hands it to the |
| | | 498 | | // waiter, and the reader's own message quotes the inbound body — property names |
| | | 499 | | // and dictionary keys straight off the wire (docs/security.md, "never logs a |
| | | 500 | | // message body"). Only the size and position survive. |
| | 534 | 501 | | var envelope = JsonSafety.SafeDeserialize((ReadOnlySpan<byte>)(byte[]?)messageValue, AsyncResponseEnvelo |
| | | 502 | | |
| | 526 | 503 | | if (envelope == null) |
| | | 504 | | { |
| | 6 | 505 | | _owner._logger.LogError("Failed to deserialize envelope for correlationId {CorrelationId}.", _correl |
| | | 506 | | |
| | 6 | 507 | | finished = true; |
| | 6 | 508 | | var deserializationError = new JsonException($"Failed to deserialize envelope for correlationId {_co |
| | 6 | 509 | | AsyncResponseDiagnostics.SetError(_activity, "deserialize_failure", deserializationError.Message); |
| | 6 | 510 | | if (!_tcs.TrySetException(deserializationError)) |
| | 2 | 511 | | _owner._logger.LogWarning(deserializationError, "TaskCompletionSource already completed for corr |
| | | 512 | | } |
| | 520 | 513 | | else if (!AsyncResponseEnvelopeSchema.IsReadable(envelope.SchemaVersion)) |
| | | 514 | | { |
| | 6 | 515 | | finished = true; |
| | 6 | 516 | | var schemaError = new InvalidOperationException( |
| | 6 | 517 | | $"Response envelope for correlationId {_correlationId} has schema version {envelope.SchemaVersio |
| | 6 | 518 | | $"which this build does not support (current: {AsyncResponseEnvelopeSchema.Current})."); |
| | 6 | 519 | | AsyncResponseDiagnostics.SetError(_activity, "schema_mismatch", schemaError.Message); |
| | 6 | 520 | | if (!_tcs.TrySetException(schemaError)) |
| | 2 | 521 | | _owner._logger.LogWarning(schemaError, "TaskCompletionSource already completed for correlationId |
| | | 522 | | } |
| | 514 | 523 | | else if (!envelope.Success) |
| | | 524 | | { |
| | 7 | 525 | | finished = true; |
| | 7 | 526 | | var remoteFailure = new Exception(envelope.ExceptionMessage ?? "Unknown error during asynchronous pr |
| | 7 | 527 | | if (!string.IsNullOrEmpty(envelope.ExceptionStackTrace)) |
| | | 528 | | { |
| | | 529 | | // Cap on receive too: the publish-side cap only bounds traces we emit, not what |
| | | 530 | | // a remote we do not control can push at us. |
| | 2 | 531 | | remoteFailure.Data["RemoteStackTrace"] = RemoteStackTrace.Cap(envelope.ExceptionStackTrace, _own |
| | | 532 | | } |
| | | 533 | | |
| | 7 | 534 | | _owner._logger.LogWarning("Received error response for correlationId {CorrelationId}: {ErrorMessage} |
| | 7 | 535 | | AsyncResponseDiagnostics.SetError(_activity, "remote_failure", remoteFailure.Message); |
| | 7 | 536 | | if (!_tcs.TrySetException(remoteFailure)) |
| | 2 | 537 | | _owner._logger.LogWarning(remoteFailure, "TaskCompletionSource already completed for correlation |
| | | 538 | | } |
| | | 539 | | else |
| | | 540 | | { |
| | 507 | 541 | | if (_owner._logger.IsEnabled(LogLevel.Debug)) |
| | 2 | 542 | | _owner._logger.LogDebug("Received response for correlationId {CorrelationId}.", _correlationId); |
| | | 543 | | |
| | 507 | 544 | | finished = await _completionPredicate(envelope.Payload!).ConfigureAwait(false); |
| | | 545 | | |
| | 507 | 546 | | if (finished && !_tcs.TrySetResult(envelope.Payload!)) |
| | 5 | 547 | | _owner._logger.LogWarning("TaskCompletionSource already completed for correlationId {Correlation |
| | | 548 | | } |
| | 526 | 549 | | } |
| | 8 | 550 | | catch (Exception ex) |
| | | 551 | | { |
| | 8 | 552 | | _owner._logger.LogError(ex, "Error processing message on channel {Channel} for correlationId {Correlatio |
| | | 553 | | |
| | 8 | 554 | | finished = true; |
| | 8 | 555 | | AsyncResponseDiagnostics.SetError(_activity, ex); |
| | 8 | 556 | | if (!_tcs.TrySetException(ex)) |
| | 2 | 557 | | _owner._logger.LogWarning(ex, "TaskCompletionSource already completed for correlationId {Correlation |
| | 8 | 558 | | } |
| | | 559 | | finally |
| | | 560 | | { |
| | | 561 | | // Unsubscription also happens on dispose, but doing it immediately after the |
| | | 562 | | // terminal message releases resources sooner. |
| | 534 | 563 | | if (finished) |
| | 413 | 564 | | await CleanupOnceAsync().ConfigureAwait(false); |
| | | 565 | | } |
| | 2582 | 566 | | } |
| | | 567 | | |
| | | 568 | | /// <summary> |
| | | 569 | | /// Task-latched so EVERY caller completes only when the one real cleanup has finished — |
| | | 570 | | /// a fire-once flag alone would let a second caller (a disposing waiter racing the |
| | | 571 | | /// timeout) return before the task was settled. |
| | | 572 | | /// </summary> |
| | | 573 | | public ValueTask CleanupOnceAsync() |
| | | 574 | | { |
| | | 575 | | Task task; |
| | 852 | 576 | | lock (_cleanupGate) |
| | | 577 | | { |
| | 852 | 578 | | task = _cleanupTask ??= CleanupCoreAsync(); |
| | 852 | 579 | | } |
| | | 580 | | |
| | 852 | 581 | | return task.IsCompletedSuccessfully ? ValueTask.CompletedTask : new ValueTask(task); |
| | | 582 | | } |
| | | 583 | | |
| | | 584 | | /// <summary> |
| | | 585 | | /// Dispose-path cleanup: DRAINS the per-channel serial executor before settling. A delivery |
| | | 586 | | /// may be mid <c>Until</c>-predicate holding a claimed terminal message; the marker work |
| | | 587 | | /// item completes only after that in-flight item finished, so by the time cleanup cancels, |
| | | 588 | | /// the task is either settled by the delivery or genuinely undelivered — never a |
| | | 589 | | /// cancellation stealing a consumed response. Must NOT be called from dispatch code (which |
| | | 590 | | /// runs ON the executor): the dispatch-triggered cleanup uses <see cref="CleanupOnceAsync"/> |
| | | 591 | | /// directly, its task already settled. |
| | | 592 | | /// <para> |
| | | 593 | | /// The drain is bounded by <c>DisposalDrainTimeout</c> — one budget covering marker |
| | | 594 | | /// ADMISSION too (a full bounded queue behind a wedged item blocks the enqueue itself). A |
| | | 595 | | /// lapsed budget must not fall back to the cleanup's cancel: the wedged delivery holds a |
| | | 596 | | /// message already consumed from the stream, and "canceled" would tell a re-attaching |
| | | 597 | | /// caller nothing was delivered. It faults the task with the explicit indeterminate |
| | | 598 | | /// contract instead, routing durable flows to a fresh idempotent restart. A |
| | | 599 | | /// tombstone-suppressed enqueue is the opposite case — the retired executor finished |
| | | 600 | | /// everything it ever admitted, so nothing is in flight and the plain cancel is truthful. |
| | | 601 | | /// </para> |
| | | 602 | | /// </summary> |
| | | 603 | | public async ValueTask DrainThenCleanupAsync(Exception? terminalIfUndelivered = null) |
| | | 604 | | { |
| | 437 | 605 | | if (Volatile.Read(ref _cleanupStarted) == 0 && ExecutorRegistered) |
| | | 606 | | { |
| | 31 | 607 | | var drainTimeout = _owner._options.DisposalDrainTimeout; |
| | 31 | 608 | | var drained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); |
| | | 609 | | try |
| | | 610 | | { |
| | 31 | 611 | | using var budget = new CancellationTokenSource(drainTimeout); |
| | 31 | 612 | | var accepted = await _owner._executors.EnqueueAsync(ChannelName, () => |
| | 31 | 613 | | { |
| | 31 | 614 | | drained.TrySetResult(); |
| | 31 | 615 | | return Task.CompletedTask; |
| | 31 | 616 | | }, budget.Token).ConfigureAwait(false); |
| | 31 | 617 | | if (accepted) |
| | 31 | 618 | | await drained.Task.WaitAsync(budget.Token).ConfigureAwait(false); |
| | 30 | 619 | | } |
| | 1 | 620 | | catch (Exception drainEx) |
| | | 621 | | { |
| | | 622 | | // Budget lapse — or an unforeseen drain failure: either way the marker never |
| | | 623 | | // ran, so an in-flight delivery cannot be ruled out (only accepted=false |
| | | 624 | | // proves the executor finished everything). Settlement unproven means the |
| | | 625 | | // cleanup's cancel below would be a false "nothing was delivered" — fault |
| | | 626 | | // with the explicit indeterminate contract instead. A TrySetResult from the |
| | | 627 | | // late-finishing dispatch loses against this and is dropped; its cleanup |
| | | 628 | | // call is a no-op behind the latch. |
| | 1 | 629 | | _owner._logger.LogWarning( |
| | 1 | 630 | | "Disposal drain for correlationId {CorrelationId} did not prove settlement within {DrainTimeout} |
| | 1 | 631 | | _correlationId, drainTimeout); |
| | 1 | 632 | | AsyncResponseDiagnostics.SetError(_activity, "indeterminate_delivery", "Disposal drain did not prove |
| | 1 | 633 | | if (drainEx is not OperationCanceledException) |
| | 0 | 634 | | _owner._logger.LogDebug(drainEx, "Dispatch drain failed for channel {Channel}.", ChannelName); |
| | 1 | 635 | | _tcs.TrySetException(new AsyncResponseIndeterminateDeliveryException(_correlationId, drainTimeout)); |
| | 1 | 636 | | } |
| | 31 | 637 | | } |
| | | 638 | | |
| | | 639 | | // Settle AFTER the drain, never before it. A delivery already inside the per-correlation |
| | | 640 | | // executor may hold a message the claim acked — the publisher was told "delivered", so |
| | | 641 | | // it exists nowhere else. Faulting first let a timeout beat that in-flight delivery and |
| | | 642 | | // report a consumed response as a timeout; TrySet loses here if the delivery won, which |
| | | 643 | | // is the whole point. (A lapsed drain budget has already faulted the task as |
| | | 644 | | // indeterminate above, and TrySet is a no-op behind it.) |
| | 437 | 645 | | if (terminalIfUndelivered is not null) |
| | 8 | 646 | | _tcs.TrySetException(terminalIfUndelivered); |
| | | 647 | | |
| | 437 | 648 | | await CleanupOnceAsync().ConfigureAwait(false); |
| | 437 | 649 | | } |
| | | 650 | | |
| | | 651 | | private async Task CleanupCoreAsync() |
| | | 652 | | { |
| | 429 | 653 | | Interlocked.Exchange(ref _cleanupStarted, 1); |
| | | 654 | | |
| | | 655 | | // A waiter disposed before any terminal signal must not leave ResponseTask pending |
| | | 656 | | // forever for callers that hold it directly — the timeout dies with this cleanup, so |
| | | 657 | | // nothing else could ever complete the task. Cancellation is a no-op after a normal |
| | | 658 | | // completion, timeout, or fault (and after a delivery drained by DrainThenCleanupAsync). |
| | 429 | 659 | | _tcs.TrySetCanceled(); |
| | | 660 | | |
| | | 661 | | try |
| | | 662 | | { |
| | | 663 | | try |
| | | 664 | | { |
| | | 665 | | // Delete the recovery state BEFORE unsubscribing. In the reverse order a publish |
| | | 666 | | // landing in the window sees "no subscriber, state present" and fires a spurious |
| | | 667 | | // recovery callback for a wait that already reached a terminal state. In this |
| | | 668 | | // order the window shows a subscriber that drops the message — a late or duplicate |
| | | 669 | | // terminal message is droppable; a resurrected recovery callback is not. |
| | 429 | 670 | | await _owner._recoveryStateStore.TryDeleteAsync(_correlationId, Id).ConfigureAwait(false); |
| | 427 | 671 | | } |
| | 2 | 672 | | catch (Exception ex) |
| | | 673 | | { |
| | | 674 | | // Best-effort: the state expires on its own, and a transient store failure must |
| | | 675 | | // not skip the unsubscribe and executor teardown below. |
| | 2 | 676 | | _owner._logger.LogError(ex, "Failed to delete recovery state for correlationId {CorrelationId}.", _c |
| | 2 | 677 | | } |
| | | 678 | | |
| | | 679 | | try |
| | | 680 | | { |
| | | 681 | | // Bounded like the drain: this latched core is what a disposing waiter awaits |
| | | 682 | | // when terminal delivery started cleanup first, so an unbudgeted unsubscribe |
| | | 683 | | // would let a wedged client library hold DisposeAsync hostage past |
| | | 684 | | // DisposalDrainTimeout. The quiet wrapper logs its own failure — including |
| | | 685 | | // one that completes AFTER this wait was abandoned, which previously died as |
| | | 686 | | // a TaskScheduler.UnobservedTaskException nobody logged. |
| | 429 | 687 | | if (Subscription is not null) |
| | 427 | 688 | | await UnsubscribeQuietlyAsync(Subscription).WaitAsync(_owner._options.DisposalDrainTimeout).Conf |
| | 429 | 689 | | } |
| | 0 | 690 | | catch (TimeoutException) |
| | | 691 | | { |
| | 0 | 692 | | _owner._logger.LogError( |
| | 0 | 693 | | "Unsubscribe for channel {Channel} did not finish within {DisposalDrainTimeout}; abandoning the |
| | 0 | 694 | | ChannelName, _owner._options.DisposalDrainTimeout); |
| | 0 | 695 | | } |
| | | 696 | | } |
| | | 697 | | finally |
| | | 698 | | { |
| | | 699 | | // Purely local teardown runs no matter which network call above failed — the |
| | | 700 | | // cleanup latch is already set, so anything skipped here would leak until process |
| | | 701 | | // exit. |
| | 429 | 702 | | if (ExecutorRegistered) |
| | 429 | 703 | | _owner._executors.OnSubscriptionRetired(ChannelName); |
| | | 704 | | |
| | | 705 | | // Schedule the disposal on the thread pool; do not await directly to prevent |
| | | 706 | | // deadlocks with work currently running on the executor. Tracked so the channel's |
| | | 707 | | // DisposeAsync can join it at host shutdown. |
| | 429 | 708 | | _owner.TrackRetirement(Task.Run(async () => |
| | 429 | 709 | | { |
| | 429 | 710 | | try |
| | 429 | 711 | | { |
| | 429 | 712 | | await _owner._executors.RemoveAsync(ChannelName).ConfigureAwait(false); |
| | 429 | 713 | | } |
| | 0 | 714 | | catch (Exception ex) |
| | 429 | 715 | | { |
| | 0 | 716 | | _owner._logger.LogError(ex, "Failed to retire the executor for channel {Channel}.", ChannelName) |
| | 0 | 717 | | } |
| | 858 | 718 | | })); |
| | | 719 | | |
| | 429 | 720 | | await _timeoutRegistration.DisposeAsync().ConfigureAwait(false); |
| | 429 | 721 | | _cancellationTokenSource.Dispose(); |
| | 429 | 722 | | _activity?.Dispose(); |
| | | 723 | | } |
| | 429 | 724 | | } |
| | | 725 | | |
| | | 726 | | /// <summary> |
| | | 727 | | /// Never faults: the unsubscribe outcome is logged HERE, so a teardown outliving the |
| | | 728 | | /// bounded wait above still records its failure instead of surfacing as an unobserved |
| | | 729 | | /// task exception. |
| | | 730 | | /// </summary> |
| | | 731 | | public async Task UnsubscribeQuietlyAsync(IRedisChannelSubscription liveSubscription) |
| | | 732 | | { |
| | | 733 | | try |
| | | 734 | | { |
| | 427 | 735 | | await liveSubscription.DisposeAsync().ConfigureAwait(false); |
| | 425 | 736 | | _owner._logger.LogDebug("Unsubscribed from channel {Channel}.", ChannelName); |
| | 425 | 737 | | } |
| | 2 | 738 | | catch (Exception ex) |
| | | 739 | | { |
| | 2 | 740 | | _owner._logger.LogError(ex, "Error during unsubscribe-once for channel {Channel}.", ChannelName); |
| | 2 | 741 | | } |
| | 427 | 742 | | } |
| | | 743 | | } |
| | | 744 | | |
| | | 745 | | // --------------------------------------------------------------------------------------- |
| | | 746 | | // IAsyncResponsePublisher |
| | | 747 | | |
| | | 748 | | /// <inheritdoc/> |
| | | 749 | | public Task SetResponse<T>(T response, string correlationId, CancellationToken cancellationToken = default) where T |
| | 513 | 750 | | => SetResponseCore(response, correlationId, cancellationToken); |
| | | 751 | | |
| | | 752 | | Task IRawAsyncResponsePublisher.SetRawResponse(object? response, string correlationId, CancellationToken cancellatio |
| | 2 | 753 | | => SetResponseCore(response, correlationId, cancellationToken); |
| | | 754 | | |
| | | 755 | | Task IRawAsyncResponsePublisher.SetRawResponseJson(string responseJson, string correlationId, CancellationToken canc |
| | 44 | 756 | | => SetRawResponseJsonCore(responseJson, correlationId, cancellationToken); |
| | | 757 | | |
| | | 758 | | // Intentionally duplicated with SetRawResponseJsonCore: this publish method is a latency hot |
| | | 759 | | // path, and earlier shared helper/delegate refactors regressed throughput in benchmarks. |
| | | 760 | | // Keep the typed Redis path inline unless a benchmark run proves a refactor is free. |
| | | 761 | | /// <summary> |
| | | 762 | | /// Retires the correlation id's serial executor after a recovery-routed publish, bounded by |
| | | 763 | | /// <c>DisposalDrainTimeout</c>. The registry's own removal joins the executor's retirement, |
| | | 764 | | /// and that retirement can be draining a work item wedged in a user <c>Until</c> predicate — |
| | | 765 | | /// so an unbounded join here stalled the ingress consumer thread per late/duplicate response |
| | | 766 | | /// for the registry's 30 s + 30 s defaults, with no configured budget applying. The |
| | | 767 | | /// retirement itself continues in the background once the wait lapses. |
| | | 768 | | /// </summary> |
| | | 769 | | private async ValueTask RetireExecutorBoundedAsync(string channel) |
| | | 770 | | { |
| | | 771 | | try |
| | | 772 | | { |
| | 59 | 773 | | await _executors.RemoveAsync(channel).AsTask().WaitAsync(_options.DisposalDrainTimeout).ConfigureAwait(false |
| | 53 | 774 | | } |
| | 6 | 775 | | catch (TimeoutException) |
| | | 776 | | { |
| | 6 | 777 | | _logger.LogWarning( |
| | 6 | 778 | | "Retiring the serial executor for channel {Channel} did not complete within DisposalDrainTimeout ({Dispo |
| | 6 | 779 | | channel, |
| | 6 | 780 | | _options.DisposalDrainTimeout); |
| | 6 | 781 | | } |
| | 59 | 782 | | } |
| | | 783 | | |
| | | 784 | | private async Task SetResponseCore<T>(T response, string correlationId, CancellationToken cancellationToken) |
| | | 785 | | { |
| | 515 | 786 | | using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_response", ActivityKind.Producer) |
| | 515 | 787 | | activity?.SetTag("asyncresponse.channel", "redis"); |
| | 515 | 788 | | AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T)); |
| | | 789 | | |
| | | 790 | | // When no correlation id is provided, fall back to the ambient context. |
| | 515 | 791 | | AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId); |
| | | 792 | | |
| | 515 | 793 | | if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the response")) |
| | 4 | 794 | | return; |
| | | 795 | | |
| | 508 | 796 | | var channel = _keys.Channel(correlationId); |
| | | 797 | | try |
| | | 798 | | { |
| | 508 | 799 | | var envelope = new AsyncResponseEnvelope<T> |
| | 508 | 800 | | { |
| | 508 | 801 | | Success = true, |
| | 508 | 802 | | Payload = response |
| | 508 | 803 | | }; |
| | 508 | 804 | | var json = AsyncResponseEnvelopeJson.Serialize(envelope); |
| | 508 | 805 | | long numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false); |
| | 506 | 806 | | activity?.SetTag("asyncresponse.subscribers", numSubscribers); |
| | | 807 | | |
| | 506 | 808 | | if (numSubscribers == 0) |
| | | 809 | | { |
| | | 810 | | // Nobody was listening (the waiter died, e.g. with a redeploy): hand the response |
| | | 811 | | // over to the lost-subscriber dispatcher, which asks the payload whether to resume |
| | | 812 | | // the flow or fail it, and invokes the matching callback. |
| | 31 | 813 | | var dispatchResult = await _lostSubscriberDispatcher |
| | 31 | 814 | | .DispatchLostResponses( |
| | 31 | 815 | | _recoveryStateStore, |
| | 31 | 816 | | correlationId, |
| | 31 | 817 | | response, |
| | 31 | 818 | | channel.ToString()!, |
| | 31 | 819 | | cancellationToken, |
| | 31 | 820 | | hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken)) |
| | 31 | 821 | | .ConfigureAwait(false); |
| | 25 | 822 | | if (dispatchResult.RetryLive) |
| | | 823 | | { |
| | | 824 | | // A waiter subscribed between the publish and the recovery-state read — |
| | | 825 | | // re-publish live instead of consuming its registration; only a second miss |
| | | 826 | | // consumes it. |
| | 6 | 827 | | numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false); |
| | 6 | 828 | | activity?.SetTag("asyncresponse.subscribers", numSubscribers); |
| | 6 | 829 | | if (numSubscribers > 0) |
| | 2 | 830 | | return; |
| | | 831 | | |
| | 4 | 832 | | dispatchResult = await _lostSubscriberDispatcher |
| | 4 | 833 | | .DispatchLostResponses( |
| | 4 | 834 | | _recoveryStateStore, |
| | 4 | 835 | | correlationId, |
| | 4 | 836 | | response, |
| | 4 | 837 | | channel.ToString()!, |
| | 4 | 838 | | cancellationToken, |
| | 4 | 839 | | hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken)) |
| | 4 | 840 | | .ConfigureAwait(false); |
| | 4 | 841 | | if (dispatchResult.RetryLive) |
| | | 842 | | { |
| | | 843 | | // Second contradiction: delivery keeps reporting no responders while the |
| | | 844 | | // probe keeps reporting a live subscriber (interest not yet visible |
| | | 845 | | // server-side, or a stale heartbeat). Consuming registrations on this |
| | | 846 | | // evidence would strip a live waiter of its recovery arm — leave all state |
| | | 847 | | // intact and surface the non-delivery to the caller, whose retry/redelivery |
| | | 848 | | // machinery re-attempts once the subscription is visible (bounded by the |
| | | 849 | | // heartbeat's liveness expiry, after which normal recovery takes over). |
| | | 850 | | // Returning here instead would silently drop the payload. |
| | 2 | 851 | | _logger.LogWarning( |
| | 2 | 852 | | "Delivery for correlationId {CorrelationId} found no subscribers twice while the liveness pr |
| | 2 | 853 | | correlationId); |
| | 2 | 854 | | activity?.SetTag("asyncresponse.recovery.liveness_contradiction", true); |
| | 2 | 855 | | throw new InvalidOperationException( |
| | 2 | 856 | | $"Redis delivery for correlationId '{correlationId}' found no subscribers twice while the li |
| | 2 | 857 | | "reporting one; the payload was not delivered and recovery registrations were left intact. R |
| | 2 | 858 | | "once the waiter's subscription is visible to the publishing endpoint."); |
| | | 859 | | } |
| | | 860 | | } |
| | | 861 | | |
| | 21 | 862 | | AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.Action, dispatchResult.RouteMix |
| | 21 | 863 | | AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.Action, dispatchResult.Callback |
| | 21 | 864 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked); |
| | | 865 | | |
| | 21 | 866 | | await RetireExecutorBoundedAsync(channel.ToString()!).ConfigureAwait(false); |
| | | 867 | | } |
| | | 868 | | else |
| | | 869 | | { |
| | 475 | 870 | | if (_logger.IsEnabled(LogLevel.Debug)) |
| | 4 | 871 | | _logger.LogDebug("Published response for correlationId {CorrelationId} on channel {Channel}. Payload |
| | | 872 | | } |
| | 496 | 873 | | } |
| | 10 | 874 | | catch (Exception ex) |
| | | 875 | | { |
| | 10 | 876 | | _logger.LogError(ex, "Failed to publish response for correlationId {CorrelationId} on channel {Channel}.", c |
| | 10 | 877 | | AsyncResponseDiagnostics.SetError(activity, ex); |
| | 10 | 878 | | throw; |
| | | 879 | | } |
| | 502 | 880 | | } |
| | | 881 | | |
| | | 882 | | // Intentionally duplicated with SetResponseCore: raw ingress uses pre-serialized payload JSON |
| | | 883 | | // and a different lost-subscriber materialization path, so avoiding shared indirection matters. |
| | | 884 | | private async Task SetRawResponseJsonCore(string responseJson, string correlationId, CancellationToken cancellationT |
| | | 885 | | { |
| | 44 | 886 | | using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.ingress.raw_response", ActivityKind.P |
| | 44 | 887 | | activity?.SetTag("asyncresponse.channel", "redis"); |
| | | 888 | | |
| | 44 | 889 | | AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId); |
| | | 890 | | |
| | 44 | 891 | | if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the raw response", dropContractViolati |
| | 4 | 892 | | return; |
| | | 893 | | |
| | 40 | 894 | | var channel = _keys.Channel(correlationId); |
| | | 895 | | try |
| | | 896 | | { |
| | 40 | 897 | | var json = SerializeRawSuccessEnvelope(responseJson); |
| | 38 | 898 | | long numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false); |
| | 36 | 899 | | activity?.SetTag("asyncresponse.subscribers", numSubscribers); |
| | | 900 | | |
| | 36 | 901 | | if (numSubscribers == 0) |
| | | 902 | | { |
| | 31 | 903 | | var response = new RawJsonResponse(responseJson).DeserializeUntyped(); |
| | | 904 | | |
| | 31 | 905 | | var dispatchResult = await _lostSubscriberDispatcher |
| | 31 | 906 | | .DispatchLostResponses( |
| | 31 | 907 | | _recoveryStateStore, |
| | 31 | 908 | | correlationId, |
| | 31 | 909 | | response, |
| | 31 | 910 | | channel.ToString()!, |
| | 31 | 911 | | cancellationToken, |
| | 31 | 912 | | hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken)) |
| | 31 | 913 | | .ConfigureAwait(false); |
| | 29 | 914 | | if (dispatchResult.RetryLive) |
| | | 915 | | { |
| | | 916 | | // A waiter subscribed between the publish and the recovery-state read — |
| | | 917 | | // re-publish live instead of consuming its registration; only a second miss |
| | | 918 | | // consumes it. |
| | 6 | 919 | | numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false); |
| | 6 | 920 | | activity?.SetTag("asyncresponse.subscribers", numSubscribers); |
| | 6 | 921 | | if (numSubscribers > 0) |
| | 2 | 922 | | return; |
| | | 923 | | |
| | 4 | 924 | | dispatchResult = await _lostSubscriberDispatcher |
| | 4 | 925 | | .DispatchLostResponses( |
| | 4 | 926 | | _recoveryStateStore, |
| | 4 | 927 | | correlationId, |
| | 4 | 928 | | response, |
| | 4 | 929 | | channel.ToString()!, |
| | 4 | 930 | | cancellationToken, |
| | 4 | 931 | | hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken)) |
| | 4 | 932 | | .ConfigureAwait(false); |
| | 4 | 933 | | if (dispatchResult.RetryLive) |
| | | 934 | | { |
| | | 935 | | // Second contradiction: delivery keeps reporting no responders while the |
| | | 936 | | // probe keeps reporting a live subscriber (interest not yet visible |
| | | 937 | | // server-side, or a stale heartbeat). Consuming registrations on this |
| | | 938 | | // evidence would strip a live waiter of its recovery arm — leave all state |
| | | 939 | | // intact and surface the non-delivery to the caller, whose retry/redelivery |
| | | 940 | | // machinery re-attempts once the subscription is visible (bounded by the |
| | | 941 | | // heartbeat's liveness expiry, after which normal recovery takes over). |
| | | 942 | | // Returning here instead would silently drop the payload. |
| | 2 | 943 | | _logger.LogWarning( |
| | 2 | 944 | | "Delivery for correlationId {CorrelationId} found no subscribers twice while the liveness pr |
| | 2 | 945 | | correlationId); |
| | 2 | 946 | | activity?.SetTag("asyncresponse.recovery.liveness_contradiction", true); |
| | 2 | 947 | | throw new InvalidOperationException( |
| | 2 | 948 | | $"Redis delivery for correlationId '{correlationId}' found no subscribers twice while the li |
| | 2 | 949 | | "reporting one; the payload was not delivered and recovery registrations were left intact. R |
| | 2 | 950 | | "once the waiter's subscription is visible to the publishing endpoint."); |
| | | 951 | | } |
| | | 952 | | } |
| | | 953 | | |
| | 25 | 954 | | AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.Action, dispatchResult.RouteMix |
| | 25 | 955 | | AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.Action, dispatchResult.Callback |
| | 25 | 956 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked); |
| | | 957 | | |
| | 25 | 958 | | await RetireExecutorBoundedAsync(channel.ToString()!).ConfigureAwait(false); |
| | 25 | 959 | | } |
| | | 960 | | else |
| | | 961 | | { |
| | 5 | 962 | | if (_logger.IsEnabled(LogLevel.Debug)) |
| | 2 | 963 | | _logger.LogDebug("Published raw response for correlationId {CorrelationId} on channel {Channel}. Sub |
| | | 964 | | } |
| | 30 | 965 | | } |
| | 8 | 966 | | catch (Exception ex) |
| | | 967 | | { |
| | 8 | 968 | | _logger.LogError(ex, "Failed to publish raw response for correlationId {CorrelationId} on channel {Channel}. |
| | 8 | 969 | | AsyncResponseDiagnostics.SetError(activity, ex); |
| | 8 | 970 | | throw; |
| | | 971 | | } |
| | 36 | 972 | | } |
| | | 973 | | |
| | | 974 | | /// <inheritdoc/> |
| | | 975 | | public async Task SetException(Exception exception, string correlationId, CancellationToken cancellationToken = defa |
| | | 976 | | { |
| | 44 | 977 | | ArgumentNullException.ThrowIfNull(exception); |
| | | 978 | | |
| | 42 | 979 | | using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_exception", ActivityKind.Producer |
| | 42 | 980 | | activity?.SetTag("asyncresponse.channel", "redis"); |
| | 42 | 981 | | activity?.SetTag("asyncresponse.exception_type", exception.GetType().FullName ?? exception.GetType().Name); |
| | | 982 | | |
| | 42 | 983 | | AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId); |
| | | 984 | | |
| | 42 | 985 | | if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the exception", exception)) |
| | 3 | 986 | | return; |
| | | 987 | | |
| | 38 | 988 | | var channel = _keys.Channel(correlationId); |
| | | 989 | | try |
| | | 990 | | { |
| | 38 | 991 | | var envelope = new AsyncResponseEnvelope<object> |
| | 38 | 992 | | { |
| | 38 | 993 | | Success = false, |
| | 38 | 994 | | ExceptionMessage = exception.Message, |
| | 38 | 995 | | ExceptionStackTrace = RemoteStackTrace.ForWire(exception.StackTrace, _options.IncludeRemoteStackTrace, _ |
| | 38 | 996 | | Payload = null |
| | 38 | 997 | | }; |
| | 38 | 998 | | var json = AsyncResponseEnvelopeJson.Serialize(envelope); |
| | 38 | 999 | | long numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false); |
| | 36 | 1000 | | activity?.SetTag("asyncresponse.subscribers", numSubscribers); |
| | | 1001 | | |
| | 36 | 1002 | | if (numSubscribers == 0) |
| | | 1003 | | { |
| | | 1004 | | // Nobody was listening: exception envelopes always go to the failure callback. |
| | 23 | 1005 | | var dispatchResult = await _lostSubscriberDispatcher |
| | 23 | 1006 | | .DispatchLostExceptions( |
| | 23 | 1007 | | _recoveryStateStore, |
| | 23 | 1008 | | correlationId, |
| | 23 | 1009 | | exception, |
| | 23 | 1010 | | channel.ToString()!, |
| | 23 | 1011 | | cancellationToken, |
| | 23 | 1012 | | hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken)) |
| | 23 | 1013 | | .ConfigureAwait(false); |
| | 17 | 1014 | | if (dispatchResult.RetryLive) |
| | | 1015 | | { |
| | | 1016 | | // A waiter subscribed between the publish and the recovery-state read — |
| | | 1017 | | // re-publish live instead of consuming its registration; only a second miss |
| | | 1018 | | // consumes it. |
| | 6 | 1019 | | numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false); |
| | 6 | 1020 | | activity?.SetTag("asyncresponse.subscribers", numSubscribers); |
| | 6 | 1021 | | if (numSubscribers > 0) |
| | 2 | 1022 | | return; |
| | | 1023 | | |
| | 4 | 1024 | | dispatchResult = await _lostSubscriberDispatcher |
| | 4 | 1025 | | .DispatchLostExceptions( |
| | 4 | 1026 | | _recoveryStateStore, |
| | 4 | 1027 | | correlationId, |
| | 4 | 1028 | | exception, |
| | 4 | 1029 | | channel.ToString()!, |
| | 4 | 1030 | | cancellationToken, |
| | 4 | 1031 | | hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken)) |
| | 4 | 1032 | | .ConfigureAwait(false); |
| | 4 | 1033 | | if (dispatchResult.RetryLive) |
| | | 1034 | | { |
| | | 1035 | | // Second contradiction: delivery keeps reporting no responders while the |
| | | 1036 | | // probe keeps reporting a live subscriber (interest not yet visible |
| | | 1037 | | // server-side, or a stale heartbeat). Consuming registrations on this |
| | | 1038 | | // evidence would strip a live waiter of its recovery arm — leave all state |
| | | 1039 | | // intact and surface the non-delivery to the caller, whose retry/redelivery |
| | | 1040 | | // machinery re-attempts once the subscription is visible (bounded by the |
| | | 1041 | | // heartbeat's liveness expiry, after which normal recovery takes over). |
| | | 1042 | | // Returning here instead would silently drop the payload. |
| | 2 | 1043 | | _logger.LogWarning( |
| | 2 | 1044 | | "Delivery for correlationId {CorrelationId} found no subscribers twice while the liveness pr |
| | 2 | 1045 | | correlationId); |
| | 2 | 1046 | | activity?.SetTag("asyncresponse.recovery.liveness_contradiction", true); |
| | 2 | 1047 | | throw new InvalidOperationException( |
| | 2 | 1048 | | $"Redis delivery for correlationId '{correlationId}' found no subscribers twice while the li |
| | 2 | 1049 | | "reporting one; the payload was not delivered and recovery registrations were left intact. R |
| | 2 | 1050 | | "once the waiter's subscription is visible to the publishing endpoint."); |
| | | 1051 | | } |
| | | 1052 | | } |
| | | 1053 | | |
| | 13 | 1054 | | activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked); |
| | 13 | 1055 | | AsyncResponseDiagnostics.RecordLostSubscriber("exception", action: RecoveryAction.Fail, dispatchResult.C |
| | | 1056 | | |
| | 13 | 1057 | | await RetireExecutorBoundedAsync(channel.ToString()!).ConfigureAwait(false); |
| | | 1058 | | } |
| | 13 | 1059 | | else if (_logger.IsEnabled(LogLevel.Debug)) |
| | | 1060 | | { |
| | 6 | 1061 | | _logger.LogDebug("Published exception response for correlationId {CorrelationId} on channel {Channel}. S |
| | | 1062 | | } |
| | 26 | 1063 | | } |
| | 10 | 1064 | | catch (Exception ex) |
| | | 1065 | | { |
| | 10 | 1066 | | _logger.LogError(ex, "Failed to publish exception response for correlationId {CorrelationId} on channel {Cha |
| | 10 | 1067 | | AsyncResponseDiagnostics.SetError(activity, ex); |
| | 10 | 1068 | | throw; |
| | | 1069 | | } |
| | 31 | 1070 | | } |
| | | 1071 | | |
| | | 1072 | | // --------------------------------------------------------------------------------------- |
| | | 1073 | | // IActiveSubscriberProbe |
| | | 1074 | | |
| | | 1075 | | /// <inheritdoc/> |
| | | 1076 | | /// <remarks> |
| | | 1077 | | /// Returns the live subscriber count, or a negative value when liveness could not be |
| | | 1078 | | /// established. Zero is reported only when every node that could hold the subscription |
| | | 1079 | | /// answered: the channels are key-routed, so the subscription lives on the single slot owner, |
| | | 1080 | | /// and a zero collected while that node was unreachable says nothing about the waiter. |
| | | 1081 | | /// </remarks> |
| | | 1082 | | public async ValueTask<long> CountActiveSubscribersAsync(string correlationId, CancellationToken cancellationToken = |
| | | 1083 | | { |
| | 126 | 1084 | | if (string.IsNullOrWhiteSpace(correlationId)) |
| | 4 | 1085 | | return 0L; |
| | | 1086 | | |
| | 122 | 1087 | | var channel = _keys.Channel(correlationId); |
| | | 1088 | | |
| | | 1089 | | // Subscriptions live on whichever node the client subscribed through, so the live count is |
| | | 1090 | | // the maximum reported across all connected endpoints. The async server call keeps large |
| | | 1091 | | // watchdog probe sweeps off blocking thread-pool waits, and the per-endpoint token check |
| | | 1092 | | // lets a shutdown abort the sweep between probes. |
| | 122 | 1093 | | long subscribers = 0; |
| | 122 | 1094 | | var answeredEveryPrimary = true; |
| | 122 | 1095 | | var primaryAnswered = false; |
| | 514 | 1096 | | foreach (var endPoint in _multiplexer.GetEndPoints()) |
| | | 1097 | | { |
| | 136 | 1098 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 1099 | | |
| | 134 | 1100 | | var server = _multiplexer.GetServer(endPoint); |
| | | 1101 | | |
| | | 1102 | | // PUBSUB NUMSUB is node-local and the response channels are key-routed, so the |
| | | 1103 | | // subscription sits on the ONE node that owns the channel key's slot — a primary. A |
| | | 1104 | | // node that has never connected reports the default (not a replica), which counts it |
| | | 1105 | | // as a primary here: the conservative direction, since the unknown node may be the |
| | | 1106 | | // very owner. Replicas are asked too (a positive answer is proof wherever it comes |
| | | 1107 | | // from) but never decide a zero. |
| | 134 | 1108 | | var isPrimary = !server.IsReplica; |
| | 134 | 1109 | | if (!server.IsConnected) |
| | | 1110 | | { |
| | 10 | 1111 | | answeredEveryPrimary &= !isPrimary; |
| | 10 | 1112 | | continue; |
| | | 1113 | | } |
| | | 1114 | | |
| | | 1115 | | try |
| | | 1116 | | { |
| | 124 | 1117 | | subscribers = Math.Max(subscribers, await server.SubscriptionSubscriberCountAsync(channel).ConfigureAwai |
| | 110 | 1118 | | primaryAnswered |= isPrimary; |
| | 110 | 1119 | | } |
| | 14 | 1120 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 1121 | | { |
| | 14 | 1122 | | answeredEveryPrimary &= !isPrimary; |
| | 14 | 1123 | | _logger.LogDebug(ex, "Failed to read subscriber count for channel {Channel}.", channel.ToString()!); |
| | 14 | 1124 | | } |
| | | 1125 | | } |
| | | 1126 | | |
| | | 1127 | | // A count above zero is proof of a live waiter wherever it was read. A zero is only the |
| | | 1128 | | // absence of one on the nodes that ANSWERED: skipping the slot owner and returning the |
| | | 1129 | | // siblings' node-local zeros asserted "definitively no live waiter" for a waiter that was |
| | | 1130 | | // subscribed all along — consuming its recovery registration (a double resume) or |
| | | 1131 | | // dropping its response. Negative = "could not be probed", the watchdog's and the |
| | | 1132 | | // snapshot-race re-check's unknown-liveness contract. |
| | 120 | 1133 | | if (subscribers > 0) |
| | 29 | 1134 | | return subscribers; |
| | | 1135 | | |
| | 91 | 1136 | | return primaryAnswered && answeredEveryPrimary ? 0L : -1L; |
| | 124 | 1137 | | } |
| | | 1138 | | |
| | | 1139 | | /// <summary> |
| | | 1140 | | /// Re-probes waiter liveness for the lost-subscriber dispatcher's snapshot-race re-check, |
| | | 1141 | | /// using the same PUBSUB NUMSUB-based probe the watchdog uses. An unprobeable result THROWS |
| | | 1142 | | /// instead of reading as "no live waiter", so the failure propagates to the publisher's catch |
| | | 1143 | | /// and the publish retries rather than consuming a live waiter's recovery registration |
| | | 1144 | | /// (parity with the DB channels, whose re-check calls the store directly). |
| | | 1145 | | /// </summary> |
| | | 1146 | | private async ValueTask<bool> HasLiveSubscriberAsync(string correlationId, CancellationToken cancellationToken) |
| | | 1147 | | { |
| | 97 | 1148 | | var subscribers = await CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(false); |
| | 97 | 1149 | | if (subscribers < 0) |
| | | 1150 | | { |
| | 6 | 1151 | | throw new InvalidOperationException( |
| | 6 | 1152 | | $"Redis subscriber liveness for correlationId '{correlationId}' could not be probed on any connected end |
| | | 1153 | | } |
| | | 1154 | | |
| | 91 | 1155 | | return subscribers > 0; |
| | 91 | 1156 | | } |
| | | 1157 | | |
| | | 1158 | | private static string SerializeRawSuccessEnvelope(string payloadJson) |
| | | 1159 | | { |
| | 40 | 1160 | | JsonSafety.ThrowIfClearlyNotJson(payloadJson); |
| | | 1161 | | |
| | 38 | 1162 | | var buffer = new ArrayBufferWriter<byte>(); |
| | 38 | 1163 | | using (var writer = new Utf8JsonWriter(buffer)) |
| | | 1164 | | { |
| | 38 | 1165 | | writer.WriteStartObject(); |
| | 38 | 1166 | | writer.WriteNumber("SchemaVersion", AsyncResponseEnvelopeSchema.Current); |
| | 38 | 1167 | | writer.WriteBoolean("Success", true); |
| | 38 | 1168 | | writer.WritePropertyName("Payload"); |
| | 38 | 1169 | | writer.WriteRawValue(payloadJson); |
| | 38 | 1170 | | writer.WriteNull("ExceptionMessage"); |
| | 38 | 1171 | | writer.WriteNull("ExceptionStackTrace"); |
| | 38 | 1172 | | writer.WriteEndObject(); |
| | 38 | 1173 | | } |
| | | 1174 | | |
| | 38 | 1175 | | return Encoding.UTF8.GetString(buffer.WrittenSpan); |
| | | 1176 | | } |
| | | 1177 | | } |