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

Information
Class: AsyncResponse.DurableFlows.MongoDB.MongoDbFlowStateStore
Assembly: AsyncResponse.DurableFlows.MongoDB
File(s): /_/src/DurableFlows/AsyncResponse.DurableFlows.MongoDB/MongoDurableFlows.cs
Line coverage
99%
Covered lines: 213
Uncovered lines: 1
Coverable lines: 214
Total lines: 606
Line coverage: 99.5%
Branch coverage
92%
Covered branches: 46
Total branches: 50
Branch coverage: 92%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.ctor(...)50%22100%
LoadAsync()83.33%6690.9%
ValidateCreate(...)50%22100%
TryCreateAsync()100%22100%
TryUpdateAsync()100%11100%
TryAcquireLeaseAsync(...)100%11100%
TryRenewLeaseAsync(...)100%11100%
ReleaseLeaseAsync()100%11100%
ObserveLeaseAsync()100%44100%
TryDeleteAsync()100%11100%
EnsureCreatedAsync()90%101087.5%
VerifyTtlIndexAsync()100%1212100%
ReadServerNowAsync()100%44100%
UpdateLeaseAsync()100%11100%
BuildLiveFilter(...)100%11100%
BuildExpiredReplaceFilter(...)100%11100%
BuildCheckpointFilter(...)100%22100%
BuildLeaseFilter(...)100%22100%
BuildStateUpdate(...)100%22100%
BuildLeaseUpdate(...)100%11100%
BuildLeaseProjection()100%11100%
ServerClockExpr(...)100%11100%
Dispose()100%22100%

File(s)

/_/src/DurableFlows/AsyncResponse.DurableFlows.MongoDB/MongoDurableFlows.cs

