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

Information
Class: AsyncResponse.Transports.MongoDB.MongoDbTransportStore
Assembly: AsyncResponse.Transports.MongoDB
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/Transports/AsyncResponse.Transports.MongoDB/MongoDbTransportStore.cs
Line coverage
96%
Covered lines: 207
Uncovered lines: 8
Coverable lines: 215
Total lines: 454
Line coverage: 96.2%
Branch coverage
92%
Covered branches: 39
Total branches: 42
Branch coverage: 92.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
EnsureCreatedAsync()100%66100%
PublishAsync()100%11100%
TryClaimAsync()100%44100%
BuildClaimFilter(...)100%11100%
BuildClaimUpdate(...)100%11100%
ClaimBatchAsync()83.33%66100%
InsertAsync()75%44100%
AckAsync()100%11100%
NakAsync()100%11100%
RenewLeaseAsync()100%210%
BuildRenewUpdate(...)100%11100%
BuildNakUpdate(...)100%11100%
DeadLetterAsync()75%8886.96%
WatchQueueAsync()100%22100%
BuildQueueWatchPipeline(...)100%11100%
IsChangeStreamUnsupported(...)100%44100%
PruneDeadLettersIfDueAsync()100%44100%
ShouldPruneDeadLetters()100%22100%
DeadLetterId(...)100%11100%
Sanitize(...)100%11100%
.cctor()100%11100%
Dispose()100%22100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/Transports/AsyncResponse.Transports.MongoDB/MongoDbTransportStore.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Microsoft.Extensions.Options;
 3using MongoDB.Bson;
 4using MongoDB.Bson.Serialization.Attributes;
 5using MongoDB.Bson.Serialization.Options;
 6using MongoDB.Driver;
 7using System.Runtime.CompilerServices;
 8using System.Security.Cryptography;
 9using System.Text;
 10
 11namespace AsyncResponse.Transports.MongoDB;
 12
 13internal enum MongoDbSubscriberRole
 14{
 15    Worker,
 16    ResponseIngress
 17}
 18
 19/// <summary>A claimed MongoDB transport document, decoupled from driver types for dispatch tests.</summary>
 20/// <remarks>
 21/// <c>RenewAsync</c> extends the claim's lease (<c>locked_until</c>) by the original lock timeout,
 22/// fenced on the claim's <c>lock_id</c>; it returns <c>false</c> when the fence no longer matches
 23/// (the lease lapsed and another subscriber re-claimed the document).
 24/// </remarks>
 25internal sealed record MongoDbTransportDelivery(
 26    Guid Id,
 27    string Queue,
 28    string Payload,
 29    IReadOnlyDictionary<string, string> Headers,
 30    int Attempt,
 31    Func<ValueTask> AckAsync,
 32    Func<TimeSpan, ValueTask> NakAsync,
 33    Func<Exception, bool, CancellationToken, ValueTask<bool>> DeadLetterAsync,
 34    Func<ValueTask<bool>> RenewAsync);
 35
 36/// <summary>Small document adapter for the MongoDB transport queue collection.</summary>
 37internal sealed class MongoDbTransportStore : IDisposable
 38{
 39    private readonly IMongoCollection<MongoTransportMessageDocument> _messages;
 40    private readonly MongoDbAsyncResponseTransportOptions _options;
 41    private readonly ILogger<MongoDbTransportStore>? _logger;
 342    private readonly SemaphoreSlim _ensureGate = new(1, 1);
 43    private readonly IMongoClient? _ownedClient;
 44    private bool _created;
 45    private long _lastDeadLetterPruneTicks;
 46
 347    public MongoDbTransportStore(
 348        IMongoDatabase database,
 349        IOptions<MongoDbAsyncResponseTransportOptions> options,
 350        IMongoClient? ownedClient = null,
 351        ILogger<MongoDbTransportStore>? logger = null)
 52    {
 353        _options = options.Value;
 354        _logger = logger;
 355        MongoDbTransportOptionsValidator.ValidateCommon(_options);
 356        _messages = database.GetCollection<MongoTransportMessageDocument>(_options.MessageCollection);
 357        _ownedClient = ownedClient;
 358    }
 59
 60    public async Task EnsureCreatedAsync(CancellationToken cancellationToken = default)
 61    {
 362        if (_created || !_options.AutoCreateIndexes)
 363            return;
 64
 165        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 66        try
 67        {
 168            if (_created)
 169                return;
 70
 171            await _messages.Indexes.CreateOneAsync(
 172                new CreateIndexModel<MongoTransportMessageDocument>(
 173                    Builders<MongoTransportMessageDocument>.IndexKeys
 174                        .Ascending(item => item.Queue)
 175                        .Ascending(item => item.AvailableAtUtc)
 176                        .Ascending(item => item.CreatedAtUtc),
 177                    new CreateIndexOptions { Name = $"{_options.MessageCollection}_claim_idx" }),
 178                cancellationToken: cancellationToken).ConfigureAwait(false);
 179            await _messages.Indexes.CreateOneAsync(
 180                new CreateIndexModel<MongoTransportMessageDocument>(
 181                    Builders<MongoTransportMessageDocument>.IndexKeys.Ascending(item => item.CreatedAtUtc),
 182                    new CreateIndexOptions { Name = $"{_options.MessageCollection}_created_idx" }),
 183                cancellationToken: cancellationToken).ConfigureAwait(false);
 184            _created = true;
 185        }
 86        finally
 87        {
 188            _ensureGate.Release();
 89        }
 390    }
 91
 92    /// <summary>
 93    /// Publishes a queue document. The caller supplies the id so a retried publish is idempotent —
 94    /// a duplicate-key insert is treated as success rather than enqueuing the same job twice.
 95    /// </summary>
 96    public async Task PublishAsync(
 97        Guid id,
 98        string queue,
 99        string payload,
 100        IReadOnlyDictionary<string, string>? headers,
 101        CancellationToken cancellationToken)
 102    {
 3103        await InsertAsync(id, queue, payload, headers, deadLetterReason: null, cancellationToken).ConfigureAwait(false);
 3104        await PruneDeadLettersIfDueAsync(cancellationToken).ConfigureAwait(false);
 3105    }
 106
 107    public async Task<MongoDbTransportDelivery?> TryClaimAsync(string queue, TimeSpan lockTimeout, CancellationToken can
 108    {
 3109        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3110        var lockId = Guid.NewGuid();
 111
 112        // findOneAndUpdate is atomic per document: of all competing consumers, exactly one observes
 113        // the document unlocked and stamps its lock_id/locked_until in the same server-side step.
 3114        var claimed = await _messages.FindOneAndUpdateAsync(
 3115            BuildClaimFilter(queue),
 3116            BuildClaimUpdate(lockId, lockTimeout),
 3117            new FindOneAndUpdateOptions<MongoTransportMessageDocument>
 3118            {
 3119                Sort = Builders<MongoTransportMessageDocument>.Sort.Ascending(item => item.CreatedAtUtc),
 3120                ReturnDocument = ReturnDocument.After
 3121            },
 3122            cancellationToken).ConfigureAwait(false);
 3123        if (claimed is null)
 3124            return null;
 125
 3126        var headers = claimed.Headers is null
 3127            ? EmptyHeaders
 3128            : new Dictionary<string, string>(claimed.Headers, StringComparer.OrdinalIgnoreCase);
 129
 3130        return new MongoDbTransportDelivery(
 3131            claimed.Id,
 3132            claimed.Queue,
 3133            claimed.Payload,
 3134            headers,
 3135            claimed.Attempts,
 3136            () => AckAsync(claimed.Id, lockId),
 3137            delay => NakAsync(claimed.Id, lockId, delay),
 3138            (exception, deleteOriginal, token) => DeadLetterAsync(claimed.Id, lockId, queue, claimed.Payload, headers, e
 1139            () => RenewLeaseAsync(claimed.Id, lockId, lockTimeout));
 3140    }
 141
 142    /// <summary>
 143    /// Claim filter: available and not (still) locked, evaluated against the server clock
 144    /// (<c>$$NOW</c>) so publisher/consumer clock skew never fences messages in or out.
 145    /// A missing <c>locked_until</c> compares as null and therefore as expired.
 146    /// </summary>
 147    internal static FilterDefinition<MongoTransportMessageDocument> BuildClaimFilter(string queue)
 3148        => Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Queue, queue)
 3149           & new BsonDocumentFilterDefinition<MongoTransportMessageDocument>(new BsonDocument(
 3150               "$expr",
 3151               new BsonDocument("$and", new BsonArray
 3152               {
 3153                   new BsonDocument("$lte", new BsonArray { "$available_at", "$$NOW" }),
 3154                   new BsonDocument("$or", new BsonArray
 3155                   {
 3156                       new BsonDocument("$eq", new BsonArray { "$locked_until", BsonNull.Value }),
 3157                       new BsonDocument("$lte", new BsonArray { "$locked_until", "$$NOW" })
 3158                   })
 3159               })));
 160
 161    internal static UpdateDefinition<MongoTransportMessageDocument> BuildClaimUpdate(Guid lockId, TimeSpan lockTimeout)
 3162        => Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 3163        {
 3164            new BsonDocument("$set", new BsonDocument
 3165            {
 3166                ["attempts"] = new BsonDocument("$add", new BsonArray
 3167                {
 3168                    new BsonDocument("$ifNull", new BsonArray { "$attempts", 0 }),
 3169                    1
 3170                }),
 3171                ["locked_until"] = new BsonDocument("$add", new BsonArray { "$$NOW", lockTimeout.TotalMilliseconds }),
 3172                ["lock_id"] = new BsonBinaryData(lockId, GuidRepresentation.Standard)
 3173            })
 3174        });
 175
 176    public async IAsyncEnumerable<MongoDbTransportDelivery> ClaimBatchAsync(
 177        string queue,
 178        int batchSize,
 179        TimeSpan lockTimeout,
 180        [EnumeratorCancellation] CancellationToken cancellationToken)
 181    {
 3182        for (var i = 0; i < batchSize; i++)
 183        {
 3184            var delivery = await TryClaimAsync(queue, lockTimeout, cancellationToken).ConfigureAwait(false);
 3185            if (delivery is null)
 3186                yield break;
 3187            yield return delivery;
 188        }
 3189    }
 190
 191    private async Task InsertAsync(
 192        Guid id,
 193        string queue,
 194        string payload,
 195        IReadOnlyDictionary<string, string>? headers,
 196        string? deadLetterReason,
 197        CancellationToken cancellationToken)
 198    {
 3199        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3200        var now = DateTime.UtcNow;
 3201        var document = new MongoTransportMessageDocument
 3202        {
 3203            Id = id,
 3204            Queue = queue,
 3205            Payload = payload,
 3206            Headers = headers is null
 3207                ? new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase)
 3208                : new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase),
 3209            CreatedAtUtc = now,
 3210            // "Available immediately on arrival": InsertOne cannot evaluate $$NOW, and stamping the
 3211            // client clock here would let client-ahead-of-server skew hide a fresh message from the
 3212            // server-clock claim filter until the skew elapsed. Epoch expresses what the SQL stores'
 3213            // "available_at DEFAULT now()" expresses; a NAK re-stamps a real server-relative time.
 3214            AvailableAtUtc = DateTime.UnixEpoch,
 3215            Attempts = 0,
 3216            DeadLetterReason = deadLetterReason
 3217        };
 218        try
 219        {
 3220            await _messages.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
 3221        }
 3222        catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
 223        {
 224            // A retried publish found the document already inserted: idempotent success.
 1225        }
 3226    }
 227
 228    private async ValueTask AckAsync(Guid id, Guid lockId)
 229    {
 3230        await _messages.DeleteOneAsync(
 3231            Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 3232            & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 3233            CancellationToken.None).ConfigureAwait(false);
 3234    }
 235
 236    private async ValueTask NakAsync(Guid id, Guid lockId, TimeSpan delay)
 237    {
 3238        await _messages.UpdateOneAsync(
 3239            Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 3240            & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 3241            BuildNakUpdate(delay),
 3242            options: null,
 3243            CancellationToken.None).ConfigureAwait(false);
 3244    }
 245
 246    private async ValueTask<bool> RenewLeaseAsync(Guid id, Guid lockId, TimeSpan lockTimeout)
 247    {
 0248        var result = await _messages.UpdateOneAsync(
 0249            Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Id, id)
 0250            & Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.LockId, lockId),
 0251            BuildRenewUpdate(lockTimeout),
 0252            options: null,
 0253            CancellationToken.None).ConfigureAwait(false);
 0254        return result.MatchedCount > 0;
 0255    }
 256
 257    /// <summary>
 258    /// Fenced lease renewal: extends <c>locked_until</c> from the server clock (<c>$$NOW</c>) only
 259    /// while the claim's <c>lock_id</c> fence still matches, mirroring the claim update.
 260    /// </summary>
 261    internal static UpdateDefinition<MongoTransportMessageDocument> BuildRenewUpdate(TimeSpan lockTimeout)
 3262        => Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 3263        {
 3264            new BsonDocument("$set", new BsonDocument
 3265            {
 3266                ["locked_until"] = new BsonDocument("$add", new BsonArray { "$$NOW", lockTimeout.TotalMilliseconds })
 3267            })
 3268        });
 269
 270    internal static UpdateDefinition<MongoTransportMessageDocument> BuildNakUpdate(TimeSpan delay)
 3271        => Builders<MongoTransportMessageDocument>.Update.Pipeline(new[]
 3272        {
 3273            new BsonDocument("$set", new BsonDocument
 3274            {
 3275                ["available_at"] = new BsonDocument("$add", new BsonArray { "$$NOW", delay.TotalMilliseconds }),
 3276                ["locked_until"] = BsonNull.Value,
 3277                ["lock_id"] = BsonNull.Value
 3278            })
 3279        });
 280
 281    private async ValueTask<bool> DeadLetterAsync(
 282        Guid id,
 283        Guid lockId,
 284        string sourceQueue,
 285        string payload,
 286        IReadOnlyDictionary<string, string> headers,
 287        Exception exception,
 288        bool deleteOriginal,
 289        CancellationToken cancellationToken)
 290    {
 3291        if (!_options.DeadLetterEnabled)
 292        {
 3293            if (deleteOriginal)
 2294                await AckAsync(id, lockId).ConfigureAwait(false);
 2295            return true;
 296        }
 297
 3298        var deadHeaders = new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase)
 3299        {
 3300            ["AR-DeadLetter-Reason"] = Sanitize(exception.Message),
 3301            ["AR-DeadLetter-Source-Queue"] = sourceQueue
 3302        };
 303
 304        try
 305        {
 3306            if (!deleteOriginal)
 307            {
 3308                await InsertAsync(Guid.NewGuid(), _options.DeadLetterQueue, payload, deadHeaders, exception.Message, can
 1309                return true;
 310            }
 311
 312            // MongoDB has no cross-document transaction we can rely on here (the transport must also
 313            // work on standalone servers), so the DLQ insert uses an id derived deterministically
 314            // from the source document: if a crash lands between the insert and the delete, the
 315            // redelivered message dead-letters onto the same id and the duplicate-key insert is
 316            // swallowed — the DLQ never accumulates copies of one poison message.
 1317            await InsertAsync(DeadLetterId(id), _options.DeadLetterQueue, payload, deadHeaders, exception.Message, cance
 1318            await AckAsync(id, lockId).ConfigureAwait(false);
 1319            return true;
 320        }
 3321        catch (Exception ex)
 322        {
 323            // Callers decide the redelivery consequence from the false return; log the cause here so
 324            // a failing dead-letter write is never silent.
 3325            _logger?.LogError(
 3326                ex,
 3327                "Failed to write MongoDB dead-letter document for message {MessageId} from queue {SourceQueue}.",
 3328                id,
 3329                sourceQueue);
 2330            return false;
 331        }
 3332    }
 333
 334    /// <summary>
 335    /// Watches the queue collection with a change stream and invokes
 336    /// <paramref name="onNotification"/> whenever a document is inserted into
 337    /// <paramref name="queue"/>. Runs until cancellation or a stream error; callers treat the wake
 338    /// as an optimization over <see cref="MongoDbSubscriberOptions.EmptyPollDelay"/> polling.
 339    /// </summary>
 340    public async Task WatchQueueAsync(string queue, Func<Task> onNotification, CancellationToken cancellationToken)
 341    {
 3342        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3343        using var cursor = await _messages.WatchAsync(
 3344            BuildQueueWatchPipeline(queue),
 3345            cancellationToken: cancellationToken).ConfigureAwait(false);
 3346        while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
 347        {
 3348            foreach (var _ in cursor.Current)
 3349                await onNotification().ConfigureAwait(false);
 350        }
 3351    }
 352
 353    /// <summary>Change-stream pipeline for queue wakes: a <c>$match</c> on inserts into one logical queue.</summary>
 354    internal static PipelineDefinition<ChangeStreamDocument<MongoTransportMessageDocument>, ChangeStreamDocument<MongoTr
 3355        => new EmptyPipelineDefinition<ChangeStreamDocument<MongoTransportMessageDocument>>()
 3356            .Match(new BsonDocument("$and", new BsonArray
 3357            {
 3358                new BsonDocument("operationType", "insert"),
 3359                new BsonDocument("fullDocument.queue", queue)
 3360            }));
 361
 362    /// <summary>Returns <c>true</c> when the server rejected the change stream itself (not a transient cursor error).</
 363    internal static bool IsChangeStreamUnsupported(Exception exception)
 3364        => exception is MongoCommandException commandException
 3365           && (commandException.Code == 40573
 3366               || commandException.Message.Contains("only supported on replica sets", StringComparison.OrdinalIgnoreCase
 367
 368    /// <summary>
 369    /// Opportunistically deletes dead-letter documents older than the configured retention. No-op
 370    /// unless <see cref="MongoDbAsyncResponseTransportOptions.DeadLetterRetention"/> is set, and
 371    /// throttled so the delete runs at most once per minute regardless of publish rate.
 372    /// </summary>
 373    private async Task PruneDeadLettersIfDueAsync(CancellationToken cancellationToken)
 374    {
 3375        if (_options.DeadLetterRetention is not { } retention || !ShouldPruneDeadLetters())
 3376            return;
 377
 1378        await _messages.DeleteManyAsync(
 1379            Builders<MongoTransportMessageDocument>.Filter.Eq(item => item.Queue, _options.DeadLetterQueue)
 1380            & Builders<MongoTransportMessageDocument>.Filter.Lt(item => item.CreatedAtUtc, DateTime.UtcNow.Subtract(rete
 1381            cancellationToken).ConfigureAwait(false);
 3382    }
 383
 384    private bool ShouldPruneDeadLetters()
 385    {
 1386        var now = DateTime.UtcNow.Ticks;
 1387        var last = Interlocked.Read(ref _lastDeadLetterPruneTicks);
 1388        return now - last >= DeadLetterPruneThrottle.Ticks
 1389            && Interlocked.CompareExchange(ref _lastDeadLetterPruneTicks, now, last) == last;
 390    }
 391
 392    /// <summary>
 393    /// Deterministic dead-letter document id for a source message: the same poison message always
 394    /// maps to the same DLQ id, making the insert-then-delete pair idempotent under crash-redelivery.
 395    /// </summary>
 396    internal static Guid DeadLetterId(Guid sourceId)
 397    {
 3398        var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"asyncresponse:deadletter:{sourceId:N}"));
 3399        return new Guid(hash.AsSpan(0, 16));
 400    }
 401
 3402    private static string Sanitize(string value) => value.Replace('\r', ' ').Replace('\n', ' ');
 403
 3404    private static readonly TimeSpan DeadLetterPruneThrottle = TimeSpan.FromMinutes(1);
 405
 3406    private static readonly IReadOnlyDictionary<string, string> EmptyHeaders =
 3407        new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase);
 408
 409    /// <summary>Disposes the Mongo client when the store created (and therefore owns) it.</summary>
 410    public void Dispose()
 411    {
 3412        _ensureGate.Dispose();
 3413        (_ownedClient as IDisposable)?.Dispose();
 2414    }
 415}
 416
 417internal sealed class MongoTransportMessageDocument
 418{
 419    [BsonId]
 420    [BsonElement("_id")]
 421    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 422    public Guid Id { get; set; }
 423
 424    [BsonElement("queue")]
 425    public string Queue { get; set; } = "";
 426
 427    [BsonElement("payload")]
 428    public string Payload { get; set; } = "";
 429
 430    // Array-of-documents representation keeps arbitrary header names (dots, dollars) legal as values
 431    // rather than as BSON field names.
 432    [BsonElement("headers")]
 433    [BsonDictionaryOptions(DictionaryRepresentation.ArrayOfDocuments)]
 434    public Dictionary<string, string>? Headers { get; set; }
 435
 436    [BsonElement("created_at")]
 437    public DateTime CreatedAtUtc { get; set; }
 438
 439    [BsonElement("available_at")]
 440    public DateTime AvailableAtUtc { get; set; }
 441
 442    [BsonElement("locked_until")]
 443    public DateTime? LockedUntilUtc { get; set; }
 444
 445    [BsonElement("lock_id")]
 446    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 447    public Guid? LockId { get; set; }
 448
 449    [BsonElement("attempts")]
 450    public int Attempts { get; set; }
 451
 452    [BsonElement("dead_letter_reason")]
 453    public string? DeadLetterReason { get; set; }
 454}