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

Information
Class: AsyncResponse.Channels.SqlServer.SqlServerAsyncResponseChannelOptions
Assembly: AsyncResponse.Channels.SqlServer
File(s): /_/src/Channels/AsyncResponse.Channels.SqlServer/SqlServerAsyncResponseChannelOptions.cs
Line coverage
100%
Covered lines: 66
Uncovered lines: 0
Coverable lines: 66
Total lines: 199
Line coverage: 100%
Branch coverage
100%
Covered branches: 20
Total branches: 20
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1namespace AsyncResponse.Channels.SqlServer;
 2
 3/// <summary>
 4/// Options for the Microsoft SQL Server-backed async-response channel.
 5/// <para>
 6/// SQL Server has no <c>LISTEN/NOTIFY</c>, so active waiters are woken by an adaptive polling sweep:
 7/// while any waiter is subscribed the dispatch loop scans the message table every
 8/// <see cref="ActivePollInterval"/>, and with no waiters it backs off to <see cref="IdlePollInterval"/>.
 9/// Same-process publishes bypass the sweep and deliver immediately. Response envelopes are stored in
 10/// a table; durable <see cref="RecoveryState"/> entries live in a separate table so late responses
 11/// can resume or fail flows after the original waiter process dies.
 12/// </para>
 13/// </summary>
 14public sealed class SqlServerAsyncResponseChannelOptions : DurableAsyncResponseChannelOptions
 15{
 16    /// <summary>The channel name reported to the startup validator.</summary>
 17    public const string ChannelName = "SqlServer";
 18
 19    /// <summary>
 20    /// SQL Server connection string used for every channel operation. Required. The database it
 21    /// targets must already exist; the channel creates only its schema, tables, and indexes.
 22    /// </summary>
 189223    public string? ConnectionString { get; set; }
 24
 25    /// <summary>Database schema that contains the channel tables. Default: <c>dbo</c>.</summary>
 596626    public string SchemaName { get; set; } = "dbo";
 27
 28    /// <summary>
 29    /// Table storing durable recovery registrations. Each waiter registration is one row keyed by
 30    /// correlation id and registration id.
 31    /// </summary>
 474832    public string RecoveryStateTable { get; set; } = "asyncresponse_recovery_state";
 33
 34    /// <summary>
 35    /// Table storing response envelopes until they expire. The adaptive polling sweep loads pending
 36    /// envelopes from this table and delivers them to local waiters.
 37    /// </summary>
 980338    public string MessageTable { get; set; } = "asyncresponse_channel_messages";
 39
 40    /// <summary>
 41    /// Table storing short-lived live-subscriber heartbeats for watchdog liveness and the publish
 42    /// fast path.
 43    /// </summary>
 474144    public string SubscriberTable { get; set; } = "asyncresponse_channel_subscribers";
 45
 46    /// <summary>
 47    /// Creates the schema, tables, and indexes on first use. Disable when migrations provision them
 48    /// out of band.
 49    /// </summary>
 101250    public bool AutoCreateSchema { get; set; } = true;
 51
 52    /// <summary>
 53    /// How long response-envelope rows are retained for active waiter delivery and cross-process
 54    /// sweep recovery. Expired rows are pruned opportunistically during channel operations.
 55    /// </summary>
 964056    public TimeSpan MessageRetention { get; set; } = TimeSpan.FromHours(1);
 57
 58    /// <summary>
 59    /// How long a publisher waits for a live waiter to acknowledge loading a response envelope
 60    /// before treating the response as lost-subscriber delivery. Default: 5 seconds.
 61    /// </summary>
 344162    public TimeSpan DeliveryConfirmationTimeout { get; set; } = TimeSpan.FromSeconds(5);
 63
 64    /// <summary>
 65    /// Poll interval used while a publisher waits for delivery acknowledgement. Default: 50 ms.
 66    /// </summary>
 315867    public TimeSpan DeliveryConfirmationPollInterval { get; set; } = TimeSpan.FromMilliseconds(50);
 68
 69    /// <summary>
 70    /// Sweep interval used by the dispatch loop while at least one waiter is subscribed. This bounds
 71    /// the wake latency of a response published by another process, so keep it tight. Same-process
 72    /// deliveries do not wait for the sweep. Default: 250 ms.
 73    /// </summary>
 896174    public TimeSpan ActivePollInterval { get; set; } = TimeSpan.FromMilliseconds(250);
 75
 76    /// <summary>
 77    /// Sweep interval used by the dispatch loop while no waiters are subscribed, so an idle
 78    /// application does not hammer the database. Must be at least <see cref="ActivePollInterval"/>;
 79    /// a new waiter re-arms the tight interval immediately. Default: 2 seconds.
 80    /// </summary>
 563481    public TimeSpan IdlePollInterval { get; set; } = TimeSpan.FromSeconds(2);
 82
 83    /// <summary>
 84    /// Minimum interval between full safety-net sweeps. A full sweep queries the store once per
 85    /// subscribed correlation id, so its idle cost is W queries per poll tick with W in-flight
 86    /// waiters. CAUTION on this provider: SQL Server has no push wake, so the poll sweep IS
 87    /// cross-process delivery — raising this raises delivery latency for every response published
 88    /// from another process, not just lost-wake recovery. Leave null (the default: sweep on every
 89    /// poll tick) unless idle database load from many concurrent waiters outweighs that latency.
 90    /// </summary>
 683791    public TimeSpan? FullSweepInterval { get; set; }
 92
 93    /// <summary>
 94    /// Number of pending response messages loaded per subscribed correlation id per sweep pass.
 95    /// Default: 64.
 96    /// </summary>
 1407997    public int PendingMessageBatchSize { get; set; } = 64;
 98
 99    /// <summary>
 100    /// Minimum interval between incremental reconciliation passes over retained message history.
 101    /// Normal scans keep their forward cursor; reconciliation catches late commits behind it,
 102    /// including rows already acknowledged by another process. One history page is read per
 103    /// dispatch pass so a long history cannot monopolize delivery to other correlations.
 104    /// Default: 5 seconds; large histories take additional poll intervals to reconcile.
 105    /// </summary>
 1878106    public TimeSpan HistoryReconciliationInterval { get; set; } = TimeSpan.FromSeconds(5);
 107
 108
 109    /// <summary>
 110    /// How often a live waiter refreshes its subscriber heartbeat row. Default: 10 seconds.
 111    /// </summary>
 7373112    public TimeSpan SubscriberHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(10);
 113
 114    /// <summary>
 115    /// How long a subscriber heartbeat remains live without refresh. Keep this above
 116    /// <see cref="SubscriberHeartbeatInterval"/>. Default: 30 seconds.
 117    /// </summary>
 5091118    public TimeSpan SubscriberHeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(30);
 119
 120    /// <summary>
 121    /// Minimum interval between opportunistic prunes of expired channel rows. Pruning is housekeeping
 122    /// only (read queries filter on expiry), so throttling it keeps publishes off a full-table delete
 123    /// on every call. Set to <see cref="TimeSpan.Zero"/> to prune on every operation. Default: 30 seconds.
 124    /// </summary>
 3568125    public TimeSpan PruneInterval { get; set; } = TimeSpan.FromSeconds(30);
 126
 127    /// <summary>Maximum attempts for a response-row insert. Set to 1 to disable publish retries. Default: 3.</summary>
 2048128    public int PublishMaxAttempts { get; set; } = 3;
 129
 130    /// <summary>Initial delay before retrying a failed response-row insert. Default: 50 ms.</summary>
 2922131    public TimeSpan PublishRetryBaseDelay { get; set; } = TimeSpan.FromMilliseconds(50);
 132
 133    /// <summary>Maximum delay between response-row insert retries. Default: 1 second.</summary>
 2922134    public TimeSpan PublishRetryMaxDelay { get; set; } = TimeSpan.FromSeconds(1);
 135
 136    /// <summary>Validates the option values and throws on misconfiguration.</summary>
 137    public void Validate()
 138    {
 139        // Shared channel knobs (RecoveryStateExpiry, DefaultTimeout, DisposalDrainTimeout) go
 140        // through the ONE base guard set — a bespoke duplicate here silently missed every knob
 141        // added to the base later (DisposalDrainTimeout was validated nowhere on this provider).
 930142        ValidateShared(nameof(SqlServerAsyncResponseChannelOptions));
 143
 926144        if (string.IsNullOrWhiteSpace(ConnectionString))
 2145            throw new InvalidOperationException($"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(ConnectionStrin
 146
 924147        SqlServerChannelSql.ValidateIdentifier(SchemaName, nameof(SchemaName));
 920148        SqlServerChannelSql.ValidateIdentifier(RecoveryStateTable, nameof(RecoveryStateTable));
 920149        SqlServerChannelSql.ValidateIdentifier(MessageTable, nameof(MessageTable));
 916150        SqlServerChannelSql.ValidateIdentifier(SubscriberTable, nameof(SubscriberTable));
 916151        SqlServerChannelSql.ValidateNamePlan(this);
 152
 914153        EnsurePersistedTtl(MessageRetention, nameof(SqlServerAsyncResponseChannelOptions), nameof(MessageRetention));
 910154        EnsurePersistedTtl(DeliveryConfirmationTimeout, nameof(SqlServerAsyncResponseChannelOptions), nameof(DeliveryCon
 906155        if (MessageRetention <= DeliveryConfirmationTimeout)
 2156            throw new InvalidOperationException(
 2157                $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(MessageRetention)} must exceed " +
 2158                $"{nameof(DeliveryConfirmationTimeout)}: a message row pruned inside the confirmation window is " +
 2159                "indistinguishable from an acknowledged one, so the response would be reported delivered and " +
 2160                "lost-response recovery silently skipped.");
 904161        EnsureTimerBacked(DeliveryConfirmationPollInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(Deliver
 900162        EnsureTimerBacked(ActivePollInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(ActivePollInterval));
 896163        EnsureTimerBacked(IdlePollInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(IdlePollInterval));
 892164        EnsureTimerBacked(HistoryReconciliationInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(HistoryRec
 890165        if (FullSweepInterval is { } fullSweepInterval)
 8166            EnsureTimerBacked(fullSweepInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(FullSweepInterval)
 890167        EnsureTimerBacked(SubscriberHeartbeatInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(SubscriberHe
 890168        EnsurePersistedTtl(SubscriberHeartbeatTimeout, nameof(SqlServerAsyncResponseChannelOptions), nameof(SubscriberHe
 169
 888170        if (ActivePollInterval > IdlePollInterval)
 2171            throw new InvalidOperationException(
 2172                $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(ActivePollInterval)} cannot exceed " +
 2173                $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(IdlePollInterval)}; the idle interval is the ba
 174
 886175        if (MaxRemoteStackTraceLength < 0)
 2176            throw new InvalidOperationException($"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(MaxRemoteStackT
 177
 884178        if (PendingMessageBatchSize <= 0)
 2179            throw new InvalidOperationException($"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(PendingMessageB
 180
 882181        if (SubscriberHeartbeatInterval >= SubscriberHeartbeatTimeout)
 2182            throw new InvalidOperationException(
 2183                $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(SubscriberHeartbeatInterval)} must be less than
 2184                $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(SubscriberHeartbeatTimeout)}.");
 185
 880186        if (PruneInterval < TimeSpan.Zero)
 2187            throw new InvalidOperationException($"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(PruneInterval)}
 188
 878189        if (PublishMaxAttempts <= 0)
 2190            throw new InvalidOperationException($"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(PublishMaxAttem
 191
 876192        EnsureTimerBacked(PublishRetryBaseDelay, nameof(SqlServerAsyncResponseChannelOptions), nameof(PublishRetryBaseDe
 876193        EnsureTimerBacked(PublishRetryMaxDelay, nameof(SqlServerAsyncResponseChannelOptions), nameof(PublishRetryMaxDela
 876194        if (PublishRetryBaseDelay > PublishRetryMaxDelay)
 2195            throw new InvalidOperationException(
 2196                $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(PublishRetryBaseDelay)} cannot exceed {nameof(P
 874197    }
 198
 199}