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

Information
Class: AsyncResponse.Channels.Redis.RedisRecoveryStateStore
Assembly: AsyncResponse.Channels.Redis
File(s): /_/src/Channels/AsyncResponse.Channels.Redis/RedisRecoveryStateStore.cs
Line coverage
94%
Covered lines: 242
Uncovered lines: 14
Coverable lines: 256
Total lines: 628
Line coverage: 94.5%
Branch coverage
84%
Covered branches: 144
Total branches: 170
Branch coverage: 84.7%
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%2222100%
GetAllAsync()100%11100%
TryDeleteAsync()100%1616100%
ScanAsync()100%1414100%
ReadScanBatchAsync()100%88100%
ResolveScanTargetsAsync()100%2424100%
ReadClusterNodeTableAsync()100%66100%
RequireSlotCoverage(...)42.85%461445.45%
ScanUnavailable(...)100%11100%
LoadStatesAsync()100%88100%
CountStoredRegistrations(...)21.42%241463.15%
.cctor()100%11100%
SerializeEntries(...)100%11100%
MaxRemaining(...)100%44100%
DeserializeEntries(...)90.9%2222100%
IsLegacyShape(...)50%4475%
IsStateReadable(...)100%88100%
get_Registrations()100%11100%
get_State()100%11100%
get_ExpiresAtUtc()100%11100%

File(s)

