< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.Transports.DbTransportHeaders
Assembly: AsyncResponse.Transports.PostgreSQL
File(s): /_/src/Transports/Shared/DbTransportShared.cs
Line coverage
100%
Covered lines: 24
Uncovered lines: 0
Coverable lines: 24
Total lines: 724
Line coverage: 100%
Branch coverage
100%
Covered branches: 12
Total branches: 12
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Materialize(...)100%1212100%
.cctor()100%11100%

File(s)

/_/src/Transports/Shared/DbTransportShared.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using System.Text.Json;
 3using System.Threading.Channels;
 4
 5namespace AsyncResponse.Transports;
 6
 7// Shared source for the database-backed worker transports (PostgreSQL, SQL Server, MongoDB),
 8// mirroring src/Channels/Shared/DbChannelShared.cs: each transport csproj pulls this file in via
 9// <Compile Include="..\Shared\DbTransportShared.cs" />, so the base class compiles INTO each
 10// provider assembly against that provider's concrete seam types. The seam is bound per project
 11// with global using aliases (declared at the top of the provider's MessageDispatcher file):
 12//
 13//   DbTransportOptions          -> the provider's transport options (e.g. PostgreSqlAsyncResponseTransportOptions)
 14//   DbSubscriberOptions         -> the provider's subscriber options (e.g. PostgreSqlSubscriberOptions)
 15//   DbTransportDelivery         -> the provider's claimed-delivery type (e.g. PostgreSqlTransportDelivery)
 16//   DbSubscriberRole            -> the provider's subscriber-role enum
 17//   DbAckMode                   -> the provider's ack-mode enum
 18//   DbTransportOptionsValidator -> the provider's static options validator
 19//   DbBackgroundFailureContext  -> the provider's OnBackgroundFailure context type
 20//
 21// Because the aliases resolve to concrete sealed types at compile time, delivery calls stay
 22// direct — no interface dispatch on the per-message path. The only provider-specific inputs are
 23// three display strings supplied by the derived constructor: the provider name rendered into log
 24// messages, the queue-item noun ("row"/"document"), and the lowercase telemetry tag. Rendered log
 25// output and activity tags are byte-identical to the pre-extraction per-provider sources.
 26
 27/// <summary>
 28/// Applies acknowledgement, redelivery, and dead-letter policy to database transport deliveries:
 29/// ack-after-handler with fenced lease renewal, opt-in early ACK behind a bounded in-process
 30/// queue with drain-on-dispose, attempt-capped dead-lettering, and the consumer receive span.
 31/// Derived dispatchers supply only the provider display name, queue-item noun, and telemetry tag.
 32/// </summary>
 33internal abstract class DbMessageDispatcherBase : IAsyncDisposable
 34{
 35    private readonly Func<DbTransportDelivery, CancellationToken, Task> _handler;
 36    private readonly DbTransportOptions _options;
 37    private readonly DbSubscriberOptions _subscriberOptions;
 38    private readonly ILogger _logger;
 39    private readonly DbSubscriberRole _role;
 40    private readonly string _providerName;
 41    private readonly string _unitNoun;
 42    private readonly string _receiveActivityName;
 43    private readonly string _transportTag;
 44    private readonly string _roleTagName;
 45    private readonly string _ackModeTagName;
 46    private readonly TimeProvider _timeProvider;
 47
 48    private readonly Channel<DbTransportDelivery>? _backgroundQueue;
 49    private readonly Task[]? _backgroundWorkers;
 50    private readonly CancellationTokenSource? _backgroundCts;
 51
 52    protected DbMessageDispatcherBase(
 53        Func<DbTransportDelivery, CancellationToken, Task> handler,
 54        DbTransportOptions options,
 55        DbSubscriberOptions subscriberOptions,
 56        ILogger logger,
 57        DbSubscriberRole role,
 58        string providerName,
 59        string unitNoun,
 60        string telemetryName,
 61        TimeProvider? timeProvider = null)
 62    {
 63        DbTransportOptionsValidator.ValidateSubscriber(options, subscriberOptions, role.ToString());
 64
 65        _handler = handler;
 66        _options = options;
 67        _subscriberOptions = subscriberOptions;
 68        _logger = logger;
 69        _role = role;
 70        _providerName = providerName;
 71        _unitNoun = unitNoun;
 72        _receiveActivityName = $"asyncresponse.{telemetryName}.receive";
 73        _transportTag = telemetryName;
 74        _roleTagName = $"asyncresponse.{telemetryName}.role";
 75        _ackModeTagName = $"asyncresponse.{telemetryName}.ack_mode";
 76
 77        // Clocks the lease-renewal beat only (a test seam; the system clock when omitted).
 78        _timeProvider = timeProvider ?? TimeProvider.System;
 79
 80        if (subscriberOptions.AckMode is DbAckMode.AckAfterEnqueue)
 81        {
 82            _backgroundQueue = Channel.CreateBounded<DbTransportDelivery>(new BoundedChannelOptions(subscriberOptions.Ba
 83            {
 84                SingleReader = false,
 85                SingleWriter = true,
 86                FullMode = BoundedChannelFullMode.Wait
 87            });
 88            _backgroundCts = new CancellationTokenSource();
 89            _backgroundWorkers = new Task[subscriberOptions.BackgroundWorkerCount];
 90            for (var i = 0; i < _backgroundWorkers.Length; i++)
 91                _backgroundWorkers[i] = Task.Run(() => BackgroundWorkerLoopAsync(_backgroundCts.Token));
 92        }
 93    }
 94
 95    /// <summary>Handles one claimed queue item.</summary>
 96    public async Task HandleAsync(DbTransportDelivery delivery, CancellationToken cancellationToken)
 97    {
 98        // Pre-execution cap, BEFORE either ack mode. HandleFailureAsync below is the only other
 99        // place the cap is consulted, and it runs only when the handler THREW — so a delivery that
 100        // ends any other way (the process dies mid-handler, the host is killed, the lease lapses
 101        // while the DB is unreachable at settlement) never reaches it. The claim already stamped
 102        // attempts+1, so the row comes back at attempts cap+1, cap+2, ... and would be executed
 103        // again every time: redelivered forever, killing each replica in turn, and never
 104        // dead-lettered — the opposite of what MaxDeliveryAttempts documents. Settlement uses
 105        // CancellationToken.None for the usual reason: burying a poison row must not be abandoned
 106        // half-done by a shutdown. Mirrors the Redis dispatcher's AlreadyExceededDeliveryAttempts.
 107        var cap = _subscriberOptions.MaxDeliveryAttempts;
 108        if (cap > 0 && delivery.Attempt > cap)
 109        {
 110            _logger.LogError(
 111                "{Provider} message on queue {Queue} ({Role}) arrived on attempt {Attempt} with a cap of {MaxDeliveryAtt
 112                _providerName,
 113                delivery.Queue,
 114                _role,
 115                delivery.Attempt,
 116                cap);
 117
 118            var buried = await DeadLetterSwallowingFailureAsync(
 119                    delivery,
 120                    new InvalidOperationException(
 121                        $"Message exceeded {cap} delivery attempts without settling (attempt {delivery.Attempt})."),
 122                    deleteOriginal: true)
 123                .ConfigureAwait(false);
 124
 125            if (!buried)
 126            {
 127                _logger.LogWarning(
 128                    "{Provider} dead-letter publish failed for over-cap message on queue {Queue} ({Role}); releasing for
 129                    _providerName,
 130                    delivery.Queue,
 131                    _role);
 132                await NakSwallowingFailureAsync(delivery).ConfigureAwait(false);
 133            }
 134
 135            return;
 136        }
 137
 138        if (_subscriberOptions.AckMode is DbAckMode.AckAfterEnqueue)
 139        {
 140            await HandleEarlyAckAsync(delivery, cancellationToken).ConfigureAwait(false);
 141            return;
 142        }
 143
 144        try
 145        {
 146            // While the handler runs, a fenced heartbeat keeps extending the claim's lease at
 147            // LockTimeout/3 cadence so a slow handler does not let the lock lapse and a competing
 148            // subscriber re-claim (and duplicate-process) the queue item. The heartbeat MUST be
 149            // armed before any user code runs: a handler can burn its lease entirely
 150            // synchronously (CPU work or blocking I/O before its first await), and only an
 151            // already-armed beat — firing on a timer thread — renews under a blocked handler
 152            // thread. Teardown is exception-free (SuppressThrowing beat), so the always-armed
 153            // loop costs allocations per delivery, not a thrown TaskCanceledException.
 154            using var renewalCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 155            var renewalTask = RenewLeaseLoopAsync(delivery, renewalCancellation.Token);
 156            try
 157            {
 158                await ExecuteHandlerAsync(delivery, cancellationToken).ConfigureAwait(false);
 159            }
 160            finally
 161            {
 162                renewalCancellation.Cancel();
 163                ObserveRenewal(renewalTask);
 164            }
 165        }
 166        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 167        {
 168            // Host shutdown, not a handler failure: NAK would burn an attempt and dead-letter
 169            // would bury healthy work once the cap is reached. Leave the claim unsettled — the
 170            // lease lapses on its own and at-least-once redelivery applies after restart.
 171            throw;
 172        }
 173        catch (Exception ex)
 174        {
 175            await HandleFailureAsync(delivery, ex).ConfigureAwait(false);
 176            return;
 177        }
 178
 179        // The ack runs outside the handler's try/catch: a transient ack failure after a
 180        // successful handler must not be misread as a handler failure — NAK/dead-letter here
 181        // would redeliver (or bury) work whose side effects already completed. Swallow and log
 182        // instead; the claim's lease lapses on its own and at-least-once redelivery applies.
 183        try
 184        {
 185            await delivery.AckAsync().ConfigureAwait(false);
 186        }
 187        catch (Exception ex)
 188        {
 189            _logger.LogWarning(
 190                ex,
 191                "Failed to ACK {Provider} message {MessageId} on queue {Queue} ({Role}) after a successful handler; the 
 192                _providerName,
 193                delivery.Id,
 194                delivery.Queue,
 195                _role,
 196                _unitNoun);
 197        }
 198    }
 199
 200    private async Task RenewLeaseLoopAsync(DbTransportDelivery delivery, CancellationToken cancellationToken)
 201    {
 202        // A third of the lease, and a FAILED beat retries on a short backoff instead of waiting out
 203        // another full beat. At LockTimeout/2 with the retry one more beat away, the retry landed
 204        // at claim + LockTimeout — after locked_until, every time: ONE transient renew failure (a
 205        // command timeout, a broken pooled connection, a SQL Server 1205 deadlock victim)
 206        // guaranteed the lease lapsed, a peer claimed the row within its EmptyPollDelay, and a
 207        // healthy long handler ran twice concurrently. Now a failed beat leaves two thirds of the
 208        // lease for retries a second (or LockTimeout/10) apart. Both waits are floored at a
 209        // millisecond: Task.Delay truncates to whole milliseconds, and a zero wait would spin.
 210        var interval = TimeSpan.FromTicks(Math.Max(TimeSpan.TicksPerMillisecond, _options.LockTimeout.Ticks / 3));
 211        var retryInterval = TimeSpan.FromTicks(Math.Max(TimeSpan.TicksPerMillisecond, Math.Min(TimeSpan.TicksPerSecond, 
 212        var wait = interval;
 213        var failing = false;
 214        try
 215        {
 216            while (true)
 217            {
 218                // Exception-free beat: the loop is cancelled once per delivery when the handler
 219                // finishes, and a thrown-and-caught TaskCanceledException per message dominated
 220                // the dispatch cost. SuppressThrowing observes the cancelled delay without
 221                // throwing; cancellation still disarms the underlying timer immediately.
 222                await Task.Delay(wait, _timeProvider, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressTh
 223                if (cancellationToken.IsCancellationRequested)
 224                    return; // The handler finished or the subscriber is stopping.
 225
 226                bool renewed;
 227                try
 228                {
 229                    renewed = await delivery.RenewAsync().ConfigureAwait(false);
 230                }
 231                catch (Exception ex)
 232                {
 233                    if (cancellationToken.IsCancellationRequested)
 234                        return;
 235
 236                    // Keep retrying past locked_until too: the renew is fenced on lock_id alone,
 237                    // so until a peer actually re-claims the row a late renew still re-establishes
 238                    // the lease. Only the first failure of a streak is a warning — at this
 239                    // cadence a database outage would otherwise log one per second per in-flight
 240                    // delivery.
 241                    _logger.Log(
 242                        failing ? LogLevel.Debug : LogLevel.Warning,
 243                        ex,
 244                        "Failed to renew the lease of {Provider} message {MessageId} on queue {Queue} ({Role}); retrying
 245                        _providerName,
 246                        delivery.Id,
 247                        delivery.Queue,
 248                        _role,
 249                        retryInterval);
 250                    failing = true;
 251                    wait = retryInterval;
 252                    continue;
 253                }
 254
 255                // The beat is not joined before settlement (see ObserveRenewal), so a renew that
 256                // was in flight when the handler finished can land AFTER the fenced ack/NAK cleared
 257                // the row's lock_id. That "no match" is the settlement's own doing, not a lost lease.
 258                if (cancellationToken.IsCancellationRequested)
 259                    return;
 260
 261                if (!renewed)
 262                {
 263                    // The lock_id fence no longer matches: the lease expired and another subscriber
 264                    // claimed the queue item. Stop renewing; the fenced ack/NAK will no-op for this
 265                    // claim.
 266                    _logger.LogWarning(
 267                        "Lease of {Provider} message {MessageId} on queue {Queue} ({Role}) was lost; another subscriber 
 268                        _providerName,
 269                        delivery.Id,
 270                        delivery.Queue,
 271                        _role);
 272                    return;
 273                }
 274
 275                failing = false;
 276                wait = interval;
 277            }
 278        }
 279        catch (OperationCanceledException)
 280        {
 281            // A cancellation surfacing through RenewAsync while the token fires; the beat wait
 282            // itself never throws.
 283        }
 284    }
 285
 286    // Single choke point for handler execution so both ACK modes emit the consumer receive span.
 287    private async Task ExecuteHandlerAsync(DbTransportDelivery delivery, CancellationToken cancellationToken)
 288    {
 289        using var activity = AsyncResponseDiagnostics.StartActivity(
 290            _receiveActivityName,
 291            System.Diagnostics.ActivityKind.Consumer);
 292        activity?.SetTag("asyncresponse.transport", _transportTag);
 293        activity?.SetTag(_roleTagName, _role.ToString());
 294        activity?.SetTag(_ackModeTagName, _subscriberOptions.AckMode.ToString());
 295        activity?.SetTag("messaging.system", _transportTag);
 296        activity?.SetTag("messaging.destination.name", delivery.Queue);
 297        activity?.SetTag("messaging.message.id", delivery.Id.ToString());
 298        activity?.SetTag("messaging.message.delivery_attempt", delivery.Attempt);
 299
 300        if (delivery.Headers.TryGetValue(_options.CorrelationIdHeader, out var correlationId))
 301            AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 302
 303        try
 304        {
 305            await _handler(delivery, cancellationToken).ConfigureAwait(false);
 306        }
 307        catch (Exception ex)
 308        {
 309            AsyncResponseDiagnostics.SetError(activity, ex);
 310            throw;
 311        }
 312    }
 313
 314    /// <summary>
 315    /// The cancelled lease-renewal heartbeat is NOT joined before settlement. Every settlement (ack,
 316    /// NAK, dead-letter) is fenced by <c>lock_id</c> in all three stores, so a beat still in flight
 317    /// is a no-op against it — while the in-flight renew pins <see cref="CancellationToken.None"/>
 318    /// for its connect and command, so a join held the ack behind a slow renew: a handler that had
 319    /// already SUCCEEDED waited on a degraded database until the lease it was trying to extend had
 320    /// lapsed, and the row was claimed and run again before its ack went out. (While the subscriber
 321    /// is stopping the wait was also up to <c>LockTimeout</c> of the host's stop budget, a term no
 322    /// shutdown validator sums.) The loop swallows its own faults; observe defensively and let the
 323    /// beat finish on its own.
 324    /// </summary>
 325    private static void ObserveRenewal(Task renewalTask)
 326        => _ = renewalTask.ContinueWith(
 327            static task => _ = task.Exception,
 328            CancellationToken.None,
 329            TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
 330            TaskScheduler.Default);
 331
 332    private async Task HandleEarlyAckAsync(DbTransportDelivery delivery, CancellationToken cancellationToken)
 333    {
 334        if (!_backgroundQueue!.Writer.TryWrite(delivery))
 335        {
 336            // Saturated: wait for a worker to free a slot instead of NAKing. The subscriber loop
 337            // treats every claimed row as progress and re-claims immediately, so NAK-on-full spins
 338            // at full database rate — one claim plus one NAK round trip per queued row, each NAK
 339            // burning an attempt (and on PostgreSQL notifying the whole fleet to come do the same).
 340            // Parking here pauses the claim loop, which is the actual backpressure (mirrors the
 341            // RabbitMQ/Kafka/NATS pause); the queue is built with FullMode.Wait for exactly this.
 342            _logger.LogDebug("Background queue full for {Provider} {Role}; pausing the claim loop until capacity frees."
 343
 344            // The park is unbounded by design, but the claim's lease is not — and in early-ACK
 345            // mode the inline path's heartbeat never runs, so nothing renews it. A park longer
 346            // than LockTimeout would let the lock lapse, a competing subscriber re-claim and run
 347            // the row, and this subscriber enqueue its own copy once the park completes: one job,
 348            // two concurrent executions, with the second ack's lock_id fence failing silently.
 349            // Arm the same fenced heartbeat as the inline path for exactly the park's duration.
 350            using var renewalCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 351            var renewalTask = RenewLeaseLoopAsync(delivery, renewalCancellation.Token);
 352            try
 353            {
 354                await _backgroundQueue.Writer.WriteAsync(delivery, cancellationToken).ConfigureAwait(false);
 355            }
 356            catch (Exception ex) when (ex is OperationCanceledException or ChannelClosedException)
 357            {
 358                // Subscriber stopping or dispatcher draining while parked: the delivery was never
 359                // enqueued, so release it promptly; if the NAK itself fails the lease lapses to
 360                // the same effect.
 361                try
 362                {
 363                    await delivery.NakAsync(_subscriberOptions.RedeliveryDelay).ConfigureAwait(false);
 364                }
 365                catch (Exception nakException)
 366                {
 367                    _logger.LogWarning(
 368                        nakException,
 369                        "Failed to NAK {Provider} message {MessageId} on queue {Queue} ({Role}) while stopping; the leas
 370                        _providerName,
 371                        delivery.Id,
 372                        delivery.Queue,
 373                        _role,
 374                        _unitNoun);
 375                }
 376
 377                return;
 378            }
 379            finally
 380            {
 381                renewalCancellation.Cancel();
 382                ObserveRenewal(renewalTask);
 383            }
 384        }
 385
 386        // Same rule as the post-handler ACK above: the delivery is already owned by a background
 387        // worker, so an ACK failure must not escape and tear down the subscriber — that would
 388        // drain the workers (running the handler) while the un-ACKed row is re-claimed and run
 389        // again. Swallow and log; the lease lapses and at-least-once redelivery applies.
 390        try
 391        {
 392            await delivery.AckAsync().ConfigureAwait(false);
 393        }
 394        catch (Exception ex)
 395        {
 396            _logger.LogWarning(
 397                ex,
 398                "Failed to ACK {Provider} message {MessageId} on queue {Queue} ({Role}) after enqueueing it for backgrou
 399                _providerName,
 400                delivery.Id,
 401                delivery.Queue,
 402                _role,
 403                _unitNoun);
 404        }
 405    }
 406
 407    private async Task BackgroundWorkerLoopAsync(CancellationToken cancellationToken)
 408    {
 409        // Token-less ReadAllAsync: on shutdown the queue is completed and fully drained, so every
 410        // already-ACKed queue item is accounted for instead of being silently dropped; each
 411        // failure is dead-lettered and surfaced below.
 412        await foreach (var delivery in _backgroundQueue!.Reader.ReadAllAsync().ConfigureAwait(false))
 413        {
 414            // Once the drain budget has lapsed, STOP executing (Redis/Pub-Sub parity). The token
 415            // below cannot stop the real handler — it is `_ingress.HandleWorkerMessageAsync(payload)`,
 416            // whose target takes no CancellationToken — so past the budget the loop kept starting
 417            // fresh work beyond the HostShutdownTimeout the options size, and every entry still
 418            // queued at process exit vanished with no record (its queue row was deleted by the
 419            // early ACK, so nothing redelivers it). Route the rest through the same
 420            // dead-letter/OnBackgroundFailure path instead of losing them silently.
 421            if (cancellationToken.IsCancellationRequested)
 422            {
 423                var lapsed = new OperationCanceledException(
 424                    "The ACK-after-enqueue drain budget lapsed before this already-ACKed message was handled.");
 425
 426                _logger.LogWarning(
 427                    "{Provider} background handler for already-ACKed message {MessageId} on queue {Queue} ({Role}) was n
 428                    _providerName,
 429                    delivery.Id,
 430                    delivery.Queue,
 431                    _role);
 432
 433                if (!await DeadLetterSwallowingFailureAsync(delivery, lapsed, deleteOriginal: false).ConfigureAwait(fals
 434                {
 435                    _logger.LogError(
 436                        "Failed to dead-letter undrained {Provider} message {MessageId} on queue {Queue} ({Role}); the l
 437                        _providerName,
 438                        delivery.Id,
 439                        delivery.Queue,
 440                        _role);
 441                }
 442
 443                await InvokeBackgroundFailureAsync(delivery, lapsed).ConfigureAwait(false);
 444                continue;
 445            }
 446
 447            try
 448            {
 449                await ExecuteHandlerAsync(delivery, cancellationToken).ConfigureAwait(false);
 450            }
 451            catch (Exception ex)
 452            {
 453                _logger.LogError(ex, "{Provider} background handler failed for {Role} on queue {Queue} after early ACK."
 454                if (!await DeadLetterSwallowingFailureAsync(delivery, ex, deleteOriginal: false).ConfigureAwait(false))
 455                {
 456                    _logger.LogError(
 457                        "Failed to dead-letter already-ACKed {Provider} message {MessageId} on queue {Queue} ({Role}); t
 458                        _providerName,
 459                        delivery.Id,
 460                        delivery.Queue,
 461                        _role);
 462                }
 463
 464                await InvokeBackgroundFailureAsync(delivery, ex).ConfigureAwait(false);
 465            }
 466        }
 467    }
 468
 469    private async Task HandleFailureAsync(DbTransportDelivery delivery, Exception exception)
 470    {
 471        var maxAttempts = _subscriberOptions.MaxDeliveryAttempts;
 472        if (maxAttempts > 0 && delivery.Attempt >= maxAttempts)
 473        {
 474            _logger.LogError(
 475                exception,
 476                "{Provider} message on queue {Queue} ({Role}) failed after {Attempts} attempts; dead-lettering.",
 477                _providerName,
 478                delivery.Queue,
 479                _role,
 480                delivery.Attempt);
 481
 482            // CancellationToken.None like every other settlement in this file: burying a poison
 483            // row must not be abandoned half-done by a shutdown — with the stopping token, a
 484            // handler failing on its LAST attempt during a stop had the burial aborted (the
 485            // store's connection/transaction calls throw on the cancelled token) and the row was
 486            // NAKed back instead of dead-lettered.
 487            var deadLettered = await DeadLetterSwallowingFailureAsync(delivery, exception, deleteOriginal: true).Configu
 488            if (!deadLettered)
 489            {
 490                _logger.LogWarning(exception, "{Provider} dead-letter publish failed for queue {Queue} ({Role}); releasi
 491                await NakSwallowingFailureAsync(delivery).ConfigureAwait(false);
 492            }
 493        }
 494        else
 495        {
 496            _logger.LogWarning(
 497                exception,
 498                "{Provider} message on queue {Queue} ({Role}) failed on attempt {Attempt}; releasing for redelivery.",
 499                _providerName,
 500                delivery.Queue,
 501                _role,
 502                delivery.Attempt);
 503            await NakSwallowingFailureAsync(delivery).ConfigureAwait(false);
 504        }
 505    }
 506
 507    // Burial with the same containment rule as NakSwallowingFailureAsync below: the delivery
 508    // contract says DeadLetterAsync returns false rather than throwing, but the stores'
 509    // DeadLetterEnabled = false branch runs its ack OUTSIDE their guarded region, so a transient
 510    // DB failure there escaped as a throw — out of HandleAsync, tearing the subscriber down (and,
 511    // from the drain loop, killing the background worker). A burial that throws is a burial that
 512    // failed: report false and let the caller's NAK / lease-lapse path apply.
 513    private async Task<bool> DeadLetterSwallowingFailureAsync(DbTransportDelivery delivery, Exception exception, bool de
 514    {
 515        try
 516        {
 517            return await delivery.DeadLetterAsync(exception, deleteOriginal, CancellationToken.None).ConfigureAwait(fals
 518        }
 519        catch (Exception ex)
 520        {
 521            _logger.LogWarning(
 522                ex,
 523                "Failed to dead-letter {Provider} message {MessageId} on queue {Queue} ({Role}); treating the burial as 
 524                _providerName,
 525                delivery.Id,
 526                delivery.Queue,
 527                _role);
 528            return false;
 529        }
 530    }
 531
 532    // Same rule as the post-handler ACK above: the handler's outcome is already decided, so a
 533    // transient NAK failure must not escape HandleAsync and tear down the subscriber — that would
 534    // dispose the dispatcher mid-flight and dead-letter unrelated already-ACKed background work on
 535    // the way down. Swallow and log; the claim's lease lapses on its own and at-least-once
 536    // redelivery applies either way.
 537    private async Task NakSwallowingFailureAsync(DbTransportDelivery delivery)
 538    {
 539        try
 540        {
 541            await delivery.NakAsync(_subscriberOptions.RedeliveryDelay).ConfigureAwait(false);
 542        }
 543        catch (Exception ex)
 544        {
 545            _logger.LogWarning(
 546                ex,
 547                "Failed to NAK {Provider} message {MessageId} on queue {Queue} ({Role}) after a failed handler; the leas
 548                _providerName,
 549                delivery.Id,
 550                delivery.Queue,
 551                _role,
 552                _unitNoun);
 553        }
 554    }
 555
 556    private async Task InvokeBackgroundFailureAsync(DbTransportDelivery delivery, Exception exception)
 557    {
 558        if (_subscriberOptions.OnBackgroundFailure is null)
 559            return;
 560
 561        try
 562        {
 563            delivery.Headers.TryGetValue(_options.CorrelationIdHeader, out var correlationId);
 564            var context = new DbBackgroundFailureContext(delivery.Queue, _role.ToString(), delivery.Attempt, correlation
 565            await _subscriberOptions.OnBackgroundFailure(context).ConfigureAwait(false);
 566        }
 567        catch (Exception ex)
 568        {
 569            _logger.LogError(ex, "{Provider} OnBackgroundFailure callback threw for {Role}.", _providerName, _role);
 570        }
 571    }
 572
 573    /// <inheritdoc />
 574    public async ValueTask DisposeAsync()
 575    {
 576        if (_backgroundQueue is null)
 577            return;
 578
 579        _backgroundQueue.Writer.TryComplete();
 580
 581        // BackgroundDrainTimeout is the whole spend the shutdown-budget validator sums for this
 582        // dispatcher, so it is split rather than exceeded: most of it lets queued and running
 583        // handlers finish, and the rest is RESERVED for the post-lapse routing the worker loop
 584        // performs once cancelled (dead-letter + OnBackgroundFailure for every entry still
 585        // queued). That routing used to be fire-and-forget with no budget at all: DisposeAsync
 586        // returned, the subscriber and then the host finished stopping, and the already-ACKed
 587        // entries the workers were only starting to bury vanished with no record — the very
 588        // loss docs/transport-semantics.md promises this path prevents.
 589        var routingReserve = TimeSpan.FromTicks(_subscriberOptions.BackgroundDrainTimeout.Ticks / 4);
 590        var drainBudget = _subscriberOptions.BackgroundDrainTimeout - routingReserve;
 591        try
 592        {
 593            await Task.WhenAll(_backgroundWorkers!).WaitAsync(drainBudget).ConfigureAwait(false);
 594            _backgroundCts!.Dispose();
 595        }
 596        catch (TimeoutException)
 597        {
 598            _logger.LogWarning("{Provider} background handlers for {Role} did not drain within {Timeout}; dead-lettering
 599            await _backgroundCts!.CancelAsync().ConfigureAwait(false);
 600
 601            try
 602            {
 603                await Task.WhenAll(_backgroundWorkers!).WaitAsync(routingReserve).ConfigureAwait(false);
 604            }
 605            catch (TimeoutException)
 606            {
 607                _logger.LogError(
 608                    "{Provider} background workers for {Role} did not finish dead-lettering the undrained entries within
 609                    _providerName,
 610                    _role,
 611                    routingReserve);
 612
 613                // The workers are still running and observe _backgroundCts.Token inside ReadAllAsync, so disposing
 614                // it now would throw ObjectDisposedException inside them. Dispose once they actually finish, off
 615                // the shutdown path, so the source is not leaked either.
 616                _ = Task.WhenAll(_backgroundWorkers!).ContinueWith(
 617                    _ => _backgroundCts.Dispose(),
 618                    CancellationToken.None,
 619                    TaskContinuationOptions.ExecuteSynchronously,
 620                    TaskScheduler.Default);
 621                return;
 622            }
 623            catch (Exception ex)
 624            {
 625                _logger.LogDebug(ex, "{Provider} background worker drain for {Role} ended with an error.", _providerName
 626            }
 627
 628            // WhenAll completed one way or the other, so every worker has finished.
 629            _backgroundCts.Dispose();
 630        }
 631        catch (Exception ex)
 632        {
 633            // WhenAll only completes once every worker has finished, so the source is safe to dispose here.
 634            _logger.LogDebug(ex, "{Provider} background worker drain for {Role} ended with an error.", _providerName, _r
 635            _backgroundCts!.Dispose();
 636        }
 637    }
 638}
 639
 640/// <summary>
 641/// Extracts the AsyncResponse correlation id from the queue item's metadata first, then from the
 642/// JSON response body via configured paths (walked by the shared <see cref="CorrelationIdJsonPaths"/>,
 643/// same as the broker transports). Shared verbatim by the three database transports — the header
 644/// name and JSON paths both come from the aliased options type.
 645/// </summary>
 646internal static class DbCorrelationIdExtractor
 647{
 648    public static string? Extract(
 649        IReadOnlyDictionary<string, string>? headers,
 650        string messageJson,
 651        DbTransportOptions options)
 652    {
 653        var headerName = DbTransportOptionsValidator.Required(options.CorrelationIdHeader, nameof(options.CorrelationIdH
 654        if (headers is not null && headers.TryGetValue(headerName, out var headerValue) && !string.IsNullOrWhiteSpace(he
 655            return headerValue;
 656
 657        return CorrelationIdJsonPaths.Extract(messageJson, options.CorrelationIdJsonPaths);
 658    }
 659}
 660
 661/// <summary>
 662/// Materializes a claimed queue item's <c>headers_json</c> without rejecting ANY content the
 663/// column can legally hold. This runs after the claim already committed <c>attempts+1</c>/<c>lock_id</c>
 664/// and before any delivery object exists, so a throw here (a wrong-typed value, a non-object root,
 665/// malformed text in an unchecked column) could never reach the failure handler or dead-letter:
 666/// an unkillable poison row that tears down the subscriber on every re-claim. Instead, string
 667/// values are taken as-is, scalars keep their raw JSON text (culture-free by construction),
 668/// object/array values keep their raw JSON so correlation extraction still sees a usable string,
 669/// nulls are skipped — as is a header whose name or string value cannot be transcoded (an escaped
 670/// lone surrogate) — and anything unusable degrades to no headers — a genuinely poison message
 671/// then fails in the handler and flows through the NORMAL dead-letter path. Keys differing only
 672/// in case (legal JSON from foreign producers) are last-wins, matching the ASB/SQS receive
 673/// adapters.
 674/// </summary>
 675internal static class DbTransportHeaders
 676{
 677    public static IReadOnlyDictionary<string, string> Materialize(string json)
 678    {
 679        JsonDocument document;
 680        try
 681        {
 439682            document = JsonDocument.Parse(json);
 435683        }
 4684        catch (JsonException)
 685        {
 4686            return Empty;
 687        }
 688
 435689        using (document)
 690        {
 435691            if (document.RootElement.ValueKind is not JsonValueKind.Object)
 4692                return Empty;
 693
 431694            var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 1122695            foreach (var property in document.RootElement.EnumerateObject())
 696            {
 697                try
 698                {
 130699                    var value = property.Value.ValueKind switch
 130700                    {
 116701                        JsonValueKind.String => property.Value.GetString(),
 2702                        JsonValueKind.Null or JsonValueKind.Undefined => null,
 12703                        _ => property.Value.GetRawText()
 130704                    };
 128705                    if (value is not null)
 126706                        headers[property.Name] = value;
 126707                }
 4708                catch (InvalidOperationException)
 709                {
 710                    // An ESCAPED lone surrogate ("\ud800") parses — it is well-formed JSON — but has
 711                    // no UTF-16 string form, so GetString/Name throw InvalidOperationException, not
 712                    // the JsonException guarded above: the same after-the-claim, before-any-delivery
 713                    // throw this type exists to prevent. The header is unusable; skip it and keep
 714                    // the rest.
 4715                }
 716            }
 717
 431718            return headers;
 719        }
 439720    }
 721
 2722    private static readonly IReadOnlyDictionary<string, string> Empty =
 2723        new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase);
 724}