| | | 1 | | namespace 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> |
| | | 14 | | public 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> |
| | 1892 | 23 | | public string? ConnectionString { get; set; } |
| | | 24 | | |
| | | 25 | | /// <summary>Database schema that contains the channel tables. Default: <c>dbo</c>.</summary> |
| | 5966 | 26 | | 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> |
| | 4748 | 32 | | 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> |
| | 9803 | 38 | | 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> |
| | 4741 | 44 | | 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> |
| | 1012 | 50 | | 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> |
| | 9640 | 56 | | 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> |
| | 3441 | 62 | | 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> |
| | 3158 | 67 | | 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> |
| | 8961 | 74 | | 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> |
| | 5634 | 81 | | 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> |
| | 6837 | 91 | | 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> |
| | 14079 | 97 | | 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> |
| | 1878 | 106 | | 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> |
| | 7373 | 112 | | 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> |
| | 5091 | 118 | | 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> |
| | 3568 | 125 | | 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> |
| | 2048 | 128 | | public int PublishMaxAttempts { get; set; } = 3; |
| | | 129 | | |
| | | 130 | | /// <summary>Initial delay before retrying a failed response-row insert. Default: 50 ms.</summary> |
| | 2922 | 131 | | public TimeSpan PublishRetryBaseDelay { get; set; } = TimeSpan.FromMilliseconds(50); |
| | | 132 | | |
| | | 133 | | /// <summary>Maximum delay between response-row insert retries. Default: 1 second.</summary> |
| | 2922 | 134 | | 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). |
| | 930 | 142 | | ValidateShared(nameof(SqlServerAsyncResponseChannelOptions)); |
| | | 143 | | |
| | 926 | 144 | | if (string.IsNullOrWhiteSpace(ConnectionString)) |
| | 2 | 145 | | throw new InvalidOperationException($"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(ConnectionStrin |
| | | 146 | | |
| | 924 | 147 | | SqlServerChannelSql.ValidateIdentifier(SchemaName, nameof(SchemaName)); |
| | 920 | 148 | | SqlServerChannelSql.ValidateIdentifier(RecoveryStateTable, nameof(RecoveryStateTable)); |
| | 920 | 149 | | SqlServerChannelSql.ValidateIdentifier(MessageTable, nameof(MessageTable)); |
| | 916 | 150 | | SqlServerChannelSql.ValidateIdentifier(SubscriberTable, nameof(SubscriberTable)); |
| | 916 | 151 | | SqlServerChannelSql.ValidateNamePlan(this); |
| | | 152 | | |
| | 914 | 153 | | EnsurePersistedTtl(MessageRetention, nameof(SqlServerAsyncResponseChannelOptions), nameof(MessageRetention)); |
| | 910 | 154 | | EnsurePersistedTtl(DeliveryConfirmationTimeout, nameof(SqlServerAsyncResponseChannelOptions), nameof(DeliveryCon |
| | 906 | 155 | | if (MessageRetention <= DeliveryConfirmationTimeout) |
| | 2 | 156 | | throw new InvalidOperationException( |
| | 2 | 157 | | $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(MessageRetention)} must exceed " + |
| | 2 | 158 | | $"{nameof(DeliveryConfirmationTimeout)}: a message row pruned inside the confirmation window is " + |
| | 2 | 159 | | "indistinguishable from an acknowledged one, so the response would be reported delivered and " + |
| | 2 | 160 | | "lost-response recovery silently skipped."); |
| | 904 | 161 | | EnsureTimerBacked(DeliveryConfirmationPollInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(Deliver |
| | 900 | 162 | | EnsureTimerBacked(ActivePollInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(ActivePollInterval)); |
| | 896 | 163 | | EnsureTimerBacked(IdlePollInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(IdlePollInterval)); |
| | 892 | 164 | | EnsureTimerBacked(HistoryReconciliationInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(HistoryRec |
| | 890 | 165 | | if (FullSweepInterval is { } fullSweepInterval) |
| | 8 | 166 | | EnsureTimerBacked(fullSweepInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(FullSweepInterval) |
| | 890 | 167 | | EnsureTimerBacked(SubscriberHeartbeatInterval, nameof(SqlServerAsyncResponseChannelOptions), nameof(SubscriberHe |
| | 890 | 168 | | EnsurePersistedTtl(SubscriberHeartbeatTimeout, nameof(SqlServerAsyncResponseChannelOptions), nameof(SubscriberHe |
| | | 169 | | |
| | 888 | 170 | | if (ActivePollInterval > IdlePollInterval) |
| | 2 | 171 | | throw new InvalidOperationException( |
| | 2 | 172 | | $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(ActivePollInterval)} cannot exceed " + |
| | 2 | 173 | | $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(IdlePollInterval)}; the idle interval is the ba |
| | | 174 | | |
| | 886 | 175 | | if (MaxRemoteStackTraceLength < 0) |
| | 2 | 176 | | throw new InvalidOperationException($"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(MaxRemoteStackT |
| | | 177 | | |
| | 884 | 178 | | if (PendingMessageBatchSize <= 0) |
| | 2 | 179 | | throw new InvalidOperationException($"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(PendingMessageB |
| | | 180 | | |
| | 882 | 181 | | if (SubscriberHeartbeatInterval >= SubscriberHeartbeatTimeout) |
| | 2 | 182 | | throw new InvalidOperationException( |
| | 2 | 183 | | $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(SubscriberHeartbeatInterval)} must be less than |
| | 2 | 184 | | $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(SubscriberHeartbeatTimeout)}."); |
| | | 185 | | |
| | 880 | 186 | | if (PruneInterval < TimeSpan.Zero) |
| | 2 | 187 | | throw new InvalidOperationException($"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(PruneInterval)} |
| | | 188 | | |
| | 878 | 189 | | if (PublishMaxAttempts <= 0) |
| | 2 | 190 | | throw new InvalidOperationException($"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(PublishMaxAttem |
| | | 191 | | |
| | 876 | 192 | | EnsureTimerBacked(PublishRetryBaseDelay, nameof(SqlServerAsyncResponseChannelOptions), nameof(PublishRetryBaseDe |
| | 876 | 193 | | EnsureTimerBacked(PublishRetryMaxDelay, nameof(SqlServerAsyncResponseChannelOptions), nameof(PublishRetryMaxDela |
| | 876 | 194 | | if (PublishRetryBaseDelay > PublishRetryMaxDelay) |
| | 2 | 195 | | throw new InvalidOperationException( |
| | 2 | 196 | | $"{nameof(SqlServerAsyncResponseChannelOptions)}.{nameof(PublishRetryBaseDelay)} cannot exceed {nameof(P |
| | 874 | 197 | | } |
| | | 198 | | |
| | | 199 | | } |