/_/src/Channels/AsyncResponse.Channels.Redis/RedisRecoveryStateStore.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Microsoft.Extensions.Options;
 3using StackExchange.Redis;
 4using System.Runtime.CompilerServices;
 5using System.Text.Json;
 6using System.Text.Json.Serialization;
 7using System.Text.Json.Serialization.Metadata;
 8
 9namespace AsyncResponse.Channels.Redis;
 10
 11/// <summary>
 12/// Redis-backed implementation of <see cref="IRecoveryStateStore"/>.
 13/// <para>
 14/// Every registration for a correlation id shares one key, but Redis applies a single TTL per key,
 15/// so each entry also carries an absolute <see cref="StoredRegistration.ExpiresAtUtc"/>: reads and
 16/// scans treat an entry past its expiry as absent, saves prune expired entries, and the key TTL is
 17/// always the longest remaining entry lifetime — a stream of fresh registrations can therefore
 18/// never keep a dead sibling registration recoverable (nor truncate a longer-lived one).
 19/// </para>
 20/// </summary>
 21internal sealed class RedisRecoveryStateStore : IRecoveryStateStore, IRecoveryStateScanner
 22{
 23    private readonly IConnectionMultiplexer _multiplexer;
 24    private readonly IDatabase _database;
 25    private readonly RedisKeySchema _keys;
 26    private readonly ILogger<RedisRecoveryStateStore> _logger;
 27    private readonly TimeProvider _timeProvider;
 28
 29    /// <summary>Creates a Redis-backed recovery state store.</summary>
 52430    public RedisRecoveryStateStore(
 52431        IConnectionMultiplexer multiplexer,
 52432        IOptions<RedisAsyncResponseOptions> options,
 52433        ILogger<RedisRecoveryStateStore> logger,
 52434        TimeProvider? timeProvider = null)
 35    {
 52436        _multiplexer = multiplexer;
 52437        _database = multiplexer.GetDatabase();
 52438        _keys = new RedisKeySchema(options.Value.KeyPrefix);
 52439        _logger = logger;
 52440        _timeProvider = timeProvider ?? TimeProvider.System;
 52441    }
 42
 43    /// <inheritdoc />
 44    public async Task SaveAsync(
 45        string correlationId,
 46        RecoveryState state,
 47        TimeSpan ttl,
 48        CancellationToken cancellationToken = default)
 49    {
 71050        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 70851        ArgumentNullException.ThrowIfNull(state);
 70652        if (ttl <= TimeSpan.Zero)
 253            throw new ArgumentOutOfRangeException(nameof(ttl), "TTL must be greater than zero.");
 70454        if (!string.Equals(state.CorrelationId, correlationId, StringComparison.Ordinal))
 255            throw new ArgumentException("The recovery-state correlation id must match the store key.", nameof(state));
 70256        if (state.SchemaVersion != RecoveryStateSchema.Current)
 257            throw new ArgumentException("The recovery state must use the current schema version.", nameof(state));
 58
 70059        cancellationToken.ThrowIfCancellationRequested();
 69860        if (state.RegistrationId == Guid.Empty)
 261            state.RegistrationId = Guid.NewGuid();
 62
 69863        var recoveryKey = _keys.RecoveryKey(correlationId);
 64
 65        // Optimistic read-modify-write: two waiters registering the same correlation id
 66        // concurrently must both survive, so each write commits only while the stored value is
 67        // still the one we read (transaction condition), retrying on a conflict.
 141668        for (var attempt = 0; attempt < MaxCasAttempts; attempt++)
 69        {
 70670            cancellationToken.ThrowIfCancellationRequested();
 71
 70672            var nowUtc = _timeProvider.GetUtcNow();
 70673            var previous = await _database.StringGetAsync(recoveryKey).ConfigureAwait(false);
 70674            var (entries, legacy) = previous.IsNullOrEmpty
 70675                ? (new List<StoredRegistration>(), false)
 70676                : DeserializeEntries(previous, recoveryKey, correlationId, logAsError: true, nowUtc, preserveUnreadable:
 70477            if (legacy)
 78            {
 79                // Legacy blobs (a bare state list) carry no per-entry expiry — under that format
 80                // every save re-armed the whole key with a full TTL anyway, so re-stamping each
 81                // entry with this save's full TTL preserves that ceiling exactly once; from here
 82                // on the blob is enveloped and expiry is per entry.
 3683                foreach (var entry in entries)
 1084                    entry.ExpiresAtUtc = nowUtc + ttl;
 85            }
 86
 72587            entries.RemoveAll(existing => existing.State?.RegistrationId == state.RegistrationId);
 70488            entries.Add(new StoredRegistration { State = state, ExpiresAtUtc = nowUtc + ttl });
 89
 70490            var transaction = _database.CreateTransaction();
 70491            transaction.AddCondition(previous.IsNull
 70492                ? Condition.KeyNotExists(recoveryKey)
 70493                : Condition.StringEqual(recoveryKey, previous));
 94            // The key must outlive its longest-lived entry and no more: a fresh full TTL here
 95            // would re-extend every co-located registration's physical lifetime on each save.
 70496            _ = transaction.StringSetAsync(recoveryKey, SerializeEntries(entries), MaxRemaining(entries, nowUtc));
 70497            if (await transaction.ExecuteAsync().ConfigureAwait(false))
 69498                return;
 99        }
 100
 2101        throw new InvalidOperationException(
 2102            $"Recovery-state save for correlationId '{correlationId}' could not commit after {MaxCasAttempts} optimistic
 694103    }
 104
 105    /// <inheritdoc />
 106    public async Task<IReadOnlyList<RecoveryState>> GetAllAsync(string correlationId, CancellationToken cancellationToke
 107    {
 82108        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 80109        cancellationToken.ThrowIfCancellationRequested();
 110
 78111        var recoveryKey = _keys.RecoveryKey(correlationId);
 78112        return await LoadStatesAsync(recoveryKey, correlationId).ConfigureAwait(false);
 76113    }
 114
 115    /// <inheritdoc />
 116    public async Task<bool> TryDeleteAsync(string correlationId, Guid registrationId, CancellationToken cancellationToke
 117    {
 428118        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 424119        if (registrationId == Guid.Empty)
 2120            throw new ArgumentException("Registration id cannot be empty.", nameof(registrationId));
 422121        cancellationToken.ThrowIfCancellationRequested();
 122
 418123        var recoveryKey = _keys.RecoveryKey(correlationId);
 124
 125        // Optimistic removal: deleting one registration must not clobber a registration that a
 126        // concurrent writer appended between our read and our write.
 856127        for (var attempt = 0; attempt < MaxCasAttempts; attempt++)
 128        {
 426129            cancellationToken.ThrowIfCancellationRequested();
 130
 426131            var nowUtc = _timeProvider.GetUtcNow();
 426132            var previous = await _database.StringGetAsync(recoveryKey).ConfigureAwait(false);
 426133            if (previous.IsNullOrEmpty)
 2134                return false;
 135
 424136            var (entries, legacy) = DeserializeEntries(previous, recoveryKey, correlationId, logAsError: true, nowUtc, p
 867137            var removed = entries.RemoveAll(entry => entry.State?.RegistrationId == registrationId) > 0;
 424138            if (!removed)
 4139                return false;
 140
 420141            var transaction = _database.CreateTransaction();
 420142            transaction.AddCondition(Condition.StringEqual(recoveryKey, previous));
 420143            if (entries.Count == 0)
 144            {
 399145                _ = transaction.KeyDeleteAsync(recoveryKey);
 146            }
 21147            else if (legacy)
 148            {
 149                // Legacy blobs keep their shape and key TTL here: only SaveAsync migrates to the
 150                // enveloped shape, because it alone has a TTL to stamp the survivors with.
 12151                _ = transaction.StringSetAsync(
 12152                    recoveryKey,
 24153                    AsyncResponseJson.Serialize(entries.ConvertAll(entry => entry.State!).FindAll(static state => state 
 12154                    Expiration.KeepTtl,
 12155                    ValueCondition.Always);
 156            }
 157            else
 158            {
 159                // Shrink the key to its longest surviving entry: keeping the previous TTL would
 160                // hold the key alive long after the removed registration — possibly the only
 161                // long-lived one — is gone.
 9162                _ = transaction.StringSetAsync(recoveryKey, SerializeEntries(entries), MaxRemaining(entries, nowUtc));
 163            }
 420164            if (await transaction.ExecuteAsync().ConfigureAwait(false))
 410165                return true;
 166        }
 167
 168        // Leave the registration for expiry rather than risking a lost concurrent registration
 169        // with an unconditional rewrite; the caller treats false as "nothing deleted".
 2170        _logger.LogWarning(
 2171            "Recovery-state delete for correlationId {CorrelationId} registration {RegistrationId} exhausted {Attempts} 
 2172            correlationId, registrationId, MaxCasAttempts);
 2173        return false;
 418174    }
 175
 176    /// <inheritdoc />
 177    public async IAsyncEnumerable<RecoveryState> ScanAsync([EnumeratorCancellation] CancellationToken cancellationToken 
 178    {
 105179        var seenKeys = new HashSet<string>(StringComparer.Ordinal);
 105180        var batch = new List<string>(ScanReadBatchSize);
 181
 358182        foreach (var server in await ResolveScanTargetsAsync().ConfigureAwait(false))
 183        {
 184            // KeysAsync, not Keys: the synchronous enumerator blocked its thread on every SCAN
 185            // page of what is, by definition, a walk of the whole keyspace.
 2064186            await foreach (var key in server.KeysAsync(pattern: _keys.RecoveryKeyPattern, pageSize: ScanPageSize)
 87187                               .WithCancellation(cancellationToken)
 87188                               .ConfigureAwait(false))
 189            {
 946190                cancellationToken.ThrowIfCancellationRequested();
 191
 944192                var recoveryKey = key.ToString();
 944193                if (!seenKeys.Add(recoveryKey))
 194                    continue;
 195
 942196                batch.Add(recoveryKey);
 942197                if (batch.Count < ScanReadBatchSize)
 198                    continue;
 199
 1548200                foreach (var state in await ReadScanBatchAsync(batch, cancellationToken).ConfigureAwait(false))
 768201                    yield return state;
 6202                batch.Clear();
 203            }
 204
 85205            if (batch.Count == 0)
 206                continue;
 207
 376208            foreach (var state in await ReadScanBatchAsync(batch, cancellationToken).ConfigureAwait(false))
 164209                yield return state;
 23210            batch.Clear();
 211        }
 79212    }
 213
 214    private const int ScanPageSize = 250;
 215
 216    /// <summary>
 217    /// Values read per pipelined batch. Each registration blob used to be awaited before the next
 218    /// GET was even sent — one network round trip per key, so a 100,000-key scan at 2 ms spent
 219    /// over three minutes on latency alone. The reads are independent, so a batch is issued
 220    /// back to back on the multiplexer's pipeline and awaited together; the bound keeps the scan
 221    /// streaming (the watchdog buffers only the fields it classifies on) instead of holding the
 222    /// keyspace's values in memory. Individual GETs rather than one MGET: on a cluster the keys of
 223    /// a batch hash to different slots, and the multiplexer routes each GET to its own shard.
 224    /// </summary>
 225    private const int ScanReadBatchSize = 128;
 226
 227    private async Task<List<RecoveryState>> ReadScanBatchAsync(List<string> recoveryKeys, CancellationToken cancellation
 228    {
 31229        cancellationToken.ThrowIfCancellationRequested();
 230
 31231        var reads = new Task<RedisValue>[recoveryKeys.Count];
 1946232        for (var i = 0; i < reads.Length; i++)
 942233            reads[i] = _database.StringGetAsync(recoveryKeys[i]);
 234
 235        // A failed read fails the scan (see ResolveScanTargets): skipping the key would report
 236        // the registrations behind it as absent.
 31237        var values = await Task.WhenAll(reads).ConfigureAwait(false);
 238
 29239        var states = new List<RecoveryState>(values.Length);
 29240        var nowUtc = _timeProvider.GetUtcNow();
 1934241        for (var i = 0; i < values.Length; i++)
 242        {
 938243            if (values[i].IsNullOrEmpty)
 244                continue;
 245
 936246            var recoveryKey = recoveryKeys[i];
 936247            var correlationId = _keys.CorrelationIdFromRecoveryKey(recoveryKey);
 936248            var (entries, _) = DeserializeEntries(values[i], recoveryKey, correlationId, logAsError: false, nowUtc);
 3736249            foreach (var entry in entries)
 932250                states.Add(entry.State!);
 251        }
 252
 29253        return states;
 29254    }
 255
 256    /// <summary>
 257    /// The servers whose keyspaces make up a complete scan — or an exception when that set cannot
 258    /// be inspected. Disconnected servers used to be filtered out silently, so with Redis down
 259    /// the scan "succeeded" over zero servers: the watchdog published an empty report with no
 260    /// error and the recovery health check went from Degraded to Healthy BECAUSE of the outage.
 261    /// An empty keyspace and an unreadable one are different answers; only the first is a scan.
 262    /// <para>
 263    /// Primaries only. Every replica holds a copy of the same keys, so scanning them too walked
 264    /// the keyspace once per node and produced nothing the primary had not already yielded —
 265    /// the dedupe hid the duplicate entries but not the round trips. It also aimed a full
 266    /// keyspace scan at nodes that exist to serve reads cheaply. On a single-node deployment
 267    /// this changes nothing: that node is the primary.
 268    /// </para>
 269    /// </summary>
 270    private async Task<List<IServer>> ResolveScanTargetsAsync()
 271    {
 105272        var primaries = new List<IServer>();
 105273        List<IServer>? unreachable = null;
 105274        List<IServer>? connectedReplicas = null;
 484275        foreach (var endPoint in _multiplexer.GetEndPoints())
 276        {
 137277            var server = _multiplexer.GetServer(endPoint);
 137278            if (server.IsReplica)
 279            {
 6280                if (server.IsConnected)
 2281                    (connectedReplicas ??= []).Add(server);
 2282                continue;
 283            }
 284
 131285            if (server.IsConnected)
 101286                primaries.Add(server);
 287            else
 30288                (unreachable ??= []).Add(server);
 289        }
 290
 105291        if (primaries.Count == 0)
 292        {
 8293            throw ScanUnavailable(
 8294                "Recovery-state scan failed: no Redis primary is connected, so the persisted registrations cannot be rea
 8295                "This is an unavailable scan, not an empty keyspace.");
 296        }
 297
 298        // A cluster shards the keyspace: every primary holds registrations no other primary has,
 299        // so one that cannot be inspected makes the scan partial. Outside a cluster every primary
 300        // the multiplexer knows serves the same dataset (a failed-over deployment lists the old
 301        // primary as disconnected until it rejoins as a replica), and one connected primary is
 302        // the whole keyspace.
 194303        if (!primaries.Exists(static primary => primary.ServerType == ServerType.Cluster))
 79304            return primaries;
 305
 306        // The multiplexer's view of who is a primary is only as good as each node's last
 307        // handshake, in BOTH directions, so a cluster scan is checked against the cluster's own
 308        // node table (CLUSTER NODES — one small command next to a walk of the whole keyspace).
 309        // Conservative in every unknown: a table that cannot be read or lists nothing changes
 310        // nothing below, and the multiplexer's view decides as before.
 18311        var nodes = await ReadClusterNodeTableAsync(primaries).ConfigureAwait(false);
 312
 18313        if (unreachable is not null)
 314        {
 315            // A node that has NEVER connected since this process started (a replica that was down
 316            // at startup) still reports the default — not a replica — so it landed here as an
 317            // "unreachable primary" and failed every scan of a cluster whose slot owners were all
 318            // reachable. Only a node that owns slots can make the scan partial; a node the table
 319            // does not list stays unreachable.
 16320            if (nodes is not null)
 10321                unreachable = unreachable.FindAll(server => !RedisClusterNodeTable.OwnsNoSlots(nodes, server.EndPoint));
 322
 16323            if (unreachable.Count > 0)
 324            {
 14325                throw ScanUnavailable(
 14326                    $"Recovery-state scan failed: Redis cluster primary {string.Join(", ", unreachable.Select(static ser
 14327                    "so the registrations in its slots cannot be read. A partial scan is reported as failed rather than 
 328            }
 329        }
 330
 4331        if (nodes is not null)
 2332            RequireSlotCoverage(nodes, primaries, connectedReplicas);
 333
 4334        return primaries;
 83335    }
 336
 337    private async Task<List<RedisClusterNodeTable.Node>?> ReadClusterNodeTableAsync(List<IServer> primaries)
 338    {
 72339        foreach (var primary in primaries)
 340        {
 20341            if (primary.ServerType == ServerType.Cluster
 20342                && await RedisClusterNodeTable.TryReadAsync(primary, _logger).ConfigureAwait(false) is { } nodes)
 4343                return nodes;
 344        }
 345
 14346        return null;
 18347    }
 348
 349    /// <summary>
 350    /// Every slot owner the node table lists must be one of the servers about to be scanned.
 351    /// Excusing the unreachable nodes that own nothing is not the same as knowing every shard is
 352    /// covered: after a failover the old primary is listed WITHOUT slots (excused, correctly),
 353    /// while the promoted node can still carry the multiplexer's pre-failover "replica" flag and
 354    /// be skipped as one — so the scan walked the remaining shards, found them healthy, and
 355    /// reported a complete, possibly empty keyspace with a whole shard's registrations unread. A
 356    /// connected node the table names as a slot owner is scanned whatever the stale flag says; a
 357    /// slot owner with no connected server at all fails the scan, like any other partial one.
 358    /// </summary>
 359    private static void RequireSlotCoverage(List<RedisClusterNodeTable.Node> nodes, List<IServer> primaries, List<IServe
 360    {
 2361        List<string>? uncovered = null;
 16362        foreach (var node in nodes)
 363        {
 12364            if (!node.IsSlotOwner || primaries.Exists(primary => RedisClusterNodeTable.IsSameNode(node, primary.EndPoint
 365                continue;
 366
 0367            if (connectedReplicas?.Find(server => RedisClusterNodeTable.IsSameNode(node, server.EndPoint)) is { } promot
 0368                primaries.Add(promoted);
 369            else
 0370                (uncovered ??= []).Add($"{node.Address}:{node.Port}");
 371        }
 372
 2373        if (uncovered is not null)
 374        {
 0375            throw ScanUnavailable(
 0376                $"Recovery-state scan failed: Redis cluster slot owner {string.Join(", ", uncovered)} has no connected s
 0377                "so the registrations in its slots cannot be read. A partial scan is reported as failed rather than as a
 378        }
 2379    }
 380
 381    private static RedisConnectionException ScanUnavailable(string message)
 22382        => new(ConnectionFailureType.UnableToConnect, CommandFlags.None, message, innerException: null, CommandStatus.Un
 383
 384    private const int MaxCasAttempts = 4;
 385
 386    private async Task<List<RecoveryState>> LoadStatesAsync(string recoveryKey, string correlationId)
 387    {
 78388        var value = await _database.StringGetAsync(recoveryKey).ConfigureAwait(false);
 78389        if (value.IsNullOrEmpty)
 11390            return [];
 391
 67392        var now = _timeProvider.GetUtcNow();
 67393        var (entries, _) = DeserializeEntries(value, recoveryKey, correlationId, logAsError: true, now);
 67394        if (entries.Count > 0)
 132395            return entries.ConvertAll(entry => entry.State!);
 396
 397        // The key held a blob and nothing readable came out of it. That must not read as "no
 398        // registration was ever armed" — the dispatcher acknowledges the response on that answer,
 399        // consuming a terminal response whose callback never ran.
 400        //
 401        // Expiry is the exception and has to be told apart here, because DeserializeEntries drops
 402        // lapsed entries by the same route it drops unreadable ones: a registration past its expiry
 403        // is legitimately gone, and failing on it would redeliver forever against a record that is
 404        // supposed to disappear.
 4405        if (CountStoredRegistrations(value, out var stored) && stored > 0)
 2406            throw new RecoveryStateUnreadableException(correlationId, stored);
 407
 2408        return [];
 76409    }
 410
 411    /// <summary>
 412    /// Counts the registrations physically present in the blob that are NOT past their expiry,
 413    /// without applying the readability rules. The gap between this and what
 414    /// <see cref="DeserializeEntries"/> returned is exactly the unreadable set. Returns
 415    /// <c>false</c> when the blob itself will not parse at all — in which case every registration it
 416    /// held is unreadable by definition, and the caller is told so via <paramref name="stored"/>.
 417    /// </summary>
 418    private bool CountStoredRegistrations(RedisValue value, out int stored)
 419    {
 4420        var json = value.ToString();
 4421        var now = _timeProvider.GetUtcNow();
 422        try
 423        {
 4424            if (IsLegacyShape(json))
 425            {
 426                // Legacy blobs carry no per-entry expiry; every element is a live registration.
 0427                stored = JsonSafety.SafeDeserialize(json, _legacyTypeInfo)?.Count ?? 0;
 0428                return true;
 429            }
 430
 4431            var parsed = JsonSafety.SafeDeserialize(json, _envelopeTypeInfo);
 2432            var registrations = parsed?.Registrations;
 2433            if (registrations is null)
 434            {
 2435                stored = 0;
 2436                return true;
 437            }
 438
 0439            stored = 0;
 0440            foreach (var entry in registrations)
 441            {
 0442                if (entry is not null && entry.ExpiresAtUtc > now)
 0443                    stored++;
 444            }
 445
 0446            return true;
 447        }
 2448        catch (Exception ex) when (ex is JsonException or InvalidDataException)
 449        {
 450            // Unparseable at the top level: the blob exists and holds an unknown number of
 451            // registrations, all of them unreadable. One is enough to fail the delivery.
 2452            stored = 1;
 2453            return true;
 454        }
 4455    }
 456
 457    /// <summary>
 458    /// The package-local envelope metadata CHAINED behind the library's resolver, not used alone.
 459    /// A callback argument is <see cref="CallbackParam"/>.Value, typed <c>object</c>, so it
 460    /// serializes by runtime type — and the source generator only emitted what this envelope
 461    /// references transitively (string, int, Guid, DateTime). On its own the context therefore
 462    /// threw NotSupportedException at waiter registration for a perfectly ordinary literal
 463    /// (a bool, a long, an enum, a DTO), on these two channels only, and bypassed
 464    /// AsyncResponseJsonSerialization.RegisterResolver — the documented trim/AOT seam — entirely.
 465    /// The wire format is unchanged: the envelope's own metadata still resolves first.
 466    /// </summary>
 12467    private static readonly JsonSerializerOptions _envelopeOptions = new()
 12468    {
 12469        TypeInfoResolver = JsonTypeInfoResolver.Combine(RedisChannelJsonContext.Default, AsyncResponseJson.Resolver)
 12470    };
 471
 472    /// <summary>The envelope's metadata off the chained options — the JsonTypeInfo overloads keep this trim/AOT-clean.<
 473    /// <summary>Legacy bare-array blobs, read with the same case-sensitive matching as before.</summary>
 12474    private static readonly JsonTypeInfo<List<RecoveryState>> _legacyTypeInfo =
 12475        AsyncResponseJson.GetTypeInfo<List<RecoveryState>>(AsyncResponseJson.Default);
 476
 12477    private static readonly JsonTypeInfo<StoredRecoveryState> _envelopeTypeInfo =
 12478        AsyncResponseJson.GetTypeInfo<StoredRecoveryState>(_envelopeOptions);
 479
 480    private static RedisValue SerializeEntries(List<StoredRegistration> entries)
 713481        => JsonSerializer.Serialize(
 713482            new StoredRecoveryState { Registrations = entries },
 713483            _envelopeTypeInfo);
 484
 485    private static TimeSpan MaxRemaining(List<StoredRegistration> entries, DateTimeOffset nowUtc)
 486    {
 713487        var maxExpiresAtUtc = DateTimeOffset.MinValue;
 2890488        foreach (var entry in entries)
 489        {
 732490            if (entry.ExpiresAtUtc > maxExpiresAtUtc)
 718491                maxExpiresAtUtc = entry.ExpiresAtUtc;
 492        }
 493
 713494        return maxExpiresAtUtc - nowUtc;
 495    }
 496
 497    /// <summary>
 498    /// Deserializes the stored blob. <c>preserveUnreadable</c>: write paths pass true. A read filters out an entry this
 499    /// schema version, a null state, a blank registration id), but a read-modify-write must carry
 500    /// it through untouched: rewriting the shared blob from the readable subset silently deleted a
 501    /// sibling registration written by a newer host mid-rolling-upgrade — the write path treating
 502    /// "unreadable" as "missing", which is exactly what the read path was hardened to refuse.
 503    /// Expired entries are still pruned either way; those are genuinely gone.
 504    /// </summary>
 505    private (List<StoredRegistration> Entries, bool Legacy) DeserializeEntries(
 506        RedisValue value,
 507        string recoveryKey,
 508        string correlationId,
 509        bool logAsError,
 510        DateTimeOffset nowUtc,
 511        bool preserveUnreadable = false,
 512        bool throwOnUnreadableEnvelope = false)
 513    {
 1450514        var json = value.ToString();
 515        try
 516        {
 517            // Legacy blobs are a bare JSON array of states; enveloped blobs are an object. The
 518            // first significant character tells the shapes apart without a speculative parse.
 1450519            if (IsLegacyShape(json))
 520            {
 106521                var states = JsonSafety.SafeDeserialize(json, _legacyTypeInfo) ?? [];
 106522                var legacyEntries = new List<StoredRegistration>(states.Count);
 480523                foreach (var state in states)
 524                {
 525                    // A legacy entry has no expiry of its own; it lives until the key's TTL, as it
 526                    // always did (SaveAsync stamps one when it rewrites the blob enveloped).
 134527                    if (preserveUnreadable || IsStateReadable(state, recoveryKey, correlationId))
 126528                        legacyEntries.Add(new StoredRegistration { State = state });
 529                }
 530
 106531                return (legacyEntries, true);
 532            }
 533
 1344534            var stored = JsonSafety.SafeDeserialize(json, _envelopeTypeInfo);
 1336535            var entries = stored?.Registrations ?? [];
 2683536            entries.RemoveAll(entry => entry is null || (!preserveUnreadable && !IsStateReadable(entry.State, recoveryKe
 537            // An entry past its per-entry expiry is logically gone even while a longer-lived
 538            // sibling keeps the key alive; surfacing it would fire recovery callbacks for a
 539            // registration that lapsed long ago.
 2683540            entries.RemoveAll(entry => entry.ExpiresAtUtc <= nowUtc);
 1336541            return (entries, false);
 542        }
 8543        catch (Exception ex) when (ex is JsonException or InvalidDataException)
 544        {
 545            // Through JsonSafety, so `ex` is the body-free rebuild (size and position), never the
 546            // reader's own message: that one appends `Path: $.States[0].Context['<key>']` built
 547            // from the stored registration's context keys — tenant and auth baggage — and this
 548            // log line is what carried them into the application log.
 8549            if (logAsError)
 6550                _logger.LogError(ex, "Failed to deserialize recovery state at {RecoveryKey}.", recoveryKey);
 551            else
 2552                _logger.LogWarning(ex, "Unreadable recovery state at {RecoveryKey}; skipping.", recoveryKey);
 553
 554            // The rewrite path must refuse, not overwrite: an empty result here would make
 555            // SaveAsync commit just the new registration over a blob whose registrations it could
 556            // not even ENUMERATE, destroying every armed callback the blob held — "unreadable"
 557            // read as "missing", which is exactly what the read path was hardened to refuse.
 8558            if (throwOnUnreadableEnvelope)
 2559                throw new RecoveryStateUnreadableException(correlationId, 1);
 560
 6561            return ([], false);
 562        }
 1448563    }
 564
 565    private static bool IsLegacyShape(string json)
 566    {
 4362567        foreach (var ch in json)
 568        {
 1454569            if (char.IsWhiteSpace(ch))
 570                continue;
 1454571            return ch == '[';
 572        }
 573
 0574        return false;
 575    }
 576
 577    private bool IsStateReadable(RecoveryState? state, string recoveryKey, string correlationId)
 578    {
 1013579        if (state is null || state.RegistrationId == Guid.Empty)
 580        {
 4581            _logger.LogWarning(
 4582                "Recovery state at {RecoveryKey} has no registration id; rejecting it because it cannot be deleted safel
 4583                recoveryKey);
 4584            return false;
 585        }
 586
 1009587        if (!RecoveryStateSchema.IsReadable(state.SchemaVersion))
 588        {
 2589            _logger.LogWarning(
 2590                "Recovery state at {RecoveryKey} has unsupported schema version {SchemaVersion} (current: {Current}); re
 2591                recoveryKey, state.SchemaVersion, RecoveryStateSchema.Current);
 2592            return false;
 593        }
 594
 1007595        if (string.Equals(state.CorrelationId, correlationId, StringComparison.Ordinal))
 1005596            return true;
 597
 2598        _logger.LogWarning(
 2599            "Recovery state at {RecoveryKey} has correlationId {StoredCorrelationId}, expected {CorrelationId}; rejectin
 2600            recoveryKey, state.CorrelationId, correlationId);
 2601        return false;
 602    }
 603
 604    /// <summary>
 605    /// The stored envelope: an object (legacy blobs were a bare array, which is how the two
 606    /// shapes are told apart) holding every registration with its own absolute expiry.
 607    /// </summary>
 608    internal sealed class StoredRecoveryState
 609    {
 4094610        public List<StoredRegistration>? Registrations { get; set; }
 611    }
 612
 613    /// <summary>One registration and the absolute expiry of the save that wrote it.</summary>
 614    internal sealed class StoredRegistration
 615    {
 5333616        public RecoveryState? State { get; set; }
 5590617        public DateTimeOffset ExpiresAtUtc { get; set; }
 618    }
 619}
 620
 621/// <summary>
 622/// Source-generated metadata for the package-local recovery envelope (trim/AOT-safe; Metadata-mode
 623/// generation with default options serializes the nested <see cref="RecoveryState"/> exactly like
 624/// the reflection-based path did).
 625/// </summary>
 626[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
 627[JsonSerializable(typeof(RedisRecoveryStateStore.StoredRecoveryState))]
 628internal sealed partial class RedisChannelJsonContext : JsonSerializerContext;