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

Information
Class: AsyncResponse.Transports.MongoDB.MongoDbTransportStore
Assembly: AsyncResponse.Transports.MongoDB
File(s): /_/src/Transports/AsyncResponse.Transports.MongoDB/MongoDbTransportStore.cs
Line coverage
95%
Covered lines: 279
Uncovered lines: 13
Coverable lines: 292
Total lines: 744
Line coverage: 95.5%
Branch coverage
83%
Covered branches: 60
Total branches: 72
Branch coverage: 83.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
EnsureCreatedAsync()90%101090.62%
WarnIfClaimIndexMissingAsync()66.66%6684.21%
PublishAsync()100%11100%
TryClaimAsync()100%66100%
BuildClaimFilter(...)100%11100%
BuildClaimUpdate(...)100%11100%
ClaimBatchAsync()100%44100%
InsertAsync()100%1180%
BuildInsertPipeline(...)100%88100%
AckAsync()100%11100%
NakAsync()100%11100%
RenewLeaseAsync()100%210%
BuildRenewUpdate(...)100%11100%
BuildNakUpdate(...)100%11100%
DeadLetterAsync()78.57%141493.93%
WatchQueueAsync()100%44100%
BuildQueueWatchPipeline(...)100%11100%
IsChangeStreamUnsupported(...)100%44100%
PruneDeadLettersIfDueAsync()100%44100%
BuildDeadLetterPruneFilter(...)100%11100%
ShouldPruneDeadLetters()100%22100%
DeadLetterId(...)100%11100%
Sanitize(...)100%11100%
.cctor()100%11100%
Dispose()100%22100%

File(s)

