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

Information
Class: AsyncResponse.Channels.MongoDB.MongoDbChannelStore<TDocument>
Assembly: AsyncResponse.Channels.MongoDB
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/Channels/AsyncResponse.Channels.MongoDB/MongoDbChannelStore.cs
Line coverage
99%
Covered lines: 290
Uncovered lines: 1
Coverable lines: 291
Total lines: 599
Line coverage: 99.6%
Branch coverage
100%
Covered branches: 64
Total branches: 64
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/Channels/AsyncResponse.Channels.MongoDB/MongoDbChannelStore.cs

#LineLine coverage
 1using Microsoft.Extensions.Options;
 2using MongoDB.Bson;
 3using MongoDB.Bson.Serialization.Attributes;
 4using MongoDB.Driver;
 5using System.Runtime.CompilerServices;
 6
 7namespace AsyncResponse.Channels.MongoDB;
 8
 9internal readonly record struct MongoDbChannelMessage(
 10    Guid Id,
 11    string CorrelationId,
 12    string EnvelopeJson,
 13    DateTimeOffset CreatedAtUtc,
 14    DateTimeOffset? AckedAtUtc = null);
 15
 16/// <summary>Document adapter for the MongoDB channel collections and change-stream wake.</summary>
 17internal sealed class MongoDbChannelStore : IDisposable
 18{
 19    private readonly IMongoCollection<MongoRecoveryStateDocument> _recovery;
 20    private readonly IMongoCollection<MongoChannelMessageDocument> _messages;
 21    private readonly IMongoCollection<MongoChannelSubscriberDocument> _subscribers;
 22    private readonly IMongoDatabase _database;
 23    private readonly MongoDbAsyncResponseChannelOptions _options;
 324    private readonly SemaphoreSlim _ensureGate = new(1, 1);
 25    private readonly IMongoClient? _ownedClient;
 26    private bool _created;
 27
 328    public MongoDbChannelStore(
 329        IMongoDatabase database,
 330        IOptions<MongoDbAsyncResponseChannelOptions> options,
 331        IMongoClient? ownedClient = null)
 32    {
 333        _options = options.Value;
 334        _options.Validate();
 335        _database = database;
 336        _recovery = database.GetCollection<MongoRecoveryStateDocument>(_options.RecoveryStateCollection);
 337        _messages = database.GetCollection<MongoChannelMessageDocument>(_options.MessageCollection);
 338        _subscribers = database.GetCollection<MongoChannelSubscriberDocument>(_options.SubscriberCollection);
 339        _ownedClient = ownedClient;
 340    }
 41
 42    public async Task EnsureCreatedAsync(CancellationToken cancellationToken = default)
 43    {
 344        if (_created || !_options.AutoCreateIndexes)
 345            return;
 46
 347        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 48        try
 49        {
 350            if (_created)
 351                return;
 52
 53            // TTL indexes (expireAfterSeconds = 0 on the expiry timestamp) make MongoDB itself reap
 54            // expired documents — no application-side pruning needed. Reads still filter on the
 55            // expiry because the TTL monitor only runs periodically (~60s).
 356            await CreateTtlIndexAsync(
 357                _recovery,
 358                Builders<MongoRecoveryStateDocument>.IndexKeys.Ascending(item => item.ExpiresAtUtc),
 359                $"{_options.RecoveryStateCollection}_expires_idx",
 360                cancellationToken).ConfigureAwait(false);
 361            await _recovery.Indexes.CreateOneAsync(
 362                new CreateIndexModel<MongoRecoveryStateDocument>(
 363                    Builders<MongoRecoveryStateDocument>.IndexKeys
 364                        .Ascending(item => item.CorrelationId)
 365                        .Ascending(item => item.RegisteredAtUtc),
 366                    new CreateIndexOptions { Name = $"{_options.RecoveryStateCollection}_correlation_idx" }),
 367                cancellationToken: cancellationToken).ConfigureAwait(false);
 68
 369            await CreateTtlIndexAsync(
 370                _messages,
 371                Builders<MongoChannelMessageDocument>.IndexKeys.Ascending(item => item.ExpiresAtUtc),
 372                $"{_options.MessageCollection}_expires_idx",
 373                cancellationToken).ConfigureAwait(false);
 374            await _messages.Indexes.CreateOneAsync(
 375                new CreateIndexModel<MongoChannelMessageDocument>(
 376                    Builders<MongoChannelMessageDocument>.IndexKeys
 377                        .Ascending(item => item.CorrelationId)
 378                        .Ascending(item => item.CreatedAtUtc),
 379                    new CreateIndexOptions { Name = $"{_options.MessageCollection}_correlation_created_idx" }),
 380                cancellationToken: cancellationToken).ConfigureAwait(false);
 81
 382            await CreateTtlIndexAsync(
 383                _subscribers,
 384                Builders<MongoChannelSubscriberDocument>.IndexKeys.Ascending(item => item.ExpiresAtUtc),
 385                $"{_options.SubscriberCollection}_expires_idx",
 386                cancellationToken).ConfigureAwait(false);
 387            await _subscribers.Indexes.CreateOneAsync(
 388                new CreateIndexModel<MongoChannelSubscriberDocument>(
 389                    Builders<MongoChannelSubscriberDocument>.IndexKeys.Ascending(item => item.CorrelationId),
 390                    new CreateIndexOptions { Name = $"{_options.SubscriberCollection}_correlation_idx" }),
 391                cancellationToken: cancellationToken).ConfigureAwait(false);
 92
 393            _created = true;
 394        }
 95        finally
 96        {
 397            _ensureGate.Release();
 98        }
 399    }
 100
 101    private static async Task CreateTtlIndexAsync<TDocument>(
 102        IMongoCollection<TDocument> collection,
 103        IndexKeysDefinition<TDocument> keys,
 104        string indexName,
 105        CancellationToken cancellationToken)
 106    {
 3107        var model = new CreateIndexModel<TDocument>(
 3108            keys,
 3109            new CreateIndexOptions { Name = indexName, ExpireAfter = TimeSpan.Zero });
 110        try
 111        {
 3112            await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
 3113        }
 2114        catch (MongoCommandException ex) when (ex.Code is 85 or 86)
 115        {
 116            // IndexOptionsConflict/IndexKeySpecsConflict: an earlier deployment created the
 117            // same-named index with different options. Replace it in place.
 3118            await collection.Indexes.DropOneAsync(indexName, cancellationToken).ConfigureAwait(false);
 2119            await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
 120        }
 3121    }
 122
 123    public async Task SaveRecoveryStateAsync(string correlationId, RecoveryState state, TimeSpan ttl, CancellationToken 
 124    {
 1125        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 126        // Upsert pipeline stamped with the server clock ($$NOW), matching the message-side
 127        // discipline (and the PG/SqlServer DB-clock discipline): app-clock expiry math would shift
 128        // the recovery window by whatever the client clock is skewed.
 1129        await _recovery.UpdateOneAsync(
 1130            Builders<MongoRecoveryStateDocument>.Filter.Eq(item => item.Id, RegistrationKey(correlationId, state.Registr
 1131            BuildRecoveryStateUpsertPipeline(correlationId, state, ttl),
 1132            new UpdateOptions { IsUpsert = true },
 1133            cancellationToken).ConfigureAwait(false);
 1134    }
 135
 136    /// <summary>
 137    /// Upsert pipeline for a recovery registration: every field is overwritten (a re-save refreshes
 138    /// the registration), with <c>expires_at</c>/<c>registered_at</c> computed on the server clock.
 139    /// </summary>
 140    internal static UpdateDefinition<MongoRecoveryStateDocument> BuildRecoveryStateUpsertPipeline(
 141        string correlationId,
 142        RecoveryState state,
 143        TimeSpan ttl)
 1144        => Builders<MongoRecoveryStateDocument>.Update.Pipeline(new[]
 1145        {
 1146            new BsonDocument("$set", new BsonDocument
 1147            {
 1148                ["correlation_id"] = correlationId,
 1149                ["registration_id"] = new BsonBinaryData(state.RegistrationId, GuidRepresentation.Standard),
 1150                ["state_json"] = AsyncResponseJson.Serialize(state),
 1151                ["expires_at"] = new BsonDocument("$add", new BsonArray { "$$NOW", ttl.TotalMilliseconds }),
 1152                ["registered_at"] = "$$NOW"
 1153            })
 1154        });
 155
 156    public async Task<IReadOnlyList<string>> LoadRecoveryStatesAsync(string correlationId, CancellationToken cancellatio
 157    {
 1158        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 1159        var filter = Builders<MongoRecoveryStateDocument>.Filter.Eq(item => item.CorrelationId, correlationId)
 1160                     & NotExpiredOnServerClock<MongoRecoveryStateDocument>();
 1161        var documents = await _recovery.Find(filter)
 1162            .SortBy(item => item.RegisteredAtUtc)
 1163            .Project(item => item.StateJson)
 1164            .ToListAsync(cancellationToken).ConfigureAwait(false);
 1165        return documents;
 1166    }
 167
 168    public async Task<bool> DeleteRecoveryStateAsync(string correlationId, Guid registrationId, CancellationToken cancel
 169    {
 1170        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 1171        var single = await _recovery.DeleteOneAsync(
 1172            Builders<MongoRecoveryStateDocument>.Filter.Eq(item => item.Id, RegistrationKey(correlationId, registrationI
 1173            cancellationToken).ConfigureAwait(false);
 1174        return single.DeletedCount > 0;
 1175    }
 176
 177    public async IAsyncEnumerable<string> ScanRecoveryStateJsonAsync([EnumeratorCancellation] CancellationToken cancella
 178    {
 1179        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 1180        var filter = NotExpiredOnServerClock<MongoRecoveryStateDocument>();
 1181        using var cursor = await _recovery.Find(filter)
 1182            .SortBy(item => item.RegisteredAtUtc)
 1183            .Project(item => item.StateJson)
 1184            .ToCursorAsync(cancellationToken).ConfigureAwait(false);
 1185        while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
 186        {
 1187            foreach (var json in cursor.Current)
 1188                yield return json;
 189        }
 1190    }
 191
 192    /// <summary>
 193    /// Inserts a response envelope document. The caller supplies the message id and the write is an
 194    /// upsert that preserves an existing document, so a retried publish is idempotent rather than
 195    /// duplicating the response. Timestamps are stamped with the server clock (<c>$$NOW</c>) so
 196    /// dispatch watermarks never mix client and server clocks. The insert itself is the wake signal:
 197    /// every process's change stream observes it.
 198    /// </summary>
 199    public Task<DateTimeOffset> InsertMessageAsync(Guid id, string correlationId, string envelopeJson, TimeSpan retentio
 1200        => AsyncResponseRetry.ExecuteAsync(
 1201            token => InsertMessageOnceAsync(id, correlationId, envelopeJson, retention, token),
 1202            IsTransient,
 1203            _options.PublishMaxAttempts,
 1204            _options.PublishRetryBaseDelay,
 1205            _options.PublishRetryMaxDelay,
 1206            cancellationToken);
 207
 208    private async Task<DateTimeOffset> InsertMessageOnceAsync(Guid id, string correlationId, string envelopeJson, TimeSp
 209    {
 1210        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 211
 212        // findOneAndUpdate instead of updateOne so the returned document carries the
 213        // server-stamped ($$NOW) created_at — the original document's on a publish retry, per the
 214        // pipeline's $ifNull — for the same-process fast path's watermark comparison.
 1215        var document = await _messages.FindOneAndUpdateAsync(
 1216            Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.Id, id),
 1217            BuildInsertMessagePipeline(correlationId, envelopeJson, retention),
 1218            new FindOneAndUpdateOptions<MongoChannelMessageDocument>
 1219            {
 1220                IsUpsert = true,
 1221                ReturnDocument = ReturnDocument.After
 1222            },
 1223            cancellationToken).ConfigureAwait(false);
 224
 225        // Upsert + ReturnDocument.After cannot return null from a healthy server. If a driver
 226        // anomaly ever surfaces one, persistence is UNKNOWN — reporting success with a fabricated
 227        // app-clock timestamp would both lie about it and feed a client clock into the
 228        // server-clock watermark. Fail instead, so the retry/error path runs.
 1229        return document is null
 1230            ? throw new InvalidOperationException(
 1231                $"MongoDB response upsert for message {id} returned no document despite IsUpsert + ReturnDocument.After;
 1232            : new DateTimeOffset(document.CreatedAtUtc, TimeSpan.Zero);
 1233    }
 234
 235    /// <summary>
 236    /// Upsert pipeline for a response envelope: <c>$ifNull</c> keeps the original server-stamped
 237    /// timestamps and claim flags when a publish retry finds the document already present.
 238    /// </summary>
 239    internal static UpdateDefinition<MongoChannelMessageDocument> BuildInsertMessagePipeline(
 240        string correlationId,
 241        string envelopeJson,
 242        TimeSpan retention)
 3243        => Builders<MongoChannelMessageDocument>.Update.Pipeline(new[]
 3244        {
 3245            new BsonDocument("$set", new BsonDocument
 3246            {
 3247                ["correlation_id"] = correlationId,
 3248                ["envelope_json"] = envelopeJson,
 3249                ["created_at"] = new BsonDocument("$ifNull", new BsonArray { "$created_at", "$$NOW" }),
 3250                ["expires_at"] = new BsonDocument("$ifNull", new BsonArray
 3251                {
 3252                    "$expires_at",
 3253                    new BsonDocument("$add", new BsonArray { "$$NOW", retention.TotalMilliseconds })
 3254                }),
 3255                ["acked_at"] = new BsonDocument("$ifNull", new BsonArray { "$acked_at", BsonNull.Value }),
 3256                ["recovery_claimed"] = new BsonDocument("$ifNull", new BsonArray { "$recovery_claimed", false })
 3257            })
 3258        });
 259
 260    public async Task<IReadOnlyList<MongoDbChannelMessage>> LoadMessagesAsync(
 261        string correlationId,
 262        DateTimeOffset sinceUtc,
 263        int batchSize,
 264        DateTimeOffset? afterCreatedAtUtc,
 265        Guid? afterId,
 266        CancellationToken cancellationToken)
 267    {
 3268        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3269        var filter = Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.CorrelationId, correlationId)
 3270                     & Builders<MongoChannelMessageDocument>.Filter.Gte(item => item.CreatedAtUtc, sinceUtc.UtcDateTime)
 3271                     & Builders<MongoChannelMessageDocument>.Filter.Gt(item => item.ExpiresAtUtc, DateTime.UtcNow);
 3272        if (afterCreatedAtUtc is not null)
 273        {
 3274            var afterCreated = afterCreatedAtUtc.Value.UtcDateTime;
 3275            var cursorId = afterId ?? throw new ArgumentNullException(nameof(afterId));
 3276            filter &= Builders<MongoChannelMessageDocument>.Filter.Or(
 3277                Builders<MongoChannelMessageDocument>.Filter.Gt(item => item.CreatedAtUtc, afterCreated),
 3278                Builders<MongoChannelMessageDocument>.Filter.And(
 3279                    Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.CreatedAtUtc, afterCreated),
 3280                    Builders<MongoChannelMessageDocument>.Filter.Gt(item => item.Id, cursorId)));
 281        }
 3282        var documents = await _messages.Find(filter)
 3283            .Sort(Builders<MongoChannelMessageDocument>.Sort
 3284                .Ascending(item => item.CreatedAtUtc)
 3285                .Ascending(item => item.Id))
 3286            .Limit(batchSize)
 3287            .ToListAsync(cancellationToken).ConfigureAwait(false);
 3288        var messages = new List<MongoDbChannelMessage>(documents.Count);
 3289        foreach (var document in documents)
 3290            messages.Add(new MongoDbChannelMessage(
 3291                document.Id,
 3292                document.CorrelationId,
 3293                document.EnvelopeJson,
 3294                new DateTimeOffset(document.CreatedAtUtc, TimeSpan.Zero),
 3295                document.AckedAtUtc is { } acked ? new DateTimeOffset(acked, TimeSpan.Zero) : null));
 3296        return messages;
 3297    }
 298
 299    /// <summary>
 300    /// Atomically claims a message for live delivery via <c>findOneAndUpdate</c>: sets
 301    /// <c>acked_at</c> unless the publisher has already routed it to the lost-subscriber path
 302    /// (<c>recovery_claimed</c>). Returns <c>false</c> when recovery owns the message, so a
 303    /// slow-but-live waiter does not deliver a response the recovery callback already handled.
 304    /// Multiple processes may each win this claim, preserving cross-process fan-out, because it
 305    /// gates only on <c>recovery_claimed</c>, not on <c>acked_at</c>.
 306    /// </summary>
 307    public async Task<bool> TryClaimForDeliveryAsync(Guid messageId, CancellationToken cancellationToken)
 308    {
 3309        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3310        var claimed = await _messages.FindOneAndUpdateAsync(
 3311            BuildDeliveryClaimFilter(messageId),
 3312            BuildDeliveryClaimUpdate(),
 3313            new FindOneAndUpdateOptions<MongoChannelMessageDocument> { ReturnDocument = ReturnDocument.After },
 3314            cancellationToken).ConfigureAwait(false);
 3315        return claimed is not null;
 3316    }
 317
 318    internal static FilterDefinition<MongoChannelMessageDocument> BuildDeliveryClaimFilter(Guid messageId)
 3319        => Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.Id, messageId)
 3320           & Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.RecoveryClaimed, false)
 3321           & Builders<MongoChannelMessageDocument>.Filter.Gt(item => item.ExpiresAtUtc, DateTime.UtcNow);
 322
 323    internal static UpdateDefinition<MongoChannelMessageDocument> BuildDeliveryClaimUpdate()
 3324        => Builders<MongoChannelMessageDocument>.Update.Pipeline(new[]
 3325        {
 3326            new BsonDocument("$set", new BsonDocument(
 3327                "acked_at",
 3328                new BsonDocument("$ifNull", new BsonArray { "$acked_at", "$$NOW" })))
 3329        });
 330
 331    /// <summary>
 332    /// Atomically claims a message for the lost-subscriber path: sets <c>recovery_claimed</c> only
 333    /// while no waiter has delivered (<c>acked_at</c> is still null). Returns <c>true</c> when
 334    /// recovery wins; <c>false</c> means a live waiter already took the message, so the publisher
 335    /// must not also fire the recovery callback. Document-level atomicity of
 336    /// <c>findOneAndUpdate</c> serializes this against <see cref="TryClaimForDeliveryAsync"/>.
 337    /// </summary>
 338    public async Task<bool> TryClaimForRecoveryAsync(Guid messageId, CancellationToken cancellationToken)
 339    {
 3340        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3341        var claimed = await _messages.FindOneAndUpdateAsync(
 3342            BuildRecoveryClaimFilter(messageId),
 3343            Builders<MongoChannelMessageDocument>.Update.Set(item => item.RecoveryClaimed, true),
 3344            new FindOneAndUpdateOptions<MongoChannelMessageDocument> { ReturnDocument = ReturnDocument.After },
 3345            cancellationToken).ConfigureAwait(false);
 3346        return claimed is not null;
 3347    }
 348
 349    internal static FilterDefinition<MongoChannelMessageDocument> BuildRecoveryClaimFilter(Guid messageId)
 3350        => Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.Id, messageId)
 3351           & Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.AckedAtUtc, null);
 352
 353    /// <summary>Returns the server's current UTC time, used as a clock-safe delivery watermark.</summary>
 354    public async Task<DateTimeOffset> GetServerTimeUtcAsync(CancellationToken cancellationToken)
 355    {
 3356        var reply = await _database.RunCommandAsync<BsonDocument>(
 3357            new BsonDocument("hello", 1),
 3358            cancellationToken: cancellationToken).ConfigureAwait(false);
 3359        return reply.TryGetValue("localTime", out var localTime) && localTime is BsonDateTime serverTime
 3360            ? new DateTimeOffset(serverTime.ToUniversalTime(), TimeSpan.Zero)
 3361            : DateTimeOffset.UtcNow;
 3362    }
 363
 364    public async Task<bool> IsMessageAcknowledgedAsync(Guid messageId, CancellationToken cancellationToken)
 365    {
 3366        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3367        var filter = Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.Id, messageId)
 3368                     & Builders<MongoChannelMessageDocument>.Filter.Gt(item => item.ExpiresAtUtc, DateTime.UtcNow);
 369        // Direct member projection, not an anonymous type: anonymous projections lower to the
 370        // RequiresUnreferencedCode Expression.New(ctor, args, members) overload, which the ILC
 371        // trim analysis rejects (Roslyn's analyzer skips compiler-lowered expression trees, so
 372        // only Native AOT publishes catch it). Missing document and unacked document both come
 373        // back as null, which is exactly the contract here.
 3374        var ackedAtUtc = await _messages.Find(filter)
 3375            .Project(item => item.AckedAtUtc)
 3376            .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 3377        return ackedAtUtc is not null;
 3378    }
 379
 380    public async Task UpsertSubscriberAsync(string correlationId, Guid registrationId, string instanceId, TimeSpan ttl, 
 381    {
 3382        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3383        await _subscribers.UpdateOneAsync(
 3384            Builders<MongoChannelSubscriberDocument>.Filter.Eq(item => item.Id, RegistrationKey(correlationId, registrat
 3385            BuildSubscriberUpsertPipeline(correlationId, registrationId, instanceId, ttl),
 3386            new UpdateOptions { IsUpsert = true },
 3387            cancellationToken).ConfigureAwait(false);
 3388    }
 389
 390    /// <summary>
 391    /// Upsert pipeline for a subscriber liveness document, with <c>expires_at</c> computed on the
 392    /// server clock ($$NOW) — app-clock liveness math would let a skewed client look dead (or
 393    /// immortal) to publishers comparing against server-side expiry.
 394    /// </summary>
 395    internal static UpdateDefinition<MongoChannelSubscriberDocument> BuildSubscriberUpsertPipeline(
 396        string correlationId,
 397        Guid registrationId,
 398        string instanceId,
 399        TimeSpan ttl)
 3400        => Builders<MongoChannelSubscriberDocument>.Update.Pipeline(new[]
 3401        {
 3402            new BsonDocument("$set", new BsonDocument
 3403            {
 3404                ["correlation_id"] = correlationId,
 3405                ["registration_id"] = new BsonBinaryData(registrationId, GuidRepresentation.Standard),
 3406                ["instance_id"] = instanceId,
 3407                ["expires_at"] = new BsonDocument("$add", new BsonArray { "$$NOW", ttl.TotalMilliseconds })
 3408            })
 3409        });
 410
 411    public async Task HeartbeatSubscribersAsync(
 412        string instanceId,
 413        IReadOnlyCollection<(string CorrelationId, Guid RegistrationId)> registrations,
 414        TimeSpan ttl,
 415        CancellationToken cancellationToken)
 416    {
 3417        if (registrations.Count == 0)
 3418            return;
 419
 3420        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 421
 422        // Per-registration upserts rather than one bare UpdateMany: the caller only heartbeats
 423        // registrations that are live in this process, so a missing document means the TTL reaper
 424        // deleted it (e.g. after a >timeout stall) — re-creating it here is what brings the waiter
 425        // back from "permanently invisible". Same document shape as UpsertSubscriberAsync.
 3426        var writes = new List<WriteModel<MongoChannelSubscriberDocument>>(registrations.Count);
 3427        foreach (var (correlationId, registrationId) in registrations)
 428        {
 3429            writes.Add(new UpdateOneModel<MongoChannelSubscriberDocument>(
 3430                Builders<MongoChannelSubscriberDocument>.Filter.Eq(item => item.Id, RegistrationKey(correlationId, regis
 3431                BuildSubscriberUpsertPipeline(correlationId, registrationId, instanceId, ttl))
 3432            {
 3433                IsUpsert = true
 3434            });
 435        }
 436
 3437        await _subscribers.BulkWriteAsync(
 3438            writes,
 3439            new BulkWriteOptions { IsOrdered = false },
 3440            cancellationToken).ConfigureAwait(false);
 3441    }
 442
 443    public async Task DeleteSubscriberAsync(string correlationId, Guid registrationId, CancellationToken cancellationTok
 444    {
 3445        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3446        await _subscribers.DeleteOneAsync(
 3447            Builders<MongoChannelSubscriberDocument>.Filter.Eq(item => item.Id, RegistrationKey(correlationId, registrat
 3448            cancellationToken).ConfigureAwait(false);
 3449    }
 450
 451    public async Task<long> CountActiveSubscribersAsync(string correlationId, CancellationToken cancellationToken)
 452    {
 3453        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3454        var filter = Builders<MongoChannelSubscriberDocument>.Filter.Eq(item => item.CorrelationId, correlationId)
 3455                     & NotExpiredOnServerClock<MongoChannelSubscriberDocument>();
 3456        return await _subscribers.CountDocumentsAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false
 3457    }
 458
 459    /// <summary>
 460    /// Server-clock expiry filter (<c>$expr: expires_at &gt; $$NOW</c>): liveness and recovery
 461    /// expiry are stamped with $$NOW, so comparing them against the app clock would reintroduce
 462    /// the clock-skew hole the server-side stamps exist to close.
 463    /// </summary>
 464    internal static FilterDefinition<TDocument> NotExpiredOnServerClock<TDocument>()
 3465        => new BsonDocument("$expr", new BsonDocument("$gt", new BsonArray { "$expires_at", "$$NOW" }));
 466
 467    /// <summary>
 468    /// Watches the message collection with a change stream and invokes
 469    /// <paramref name="onNotification"/> with the correlation id of every inserted response. One
 470    /// stream serves every local waiter — the caller routes the id to the right subscription — so
 471    /// waiter count never multiplies server-side cursors. Runs until cancellation or a stream error.
 472    /// </summary>
 473    public async Task WatchMessagesAsync(Func<string?, Task> onNotification, CancellationToken cancellationToken)
 474    {
 3475        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3476        using var cursor = await _messages.WatchAsync(
 3477            BuildMessageWatchPipeline(),
 3478            new ChangeStreamOptions { FullDocument = ChangeStreamFullDocumentOption.UpdateLookup },
 3479            cancellationToken).ConfigureAwait(false);
 3480        while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
 481        {
 1482            foreach (var change in cursor.Current)
 1483                await onNotification(change.FullDocument?.CorrelationId).ConfigureAwait(false);
 484        }
 3485    }
 486
 487    /// <summary>
 488    /// Change-stream pipeline for response wakes: a <c>$match</c> on insert events. The correlation
 489    /// id travels in the event's full document, letting the dispatcher scan only the signaled
 490    /// correlation id — the targeted-wake contract.
 491    /// </summary>
 492    internal static PipelineDefinition<ChangeStreamDocument<MongoChannelMessageDocument>, ChangeStreamDocument<MongoChan
 3493        => new EmptyPipelineDefinition<ChangeStreamDocument<MongoChannelMessageDocument>>()
 3494            .Match(change => change.OperationType == ChangeStreamOperationType.Insert);
 495
 496    /// <summary>Returns <c>true</c> when the server rejected the change stream itself (not a transient cursor error).</
 497    internal static bool IsChangeStreamUnsupported(Exception exception)
 3498        => exception is MongoCommandException commandException
 3499           && (commandException.Code == 40573
 3500               || commandException.Message.Contains("only supported on replica sets", StringComparison.OrdinalIgnoreCase
 501
 502    internal static string RegistrationKey(string correlationId, Guid registrationId)
 3503        => $"{correlationId}:{registrationId:N}";
 504
 505    internal static bool IsTransient(Exception exception)
 3506        => exception is not OperationCanceledException
 3507           && (exception is MongoConnectionException
 3508               or MongoNotPrimaryException
 3509               or MongoNodeIsRecoveringException
 3510               or MongoExecutionTimeoutException
 3511               or TimeoutException
 3512               || (exception is MongoException mongoException && mongoException.HasErrorLabel("RetryableWriteError")));
 513
 514    /// <summary>Validates a MongoDB collection name coming from options.</summary>
 515    public static void ValidateCollectionName(string? value, string name)
 516    {
 3517        if (string.IsNullOrWhiteSpace(value))
 3518            throw new InvalidOperationException($"{nameof(MongoDbAsyncResponseChannelOptions)}.{name} must be configured
 3519        if (value.Contains('$') || value.Contains('\0') || value.StartsWith("system.", StringComparison.Ordinal))
 3520            throw new InvalidOperationException(
 3521                $"{nameof(MongoDbAsyncResponseChannelOptions)}.{name} '{value}' must be a valid MongoDB collection name 
 3522    }
 523
 524    /// <summary>Disposes the Mongo client when the store created (and therefore owns) it.</summary>
 525    public void Dispose()
 526    {
 1527        _ensureGate.Dispose();
 1528        (_ownedClient as IDisposable)?.Dispose();
 0529    }
 530}
 531
 532internal sealed class MongoRecoveryStateDocument
 533{
 534    [BsonId]
 535    [BsonElement("_id")]
 536    public string Id { get; set; } = "";
 537
 538    [BsonElement("correlation_id")]
 539    public string CorrelationId { get; set; } = "";
 540
 541    [BsonElement("registration_id")]
 542    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 543    public Guid RegistrationId { get; set; }
 544
 545    [BsonElement("state_json")]
 546    public string StateJson { get; set; } = "";
 547
 548    [BsonElement("expires_at")]
 549    public DateTime ExpiresAtUtc { get; set; }
 550
 551    [BsonElement("registered_at")]
 552    public DateTime RegisteredAtUtc { get; set; }
 553}
 554
 555internal sealed class MongoChannelMessageDocument
 556{
 557    [BsonId]
 558    [BsonElement("_id")]
 559    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 560    public Guid Id { get; set; }
 561
 562    [BsonElement("correlation_id")]
 563    public string CorrelationId { get; set; } = "";
 564
 565    [BsonElement("envelope_json")]
 566    public string EnvelopeJson { get; set; } = "";
 567
 568    [BsonElement("created_at")]
 569    public DateTime CreatedAtUtc { get; set; }
 570
 571    [BsonElement("expires_at")]
 572    public DateTime ExpiresAtUtc { get; set; }
 573
 574    [BsonElement("acked_at")]
 575    public DateTime? AckedAtUtc { get; set; }
 576
 577    [BsonElement("recovery_claimed")]
 578    public bool RecoveryClaimed { get; set; }
 579}
 580
 581internal sealed class MongoChannelSubscriberDocument
 582{
 583    [BsonId]
 584    [BsonElement("_id")]
 585    public string Id { get; set; } = "";
 586
 587    [BsonElement("correlation_id")]
 588    public string CorrelationId { get; set; } = "";
 589
 590    [BsonElement("registration_id")]
 591    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 592    public Guid RegistrationId { get; set; }
 593
 594    [BsonElement("instance_id")]
 595    public string InstanceId { get; set; } = "";
 596
 597    [BsonElement("expires_at")]
 598    public DateTime ExpiresAtUtc { get; set; }
 599}