#LineLine coverage
 1using AsyncResponse;
 2using AsyncResponse.DurableFlows.Internal;
 3using AsyncResponse.DurableFlows.MongoDB;
 4using AsyncResponse.Internal;
 5using Microsoft.Extensions.DependencyInjection.Extensions;
 6using Microsoft.Extensions.Options;
 7using MongoDB.Bson;
 8using MongoDB.Bson.Serialization.Attributes;
 9using MongoDB.Driver;
 10
 11namespace Microsoft.Extensions.DependencyInjection
 12{
 13    /// <summary>DI registration for the MongoDB durable-flow state store.</summary>
 14    public static class MongoDurableFlowServiceCollectionExtensions
 15    {
 16        /// <summary>
 17        /// Stores durable-flow state in MongoDB. Hosts may either register an
 18        /// <see cref="IMongoDatabase"/> singleton or set connection options here.
 19        /// </summary>
 20        public static AsyncResponseRegistrationBuilder WithMongoDbDurableFlows(
 21            this AsyncResponseRegistrationBuilder builder,
 22            Action<MongoDbDurableFlowOptions>? configure = null)
 23        {
 24            // Singleton on purpose: index provisioning is cached per store instance, and the
 25            // executor resolves the store from a fresh scope per flow execution. Host-registered
 26            // IMongoDatabase / IMongoClient services are reused when present; otherwise the store
 27            // creates and owns a client from the options. Nothing is registered as a bare
 28            // IMongoClient/IMongoDatabase service, so unrelated resolutions of those types are
 29            // never answered — or broken — by this package.
 30            builder.Services.TryAddSingleton<IMongoNamespaceRegistry, MongoNamespaceRegistry>();
 31            builder.Services.TryAddSingleton(provider =>
 32            {
 33                var options = provider.GetRequiredService<IOptions<MongoDbDurableFlowOptions>>();
 34
 35                var database = provider.GetService<IMongoDatabase>();
 36                if (database is not null)
 37                    return new MongoDbFlowStateStore(database, options, ownedClient: null, provider.GetRequiredService<I
 38
 39                if (string.IsNullOrWhiteSpace(options.Value.DatabaseName))
 40                    throw new InvalidOperationException($"{nameof(MongoDbDurableFlowOptions)}.{nameof(MongoDbDurableFlow
 41
 42                var sharedClient = provider.GetService<IMongoClient>();
 43                if (sharedClient is not null)
 44                    return new MongoDbFlowStateStore(sharedClient.GetDatabase(options.Value.DatabaseName), options, owne
 45
 46                if (string.IsNullOrWhiteSpace(options.Value.ConnectionString))
 47                    throw new InvalidOperationException($"{nameof(MongoDbDurableFlowOptions)}.{nameof(MongoDbDurableFlow
 48
 49                var ownedClient = new MongoClient(options.Value.ConnectionString);
 50                return new MongoDbFlowStateStore(ownedClient.GetDatabase(options.Value.DatabaseName), options, ownedClie
 51            });
 52            return builder.WithDurableFlows<MongoDbFlowStateStore, MongoDbDurableFlowOptions>(configure);
 53        }
 54    }
 55}
 56
 57namespace AsyncResponse.DurableFlows.MongoDB
 58{
 59/// <summary>Options for the MongoDB durable-flow state store.</summary>
 60public sealed class MongoDbDurableFlowOptions : DurableFlowOptions
 61{
 62    /// <summary>Optional MongoDB connection string used when no <see cref="IMongoDatabase"/> is registered.</summary>
 63    public string? ConnectionString { get; set; }
 64
 65    /// <summary>Optional database name used when no <see cref="IMongoDatabase"/> is registered.</summary>
 66    public string? DatabaseName { get; set; }
 67
 68    /// <summary>Collection storing one durable-flow ledger document per flow id.</summary>
 69    public string CollectionName { get; set; } = "asyncresponse_flow_state";
 70
 71    /// <summary>Creates the expiry index on first use.</summary>
 72    public bool AutoCreateIndexes { get; set; } = true;
 73
 74    /// <summary>
 75    /// Claims the ledger collection in the persisted cross-component ownership ledger
 76    /// (<c>asyncresponse_ownership</c>) at first use, so another AsyncResponse component — in
 77    /// this or any other process — misconfigured onto the same collection fails startup instead
 78    /// of silently corrupting data (a flow store on the channel's derived counters collection
 79    /// would let this store's TTL index delete the ack counter). Independent of
 80    /// <see cref="AutoCreateIndexes"/>. Disable only for least-privilege deployments that cannot
 81    /// write the ledger collection. Default: <c>true</c>.
 82    /// </summary>
 83    public bool UseOwnershipLedger { get; set; } = true;
 84
 85    /// <summary>
 86    /// Maximum serialized flow-state size in bytes accepted by writes; oversized ledgers fail fast
 87    /// with an actionable error instead of the raw 16 MB BSON-document error the executor would
 88    /// retry into the dead-letter queue. Default: 15 MB (headroom under MongoDB's 16 MB document
 89    /// cap for the sibling fields); <c>null</c> disables the guard.
 90    /// </summary>
 91    public long? MaxStateBytes { get; set; } = 15_000_000;
 92
 93    /// <summary>Validates option values and throws on misconfiguration.</summary>
 94    public void Validate()
 95    {
 96        if (string.IsNullOrWhiteSpace(CollectionName))
 97            throw new InvalidOperationException($"{nameof(MongoDbDurableFlowOptions)}.{nameof(CollectionName)} must be c
 98        if (CollectionName.Contains('$') || CollectionName.Contains('\0')
 99            || CollectionName.StartsWith("system.", StringComparison.Ordinal) || CollectionName.Contains(".system.", Str
 100            throw new InvalidOperationException(
 101                $"{nameof(MongoDbDurableFlowOptions)}.{nameof(CollectionName)} '{CollectionName}' must be a valid MongoD
 102        if (string.Equals(CollectionName, MongoOwnershipLedger.CollectionName, StringComparison.Ordinal))
 103            throw new InvalidOperationException(
 104                $"{nameof(MongoDbDurableFlowOptions)}.{nameof(CollectionName)} '{CollectionName}' is reserved for the cr
 105        DurableFlowStoreShared.ValidateMaxStateBytes(MaxStateBytes, nameof(MongoDbDurableFlowOptions));
 106    }
 107}
 108
 109/// <summary>MongoDB implementation of <see cref="IFlowStateStore"/>.</summary>
 110public sealed class MongoDbFlowStateStore : IFlowStateStore, IDisposable
 111{
 112    private readonly IMongoDatabase _database;
 113    private readonly IMongoCollection<MongoFlowStateDocument> _collection;
 114    private readonly MongoDbDurableFlowOptions _options;
 245115    private readonly SemaphoreSlim _ensureGate = new(1, 1);
 116    private readonly IMongoClient? _ownedClient;
 117    private volatile bool _created;
 118
 119    /// <summary>
 120    /// DI construction path: also claims the collection in the container's cross-component
 121    /// ownership ledger — a flow store configured onto the channel's derived counters collection
 122    /// would let the flow TTL index silently delete the ack-sequence counter.
 123    /// </summary>
 124    internal MongoDbFlowStateStore(
 125        IMongoDatabase database,
 126        IOptions<MongoDbDurableFlowOptions> options,
 127        IMongoClient? ownedClient,
 128        IMongoNamespaceRegistry? namespaceRegistry)
 210129        : this(database, options, ownedClient)
 130    {
 210131        namespaceRegistry?.Claim(
 210132            MongoNamespaceRegistry.ClusterKey(database),
 210133            database.DatabaseNamespace.DatabaseName,
 210134            "MongoDB durable-flow store",
 210135            [(_options.CollectionName, nameof(_options.CollectionName))]);
 208136    }
 137
 245138    public MongoDbFlowStateStore(
 245139        IMongoDatabase database,
 245140        IOptions<MongoDbDurableFlowOptions> options,
 245141        IMongoClient? ownedClient = null)
 142    {
 245143        _options = options.Value;
 245144        _options.Validate();
 245145        _database = database;
 146
 147        // The namespace BYTE limit can only be checked here, where the actual database name is
 148        // first known; a near-limit configuration otherwise passes every static check and fails
 149        // at the first server operation.
 245150        MongoNamespaceRegistry.ValidateEffectiveNamespace(database, _options.CollectionName, nameof(_options.CollectionN
 151
 152        // Primary reads, whatever read preference the host-supplied database carries: a
 153        // secondaryPreferred connection string would route every ledger load to a lagging
 154        // secondary, where a stale revision replays an already-checkpointed step and a
 155        // not-yet-replicated ledger reads as null — the one answer callers ACK a wake-up on
 156        // (LoadAsync's contract, and the reason the DynamoDB sibling pins ConsistentRead).
 157        // It also keeps reads on the same authority whose $$NOW the filters evaluate against
 158        // (see ReadServerNowAsync).
 159        //
 160        // Majority writes, whatever write concern the host-supplied database carries: under an
 161        // inherited w=1 (a connection-string default, or any pre-5.0 deployment) the primary
 162        // acknowledges a checkpoint, lease, or create before a single secondary has it, and a
 163        // failover rolls it back — the lease a worker is executing under, or the step result it
 164        // just recorded, silently disappears and the step's side effect runs again. The read
 165        // concern stays inherited on purpose: primary reads already see every write this store
 166        // had acknowledged (read-your-writes needs nothing more), and a majority snapshot could
 167        // only hide a competitor's newer write, which the revision/lease filters reject anyway.
 243168        _collection = database.GetCollection<MongoFlowStateDocument>(_options.CollectionName)
 243169            .WithReadPreference(ReadPreference.Primary)
 243170            .WithWriteConcern(WriteConcern.WMajority);
 243171        _ownedClient = ownedClient;
 243172    }
 173
 174    public async Task<FlowState?> LoadAsync(string flowId, CancellationToken cancellationToken = default)
 175    {
 676176        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 676177        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 178
 179        // Expiry is evaluated against the server clock ($$NOW) — the same authority the TTL
 180        // monitor reaps with — so app clock skew can never resurrect an expired ledger or hide a
 181        // live one. All lease fencing below uses the same authority.
 676182        var document = await _collection.Find(BuildLiveFilter(flowId)).FirstOrDefaultAsync(cancellationToken).ConfigureA
 676183        if (document is null)
 4184            return null;
 185
 186        // BuildLiveFilter already excluded expired documents server-side, so reaching here with a
 187        // document means the ledger is present and live. A missing required field is therefore an
 188        // unreadable ledger, not an absent one — returning null for it acknowledged the only
 189        // wake-up of a run still sitting in the collection.
 672190        if (document.Revision is not { } revision)
 1191            throw new FlowStateUnreadableException(flowId, "its stored document has no revision");
 192
 671193        if (string.IsNullOrEmpty(document.StateJson))
 0194            throw new FlowStateUnreadableException(flowId, "its stored document has no state JSON");
 195
 671196        return DurableFlowStoreShared.ReadState(flowId, document.StateJson, revision);
 673197    }
 198
 199    /// <inheritdoc />
 200    public void ValidateCreate(string flowId, FlowState state, TimeSpan ttl)
 201    {
 134202        DurableFlowStoreShared.ValidateCreate(flowId, state, ttl);
 134203        if (_options.MaxStateBytes is not null)
 134204            _ = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "MongoDB");
 132205    }
 206
 207    public async Task<bool> TryCreateAsync(string flowId, FlowState state, TimeSpan ttl, CancellationToken cancellationT
 208    {
 307209        DurableFlowStoreShared.ValidateCreate(flowId, state, ttl);
 306210        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "MongoDB");
 304211        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 212
 213        // Two server-side steps instead of one upsert because MongoDB rejects upserts whose query
 214        // uses $expr, and $expr is what lets the expired-check run on the server clock.
 215        //
 216        // Step 1: atomically replace an expired ledger in place. Filter and assignments both
 217        // evaluate on $$NOW, so exactly one competing creator wins and every loser then sees the
 218        // fresh future expiry.
 299219        var replaced = await _collection.UpdateOneAsync(
 299220            BuildExpiredReplaceFilter(flowId),
 299221            BuildStateUpdate(stateJson, state.Revision, ttl, resetLease: true),
 299222            options: null,
 299223            cancellationToken).ConfigureAwait(false);
 299224        if (replaced.ModifiedCount > 0)
 1225            return true;
 226
 227        // Step 2: the id was absent (or the expired document was TTL-purged after step 1 looked):
 228        // insert a fresh ledger. A plain insert has no aggregation context, so $$NOW is
 229        // unavailable — instead the server clock is read with one cheap `hello` round-trip and
 230        // stamped client-side. Creation then uses the same authority as every $$NOW comparison
 231        // and refresh below, so app clock skew can never mint a ledger that is born expired or
 232        // outlives its TTL window. A duplicate key means a live ledger owns the id.
 298233        var serverNow = await ReadServerNowAsync(cancellationToken).ConfigureAwait(false);
 234        try
 235        {
 298236            await _collection.InsertOneAsync(
 298237                new MongoFlowStateDocument
 298238                {
 298239                    FlowId = flowId,
 298240                    StateJson = stateJson,
 298241                    ExpiresAtUtc = DurableFlowStoreShared.AddSaturating(serverNow, ttl),
 298242                    UpdatedAtUtc = serverNow,
 298243                    Revision = state.Revision
 298244                },
 298245                options: null,
 298246                cancellationToken).ConfigureAwait(false);
 151247            return true;
 248        }
 147249        catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
 250        {
 147251            return false;
 252        }
 299253    }
 254
 255    public async Task<bool> TryUpdateAsync(
 256        string flowId,
 257        FlowState state,
 258        long expectedRevision,
 259        TimeSpan ttl,
 260        string? leaseId = null,
 261        CancellationToken cancellationToken = default)
 262    {
 865263        DurableFlowStoreShared.ValidateUpdate(flowId, state, expectedRevision, ttl);
 865264        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "MongoDB");
 865265        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 266
 865267        var result = await _collection.UpdateOneAsync(
 865268            BuildCheckpointFilter(flowId, expectedRevision, leaseId),
 865269            BuildStateUpdate(stateJson, state.Revision, ttl, resetLease: false),
 865270            options: null,
 865271            cancellationToken).ConfigureAwait(false);
 272        // ModifiedCount is safe here (unlike lease renewal): a checkpoint always bumps the
 273        // revision, so a matched document is always modified.
 865274        return result.ModifiedCount > 0;
 865275    }
 276
 277    public Task<bool> TryAcquireLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken canc
 151278        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: true, cancellationToken);
 279
 280    public Task<bool> TryRenewLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken cancel
 13281        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: false, cancellationToken);
 282
 283    public async Task ReleaseLeaseAsync(string flowId, string leaseId, CancellationToken cancellationToken = default)
 284    {
 142285        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 142286        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 142287                     & Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId);
 142288        var update = Builders<MongoFlowStateDocument>.Update
 142289            .Unset(item => item.LeaseId)
 142290            .Unset(item => item.LeaseExpiresAtUtc);
 142291        await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 142292    }
 293
 294    /// <inheritdoc />
 295    public async Task<FlowLeaseObservation?> ObserveLeaseAsync(string flowId, CancellationToken cancellationToken = defa
 296    {
 32297        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 26298        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 299
 300        // The two lease fields exactly as stored — deliberately an id-only filter with no $$NOW
 301        // comparison, unlike every other read and write in this store: an expired lease nobody has
 302        // taken over must keep reading as the same lease, because the engine's proof of a live
 303        // holder is that two observations DIFFER. Whether it has lapsed stays BuildLeaseFilter's
 304        // call, on the server clock. Read from the primary like every ledger read (the collection
 305        // handle is pinned at construction), so a lagging secondary can never replay a stale
 306        // lease as "unchanged"; the projection keeps state_json off the wire.
 26307        var document = await _collection
 26308            .Find(Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId))
 26309            .Project<MongoFlowStateDocument>(BuildLeaseProjection())
 26310            .FirstOrDefaultAsync(cancellationToken)
 26311            .ConfigureAwait(false);
 312
 313        // BSON dates are UTC milliseconds and the driver materializes them as DateTimeKind.Utc.
 26314        return DurableFlowStoreShared.LeaseObservation(document?.LeaseId, document?.LeaseExpiresAtUtc);
 26315    }
 316
 317    public async Task<bool> TryDeleteAsync(string flowId, CancellationToken cancellationToken = default)
 318    {
 10319        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 10320        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 321
 10322        var result = await _collection.DeleteOneAsync(
 10323            Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId),
 10324            cancellationToken).ConfigureAwait(false);
 10325        return result.DeletedCount > 0;
 10326    }
 327
 328    private async Task EnsureCreatedAsync(CancellationToken cancellationToken)
 329    {
 330        // No AutoCreateIndexes/UseOwnershipLedger fast-path here: with both disabled there is no
 331        // DDL and no ledger claim, but the TTL-reaper VERIFICATION below must still run once — an
 332        // early-out on the flag pair skipped it for exactly the locked-down deployment
 333        // (operator-provisioned indexes, no ledger writes) it exists to protect, and the
 334        // collection grew without bound with no error and no log line.
 2187335        if (_created)
 1919336            return;
 337
 268338        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 339        try
 340        {
 268341            if (_created)
 111342                return;
 343
 344            // Persisted cross-host ownership, independent of AutoCreateIndexes: a flow store
 345            // configured onto the channel's derived counters collection would let this TTL
 346            // index silently delete the ack-sequence counter — see MongoOwnershipLedger.
 157347            if (_options.UseOwnershipLedger)
 348            {
 155349                await MongoOwnershipLedger.ClaimAsync(
 155350                    _database,
 155351                    "MongoDB durable-flow store",
 155352                    [(_options.CollectionName, nameof(_options.CollectionName))],
 155353                    cancellationToken).ConfigureAwait(false);
 354            }
 355
 156356            if (!_options.AutoCreateIndexes)
 357            {
 358                // The TTL index is this store's ONLY cleanup mechanism (no application-side
 359                // pruning exists), so an operator-provisioned collection must be verified to
 360                // carry one — Cosmos and DynamoDB hard-fail the same way when their server-side
 361                // reaper is missing. Without this, a collection provisioned without
 362                // expireAfterSeconds grew without bound, with no error and no log line.
 22363                await VerifyTtlIndexAsync(cancellationToken).ConfigureAwait(false);
 18364                _created = true;
 18365                return;
 366            }
 367
 368            // A TTL index (expireAfterSeconds = 0 on the expiry timestamp) makes MongoDB itself
 369            // reap expired ledgers — no application-side pruning needed. Loads still filter on
 370            // ExpiresAtUtc because the TTL monitor only runs periodically (~60s).
 134371            var indexName = $"{_options.CollectionName}_expires_idx";
 134372            var model = new CreateIndexModel<MongoFlowStateDocument>(
 134373                Builders<MongoFlowStateDocument>.IndexKeys.Ascending(item => item.ExpiresAtUtc),
 134374                new CreateIndexOptions { Name = indexName, ExpireAfter = TimeSpan.Zero });
 375            // Do not drop or rewrite a conflicting application-owned index. MongoDB reports the
 376            // mismatch and startup fails, leaving the operator to correct schema intentionally.
 134377            await _collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
 134378            _created = true;
 134379        }
 380        finally
 381        {
 268382            _ensureGate.Release();
 383        }
 2182384    }
 385
 386    /// <summary>
 387    /// Verifies an operator-provisioned collection carries the TTL reaper this store depends on:
 388    /// a single-field index on the expiry timestamp with <c>expireAfterSeconds</c> set (any
 389    /// value — a delayed reap is bounded; a missing one is unbounded growth).
 390    /// </summary>
 391    private async Task VerifyTtlIndexAsync(CancellationToken cancellationToken)
 392    {
 22393        using var cursor = await _collection.Indexes.ListAsync(cancellationToken).ConfigureAwait(false);
 22394        var indexes = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
 70395        foreach (var index in indexes)
 396        {
 22397            if (index.Contains("expireAfterSeconds")
 22398                && index.TryGetValue("key", out var key)
 22399                && key is BsonDocument keyDocument
 22400                && keyDocument.ElementCount == 1
 22401                && keyDocument.Contains("expires_at_utc"))
 402            {
 18403                return;
 404            }
 405        }
 406
 4407        throw new InvalidOperationException(
 4408            $"The MongoDB durable-flow collection '{_options.CollectionName}' has no TTL index on 'expires_at_utc' and "
 4409            $"{nameof(MongoDbDurableFlowOptions)}.{nameof(MongoDbDurableFlowOptions.AutoCreateIndexes)} is disabled. The
 4410            "the store's only cleanup mechanism; without it expired flow ledgers accumulate forever. Create it " +
 4411            "(createIndex({ expires_at_utc: 1 }, { expireAfterSeconds: 0 })) or enable AutoCreateIndexes.");
 18412    }
 413
 414    /// <summary>
 415    /// Server clock for the one write that cannot compute it in place: plain inserts evaluate no
 416    /// pipeline, so <c>$$NOW</c> is out of reach. <c>hello</c> is answered by every supported
 417    /// server (the 4.2+ floor the <c>$$NOW</c> filters already require) and carries the node's
 418    /// <c>localTime</c>; reading it from the primary keeps the authority the same node whose
 419    /// <c>$$NOW</c> the filters evaluate against.
 420    /// </summary>
 421    private async Task<DateTime> ReadServerNowAsync(CancellationToken cancellationToken)
 422    {
 298423        var reply = await _database.RunCommandAsync<BsonDocument>(
 298424            new BsonDocument("hello", 1),
 298425            ReadPreference.Primary,
 298426            cancellationToken).ConfigureAwait(false);
 427        // Defensive: a mongo-compatible endpoint omitting localTime — or answering with a
 428        // non-date value (only BsonDateTime implements ToUniversalTime; every other BsonValue
 429        // throws) — falls back to the app clock, the pre-server-clock behavior, instead of
 430        // failing every create.
 298431        return reply.TryGetValue("localTime", out var localTime) && localTime is BsonDateTime serverTime
 298432            ? serverTime.ToUniversalTime()
 298433            : DateTime.UtcNow;
 298434    }
 435
 436    private async Task<bool> UpdateLeaseAsync(
 437        string flowId,
 438        string leaseId,
 439        TimeSpan leaseDuration,
 440        bool acquire,
 441        CancellationToken cancellationToken)
 442    {
 164443        DurableFlowStoreShared.ValidateLeaseArgs(flowId, leaseId, leaseDuration);
 444
 164445        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 164446        var result = await _collection.UpdateOneAsync(
 164447            BuildLeaseFilter(flowId, leaseId, acquire),
 164448            BuildLeaseUpdate(leaseId, leaseDuration),
 164449            options: null,
 164450            cancellationToken).ConfigureAwait(false);
 451        // MatchedCount, not ModifiedCount: matching the filter proves this owner held (or could
 452        // take) the lease — the atomic update then applied. A renewal that lands in the same
 453        // millisecond as the previous one writes an identical lease_expires_at_utc, which MongoDB
 454        // reports as matched-but-not-modified; treating that no-op as failure would abort a
 455        // healthy execution mid-flight.
 164456        return result.MatchedCount > 0;
 164457    }
 458
 459    /// <summary>Live-ledger filter: id match plus a server-clock ($$NOW) expiry check.</summary>
 460    internal static FilterDefinition<MongoFlowStateDocument> BuildLiveFilter(string flowId)
 678461        => Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 678462           & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$expires_at_utc", "$$NOW" }));
 463
 464    /// <summary>Expired-ledger filter used by create to replace a dead ledger in place.</summary>
 465    internal static FilterDefinition<MongoFlowStateDocument> BuildExpiredReplaceFilter(string flowId)
 301466        => Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 301467           & ServerClockExpr(new BsonDocument("$lte", new BsonArray { "$expires_at_utc", "$$NOW" }));
 468
 469    /// <summary>
 470    /// Checkpoint filter: revision fence plus server-clock expiry (and, when fenced by a lease,
 471    /// server-clock lease validity).
 472    /// </summary>
 473    internal static FilterDefinition<MongoFlowStateDocument> BuildCheckpointFilter(string flowId, long expectedRevision,
 474    {
 869475        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 869476                     & Builders<MongoFlowStateDocument>.Filter.Eq(item => item.Revision, expectedRevision)
 869477                     & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$expires_at_utc", "$$NOW" }));
 869478        if (leaseId is not null)
 479        {
 863480            filter &= Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId)
 863481                      & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$lease_expires_at_utc", "$$NOW" }));
 482        }
 483
 869484        return filter;
 485    }
 486
 487    /// <summary>
 488    /// Lease filter: acquire takes a free lease (absent, expired on the server clock, or already
 489    /// ours); renew requires ours and still live on the server clock. A missing
 490    /// <c>lease_expires_at_utc</c> compares below any date, so it counts as expired for acquire
 491    /// and as unrenewable for renew.
 492    /// </summary>
 493    internal static FilterDefinition<MongoFlowStateDocument> BuildLeaseFilter(string flowId, string leaseId, bool acquir
 494    {
 168495        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 168496                     & Builders<MongoFlowStateDocument>.Filter.Ne(item => item.Revision, null)
 168497                     & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$expires_at_utc", "$$NOW" }));
 168498        filter &= acquire
 168499            ? Builders<MongoFlowStateDocument>.Filter.Or(
 168500                Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, null),
 168501                ServerClockExpr(new BsonDocument("$lte", new BsonArray { "$lease_expires_at_utc", "$$NOW" })),
 168502                Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId))
 168503            : Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId)
 168504              & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$lease_expires_at_utc", "$$NOW" }));
 168505        return filter;
 506    }
 507
 508    /// <summary>
 509    /// Full-state write as an aggregation-pipeline update so the expiry lands on the server clock
 510    /// ($$NOW + ttl). <paramref name="resetLease"/> clears the lease columns (create-over-expired
 511    /// replaces ownership); checkpoints leave the running lease in place.
 512    /// </summary>
 513    internal static UpdateDefinition<MongoFlowStateDocument> BuildStateUpdate(string stateJson, long revision, TimeSpan 
 514    {
 1168515        var stages = new List<BsonDocument>
 1168516        {
 1168517            new("$set", new BsonDocument
 1168518            {
 1168519                // $literal keeps the JSON payload a value: a pipeline $set treats "$"-prefixed
 1168520                // strings as field paths.
 1168521                ["state_json"] = new BsonDocument("$literal", stateJson),
 1168522                ["expires_at_utc"] = new BsonDocument("$add", new BsonArray
 1168523                {
 1168524                    "$$NOW",
 1168525                    DurableFlowStoreShared.ServerClockTtlMilliseconds(ttl)
 1168526                }),
 1168527                ["updated_at_utc"] = "$$NOW",
 1168528                ["revision"] = revision
 1168529            })
 1168530        };
 1168531        if (resetLease)
 301532            stages.Add(new BsonDocument("$unset", new BsonArray { "lease_id", "lease_expires_at_utc" }));
 1168533        return Builders<MongoFlowStateDocument>.Update.Pipeline(stages.ToArray());
 534    }
 535
 536    /// <summary>Lease grant/renewal on the server clock: <c>lease_expires_at_utc = $$NOW + duration</c>.</summary>
 537    internal static UpdateDefinition<MongoFlowStateDocument> BuildLeaseUpdate(string leaseId, TimeSpan leaseDuration)
 166538        => Builders<MongoFlowStateDocument>.Update.Pipeline(new[]
 166539        {
 166540            new BsonDocument("$set", new BsonDocument
 166541            {
 166542                ["lease_id"] = new BsonDocument("$literal", leaseId),
 166543                ["lease_expires_at_utc"] = new BsonDocument("$add", new BsonArray
 166544                {
 166545                    "$$NOW",
 166546                    DurableFlowStoreShared.ServerClockTtlMilliseconds(leaseDuration)
 166547                })
 166548            })
 166549        });
 550
 551    /// <summary>
 552    /// Projection for <see cref="ObserveLeaseAsync"/>: only <c>lease_id</c> and
 553    /// <c>lease_expires_at_utc</c> (plus the implicit <c>_id</c>) leave the server, so observing a
 554    /// lease costs the same whatever the ledger's size.
 555    /// </summary>
 556    internal static ProjectionDefinition<MongoFlowStateDocument> BuildLeaseProjection()
 26557        => Builders<MongoFlowStateDocument>.Projection
 26558            .Include(item => item.LeaseId)
 26559            .Include(item => item.LeaseExpiresAtUtc);
 560
 561    private static FilterDefinition<MongoFlowStateDocument> ServerClockExpr(BsonDocument comparison)
 3047562        => new BsonDocumentFilterDefinition<MongoFlowStateDocument>(new BsonDocument("$expr", comparison));
 563
 564    /// <summary>Disposes the Mongo client when the store created (and therefore owns) it.</summary>
 565    public void Dispose()
 566    {
 444567        _ensureGate.Dispose();
 444568        (_ownedClient as IDisposable)?.Dispose();
 4569    }
 570}
 571
 572/// <remarks>
 573/// [BsonIgnoreExtraElements] for the same reason the transport's queue document carries it: the
 574/// driver's default is to THROW FormatException for any element outside this class map, so one
 575/// column a newer build added would make every older replica in a rolling deploy fail to read a
 576/// live flow's ledger — and an unreadable ledger is the one outcome the store contract refuses to
 577/// report as "absent".
 578/// </remarks>
 579[BsonIgnoreExtraElements]
 580internal sealed class MongoFlowStateDocument
 581{
 582    [BsonId]
 583    [BsonElement("_id")]
 584    public string FlowId { get; set; } = "";
 585
 586    [BsonElement("state_json")]
 587    public string StateJson { get; set; } = "";
 588
 589    [BsonElement("expires_at_utc")]
 590    public DateTime ExpiresAtUtc { get; set; }
 591
 592    [BsonElement("updated_at_utc")]
 593    public DateTime UpdatedAtUtc { get; set; }
 594
 595    [BsonElement("revision")]
 596    public long? Revision { get; set; }
 597
 598    [BsonElement("lease_id")]
 599    [BsonIgnoreIfNull]
 600    public string? LeaseId { get; set; }
 601
 602    [BsonElement("lease_expires_at_utc")]
 603    [BsonIgnoreIfNull]
 604    public DateTime? LeaseExpiresAtUtc { get; set; }
 605}
 606}