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

Information
Class: AsyncResponse.Channels.MongoDB.MongoDbChannelStore
Assembly: AsyncResponse.Channels.MongoDB
File(s): /_/src/Channels/AsyncResponse.Channels.MongoDB/MongoDbChannelStore.cs
Line coverage
99%
Covered lines: 440
Uncovered lines: 4
Coverable lines: 444
Total lines: 915
Line coverage: 99%
Branch coverage
82%
Covered branches: 64
Total branches: 78
Branch coverage: 82%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%44100%
EnsureCreatedAsync()100%1010100%
CreateTtlIndexAsync()100%11100%
WarnIfManagedIndexesMissingAsync()100%11100%
WarnIfCollectionIndexesMissingAsync()100%66100%
IndexLeadsOn(...)50%66100%
SaveRecoveryStateAsync()100%11100%
BuildRecoveryStateUpsertPipeline(...)100%11100%
LoadRecoveryStatesAsync()100%11100%
DeleteRecoveryStateAsync()100%11100%
ScanRecoveryStateJsonAsync()100%44100%
InsertMessageAsync(...)100%11100%
InsertMessageOnceAsync()50%44100%
BuildInsertMessagePipeline(...)100%11100%
LoadMessagesAsync()75%44100%
.cctor()100%11100%
LoadMessagesByIdAsync()50%2292.3%
ToMessages(...)100%44100%
TryClaimForDeliveryAsync()100%11100%
BuildDeliveryClaimFilter(...)100%11100%
BuildDeliveryClaimUpdate(...)100%11100%
DrawAckSequenceAsync()100%11100%
TryClaimForRecoveryAsync()100%11100%
BuildRecoveryClaimFilter(...)100%11100%
GetServerTimeUtcAsync()50%44100%
GetSubscriptionStartAsync()100%66100%
BuildSubscriptionStartPipeline()100%11100%
IsMessageAcknowledgedAsync()100%11100%
UpsertSubscriberAsync()100%11100%
BuildSubscriberUpsertPipeline(...)100%11100%
HeartbeatSubscribersAsync()100%44100%
DeleteSubscriberAsync()100%11100%
CountActiveSubscribersAsync()100%11100%
NotExpiredOnServerClock()100%11100%
WatchMessagesAsync()66.66%6688.88%
BuildMessageWatchPipeline()100%11100%
IsChangeStreamUnsupported(...)100%44100%
RegistrationKey(...)100%11100%
CountersCollectionName(...)100%11100%
ValidateCollectionName(...)83.33%131280%
Dispose()50%2266.66%

File(s)

