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

Information
Class: AsyncResponse.Channels.SqlServer.SqlServerRecoveryStateStore
Assembly: AsyncResponse.Channels.SqlServer
File(s): /_/src/Channels/AsyncResponse.Channels.SqlServer/SqlServerRecoveryStateStore.cs
Line coverage
100%
Covered lines: 75
Uncovered lines: 0
Coverable lines: 75
Total lines: 155
Line coverage: 100%
Branch coverage
90%
Covered branches: 36
Total branches: 40
Branch coverage: 90%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
SaveAsync()62.5%9876.92%
GetAllAsync()100%11100%
TryDeleteAsync(...)50%2280%
ScanAsync()100%44100%
DeserializeStates(...)100%1010100%
.cctor()100%11100%
DeserializeState(...)87.5%1616100%

File(s)

/_/src/Channels/AsyncResponse.Channels.SqlServer/SqlServerRecoveryStateStore.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using System.Runtime.CompilerServices;
 3using System.Text.Json;
 4using System.Text.Json.Serialization.Metadata;
 5
 6namespace AsyncResponse.Channels.SqlServer;
 7
 8/// <summary>
 9/// Microsoft SQL Server implementation of <see cref="IRecoveryStateStore"/> and
 10/// <see cref="IRecoveryStateScanner"/>.
 11/// </summary>
 38612internal sealed class SqlServerRecoveryStateStore(
 38613    SqlServerChannelSql _sql,
 38614    ILogger<SqlServerRecoveryStateStore> _logger) : IRecoveryStateStore, IRecoveryStateScanner
 15{
 16    /// <inheritdoc />
 17    public async Task SaveAsync(string correlationId, RecoveryState state, TimeSpan ttl, CancellationToken cancellationT
 18    {
 47919        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 47920        ArgumentNullException.ThrowIfNull(state);
 47921        if (ttl <= TimeSpan.Zero)
 222            throw new ArgumentOutOfRangeException(nameof(ttl), "TTL must be greater than zero.");
 47723        if (!string.Equals(state.CorrelationId, correlationId, StringComparison.Ordinal))
 424            throw new ArgumentException("The recovery-state correlation id must match the store key.", nameof(state));
 47325        if (state.SchemaVersion != RecoveryStateSchema.Current)
 226            throw new ArgumentException("The recovery state must use the current schema version.", nameof(state));
 27
 47128        cancellationToken.ThrowIfCancellationRequested();
 47129        if (state.RegistrationId == Guid.Empty)
 630            state.RegistrationId = Guid.NewGuid();
 31
 47132        await _sql.SaveRecoveryStateAsync(correlationId, state, ttl, cancellationToken).ConfigureAwait(false);
 47133    }
 34
 35    /// <inheritdoc />
 36    public async Task<IReadOnlyList<RecoveryState>> GetAllAsync(string correlationId, CancellationToken cancellationToke
 37    {
 3738        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 3739        cancellationToken.ThrowIfCancellationRequested();
 40
 3741        var jsonStates = await _sql.LoadRecoveryStatesAsync(correlationId, cancellationToken).ConfigureAwait(false);
 3742        return DeserializeStates(jsonStates, correlationId);
 3543    }
 44
 45    /// <inheritdoc />
 46    public Task<bool> TryDeleteAsync(string correlationId, Guid registrationId, CancellationToken cancellationToken = de
 47    {
 47548        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 47549        if (registrationId == Guid.Empty)
 250            throw new ArgumentException("Registration id cannot be empty.", nameof(registrationId));
 47351        cancellationToken.ThrowIfCancellationRequested();
 47352        return _sql.DeleteRecoveryStateAsync(correlationId, registrationId, cancellationToken);
 53    }
 54
 55    /// <inheritdoc />
 56    public async IAsyncEnumerable<RecoveryState> ScanAsync([EnumeratorCancellation] CancellationToken cancellationToken 
 57    {
 458        await foreach (var json in _sql.ScanRecoveryStateJsonAsync(cancellationToken).ConfigureAwait(false))
 59        {
 160            var ignored = 0;
 161            var state = DeserializeState(json, correlationId: null, ref ignored);
 162            if (state is not null)
 163                yield return state;
 64        }
 165    }
 66
 67    private IReadOnlyList<RecoveryState> DeserializeStates(IReadOnlyList<string> jsonStates, string correlationId)
 68    {
 3769        if (jsonStates.Count == 0)
 1270            return [];
 71
 2572        var states = new List<RecoveryState>(jsonStates.Count);
 2573        var unreadable = 0;
 10074        foreach (var json in jsonStates)
 75        {
 2576            var state = DeserializeState(json, correlationId, ref unreadable);
 2577            if (state is not null)
 2378                states.Add(state);
 79        }
 80
 81        // Rows existed and none of them survived materialization. Returning an empty list here told
 82        // the dispatcher "no recovery callback was ever armed", which it answers by acknowledging
 83        // the response — so a corrupt or newer-schema registration silently consumed a terminal
 84        // response its callback never saw. Fail instead, and let redelivery reach a build that can
 85        // read it. A PARTIALLY readable batch deliberately does not throw: see
 86        // RecoveryStateUnreadableException.
 87        // Only rows this build could not INTERPRET count. A row rejected for belonging to another
 88        // correlation id is perfectly readable — it surfaced because a legacy case-insensitive
 89        // collation matched the wrong key, and refusing it is the ordinal re-check doing its job.
 90        // For the id actually asked about, that is absence, not corruption, and absence must stay
 91        // an empty list.
 2592        if (states.Count == 0 && unreadable > 0)
 293            throw new RecoveryStateUnreadableException(correlationId, unreadable);
 94
 2395        return states;
 96    }
 97
 98    /// <summary>The registration's metadata off the library's resolver — case-sensitive matching, as before.</summary>
 399    private static readonly JsonTypeInfo<RecoveryState> _stateTypeInfo =
 3100        AsyncResponseJson.GetTypeInfo<RecoveryState>(AsyncResponseJson.Default);
 101
 102    private RecoveryState? DeserializeState(string json, string? correlationId, ref int unreadable)
 103    {
 104        try
 105        {
 106            // Through JsonSafety, not the raw reader: the exception logged below is the body-free
 107            // rebuild (size and position). The reader's own appends `Path: $.Context['<key>']`
 108            // built from the stored registration's context keys — tenant and auth baggage — which
 109            // the warning then carried into the application log.
 58110            var state = JsonSafety.SafeDeserialize(json, _stateTypeInfo);
 49111            if (state is null)
 112            {
 4113                unreadable++;
 4114                return null;
 115            }
 116
 45117            if (state.RegistrationId == Guid.Empty || string.IsNullOrWhiteSpace(state.CorrelationId))
 118            {
 6119                _logger.LogWarning(
 6120                    "SQL Server recovery state for correlationId {CorrelationId} has an incomplete identity; rejecting i
 6121                    correlationId ?? state.CorrelationId);
 6122                unreadable++;
 6123                return null;
 124            }
 125
 39126            if (!RecoveryStateSchema.IsReadable(state.SchemaVersion))
 127            {
 3128                _logger.LogWarning(
 3129                    "SQL Server recovery state for correlationId {CorrelationId} has unsupported schema version {SchemaV
 3130                    correlationId ?? state.CorrelationId,
 3131                    state.SchemaVersion,
 3132                    RecoveryStateSchema.Current);
 3133                unreadable++;
 3134                return null;
 135            }
 136
 36137            if (!string.IsNullOrWhiteSpace(correlationId)
 36138                && !string.Equals(state.CorrelationId, correlationId, StringComparison.Ordinal))
 139            {
 6140                _logger.LogWarning(
 6141                    "SQL Server recovery state has correlationId {StoredCorrelationId}, expected {CorrelationId}; reject
 6142                    state.CorrelationId, correlationId);
 6143                return null;
 144            }
 145
 30146            return state;
 147        }
 9148        catch (Exception ex) when (ex is JsonException or InvalidDataException)
 149        {
 9150            _logger.LogWarning(ex, "Unreadable SQL Server recovery state for correlationId {CorrelationId}; skipping.", 
 9151            unreadable++;
 9152            return null;
 153        }
 58154    }
 155}