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

Information
Class: AsyncResponse.Channels.NATS.NatsRecoveryStateStore
Assembly: AsyncResponse.Channels.NATS
File(s): /_/src/Channels/AsyncResponse.Channels.NATS/NatsRecoveryStateStore.cs
Line coverage
98%
Covered lines: 182
Uncovered lines: 3
Coverable lines: 185
Total lines: 443
Line coverage: 98.3%
Branch coverage
96%
Covered branches: 98
Total branches: 102
Branch coverage: 96%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
SaveAsync()100%2626100%
GetAllAsync()100%1616100%
TryDeleteAsync()100%2020100%
ScanAsync()100%1212100%
LoadStoredAsync()100%44100%
.cctor()100%11100%
SerializeStates(...)100%44100%
EntriesFrom(...)91.66%1212100%
IsExpired(...)100%11100%
IsStateReadable(...)100%11100%
IsStateReadable(...)87.5%88100%
TryDeserialize(...)100%11100%
TryDeleteSilentlyAsync()100%1150%
get_States()100%11100%
get_StateExpiries()100%11100%
get_ExpiresAtUtc()100%11100%

File(s)

/_/src/Channels/AsyncResponse.Channels.NATS/NatsRecoveryStateStore.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Microsoft.Extensions.Options;
 3using System.Runtime.CompilerServices;
 4using System.Text.Json;
 5using System.Text.Json.Serialization;
 6using System.Text.Json.Serialization.Metadata;
 7
 8namespace AsyncResponse.Channels.NATS;
 9
 10/// <summary>
 11/// NATS JetStream Key-Value implementation of <see cref="IRecoveryStateStore"/> and
 12/// <see cref="IRecoveryStateScanner"/>.
 13/// <para>
 14/// NATS KV applies a single <c>MaxAge</c> per bucket rather than a TTL per key, so each stored
 15/// registration carries its own absolute expiry (<see cref="StoredRecoveryState.StateExpiries"/>):
 16/// reads and scans treat a registration past its stamp as absent — a fresh sibling registration
 17/// under the same correlation id never extends it — and a fully expired key is deleted
 18/// best-effort, while the bucket's <c>MaxAge</c> acts as a garbage-collection ceiling for orphans.
 19/// </para>
 20/// </summary>
 21internal sealed class NatsRecoveryStateStore : IRecoveryStateStore, IRecoveryStateScanner
 22{
 23    private readonly INatsKvStore _store;
 24    private readonly ILogger<NatsRecoveryStateStore> _logger;
 25    private readonly TimeProvider _timeProvider;
 26
 27    /// <summary>Creates a NATS JetStream Key-Value recovery state store.</summary>
 42528    public NatsRecoveryStateStore(
 42529        INatsKvStore store,
 42530        IOptions<NatsAsyncResponseChannelOptions> options,
 42531        ILogger<NatsRecoveryStateStore> logger,
 42532        TimeProvider? timeProvider = null)
 33    {
 42534        options.Value.Validate();
 42535        _store = store;
 42536        _logger = logger;
 42537        _timeProvider = timeProvider ?? TimeProvider.System;
 42538    }
 39
 40    /// <inheritdoc />
 41    public async Task SaveAsync(
 42        string correlationId,
 43        RecoveryState state,
 44        TimeSpan ttl,
 45        CancellationToken cancellationToken = default)
 46    {
 44647        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 44448        ArgumentNullException.ThrowIfNull(state);
 44249        if (ttl <= TimeSpan.Zero)
 250            throw new ArgumentOutOfRangeException(nameof(ttl), "TTL must be greater than zero.");
 44051        if (!string.Equals(state.CorrelationId, correlationId, StringComparison.Ordinal))
 252            throw new ArgumentException("The recovery-state correlation id must match the store key.", nameof(state));
 43853        if (state.SchemaVersion != RecoveryStateSchema.Current)
 254            throw new ArgumentException("The recovery state must use the current schema version.", nameof(state));
 55
 43656        cancellationToken.ThrowIfCancellationRequested();
 43457        if (state.RegistrationId == Guid.Empty)
 1658            state.RegistrationId = Guid.NewGuid();
 59
 43460        var key = NatsSubjectSchema.RecoveryKey(correlationId);
 61
 62        // Revision-conditioned read-modify-write: two waiters registering the same correlation id
 63        // concurrently must both survive.
 88864        for (var attempt = 0; attempt < MaxCasAttempts; attempt++)
 65        {
 44266            cancellationToken.ThrowIfCancellationRequested();
 67
 44268            var entry = await _store.GetAsync(key, cancellationToken).ConfigureAwait(false);
 44269            var stored = entry is { } existing ? TryDeserialize(existing.Value, key) : null;
 70
 71            // The rewrite path must refuse, not overwrite: an unparseable envelope deserializing
 72            // to "no entries" would make this save commit just the new registration over a blob
 73            // whose registrations it could not even ENUMERATE, destroying every armed callback it
 74            // held — "unreadable" read as "missing", which is exactly what GetAllAsync was
 75            // hardened to refuse.
 44276            if (entry is not null && stored is null)
 277                throw new RecoveryStateUnreadableException(correlationId, 1);
 78
 44079            var now = _timeProvider.GetUtcNow();
 44080            List<(RecoveryState? State, DateTimeOffset ExpiresAtUtc)> entries = stored is not null && !IsExpired(stored)
 44081                ? EntriesFrom(stored)
 44082                : [];
 83            // Deliberately NOT pruned by readability: a registration this build cannot INTERPRET
 84            // (a newer schema version written by a host mid-rolling-upgrade) must be carried
 85            // through untouched. Rewriting the shared envelope from the readable subset silently
 86            // deleted that sibling — the write path treating "unreadable" as "missing", which is
 87            // exactly what GetAllAsync was hardened to refuse.
 88            //
 89            // Per-registration expiry, though, IS pruned: a sibling keeps its OWN stamp rather
 90            // than inheriting this save's fresh TTL. Re-stamping the shared envelope kept a dead
 91            // waiter's registration recoverable for as long as anything else registered under the
 92            // correlation id — its stale callback then fired into a flow that lapsed days
 93            // earlier. An entry past its own stamp is dropped exactly like a relational store's
 94            // per-row expires_at drops it.
 46595            entries.RemoveAll(existingEntry => existingEntry.ExpiresAtUtc <= now);
 46596            entries.RemoveAll(existingEntry => existingEntry.State is { } existingState && existingState.RegistrationId 
 44097            entries.Add((state, now + ttl));
 44098            var json = SerializeStates(entries);
 99
 440100            var written = entry is { } current
 440101                ? await _store.TryUpdateAsync(key, json, current.Revision, cancellationToken).ConfigureAwait(false)
 440102                : await _store.TryCreateAsync(key, json, cancellationToken).ConfigureAwait(false);
 440103            if (written)
 430104                return;
 10105        }
 106
 2107        throw new InvalidOperationException(
 2108            $"Recovery-state save for correlationId '{correlationId}' could not commit after {MaxCasAttempts} optimistic
 430109    }
 110
 111    /// <inheritdoc />
 112    public async Task<IReadOnlyList<RecoveryState>> GetAllAsync(string correlationId, CancellationToken cancellationToke
 113    {
 66114        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 64115        cancellationToken.ThrowIfCancellationRequested();
 116
 64117        var key = NatsSubjectSchema.RecoveryKey(correlationId);
 64118        var loaded = await LoadStoredAsync(key, cancellationToken).ConfigureAwait(false);
 119
 120        // The ENVELOPE itself is unreadable — a truncated or corrupt value, or a shape a newer
 121        // build wrote whose JSON this one cannot parse at all (so the per-registration schema check
 122        // below never gets to classify it). That is the same "unreadable is not missing" case the
 123        // per-registration branch guards, one level up: returning [] here would read as "no
 124        // recovery callback was ever armed" and the dispatcher would acknowledge the terminal
 125        // response, consuming it for a callback that never ran. Refuse so redelivery can reach a
 126        // build that can read it.
 64127        if (loaded.Unreadable)
 2128            throw new RecoveryStateUnreadableException(correlationId, 1);
 129
 62130        if (loaded.Stored is not { } stored)
 13131            return [];
 132
 49133        var found = (Stored: stored, loaded.Revision);
 134
 49135        if (IsExpired(found.Stored))
 136        {
 137            // Past its logical expiry but still physically present (bucket MaxAge has not collected it
 138            // yet): treat as gone and remove it best-effort so it never resurfaces.
 8139            await TryDeleteSilentlyAsync(key, found.Revision, cancellationToken).ConfigureAwait(false);
 8140            return [];
 141        }
 142
 41143        var now = _timeProvider.GetUtcNow();
 41144        var entries = EntriesFrom(found.Stored);
 41145        var states = new List<RecoveryState>(entries.Count);
 41146        var unreadable = 0;
 196147        foreach (var (state, expiresAtUtc) in entries)
 148        {
 149            // A registration past its own stamp is absence (the relational stores' per-row
 150            // expires_at), even while a fresher sibling keeps the shared key alive.
 57151            if (expiresAtUtc <= now)
 152                continue;
 153
 55154            if (!IsStateReadable(state, key, correlationId, ref unreadable))
 155                continue;
 156
 41157            states.Add(state!);
 158        }
 159
 160        // Registrations existed and none this build could INTERPRET survived. An empty list reads
 161        // as "no recovery callback was ever armed", which the dispatcher answers by acknowledging
 162        // the response — so a corrupt or newer-schema registration would consume a terminal response
 163        // its callback never saw. A partially readable batch deliberately does not throw (see
 164        // RecoveryStateUnreadableException), and neither does a row rejected for carrying ANOTHER
 165        // correlation id: that row is readable and simply belongs elsewhere, so for the id actually
 166        // asked about it is absence, not corruption.
 41167        if (unreadable > 0 && states.Count == 0)
 4168            throw new RecoveryStateUnreadableException(correlationId, unreadable);
 169
 37170        return states;
 58171    }
 172
 173    /// <inheritdoc />
 174    public async Task<bool> TryDeleteAsync(string correlationId, Guid registrationId, CancellationToken cancellationToke
 175    {
 398176        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 396177        if (registrationId == Guid.Empty)
 2178            throw new ArgumentException("Registration id cannot be empty.", nameof(registrationId));
 394179        cancellationToken.ThrowIfCancellationRequested();
 180
 394181        var key = NatsSubjectSchema.RecoveryKey(correlationId);
 182
 183        // Revision-conditioned removal: deleting one registration must not clobber a registration
 184        // that another writer appended between our read and our write.
 814185        for (var attempt = 0; attempt < MaxCasAttempts; attempt++)
 186        {
 405187            cancellationToken.ThrowIfCancellationRequested();
 188
 405189            var entry = await _store.GetAsync(key, cancellationToken).ConfigureAwait(false);
 405190            if (entry is not { } existing)
 4191                return false;
 192
 401193            var stored = TryDeserialize(existing.Value, key);
 401194            if (stored is null)
 2195                return false;
 196
 399197            if (IsExpired(stored))
 198            {
 2199                await TryDeleteSilentlyAsync(key, existing.Revision, cancellationToken).ConfigureAwait(false);
 2200                return false;
 201            }
 202
 397203            var entries = EntriesFrom(stored);
 204            // Same rule as SaveAsync: remove only the targeted registration, never a sibling this
 205            // build merely cannot read — dropping those here also let the key be deleted outright
 206            // when they were the only survivors. Entries past their own expiry go too (they are
 207            // already invisible to every read), and the survivors keep their own stamps.
 816208            var removed = entries.RemoveAll(candidate => candidate.State is { } candidateState && candidateState.Registr
 397209            if (!removed)
 2210                return false;
 211
 417212            entries.RemoveAll(candidate => candidate.ExpiresAtUtc <= _timeProvider.GetUtcNow());
 213
 395214            var succeeded = entries.Count == 0
 395215                ? await _store.TryDeleteAsync(key, existing.Revision, cancellationToken).ConfigureAwait(false)
 395216                : await _store.TryUpdateAsync(key, SerializeStates(entries), existing.Revision, cancellationToken).Confi
 395217            if (succeeded)
 382218                return true;
 219        }
 220
 2221        _logger.LogWarning(
 2222            "Recovery-state delete for correlationId {CorrelationId} registration {RegistrationId} exhausted {Attempts} 
 2223            correlationId, registrationId, MaxCasAttempts);
 2224        return false;
 394225    }
 226
 227    /// <inheritdoc />
 228    public async IAsyncEnumerable<RecoveryState> ScanAsync([EnumeratorCancellation] CancellationToken cancellationToken 
 229    {
 564230        await foreach (var key in _store.GetKeysAsync(cancellationToken).ConfigureAwait(false))
 231        {
 12232            cancellationToken.ThrowIfCancellationRequested();
 233
 12234            var loaded = await LoadStoredAsync(key, cancellationToken).ConfigureAwait(false);
 235
 236            // The watchdog scan reports; it does not settle a delivery, so an unreadable envelope
 237            // is skipped here rather than thrown (TryDeserialize already logged it). GetAllAsync —
 238            // the path whose answer decides whether a terminal response is acknowledged — refuses.
 12239            if (loaded.Stored is not { } storedState)
 240                continue;
 241
 10242            var found = (Stored: storedState, loaded.Revision);
 243
 10244            if (IsExpired(found.Stored))
 245            {
 2246                await TryDeleteSilentlyAsync(key, found.Revision, cancellationToken).ConfigureAwait(false);
 2247                continue;
 248            }
 249
 8250            var now = _timeProvider.GetUtcNow();
 36251            foreach (var (state, expiresAtUtc) in EntriesFrom(found.Stored))
 252            {
 10253                if (expiresAtUtc <= now)
 254                    continue;
 255
 10256                var correlationId = NatsSubjectSchema.CorrelationIdFromRecoveryKey(key);
 10257                if (!IsStateReadable(state, key, correlationId))
 258                    continue;
 259
 6260                yield return state!;
 261            }
 8262        }
 267263    }
 264
 265    private const int MaxCasAttempts = 4;
 266
 267    /// <summary>
 268    /// Tri-state load: the key is absent, or it holds an envelope this build can read, or it holds
 269    /// an envelope it cannot. The third case is NOT absence — see the note in <see cref="GetAllAsync"/>.
 270    /// </summary>
 271    private async Task<(StoredRecoveryState? Stored, ulong Revision, bool Unreadable)> LoadStoredAsync(string key, Cance
 272    {
 76273        var entry = await _store.GetAsync(key, cancellationToken).ConfigureAwait(false);
 76274        if (entry is not { } existing)
 13275            return (null, 0, false);
 276
 63277        var stored = TryDeserialize(existing.Value, key);
 63278        return stored is null ? (null, existing.Revision, true) : (stored, existing.Revision, false);
 76279    }
 280
 281    /// <summary>
 282    /// The package-local envelope metadata CHAINED behind the library's resolver, not used alone.
 283    /// A callback argument is <see cref="CallbackParam"/>.Value, typed <c>object</c>, so it
 284    /// serializes by runtime type — and the source generator only emitted what this envelope
 285    /// references transitively (string, int, Guid, DateTime). On its own the context therefore
 286    /// threw NotSupportedException at waiter registration for a perfectly ordinary literal
 287    /// (a bool, a long, an enum, a DTO), on this channel and Redis only, and bypassed
 288    /// AsyncResponseJsonSerialization.RegisterResolver — the documented trim/AOT seam — entirely.
 289    /// The wire format is unchanged: the envelope's own metadata still resolves first.
 290    /// </summary>
 13291    private static readonly JsonSerializerOptions _envelopeOptions = new()
 13292    {
 13293        TypeInfoResolver = JsonTypeInfoResolver.Combine(NatsChannelJsonContext.Default, AsyncResponseJson.Resolver)
 13294    };
 295
 296    /// <summary>The envelope's metadata off the chained options — the JsonTypeInfo overloads keep this trim/AOT-clean.<
 13297    private static readonly JsonTypeInfo<StoredRecoveryState> _envelopeTypeInfo =
 13298        AsyncResponseJson.GetTypeInfo<StoredRecoveryState>(_envelopeOptions);
 299
 300    private static string SerializeStates(List<(RecoveryState? State, DateTimeOffset ExpiresAtUtc)> entries)
 301    {
 462302        var states = new List<RecoveryState>(entries.Count);
 462303        var expiries = new List<DateTimeOffset>(entries.Count);
 462304        var maxExpiresAtUtc = DateTimeOffset.MinValue;
 1898305        foreach (var (state, expiresAtUtc) in entries)
 306        {
 487307            states.Add(state!);
 487308            expiries.Add(expiresAtUtc);
 487309            if (expiresAtUtc > maxExpiresAtUtc)
 469310                maxExpiresAtUtc = expiresAtUtc;
 311        }
 312
 462313        return JsonSerializer.Serialize(new StoredRecoveryState
 462314        {
 462315            States = states,
 462316            StateExpiries = expiries,
 462317            ExpiresAtUtc = maxExpiresAtUtc
 462318        }, _envelopeTypeInfo);
 319    }
 320
 321    /// <summary>
 322    /// Pairs each stored registration with its own expiry. Envelopes written before
 323    /// <see cref="StoredRecoveryState.StateExpiries"/> existed carry only the shared stamp, which
 324    /// those registrations inherit — the old behavior, applied to old data only.
 325    /// </summary>
 326    private static List<(RecoveryState? State, DateTimeOffset ExpiresAtUtc)> EntriesFrom(StoredRecoveryState stored)
 327    {
 469328        var states = stored.States;
 469329        if (states is not { Count: > 0 })
 4330            return [];
 331
 465332        var expiries = stored.StateExpiries is { } perState && perState.Count == states.Count ? stored.StateExpiries : n
 465333        var entries = new List<(RecoveryState? State, DateTimeOffset ExpiresAtUtc)>(states.Count);
 1952334        for (var i = 0; i < states.Count; i++)
 511335            entries.Add((states[i], expiries is not null ? expiries[i] : stored.ExpiresAtUtc));
 336
 465337        return entries;
 338    }
 339
 483340    private bool IsExpired(StoredRecoveryState stored) => stored.ExpiresAtUtc <= _timeProvider.GetUtcNow();
 341
 342    /// <summary>Readability check for paths that prune without judging a read.</summary>
 343    private bool IsStateReadable(RecoveryState? state, string key, string correlationId)
 344    {
 10345        var ignored = 0;
 10346        return IsStateReadable(state, key, correlationId, ref ignored);
 347    }
 348
 349    /// <summary>
 350    /// Readability check that also counts rows this build could not INTERPRET. A row carrying
 351    /// another correlation id is deliberately NOT counted: it is readable and belongs elsewhere.
 352    /// </summary>
 353    private bool IsStateReadable(RecoveryState? state, string key, string correlationId, ref int unreadable)
 354    {
 65355        if (state is null || state.RegistrationId == Guid.Empty)
 356        {
 4357            _logger.LogWarning(
 4358                "Recovery state at key {RecoveryKey} has no registration id; rejecting it because it cannot be deleted s
 4359                key);
 4360            unreadable++;
 4361            return false;
 362        }
 363
 61364        if (!RecoveryStateSchema.IsReadable(state.SchemaVersion))
 365        {
 6366            _logger.LogWarning(
 6367                "Recovery state at key {RecoveryKey} has unsupported schema version {SchemaVersion} (current: {Current})
 6368                key, state.SchemaVersion, RecoveryStateSchema.Current);
 6369            unreadable++;
 6370            return false;
 371        }
 372
 55373        if (string.Equals(state.CorrelationId, correlationId, StringComparison.Ordinal))
 47374            return true;
 375
 8376        _logger.LogWarning(
 8377            "Recovery state at key {RecoveryKey} has correlationId {StoredCorrelationId}, expected {CorrelationId}; reje
 8378            key, state.CorrelationId, correlationId);
 8379        return false;
 380    }
 381
 382    private StoredRecoveryState? TryDeserialize(string json, string key)
 383    {
 384        try
 385        {
 386            // Through JsonSafety, not the raw reader: the exception logged below is the body-free
 387            // rebuild (size and position). The reader's own appends `Path: $.States[0].Context['<key>']`
 388            // built from the stored registration's context keys — tenant and auth baggage — which
 389            // this warning then carried into the application log.
 493390            return JsonSafety.SafeDeserialize(json, _envelopeTypeInfo);
 391        }
 10392        catch (Exception ex) when (ex is JsonException or InvalidDataException)
 393        {
 10394            _logger.LogWarning(ex, "Unreadable recovery state at key {RecoveryKey}; skipping.", key);
 10395            return null;
 396        }
 493397    }
 398
 399    private async Task TryDeleteSilentlyAsync(string key, ulong revision, CancellationToken cancellationToken)
 400    {
 401        try
 402        {
 403            // Revision-conditioned like every other write in this store: an unconditional delete
 404            // could destroy a FRESH registration a concurrent SaveAsync committed between our
 405            // expired read and this cleanup. On a revision conflict the delete is simply skipped —
 406            // the new writer owns the key now.
 12407            await _store.TryDeleteAsync(key, revision, cancellationToken).ConfigureAwait(false);
 12408        }
 0409        catch (Exception ex)
 410        {
 0411            _logger.LogDebug(ex, "Best-effort delete of expired recovery state at key {RecoveryKey} failed.", key);
 0412        }
 12413    }
 414
 415    /// <summary>The stored envelope: the recovery states plus their expiries, for per-registration logical TTL.</summar
 416    internal sealed class StoredRecoveryState
 417    {
 1896418        public List<RecoveryState>? States { get; set; }
 419
 420        /// <summary>
 421        /// Per-registration absolute expiries, parallel to <see cref="States"/> by index.
 422        /// Additive wire property: envelopes written before it existed carry only the shared
 423        /// <see cref="ExpiresAtUtc"/>, which those registrations inherit on read.
 424        /// </summary>
 2323425        public List<DateTimeOffset>? StateExpiries { get; set; }
 426
 427        /// <summary>
 428        /// The envelope-level expiry — the maximum of <see cref="StateExpiries"/>. Kept for the
 429        /// whole-key fast path (every registration expired ⇒ the key is deletable) and for
 430        /// downgrade compatibility with builds that read only this stamp.
 431        /// </summary>
 1940432        public DateTimeOffset ExpiresAtUtc { get; set; }
 433    }
 434}
 435
 436/// <summary>
 437/// Source-generated metadata for the package-local KV envelope (trim/AOT-safe; the wire format is
 438/// unchanged — Metadata-mode generation with default options matches the previous reflection-based
 439/// serialization exactly).
 440/// </summary>
 441[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
 442[JsonSerializable(typeof(NatsRecoveryStateStore.StoredRecoveryState))]
 443internal sealed partial class NatsChannelJsonContext : JsonSerializerContext;