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

Information
Class: AsyncResponse.FlowStateJson
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/FlowStateJson.cs
Line coverage
95%
Covered lines: 57
Uncovered lines: 3
Coverable lines: 60
Total lines: 185
Line coverage: 95%
Branch coverage
94%
Covered branches: 53
Total branches: 56
Branch coverage: 94.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_TypeInfo()100%11100%
Serialize(...)100%11100%
Deserialize(...)100%44100%
EstimateLedgerChars(...)93.33%3030100%
JsonEquivalent(...)100%44100%
InputEquivalent(...)75%5462.5%
SerializeSnapshot(...)100%1414100%

File(s)

/_/src/AsyncResponse.Core/FlowStateJson.cs

#LineLine coverage
 1using System.Text.Json;
 2using System.Text.Json.Nodes;
 3using System.Text.Json.Serialization.Metadata;
 4
 5namespace AsyncResponse;
 6
 7internal static class FlowStateJson
 8{
 9    // FlowState is a library wire type: its metadata is source-generated
 10    // (AsyncResponseJsonContext), and the ledger omits nulls exactly as before.
 11    private static JsonTypeInfo<FlowState> TypeInfo
 4587012        => AsyncResponseJson.GetTypeInfo<FlowState>(AsyncResponseJson.IgnoreNullWrites);
 13
 2877714    public static string Serialize(FlowState state) => JsonSerializer.Serialize(state, TypeInfo);
 15
 16    /// <summary>
 17    /// Materializes a ledger row that the store has already found. Every failure here means the
 18    /// row EXISTS and cannot be read, which is categorically different from the row being absent —
 19    /// so none of them returns <c>null</c>. See <see cref="FlowStateUnreadableException"/> for why
 20    /// that difference decides whether a wake-up may be acknowledged.
 21    /// </summary>
 22    /// <exception cref="FlowStateUnreadableException">The row is present but uninterpretable.</exception>
 23    public static FlowState Deserialize(string json, string flowId)
 24    {
 25        FlowState? state;
 26        try
 27        {
 28            // Body-free failure contract (see JsonSafety): the reader's own message appends
 29            // `Path: $.<name>` built from the property names and dictionary keys it was reading —
 30            // a ledger's Values or Context keys, or whatever a start job's carrier holds — and this
 31            // exception is chained into FlowStateUnreadableException, which the worker ingress
 32            // logs in full. Only the size and position are carried across; the raw reader
 33            // exception is dropped, not chained.
 1709334            state = JsonSafety.SafeDeserialize(json, TypeInfo);
 1704635        }
 4736        catch (InvalidDataException ex)
 37        {
 4738            throw new FlowStateUnreadableException(flowId, "the stored JSON is malformed", ex);
 39        }
 40
 1704641        if (state is null)
 2242            throw new FlowStateUnreadableException(flowId, "the stored JSON is the literal null");
 43
 1702444        if (!FlowStateSchema.IsReadable(state.SchemaVersion))
 45        {
 4146            throw new FlowStateUnreadableException(
 4147                flowId,
 4148                $"its schema version is {state.SchemaVersion} and this build reads {FlowStateSchema.Current}");
 49        }
 50
 1698351        return state;
 52    }
 53
 54    /// <summary>
 55    /// A cheap lower-bound estimate of the serialized ledger size in UTF-16 code units: the sum of
 56    /// every string the ledger carries (input, messages, step results, values, context). O(steps +
 57    /// values) string-length reads, against a serialization that is O(bytes) — used to decide
 58    /// whether to warn about ledger growth without paying a second serialization per checkpoint.
 59    /// JSON escaping and property names only add to the real size, so "over the threshold" here
 60    /// is never a false positive.
 61    /// </summary>
 62    public static long EstimateLedgerChars(FlowState state)
 63    {
 863864        long size = (state.InputJson?.Length ?? 0) + (state.LastMessage?.Length ?? 0);
 65
 863866        if (state.Steps is { } steps)
 67        {
 31243468            foreach (var (name, step) in steps)
 69            {
 14758370                size += name.Length
 14758371                    + (step.ResultJson?.Length ?? 0)
 14758372                    + (step.Message?.Length ?? 0)
 14758373                    + (step.PendingCorrelationId?.Length ?? 0)
 14758374                    + (step.PendingPayloadTypeFullName?.Length ?? 0)
 14758375                    + (step.ChildFlowId?.Length ?? 0);
 76            }
 77        }
 78
 863879        if (state.Values is { } values)
 80        {
 1107281            foreach (var (key, value) in values)
 344682                size += key.Length + (value?.Length ?? 0);
 83        }
 84
 863885        if (state.Context is { } context)
 86        {
 887            foreach (var (key, value) in context)
 288                size += key.Length + (value?.Length ?? 0);
 89        }
 90
 863891        return size;
 92    }
 93
 94    public static bool JsonEquivalent(string? left, string right)
 95    {
 173296        if (string.Equals(left, right, StringComparison.Ordinal))
 168697            return true;
 4698        if (left is null)
 299            return false;
 100
 101        try
 102        {
 44103            return JsonNode.DeepEquals(JsonNode.Parse(left), JsonNode.Parse(right));
 104        }
 2105        catch (JsonException)
 106        {
 2107            return false;
 108        }
 44109    }
 110
 111    /// <summary>
 112    /// <see cref="JsonEquivalent"/> for a flow input whose type is known, comparing the VALUE
 113    /// rather than the shape a serializer once gave it. Inputs are written with their nulls and
 114    /// defaults, so the JSON of one and the same value changes whenever <typeparamref name="TInput"/>
 115    /// gains or loses a member: <c>{"TenantId":7}</c> persisted last month and
 116    /// <c>{"TenantId":7,"Region":null}</c> serialized today are the same input. The persisted JSON
 117    /// is therefore read as <typeparamref name="TInput"/> and written back by today's serializer
 118    /// before it is compared. A genuinely different value still differs after the round trip, and
 119    /// a persisted input today's type cannot read is a mismatch, never an exception.
 120    /// </summary>
 121    public static bool InputEquivalent<TInput>(string? persisted, string requested)
 122    {
 170123        if (JsonEquivalent(persisted, requested))
 164124            return true;
 6125        if (persisted is null)
 0126            return false;
 127
 128        try
 129        {
 6130            return JsonEquivalent(AsyncResponseJson.Serialize(JsonSafety.SafeDeserialize<TInput>(persisted)), requested)
 131        }
 0132        catch (Exception ex) when (ex is JsonException or InvalidDataException or NotSupportedException)
 133        {
 0134            return false;
 135        }
 6136    }
 137
 138    /// <summary>
 139    /// Serializes a child <see cref="FlowState"/> for memoization as a parent step result, without
 140    /// the captured ambient <see cref="FlowState.Context"/> (propagation machinery — it can carry
 141    /// principal/tenant values — that the parent never needs) and without the child's OWN
 142    /// memoized child snapshots: a step whose <see cref="FlowStepState.ChildFlowId"/> is set has
 143    /// its <see cref="FlowStepState.ResultJson"/> elided (the id, completion, and fault marker
 144    /// stay). The snapshot is stored as a JSON <em>string</em> inside the parent's ledger, so every
 145    /// ancestor level re-escapes the level below it; carrying grandchild snapshots along made the
 146    /// ledger grow exponentially with nesting depth (a 72-byte leaf became ~77 KB at depth 12 and
 147    /// ~600 KB at depth 15 — past DynamoDB's item cap — with no business payload at all). Eliding
 148    /// them makes a memoized snapshot depth-independent: a parent holds its direct children's
 149    /// outcomes and local step results; a grandchild's own snapshot lives in the grandchild's
 150    /// ledger, reachable by the elided step's <c>ChildFlowId</c> while that ledger lives.
 151    /// The instance handed in is restored before returning.
 152    /// </summary>
 153    public static string SerializeSnapshot(FlowState state)
 154    {
 148155        var context = state.Context;
 148156        state.Context = null;
 157
 148158        List<(FlowStepState Step, string ResultJson)>? elided = null;
 148159        if (state.Steps is { } steps)
 160        {
 1324161            foreach (var step in steps.Values)
 162            {
 524163                if (step.ChildFlowId is null || step.ResultJson is null)
 164                    continue;
 165
 120166                (elided ??= []).Add((step, step.ResultJson));
 120167                step.ResultJson = null;
 168            }
 169        }
 170
 171        try
 172        {
 148173            return Serialize(state);
 174        }
 175        finally
 176        {
 148177            state.Context = context;
 148178            if (elided is not null)
 179            {
 400180                foreach (var (step, resultJson) in elided)
 120181                    step.ResultJson = resultJson;
 182            }
 148183        }
 148184    }
 185}