/_/src/Transports/AsyncResponse.Transports.MongoDB/MongoDbTransportStore.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Microsoft.Extensions.Options;
 3using MongoDB.Bson;
 4using MongoDB.Bson.Serialization;
 5using MongoDB.Bson.Serialization.Attributes;
 6using MongoDB.Bson.Serialization.Serializers;
 7using MongoDB.Driver;
 8using System.Runtime.CompilerServices;
 9using System.Security.Cryptography;
 10using System.Text;
 11using AsyncResponse.Internal;
 12
 13namespace AsyncResponse.Transports.MongoDB;
 14
 15internal enum MongoDbSubscriberRole
 16{
 17    Worker,
 18    ResponseIngress
 19}
 20
 21/// <summary>A claimed MongoDB transport document, decoupled from driver types for dispatch tests.</summary>
 22/// <remarks>
 23/// <c>RenewAsync</c> extends the claim's lease (<c>locked_until</c>) by the original lock timeout,
 24/// fenced on the claim's <c>lock_id</c>; it returns <c>false</c> when the fence no longer matches
 25/// (the lease lapsed and another subscriber re-claimed the document).
 26/// </remarks>
 27internal sealed record MongoDbTransportDelivery(
 28    Guid Id,
 29    string Queue,
 30    string Payload,
 31    IReadOnlyDictionary<string, string> Headers,
 32    int Attempt,
 33    Func<ValueTask> AckAsync,
 34    Func<TimeSpan, ValueTask> NakAsync,
 35    Func<Exception, bool, CancellationToken, ValueTask<bool>> DeadLetterAsync,
 36    Func<ValueTask<bool>> RenewAsync);
 37
 38/// <summary>Small document adapter for the MongoDB transport queue collection.</summary>
 39internal sealed class MongoDbTransportStore : IDisposable
 40{
 41    private readonly IMongoCollection<MongoTransportMessageDocument> _messages;
 42    private readonly MongoDbAsyncResponseTransportOptions _options;
 43    private readonly ILogger<MongoDbTransportStore>? _logger;
 26344    private readonly SemaphoreSlim _ensureGate = new(1, 1);
 45    private readonly IMongoClient? _ownedClient;
 46    private bool _created;
 47    private long _lastDeadLetterPruneTicks;
 48    private readonly IMongoDatabase _database;
 49
 26350    public MongoDbTransportStore(
 26351        IMongoDatabase database,
 26352        IOptions<MongoDbAsyncResponseTransportOptions> options,
 26353        IMongoClient? ownedClient = null,
 26354        ILogger<MongoDbTransportStore>? logger = null,
 26355        IMongoNamespaceRegistry? namespaceRegistry = null)
 56    {
 26357        _options = options.Value;
 26358        _logger = logger;
 26359        MongoDbTransportOptionsValidator.ValidateCommon(_options);
 60
 61        // Cross-component collection ownership (DI-hosted stores only): see MongoNamespaceRegistry.
 26362        namespaceRegistry?.Claim(
 26363            MongoNamespaceRegistry.ClusterKey(database),
 26364            database.DatabaseNamespace.DatabaseName,
 26365            "MongoDB transport",
 26366            [(_options.MessageCollection, nameof(_options.MessageCollection))]);
 67
 68        // The namespace BYTE limit can only be checked here, where the actual database name is
 69        // first known; a near-limit configuration otherwise passes every static check and fails
 70        // at the first server operation.
 26171        MongoNamespaceRegistry.ValidateEffectiveNamespace(database, _options.MessageCollection, nameof(_options.MessageC
 72
 25773        _database = database;
 74        // Pinned to the primary (channel / flow-store parity): a secondaryPreferred client would
 75        // route the change-stream wake to a lagging secondary, so worker jobs woke at replication
 76        // lag and delivery quietly degraded to EmptyPollDelay polling.
 25777        _messages = database.GetCollection<MongoTransportMessageDocument>(_options.MessageCollection)
 25778            .WithReadPreference(ReadPreference.Primary);
 25779        _ownedClient = ownedClient;
 25780    }
 81
 82    public async Task EnsureCreatedAsync(CancellationToken cancellationToken = default)
 83    {
 84        // Not short-circuited on AutoCreateIndexes/UseOwnershipLedger both being off: the
 85        // read-only index check below still runs once, and _created then keeps this cheap.
 315786        if (_created)
 253587            return;
 88
 62289        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 90        try
 91        {
 62292            if (_created)
 38493                return;
 94
 95            // Persisted cross-host ownership, independent of AutoCreateIndexes: see
 96            // MongoOwnershipLedger.
 23897            if (_options.UseOwnershipLedger)
 98            {
 23499                await MongoOwnershipLedger.ClaimAsync(
 234100                    _database,
 234101                    "MongoDB transport",
 234102                    [(_options.MessageCollection, nameof(_options.MessageCollection))],
 234103                    cancellationToken).ConfigureAwait(false);
 104            }
 105
 238106            if (!_options.AutoCreateIndexes)
 107            {
 108                // Channel / flow-store parity: verify (read-only, warn-only) instead of skipping
 109                // silently. A missing claim index turned every claim into a full collection scan
 110                // per poll tick, per subscriber, with no error and no log line.
 44111                await WarnIfClaimIndexMissingAsync(cancellationToken).ConfigureAwait(false);
 44112                _created = true;
 44113                return;
 114            }
 115
 194116            await _messages.Indexes.CreateOneAsync(
 194117                new CreateIndexModel<MongoTransportMessageDocument>(
 194118                    Builders<MongoTransportMessageDocument>.IndexKeys
 194119                        .Ascending(item => item.Queue)
 194120                        .Ascending(item => item.AvailableAtUtc)
 194121                        .Ascending(item => item.CreatedAtUtc),
 194122                    new CreateIndexOptions { Name = $"{_options.MessageCollection}_claim_idx" }),
 194123                cancellationToken: cancellationToken).ConfigureAwait(false);
 194124            await _messages.Indexes.CreateOneAsync(
 194125                new CreateIndexModel<MongoTransportMessageDocument>(
 194126                    Builders<MongoTransportMessageDocument>.IndexKeys.Ascending(item => item.CreatedAtUtc),
 194127                    new CreateIndexOptions { Name = $"{_options.MessageCollection}_created_idx" }),
 194128                cancellationToken: cancellationToken).ConfigureAwait(false);
 194129            _created = true;
 194130        }
 131        finally
 132        {
 622133            _ensureGate.Release();
 134        }
 3157135    }
 136
 137    private async Task WarnIfClaimIndexMissingAsync(CancellationToken cancellationToken)
 138    {
 139        try
 140        {
 141            List<BsonDocument> indexes;
 142            try
 143            {
 44144                using var cursor = await _messages.Indexes.ListAsync(cancellationToken).ConfigureAwait(false);
 2145                indexes = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
 2146            }
 0147            catch (MongoCommandException ex) when (ex.Code == 26)
 148            {
 149                // NamespaceNotFound: MongoDB creates the collection bare on the first write —
 150                // which, with index DDL disabled, is exactly a collection with no claim index.
 0151                indexes = [];
 0152            }
 153
 154            // Matched by KEY, not by name: operators own the naming of manually provisioned indexes.
 2155            var claimIndexed = indexes.Any(index =>
 2156                index.TryGetValue("key", out var key)
 2157                && key is BsonDocument keys
 2158                && keys.ElementCount > 0
 2159                && string.Equals(keys.GetElement(0).Name, "queue", StringComparison.Ordinal));
 2160            if (!claimIndexed)
 161            {
 2162                _logger?.LogWarning(
 2163                    "MongoDB collection {Database}.{Collection} has no index leading on 'queue' and AutoCreateIndexes is
 2164                    "Every claim scans the whole collection on every poll tick — performance only; create the claim inde
 2165                    "(queue, available_at, created_at) or enable AutoCreateIndexes.",
 2166                    _database.DatabaseNamespace.DatabaseName,
 2167                    _options.MessageCollection);
 168            }
 2169        }
 42170        catch (Exception ex) when (ex is not OperationCanceledException)
 171        {
 172            // A deployment that cannot even list indexes (no listIndexes privilege, server
 173            // unreachable at first use) must not lose the actual operation to the check: the
 174            // caller's own store call surfaces any real connectivity failure.
 42175            _logger?.LogDebug(ex, "Skipping index verification for the manually managed MongoDB transport collection; li
 42176        }
 44177    }
 178
 179    /// <summary>
 180    /// Publishes a queue document. The caller supplies the id so a retried publish is idempotent —
 181    /// a duplicate-key insert is treated as success rather than enqueuing the same job twice.
 182    /// </summary>
 183    public async Task PublishAsync(
 184        Guid id,
 185        string queue,
 186        string payload,
 187        IReadOnlyDictionary<string, string>? headers,
 188        CancellationToken cancellationToken,
 189        TimeSpan? delay = null)
 190    {
 423191        await InsertAsync(id, queue, payload, headers, deadLetterReason: null, cancellationToken, delay).ConfigureAwait(
 421192        await PruneDeadLettersIfDueAsync(cancellationToken).ConfigureAwait(false);
 421193    }
 194
 195    public async Task<MongoDbTransportDelivery?> TryClaimAsync(string queue, TimeSpan lockTimeout, CancellationToken can
 196    {
 1911197        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 1911198        var lockId = Guid.NewGuid();
 199
 200        // findOneAndUpdate is atomic per document: of all competing consumers, exactly one observes
 201        // the document unlocked and stamps its lock_id/locked_until in the same server-side step.
 202        // Binary (simple) collation pinned on the claim (SQL Server BIN2 / PostgreSQL
 203        // deterministic-collation parity): the three logical queues share this collection and
 204        // are told apart by nothing but the queue field, and an operator-created collection
 205        // with a case- or accent-folding default collation made the worker subscriber claim
 206        // response documents — which the ingress then dropped and ACKed with no dead-letter
 207        // record. An explicit simple collation cannot use an index built with a folding one, so
 208        // correctness costs a scan there; the default (bare) collection is unaffected.
 1911209        var claimed = await _messages.FindOneAndUpdateAsync(
 1911210            BuildClaimFilter(queue),
 1911211            BuildClaimUpdate(lockId, lockTimeout),
 1911212            new FindOneAndUpdateOptions<MongoTransportMessageDocument>
 1911213            {
 1911214                Sort = Builders<MongoTransportMessageDocument>.Sort.Ascending(item => item.CreatedAtUtc),
 1911215                ReturnDocument = ReturnDocument.After,
 1911216                Collation = Collation.Simple
 1911217            },
 1911218            cancellationToken).ConfigureAwait(false);
 1845219        if (claimed is null)
 1407220            return null;
 221
 222        // Indexer, not the copying constructor: documents can be written by foreign producers, and
 223        // BSON legally carries field names differing only in case — the constructor's internal Add
 224        // would throw AFTER the claim already stamped attempts+1/lock_id, before any delivery
 225        // exists, so the document could never reach HandleFailureAsync or dead-letter: an
 226        // unkillable poison document that tears down the subscriber on every re-claim. Last-wins,
 227        // matching the ASB/SQS receive adapters.
 228        IReadOnlyDictionary<string, string> headers;
 438229        if (claimed.Headers is null)
 230        {
 10231            headers = EmptyHeaders;
 232        }
 233        else
 234        {
 428235            var copied = new Dictionary<string, string>(claimed.Headers.Count, StringComparer.OrdinalIgnoreCase);
 1074236            foreach (var pair in claimed.Headers)
 109237                copied[pair.Key] = pair.Value;
 428238            headers = copied;
 239        }
 240
 438241        return new MongoDbTransportDelivery(
 438242            claimed.Id,
 438243            claimed.Queue,
 438244            claimed.Payload,
 438245            headers,
 438246            claimed.Attempts,
 417247            () => AckAsync(claimed.Id, lockId),
 6248            delay => NakAsync(claimed.Id, lockId, delay),
 9249            (exception, deleteOriginal, token) => DeadLetterAsync(claimed.Id, lockId, queue, claimed.Payload, headers, e
 438250            () => RenewLeaseAsync(claimed.Id, lockId, lockTimeout));
 1845251    }
 252
 253    /// <summary>
 254    /// Claim filter: available and not (still) locked, evaluated against the server clock
 255    /// (<c>$$NOW</c>) so publisher/consumer clock skew never fences messages in or out.
 256    /// A missing <c>locked_until</c> compares as null and therefore as expired.
 257    /// </summary>
 258    internal static FilterDefinition<MongoTransportMessageDocument> BuildClaimFilter(string queue)
 1913259        => Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Queue, queue)
 1913260           & new BsonDocumentFilterDefinition<MongoTransportMessageDocument>(new BsonDocument(
 1913261               "$expr",
 1913262               new BsonDocument("$and", new BsonArray
 1913263               {
 1913264                   new BsonDocument("$lte", new BsonArray { "$available_at", "$$NOW" }),
 1913265                   new BsonDocument("$or", new BsonArray
 1913266                   {
 1913267                       new BsonDocument("$eq", new BsonArray { "$locked_until", BsonNull.Value }),
 1913268                       new BsonDocument("$lte", new BsonArray { "$locked_until", "$$NOW" })
 1913269                   })
 1913270               })));
 271
 272    internal static UpdateDefinition<MongoTransportMessageDocument> BuildClaimUpdate(Guid lockId, TimeSpan lockTimeout)
 1913273        => Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 1913274        {
 1913275            new BsonDocument("$set", new BsonDocument
 1913276            {
 1913277                ["attempts"] = new BsonDocument("$add", new BsonArray
 1913278                {
 1913279                    new BsonDocument("$ifNull", new BsonArray { "$attempts", 0 }),
 1913280                    1
 1913281                }),
 1913282                ["locked_until"] = new BsonDocument("$add", new BsonArray { "$$NOW", lockTimeout.TotalMilliseconds }),
 1913283                ["lock_id"] = new BsonBinaryData(lockId, GuidRepresentation.Standard)
 1913284            })
 1913285        });
 286
 287    public async IAsyncEnumerable<MongoDbTransportDelivery> ClaimBatchAsync(
 288        string queue,
 289        int batchSize,
 290        TimeSpan lockTimeout,
 291        [EnumeratorCancellation] CancellationToken cancellationToken)
 292    {
 3784293        for (var i = 0; i < batchSize; i++)
 294        {
 1889295            var delivery = await TryClaimAsync(queue, lockTimeout, cancellationToken).ConfigureAwait(false);
 1823296            if (delivery is null)
 1401297                yield break;
 422298            yield return delivery;
 299        }
 1404300    }
 301
 302    private async Task InsertAsync(
 303        Guid id,
 304        string queue,
 305        string payload,
 306        IReadOnlyDictionary<string, string>? headers,
 307        string? deadLetterReason,
 308        CancellationToken cancellationToken,
 309        TimeSpan? delay = null)
 310    {
 430311        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 312        try
 313        {
 430314            await _messages.UpdateOneAsync(
 430315                Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id),
 430316                BuildInsertPipeline(queue, payload, headers, deadLetterReason, delay),
 430317                new UpdateOptions { IsUpsert = true },
 430318                cancellationToken).ConfigureAwait(false);
 426319        }
 0320        catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
 321        {
 322            // Two concurrent upserts of one id can both attempt the insert; the loser's
 323            // duplicate-key error is the outcome the caller asked for (a retried publish found the
 324            // document already present): idempotent success.
 0325        }
 426326    }
 327
 328    /// <summary>
 329    /// Upsert pipeline for a queue document. <c>created_at</c> — the claim <c>Sort</c> key and the
 330    /// dead-letter prune cutoff — is stamped from the SERVER clock (<c>$$NOW</c>), matching the
 331    /// claim/renew/NAK updates: a behind-clock publisher's client stamp would otherwise sort its
 332    /// rows permanently to the queue head and shift them across the prune boundary. <c>$ifNull</c>
 333    /// keeps the first (server-stamped) values when a publish retry finds the document already
 334    /// present, and a retry must not reset a claimed document's attempts or lease either — those
 335    /// fields are left alone entirely.
 336    /// </summary>
 337    /// <remarks>
 338    /// "Available immediately on arrival" still stamps epoch: it expresses what the SQL stores'
 339    /// <c>available_at DEFAULT now()</c> expresses without even a same-millisecond tie against the
 340    /// claim filter's <c>$$NOW</c>. A DELAYED publish computes its due time server-relative
 341    /// (<c>$$NOW + delay</c>), mirroring the NAK update, so client clock skew cannot shift it.
 342    /// User-supplied strings ride inside <c>$literal</c>: in a pipeline expression a plain string
 343    /// beginning with <c>$</c> would otherwise be read as a field path or variable.
 344    /// </remarks>
 345    internal static UpdateDefinition<MongoTransportMessageDocument> BuildInsertPipeline(
 346        string queue,
 347        string payload,
 348        IReadOnlyDictionary<string, string>? headers,
 349        string? deadLetterReason,
 350        TimeSpan? delay)
 351    {
 434352        var headerArray = new BsonArray();
 434353        if (headers is not null)
 354        {
 452355            foreach (var pair in headers)
 117356                headerArray.Add(new BsonDocument { ["k"] = pair.Key, ["v"] = pair.Value });
 357        }
 358
 434359        return Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 434360        {
 434361            new BsonDocument("$set", new BsonDocument
 434362            {
 434363                ["queue"] = new BsonDocument("$literal", queue),
 434364                ["payload"] = new BsonDocument("$literal", payload),
 434365                ["headers"] = new BsonDocument("$ifNull", new BsonArray { "$headers", new BsonDocument("$literal", heade
 434366                ["created_at"] = new BsonDocument("$ifNull", new BsonArray { "$created_at", "$$NOW" }),
 434367                ["available_at"] = new BsonDocument("$ifNull", new BsonArray
 434368                {
 434369                    "$available_at",
 434370                    delay is { } pending
 434371                        ? new BsonDocument("$add", new BsonArray { "$$NOW", pending.TotalMilliseconds })
 434372                        : (BsonValue)new BsonDateTime(DateTime.UnixEpoch)
 434373                }),
 434374                ["attempts"] = new BsonDocument("$ifNull", new BsonArray { "$attempts", 0 }),
 434375                ["dead_letter_reason"] = new BsonDocument("$ifNull", new BsonArray
 434376                {
 434377                    "$dead_letter_reason",
 434378                    deadLetterReason is null ? BsonNull.Value : new BsonDocument("$literal", deadLetterReason)
 434379                })
 434380            })
 434381        });
 382    }
 383
 384    private async ValueTask AckAsync(Guid id, Guid lockId)
 385    {
 419386        await _messages.DeleteOneAsync(
 419387            Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 419388            & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 419389            CancellationToken.None).ConfigureAwait(false);
 419390    }
 391
 392    private async ValueTask NakAsync(Guid id, Guid lockId, TimeSpan delay)
 393    {
 6394        await _messages.UpdateOneAsync(
 6395            Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 6396            & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 6397            BuildNakUpdate(delay),
 6398            options: null,
 6399            CancellationToken.None).ConfigureAwait(false);
 6400    }
 401
 402    private async ValueTask<bool> RenewLeaseAsync(Guid id, Guid lockId, TimeSpan lockTimeout)
 403    {
 0404        var result = await _messages.UpdateOneAsync(
 0405            Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 0406            & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 0407            BuildRenewUpdate(lockTimeout),
 0408            options: null,
 0409            CancellationToken.None).ConfigureAwait(false);
 0410        return result.MatchedCount > 0;
 0411    }
 412
 413    /// <summary>
 414    /// Fenced lease renewal: extends <c>locked_until</c> from the server clock (<c>$$NOW</c>) only
 415    /// while the claim's <c>lock_id</c> fence still matches, mirroring the claim update.
 416    /// </summary>
 417    internal static UpdateDefinition<MongoTransportMessageDocument> BuildRenewUpdate(TimeSpan lockTimeout)
 2418        => Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 2419        {
 2420            new BsonDocument("$set", new BsonDocument
 2421            {
 2422                ["locked_until"] = new BsonDocument("$add", new BsonArray { "$$NOW", lockTimeout.TotalMilliseconds })
 2423            })
 2424        });
 425
 426    internal static UpdateDefinition<MongoTransportMessageDocument> BuildNakUpdate(TimeSpan delay)
 8427        => Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 8428        {
 8429            new BsonDocument("$set", new BsonDocument
 8430            {
 8431                ["available_at"] = new BsonDocument("$add", new BsonArray { "$$NOW", delay.TotalMilliseconds }),
 8432                ["locked_until"] = BsonNull.Value,
 8433                ["lock_id"] = BsonNull.Value
 8434            })
 8435        });
 436
 437    private async ValueTask<bool> DeadLetterAsync(
 438        Guid id,
 439        Guid lockId,
 440        string sourceQueue,
 441        string payload,
 442        IReadOnlyDictionary<string, string> headers,
 443        Exception exception,
 444        bool deleteOriginal,
 445        CancellationToken cancellationToken)
 446    {
 9447        if (!_options.DeadLetterEnabled)
 448        {
 2449            if (deleteOriginal)
 2450                await AckAsync(id, lockId).ConfigureAwait(false);
 2451            return true;
 452        }
 453
 7454        var deadHeaders = new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase)
 7455        {
 7456            ["AR-DeadLetter-Reason"] = Sanitize(exception.Message),
 7457            ["AR-DeadLetter-Source-Queue"] = sourceQueue
 7458        };
 459
 460        try
 461        {
 7462            if (!deleteOriginal)
 463            {
 3464                await InsertAsync(Guid.NewGuid(), _options.DeadLetterQueue, payload, deadHeaders, exception.Message, can
 1465                return true;
 466            }
 467
 468            // MongoDB has no cross-document transaction we can rely on here (the transport must also
 469            // work on standalone servers), so the DLQ insert uses an id derived deterministically
 470            // from the source document: if a crash lands between the insert and the delete, the
 471            // redelivered message dead-letters onto the same id and the duplicate-key insert is
 472            // swallowed — the DLQ never accumulates copies of one poison message. Insert stays
 473            // FIRST for that reason: delete-first would lose the message outright in the same window.
 4474            var deadLetterId = DeadLetterId(id);
 4475            await InsertAsync(deadLetterId, _options.DeadLetterQueue, payload, deadHeaders, exception.Message, cancellat
 476
 477            // ...but the burial only counts if the fenced delete matched. A stale claim (the lease
 478            // lapsed and a peer re-claimed the document) must no-op here exactly as the fenced ack
 479            // and NAK do. The DLQ copy written a moment ago is deliberately NOT compensated away:
 480            // the deterministic id means a peer that also reached the cap buried into the SAME
 481            // document, so deleting it here erased the peer's just-logged burial and the message
 482            // vanished from both the live queue and the DLQ. The worst a kept copy can be is a
 483            // spurious DLQ entry for a message whose new owner later succeeds — visible, prunable
 484            // by dead-letter retention, and strictly better than losing the only record. (The SQL
 485            // siblings avoid the dilemma by making delete+insert one atomic statement; standalone
 486            // MongoDB has no equivalent.)
 4487            var removed = await _messages.DeleteOneAsync(
 4488                Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 4489                & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 4490                CancellationToken.None).ConfigureAwait(false);
 491
 4492            if (removed.DeletedCount == 0)
 493            {
 2494                _logger?.LogWarning(
 2495                    "MongoDB dead-letter for message {MessageId} from queue {SourceQueue} no-opped: the claim's lease ha
 2496                    id,
 2497                    sourceQueue);
 2498                return false;
 499            }
 500
 2501            return true;
 502        }
 2503        catch (Exception ex)
 504        {
 505            // Callers decide the redelivery consequence from the false return; log the cause here so
 506            // a failing dead-letter write is never silent.
 2507            _logger?.LogError(
 2508                ex,
 2509                "Failed to write MongoDB dead-letter document for message {MessageId} from queue {SourceQueue}.",
 2510                id,
 2511                sourceQueue);
 2512            return false;
 513        }
 9514    }
 515
 516    /// <summary>
 517    /// Watches the queue collection with a change stream and invokes
 518    /// <paramref name="onNotification"/> whenever a document is inserted into
 519    /// <paramref name="queue"/>. Runs until cancellation or a stream error; callers treat the wake
 520    /// as an optimization over <see cref="MongoDbSubscriberOptions.EmptyPollDelay"/> polling.
 521    /// </summary>
 522    public async Task WatchQueueAsync(string queue, Func<Task> onNotification, CancellationToken cancellationToken)
 523    {
 403524        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 403525        using var cursor = await _messages.WatchAsync(
 403526            BuildQueueWatchPipeline(queue),
 403527            cancellationToken: cancellationToken).ConfigureAwait(false);
 1165528        while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
 529        {
 2248530            foreach (var _ in cursor.Current)
 354531                await onNotification().ConfigureAwait(false);
 532        }
 4533    }
 534
 535    /// <summary>Change-stream pipeline for queue wakes: a <c>$match</c> on inserts into one logical queue.</summary>
 536    internal static PipelineDefinition<ChangeStreamDocument<MongoTransportMessageDocument>, ChangeStreamDocument<MongoTr
 405537        => new EmptyPipelineDefinition<ChangeStreamDocument<MongoTransportMessageDocument>>()
 405538            .Match(new BsonDocument("$and", new BsonArray
 405539            {
 405540                new BsonDocument("operationType", "insert"),
 405541                new BsonDocument("fullDocument.queue", queue)
 405542            }));
 543
 544    /// <summary>Returns <c>true</c> when the server rejected the change stream itself (not a transient cursor error).</
 545    internal static bool IsChangeStreamUnsupported(Exception exception)
 14546        => exception is MongoCommandException commandException
 14547           && (commandException.Code == 40573
 14548               || commandException.Message.Contains("only supported on replica sets", StringComparison.OrdinalIgnoreCase
 549
 550    /// <summary>
 551    /// Opportunistically deletes dead-letter documents older than the configured retention. No-op
 552    /// unless <see cref="MongoDbAsyncResponseTransportOptions.DeadLetterRetention"/> is set, and
 553    /// throttled so the delete runs at most once per minute regardless of publish rate.
 554    /// </summary>
 555    private async Task PruneDeadLettersIfDueAsync(CancellationToken cancellationToken)
 556    {
 421557        if (_options.DeadLetterRetention is not { } retention || !ShouldPruneDeadLetters())
 418558            return;
 559
 560        // Same binary collation as the claim: under a folding collection collation this prune
 561        // matched live-queue documents whose name differed only by case.
 3562        await _messages.DeleteManyAsync(
 3563            BuildDeadLetterPruneFilter(_options.DeadLetterQueue, retention),
 3564            new DeleteOptions { Collation = Collation.Simple },
 3565            cancellationToken).ConfigureAwait(false);
 421566    }
 567
 568    /// <summary>
 569    /// Prune filter: age is evaluated ENTIRELY on the server clock — <c>$$NOW</c> against the
 570    /// server-stamped <c>created_at</c> — mirroring the claim filter. Comparing an app-clock
 571    /// cutoff against another instance's stamp mixes two clocks: a pruner running behind the
 572    /// publisher deletes fresh dead letters on arrival, destroying the forensic record of a
 573    /// poison message, and one running ahead keeps them past retention.
 574    /// </summary>
 575    internal static FilterDefinition<MongoTransportMessageDocument> BuildDeadLetterPruneFilter(string deadLetterQueue, T
 5576        => Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Queue, deadLetterQueue)
 5577           & new BsonDocumentFilterDefinition<MongoTransportMessageDocument>(new BsonDocument(
 5578               "$expr",
 5579               new BsonDocument("$lt", new BsonArray
 5580               {
 5581                   "$created_at",
 5582                   new BsonDocument("$subtract", new BsonArray { "$$NOW", retention.TotalMilliseconds })
 5583               })));
 584
 585    private bool ShouldPruneDeadLetters()
 586    {
 10587        var now = DateTime.UtcNow.Ticks;
 10588        var last = Interlocked.Read(ref _lastDeadLetterPruneTicks);
 10589        return now - last >= DeadLetterPruneThrottle.Ticks
 10590            && Interlocked.CompareExchange(ref _lastDeadLetterPruneTicks, now, last) == last;
 591    }
 592
 593    /// <summary>
 594    /// Deterministic dead-letter document id for a source message: the same poison message always
 595    /// maps to the same DLQ id, making the insert-then-delete pair idempotent under crash-redelivery.
 596    /// </summary>
 597    internal static Guid DeadLetterId(Guid sourceId)
 598    {
 15599        var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"asyncresponse:deadletter:{sourceId:N}"));
 15600        return new Guid(hash.AsSpan(0, 16));
 601    }
 602
 7603    private static string Sanitize(string value) => value.Replace('\r', ' ').Replace('\n', ' ');
 604
 3605    private static readonly TimeSpan DeadLetterPruneThrottle = TimeSpan.FromMinutes(1);
 606
 3607    private static readonly IReadOnlyDictionary<string, string> EmptyHeaders =
 3608        new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase);
 609
 610    /// <summary>Disposes the Mongo client when the store created (and therefore owns) it.</summary>
 611    public void Dispose()
 612    {
 221613        _ensureGate.Dispose();
 221614        (_ownedClient as IDisposable)?.Dispose();
 2615    }
 616}
 617
 618/// <remarks>
 619/// [BsonIgnoreExtraElements] is load-bearing, not tidiness. Queue documents can be written by
 620/// foreign producers (the documented reply-target consumer) and by newer builds mid-rolling-deploy,
 621/// and the driver's default is to THROW FormatException for any element outside this class map.
 622/// That throw lands after findOneAndUpdate has already stamped attempts+1/lock_id and before any
 623/// delivery object exists, so the document could never reach HandleFailureAsync or the dead-letter
 624/// queue: it tore the subscriber down on every re-claim, forever, with attempts climbing unbounded.
 625/// </remarks>
 626[BsonIgnoreExtraElements]
 627internal sealed class MongoTransportMessageDocument
 628{
 629    [BsonId]
 630    [BsonElement("_id")]
 631    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 632    public Guid Id { get; set; }
 633
 634    [BsonElement("queue")]
 635    public string Queue { get; set; } = "";
 636
 637    [BsonElement("payload")]
 638    public string Payload { get; set; } = "";
 639
 640    // Array-of-documents representation keeps arbitrary header names (dots, dollars) legal as
 641    // values rather than as BSON field names; the lenient serializer keeps that wire shape while
 642    // never rejecting a foreign producer's value types.
 643    [BsonElement("headers")]
 644    [BsonSerializer(typeof(LenientTransportHeaderSerializer))]
 645    public Dictionary<string, string>? Headers { get; set; }
 646
 647    [BsonElement("created_at")]
 648    public DateTime CreatedAtUtc { get; set; }
 649
 650    [BsonElement("available_at")]
 651    public DateTime AvailableAtUtc { get; set; }
 652
 653    [BsonElement("locked_until")]
 654    public DateTime? LockedUntilUtc { get; set; }
 655
 656    [BsonElement("lock_id")]
 657    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 658    public Guid? LockId { get; set; }
 659
 660    [BsonElement("attempts")]
 661    public int Attempts { get; set; }
 662
 663    [BsonElement("dead_letter_reason")]
 664    public string? DeadLetterReason { get; set; }
 665}
 666
 667/// <summary>
 668/// Serializes headers in the driver's array-of-documents shape (<c>[{ "k": …, "v": … }, …]</c>)
 669/// and deserializes them without rejecting ANY BSON a foreign producer can legally store there.
 670/// The default dictionary serializer throws on a wrong-typed value ("Cannot deserialize a
 671/// 'String' from BsonType 'Int32'") — and header materialization runs inside the claim's
 672/// <c>findOneAndUpdate</c>, AFTER the server already stamped <c>attempts+1</c>/<c>lock_id</c> and
 673/// before any delivery object exists, so that throw could never reach the failure handler or
 674/// dead-letter: an unkillable poison document that tears down the subscriber on every re-claim.
 675/// Instead, string values are taken as-is, other scalars keep their canonical (culture-free)
 676/// string form, document/array values keep their JSON text so correlation extraction still sees a
 677/// usable string, nulls are skipped, and unusable shapes degrade to no headers — a genuinely
 678/// poison message then fails in the handler and flows through the NORMAL dead-letter path.
 679/// </summary>
 680internal sealed class LenientTransportHeaderSerializer : SerializerBase<Dictionary<string, string>?>
 681{
 682    public override Dictionary<string, string>? Deserialize(BsonDeserializationContext context, BsonDeserializationArgs 
 683        => Materialize(BsonValueSerializer.Instance.Deserialize(context));
 684
 685    internal static Dictionary<string, string>? Materialize(BsonValue value)
 686    {
 687        if (value is not BsonArray entries)
 688            return null;
 689
 690        // Default (ordinal) comparer, matching the driver's own dictionary: the claim-side copy is
 691        // the case-folding point. Indexer writes, so duplicate keys — case-variant or exact, both
 692        // legal BSON — are last-wins instead of a throw.
 693        var headers = new Dictionary<string, string>(entries.Count);
 694        foreach (var entry in entries)
 695        {
 696            if (entry is not BsonDocument pair
 697                || !pair.TryGetValue("k", out var key)
 698                || !pair.TryGetValue("v", out var rawValue))
 699            {
 700                continue;
 701            }
 702
 703            var name = Coerce(key);
 704            var text = Coerce(rawValue);
 705            if (name is not null && text is not null)
 706                headers[name] = text;
 707        }
 708
 709        return headers;
 710    }
 711
 712    private static string? Coerce(BsonValue value) => value.BsonType switch
 713    {
 714        BsonType.String => value.AsString,
 715        BsonType.Null or BsonType.Undefined => null,
 716        BsonType.Document or BsonType.Array => value.ToJson(),
 717        // The scalar ToString overrides are JSON-flavored and culture-free ("1.5", "123", "true",
 718        // ISO-8601 dates — raw epoch millis when out of DateTime range), and none of them throws.
 719        _ => value.ToString() ?? ""
 720    };
 721
 722    public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Dictionary<string, stri
 723    {
 724        var writer = context.Writer;
 725        if (value is null)
 726        {
 727            writer.WriteNull();
 728            return;
 729        }
 730
 731        writer.WriteStartArray();
 732        foreach (var pair in value)
 733        {
 734            writer.WriteStartDocument();
 735            writer.WriteName("k");
 736            writer.WriteString(pair.Key);
 737            writer.WriteName("v");
 738            writer.WriteString(pair.Value);
 739            writer.WriteEndDocument();
 740        }
 741
 742        writer.WriteEndArray();
 743    }
 744}