/_/src/Channels/AsyncResponse.Channels.MongoDB/MongoDbChannelStore.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Microsoft.Extensions.Logging.Abstractions;
 3using Microsoft.Extensions.Options;
 4using MongoDB.Bson;
 5using MongoDB.Bson.Serialization.Attributes;
 6using MongoDB.Driver;
 7using System.Runtime.CompilerServices;
 8using AsyncResponse.Internal;
 9
 10namespace AsyncResponse.Channels.MongoDB;
 11
 12/// <summary>One stored response envelope row/document as the channel store returns it.</summary>
 13/// <remarks>
 14/// <c>EnvelopeJson</c> is the stored envelope, or <c>null</c> for a document the dispatch sweep loaded header-only (an
 15/// already-acknowledged one — see <see cref="MongoDbChannelStore.LoadMessagesAsync"/>); the
 16/// sweep hydrates the few such documents it still has to deliver through
 17/// <see cref="MongoDbChannelStore.LoadMessagesByIdAsync"/> before handing them to a waiter.
 18/// </remarks>
 19internal readonly record struct MongoDbChannelMessage(
 20    Guid Id,
 21    string CorrelationId,
 22    string? EnvelopeJson,
 23    DateTimeOffset CreatedAtUtc,
 24    DateTimeOffset? AckedAtUtc = null,
 25    long? AckedSeq = null);
 26
 27/// <summary>Document adapter for the MongoDB channel collections and change-stream wake.</summary>
 28internal sealed class MongoDbChannelStore : IDisposable
 29{
 30    private readonly IMongoCollection<MongoRecoveryStateDocument> _recovery;
 31    private readonly IMongoCollection<MongoChannelMessageDocument> _messages;
 32    private readonly IMongoCollection<MongoChannelSubscriberDocument> _subscribers;
 33    private readonly IMongoCollection<BsonDocument> _counters;
 34    private readonly IMongoDatabase _database;
 35    private readonly MongoDbAsyncResponseChannelOptions _options;
 54036    private readonly SemaphoreSlim _ensureGate = new(1, 1);
 37    private readonly IMongoClient? _ownedClient;
 38    private readonly ILogger _logger;
 39    private bool _created;
 40
 54041    public MongoDbChannelStore(
 54042        IMongoDatabase database,
 54043        IOptions<MongoDbAsyncResponseChannelOptions> options,
 54044        IMongoClient? ownedClient = null,
 54045        IMongoNamespaceRegistry? namespaceRegistry = null,
 54046        ILogger? logger = null)
 47    {
 54048        _options = options.Value;
 54049        _options.Validate();
 54050        _database = database;
 54051        _logger = logger ?? NullLogger.Instance;
 52
 53        // Cross-component collection ownership (DI-hosted stores only): another AsyncResponse
 54        // component configured onto one of these collections — the derived counters collection
 55        // included — must fail startup in either construction order.
 54056        namespaceRegistry?.Claim(
 54057            MongoNamespaceRegistry.ClusterKey(database),
 54058            database.DatabaseNamespace.DatabaseName,
 54059            "MongoDB channel",
 54060            [
 54061                (_options.RecoveryStateCollection, nameof(_options.RecoveryStateCollection)),
 54062                (_options.MessageCollection, nameof(_options.MessageCollection)),
 54063                (_options.SubscriberCollection, nameof(_options.SubscriberCollection)),
 54064                (CountersCollectionName(_options.MessageCollection), "derived ack-counter collection"),
 54065            ]);
 66
 67        // Namespace BYTE limits can only be checked here, where the actual database name is
 68        // first known — and the derived counters namespace is 9 bytes longer than the configured
 69        // message collection, so a near-limit configuration passed every static check and failed
 70        // at the first ack-sequence draw.
 53871        MongoNamespaceRegistry.ValidateEffectiveNamespace(database, _options.RecoveryStateCollection, nameof(_options.Re
 53872        MongoNamespaceRegistry.ValidateEffectiveNamespace(database, _options.MessageCollection, nameof(_options.MessageC
 53673        MongoNamespaceRegistry.ValidateEffectiveNamespace(database, _options.SubscriberCollection, nameof(_options.Subsc
 53674        MongoNamespaceRegistry.ValidateEffectiveNamespace(database, CountersCollectionName(_options.MessageCollection), 
 75
 76        // Primary reads override whatever the host-registered client/connection string configured:
 77        // a secondaryPreferred connection routed the liveness probe, recovery-state read and message
 78        // load to a lagging secondary, so a publisher racing a fresh registration saw 0 subscribers
 79        // AND 0 recovery states and dropped the response while reporting success. It also keeps
 80        // reads on the same authority whose $$NOW the expiry filters evaluate against (same
 81        // reasoning as the MongoDB durable-flow store's pin).
 53482        _recovery = database.GetCollection<MongoRecoveryStateDocument>(_options.RecoveryStateCollection)
 53483            .WithReadPreference(ReadPreference.Primary);
 53484        _messages = database.GetCollection<MongoChannelMessageDocument>(_options.MessageCollection)
 53485            .WithReadPreference(ReadPreference.Primary);
 53486        _subscribers = database.GetCollection<MongoChannelSubscriberDocument>(_options.SubscriberCollection)
 53487            .WithReadPreference(ReadPreference.Primary);
 88        // The monotonic ack sequence: delivery claims and subscription registrations draw from
 89        // this ONE counter, giving acked_seq and a subscription's start position a total order no
 90        // pair of same-tick timestamps has. Created on first upsert; no index needed (_id only).
 53491        _counters = database.GetCollection<BsonDocument>(CountersCollectionName(_options.MessageCollection))
 53492            .WithReadPreference(ReadPreference.Primary);
 53493        _ownedClient = ownedClient;
 53494    }
 95
 96    public async Task EnsureCreatedAsync(CancellationToken cancellationToken = default)
 97    {
 1078198        if (_created)
 1030599            return;
 100
 476101        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 102        try
 103        {
 476104            if (_created)
 2105                return;
 106
 107            // Persisted cross-host ownership: the in-container registry cannot see other hosts
 108            // or directly constructed stores, so claim the effective collections here — before
 109            // any index DDL, and INDEPENDENTLY of AutoCreateIndexes (disabling index DDL must
 110            // not disable collision protection) — and fail startup when another component
 111            // already owns one.
 474112            if (_options.UseOwnershipLedger)
 113            {
 428114                await MongoOwnershipLedger.ClaimAsync(
 428115                    _database,
 428116                    "MongoDB channel",
 428117                    [
 428118                        (_options.RecoveryStateCollection, nameof(_options.RecoveryStateCollection)),
 428119                        (_options.MessageCollection, nameof(_options.MessageCollection)),
 428120                        (_options.SubscriberCollection, nameof(_options.SubscriberCollection)),
 428121                        (CountersCollectionName(_options.MessageCollection), "derived ack-counter collection"),
 428122                    ],
 428123                    cancellationToken).ConfigureAwait(false);
 124            }
 125
 473126            if (!_options.AutoCreateIndexes)
 127            {
 128                // Manually managed indexes get a one-time read-only check instead of DDL. There
 129                // is no collection shape to verify (documents are schemaless), so the silent
 130                // failure modes are all indexes — above all a missing TTL index, which means
 131                // nothing ever reaps expired documents. Absence is a warning, never a startup
 132                // failure: indexes degrade retention and performance, not correctness, and a
 133                // least-privilege operator may provision them out of band.
 98134                await WarnIfManagedIndexesMissingAsync(cancellationToken).ConfigureAwait(false);
 98135                _created = true;
 98136                return;
 137            }
 138
 139            // TTL indexes (expireAfterSeconds = 0 on the expiry timestamp) make MongoDB itself reap
 140            // expired documents — no application-side pruning needed. Reads still filter on the
 141            // expiry because the TTL monitor only runs periodically (~60s).
 375142            await CreateTtlIndexAsync(
 375143                _recovery,
 375144                Builders<MongoRecoveryStateDocument>.IndexKeys.Ascending(item => item.ExpiresAtUtc),
 375145                $"{_options.RecoveryStateCollection}_expires_idx",
 375146                cancellationToken).ConfigureAwait(false);
 375147            await _recovery.Indexes.CreateOneAsync(
 375148                new CreateIndexModel<MongoRecoveryStateDocument>(
 375149                    Builders<MongoRecoveryStateDocument>.IndexKeys
 375150                        .Ascending(item => item.CorrelationId)
 375151                        .Ascending(item => item.RegisteredAtUtc),
 375152                    new CreateIndexOptions { Name = $"{_options.RecoveryStateCollection}_correlation_idx" }),
 375153                cancellationToken: cancellationToken).ConfigureAwait(false);
 154
 375155            await CreateTtlIndexAsync(
 375156                _messages,
 375157                Builders<MongoChannelMessageDocument>.IndexKeys.Ascending(item => item.ExpiresAtUtc),
 375158                $"{_options.MessageCollection}_expires_idx",
 375159                cancellationToken).ConfigureAwait(false);
 375160            await _messages.Indexes.CreateOneAsync(
 375161                new CreateIndexModel<MongoChannelMessageDocument>(
 375162                    Builders<MongoChannelMessageDocument>.IndexKeys
 375163                        .Ascending(item => item.CorrelationId)
 375164                        .Ascending(item => item.CreatedAtUtc),
 375165                    new CreateIndexOptions { Name = $"{_options.MessageCollection}_correlation_created_idx" }),
 375166                cancellationToken: cancellationToken).ConfigureAwait(false);
 167
 375168            await CreateTtlIndexAsync(
 375169                _subscribers,
 375170                Builders<MongoChannelSubscriberDocument>.IndexKeys.Ascending(item => item.ExpiresAtUtc),
 375171                $"{_options.SubscriberCollection}_expires_idx",
 375172                cancellationToken).ConfigureAwait(false);
 375173            await _subscribers.Indexes.CreateOneAsync(
 375174                new CreateIndexModel<MongoChannelSubscriberDocument>(
 375175                    Builders<MongoChannelSubscriberDocument>.IndexKeys.Ascending(item => item.CorrelationId),
 375176                    new CreateIndexOptions { Name = $"{_options.SubscriberCollection}_correlation_idx" }),
 375177                cancellationToken: cancellationToken).ConfigureAwait(false);
 178
 375179            _created = true;
 375180        }
 181        finally
 182        {
 476183            _ensureGate.Release();
 184        }
 10780185    }
 186
 187    private static async Task CreateTtlIndexAsync<TDocument>(
 188        IMongoCollection<TDocument> collection,
 189        IndexKeysDefinition<TDocument> keys,
 190        string indexName,
 191        CancellationToken cancellationToken)
 192    {
 1125193        var model = new CreateIndexModel<TDocument>(
 1125194            keys,
 1125195            new CreateIndexOptions { Name = indexName, ExpireAfter = TimeSpan.Zero });
 196        try
 197        {
 1125198            await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
 1121199        }
 4200        catch (MongoCommandException ex) when (ex.Code is 85 or 86)
 201        {
 202            // IndexOptionsConflict/IndexKeySpecsConflict: an earlier deployment created the
 203            // same-named index with different options. Replace it in place.
 204            try
 205            {
 4206                await collection.Indexes.DropOneAsync(indexName, cancellationToken).ConfigureAwait(false);
 2207            }
 2208            catch (MongoCommandException dropException) when (dropException.Code == 27)
 209            {
 210                // IndexNotFound: a peer host in the same rolling deploy took the same branch and
 211                // dropped it first. Converge on the recreate below (idempotent for an identical spec).
 2212            }
 213
 4214            await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
 215        }
 1125216    }
 217
 218    private async Task WarnIfManagedIndexesMissingAsync(CancellationToken cancellationToken)
 219    {
 220        try
 221        {
 98222            await WarnIfCollectionIndexesMissingAsync(_recovery, _options.RecoveryStateCollection, cancellationToken).Co
 4223            await WarnIfCollectionIndexesMissingAsync(_messages, _options.MessageCollection, cancellationToken).Configur
 4224            await WarnIfCollectionIndexesMissingAsync(_subscribers, _options.SubscriberCollection, cancellationToken).Co
 4225        }
 94226        catch (Exception ex) when (ex is not OperationCanceledException)
 227        {
 228            // A deployment that cannot even list indexes (no listIndexes privilege, server
 229            // unreachable at first use) must not lose the actual operation to the check: the
 230            // caller's own store call surfaces any real connectivity failure.
 94231            _logger.LogDebug(ex, "Skipping index verification for the manually managed MongoDB channel collections; list
 94232        }
 98233    }
 234
 235    private async Task WarnIfCollectionIndexesMissingAsync<TDocument>(
 236        IMongoCollection<TDocument> collection,
 237        string collectionName,
 238        CancellationToken cancellationToken)
 239    {
 240        List<BsonDocument> indexes;
 241        try
 242        {
 106243            using var cursor = await collection.Indexes.ListAsync(cancellationToken).ConfigureAwait(false);
 10244            indexes = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
 10245        }
 4246        catch (MongoCommandException ex) when (ex.Code == 26)
 247        {
 248            // NamespaceNotFound: the collection does not exist yet. MongoDB creates it bare on
 249            // the first write — which, with index DDL disabled, is exactly a collection with no
 250            // TTL index.
 2251            indexes = [];
 2252        }
 253
 254        // Matched by KEY, not by name: operators own the naming of manually provisioned indexes.
 20255        if (!indexes.Any(index => IndexLeadsOn(index, "expires_at") && index.Contains("expireAfterSeconds")))
 256        {
 4257            _logger.LogWarning(
 4258                "MongoDB collection {Database}.{Collection} has no TTL index on 'expires_at' and AutoCreateIndexes is di
 4259                "Expired documents are never reaped, so the collection grows without bound. " +
 4260                "Create a TTL index (expireAfterSeconds: 0 on 'expires_at') or enable AutoCreateIndexes.",
 4261                _database.DatabaseNamespace.DatabaseName, collectionName);
 262        }
 263
 28264        if (!indexes.Any(index => IndexLeadsOn(index, "correlation_id")))
 265        {
 4266            _logger.LogWarning(
 4267                "MongoDB collection {Database}.{Collection} has no index leading on 'correlation_id' and AutoCreateIndex
 4268                "Correlation-id lookups scan the whole collection — performance only; create the index to restore indexe
 4269                _database.DatabaseNamespace.DatabaseName, collectionName);
 270        }
 12271    }
 272
 273    /// <summary>
 274    /// Whether the listed index's FIRST key field is <paramref name="field"/> — what a prefix
 275    /// lookup uses, and (TTL indexes being single-field) what identifies the TTL index.
 276    /// </summary>
 277    private static bool IndexLeadsOn(BsonDocument index, string field)
 24278        => index.TryGetValue("key", out var key)
 24279           && key is BsonDocument keyDocument
 24280           && keyDocument.ElementCount > 0
 24281           && keyDocument.GetElement(0).Name == field;
 282
 283    public async Task SaveRecoveryStateAsync(string correlationId, RecoveryState state, TimeSpan ttl, CancellationToken 
 284    {
 386285        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 286        // Upsert pipeline stamped with the server clock ($$NOW), matching the message-side
 287        // discipline (and the PG/SqlServer DB-clock discipline): app-clock expiry math would shift
 288        // the recovery window by whatever the client clock is skewed.
 386289        await _recovery.UpdateOneAsync(
 386290            Builders<MongoRecoveryStateDocument>.Filter.Eq(item => item.Id, RegistrationKey(correlationId, state.Registr
 386291            BuildRecoveryStateUpsertPipeline(correlationId, state, ttl),
 386292            new UpdateOptions { IsUpsert = true },
 386293            cancellationToken).ConfigureAwait(false);
 386294    }
 295
 296    /// <summary>
 297    /// Upsert pipeline for a recovery registration: every field is overwritten (a re-save refreshes
 298    /// the registration), with <c>expires_at</c>/<c>registered_at</c> computed on the server clock.
 299    /// </summary>
 300    internal static UpdateDefinition<MongoRecoveryStateDocument> BuildRecoveryStateUpsertPipeline(
 301        string correlationId,
 302        RecoveryState state,
 303        TimeSpan ttl)
 386304        => Builders<MongoRecoveryStateDocument>.Update.Pipeline(new[]
 386305        {
 386306            new BsonDocument("$set", new BsonDocument
 386307            {
 386308                ["correlation_id"] = correlationId,
 386309                ["registration_id"] = new BsonBinaryData(state.RegistrationId, GuidRepresentation.Standard),
 386310                ["state_json"] = AsyncResponseJson.Serialize(state),
 386311                ["expires_at"] = new BsonDocument("$add", new BsonArray { "$$NOW", ttl.TotalMilliseconds }),
 386312                ["registered_at"] = "$$NOW"
 386313            })
 386314        });
 315
 316    public async Task<IReadOnlyList<string>> LoadRecoveryStatesAsync(string correlationId, CancellationToken cancellatio
 317    {
 33318        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 33319        var filter = Builders<MongoRecoveryStateDocument>.Filter.Eq(item => item.CorrelationId, correlationId)
 33320                     & NotExpiredOnServerClock<MongoRecoveryStateDocument>();
 33321        var documents = await _recovery.Find(filter)
 33322            .SortBy(item => item.RegisteredAtUtc)
 33323            .Project(item => item.StateJson)
 33324            .ToListAsync(cancellationToken).ConfigureAwait(false);
 33325        return documents;
 33326    }
 327
 328    public async Task<bool> DeleteRecoveryStateAsync(string correlationId, Guid registrationId, CancellationToken cancel
 329    {
 385330        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 385331        var single = await _recovery.DeleteOneAsync(
 385332            Builders<MongoRecoveryStateDocument>.Filter.Eq(item => item.Id, RegistrationKey(correlationId, registrationI
 385333            cancellationToken).ConfigureAwait(false);
 385334        return single.DeletedCount > 0;
 385335    }
 336
 337    public async IAsyncEnumerable<string> ScanRecoveryStateJsonAsync([EnumeratorCancellation] CancellationToken cancella
 338    {
 1339        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 1340        var filter = NotExpiredOnServerClock<MongoRecoveryStateDocument>();
 1341        using var cursor = await _recovery.Find(filter)
 1342            .SortBy(item => item.RegisteredAtUtc)
 1343            .Project(item => item.StateJson)
 1344            .ToCursorAsync(cancellationToken).ConfigureAwait(false);
 2345        while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
 346        {
 4347            foreach (var json in cursor.Current)
 1348                yield return json;
 349        }
 1350    }
 351
 352    /// <summary>
 353    /// Inserts a response envelope document. The caller supplies the message id and the write is an
 354    /// upsert that preserves an existing document, so a retried publish is idempotent rather than
 355    /// duplicating the response. Timestamps are stamped with the server clock (<c>$$NOW</c>) so
 356    /// dispatch watermarks never mix client and server clocks. The insert itself is the wake signal:
 357    /// every process's change stream observes it.
 358    /// </summary>
 359    public Task<MongoDbChannelMessage> InsertMessageAsync(Guid id, string correlationId, string envelopeJson, TimeSpan r
 565360        => AsyncResponseRetry.ExecuteAsync(
 565361            token => InsertMessageOnceAsync(id, correlationId, envelopeJson, retention, token),
 565362            MongoTransientFaults.IsTransient,
 565363            _options.PublishMaxAttempts,
 565364            _options.PublishRetryBaseDelay,
 565365            _options.PublishRetryMaxDelay,
 565366            cancellationToken);
 367
 368    private async Task<MongoDbChannelMessage> InsertMessageOnceAsync(Guid id, string correlationId, string envelopeJson,
 369    {
 565370        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 371
 372        // findOneAndUpdate instead of updateOne so the returned document carries the
 373        // server-stamped ($$NOW) created_at — the original document's on a publish retry, per the
 374        // pipeline's $ifNull, together with its settlement columns — for the same-process fast
 375        // path's watermark comparison (a fabricated null acked_at replayed an already-consumed
 376        // response to a waiter registered after the ack).
 565377        var document = await _messages.FindOneAndUpdateAsync(
 565378            Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.Id, id),
 565379            BuildInsertMessagePipeline(correlationId, envelopeJson, retention),
 565380            new FindOneAndUpdateOptions<MongoChannelMessageDocument>
 565381            {
 565382                IsUpsert = true,
 565383                ReturnDocument = ReturnDocument.After
 565384            },
 565385            cancellationToken).ConfigureAwait(false);
 386
 387        // Upsert + ReturnDocument.After cannot return null from a healthy server. If a driver
 388        // anomaly ever surfaces one, persistence is UNKNOWN — reporting success with a fabricated
 389        // app-clock timestamp would both lie about it and feed a client clock into the
 390        // server-clock watermark. Fail instead, so the retry/error path runs.
 565391        return document is null
 565392            ? throw new InvalidOperationException(
 565393                $"MongoDB response upsert for message {id} returned no document despite IsUpsert + ReturnDocument.After;
 565394            : new MongoDbChannelMessage(
 565395                id,
 565396                correlationId,
 565397                envelopeJson,
 565398                new DateTimeOffset(document.CreatedAtUtc, TimeSpan.Zero),
 565399                document.AckedAtUtc is { } acked ? new DateTimeOffset(acked, TimeSpan.Zero) : null,
 565400                document.AckedSeq);
 565401    }
 402
 403    /// <summary>
 404    /// Upsert pipeline for a response envelope: <c>$ifNull</c> keeps the original server-stamped
 405    /// timestamps and claim flags when a publish retry finds the document already present.
 406    /// </summary>
 407    internal static UpdateDefinition<MongoChannelMessageDocument> BuildInsertMessagePipeline(
 408        string correlationId,
 409        string envelopeJson,
 410        TimeSpan retention)
 567411        => Builders<MongoChannelMessageDocument>.Update.Pipeline(new[]
 567412        {
 567413            new BsonDocument("$set", new BsonDocument
 567414            {
 567415                ["correlation_id"] = correlationId,
 567416                ["envelope_json"] = envelopeJson,
 567417                ["created_at"] = new BsonDocument("$ifNull", new BsonArray { "$created_at", "$$NOW" }),
 567418                ["expires_at"] = new BsonDocument("$ifNull", new BsonArray
 567419                {
 567420                    "$expires_at",
 567421                    new BsonDocument("$add", new BsonArray { "$$NOW", retention.TotalMilliseconds })
 567422                }),
 567423                ["acked_at"] = new BsonDocument("$ifNull", new BsonArray { "$acked_at", BsonNull.Value }),
 567424                ["recovery_claimed"] = new BsonDocument("$ifNull", new BsonArray { "$recovery_claimed", false })
 567425            })
 567426        });
 427
 428    public async Task<IReadOnlyList<MongoDbChannelMessage>> LoadMessagesAsync(
 429        string correlationId,
 430        DateTimeOffset sinceUtc,
 431        int batchSize,
 432        DateTimeOffset? afterCreatedAtUtc,
 433        Guid? afterId,
 434        CancellationToken cancellationToken)
 435    {
 1484436        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 1484437        var filter = Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.CorrelationId, correlationId)
 1484438                     & Builders<MongoChannelMessageDocument>.Filter.Gte(item => item.CreatedAtUtc, sinceUtc.UtcDateTime)
 1484439                     & NotExpiredOnServerClock<MongoChannelMessageDocument>();
 1484440        if (afterCreatedAtUtc is not null)
 441        {
 687442            var afterCreated = afterCreatedAtUtc.Value.UtcDateTime;
 687443            var cursorId = afterId ?? throw new ArgumentNullException(nameof(afterId));
 687444            filter &= Builders<MongoChannelMessageDocument>.Filter.Or(
 687445                Builders<MongoChannelMessageDocument>.Filter.Gt(item => item.CreatedAtUtc, afterCreated),
 687446                Builders<MongoChannelMessageDocument>.Filter.And(
 687447                    Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.CreatedAtUtc, afterCreated),
 687448                    Builders<MongoChannelMessageDocument>.Filter.Gt(item => item.Id, cursorId)));
 449        }
 1484450        var documents = await _messages.Find(filter)
 1484451            .Project(SweepProjection)
 1484452            .Sort(Builders<MongoChannelMessageDocument>.Sort
 1484453                .Ascending(item => item.CreatedAtUtc)
 1484454                .Ascending(item => item.Id))
 1484455            .Limit(batchSize)
 1484456            .ToListAsync(cancellationToken).ConfigureAwait(false);
 1460457        return ToMessages(documents);
 1460458    }
 459
 460    /// <summary>
 461    /// The sweep's projection: every field but the envelope, and the envelope only for a document
 462    /// nobody has acknowledged yet. Acknowledged documents are the consumed history the sweep
 463    /// re-reads on every tick (they stay in the result so a fan-out waiter in ANOTHER process
 464    /// still receives them): shipping their bodies with each sweep made a long-lived progress
 465    /// subscription's cost grow with its whole retained history. The shared sweep fetches the
 466    /// envelope by id for the rare acknowledged document a live subscription has not seen.
 467    /// <c>$ifNull</c> folds a missing <c>acked_at</c> (a pre-settlement document) into null.
 468    /// </summary>
 12469    internal static readonly ProjectionDefinition<MongoChannelMessageDocument, MongoChannelMessageDocument> SweepProject
 12470        new BsonDocumentProjectionDefinition<MongoChannelMessageDocument, MongoChannelMessageDocument>(new BsonDocument
 12471        {
 12472            ["_id"] = 1,
 12473            ["correlation_id"] = 1,
 12474            ["created_at"] = 1,
 12475            ["expires_at"] = 1,
 12476            ["acked_at"] = 1,
 12477            ["acked_seq"] = 1,
 12478            ["recovery_claimed"] = 1,
 12479            ["envelope_json"] = new BsonDocument("$cond", new BsonArray
 12480            {
 12481                new BsonDocument("$eq", new BsonArray
 12482                {
 12483                    new BsonDocument("$ifNull", new BsonArray { "$acked_at", BsonNull.Value }),
 12484                    BsonNull.Value
 12485                }),
 12486                "$envelope_json",
 12487                BsonNull.Value
 12488            })
 12489        });
 490
 491    /// <summary>
 492    /// The full documents (envelope included) for <paramref name="ids"/> under
 493    /// <paramref name="correlationId"/>, in sweep order — how the dispatch sweep hydrates the
 494    /// header-only acknowledged documents it still has to deliver. A document reaped between the
 495    /// sweep's page and this read is simply absent.
 496    /// </summary>
 497    public async Task<IReadOnlyList<MongoDbChannelMessage>> LoadMessagesByIdAsync(
 498        string correlationId,
 499        IReadOnlyList<Guid> ids,
 500        CancellationToken cancellationToken)
 501    {
 65502        if (ids.Count == 0)
 0503            return [];
 504
 65505        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 65506        var filter = Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.CorrelationId, correlationId)
 65507                     & Builders<MongoChannelMessageDocument>.Filter.In(item => item.Id, ids)
 65508                     & NotExpiredOnServerClock<MongoChannelMessageDocument>();
 65509        var documents = await _messages.Find(filter)
 65510            .Sort(Builders<MongoChannelMessageDocument>.Sort
 65511                .Ascending(item => item.CreatedAtUtc)
 65512                .Ascending(item => item.Id))
 65513            .ToListAsync(cancellationToken).ConfigureAwait(false);
 65514        return ToMessages(documents);
 65515    }
 516
 517    private static List<MongoDbChannelMessage> ToMessages(List<MongoChannelMessageDocument> documents)
 518    {
 1525519        var messages = new List<MongoDbChannelMessage>(documents.Count);
 15178520        foreach (var document in documents)
 6064521            messages.Add(new MongoDbChannelMessage(
 6064522                document.Id,
 6064523                document.CorrelationId,
 6064524                document.EnvelopeJson,
 6064525                new DateTimeOffset(document.CreatedAtUtc, TimeSpan.Zero),
 6064526                document.AckedAtUtc is { } acked ? new DateTimeOffset(acked, TimeSpan.Zero) : null,
 6064527                document.AckedSeq));
 1525528        return messages;
 529    }
 530
 531    /// <summary>
 532    /// Atomically claims a message for live delivery via <c>findOneAndUpdate</c>: sets
 533    /// <c>acked_at</c> unless the publisher has already routed it to the lost-subscriber path
 534    /// (<c>recovery_claimed</c>). Returns <c>false</c> when recovery owns the message, so a
 535    /// slow-but-live waiter does not deliver a response the recovery callback already handled.
 536    /// Multiple processes may each win this claim, preserving cross-process fan-out, because it
 537    /// gates only on <c>recovery_claimed</c>, not on <c>acked_at</c>.
 538    /// </summary>
 539    public async Task<bool> TryClaimForDeliveryAsync(Guid messageId, CancellationToken cancellationToken)
 540    {
 5205541        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 542        // The sequence value is drawn BEFORE the claim lands, so its position reflects when this
 543        // delivery happened relative to subscription registrations (which draw from the same
 544        // counter). An unused draw on an already-acked row leaves a harmless gap; $ifNull keeps
 545        // the FIRST claim's stamp, mirroring acked_at.
 5205546        var ackSeq = await DrawAckSequenceAsync(cancellationToken).ConfigureAwait(false);
 5205547        var claimed = await _messages.FindOneAndUpdateAsync(
 5205548            BuildDeliveryClaimFilter(messageId),
 5205549            BuildDeliveryClaimUpdate(ackSeq),
 5205550            new FindOneAndUpdateOptions<MongoChannelMessageDocument> { ReturnDocument = ReturnDocument.After },
 5205551            cancellationToken).ConfigureAwait(false);
 5197552        return claimed is not null;
 5197553    }
 554
 555    internal static FilterDefinition<MongoChannelMessageDocument> BuildDeliveryClaimFilter(Guid messageId)
 5207556        => Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.Id, messageId)
 5207557           & Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.RecoveryClaimed, false)
 5207558           & NotExpiredOnServerClock<MongoChannelMessageDocument>();
 559
 560    internal static UpdateDefinition<MongoChannelMessageDocument> BuildDeliveryClaimUpdate(long ackSeq)
 5207561        => Builders<MongoChannelMessageDocument>.Update.Pipeline(new[]
 5207562        {
 5207563            // Both fields are computed from the PRE-update document (one $set stage), and the
 5207564            // sequence is stamped ONLY when this same update transitions acked_at from null: a
 5207565            // row acked by a pre-sequence build must stay permanently unsequenced — back-filling
 5207566            // it on a later fan-out re-claim would pair an OLD acked_at with a FRESH sequence
 5207567            // value, and a waiter that registered in the original ack's tick would then read the
 5207568            // tie as post-registration fan-out, replaying a response its predecessor consumed.
 5207569            new BsonDocument("$set", new BsonDocument
 5207570            {
 5207571                ["acked_at"] = new BsonDocument("$ifNull", new BsonArray { "$acked_at", "$$NOW" }),
 5207572                ["acked_seq"] = new BsonDocument("$cond", new BsonArray
 5207573                {
 5207574                    new BsonDocument("$eq", new BsonArray { new BsonDocument("$ifNull", new BsonArray { "$acked_at", Bso
 5207575                    ackSeq,
 5207576                    "$acked_seq"
 5207577                })
 5207578            })
 5207579        });
 580
 581    private async Task<long> DrawAckSequenceAsync(CancellationToken cancellationToken)
 582    {
 5205583        var counter = await _counters.FindOneAndUpdateAsync<BsonDocument>(
 5205584            new BsonDocument("_id", "ack_seq"),
 5205585            new BsonDocument("$inc", new BsonDocument("seq", 1L)),
 5205586            new FindOneAndUpdateOptions<BsonDocument, BsonDocument> { IsUpsert = true, ReturnDocument = ReturnDocument.A
 5205587            cancellationToken).ConfigureAwait(false);
 5205588        return counter["seq"].ToInt64();
 5205589    }
 590
 591    /// <summary>
 592    /// Atomically claims a message for the lost-subscriber path: sets <c>recovery_claimed</c> only
 593    /// while no waiter has delivered (<c>acked_at</c> is still null). Returns <c>true</c> when
 594    /// recovery wins; <c>false</c> means a live waiter already took the message, so the publisher
 595    /// must not also fire the recovery callback. Document-level atomicity of
 596    /// <c>findOneAndUpdate</c> serializes this against <see cref="TryClaimForDeliveryAsync"/>.
 597    /// </summary>
 598    public async Task<bool> TryClaimForRecoveryAsync(Guid messageId, CancellationToken cancellationToken)
 599    {
 12600        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 12601        var claimed = await _messages.FindOneAndUpdateAsync(
 12602            BuildRecoveryClaimFilter(messageId),
 12603            Builders<MongoChannelMessageDocument>.Update.Set(item => item.RecoveryClaimed, true),
 12604            new FindOneAndUpdateOptions<MongoChannelMessageDocument> { ReturnDocument = ReturnDocument.After },
 12605            cancellationToken).ConfigureAwait(false);
 12606        return claimed is not null;
 12607    }
 608
 609    internal static FilterDefinition<MongoChannelMessageDocument> BuildRecoveryClaimFilter(Guid messageId)
 14610        => Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.Id, messageId)
 14611           & Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.AckedAtUtc, null);
 612
 613    /// <summary>Returns the server's current UTC time, used as a clock-safe delivery watermark.</summary>
 614    public async Task<DateTimeOffset> GetServerTimeUtcAsync(CancellationToken cancellationToken)
 615    {
 616        // Primary, explicitly: the watermark must come from the clock whose $$NOW stamps the rows
 617        // it bounds — a secondary's localTime can lag or skew from the primary's.
 3618        var reply = await _database.RunCommandAsync<BsonDocument>(
 3619            new BsonDocument("hello", 1),
 3620            ReadPreference.Primary,
 3621            cancellationToken).ConfigureAwait(false);
 3622        return reply.TryGetValue("localTime", out var localTime) && localTime is BsonDateTime serverTime
 3623            ? new DateTimeOffset(serverTime.ToUniversalTime(), TimeSpan.Zero)
 3624            : DateTimeOffset.UtcNow;
 3625    }
 626
 627    /// <summary>
 628    /// A subscription's registration watermark: the server's UTC clock (for the created-at bound)
 629    /// and a fresh position in the monotonic ack sequence (for the exact acked-history bound —
 630    /// see the watermark in the shared channel base). Drawn by ONE atomic counter update whose
 631    /// pipeline advances the sequence and stamps <c>$$NOW</c> in the same document write
 632    /// (PG/SqlServer single-statement parity): with separate clock and sequence round trips, a
 633    /// delivery claim landing between them pairs a same-millisecond <c>acked_at</c> with a lower
 634    /// sequence, and the same-tick tie-breaker then files a legitimate fan-out delivery as
 635    /// history.
 636    /// </summary>
 637    public async Task<(DateTimeOffset ServerTimeUtc, long StartSeq)> GetSubscriptionStartAsync(CancellationToken cancell
 638    {
 393639        var counter = await _counters.FindOneAndUpdateAsync<BsonDocument>(
 393640            new BsonDocument("_id", "ack_seq"),
 393641            BuildSubscriptionStartPipeline(),
 393642            new FindOneAndUpdateOptions<BsonDocument, BsonDocument> { IsUpsert = true, ReturnDocument = ReturnDocument.A
 393643            cancellationToken).ConfigureAwait(false);
 644
 645        // The pipeline stamps drawn_at from $$NOW unconditionally, so anything else is a driver
 646        // anomaly. Failing beats an app-clock fallback, which would silently feed a client clock
 647        // into the server-clock watermark this draw exists to protect.
 393648        return counter is not null
 393649               && counter.TryGetValue("drawn_at", out var drawnAt)
 393650               && drawnAt is BsonDateTime serverTime
 393651            ? (new DateTimeOffset(serverTime.ToUniversalTime(), TimeSpan.Zero), counter["seq"].ToInt64())
 393652            : throw new InvalidOperationException(
 393653                "MongoDB subscription-start draw returned no server-stamped counter document despite IsUpsert + ReturnDo
 391654    }
 655
 656    /// <summary>
 657    /// Counter-update pipeline for a subscription registration: advances the monotonic ack
 658    /// sequence AND stamps the draw with the server clock in one atomic document update, so no
 659    /// delivery claim can interleave between the sequence draw and the clock read.
 660    /// <c>$ifNull</c> seeds a fresh counter document at 1, matching the delivery claim's
 661    /// <c>$inc</c> upsert.
 662    /// </summary>
 663    internal static UpdateDefinition<BsonDocument> BuildSubscriptionStartPipeline()
 395664        => Builders<BsonDocument>.Update.Pipeline(new[]
 395665        {
 395666            new BsonDocument("$set", new BsonDocument
 395667            {
 395668                ["seq"] = new BsonDocument("$add", new BsonArray { new BsonDocument("$ifNull", new BsonArray { "$seq", 0
 395669                ["drawn_at"] = "$$NOW"
 395670            })
 395671        });
 672
 673    public async Task<bool> IsMessageAcknowledgedAsync(Guid messageId, CancellationToken cancellationToken)
 674    {
 118675        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 118676        var filter = Builders<MongoChannelMessageDocument>.Filter.Eq(item => item.Id, messageId)
 118677                     & NotExpiredOnServerClock<MongoChannelMessageDocument>();
 678        // Direct member projection, not an anonymous type: anonymous projections lower to the
 679        // RequiresUnreferencedCode Expression.New(ctor, args, members) overload, which the ILC
 680        // trim analysis rejects (Roslyn's analyzer skips compiler-lowered expression trees, so
 681        // only Native AOT publishes catch it). Missing document and unacked document both come
 682        // back as null, which is exactly the contract here.
 118683        var ackedAtUtc = await _messages.Find(filter)
 118684            .Project(item => item.AckedAtUtc)
 118685            .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 118686        return ackedAtUtc is not null;
 118687    }
 688
 689    public async Task UpsertSubscriberAsync(string correlationId, Guid registrationId, string instanceId, TimeSpan ttl, 
 690    {
 396691        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 396692        await _subscribers.UpdateOneAsync(
 396693            Builders<MongoChannelSubscriberDocument>.Filter.Eq(item => item.Id, RegistrationKey(correlationId, registrat
 396694            BuildSubscriberUpsertPipeline(correlationId, registrationId, instanceId, ttl),
 396695            new UpdateOptions { IsUpsert = true },
 396696            cancellationToken).ConfigureAwait(false);
 396697    }
 698
 699    /// <summary>
 700    /// Upsert pipeline for a subscriber liveness document, with <c>expires_at</c> computed on the
 701    /// server clock ($$NOW) — app-clock liveness math would let a skewed client look dead (or
 702    /// immortal) to publishers comparing against server-side expiry.
 703    /// </summary>
 704    internal static UpdateDefinition<MongoChannelSubscriberDocument> BuildSubscriberUpsertPipeline(
 705        string correlationId,
 706        Guid registrationId,
 707        string instanceId,
 708        TimeSpan ttl)
 752709        => Builders<MongoChannelSubscriberDocument>.Update.Pipeline(new[]
 752710        {
 752711            new BsonDocument("$set", new BsonDocument
 752712            {
 752713                ["correlation_id"] = correlationId,
 752714                ["registration_id"] = new BsonBinaryData(registrationId, GuidRepresentation.Standard),
 752715                ["instance_id"] = instanceId,
 752716                ["expires_at"] = new BsonDocument("$add", new BsonArray { "$$NOW", ttl.TotalMilliseconds })
 752717            })
 752718        });
 719
 720    public async Task HeartbeatSubscribersAsync(
 721        string instanceId,
 722        IReadOnlyCollection<(string CorrelationId, Guid RegistrationId)> registrations,
 723        TimeSpan ttl,
 724        CancellationToken cancellationToken)
 725    {
 355726        if (registrations.Count == 0)
 2727            return;
 728
 353729        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 730
 731        // Per-registration upserts rather than one bare UpdateMany: the caller only heartbeats
 732        // registrations that are live in this process, so a missing document means the TTL reaper
 733        // deleted it (e.g. after a >timeout stall) — re-creating it here is what brings the waiter
 734        // back from "permanently invisible". Same document shape as UpsertSubscriberAsync.
 353735        var writes = new List<WriteModel<MongoChannelSubscriberDocument>>(registrations.Count);
 1418736        foreach (var (correlationId, registrationId) in registrations)
 737        {
 356738            writes.Add(new UpdateOneModel<MongoChannelSubscriberDocument>(
 356739                Builders<MongoChannelSubscriberDocument>.Filter.Eq(item => item.Id, RegistrationKey(correlationId, regis
 356740                BuildSubscriberUpsertPipeline(correlationId, registrationId, instanceId, ttl))
 356741            {
 356742                IsUpsert = true
 356743            });
 744        }
 745
 353746        await _subscribers.BulkWriteAsync(
 353747            writes,
 353748            new BulkWriteOptions { IsOrdered = false },
 353749            cancellationToken).ConfigureAwait(false);
 343750    }
 751
 752    public async Task DeleteSubscriberAsync(string correlationId, Guid registrationId, CancellationToken cancellationTok
 753    {
 442754        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 442755        await _subscribers.DeleteOneAsync(
 442756            Builders<MongoChannelSubscriberDocument>.Filter.Eq(item => item.Id, RegistrationKey(correlationId, registrat
 442757            cancellationToken).ConfigureAwait(false);
 428758    }
 759
 760    public async Task<long> CountActiveSubscribersAsync(string correlationId, CancellationToken cancellationToken)
 761    {
 565762        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 565763        var filter = Builders<MongoChannelSubscriberDocument>.Filter.Eq(item => item.CorrelationId, correlationId)
 565764                     & NotExpiredOnServerClock<MongoChannelSubscriberDocument>();
 565765        return await _subscribers.CountDocumentsAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false
 557766    }
 767
 768    /// <summary>
 769    /// Server-clock expiry filter (<c>$expr: expires_at &gt; $$NOW</c>): message, liveness, and
 770    /// recovery expiry are all stamped with $$NOW, so comparing them against the app clock would
 771    /// reintroduce the clock-skew hole the server-side stamps exist to close.
 772    /// </summary>
 773    internal static FilterDefinition<TDocument> NotExpiredOnServerClock<TDocument>()
 7473774        => new BsonDocument("$expr", new BsonDocument("$gt", new BsonArray { "$expires_at", "$$NOW" }));
 775
 776    /// <summary>
 777    /// Watches the message collection with a change stream and invokes
 778    /// <paramref name="onNotification"/> with the correlation id of every inserted response. One
 779    /// stream serves every local waiter — the caller routes the id to the right subscription — so
 780    /// waiter count never multiplies server-side cursors. Runs until cancellation or a stream error.
 781    /// </summary>
 782    public async Task WatchMessagesAsync(Func<string?, Task> onNotification, CancellationToken cancellationToken)
 783    {
 359784        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 359785        using var cursor = await _messages.WatchAsync(
 359786            BuildMessageWatchPipeline(),
 359787            new ChangeStreamOptions { FullDocument = ChangeStreamFullDocumentOption.UpdateLookup },
 359788            cancellationToken).ConfigureAwait(false);
 1231789        while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
 790        {
 2692791            foreach (var change in cursor.Current)
 474792                await onNotification(change.FullDocument?.CorrelationId).ConfigureAwait(false);
 793        }
 2794    }
 795
 796    /// <summary>
 797    /// Change-stream pipeline for response wakes: a <c>$match</c> on insert events. The correlation
 798    /// id travels in the event's full document, letting the dispatcher scan only the signaled
 799    /// correlation id — the targeted-wake contract.
 800    /// </summary>
 801    internal static PipelineDefinition<ChangeStreamDocument<MongoChannelMessageDocument>, ChangeStreamDocument<MongoChan
 361802        => new EmptyPipelineDefinition<ChangeStreamDocument<MongoChannelMessageDocument>>()
 361803            .Match(change => change.OperationType == ChangeStreamOperationType.Insert);
 804
 805    /// <summary>Returns <c>true</c> when the server rejected the change stream itself (not a transient cursor error).</
 806    internal static bool IsChangeStreamUnsupported(Exception exception)
 6807        => exception is MongoCommandException commandException
 6808           && (commandException.Code == 40573
 6809               || commandException.Message.Contains("only supported on replica sets", StringComparison.OrdinalIgnoreCase
 810
 811    internal static string RegistrationKey(string correlationId, Guid registrationId)
 1971812        => $"{correlationId}:{registrationId:N}";
 813
 814    /// <summary>
 815    /// Name of the derived ack-counter collection. Part of the effective collection-name plan:
 816    /// options validation must keep the configured collections distinct from this derived name,
 817    /// or counter documents land in (for example) the TTL-indexed recovery collection, where the
 818    /// reaper would silently delete the ack sequence and reset the same-tick tie-breaker.
 819    /// </summary>
 2975820    internal static string CountersCollectionName(string messageCollection) => $"{messageCollection}_counters";
 821
 822    /// <summary>Validates a MongoDB collection name coming from options.</summary>
 823    public static void ValidateCollectionName(string? value, string name)
 824    {
 3279825        if (string.IsNullOrWhiteSpace(value))
 2826            throw new InvalidOperationException($"{nameof(MongoDbAsyncResponseChannelOptions)}.{name} must be configured
 3277827        if (value.Contains('$') || value.Contains('\0')
 3277828            || value.StartsWith("system.", StringComparison.Ordinal) || value.Contains(".system.", StringComparison.Ordi
 6829            throw new InvalidOperationException(
 6830                $"{nameof(MongoDbAsyncResponseChannelOptions)}.{name} '{value}' must be a valid MongoDB collection name 
 3271831        if (string.Equals(value, MongoOwnershipLedger.CollectionName, StringComparison.Ordinal))
 0832            throw new InvalidOperationException(
 0833                $"{nameof(MongoDbAsyncResponseChannelOptions)}.{name} '{value}' is reserved for the cross-component owne
 3271834    }
 835
 836    /// <summary>Disposes the Mongo client when the store created (and therefore owns) it.</summary>
 837    public void Dispose()
 838    {
 381839        _ensureGate.Dispose();
 381840        (_ownedClient as IDisposable)?.Dispose();
 0841    }
 842}
 843
 844internal sealed class MongoRecoveryStateDocument
 845{
 846    [BsonId]
 847    [BsonElement("_id")]
 848    public string Id { get; set; } = "";
 849
 850    [BsonElement("correlation_id")]
 851    public string CorrelationId { get; set; } = "";
 852
 853    [BsonElement("registration_id")]
 854    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 855    public Guid RegistrationId { get; set; }
 856
 857    [BsonElement("state_json")]
 858    public string StateJson { get; set; } = "";
 859
 860    [BsonElement("expires_at")]
 861    public DateTime ExpiresAtUtc { get; set; }
 862
 863    [BsonElement("registered_at")]
 864    public DateTime RegisteredAtUtc { get; set; }
 865}
 866
 867internal sealed class MongoChannelMessageDocument
 868{
 869    [BsonId]
 870    [BsonElement("_id")]
 871    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 872    public Guid Id { get; set; }
 873
 874    [BsonElement("correlation_id")]
 875    public string CorrelationId { get; set; } = "";
 876
 877    /// <summary>Null only on a sweep projection of an acknowledged document (<see cref="MongoDbChannelStore.SweepProjec
 878    [BsonElement("envelope_json")]
 879    public string? EnvelopeJson { get; set; } = "";
 880
 881    [BsonElement("created_at")]
 882    public DateTime CreatedAtUtc { get; set; }
 883
 884    [BsonElement("expires_at")]
 885    public DateTime ExpiresAtUtc { get; set; }
 886
 887    [BsonElement("acked_at")]
 888    public DateTime? AckedAtUtc { get; set; }
 889
 890    [BsonElement("acked_seq")]
 891    public long? AckedSeq { get; set; }
 892
 893    [BsonElement("recovery_claimed")]
 894    public bool RecoveryClaimed { get; set; }
 895}
 896
 897internal sealed class MongoChannelSubscriberDocument
 898{
 899    [BsonId]
 900    [BsonElement("_id")]
 901    public string Id { get; set; } = "";
 902
 903    [BsonElement("correlation_id")]
 904    public string CorrelationId { get; set; } = "";
 905
 906    [BsonElement("registration_id")]
 907    [BsonGuidRepresentation(GuidRepresentation.Standard)]
 908    public Guid RegistrationId { get; set; }
 909
 910    [BsonElement("instance_id")]
 911    public string InstanceId { get; set; } = "";
 912
 913    [BsonElement("expires_at")]
 914    public DateTime ExpiresAtUtc { get; set; }
 915}

Methods/Properties

.ctor(MongoDB.Driver.IMongoDatabase,Microsoft.Extensions.Options.IOptions`1<AsyncResponse.Channels.MongoDB.MongoDbAsyncResponseChannelOptions>,MongoDB.Driver.IMongoClient,AsyncResponse.Internal.IMongoNamespaceRegistry,Microsoft.Extensions.Logging.ILogger)
EnsureCreatedAsync()
CreateTtlIndexAsync()
WarnIfManagedIndexesMissingAsync()
WarnIfCollectionIndexesMissingAsync()
IndexLeadsOn(MongoDB.Bson.BsonDocument,System.String)
SaveRecoveryStateAsync()
BuildRecoveryStateUpsertPipeline(System.String,AsyncResponse.RecoveryState,System.TimeSpan)
LoadRecoveryStatesAsync()
DeleteRecoveryStateAsync()
ScanRecoveryStateJsonAsync()
InsertMessageAsync(System.Guid,System.String,System.String,System.TimeSpan,System.Threading.CancellationToken)
InsertMessageOnceAsync()
BuildInsertMessagePipeline(System.String,System.String,System.TimeSpan)
LoadMessagesAsync()
.cctor()
LoadMessagesByIdAsync()
ToMessages(System.Collections.Generic.List`1<AsyncResponse.Channels.MongoDB.MongoChannelMessageDocument>)
TryClaimForDeliveryAsync()
BuildDeliveryClaimFilter(System.Guid)
BuildDeliveryClaimUpdate(System.Int64)
DrawAckSequenceAsync()
TryClaimForRecoveryAsync()
BuildRecoveryClaimFilter(System.Guid)
GetServerTimeUtcAsync()
GetSubscriptionStartAsync()
BuildSubscriptionStartPipeline()
IsMessageAcknowledgedAsync()
UpsertSubscriberAsync()
BuildSubscriberUpsertPipeline(System.String,System.Guid,System.String,System.TimeSpan)
HeartbeatSubscribersAsync()
DeleteSubscriberAsync()
CountActiveSubscribersAsync()
NotExpiredOnServerClock()
WatchMessagesAsync()
BuildMessageWatchPipeline()
IsChangeStreamUnsupported(System.Exception)
RegistrationKey(System.String,System.Guid)
CountersCollectionName(System.String)
ValidateCollectionName(System.String,System.String)
Dispose()