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

Information
Class: AsyncResponse.DurableFlows.MongoDB.MongoDbDurableFlowOptions
Assembly: AsyncResponse.DurableFlows.MongoDB
File(s): /_/src/DurableFlows/AsyncResponse.DurableFlows.MongoDB/MongoDurableFlows.cs
Line coverage
88%
Covered lines: 15
Uncovered lines: 2
Coverable lines: 17
Total lines: 606
Line coverage: 88.2%
Branch coverage
83%
Covered branches: 10
Total branches: 12
Branch coverage: 83.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_ConnectionString()100%11100%
get_DatabaseName()100%11100%
get_CollectionName()100%11100%
get_AutoCreateIndexes()100%11100%
get_UseOwnershipLedger()100%11100%
get_MaxStateBytes()100%11100%
Validate()83.33%131281.81%

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>
 863    public string? ConnectionString { get; set; }
 64
 65    /// <summary>Optional database name used when no <see cref="IMongoDatabase"/> is registered.</summary>
 1866    public string? DatabaseName { get; set; }
 67
 68    /// <summary>Collection storing one durable-flow ledger document per flow id.</summary>
 301169    public string CollectionName { get; set; } = "asyncresponse_flow_state";
 70
 71    /// <summary>Creates the expiry index on first use.</summary>
 44772    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>
 42083    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>
 195391    public long? MaxStateBytes { get; set; } = 15_000_000;
 92
 93    /// <summary>Validates option values and throws on misconfiguration.</summary>
 94    public void Validate()
 95    {
 25596        if (string.IsNullOrWhiteSpace(CollectionName))
 297            throw new InvalidOperationException($"{nameof(MongoDbDurableFlowOptions)}.{nameof(CollectionName)} must be c
 25398        if (CollectionName.Contains('$') || CollectionName.Contains('\0')
 25399            || CollectionName.StartsWith("system.", StringComparison.Ordinal) || CollectionName.Contains(".system.", Str
 6100            throw new InvalidOperationException(
 6101                $"{nameof(MongoDbDurableFlowOptions)}.{nameof(CollectionName)} '{CollectionName}' must be a valid MongoD
 247102        if (string.Equals(CollectionName, MongoOwnershipLedger.CollectionName, StringComparison.Ordinal))
 0103            throw new InvalidOperationException(
 0104                $"{nameof(MongoDbDurableFlowOptions)}.{nameof(CollectionName)} '{CollectionName}' is reserved for the cr
 247105        DurableFlowStoreShared.ValidateMaxStateBytes(MaxStateBytes, nameof(MongoDbDurableFlowOptions));
 245106    }
 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;
 115    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)
 129        : this(database, options, ownedClient)
 130    {
 131        namespaceRegistry?.Claim(
 132            MongoNamespaceRegistry.ClusterKey(database),
 133            database.DatabaseNamespace.DatabaseName,
 134            "MongoDB durable-flow store",
 135            [(_options.CollectionName, nameof(_options.CollectionName))]);
 136    }
 137
 138    public MongoDbFlowStateStore(
 139        IMongoDatabase database,
 140        IOptions<MongoDbDurableFlowOptions> options,
 141        IMongoClient? ownedClient = null)
 142    {
 143        _options = options.Value;
 144        _options.Validate();
 145        _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.
 150        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.
 168        _collection = database.GetCollection<MongoFlowStateDocument>(_options.CollectionName)
 169            .WithReadPreference(ReadPreference.Primary)
 170            .WithWriteConcern(WriteConcern.WMajority);
 171        _ownedClient = ownedClient;
 172    }
 173
 174    public async Task<FlowState?> LoadAsync(string flowId, CancellationToken cancellationToken = default)
 175    {
 176        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 177        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.
 182        var document = await _collection.Find(BuildLiveFilter(flowId)).FirstOrDefaultAsync(cancellationToken).ConfigureA
 183        if (document is null)
 184            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.
 190        if (document.Revision is not { } revision)
 191            throw new FlowStateUnreadableException(flowId, "its stored document has no revision");
 192
 193        if (string.IsNullOrEmpty(document.StateJson))
 194            throw new FlowStateUnreadableException(flowId, "its stored document has no state JSON");
 195
 196        return DurableFlowStoreShared.ReadState(flowId, document.StateJson, revision);
 197    }
 198
 199    /// <inheritdoc />
 200    public void ValidateCreate(string flowId, FlowState state, TimeSpan ttl)
 201    {
 202        DurableFlowStoreShared.ValidateCreate(flowId, state, ttl);
 203        if (_options.MaxStateBytes is not null)
 204            _ = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "MongoDB");
 205    }
 206
 207    public async Task<bool> TryCreateAsync(string flowId, FlowState state, TimeSpan ttl, CancellationToken cancellationT
 208    {
 209        DurableFlowStoreShared.ValidateCreate(flowId, state, ttl);
 210        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "MongoDB");
 211        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.
 219        var replaced = await _collection.UpdateOneAsync(
 220            BuildExpiredReplaceFilter(flowId),
 221            BuildStateUpdate(stateJson, state.Revision, ttl, resetLease: true),
 222            options: null,
 223            cancellationToken).ConfigureAwait(false);
 224        if (replaced.ModifiedCount > 0)
 225            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.
 233        var serverNow = await ReadServerNowAsync(cancellationToken).ConfigureAwait(false);
 234        try
 235        {
 236            await _collection.InsertOneAsync(
 237                new MongoFlowStateDocument
 238                {
 239                    FlowId = flowId,
 240                    StateJson = stateJson,
 241                    ExpiresAtUtc = DurableFlowStoreShared.AddSaturating(serverNow, ttl),
 242                    UpdatedAtUtc = serverNow,
 243                    Revision = state.Revision
 244                },
 245                options: null,
 246                cancellationToken).ConfigureAwait(false);
 247            return true;
 248        }
 249        catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
 250        {
 251            return false;
 252        }
 253    }
 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    {
 263        DurableFlowStoreShared.ValidateUpdate(flowId, state, expectedRevision, ttl);
 264        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "MongoDB");
 265        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 266
 267        var result = await _collection.UpdateOneAsync(
 268            BuildCheckpointFilter(flowId, expectedRevision, leaseId),
 269            BuildStateUpdate(stateJson, state.Revision, ttl, resetLease: false),
 270            options: null,
 271            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.
 274        return result.ModifiedCount > 0;
 275    }
 276
 277    public Task<bool> TryAcquireLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken canc
 278        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: true, cancellationToken);
 279
 280    public Task<bool> TryRenewLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken cancel
 281        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: false, cancellationToken);
 282
 283    public async Task ReleaseLeaseAsync(string flowId, string leaseId, CancellationToken cancellationToken = default)
 284    {
 285        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 286        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 287                     & Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId);
 288        var update = Builders<MongoFlowStateDocument>.Update
 289            .Unset(item => item.LeaseId)
 290            .Unset(item => item.LeaseExpiresAtUtc);
 291        await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 292    }
 293
 294    /// <inheritdoc />
 295    public async Task<FlowLeaseObservation?> ObserveLeaseAsync(string flowId, CancellationToken cancellationToken = defa
 296    {
 297        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 298        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.
 307        var document = await _collection
 308            .Find(Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId))
 309            .Project<MongoFlowStateDocument>(BuildLeaseProjection())
 310            .FirstOrDefaultAsync(cancellationToken)
 311            .ConfigureAwait(false);
 312
 313        // BSON dates are UTC milliseconds and the driver materializes them as DateTimeKind.Utc.
 314        return DurableFlowStoreShared.LeaseObservation(document?.LeaseId, document?.LeaseExpiresAtUtc);
 315    }
 316
 317    public async Task<bool> TryDeleteAsync(string flowId, CancellationToken cancellationToken = default)
 318    {
 319        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 320        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 321
 322        var result = await _collection.DeleteOneAsync(
 323            Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId),
 324            cancellationToken).ConfigureAwait(false);
 325        return result.DeletedCount > 0;
 326    }
 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.
 335        if (_created)
 336            return;
 337
 338        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 339        try
 340        {
 341            if (_created)
 342                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.
 347            if (_options.UseOwnershipLedger)
 348            {
 349                await MongoOwnershipLedger.ClaimAsync(
 350                    _database,
 351                    "MongoDB durable-flow store",
 352                    [(_options.CollectionName, nameof(_options.CollectionName))],
 353                    cancellationToken).ConfigureAwait(false);
 354            }
 355
 356            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.
 363                await VerifyTtlIndexAsync(cancellationToken).ConfigureAwait(false);
 364                _created = true;
 365                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).
 371            var indexName = $"{_options.CollectionName}_expires_idx";
 372            var model = new CreateIndexModel<MongoFlowStateDocument>(
 373                Builders<MongoFlowStateDocument>.IndexKeys.Ascending(item => item.ExpiresAtUtc),
 374                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.
 377            await _collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
 378            _created = true;
 379        }
 380        finally
 381        {
 382            _ensureGate.Release();
 383        }
 384    }
 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    {
 393        using var cursor = await _collection.Indexes.ListAsync(cancellationToken).ConfigureAwait(false);
 394        var indexes = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
 395        foreach (var index in indexes)
 396        {
 397            if (index.Contains("expireAfterSeconds")
 398                && index.TryGetValue("key", out var key)
 399                && key is BsonDocument keyDocument
 400                && keyDocument.ElementCount == 1
 401                && keyDocument.Contains("expires_at_utc"))
 402            {
 403                return;
 404            }
 405        }
 406
 407        throw new InvalidOperationException(
 408            $"The MongoDB durable-flow collection '{_options.CollectionName}' has no TTL index on 'expires_at_utc' and "
 409            $"{nameof(MongoDbDurableFlowOptions)}.{nameof(MongoDbDurableFlowOptions.AutoCreateIndexes)} is disabled. The
 410            "the store's only cleanup mechanism; without it expired flow ledgers accumulate forever. Create it " +
 411            "(createIndex({ expires_at_utc: 1 }, { expireAfterSeconds: 0 })) or enable AutoCreateIndexes.");
 412    }
 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    {
 423        var reply = await _database.RunCommandAsync<BsonDocument>(
 424            new BsonDocument("hello", 1),
 425            ReadPreference.Primary,
 426            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.
 431        return reply.TryGetValue("localTime", out var localTime) && localTime is BsonDateTime serverTime
 432            ? serverTime.ToUniversalTime()
 433            : DateTime.UtcNow;
 434    }
 435
 436    private async Task<bool> UpdateLeaseAsync(
 437        string flowId,
 438        string leaseId,
 439        TimeSpan leaseDuration,
 440        bool acquire,
 441        CancellationToken cancellationToken)
 442    {
 443        DurableFlowStoreShared.ValidateLeaseArgs(flowId, leaseId, leaseDuration);
 444
 445        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 446        var result = await _collection.UpdateOneAsync(
 447            BuildLeaseFilter(flowId, leaseId, acquire),
 448            BuildLeaseUpdate(leaseId, leaseDuration),
 449            options: null,
 450            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.
 456        return result.MatchedCount > 0;
 457    }
 458
 459    /// <summary>Live-ledger filter: id match plus a server-clock ($$NOW) expiry check.</summary>
 460    internal static FilterDefinition<MongoFlowStateDocument> BuildLiveFilter(string flowId)
 461        => Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 462           & 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)
 466        => Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 467           & 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    {
 475        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 476                     & Builders<MongoFlowStateDocument>.Filter.Eq(item => item.Revision, expectedRevision)
 477                     & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$expires_at_utc", "$$NOW" }));
 478        if (leaseId is not null)
 479        {
 480            filter &= Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId)
 481                      & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$lease_expires_at_utc", "$$NOW" }));
 482        }
 483
 484        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    {
 495        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 496                     & Builders<MongoFlowStateDocument>.Filter.Ne(item => item.Revision, null)
 497                     & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$expires_at_utc", "$$NOW" }));
 498        filter &= acquire
 499            ? Builders<MongoFlowStateDocument>.Filter.Or(
 500                Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, null),
 501                ServerClockExpr(new BsonDocument("$lte", new BsonArray { "$lease_expires_at_utc", "$$NOW" })),
 502                Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId))
 503            : Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId)
 504              & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$lease_expires_at_utc", "$$NOW" }));
 505        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    {
 515        var stages = new List<BsonDocument>
 516        {
 517            new("$set", new BsonDocument
 518            {
 519                // $literal keeps the JSON payload a value: a pipeline $set treats "$"-prefixed
 520                // strings as field paths.
 521                ["state_json"] = new BsonDocument("$literal", stateJson),
 522                ["expires_at_utc"] = new BsonDocument("$add", new BsonArray
 523                {
 524                    "$$NOW",
 525                    DurableFlowStoreShared.ServerClockTtlMilliseconds(ttl)
 526                }),
 527                ["updated_at_utc"] = "$$NOW",
 528                ["revision"] = revision
 529            })
 530        };
 531        if (resetLease)
 532            stages.Add(new BsonDocument("$unset", new BsonArray { "lease_id", "lease_expires_at_utc" }));
 533        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)
 538        => Builders<MongoFlowStateDocument>.Update.Pipeline(new[]
 539        {
 540            new BsonDocument("$set", new BsonDocument
 541            {
 542                ["lease_id"] = new BsonDocument("$literal", leaseId),
 543                ["lease_expires_at_utc"] = new BsonDocument("$add", new BsonArray
 544                {
 545                    "$$NOW",
 546                    DurableFlowStoreShared.ServerClockTtlMilliseconds(leaseDuration)
 547                })
 548            })
 549        });
 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()
 557        => Builders<MongoFlowStateDocument>.Projection
 558            .Include(item => item.LeaseId)
 559            .Include(item => item.LeaseExpiresAtUtc);
 560
 561    private static FilterDefinition<MongoFlowStateDocument> ServerClockExpr(BsonDocument comparison)
 562        => 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    {
 567        _ensureGate.Dispose();
 568        (_ownedClient as IDisposable)?.Dispose();
 569    }
 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}