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

Information
Class: AsyncResponse.DurableFlows.Internal.DurableFlowStoreShared
Assembly: AsyncResponse.DurableFlows.Oracle
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/DurableFlows/Shared/DurableFlowStoreShared.cs
Line coverage
100%
Covered lines: 68
Uncovered lines: 0
Coverable lines: 68
Total lines: 219
Line coverage: 100%
Branch coverage
100%
Covered branches: 50
Total branches: 50
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
get_FlowStateTypeInfo()100%11100%
ValidateCreate(...)100%22100%
ValidateUpdate(...)100%44100%
Serialize(...)100%11100%
SerializeBounded(...)100%44100%
ReadState(...)100%66100%
AddSaturating(...)100%22100%
AddSaturating(...)100%22100%
ServerClockTtl(...)100%22100%
ServerClockTtlMilliseconds(...)100%11100%
Deserialize(...)100%44100%
ShouldPrune(...)100%44100%
SchemaLockKey(...)100%22100%
SchemaLockResource(...)100%11100%
ValidateIdentifier(...)100%1212100%
ValidateWrite(...)100%66100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/DurableFlows/Shared/DurableFlowStoreShared.cs

#LineLine coverage
 1using System.Text;
 2using System.Text.Json;
 3using System.Text.Json.Serialization;
 4using System.Text.Json.Serialization.Metadata;
 5
 6namespace AsyncResponse.DurableFlows.Internal;
 7
 8internal static class DurableFlowStoreShared
 9{
 10    /// <summary>
 11    /// Upper bound for TTL values handed to server-clock date arithmetic (~68 years). SQL Server's
 12    /// <c>DATEADD</c> takes <c>int</c> seconds, and MySQL/Oracle datetime types stop at year 9999,
 13    /// so an absurd <see cref="DurableFlowOptions.StateExpiry"/> (for example
 14    /// <see cref="TimeSpan.MaxValue"/>) would overflow inside the database. Clamping mirrors
 15    /// <see cref="AddSaturating(DateTime, TimeSpan)"/> on the client side: huge expiries saturate
 16    /// to "effectively never" instead of failing every write.
 17    /// </summary>
 318    private static readonly TimeSpan MaxServerClockTtl = TimeSpan.FromSeconds(int.MaxValue);
 19
 320    private static readonly JsonSerializerOptions Options = new()
 321    {
 322        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
 323        TypeInfoResolver = DurableFlowStoreJsonContext.Default
 324    };
 25
 26    private static JsonTypeInfo<FlowState> FlowStateTypeInfo
 327        => (JsonTypeInfo<FlowState>)Options.GetTypeInfo(typeof(FlowState));
 28
 29    public static void ValidateCreate(string flowId, FlowState state, TimeSpan ttl)
 30    {
 331        ValidateWrite(flowId, state, ttl);
 332        if (state.Revision != 0)
 233            throw new ArgumentException("A new flow ledger must start at revision zero.", nameof(state));
 334    }
 35
 36    public static void ValidateUpdate(string flowId, FlowState state, long expectedRevision, TimeSpan ttl)
 37    {
 338        ValidateWrite(flowId, state, ttl);
 339        if (expectedRevision < 0)
 240            throw new ArgumentOutOfRangeException(nameof(expectedRevision), "The expected revision cannot be negative.")
 341        if (state.Revision != checked(expectedRevision + 1))
 242            throw new ArgumentException("The new flow-state revision must increment the expected revision by one.", name
 343    }
 44
 345    public static string Serialize(FlowState state) => JsonSerializer.Serialize(state, FlowStateTypeInfo);
 46
 47    /// <summary>
 48    /// Serializes a ledger for a full-state write and enforces the store's <c>MaxStateBytes</c>
 49    /// budget. Without the guard an oversized ledger surfaces as the provider's opaque payload
 50    /// error (DynamoDB 400 KB item cap, Cosmos 2 MB, MongoDB 16 MB) which the executor retries
 51    /// into the dead-letter queue with no hint at the real cause. Throwing here keeps the same
 52    /// at-least-once semantics (run fails → retries → DLQ = operator alarm) but names the cause.
 53    /// </summary>
 54    /// <exception cref="FlowStateTooLargeException">The serialized state exceeds <paramref name="maxStateBytes"/>.</exc
 55    public static string SerializeBounded(string flowId, FlowState state, long? maxStateBytes, string providerName)
 56    {
 357        var json = Serialize(state);
 358        if (maxStateBytes is { } limit)
 59        {
 260            long size = Encoding.UTF8.GetByteCount(json);
 261            if (size > limit)
 262                throw new FlowStateTooLargeException(flowId, size, limit, providerName);
 63        }
 64
 365        return json;
 66    }
 67
 68    /// <summary>
 69    /// Materializes a loaded ledger row. Unreadable JSON, an unknown schema version, a revision
 70    /// that does not match the stored row, and an identity-mismatched ledger
 71    /// (<c>state.FlowId != flowId</c>) all load as absent — the read-side mirror of the write-side
 72    /// key/identity validation in <see cref="ValidateCreate"/>, so a row copied or restored under
 73    /// the wrong key can never resurrect as that flow.
 74    /// </summary>
 75    public static FlowState? ReadState(string flowId, string stateJson, long revision)
 76    {
 377        var state = Deserialize(stateJson);
 378        return state is not null
 379            && state.Revision == revision
 380            && string.Equals(state.FlowId, flowId, StringComparison.Ordinal)
 381                ? state
 382                : null;
 83    }
 84
 85    /// <summary>
 86    /// <paramref name="instant"/> + <paramref name="ttl"/>, saturating at
 87    /// <see cref="DateTime.MaxValue"/> instead of throwing: an absurd
 88    /// <see cref="DurableFlowOptions.StateExpiry"/> then means "effectively never expires" rather
 89    /// than failing every write with an <see cref="ArgumentOutOfRangeException"/>.
 90    /// </summary>
 91    public static DateTime AddSaturating(DateTime instant, TimeSpan ttl)
 292        => ttl > DateTime.MaxValue - instant ? DateTime.MaxValue : instant + ttl;
 93
 94    /// <inheritdoc cref="AddSaturating(DateTime, TimeSpan)"/>
 95    public static DateTimeOffset AddSaturating(DateTimeOffset instant, TimeSpan ttl)
 296        => ttl > DateTimeOffset.MaxValue - instant ? DateTimeOffset.MaxValue : instant + ttl;
 97
 98    /// <summary>TTL clamped for server-clock date arithmetic; see <see cref="MaxServerClockTtl"/>.</summary>
 99    public static TimeSpan ServerClockTtl(TimeSpan ttl)
 3100        => ttl > MaxServerClockTtl ? MaxServerClockTtl : ttl;
 101
 102    /// <summary>Whole milliseconds of <see cref="ServerClockTtl"/>, for stores that bind the TTL as a number.</summary>
 103    public static long ServerClockTtlMilliseconds(TimeSpan ttl)
 3104        => (long)ServerClockTtl(ttl).TotalMilliseconds;
 105
 106    public static FlowState? Deserialize(string json)
 107    {
 108        try
 109        {
 3110            var state = JsonSerializer.Deserialize(json, DurableFlowStoreJsonContext.Default.FlowState);
 3111            return state is not null && FlowStateSchema.IsReadable(state.SchemaVersion) ? state : null;
 112        }
 2113        catch (JsonException)
 114        {
 2115            return null;
 116        }
 3117    }
 118
 119    /// <summary>
 120    /// Throttles opportunistic expired-state pruning: returns <c>true</c> at most once per
 121    /// <paramref name="interval"/> (a non-positive interval prunes on every operation, matching the
 122    /// channel packages). Loads already filter on expiry, so throttling never affects correctness.
 123    /// </summary>
 124    public static bool ShouldPrune(ref long lastTicks, TimeSpan interval)
 125    {
 3126        if (interval <= TimeSpan.Zero)
 2127            return true;
 128
 3129        var now = DateTime.UtcNow.Ticks;
 3130        var last = Interlocked.Read(ref lastTicks);
 3131        return now - last >= interval.Ticks
 3132            && Interlocked.CompareExchange(ref lastTicks, now, last) == last;
 133    }
 134
 135    /// <summary>
 136    /// Advisory-lock key for schema DDL, derived exactly like the channel/transport packages
 137    /// (FNV-1a over <c>asyncresponse:ddl:{schemaName}</c>) so flow-store DDL serializes with any
 138    /// channel/transport DDL running against the same schema.
 139    /// </summary>
 140    public static long SchemaLockKey(string schemaName)
 141    {
 142        const ulong offset = 14695981039346656037UL;
 143        const ulong prime = 1099511628211UL;
 2144        var hash = offset;
 2145        foreach (var b in Encoding.UTF8.GetBytes(SchemaLockResource(schemaName)))
 146        {
 2147            hash ^= b;
 2148            hash *= prime;
 149        }
 150
 2151        return unchecked((long)hash);
 152    }
 153
 154    /// <summary>SQL Server <c>sp_getapplock</c> resource name for schema DDL (shared with the channel/transport package
 155    public static string SchemaLockResource(string schemaName)
 2156        => $"asyncresponse:ddl:{schemaName}";
 157
 158    public static void ValidateIdentifier(string? value, string optionName, string providerName)
 159    {
 3160        if (string.IsNullOrWhiteSpace(value))
 2161            throw new InvalidOperationException($"{optionName} must be configured.");
 162
 3163        if (!(char.IsAsciiLetter(value[0]) || value[0] == '_'))
 2164            throw new InvalidOperationException($"{optionName} '{value}' must be a simple {providerName} identifier (let
 165
 3166        foreach (var c in value)
 167        {
 3168            if (!(char.IsAsciiLetterOrDigit(c) || c == '_'))
 2169                throw new InvalidOperationException($"{optionName} '{value}' must be a simple {providerName} identifier 
 170        }
 3171    }
 172
 173    private static void ValidateWrite(string flowId, FlowState state, TimeSpan ttl)
 174    {
 3175        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 3176        ArgumentNullException.ThrowIfNull(state);
 3177        if (!string.Equals(state.FlowId, flowId, StringComparison.Ordinal))
 2178            throw new ArgumentException("The flow state id must match the store key.", nameof(state));
 3179        if (state.SchemaVersion != FlowStateSchema.Current)
 2180            throw new ArgumentException("The flow state must use the current schema version.", nameof(state));
 3181        if (ttl <= TimeSpan.Zero)
 2182            throw new ArgumentOutOfRangeException(nameof(ttl), "TTL must be greater than zero.");
 3183    }
 184}
 185
 186/// <summary>
 187/// Thrown when a serialized durable-flow ledger exceeds the store's configured
 188/// <c>MaxStateBytes</c> budget. Internal on purpose: this shared source is compiled into every
 189/// store package, so a public type here would surface as identically-named colliding public types
 190/// when a host references two store packages. Callers catch it as its
 191/// <see cref="InvalidOperationException"/> base; the message carries the diagnosis.
 192/// </summary>
 193internal sealed class FlowStateTooLargeException(string flowId, long serializedSizeBytes, long maxStateBytes, string pro
 194    : InvalidOperationException(
 195        $"Flow '{flowId}' state serialized to {serializedSizeBytes} bytes, exceeding the {providerName} MaxStateBytes li
 196        "flow state exceeded the provider's size limit. Keep large payloads in your own storage and pass references in f
 197        "see docs/durable-flows.md (ledger-size note).")
 198{
 199    /// <summary>The flow whose ledger write was rejected.</summary>
 200    public string FlowId { get; } = flowId;
 201
 202    /// <summary>Serialized ledger size in UTF-8 bytes.</summary>
 203    public long SerializedSizeBytes { get; } = serializedSizeBytes;
 204
 205    /// <summary>The configured budget the write exceeded.</summary>
 206    public long MaxStateBytes { get; } = maxStateBytes;
 207}
 208
 209/// <summary>
 210/// Source-generated JSON metadata for <see cref="FlowState"/> so the store packages never fall back
 211/// to reflection-based serialization (trim/AOT-safe). Compiled into each store package alongside
 212/// <see cref="DurableFlowStoreShared"/>, so every assembly gets its own generated context. Metadata
 213/// mode (no fast-path writer) so writes honor <see cref="DurableFlowStoreShared"/>'s options
 214/// (WhenWritingNull) and the persisted bytes are unchanged; reads use the context's bare default
 215/// options, matching the previous options-less <c>Deserialize&lt;FlowState&gt;(json)</c> call.
 216/// </summary>
 217[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
 218[JsonSerializable(typeof(FlowState))]
 219internal sealed partial class DurableFlowStoreJsonContext : JsonSerializerContext;