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

Information
Class: AsyncResponse.Transports.MongoDB.MongoTransportMessageDocument
Assembly: AsyncResponse.Transports.MongoDB
File(s): /_/src/Transports/AsyncResponse.Transports.MongoDB/MongoDbTransportStore.cs
Line coverage
100%
Covered lines: 10
Uncovered lines: 0
Coverable lines: 10
Total lines: 744
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Id()100%11100%
get_Queue()100%11100%
get_Payload()100%11100%
get_Headers()100%11100%
get_CreatedAtUtc()100%11100%
get_AvailableAtUtc()100%11100%
get_LockedUntilUtc()100%11100%
get_LockId()100%11100%
get_Attempts()100%11100%
get_DeadLetterReason()100%11100%

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;
 44    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
 50    public MongoDbTransportStore(
 51        IMongoDatabase database,
 52        IOptions<MongoDbAsyncResponseTransportOptions> options,
 53        IMongoClient? ownedClient = null,
 54        ILogger<MongoDbTransportStore>? logger = null,
 55        IMongoNamespaceRegistry? namespaceRegistry = null)
 56    {
 57        _options = options.Value;
 58        _logger = logger;
 59        MongoDbTransportOptionsValidator.ValidateCommon(_options);
 60
 61        // Cross-component collection ownership (DI-hosted stores only): see MongoNamespaceRegistry.
 62        namespaceRegistry?.Claim(
 63            MongoNamespaceRegistry.ClusterKey(database),
 64            database.DatabaseNamespace.DatabaseName,
 65            "MongoDB transport",
 66            [(_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.
 71        MongoNamespaceRegistry.ValidateEffectiveNamespace(database, _options.MessageCollection, nameof(_options.MessageC
 72
 73        _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.
 77        _messages = database.GetCollection<MongoTransportMessageDocument>(_options.MessageCollection)
 78            .WithReadPreference(ReadPreference.Primary);
 79        _ownedClient = ownedClient;
 80    }
 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.
 86        if (_created)
 87            return;
 88
 89        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 90        try
 91        {
 92            if (_created)
 93                return;
 94
 95            // Persisted cross-host ownership, independent of AutoCreateIndexes: see
 96            // MongoOwnershipLedger.
 97            if (_options.UseOwnershipLedger)
 98            {
 99                await MongoOwnershipLedger.ClaimAsync(
 100                    _database,
 101                    "MongoDB transport",
 102                    [(_options.MessageCollection, nameof(_options.MessageCollection))],
 103                    cancellationToken).ConfigureAwait(false);
 104            }
 105
 106            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.
 111                await WarnIfClaimIndexMissingAsync(cancellationToken).ConfigureAwait(false);
 112                _created = true;
 113                return;
 114            }
 115
 116            await _messages.Indexes.CreateOneAsync(
 117                new CreateIndexModel<MongoTransportMessageDocument>(
 118                    Builders<MongoTransportMessageDocument>.IndexKeys
 119                        .Ascending(item => item.Queue)
 120                        .Ascending(item => item.AvailableAtUtc)
 121                        .Ascending(item => item.CreatedAtUtc),
 122                    new CreateIndexOptions { Name = $"{_options.MessageCollection}_claim_idx" }),
 123                cancellationToken: cancellationToken).ConfigureAwait(false);
 124            await _messages.Indexes.CreateOneAsync(
 125                new CreateIndexModel<MongoTransportMessageDocument>(
 126                    Builders<MongoTransportMessageDocument>.IndexKeys.Ascending(item => item.CreatedAtUtc),
 127                    new CreateIndexOptions { Name = $"{_options.MessageCollection}_created_idx" }),
 128                cancellationToken: cancellationToken).ConfigureAwait(false);
 129            _created = true;
 130        }
 131        finally
 132        {
 133            _ensureGate.Release();
 134        }
 135    }
 136
 137    private async Task WarnIfClaimIndexMissingAsync(CancellationToken cancellationToken)
 138    {
 139        try
 140        {
 141            List<BsonDocument> indexes;
 142            try
 143            {
 144                using var cursor = await _messages.Indexes.ListAsync(cancellationToken).ConfigureAwait(false);
 145                indexes = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
 146            }
 147            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.
 151                indexes = [];
 152            }
 153
 154            // Matched by KEY, not by name: operators own the naming of manually provisioned indexes.
 155            var claimIndexed = indexes.Any(index =>
 156                index.TryGetValue("key", out var key)
 157                && key is BsonDocument keys
 158                && keys.ElementCount > 0
 159                && string.Equals(keys.GetElement(0).Name, "queue", StringComparison.Ordinal));
 160            if (!claimIndexed)
 161            {
 162                _logger?.LogWarning(
 163                    "MongoDB collection {Database}.{Collection} has no index leading on 'queue' and AutoCreateIndexes is
 164                    "Every claim scans the whole collection on every poll tick — performance only; create the claim inde
 165                    "(queue, available_at, created_at) or enable AutoCreateIndexes.",
 166                    _database.DatabaseNamespace.DatabaseName,
 167                    _options.MessageCollection);
 168            }
 169        }
 170        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.
 175            _logger?.LogDebug(ex, "Skipping index verification for the manually managed MongoDB transport collection; li
 176        }
 177    }
 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    {
 191        await InsertAsync(id, queue, payload, headers, deadLetterReason: null, cancellationToken, delay).ConfigureAwait(
 192        await PruneDeadLettersIfDueAsync(cancellationToken).ConfigureAwait(false);
 193    }
 194
 195    public async Task<MongoDbTransportDelivery?> TryClaimAsync(string queue, TimeSpan lockTimeout, CancellationToken can
 196    {
 197        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 198        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.
 209        var claimed = await _messages.FindOneAndUpdateAsync(
 210            BuildClaimFilter(queue),
 211            BuildClaimUpdate(lockId, lockTimeout),
 212            new FindOneAndUpdateOptions<MongoTransportMessageDocument>
 213            {
 214                Sort = Builders<MongoTransportMessageDocument>.Sort.Ascending(item => item.CreatedAtUtc),
 215                ReturnDocument = ReturnDocument.After,
 216                Collation = Collation.Simple
 217            },
 218            cancellationToken).ConfigureAwait(false);
 219        if (claimed is null)
 220            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;
 229        if (claimed.Headers is null)
 230        {
 231            headers = EmptyHeaders;
 232        }
 233        else
 234        {
 235            var copied = new Dictionary<string, string>(claimed.Headers.Count, StringComparer.OrdinalIgnoreCase);
 236            foreach (var pair in claimed.Headers)
 237                copied[pair.Key] = pair.Value;
 238            headers = copied;
 239        }
 240
 241        return new MongoDbTransportDelivery(
 242            claimed.Id,
 243            claimed.Queue,
 244            claimed.Payload,
 245            headers,
 246            claimed.Attempts,
 247            () => AckAsync(claimed.Id, lockId),
 248            delay => NakAsync(claimed.Id, lockId, delay),
 249            (exception, deleteOriginal, token) => DeadLetterAsync(claimed.Id, lockId, queue, claimed.Payload, headers, e
 250            () => RenewLeaseAsync(claimed.Id, lockId, lockTimeout));
 251    }
 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)
 259        => Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Queue, queue)
 260           & new BsonDocumentFilterDefinition<MongoTransportMessageDocument>(new BsonDocument(
 261               "$expr",
 262               new BsonDocument("$and", new BsonArray
 263               {
 264                   new BsonDocument("$lte", new BsonArray { "$available_at", "$$NOW" }),
 265                   new BsonDocument("$or", new BsonArray
 266                   {
 267                       new BsonDocument("$eq", new BsonArray { "$locked_until", BsonNull.Value }),
 268                       new BsonDocument("$lte", new BsonArray { "$locked_until", "$$NOW" })
 269                   })
 270               })));
 271
 272    internal static UpdateDefinition<MongoTransportMessageDocument> BuildClaimUpdate(Guid lockId, TimeSpan lockTimeout)
 273        => Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 274        {
 275            new BsonDocument("$set", new BsonDocument
 276            {
 277                ["attempts"] = new BsonDocument("$add", new BsonArray
 278                {
 279                    new BsonDocument("$ifNull", new BsonArray { "$attempts", 0 }),
 280                    1
 281                }),
 282                ["locked_until"] = new BsonDocument("$add", new BsonArray { "$$NOW", lockTimeout.TotalMilliseconds }),
 283                ["lock_id"] = new BsonBinaryData(lockId, GuidRepresentation.Standard)
 284            })
 285        });
 286
 287    public async IAsyncEnumerable<MongoDbTransportDelivery> ClaimBatchAsync(
 288        string queue,
 289        int batchSize,
 290        TimeSpan lockTimeout,
 291        [EnumeratorCancellation] CancellationToken cancellationToken)
 292    {
 293        for (var i = 0; i < batchSize; i++)
 294        {
 295            var delivery = await TryClaimAsync(queue, lockTimeout, cancellationToken).ConfigureAwait(false);
 296            if (delivery is null)
 297                yield break;
 298            yield return delivery;
 299        }
 300    }
 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    {
 311        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 312        try
 313        {
 314            await _messages.UpdateOneAsync(
 315                Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id),
 316                BuildInsertPipeline(queue, payload, headers, deadLetterReason, delay),
 317                new UpdateOptions { IsUpsert = true },
 318                cancellationToken).ConfigureAwait(false);
 319        }
 320        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.
 325        }
 326    }
 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    {
 352        var headerArray = new BsonArray();
 353        if (headers is not null)
 354        {
 355            foreach (var pair in headers)
 356                headerArray.Add(new BsonDocument { ["k"] = pair.Key, ["v"] = pair.Value });
 357        }
 358
 359        return Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 360        {
 361            new BsonDocument("$set", new BsonDocument
 362            {
 363                ["queue"] = new BsonDocument("$literal", queue),
 364                ["payload"] = new BsonDocument("$literal", payload),
 365                ["headers"] = new BsonDocument("$ifNull", new BsonArray { "$headers", new BsonDocument("$literal", heade
 366                ["created_at"] = new BsonDocument("$ifNull", new BsonArray { "$created_at", "$$NOW" }),
 367                ["available_at"] = new BsonDocument("$ifNull", new BsonArray
 368                {
 369                    "$available_at",
 370                    delay is { } pending
 371                        ? new BsonDocument("$add", new BsonArray { "$$NOW", pending.TotalMilliseconds })
 372                        : (BsonValue)new BsonDateTime(DateTime.UnixEpoch)
 373                }),
 374                ["attempts"] = new BsonDocument("$ifNull", new BsonArray { "$attempts", 0 }),
 375                ["dead_letter_reason"] = new BsonDocument("$ifNull", new BsonArray
 376                {
 377                    "$dead_letter_reason",
 378                    deadLetterReason is null ? BsonNull.Value : new BsonDocument("$literal", deadLetterReason)
 379                })
 380            })
 381        });
 382    }
 383
 384    private async ValueTask AckAsync(Guid id, Guid lockId)
 385    {
 386        await _messages.DeleteOneAsync(
 387            Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 388            & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 389            CancellationToken.None).ConfigureAwait(false);
 390    }
 391
 392    private async ValueTask NakAsync(Guid id, Guid lockId, TimeSpan delay)
 393    {
 394        await _messages.UpdateOneAsync(
 395            Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 396            & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 397            BuildNakUpdate(delay),
 398            options: null,
 399            CancellationToken.None).ConfigureAwait(false);
 400    }
 401
 402    private async ValueTask<bool> RenewLeaseAsync(Guid id, Guid lockId, TimeSpan lockTimeout)
 403    {
 404        var result = await _messages.UpdateOneAsync(
 405            Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 406            & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 407            BuildRenewUpdate(lockTimeout),
 408            options: null,
 409            CancellationToken.None).ConfigureAwait(false);
 410        return result.MatchedCount > 0;
 411    }
 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)
 418        => Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 419        {
 420            new BsonDocument("$set", new BsonDocument
 421            {
 422                ["locked_until"] = new BsonDocument("$add", new BsonArray { "$$NOW", lockTimeout.TotalMilliseconds })
 423            })
 424        });
 425
 426    internal static UpdateDefinition<MongoTransportMessageDocument> BuildNakUpdate(TimeSpan delay)
 427        => Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 428        {
 429            new BsonDocument("$set", new BsonDocument
 430            {
 431                ["available_at"] = new BsonDocument("$add", new BsonArray { "$$NOW", delay.TotalMilliseconds }),
 432                ["locked_until"] = BsonNull.Value,
 433                ["lock_id"] = BsonNull.Value
 434            })
 435        });
 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    {
 447        if (!_options.DeadLetterEnabled)
 448        {
 449            if (deleteOriginal)
 450                await AckAsync(id, lockId).ConfigureAwait(false);
 451            return true;
 452        }
 453
 454        var deadHeaders = new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase)
 455        {
 456            ["AR-DeadLetter-Reason"] = Sanitize(exception.Message),
 457            ["AR-DeadLetter-Source-Queue"] = sourceQueue
 458        };
 459
 460        try
 461        {
 462            if (!deleteOriginal)
 463            {
 464                await InsertAsync(Guid.NewGuid(), _options.DeadLetterQueue, payload, deadHeaders, exception.Message, can
 465                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.
 474            var deadLetterId = DeadLetterId(id);
 475            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.)
 487            var removed = await _messages.DeleteOneAsync(
 488                Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 489                & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 490                CancellationToken.None).ConfigureAwait(false);
 491
 492            if (removed.DeletedCount == 0)
 493            {
 494                _logger?.LogWarning(
 495                    "MongoDB dead-letter for message {MessageId} from queue {SourceQueue} no-opped: the claim's lease ha
 496                    id,
 497                    sourceQueue);
 498                return false;
 499            }
 500
 501            return true;
 502        }
 503        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.
 507            _logger?.LogError(
 508                ex,
 509                "Failed to write MongoDB dead-letter document for message {MessageId} from queue {SourceQueue}.",
 510                id,
 511                sourceQueue);
 512            return false;
 513        }
 514    }
 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    {
 524        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 525        using var cursor = await _messages.WatchAsync(
 526            BuildQueueWatchPipeline(queue),
 527            cancellationToken: cancellationToken).ConfigureAwait(false);
 528        while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
 529        {
 530            foreach (var _ in cursor.Current)
 531                await onNotification().ConfigureAwait(false);
 532        }
 533    }
 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
 537        => new EmptyPipelineDefinition<ChangeStreamDocument<MongoTransportMessageDocument>>()
 538            .Match(new BsonDocument("$and", new BsonArray
 539            {
 540                new BsonDocument("operationType", "insert"),
 541                new BsonDocument("fullDocument.queue", queue)
 542            }));
 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)
 546        => exception is MongoCommandException commandException
 547           && (commandException.Code == 40573
 548               || 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    {
 557        if (_options.DeadLetterRetention is not { } retention || !ShouldPruneDeadLetters())
 558            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.
 562        await _messages.DeleteManyAsync(
 563            BuildDeadLetterPruneFilter(_options.DeadLetterQueue, retention),
 564            new DeleteOptions { Collation = Collation.Simple },
 565            cancellationToken).ConfigureAwait(false);
 566    }
 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
 576        => Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Queue, deadLetterQueue)
 577           & new BsonDocumentFilterDefinition<MongoTransportMessageDocument>(new BsonDocument(
 578               "$expr",
 579               new BsonDocument("$lt", new BsonArray
 580               {
 581                   "$created_at",
 582                   new BsonDocument("$subtract", new BsonArray { "$$NOW", retention.TotalMilliseconds })
 583               })));
 584
 585    private bool ShouldPruneDeadLetters()
 586    {
 587        var now = DateTime.UtcNow.Ticks;
 588        var last = Interlocked.Read(ref _lastDeadLetterPruneTicks);
 589        return now - last >= DeadLetterPruneThrottle.Ticks
 590            && 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    {
 599        var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"asyncresponse:deadletter:{sourceId:N}"));
 600        return new Guid(hash.AsSpan(0, 16));
 601    }
 602
 603    private static string Sanitize(string value) => value.Replace('\r', ' ').Replace('\n', ' ');
 604
 605    private static readonly TimeSpan DeadLetterPruneThrottle = TimeSpan.FromMinutes(1);
 606
 607    private static readonly IReadOnlyDictionary<string, string> EmptyHeaders =
 608        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    {
 613        _ensureGate.Dispose();
 614        (_ownedClient as IDisposable)?.Dispose();
 615    }
 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)]
 1318632    public Guid Id { get; set; }
 633
 634    [BsonElement("queue")]
 1330635    public string Queue { get; set; } = "";
 636
 637    [BsonElement("payload")]
 1339638    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))]
 1732645    public Dictionary<string, string>? Headers { get; set; }
 646
 647    [BsonElement("created_at")]
 420648    public DateTime CreatedAtUtc { get; set; }
 649
 650    [BsonElement("available_at")]
 420651    public DateTime AvailableAtUtc { get; set; }
 652
 653    [BsonElement("locked_until")]
 418654    public DateTime? LockedUntilUtc { get; set; }
 655
 656    [BsonElement("lock_id")]
 657    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 418658    public Guid? LockId { get; set; }
 659
 660    [BsonElement("attempts")]
 868661    public int Attempts { get; set; }
 662
 663    [BsonElement("dead_letter_reason")]
 418664    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}