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

Information
Class: AsyncResponse.DurableFlows.MongoDB.MongoDbFlowStateStore
Assembly: AsyncResponse.DurableFlows.MongoDB
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/DurableFlows/AsyncResponse.DurableFlows.MongoDB/MongoDurableFlows.cs
Line coverage
100%
Covered lines: 161
Uncovered lines: 0
Coverable lines: 161
Total lines: 428
Line coverage: 100%
Branch coverage
96%
Covered branches: 25
Total branches: 26
Branch coverage: 96.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
LoadAsync()100%44100%
TryCreateAsync()75%44100%
TryUpdateAsync()100%11100%
TryAcquireLeaseAsync(...)100%11100%
TryRenewLeaseAsync(...)100%11100%
ReleaseLeaseAsync()100%11100%
TryDeleteAsync()100%11100%
EnsureCreatedAsync()100%66100%
ReadServerNowAsync()100%22100%
UpdateLeaseAsync()100%22100%
BuildLiveFilter(...)100%11100%
BuildExpiredReplaceFilter(...)100%11100%
BuildCheckpointFilter(...)100%22100%
BuildLeaseFilter(...)100%22100%
BuildStateUpdate(...)100%22100%
BuildLeaseUpdate(...)100%11100%
ServerClockExpr(...)100%11100%
Dispose()100%22100%

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;
 396    private readonly SemaphoreSlim _ensureGate = new(1, 1);
 97    private readonly IMongoClient? _ownedClient;
 98    private bool _created;
 99
 3100    public MongoDbFlowStateStore(IMongoDatabase database, IOptions<MongoDbDurableFlowOptions> options, IMongoClient? own
 101    {
 3102        _options = options.Value;
 3103        _options.Validate();
 3104        _database = database;
 3105        _collection = database.GetCollection<MongoFlowStateDocument>(_options.CollectionName);
 3106        _ownedClient = ownedClient;
 3107    }
 108
 109    public async Task<FlowState?> LoadAsync(string flowId, CancellationToken cancellationToken = default)
 110    {
 1111        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 1112        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.
 1117        var document = await _collection.Find(BuildLiveFilter(flowId)).FirstOrDefaultAsync(cancellationToken).ConfigureA
 1118        if (document is null)
 1119            return null;
 120
 1121        return document.Revision is { } revision
 1122            ? DurableFlowStoreShared.ReadState(flowId, document.StateJson, revision)
 1123            : null;
 1124    }
 125
 126    public async Task<bool> TryCreateAsync(string flowId, FlowState state, TimeSpan ttl, CancellationToken cancellationT
 127    {
 3128        DurableFlowStoreShared.ValidateCreate(flowId, state, ttl);
 3129        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "MongoDB");
 3130        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.
 3138        var replaced = await _collection.UpdateOneAsync(
 3139            BuildExpiredReplaceFilter(flowId),
 3140            BuildStateUpdate(stateJson, state.Revision, ttl, resetLease: true),
 3141            options: null,
 3142            cancellationToken).ConfigureAwait(false);
 3143        if (replaced.ModifiedCount > 0)
 1144            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.
 3152        var serverNow = await ReadServerNowAsync(cancellationToken).ConfigureAwait(false);
 153        try
 154        {
 3155            await _collection.InsertOneAsync(
 3156                new MongoFlowStateDocument
 3157                {
 3158                    FlowId = flowId,
 3159                    StateJson = stateJson,
 3160                    ExpiresAtUtc = DurableFlowStoreShared.AddSaturating(serverNow, ttl),
 3161                    UpdatedAtUtc = serverNow,
 3162                    Revision = state.Revision
 3163                },
 3164                options: null,
 3165                cancellationToken).ConfigureAwait(false);
 3166            return true;
 167        }
 1168        catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
 169        {
 1170            return false;
 171        }
 3172    }
 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    {
 3182        DurableFlowStoreShared.ValidateUpdate(flowId, state, expectedRevision, ttl);
 3183        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "MongoDB");
 3184        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 185
 3186        var result = await _collection.UpdateOneAsync(
 3187            BuildCheckpointFilter(flowId, expectedRevision, leaseId),
 3188            BuildStateUpdate(stateJson, state.Revision, ttl, resetLease: false),
 3189            options: null,
 3190            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.
 3193        return result.ModifiedCount > 0;
 3194    }
 195
 196    public Task<bool> TryAcquireLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken canc
 3197        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: true, cancellationToken);
 198
 199    public Task<bool> TryRenewLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken cancel
 3200        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: false, cancellationToken);
 201
 202    public async Task ReleaseLeaseAsync(string flowId, string leaseId, CancellationToken cancellationToken = default)
 203    {
 1204        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 1205        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 1206                     & Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId);
 1207        var update = Builders<MongoFlowStateDocument>.Update
 1208            .Unset(item => item.LeaseId)
 1209            .Unset(item => item.LeaseExpiresAtUtc);
 1210        await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 1211    }
 212
 213    public async Task<bool> TryDeleteAsync(string flowId, CancellationToken cancellationToken = default)
 214    {
 1215        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 1216        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 217
 1218        var result = await _collection.DeleteOneAsync(
 1219            Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId),
 1220            cancellationToken).ConfigureAwait(false);
 1221        return result.DeletedCount > 0;
 1222    }
 223
 224    private async Task EnsureCreatedAsync(CancellationToken cancellationToken)
 225    {
 3226        if (_created || !_options.AutoCreateIndexes)
 3227            return;
 228
 1229        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 230        try
 231        {
 1232            if (_created)
 1233                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).
 1238            var indexName = $"{_options.CollectionName}_expires_idx";
 1239            var model = new CreateIndexModel<MongoFlowStateDocument>(
 1240                Builders<MongoFlowStateDocument>.IndexKeys.Ascending(item => item.ExpiresAtUtc),
 1241                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.
 1244            await _collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
 1245            _created = true;
 1246        }
 247        finally
 248        {
 1249            _ensureGate.Release();
 250        }
 3251    }
 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    {
 3262        var reply = await _database.RunCommandAsync<BsonDocument>(
 3263            new BsonDocument("hello", 1),
 3264            ReadPreference.Primary,
 3265            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.
 3268        return reply.TryGetValue("localTime", out var localTime)
 3269            ? localTime.ToUniversalTime()
 3270            : DateTime.UtcNow;
 3271    }
 272
 273    private async Task<bool> UpdateLeaseAsync(
 274        string flowId,
 275        string leaseId,
 276        TimeSpan leaseDuration,
 277        bool acquire,
 278        CancellationToken cancellationToken)
 279    {
 3280        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 3281        ArgumentException.ThrowIfNullOrWhiteSpace(leaseId);
 3282        if (leaseDuration <= TimeSpan.Zero)
 1283            throw new ArgumentOutOfRangeException(nameof(leaseDuration));
 284
 3285        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 3286        var result = await _collection.UpdateOneAsync(
 3287            BuildLeaseFilter(flowId, leaseId, acquire),
 3288            BuildLeaseUpdate(leaseId, leaseDuration),
 3289            options: null,
 3290            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.
 3296        return result.MatchedCount > 0;
 3297    }
 298
 299    /// <summary>Live-ledger filter: id match plus a server-clock ($$NOW) expiry check.</summary>
 300    internal static FilterDefinition<MongoFlowStateDocument> BuildLiveFilter(string flowId)
 3301        => Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 3302           & 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)
 3306        => Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 3307           & 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    {
 3315        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 3316                     & Builders<MongoFlowStateDocument>.Filter.Eq(item => item.Revision, expectedRevision)
 3317                     & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$expires_at_utc", "$$NOW" }));
 3318        if (leaseId is not null)
 319        {
 3320            filter &= Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId)
 3321                      & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$lease_expires_at_utc", "$$NOW" }));
 322        }
 323
 3324        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    {
 3335        var filter = Builders<MongoFlowStateDocument>.Filter.Eq(item => item.FlowId, flowId)
 3336                     & Builders<MongoFlowStateDocument>.Filter.Ne(item => item.Revision, null)
 3337                     & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$expires_at_utc", "$$NOW" }));
 3338        filter &= acquire
 3339            ? Builders<MongoFlowStateDocument>.Filter.Or(
 3340                Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, null),
 3341                ServerClockExpr(new BsonDocument("$lte", new BsonArray { "$lease_expires_at_utc", "$$NOW" })),
 3342                Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId))
 3343            : Builders<MongoFlowStateDocument>.Filter.Eq(item => item.LeaseId, leaseId)
 3344              & ServerClockExpr(new BsonDocument("$gt", new BsonArray { "$lease_expires_at_utc", "$$NOW" }));
 3345        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    {
 3355        var stages = new List<BsonDocument>
 3356        {
 3357            new("$set", new BsonDocument
 3358            {
 3359                // $literal keeps the JSON payload a value: a pipeline $set treats "$"-prefixed
 3360                // strings as field paths.
 3361                ["state_json"] = new BsonDocument("$literal", stateJson),
 3362                ["expires_at_utc"] = new BsonDocument("$add", new BsonArray
 3363                {
 3364                    "$$NOW",
 3365                    DurableFlowStoreShared.ServerClockTtlMilliseconds(ttl)
 3366                }),
 3367                ["updated_at_utc"] = "$$NOW",
 3368                ["revision"] = revision
 3369            })
 3370        };
 3371        if (resetLease)
 3372            stages.Add(new BsonDocument("$unset", new BsonArray { "lease_id", "lease_expires_at_utc" }));
 3373        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)
 3378        => Builders<MongoFlowStateDocument>.Update.Pipeline(new[]
 3379        {
 3380            new BsonDocument("$set", new BsonDocument
 3381            {
 3382                ["lease_id"] = new BsonDocument("$literal", leaseId),
 3383                ["lease_expires_at_utc"] = new BsonDocument("$add", new BsonArray
 3384                {
 3385                    "$$NOW",
 3386                    DurableFlowStoreShared.ServerClockTtlMilliseconds(leaseDuration)
 3387                })
 3388            })
 3389        });
 390
 391    private static FilterDefinition<MongoFlowStateDocument> ServerClockExpr(BsonDocument comparison)
 3392        => 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    {
 3397        _ensureGate.Dispose();
 3398        (_ownedClient as IDisposable)?.Dispose();
 3399    }
 400}
 401
 402internal sealed class MongoFlowStateDocument
 403{
 404    [BsonId]
 405    [BsonElement("_id")]
 406    public string FlowId { get; set; } = "";
 407
 408    [BsonElement("state_json")]
 409    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}