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

Information
Class: AsyncResponse.DurableFlows.Cosmos.CosmosFlowStateStore
Assembly: AsyncResponse.DurableFlows.Cosmos
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/DurableFlows/AsyncResponse.DurableFlows.Cosmos/CosmosDurableFlows.cs
Line coverage
96%
Covered lines: 226
Uncovered lines: 8
Coverable lines: 234
Total lines: 573
Line coverage: 96.5%
Branch coverage
90%
Covered branches: 67
Total branches: 74
Branch coverage: 90.5%
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()100%6690.32%
TryUpdateAsync()92.86%1414100%
TryAcquireLeaseAsync(...)100%11100%
TryRenewLeaseAsync(...)100%11100%
ReleaseLeaseAsync()100%44100%
TryDeleteAsync()100%11100%
GetContainerAsync()100%22100%
EnsureCreatedAsync()100%1010100%
ValidateHostSerializer()50%9873.91%
UpdateLeaseAsync()91.67%2424100%
CreateDocument(...)100%11100%
CosmosTtlSeconds(...)100%11100%
CosmosTtlSeconds(...)100%11100%
Dispose()100%22100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/DurableFlows/AsyncResponse.DurableFlows.Cosmos/CosmosDurableFlows.cs

#LineLine coverage
 1using AsyncResponse;
 2using AsyncResponse.DurableFlows.Internal;
 3using AsyncResponse.DurableFlows.Cosmos;
 4using Microsoft.Azure.Cosmos;
 5using Microsoft.Extensions.DependencyInjection.Extensions;
 6using Microsoft.Extensions.Options;
 7using Newtonsoft.Json;
 8using System.Net;
 9
 10namespace Microsoft.Extensions.DependencyInjection
 11{
 12    /// <summary>DI registration for the Azure Cosmos DB durable-flow state store.</summary>
 13    public static class CosmosDurableFlowServiceCollectionExtensions
 14    {
 15        /// <summary>
 16        /// Stores durable-flow state in Azure Cosmos DB. Hosts may either register a
 17        /// <see cref="CosmosClient"/> singleton or set connection options here.
 18        /// </summary>
 19        public static AsyncResponseRegistrationBuilder WithCosmosDurableFlows(
 20            this AsyncResponseRegistrationBuilder builder,
 21            Action<CosmosDurableFlowOptions>? configure = null)
 22        {
 23            // Singleton on purpose: database/container provisioning is cached per store instance
 24            // and Cosmos metadata operations are RU-charged and rate-limited — a scoped store would
 25            // re-issue them on every flow execution. A host-registered CosmosClient is reused when
 26            // present; otherwise the store creates and owns one from ConnectionString. Nothing is
 27            // registered as a bare CosmosClient service, so unrelated resolutions of that type are
 28            // never answered — or broken — by this package.
 29            builder.Services.TryAddSingleton(provider =>
 30            {
 31                var options = provider.GetRequiredService<IOptions<CosmosDurableFlowOptions>>();
 32
 33                var shared = provider.GetService<CosmosClient>();
 34                if (shared is not null)
 35                    return new CosmosFlowStateStore(shared, options);
 36
 37                if (string.IsNullOrWhiteSpace(options.Value.ConnectionString))
 38                    throw new InvalidOperationException($"{nameof(CosmosDurableFlowOptions)}.{nameof(CosmosDurableFlowOp
 39                return new CosmosFlowStateStore(new CosmosClient(options.Value.ConnectionString), options, ownsClient: t
 40            });
 41            return builder.WithDurableFlows<CosmosFlowStateStore, CosmosDurableFlowOptions>(configure);
 42        }
 43    }
 44}
 45
 46namespace AsyncResponse.DurableFlows.Cosmos
 47{
 48/// <summary>Options for the Azure Cosmos DB durable-flow state store.</summary>
 49public sealed class CosmosDurableFlowOptions : DurableFlowOptions
 50{
 51    /// <summary>Optional Cosmos DB connection string used when no <see cref="CosmosClient"/> is registered.</summary>
 52    public string? ConnectionString { get; set; }
 53
 54    /// <summary>Cosmos database name. Required.</summary>
 55    public string? DatabaseName { get; set; }
 56
 57    /// <summary>Container storing one durable-flow ledger document per flow id.</summary>
 58    public string ContainerName { get; set; } = "asyncresponse_flow_state";
 59
 60    /// <summary>Partition-key path for the container. Default: <c>/flowId</c>.</summary>
 61    public string PartitionKeyPath { get; set; } = "/flowId";
 62
 63    /// <summary>Creates the database and container on first use.</summary>
 64    public bool AutoCreateContainer { get; set; } = true;
 65
 66    /// <summary>Optional throughput used when auto-creating the container.</summary>
 67    public int? Throughput { get; set; }
 68
 69    /// <summary>
 70    /// Maximum serialized flow-state size in bytes accepted by writes; oversized ledgers fail fast
 71    /// with an actionable error instead of the raw Cosmos 413 the executor would retry into the
 72    /// dead-letter queue. Default: 1.9 MB (headroom under Cosmos's 2 MB item cap for the sibling
 73    /// fields); <c>null</c> disables the guard.
 74    /// </summary>
 75    public long? MaxStateBytes { get; set; } = 1_900_000;
 76
 77    /// <summary>Validates option values and throws on misconfiguration.</summary>
 78    public void Validate()
 79    {
 80        if (string.IsNullOrWhiteSpace(DatabaseName))
 81            throw new InvalidOperationException($"{nameof(CosmosDurableFlowOptions)}.{nameof(DatabaseName)} must be conf
 82        if (string.IsNullOrWhiteSpace(ContainerName))
 83            throw new InvalidOperationException($"{nameof(CosmosDurableFlowOptions)}.{nameof(ContainerName)} must be con
 84        if (string.IsNullOrWhiteSpace(PartitionKeyPath) || !PartitionKeyPath.StartsWith("/", StringComparison.Ordinal))
 85            throw new InvalidOperationException($"{nameof(CosmosDurableFlowOptions)}.{nameof(PartitionKeyPath)} must sta
 86        if (Throughput is <= 0)
 87            throw new InvalidOperationException($"{nameof(CosmosDurableFlowOptions)}.{nameof(Throughput)} must be positi
 88        if (MaxStateBytes is <= 0)
 89            throw new InvalidOperationException($"{nameof(CosmosDurableFlowOptions)}.{nameof(MaxStateBytes)} must be pos
 90    }
 91}
 92
 93/// <summary>Azure Cosmos DB implementation of <see cref="IFlowStateStore"/>.</summary>
 94public sealed class CosmosFlowStateStore : IFlowStateStore, IDisposable
 95{
 96    // Time authority: this store keeps the app clock (DateTime.UtcNow) for expiry and lease
 97    // comparisons. Cosmos conditional writes (ETag preconditions) evaluate client-supplied
 98    // values only — there is no server-clock expression usable inside a point write — so the
 99    // read-check-replace cycles below compare against the app clock and rely on the ETag fence
 100    // for atomicity. Multi-node deployments should keep worker clocks synchronized well inside
 101    // the lease window. (The server's own TTL sweep, by contrast, runs on the service clock.)
 102    private readonly CosmosClient _client;
 103    private readonly CosmosDurableFlowOptions _options;
 3104    private readonly SemaphoreSlim _ensureGate = new(1, 1);
 105    private readonly bool _ownsClient;
 106    private bool _created;
 107
 3108    public CosmosFlowStateStore(CosmosClient client, IOptions<CosmosDurableFlowOptions> options, bool ownsClient = false
 109    {
 3110        _client = client;
 3111        _options = options.Value;
 3112        _options.Validate();
 3113        _ownsClient = ownsClient;
 3114    }
 115
 116    public async Task<FlowState?> LoadAsync(string flowId, CancellationToken cancellationToken = default)
 117    {
 3118        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 3119        var container = await GetContainerAsync(cancellationToken).ConfigureAwait(false);
 120
 121        try
 122        {
 3123            var response = await container.ReadItemAsync<CosmosFlowStateDocument>(
 3124                flowId,
 3125                new PartitionKey(flowId),
 3126                cancellationToken: cancellationToken).ConfigureAwait(false);
 3127            var document = response.Resource;
 128            // Point reads have no predicate, so the expiry check happens client-side on the app
 129            // clock — see the time-authority note on this class.
 3130            if (document.ExpiresAtUtc <= DateTime.UtcNow)
 3131                return null;
 132
 3133            return document.Revision is { } revision
 3134                ? DurableFlowStoreShared.ReadState(flowId, document.StateJson, revision)
 3135                : null;
 136        }
 3137        catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
 138        {
 3139            return null;
 140        }
 3141    }
 142
 143    public async Task<bool> TryCreateAsync(string flowId, FlowState state, TimeSpan ttl, CancellationToken cancellationT
 144    {
 3145        DurableFlowStoreShared.ValidateCreate(flowId, state, ttl);
 3146        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "Cosmos DB");
 3147        var container = await GetContainerAsync(cancellationToken).ConfigureAwait(false);
 148
 149        // Bounded retry: documents carry native Cosmos TTL alongside the logical ExpiresAtUtc, so
 150        // the server's TTL sweep can physically purge an expired document between our create's
 151        // 409 and the follow-up read (or the conditional replace). A vanished conflicting
 152        // document means the slot is free — create again — not that a live competitor won.
 153        // The short growing delay lets one purge cycle finish instead of burning all attempts
 154        // inside the same inconsistency window (the Linux emulator is markedly worse here than
 155        // the service, but the race itself is real on both).
 3156        for (var attempt = 0; attempt < 4; attempt++)
 157        {
 3158            if (attempt > 0)
 2159                await Task.Delay(TimeSpan.FromMilliseconds(50 * attempt), cancellationToken).ConfigureAwait(false);
 160
 3161            var now = DateTime.UtcNow;
 3162            var document = CreateDocument(flowId, stateJson, state.Revision, ttl, now);
 163            try
 164            {
 3165                await container.CreateItemAsync(document, new PartitionKey(flowId), cancellationToken: cancellationToken
 3166                return true;
 167            }
 3168            catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
 169            {
 170                try
 171                {
 3172                    var current = await container.ReadItemAsync<CosmosFlowStateDocument>(
 3173                        flowId,
 3174                        new PartitionKey(flowId),
 3175                        cancellationToken: cancellationToken).ConfigureAwait(false);
 3176                    if (current.Resource.ExpiresAtUtc > now)
 3177                        return false;
 178
 3179                    await container.ReplaceItemAsync(
 3180                        document,
 3181                        flowId,
 3182                        new PartitionKey(flowId),
 3183                        new ItemRequestOptions { IfMatchEtag = current.ETag },
 3184                        cancellationToken).ConfigureAwait(false);
 3185                    return true;
 186                }
 2187                catch (CosmosException retryEx) when (retryEx.StatusCode == HttpStatusCode.PreconditionFailed)
 188                {
 189                    // The ETag moved under us. That is either a live writer winning the id or the
 190                    // TTL purge touching the expired document; only the next read can tell them
 191                    // apart, so retry instead of conceding — a live occupant surfaces as an
 192                    // unexpired read (false) on the next attempt, a purged one as a clean create.
 2193                    continue;
 194                }
 0195                catch (CosmosException retryEx) when (retryEx.StatusCode == HttpStatusCode.NotFound)
 196                {
 197                    // The expired document was TTL-purged after our 409 — the id is free again.
 0198                    continue;
 199                }
 200            }
 0201        }
 202
 2203        return false;
 3204    }
 205
 206    public async Task<bool> TryUpdateAsync(
 207        string flowId,
 208        FlowState state,
 209        long expectedRevision,
 210        TimeSpan ttl,
 211        string? leaseId = null,
 212        CancellationToken cancellationToken = default)
 213    {
 3214        DurableFlowStoreShared.ValidateUpdate(flowId, state, expectedRevision, ttl);
 3215        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "Cosmos DB");
 3216        var container = await GetContainerAsync(cancellationToken).ConfigureAwait(false);
 3217        for (var attempt = 0; attempt < 4; attempt++)
 218        {
 3219            var now = DateTime.UtcNow;
 220            try
 221            {
 3222                var current = await container.ReadItemAsync<CosmosFlowStateDocument>(
 3223                    flowId,
 3224                    new PartitionKey(flowId),
 3225                    cancellationToken: cancellationToken).ConfigureAwait(false);
 3226                var document = current.Resource;
 3227                if (document.ExpiresAtUtc <= now || document.Revision != expectedRevision)
 3228                    return false;
 3229                if (leaseId is not null && (document.LeaseId != leaseId || document.LeaseExpiresAtUtc <= now))
 3230                    return false;
 231
 3232                document.StateJson = stateJson;
 3233                document.ExpiresAtUtc = DurableFlowStoreShared.AddSaturating(now, ttl);
 3234                document.UpdatedAtUtc = now;
 3235                document.Revision = state.Revision;
 3236                document.Ttl = CosmosTtlSeconds(ttl);
 3237                await container.ReplaceItemAsync(
 3238                    document,
 3239                    flowId,
 3240                    new PartitionKey(flowId),
 3241                    new ItemRequestOptions { IfMatchEtag = current.ETag },
 3242                    cancellationToken).ConfigureAwait(false);
 3243                return true;
 244            }
 2245            catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
 246            {
 2247                return false;
 248            }
 2249            catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
 250            {
 251                // Lease renewal also replaces the document and changes its ETag without changing
 252                // the ledger revision. Re-read and retry so that benign race is not reported as a
 253                // lost execution lease; a real state race fails the revision check above.
 2254            }
 255        }
 256
 2257        return false;
 3258    }
 259
 260    public Task<bool> TryAcquireLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken canc
 3261        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: true, cancellationToken);
 262
 263    public Task<bool> TryRenewLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken cancel
 3264        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: false, cancellationToken);
 265
 266    public async Task ReleaseLeaseAsync(string flowId, string leaseId, CancellationToken cancellationToken = default)
 267    {
 3268        var container = await GetContainerAsync(cancellationToken).ConfigureAwait(false);
 3269        for (var attempt = 0; attempt < 4; attempt++)
 270        {
 271            try
 272            {
 3273                var current = await container.ReadItemAsync<CosmosFlowStateDocument>(
 3274                    flowId,
 3275                    new PartitionKey(flowId),
 3276                    cancellationToken: cancellationToken).ConfigureAwait(false);
 3277                if (current.Resource.LeaseId != leaseId)
 3278                    return;
 279
 3280                current.Resource.LeaseId = null;
 3281                current.Resource.LeaseExpiresAtUtc = null;
 282                // Every replace refreshes _ts — the anchor the server-side TTL counts from — so
 283                // re-persisting the stored full-window ttl would restart the physical-retention
 284                // countdown and decouple it from the logical ExpiresAtUtc. Rewrite it from the
 285                // remaining logical window instead. (Checkpoints recompute both together in
 286                // TryUpdateAsync; only the lease paths replace without moving ExpiresAtUtc.)
 3287                current.Resource.Ttl = CosmosTtlSeconds(current.Resource.ExpiresAtUtc, DateTime.UtcNow);
 3288                await container.ReplaceItemAsync(
 3289                    current.Resource,
 3290                    flowId,
 3291                    new PartitionKey(flowId),
 3292                    new ItemRequestOptions { IfMatchEtag = current.ETag },
 3293                    cancellationToken).ConfigureAwait(false);
 3294                return;
 295            }
 2296            catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
 297            {
 2298                return;
 299            }
 2300            catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
 301            {
 2302            }
 303        }
 3304    }
 305
 306    public async Task<bool> TryDeleteAsync(string flowId, CancellationToken cancellationToken = default)
 307    {
 3308        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 3309        var container = await GetContainerAsync(cancellationToken).ConfigureAwait(false);
 310
 311        try
 312        {
 3313            await container.DeleteItemAsync<CosmosFlowStateDocument>(
 3314                flowId,
 3315                new PartitionKey(flowId),
 3316                cancellationToken: cancellationToken).ConfigureAwait(false);
 3317            return true;
 318        }
 3319        catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
 320        {
 3321            return false;
 322        }
 3323    }
 324
 325    private async Task<Container> GetContainerAsync(CancellationToken cancellationToken)
 326    {
 3327        if (!_created)
 3328            await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 329
 3330        return _client.GetContainer(_options.DatabaseName, _options.ContainerName);
 3331    }
 332
 333    private async Task EnsureCreatedAsync(CancellationToken cancellationToken)
 334    {
 3335        if (_created)
 2336            return;
 337
 3338        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 339        try
 340        {
 3341            if (_created)
 2342                return;
 343
 344            ContainerResponse container;
 3345            if (_options.AutoCreateContainer)
 346            {
 3347                var database = await _client.CreateDatabaseIfNotExistsAsync(
 3348                    _options.DatabaseName,
 3349                    cancellationToken: cancellationToken).ConfigureAwait(false);
 350
 351                // DefaultTimeToLive = -1 enables per-item TTL without a container-wide default.
 3352                var properties = new ContainerProperties(_options.ContainerName, _options.PartitionKeyPath)
 3353                {
 3354                    DefaultTimeToLive = -1
 3355                };
 3356                container = await database.Database.CreateContainerIfNotExistsAsync(
 3357                    properties,
 3358                    _options.Throughput,
 3359                    cancellationToken: cancellationToken).ConfigureAwait(false);
 360            }
 361            else
 362            {
 3363                container = await _client
 3364                    .GetContainer(_options.DatabaseName, _options.ContainerName)
 3365                    .ReadContainerAsync(cancellationToken: cancellationToken)
 3366                    .ConfigureAwait(false);
 367            }
 368
 3369            if (!string.Equals(container.Resource.PartitionKeyPath, _options.PartitionKeyPath, StringComparison.Ordinal)
 2370                throw new InvalidOperationException(
 2371                    $"Cosmos container '{_options.ContainerName}' uses partition key '{container.Resource.PartitionKeyPa
 2372                    $"but '{_options.PartitionKeyPath}' is required.");
 3373            if (container.Resource.DefaultTimeToLive is null)
 3374                throw new InvalidOperationException(
 3375                    $"Cosmos container '{_options.ContainerName}' does not have TTL enabled. " +
 3376                    "Enable container TTL (DefaultTimeToLive = -1) before using it for durable flows.");
 377
 3378            ValidateHostSerializer();
 3379            _created = true;
 3380        }
 381        finally
 382        {
 3383            _ensureGate.Release();
 384        }
 3385    }
 386
 387    /// <summary>
 388    /// Fails provisioning fast when a host-registered <see cref="CosmosClient"/> carries a custom
 389    /// serializer that does not honor the flow-state document's JSON property names.
 390    /// <see cref="CosmosFlowStateDocument"/> is attributed for both Newtonsoft.Json
 391    /// (<c>[JsonProperty]</c>) and System.Text.Json (<c>[JsonPropertyName]</c>), so the SDK's
 392    /// default serializer and STJ-based serializers both map correctly; a serializer honoring
 393    /// neither would write documents whose <c>id</c> Cosmos rejects — or, worse, whose fields
 394    /// silently round-trip as nulls. The probe serializes a sentinel document through the host's
 395    /// serializer and verifies the wire property names survive.
 396    /// </summary>
 397    private void ValidateHostSerializer()
 398    {
 3399        if (_client.ClientOptions?.Serializer is not { } serializer)
 2400            return; // The SDK default serializer honors [JsonProperty]; nothing to probe.
 401
 1402        var now = DateTime.UtcNow;
 1403        using var stream = serializer.ToStream(new CosmosFlowStateDocument
 1404        {
 1405            Id = "asyncresponse-serializer-probe",
 1406            FlowId = "asyncresponse-serializer-probe",
 1407            StateJson = "{}",
 1408            ExpiresAtUtc = now,
 1409            UpdatedAtUtc = now,
 1410            Revision = 0,
 1411            LeaseId = "probe",
 1412            LeaseExpiresAtUtc = now,
 1413            Ttl = 1
 1414        });
 1415        using var probe = System.Text.Json.JsonDocument.Parse(stream);
 1416        if (!probe.RootElement.TryGetProperty("id", out _) || !probe.RootElement.TryGetProperty("leaseExpiresAtUtc", out
 417        {
 0418            throw new InvalidOperationException(
 0419                $"The registered CosmosClient's serializer ({serializer.GetType().Name}) does not honor the durable-flow
 0420                "JSON property names ('id', 'flowId', 'leaseExpiresAtUtc', ...). Flow-state documents would be written w
 0421                "property names and could not be read back. Configure the serializer to honor Newtonsoft.Json [JsonPrope
 0422                "System.Text.Json [JsonPropertyName] attributes, or let the SDK use its default serializer.");
 423        }
 1424    }
 425
 426    private async Task<bool> UpdateLeaseAsync(
 427        string flowId,
 428        string leaseId,
 429        TimeSpan leaseDuration,
 430        bool acquire,
 431        CancellationToken cancellationToken)
 432    {
 3433        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 3434        ArgumentException.ThrowIfNullOrWhiteSpace(leaseId);
 3435        if (leaseDuration <= TimeSpan.Zero)
 2436            throw new ArgumentOutOfRangeException(nameof(leaseDuration));
 437
 3438        var container = await GetContainerAsync(cancellationToken).ConfigureAwait(false);
 3439        for (var attempt = 0; attempt < 4; attempt++)
 440        {
 3441            var now = DateTime.UtcNow;
 442            try
 443            {
 3444                var current = await container.ReadItemAsync<CosmosFlowStateDocument>(
 3445                    flowId,
 3446                    new PartitionKey(flowId),
 3447                    cancellationToken: cancellationToken).ConfigureAwait(false);
 3448                var document = current.Resource;
 3449                if (document.ExpiresAtUtc <= now || document.Revision is null)
 2450                    return false;
 3451                if (acquire)
 452                {
 3453                    if (document.LeaseId is not null && document.LeaseId != leaseId && document.LeaseExpiresAtUtc > now)
 3454                        return false;
 455                }
 3456                else if (document.LeaseId != leaseId || document.LeaseExpiresAtUtc <= now)
 457                {
 3458                    return false;
 459                }
 460
 3461                document.LeaseId = leaseId;
 3462                document.LeaseExpiresAtUtc = DurableFlowStoreShared.AddSaturating(now, leaseDuration);
 463                // Same _ts realignment as ReleaseLeaseAsync: a lease heartbeat replaces the
 464                // document without moving ExpiresAtUtc, so it must not restart the server TTL's
 465                // full retention window.
 3466                document.Ttl = CosmosTtlSeconds(document.ExpiresAtUtc, now);
 3467                await container.ReplaceItemAsync(
 3468                    document,
 3469                    flowId,
 3470                    new PartitionKey(flowId),
 3471                    new ItemRequestOptions { IfMatchEtag = current.ETag },
 3472                    cancellationToken).ConfigureAwait(false);
 3473                return true;
 474            }
 2475            catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
 476            {
 2477                return false;
 478            }
 2479            catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
 480            {
 2481            }
 482        }
 483
 2484        return false;
 3485    }
 486
 487    private static CosmosFlowStateDocument CreateDocument(string flowId, string stateJson, long revision, TimeSpan ttl, 
 3488        => new()
 3489        {
 3490            Id = flowId,
 3491            FlowId = flowId,
 3492            StateJson = stateJson,
 3493            ExpiresAtUtc = DurableFlowStoreShared.AddSaturating(now, ttl),
 3494            UpdatedAtUtc = now,
 3495            Revision = revision,
 3496            // Cosmos reaps the item itself once container TTL is enabled. Ceiling keeps the
 3497            // server-side TTL from being shorter than the requested duration.
 3498            Ttl = CosmosTtlSeconds(ttl)
 3499        };
 500
 501    /// <summary>Per-item TTL in whole seconds, rounded up and saturated at int.MaxValue (~68 years) for absurd expiries
 502    private static int CosmosTtlSeconds(TimeSpan ttl)
 3503        => (int)Math.Min(Math.Ceiling(ttl.TotalSeconds), int.MaxValue);
 504
 505    /// <summary>
 506    /// Remaining per-item TTL in whole seconds until <paramref name="expiresAtUtc"/>, rounded up
 507    /// and floored at 1 (Cosmos rejects 0). Used by replaces that keep the logical expiry in place:
 508    /// an already-due document collapses to the shortest legal TTL so the next sweep purges it
 509    /// instead of the replace granting it a fresh retention window.
 510    /// </summary>
 511    private static int CosmosTtlSeconds(DateTime expiresAtUtc, DateTime now)
 3512        => (int)Math.Min(Math.Max(Math.Ceiling((expiresAtUtc - now).TotalSeconds), 1), int.MaxValue);
 513
 514    /// <summary>Disposes the Cosmos client when the store created (and therefore owns) it.</summary>
 515    public void Dispose()
 516    {
 2517        _ensureGate.Dispose();
 2518        if (_ownsClient)
 2519            _client.Dispose();
 2520    }
 521}
 522
 523/// <summary>
 524/// One durable-flow ledger document. Attributed for BOTH Newtonsoft.Json and System.Text.Json:
 525/// the Cosmos SDK's default serializer is Newtonsoft-based, but hosts may register a
 526/// <see cref="CosmosClient"/> with an STJ-based serializer — with single-stack attributes such a
 527/// client would silently write PascalCase property names (breaking <c>id</c> and every read
 528/// back). <see cref="CosmosFlowStateStore"/> additionally probes custom serializers at
 529/// provisioning time and fails fast when neither attribute set is honored.
 530/// </summary>
 531internal sealed class CosmosFlowStateDocument
 532{
 533    [JsonProperty("id")]
 534    [System.Text.Json.Serialization.JsonPropertyName("id")]
 535    public string Id { get; set; } = "";
 536
 537    [JsonProperty("flowId")]
 538    [System.Text.Json.Serialization.JsonPropertyName("flowId")]
 539    public string FlowId { get; set; } = "";
 540
 541    [JsonProperty("stateJson")]
 542    [System.Text.Json.Serialization.JsonPropertyName("stateJson")]
 543    public string StateJson { get; set; } = "";
 544
 545    [JsonProperty("expiresAtUtc")]
 546    [System.Text.Json.Serialization.JsonPropertyName("expiresAtUtc")]
 547    public DateTime ExpiresAtUtc { get; set; }
 548
 549    [JsonProperty("updatedAtUtc")]
 550    [System.Text.Json.Serialization.JsonPropertyName("updatedAtUtc")]
 551    public DateTime UpdatedAtUtc { get; set; }
 552
 553    [JsonProperty("revision")]
 554    [System.Text.Json.Serialization.JsonPropertyName("revision")]
 555    public long? Revision { get; set; }
 556
 557    [JsonProperty("leaseId", NullValueHandling = NullValueHandling.Ignore)]
 558    [System.Text.Json.Serialization.JsonPropertyName("leaseId")]
 559    [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritin
 560    public string? LeaseId { get; set; }
 561
 562    [JsonProperty("leaseExpiresAtUtc", NullValueHandling = NullValueHandling.Ignore)]
 563    [System.Text.Json.Serialization.JsonPropertyName("leaseExpiresAtUtc")]
 564    [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritin
 565    public DateTime? LeaseExpiresAtUtc { get; set; }
 566
 567    /// <summary>Cosmos per-item TTL in seconds; honored once the container enables TTL.</summary>
 568    [JsonProperty("ttl", NullValueHandling = NullValueHandling.Ignore)]
 569    [System.Text.Json.Serialization.JsonPropertyName("ttl")]
 570    [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritin
 571    public int? Ttl { get; set; }
 572}
 573}