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

Information
Class: AsyncResponse.DurableFlows.MongoDB.MongoFlowStateDocument
Assembly: AsyncResponse.DurableFlows.MongoDB
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/DurableFlows/AsyncResponse.DurableFlows.MongoDB/MongoDurableFlows.cs
Line coverage
100%
Covered lines: 2
Uncovered lines: 0
Coverable lines: 2
Total lines: 428
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/DurableFlows/AsyncResponse.DurableFlows.MongoDB/MongoDurableFlows.cs

#LineLine coverage
 1using AsyncResponse;
 2using AsyncResponse.DurableFlows.Internal;
 3using AsyncResponse.DurableFlows.MongoDB;
 4using Microsoft.Extensions.DependencyInjection.Extensions;
 5using Microsoft.Extensions.Options;
 6using MongoDB.Bson;
 7using MongoDB.Bson.Serialization.Attributes;
 8using MongoDB.Driver;
 9
 10namespace Microsoft.Extensions.DependencyInjection
 11{
 12    /// <summary>DI registration for the MongoDB durable-flow state store.</summary>
 13    public static class MongoDurableFlowServiceCollectionExtensions
 14    {
 15        /// <summary>
 16        /// Stores durable-flow state in MongoDB. Hosts may either register an
 17        /// <see cref="IMongoDatabase"/> singleton or set connection options here.
 18        /// </summary>
 19        public static AsyncResponseRegistrationBuilder WithMongoDbDurableFlows(
 20            this AsyncResponseRegistrationBuilder builder,
 21            Action<MongoDbDurableFlowOptions>? configure = null)
 22        {
 23            // Singleton on purpose: index provisioning is cached per store instance, and the
 24            // executor resolves the store from a fresh scope per flow execution. Host-registered
 25            // IMongoDatabase / IMongoClient services are reused when present; otherwise the store
 26            // creates and owns a client from the options. Nothing is registered as a bare
 27            // IMongoClient/IMongoDatabase service, so unrelated resolutions of those types are
 28            // never answered — or broken — by this package.
 29            builder.Services.TryAddSingleton(provider =>
 30            {
 31                var options = provider.GetRequiredService<IOptions<MongoDbDurableFlowOptions>>();
 32
 33                var database = provider.GetService<IMongoDatabase>();
 34                if (database is not null)
 35                    return new MongoDbFlowStateStore(database, options);
 36
 37                if (string.IsNullOrWhiteSpace(options.Value.DatabaseName))
 38                    throw new InvalidOperationException($"{nameof(MongoDbDurableFlowOptions)}.{nameof(MongoDbDurableFlow
 39
 40                var sharedClient = provider.GetService<IMongoClient>();
 41                if (sharedClient is not null)
 42                    return new MongoDbFlowStateStore(sharedClient.GetDatabase(options.Value.DatabaseName), options);
 43
 44                if (string.IsNullOrWhiteSpace(options.Value.ConnectionString))
 45                    throw new InvalidOperationException($"{nameof(MongoDbDurableFlowOptions)}.{nameof(MongoDbDurableFlow
 46
 47                var ownedClient = new MongoClient(options.Value.ConnectionString);
 48                return new MongoDbFlowStateStore(ownedClient.GetDatabase(options.Value.DatabaseName), options, ownedClie
 49            });
 50            return builder.WithDurableFlows<MongoDbFlowStateStore, MongoDbDurableFlowOptions>(configure);
 51        }
 52    }
 53}
 54
 55namespace AsyncResponse.DurableFlows.MongoDB
 56{
 57/// <summary>Options for the MongoDB durable-flow state store.</summary>
 58public sealed class MongoDbDurableFlowOptions : DurableFlowOptions
 59{
 60    /// <summary>Optional MongoDB connection string used when no <see cref="IMongoDatabase"/> is registered.</summary>
 61    public string? ConnectionString { get; set; }
 62
 63    /// <summary>Optional database name used when no <see cref="IMongoDatabase"/> is registered.</summary>
 64    public string? DatabaseName { get; set; }
 65
 66    /// <summary>Collection storing one durable-flow ledger document per flow id.</summary>
 67    public string CollectionName { get; set; } = "asyncresponse_flow_state";
 68
 69    /// <summary>Creates the expiry index on first use.</summary>
 70    public bool AutoCreateIndexes { get; set; } = true;
 71
 72    /// <summary>
 73    /// Maximum serialized flow-state size in bytes accepted by writes; oversized ledgers fail fast
 74    /// with an actionable error instead of the raw 16 MB BSON-document error the executor would
 75    /// retry into the dead-letter queue. Default: 15 MB (headroom under MongoDB's 16 MB document
 76    /// cap for the sibling fields); <c>null</c> disables the guard.
 77    /// </summary>
 78    public long? MaxStateBytes { get; set; } = 15_000_000;
 79
 80    /// <summary>Validates option values and throws on misconfiguration.</summary>
 81    public void Validate()
 82    {
 83        if (string.IsNullOrWhiteSpace(CollectionName))
 84            throw new InvalidOperationException($"{nameof(MongoDbDurableFlowOptions)}.{nameof(CollectionName)} must be c
 85        if (MaxStateBytes is <= 0)
 86            throw new InvalidOperationException($"{nameof(MongoDbDurableFlowOptions)}.{nameof(MaxStateBytes)} must be po
 87    }
 88}
 89
 90/// <summary>MongoDB implementation of <see cref="IFlowStateStore"/>.</summary>
 91public sealed class MongoDbFlowStateStore : IFlowStateStore, IDisposable
 92{
 93    private readonly IMongoDatabase _database;
 94    private readonly IMongoCollection<MongoFlowStateDocument> _collection;
 95    private readonly MongoDbDurableFlowOptions _options;
 96    private readonly SemaphoreSlim _ensureGate = new(1, 1);
 97    private readonly IMongoClient? _ownedClient;
 98    private bool _created;
 99
 100    public MongoDbFlowStateStore(IMongoDatabase database, IOptions<MongoDbDurableFlowOptions> options, IMongoClient? own
 101    {
 102        _options = options.Value;
 103        _options.Validate();
 104        _database = database;
 105        _collection = database.GetCollection<MongoFlowStateDocument>(_options.CollectionName);
 106        _ownedClient = ownedClient;
 107    }
 108
 109    public async Task<FlowState?> LoadAsync(string flowId, CancellationToken cancellationToken = default)
 110    {
 111        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 112        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 113
 114        // Expiry is evaluated against the server clock ($$NOW) — the same authority the TTL
 115        // monitor reaps with — so app clock skew can never resurrect an expired ledger or hide a
 116        // live one. All lease fencing below uses the same authority.
 117        var document = await _collection.Find(BuildLiveFilter(flowId)).FirstOrDefaultAsync(cancellationToken).ConfigureA
 118        if (document is null)
 119            return null;
 120
 121        return document.Revision is { } revision
 122            ? DurableFlowStoreShared.ReadState(flowId, document.StateJson, revision)
 123            : null;
 124    }
 125
 126    public async Task<bool> TryCreateAsync(string flowId, FlowState state, TimeSpan ttl, CancellationToken cancellationT
 127    {
 128        DurableFlowStoreShared.ValidateCreate(flowId, state, ttl);
 129        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "MongoDB");
 130        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 131
 132        // Two server-side steps instead of one upsert because MongoDB rejects upserts whose query
 133        // uses $expr, and $expr is what lets the expired-check run on the server clock.
 134        //
 135        // Step 1: atomically replace an expired ledger in place. Filter and assignments both
 136        // evaluate on $$NOW, so exactly one competing creator wins and every loser then sees the
 137        // fresh future expiry.
 138        var replaced = await _collection.UpdateOneAsync(
 139            BuildExpiredReplaceFilter(flowId),
 140            BuildStateUpdate(stateJson, state.Revision, ttl, resetLease: true),
 141            options: null,
 142            cancellationToken).ConfigureAwait(false);
 143        if (replaced.ModifiedCount > 0)
 144            return true;
 145
 146        // Step 2: the id was absent (or the expired document was TTL-purged after step 1 looked):
 147        // insert a fresh ledger. A plain insert has no aggregation context, so $$NOW is
 148        // unavailable — instead the server clock is read with one cheap `hello` round-trip and
 149        // stamped client-side. Creation then uses the same authority as every $$NOW comparison
 150        // and refresh below, so app clock skew can never mint a ledger that is born expired or
 151        // outlives its TTL window. A duplicate key means a live ledger owns the id.
 152        var serverNow = await ReadServerNowAsync(cancellationToken).ConfigureAwait(false);
 153        try
 154        {
 155            await _collection.InsertOneAsync(
 156                new MongoFlowStateDocument
 157                {
 158                    FlowId = flowId,
 159                    StateJson = stateJson,
 160                    ExpiresAtUtc = DurableFlowStoreShared.AddSaturating(serverNow, ttl),
 161                    UpdatedAtUtc = serverNow,
 162                    Revision = state.Revision
 163                },
 164                options: null,
 165                cancellationToken).ConfigureAwait(false);
 166            return true;
 167        }
 168        catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
 169        {
 170            return false;
 171        }
 172    }
 173
 174    public async Task<bool> TryUpdateAsync(
 175        string flowId,
 176        FlowState state,
 177        long expectedRevision,
 178        TimeSpan ttl,
 179        string? leaseId = null,
 180        CancellationToken cancellationToken = default)
 181    {
 182        DurableFlowStoreShared.ValidateUpdate(flowId, state, expectedRevision, ttl);
 183        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "MongoDB");
 184        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 185
 186        var result = await _collection.UpdateOneAsync(
 187            BuildCheckpointFilter(flowId, expectedRevision, leaseId),
 188            BuildStateUpdate(stateJson, state.Revision, ttl, resetLease: false),
 189            options: null,
 190            cancellationToken).ConfigureAwait(false);
 191        // ModifiedCount is safe here (unlike lease renewal): a checkpoint always bumps the
 192        // revision, so a matched document is always modified.
 193        return result.ModifiedCount > 0;
 194    }
 195
 196    public Task<bool> TryAcquireLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken canc
 197        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: true, cancellationToken);
 198
 199    public Task<bool> TryRenewLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken cancel
 200        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: false, cancellationToken);
 201
 202    public async Task ReleaseLeaseAsync(string flowId, string leaseId, CancellationToken cancellationToken = default)
 203    {
 204        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 205        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 206                     & Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId);
 207        var update = Builders<MongoFlowStateDocument>.Update
 208            .Unset(item => item.LeaseId)
 209            .Unset(item => item.LeaseExpiresAtUtc);
 210        await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 211    }
 212
 213    public async Task<bool> TryDeleteAsync(string flowId, CancellationToken cancellationToken = default)
 214    {
 215        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 216        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 217
 218        var result = await _collection.DeleteOneAsync(
 219            Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId),
 220            cancellationToken).ConfigureAwait(false);
 221        return result.DeletedCount > 0;
 222    }
 223
 224    private async Task EnsureCreatedAsync(CancellationToken cancellationToken)
 225    {
 226        if (_created || !_options.AutoCreateIndexes)
 227            return;
 228
 229        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 230        try
 231        {
 232            if (_created)
 233                return;
 234
 235            // A TTL index (expireAfterSeconds = 0 on the expiry timestamp) makes MongoDB itself
 236            // reap expired ledgers — no application-side pruning needed. Loads still filter on
 237            // ExpiresAtUtc because the TTL monitor only runs periodically (~60s).
 238            var indexName = $"{_options.CollectionName}_expires_idx";
 239            var model = new CreateIndexModel<MongoFlowStateDocument>(
 240                Builders<MongoFlowStateDocument>.IndexKeys.Ascending(item => item.ExpiresAtUtc),
 241                new CreateIndexOptions { Name = indexName, ExpireAfter = TimeSpan.Zero });
 242            // Do not drop or rewrite a conflicting application-owned index. MongoDB reports the
 243            // mismatch and startup fails, leaving the operator to correct schema intentionally.
 244            await _collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
 245            _created = true;
 246        }
 247        finally
 248        {
 249            _ensureGate.Release();
 250        }
 251    }
 252
 253    /// <summary>
 254    /// Server clock for the one write that cannot compute it in place: plain inserts evaluate no
 255    /// pipeline, so <c>$$NOW</c> is out of reach. <c>hello</c> is answered by every supported
 256    /// server (the 4.2+ floor the <c>$$NOW</c> filters already require) and carries the node's
 257    /// <c>localTime</c>; reading it from the primary keeps the authority the same node whose
 258    /// <c>$$NOW</c> the filters evaluate against.
 259    /// </summary>
 260    private async Task<DateTime> ReadServerNowAsync(CancellationToken cancellationToken)
 261    {
 262        var reply = await _database.RunCommandAsync<BsonDocument>(
 263            new BsonDocument("hello", 1),
 264            ReadPreference.Primary,
 265            cancellationToken).ConfigureAwait(false);
 266        // Defensive: a mongo-compatible endpoint omitting localTime falls back to the app clock —
 267        // the pre-server-clock behavior — instead of failing every create.
 268        return reply.TryGetValue("localTime", out var localTime)
 269            ? localTime.ToUniversalTime()
 270            : DateTime.UtcNow;
 271    }
 272
 273    private async Task<bool> UpdateLeaseAsync(
 274        string flowId,
 275        string leaseId,
 276        TimeSpan leaseDuration,
 277        bool acquire,
 278        CancellationToken cancellationToken)
 279    {
 280        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 281        ArgumentException.ThrowIfNullOrWhiteSpace(leaseId);
 282        if (leaseDuration <= TimeSpan.Zero)
 283            throw new ArgumentOutOfRangeException(nameof(leaseDuration));
 284
 285        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 286        var result = await _collection.UpdateOneAsync(
 287            BuildLeaseFilter(flowId, leaseId, acquire),
 288            BuildLeaseUpdate(leaseId, leaseDuration),
 289            options: null,
 290            cancellationToken).ConfigureAwait(false);
 291        // MatchedCount, not ModifiedCount: matching the filter proves this owner held (or could
 292        // take) the lease — the atomic update then applied. A renewal that lands in the same
 293        // millisecond as the previous one writes an identical lease_expires_at_utc, which MongoDB
 294        // reports as matched-but-not-modified; treating that no-op as failure would abort a
 295        // healthy execution mid-flight.
 296        return result.MatchedCount > 0;
 297    }
 298
 299    /// <summary>Live-ledger filter: id match plus a server-clock ($$NOW) expiry check.</summary>
 300    internal static FilterDefinition<MongoFlowStateDocument> BuildLiveFilter(string flowId)
 301        => Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 302           & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$expires_at_utc", "$$NOW" }));
 303
 304    /// <summary>Expired-ledger filter used by create to replace a dead ledger in place.</summary>
 305    internal static FilterDefinition<MongoFlowStateDocument> BuildExpiredReplaceFilter(string flowId)
 306        => Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 307           & ServerClockExpr(new BsonDocument("$lte", new BsonArray { "$expires_at_utc", "$$NOW" }));
 308
 309    /// <summary>
 310    /// Checkpoint filter: revision fence plus server-clock expiry (and, when fenced by a lease,
 311    /// server-clock lease validity).
 312    /// </summary>
 313    internal static FilterDefinition<MongoFlowStateDocument> BuildCheckpointFilter(string flowId, long expectedRevision,
 314    {
 315        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 316                     & Builders<MongoFlowStateDocument>.Filter.Eq(item => item.Revision, expectedRevision)
 317                     & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$expires_at_utc", "$$NOW" }));
 318        if (leaseId is not null)
 319        {
 320            filter &= Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId)
 321                      & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$lease_expires_at_utc", "$$NOW" }));
 322        }
 323
 324        return filter;
 325    }
 326
 327    /// <summary>
 328    /// Lease filter: acquire takes a free lease (absent, expired on the server clock, or already
 329    /// ours); renew requires ours and still live on the server clock. A missing
 330    /// <c>lease_expires_at_utc</c> compares below any date, so it counts as expired for acquire
 331    /// and as unrenewable for renew.
 332    /// </summary>
 333    internal static FilterDefinition<MongoFlowStateDocument> BuildLeaseFilter(string flowId, string leaseId, bool acquir
 334    {
 335        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 336                     & Builders<MongoFlowStateDocument>.Filter.Ne(item => item.Revision, null)
 337                     & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$expires_at_utc", "$$NOW" }));
 338        filter &= acquire
 339            ? Builders<MongoFlowStateDocument>.Filter.Or(
 340                Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, null),
 341                ServerClockExpr(new BsonDocument("$lte", new BsonArray { "$lease_expires_at_utc", "$$NOW" })),
 342                Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId))
 343            : Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId)
 344              & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$lease_expires_at_utc", "$$NOW" }));
 345        return filter;
 346    }
 347
 348    /// <summary>
 349    /// Full-state write as an aggregation-pipeline update so the expiry lands on the server clock
 350    /// ($$NOW + ttl). <paramref name="resetLease"/> clears the lease columns (create-over-expired
 351    /// replaces ownership); checkpoints leave the running lease in place.
 352    /// </summary>
 353    internal static UpdateDefinition<MongoFlowStateDocument> BuildStateUpdate(string stateJson, long revision, TimeSpan 
 354    {
 355        var stages = new List<BsonDocument>
 356        {
 357            new("$set", new BsonDocument
 358            {
 359                // $literal keeps the JSON payload a value: a pipeline $set treats "$"-prefixed
 360                // strings as field paths.
 361                ["state_json"] = new BsonDocument("$literal", stateJson),
 362                ["expires_at_utc"] = new BsonDocument("$add", new BsonArray
 363                {
 364                    "$$NOW",
 365                    DurableFlowStoreShared.ServerClockTtlMilliseconds(ttl)
 366                }),
 367                ["updated_at_utc"] = "$$NOW",
 368                ["revision"] = revision
 369            })
 370        };
 371        if (resetLease)
 372            stages.Add(new BsonDocument("$unset", new BsonArray { "lease_id", "lease_expires_at_utc" }));
 373        return Builders<MongoFlowStateDocument>.Update.Pipeline(stages.ToArray());
 374    }
 375
 376    /// <summary>Lease grant/renewal on the server clock: <c>lease_expires_at_utc = $$NOW + duration</c>.</summary>
 377    internal static UpdateDefinition<MongoFlowStateDocument> BuildLeaseUpdate(string leaseId, TimeSpan leaseDuration)
 378        => Builders<MongoFlowStateDocument>.Update.Pipeline(new[]
 379        {
 380            new BsonDocument("$set", new BsonDocument
 381            {
 382                ["lease_id"] = new BsonDocument("$literal", leaseId),
 383                ["lease_expires_at_utc"] = new BsonDocument("$add", new BsonArray
 384                {
 385                    "$$NOW",
 386                    DurableFlowStoreShared.ServerClockTtlMilliseconds(leaseDuration)
 387                })
 388            })
 389        });
 390
 391    private static FilterDefinition<MongoFlowStateDocument> ServerClockExpr(BsonDocument comparison)
 392        => new BsonDocumentFilterDefinition<MongoFlowStateDocument>(new BsonDocument("$expr", comparison));
 393
 394    /// <summary>Disposes the Mongo client when the store created (and therefore owns) it.</summary>
 395    public void Dispose()
 396    {
 397        _ensureGate.Dispose();
 398        (_ownedClient as IDisposable)?.Dispose();
 399    }
 400}
 401
 402internal sealed class MongoFlowStateDocument
 403{
 404    [BsonId]
 405    [BsonElement("_id")]
 3406    public string FlowId { get; set; } = "";
 407
 408    [BsonElement("state_json")]
 3409    public string StateJson { get; set; } = "";
 410
 411    [BsonElement("expires_at_utc")]
 412    public DateTime ExpiresAtUtc { get; set; }
 413
 414    [BsonElement("updated_at_utc")]
 415    public DateTime UpdatedAtUtc { get; set; }
 416
 417    [BsonElement("revision")]
 418    public long? Revision { get; set; }
 419
 420    [BsonElement("lease_id")]
 421    [BsonIgnoreIfNull]
 422    public string? LeaseId { get; set; }
 423
 424    [BsonElement("lease_expires_at_utc")]
 425    [BsonIgnoreIfNull]
 426    public DateTime? LeaseExpiresAtUtc { get; set; }
 427}
 428}

Methods/Properties

.ctor()