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

Information
Class: AsyncResponse.Internal.MongoOwnershipLedger
Assembly: AsyncResponse.DurableFlows.MongoDB
File(s): /_/src/Shared/MongoOwnershipLedger.cs
Line coverage
89%
Covered lines: 33
Uncovered lines: 4
Coverable lines: 37
Total lines: 100
Line coverage: 89.1%
Branch coverage
53%
Covered branches: 15
Total branches: 28
Branch coverage: 53.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ClaimAsync()93.75%161690.9%
UpsertClaimAsync()100%11100%
IsDuplicateKey(...)0%156120%

File(s)

/_/src/Shared/MongoOwnershipLedger.cs

#LineLine coverage
 1using MongoDB.Bson;
 2using MongoDB.Driver;
 3
 4namespace AsyncResponse.Internal;
 5
 6/// <summary>
 7/// Persisted cross-host collection-ownership ledger. The in-container
 8/// <c>MongoNamespaceRegistry</c> fails same-container collisions at construction, but two HOSTS
 9/// (or a directly constructed store) sharing a database cannot see each other's claims — and
 10/// MongoDB has no catalog metadata to verify after the fact: a durable-flow store configured
 11/// onto the channel's derived <c>{MessageCollection}_counters</c> collection happily writes flow
 12/// documents there, and its TTL index then silently deletes the ack-sequence counter. Each
 13/// store, at first use (EnsureCreated), atomically upserts one claim document per effective
 14/// collection into the fixed <c>asyncresponse_ownership</c> collection; a claim already held by
 15/// a DIFFERENT component (or the same component in a different role) fails startup with an
 16/// actionable error naming both claimants — whichever process starts second, whatever the
 17/// order. Restarts re-claim idempotently. Deployments that disable auto-creation own their
 18/// provisioning and skip the ledger, like the rest of the first-use DDL. Renaming a collection
 19/// in configuration leaves the old claim behind; the error text covers removing a stale claim
 20/// document deliberately. Source-linked into the channel, transport, and durable-flow packages.
 21/// </summary>
 22internal static class MongoOwnershipLedger
 23{
 24    /// <summary>Fixed ledger collection name; rejected as a configurable data collection by every store.</summary>
 25    public const string CollectionName = "asyncresponse_ownership";
 26
 27    public static async Task ClaimAsync(
 28        IMongoDatabase database,
 29        string componentName,
 30        IReadOnlyList<(string Collection, string Purpose)> claims,
 31        CancellationToken cancellationToken)
 32    {
 15533        var ledger = database.GetCollection<BsonDocument>(CollectionName);
 61934        foreach (var (collection, purpose) in claims)
 35        {
 36            // Atomic claim: the upsert inserts our ownership document only when no document
 37            // exists for the collection; a concurrent claimant loses the insert and reads the
 38            // winner's document. Documents lacking the component/purpose fields — or carrying
 39            // non-string values (a hand-repaired claim with component: null, a migration that
 40            // stored an enum) — are foreign writes: tolerated rather than guessed about, and the
 41            // BsonString pattern matches below keep the guard itself from throwing
 42            // InvalidCastException while evaluating them.
 15543            Task<BsonDocument?> UpsertClaimAsync() => ledger.FindOneAndUpdateAsync<BsonDocument?>(
 15544                new BsonDocument("_id", collection),
 15545                new BsonDocument("$setOnInsert", new BsonDocument
 15546                {
 15547                    { "component", componentName },
 15548                    { "purpose", purpose }
 15549                }),
 15550                new FindOneAndUpdateOptions<BsonDocument, BsonDocument?>
 15551                {
 15552                    IsUpsert = true,
 15553                    ReturnDocument = ReturnDocument.Before
 15554                },
 15555                cancellationToken);
 56
 57            BsonDocument? existing;
 58            try
 59            {
 15560                existing = await UpsertClaimAsync().ConfigureAwait(false);
 15561            }
 062            catch (MongoException ex) when (IsDuplicateKey(ex))
 63            {
 64                // The upsert's no-match-then-insert is not atomic against a concurrent FIRST
 65                // claim on the same _id: the loser of that race gets E11000 instead of the
 66                // winner's document. One identical retry now matches the winner's document and
 67                // resolves through the ownership check below — idempotent success for the same
 68                // component, the actionable conflict error for a different one.
 069                existing = await UpsertClaimAsync().ConfigureAwait(false);
 70            }
 71
 15572            if (existing is not null
 15573                && existing.TryGetValue("component", out var owner)
 15574                && existing.TryGetValue("purpose", out var ownerPurpose)
 15575                && owner is BsonString ownerName
 15576                && ownerPurpose is BsonString ownerPurposeName
 15577                && !(ownerName.Value == componentName && ownerPurposeName.Value == purpose))
 78            {
 179                throw new InvalidOperationException(
 180                    $"MongoDB collection '{database.DatabaseNamespace.DatabaseName}.{collection}' is already claimed by 
 181                    $"{ownerName.Value} ({ownerPurposeName.Value}) in the persisted ownership ledger " +
 182                    $"('{CollectionName}'), and this {componentName} configured it as {purpose}. Components sharing a da
 183                    "must use distinct collections — including derived ones such as the channel's '{MessageCollection}_c
 184                    "ack-sequence counter, whose documents another component's TTL index would silently delete. Rename o
 185                    "configured collection names, or delete the stale claim document if the other component was delibera
 186                    "reconfigured away from this collection.");
 87            }
 15488        }
 15489    }
 90
 91    /// <summary>
 92    /// The server's duplicate-key rejection (the E11000 family), on either surface it reaches the
 93    /// driver through: findAndModify reports it as a command error, write commands as a
 94    /// categorized write error. The code set matches the driver's own
 95    /// <see cref="ServerErrorCategory.DuplicateKey"/> mapping.
 96    /// </summary>
 97    private static bool IsDuplicateKey(MongoException exception)
 098        => exception is MongoWriteException { WriteError.Category: ServerErrorCategory.DuplicateKey }
 099           || exception is MongoCommandException { Code: 11000 or 11001 or 12582 };
 100}