| | | 1 | | namespace AsyncResponse.Transports.SQS; |
| | | 2 | | |
| | | 3 | | internal static class SqsOptionsValidator |
| | | 4 | | { |
| | 5 | 5 | | private static readonly TimeSpan MaxReceiveWaitTime = TimeSpan.FromSeconds(20); |
| | 5 | 6 | | private static readonly TimeSpan MaxVisibilityTimeout = TimeSpan.FromHours(12); |
| | | 7 | | |
| | | 8 | | public static void ValidateCommon(SqsAsyncResponseOptions options) |
| | | 9 | | { |
| | 898 | 10 | | Required(options.WorkerQueue, nameof(options.WorkerQueue)); |
| | 898 | 11 | | Required(options.ResponseQueue, nameof(options.ResponseQueue)); |
| | 898 | 12 | | Required(options.CorrelationIdAttribute, nameof(options.CorrelationIdAttribute)); |
| | 898 | 13 | | Required(options.DefaultReplyTargetName, nameof(options.DefaultReplyTargetName)); |
| | | 14 | | |
| | 898 | 15 | | if (StringComparer.Ordinal.Equals(options.WorkerQueue, options.ResponseQueue)) |
| | | 16 | | { |
| | 2 | 17 | | throw new InvalidOperationException( |
| | 2 | 18 | | $"{nameof(SqsAsyncResponseOptions)}.{nameof(options.WorkerQueue)} and " + |
| | 2 | 19 | | $"{nameof(options.ResponseQueue)} must be distinct so worker and response subscribers do not consume eac |
| | | 20 | | } |
| | | 21 | | |
| | 896 | 22 | | if (options.MaxMessagesPerReceive is < 1 or > 10) |
| | | 23 | | { |
| | 4 | 24 | | throw new InvalidOperationException( |
| | 4 | 25 | | $"{nameof(SqsAsyncResponseOptions)}.{nameof(options.MaxMessagesPerReceive)} must be between 1 and 10 (th |
| | | 26 | | } |
| | | 27 | | |
| | 892 | 28 | | if (options.ReceiveWaitTime < TimeSpan.Zero || options.ReceiveWaitTime > MaxReceiveWaitTime) |
| | | 29 | | { |
| | 4 | 30 | | throw new InvalidOperationException( |
| | 4 | 31 | | $"{nameof(SqsAsyncResponseOptions)}.{nameof(options.ReceiveWaitTime)} must be between 0 and 20 seconds ( |
| | | 32 | | } |
| | | 33 | | |
| | 888 | 34 | | if (options.PublishMaxAttempts <= 0) |
| | 2 | 35 | | throw new InvalidOperationException($"{nameof(SqsAsyncResponseOptions)}.{nameof(options.PublishMaxAttempts)} |
| | | 36 | | |
| | 886 | 37 | | if (options.CreateQueues) |
| | | 38 | | { |
| | 798 | 39 | | Required(options.DeadLetterQueueSuffix, nameof(options.DeadLetterQueueSuffix)); |
| | 796 | 40 | | if (options.MaxReceiveCount is < 1 or > 1000) |
| | | 41 | | { |
| | 2 | 42 | | throw new InvalidOperationException( |
| | 2 | 43 | | $"{nameof(SqsAsyncResponseOptions)}.{nameof(options.MaxReceiveCount)} must be between 1 and 1000 (th |
| | | 44 | | } |
| | | 45 | | |
| | | 46 | | // The derived dead-letter names must not collide with a LIVE queue (the guard every |
| | | 47 | | // sibling transport applies to its dead-letter destination): a redrive policy aimed at |
| | | 48 | | // the live response queue moves poison worker jobs into the ingress, where any |
| | | 49 | | // parseable JSON completes a real waiter — and provisioning would also silently |
| | | 50 | | // reconfigure the live queue's attributes. |
| | 4752 | 51 | | foreach (var queue in new[] { options.WorkerQueue!, options.ResponseQueue! }) |
| | | 52 | | { |
| | 1584 | 53 | | if (SqsQueueAddress.IsUrl(queue)) |
| | | 54 | | continue; |
| | | 55 | | |
| | 1582 | 56 | | var deadLetterQueue = SqsQueueAddress.DeriveDeadLetterQueueName(queue, options.DeadLetterQueueSuffix!); |
| | 1582 | 57 | | if (StringComparer.Ordinal.Equals(deadLetterQueue, options.WorkerQueue) |
| | 1582 | 58 | | || StringComparer.Ordinal.Equals(deadLetterQueue, options.ResponseQueue)) |
| | | 59 | | { |
| | 4 | 60 | | throw new InvalidOperationException( |
| | 4 | 61 | | $"{nameof(SqsAsyncResponseOptions)}: the dead-letter queue derived for '{queue}' with " + |
| | 4 | 62 | | $"{nameof(options.DeadLetterQueueSuffix)} '{options.DeadLetterQueueSuffix}' is '{deadLetterQueue |
| | 4 | 63 | | "which collides with a live worker/response queue. Rename the queues or change the suffix."); |
| | | 64 | | } |
| | | 65 | | } |
| | | 66 | | } |
| | | 67 | | |
| | 878 | 68 | | if (SqsQueueAddress.IsFifo(options.WorkerQueue)) |
| | 12 | 69 | | Required(options.FifoMessageGroupIdFallback, nameof(options.FifoMessageGroupIdFallback)); |
| | | 70 | | |
| | 876 | 71 | | AsyncResponseChannelOptions.EnsureTimerBacked(options.PublishRetryBaseDelay, nameof(SqsAsyncResponseOptions), na |
| | 874 | 72 | | AsyncResponseChannelOptions.EnsureTimerBacked(options.PublishRetryMaxDelay, nameof(SqsAsyncResponseOptions), nam |
| | 874 | 73 | | AsyncResponseChannelOptions.EnsureTimerBacked(options.SubscriberRetryBaseDelay, nameof(SqsAsyncResponseOptions), |
| | 872 | 74 | | AsyncResponseChannelOptions.EnsureTimerBacked(options.SubscriberRetryMaxDelay, nameof(SqsAsyncResponseOptions), |
| | 872 | 75 | | AsyncResponseChannelOptions.EnsureTimerBacked(options.ShutdownTimeout, nameof(SqsAsyncResponseOptions), nameof(o |
| | | 76 | | |
| | 870 | 77 | | if (options.PublishRetryBaseDelay > options.PublishRetryMaxDelay) |
| | 2 | 78 | | throw new InvalidOperationException($"{nameof(SqsAsyncResponseOptions)}.{nameof(options.PublishRetryBaseDela |
| | 868 | 79 | | if (options.SubscriberRetryBaseDelay > options.SubscriberRetryMaxDelay) |
| | 2 | 80 | | throw new InvalidOperationException($"{nameof(SqsAsyncResponseOptions)}.{nameof(options.SubscriberRetryBaseD |
| | 866 | 81 | | } |
| | | 82 | | |
| | | 83 | | public static void ValidateSubscriber( |
| | | 84 | | SqsAsyncResponseOptions transportOptions, |
| | | 85 | | SqsSubscriberOptions subscriberOptions, |
| | | 86 | | SqsSubscriberRole role) |
| | | 87 | | { |
| | 892 | 88 | | var optionPath = role is SqsSubscriberRole.Worker |
| | 892 | 89 | | ? $"{nameof(SqsAsyncResponseOptions)}.{nameof(SqsAsyncResponseOptions.WorkerSubscriber)}" |
| | 892 | 90 | | : $"{nameof(SqsAsyncResponseOptions)}.{nameof(SqsAsyncResponseOptions.ResponseSubscriber)}"; |
| | | 91 | | |
| | 892 | 92 | | if (subscriberOptions.VisibilityTimeout is { } visibilityTimeout |
| | 892 | 93 | | && (visibilityTimeout <= TimeSpan.Zero || visibilityTimeout > MaxVisibilityTimeout)) |
| | | 94 | | { |
| | 4 | 95 | | throw new InvalidOperationException( |
| | 4 | 96 | | $"{optionPath}.{nameof(SqsSubscriberOptions.VisibilityTimeout)} must be positive and at most 12 hours (t |
| | | 97 | | } |
| | | 98 | | |
| | 888 | 99 | | if (subscriberOptions.RedeliveryDelay is { } redeliveryDelay |
| | 888 | 100 | | && (redeliveryDelay < TimeSpan.Zero || redeliveryDelay > MaxVisibilityTimeout)) |
| | | 101 | | { |
| | 4 | 102 | | throw new InvalidOperationException( |
| | 4 | 103 | | $"{optionPath}.{nameof(SqsSubscriberOptions.RedeliveryDelay)} must be between zero and 12 hours (the SQS |
| | | 104 | | } |
| | | 105 | | |
| | 884 | 106 | | if (subscriberOptions.VisibilityRenewalInterval is { } renewalInterval) |
| | | 107 | | { |
| | | 108 | | // The renewal heartbeat arms Task.Delay, so the interval carries the timer ceiling |
| | | 109 | | // (in practice the shorter-than-visibility rule below is far tighter). |
| | 32 | 110 | | AsyncResponseChannelOptions.EnsureTimerBacked(renewalInterval, optionPath, nameof(SqsSubscriberOptions.Visib |
| | | 111 | | |
| | 30 | 112 | | if (subscriberOptions.VisibilityTimeout is not { } renewedVisibility) |
| | | 113 | | { |
| | 2 | 114 | | throw new InvalidOperationException( |
| | 2 | 115 | | $"{optionPath}.{nameof(SqsSubscriberOptions.VisibilityRenewalInterval)} requires " + |
| | 2 | 116 | | $"{nameof(SqsSubscriberOptions.VisibilityTimeout)} so the heartbeat knows how far to extend each mes |
| | | 117 | | } |
| | | 118 | | |
| | 28 | 119 | | if (renewalInterval >= renewedVisibility) |
| | | 120 | | { |
| | 2 | 121 | | throw new InvalidOperationException( |
| | 2 | 122 | | $"{optionPath}.{nameof(SqsSubscriberOptions.VisibilityRenewalInterval)} must be shorter than " + |
| | 2 | 123 | | $"{nameof(SqsSubscriberOptions.VisibilityTimeout)}, or messages become visible between heartbeats.") |
| | | 124 | | } |
| | | 125 | | } |
| | | 126 | | |
| | 878 | 127 | | switch (subscriberOptions.AckMode) |
| | | 128 | | { |
| | | 129 | | case SqsAckMode.AckAfterHandlerCompletes: |
| | | 130 | | // ShutdownTimeout is spent at shutdown even without a background drain: the |
| | | 131 | | // visibility-renewal join on the final batch waits it out against a degraded |
| | | 132 | | // endpoint, exactly as its own XML doc says ("at shutdown it counts against the |
| | | 133 | | // host's budget"). |
| | 832 | 134 | | ShutdownBudgetValidator.Validate( |
| | 832 | 135 | | "SQS", |
| | 832 | 136 | | $"{nameof(SqsAsyncResponseOptions)}.{nameof(SqsAsyncResponseOptions.HostShutdownTimeout)}", |
| | 832 | 137 | | transportOptions.HostShutdownTimeout, |
| | 832 | 138 | | ($"{nameof(SqsAsyncResponseOptions)}.{nameof(SqsAsyncResponseOptions.ShutdownTimeout)}", transportOp |
| | 830 | 139 | | return; |
| | | 140 | | |
| | | 141 | | case SqsAckMode.AckAfterEnqueue: |
| | 44 | 142 | | if (subscriberOptions.BackgroundWorkerCount <= 0) |
| | | 143 | | { |
| | 4 | 144 | | throw new InvalidOperationException( |
| | 4 | 145 | | $"{optionPath}.{nameof(SqsSubscriberOptions.BackgroundWorkerCount)} must be explicitly configure |
| | 4 | 146 | | $"when {nameof(SqsSubscriberOptions.AckMode)} is {nameof(SqsAckMode.AckAfterEnqueue)}."); |
| | | 147 | | } |
| | | 148 | | |
| | 40 | 149 | | if (subscriberOptions.BackgroundQueueCapacity <= 0) |
| | | 150 | | { |
| | 2 | 151 | | throw new InvalidOperationException( |
| | 2 | 152 | | $"{optionPath}.{nameof(SqsSubscriberOptions.BackgroundQueueCapacity)} must be explicitly configu |
| | 2 | 153 | | $"when {nameof(SqsSubscriberOptions.AckMode)} is {nameof(SqsAckMode.AckAfterEnqueue)}."); |
| | | 154 | | } |
| | | 155 | | |
| | 38 | 156 | | AsyncResponseChannelOptions.EnsureTimerBacked(subscriberOptions.BackgroundDrainTimeout, optionPath, name |
| | | 157 | | |
| | | 158 | | // The receive loop stops with the host token and the SDK client needs no bounded |
| | | 159 | | // close, but shutdown spends BOTH the background drain and the visibility-renewal |
| | | 160 | | // join's ShutdownTimeout (Azure Service Bus parity) — validating only the drain let |
| | | 161 | | // a raised ShutdownTimeout blow the host budget and force-terminate mid-join, |
| | | 162 | | // redelivering handled-but-undeleted work. |
| | 36 | 163 | | ShutdownBudgetValidator.Validate( |
| | 36 | 164 | | "SQS", |
| | 36 | 165 | | $"{nameof(SqsAsyncResponseOptions)}.{nameof(SqsAsyncResponseOptions.HostShutdownTimeout)}", |
| | 36 | 166 | | transportOptions.HostShutdownTimeout, |
| | 36 | 167 | | ($"{optionPath}.{nameof(SqsSubscriberOptions.BackgroundDrainTimeout)}", subscriberOptions.Background |
| | 36 | 168 | | ($"{nameof(SqsAsyncResponseOptions)}.{nameof(SqsAsyncResponseOptions.ShutdownTimeout)}", transportOp |
| | | 169 | | |
| | 30 | 170 | | return; |
| | | 171 | | |
| | | 172 | | default: |
| | 2 | 173 | | throw new InvalidOperationException( |
| | 2 | 174 | | $"{optionPath}.{nameof(SqsSubscriberOptions.AckMode)} has unsupported value '{subscriberOptions.AckM |
| | | 175 | | } |
| | | 176 | | } |
| | | 177 | | |
| | | 178 | | public static string Required(string? value, string name) |
| | 5230 | 179 | | => !string.IsNullOrWhiteSpace(value) |
| | 5230 | 180 | | ? value |
| | 5230 | 181 | | : throw new InvalidOperationException($"{nameof(SqsAsyncResponseOptions)}.{name} must be configured."); |
| | | 182 | | |
| | | 183 | | } |