| | | 1 | | namespace AsyncResponse.Transports.NATS; |
| | | 2 | | |
| | | 3 | | internal static class NatsTransportOptionsValidator |
| | | 4 | | { |
| | | 5 | | /// <summary>Validates the supplied options.</summary> |
| | | 6 | | public static string Required(string? value, string name) |
| | 4018 | 7 | | => !string.IsNullOrWhiteSpace(value) |
| | 4018 | 8 | | ? value |
| | 4018 | 9 | | : throw new InvalidOperationException($"{nameof(NatsAsyncResponseTransportOptions)}.{name} must be configure |
| | | 10 | | |
| | | 11 | | /// <summary>Validates the supplied options.</summary> |
| | | 12 | | public static void PositiveOrNull(long? value, string name) |
| | | 13 | | { |
| | 1306 | 14 | | if (value is <= 0) |
| | 2 | 15 | | throw new InvalidOperationException($"{nameof(NatsAsyncResponseTransportOptions)}.{name} must be positive wh |
| | 1304 | 16 | | } |
| | | 17 | | |
| | | 18 | | private static void ValidateSubjectToken(string? value, string name) |
| | | 19 | | { |
| | 2736 | 20 | | if (string.IsNullOrWhiteSpace(value)) |
| | 2010 | 21 | | return; |
| | | 22 | | |
| | 726 | 23 | | if (value.IndexOfAny([' ', '\t', '*', '>', '\r', '\n']) >= 0) |
| | 10 | 24 | | throw new InvalidOperationException( |
| | 10 | 25 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{name} '{value}' must not contain whitespace or the NATS w |
| | | 26 | | |
| | | 27 | | // Dots namespace a subject, but a leading, trailing or doubled '.' yields an EMPTY token |
| | | 28 | | // — a subject nats-server rejects with a non-fatal -ERR that NATS.Net never surfaces, so |
| | | 29 | | // the failure showed up as silent NoResponders at runtime rather than here (channel-options |
| | | 30 | | // parity). |
| | 716 | 31 | | if (value.StartsWith('.') || value.EndsWith('.') || value.Contains("..", StringComparison.Ordinal)) |
| | 12 | 32 | | throw new InvalidOperationException( |
| | 12 | 33 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{name} '{value}' must not begin or end with '.' or contain |
| | 704 | 34 | | } |
| | | 35 | | |
| | | 36 | | /// <summary> |
| | | 37 | | /// nats-server caps JetStream stream/consumer names at 255 characters (its subject-length |
| | | 38 | | /// default is far larger, but names derived from subjects share the cap). A longer value fails |
| | | 39 | | /// stream/consumer creation at first use — deep inside the subscriber retry loop as an opaque |
| | | 40 | | /// broker error retried forever — and derived stream names size a stack buffer from the |
| | | 41 | | /// subject, so the bound is enforced here as a named startup error. |
| | | 42 | | /// </summary> |
| | | 43 | | private const int NameLengthCap = 255; |
| | | 44 | | |
| | | 45 | | private static void EnsureNameLength(string? value, string name) |
| | | 46 | | { |
| | 10042 | 47 | | if (value is not null && value.Length > NameLengthCap) |
| | 10 | 48 | | throw new InvalidOperationException( |
| | 10 | 49 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{name} resolves to {value.Length} characters; " + |
| | 10 | 50 | | $"NATS limits subjects and JetStream stream/consumer names to {NameLengthCap} characters, " + |
| | 10 | 51 | | "so longer values fail stream/consumer creation at first use instead of at startup."); |
| | 10032 | 52 | | } |
| | | 53 | | |
| | | 54 | | private static void EnsureDistinct(string left, string right, string kind, string leftName, string rightName) |
| | | 55 | | { |
| | 3974 | 56 | | if (StringComparer.Ordinal.Equals(left, right)) |
| | 6 | 57 | | throw new InvalidOperationException( |
| | 6 | 58 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{leftName} and " + |
| | 6 | 59 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{rightName} must resolve to distinct {kind}s " + |
| | 6 | 60 | | $"(both resolve to '{left}') so worker, response, and dead-letter traffic do not consume each other's me |
| | 3968 | 61 | | } |
| | | 62 | | |
| | | 63 | | /// <summary>Validates the supplied options.</summary> |
| | | 64 | | public static void ValidateCommon(NatsAsyncResponseTransportOptions options) |
| | | 65 | | { |
| | 700 | 66 | | _ = Required(options.SubjectPrefix, nameof(options.SubjectPrefix)); |
| | 700 | 67 | | _ = Required(options.WorkerConsumer, nameof(options.WorkerConsumer)); |
| | 698 | 68 | | _ = Required(options.ResponseConsumer, nameof(options.ResponseConsumer)); |
| | 698 | 69 | | _ = Required(options.CorrelationIdHeader, nameof(options.CorrelationIdHeader)); |
| | 698 | 70 | | _ = Required(options.DefaultReplyTargetName, nameof(options.DefaultReplyTargetName)); |
| | | 71 | | |
| | | 72 | | // A subject prefix becomes leading tokens of every transport subject; it must not contain |
| | | 73 | | // whitespace, the NATS subject wildcards, or an empty token. |
| | 698 | 74 | | ValidateSubjectToken(options.SubjectPrefix, nameof(options.SubjectPrefix)); |
| | | 75 | | |
| | | 76 | | // An explicitly configured subject must satisfy the same token rules as the prefix-derived |
| | | 77 | | // defaults: whitespace or a wildcard fails stream/consumer creation at first use — deep |
| | | 78 | | // inside the subscriber retry loop as an opaque broker error retried forever — instead of |
| | | 79 | | // as a named startup error here. |
| | 686 | 80 | | ValidateSubjectToken(options.WorkerSubject, nameof(options.WorkerSubject)); |
| | 676 | 81 | | ValidateSubjectToken(options.ResponseSubject, nameof(options.ResponseSubject)); |
| | 676 | 82 | | ValidateSubjectToken(options.DeadLetterSubject, nameof(options.DeadLetterSubject)); |
| | | 83 | | |
| | | 84 | | // Length caps must run BEFORE the schema below resolves anything: stream defaulting sizes |
| | | 85 | | // a stack buffer from the subject, so the raw inputs are bounded before code derives from |
| | | 86 | | // them. |
| | 676 | 87 | | EnsureNameLength(options.SubjectPrefix, nameof(options.SubjectPrefix)); |
| | 674 | 88 | | EnsureNameLength(options.WorkerSubject, nameof(options.WorkerSubject)); |
| | 672 | 89 | | EnsureNameLength(options.ResponseSubject, nameof(options.ResponseSubject)); |
| | 672 | 90 | | EnsureNameLength(options.DeadLetterSubject, nameof(options.DeadLetterSubject)); |
| | 672 | 91 | | EnsureNameLength(options.WorkerStream, nameof(options.WorkerStream)); |
| | 670 | 92 | | EnsureNameLength(options.ResponseStream, nameof(options.ResponseStream)); |
| | 670 | 93 | | EnsureNameLength(options.DeadLetterStream, nameof(options.DeadLetterStream)); |
| | 670 | 94 | | EnsureNameLength(options.WorkerConsumer, nameof(options.WorkerConsumer)); |
| | 668 | 95 | | EnsureNameLength(options.ResponseConsumer, nameof(options.ResponseConsumer)); |
| | | 96 | | |
| | | 97 | | // Worker, response, and dead-letter traffic must never share a subject or a stream: the |
| | | 98 | | // durable consumers are unfiltered, so a shared stream feeds every role every message (and |
| | | 99 | | // a dead-letter republish landing back in the worker stream loops poison forever). Compare |
| | | 100 | | // the RESOLVED names, as the Redis sibling does: stream defaulting sanitizes every |
| | | 101 | | // non-[A-Za-z0-9-_] char to '_', so even distinct subjects ('a.b' vs 'a_b') can collide on |
| | | 102 | | // one stream — which EnsureStreamAsync would then silently repoint to whichever role ran |
| | | 103 | | // last. |
| | 668 | 104 | | var schema = new NatsTransportSubjectSchema(options); |
| | | 105 | | |
| | | 106 | | // Re-check the RESOLVED names: a prefix inside the cap can still derive an over-cap |
| | | 107 | | // subject/stream once the role suffix is appended. |
| | 668 | 108 | | EnsureNameLength(schema.WorkerSubject, nameof(options.WorkerSubject)); |
| | 666 | 109 | | EnsureNameLength(schema.ResponseSubject, nameof(options.ResponseSubject)); |
| | 666 | 110 | | EnsureNameLength(schema.DeadLetterSubject, nameof(options.DeadLetterSubject)); |
| | 666 | 111 | | EnsureNameLength(schema.WorkerStream, nameof(options.WorkerStream)); |
| | 666 | 112 | | EnsureNameLength(schema.ResponseStream, nameof(options.ResponseStream)); |
| | 666 | 113 | | EnsureNameLength(schema.DeadLetterStream, nameof(options.DeadLetterStream)); |
| | | 114 | | |
| | 666 | 115 | | EnsureDistinct(schema.WorkerSubject, schema.ResponseSubject, "subject", nameof(options.WorkerSubject), nameof(op |
| | 664 | 116 | | EnsureDistinct(schema.WorkerSubject, schema.DeadLetterSubject, "subject", nameof(options.WorkerSubject), nameof( |
| | 662 | 117 | | EnsureDistinct(schema.ResponseSubject, schema.DeadLetterSubject, "subject", nameof(options.ResponseSubject), nam |
| | 662 | 118 | | EnsureDistinct(schema.WorkerStream, schema.ResponseStream, "stream", nameof(options.WorkerStream), nameof(option |
| | 660 | 119 | | EnsureDistinct(schema.WorkerStream, schema.DeadLetterStream, "stream", nameof(options.WorkerStream), nameof(opti |
| | 660 | 120 | | EnsureDistinct(schema.ResponseStream, schema.DeadLetterStream, "stream", nameof(options.ResponseStream), nameof( |
| | | 121 | | |
| | | 122 | | // AckWait is a server-side JetStream consumer deadline carried as nanoseconds on the wire, |
| | | 123 | | // but it ALSO arms the in-process ack-extension heartbeat's Task.Delay at one third of its |
| | | 124 | | // value, so its real sink is the timer ceiling — under the persistence bound a legal |
| | | 125 | | // multi-month value passed validation and then killed every batch with |
| | | 126 | | // ArgumentOutOfRangeException from the heartbeat's delay. The retry delays arm in-process |
| | | 127 | | // Task.Delay timers too (timer ceiling). |
| | 660 | 128 | | AsyncResponseChannelOptions.EnsureTimerBacked(options.AckWait, nameof(NatsAsyncResponseTransportOptions), nameof |
| | 656 | 129 | | AsyncResponseChannelOptions.EnsureTimerBacked(options.PublishRetryBaseDelay, nameof(NatsAsyncResponseTransportOp |
| | 656 | 130 | | AsyncResponseChannelOptions.EnsureTimerBacked(options.PublishRetryMaxDelay, nameof(NatsAsyncResponseTransportOpt |
| | 656 | 131 | | AsyncResponseChannelOptions.EnsureTimerBacked(options.SubscriberRetryBaseDelay, nameof(NatsAsyncResponseTranspor |
| | 654 | 132 | | AsyncResponseChannelOptions.EnsureTimerBacked(options.SubscriberRetryMaxDelay, nameof(NatsAsyncResponseTransport |
| | 654 | 133 | | PositiveOrNull(options.StreamMaxMessages, nameof(options.StreamMaxMessages)); |
| | 652 | 134 | | PositiveOrNull(options.DeadLetterStreamMaxMessages, nameof(options.DeadLetterStreamMaxMessages)); |
| | | 135 | | |
| | | 136 | | // nats-server rejects num_replicas outside 1..5 when the stream is created — at first use, |
| | | 137 | | // inside the subscriber retry loop as an opaque broker error retried forever — so the |
| | | 138 | | // bound is a named startup error here. |
| | 652 | 139 | | if (options.StreamReplicas is < 1 or > 5) |
| | 0 | 140 | | throw new InvalidOperationException( |
| | 0 | 141 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.StreamReplicas)} must be between 1 and 5 ( |
| | | 142 | | |
| | 652 | 143 | | if (options.PublishMaxAttempts <= 0) |
| | 2 | 144 | | throw new InvalidOperationException($"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.PublishMax |
| | | 145 | | |
| | 650 | 146 | | if (options.PublishRetryBaseDelay > options.PublishRetryMaxDelay) |
| | 2 | 147 | | throw new InvalidOperationException( |
| | 2 | 148 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.PublishRetryBaseDelay)} cannot exceed " + |
| | 2 | 149 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.PublishRetryMaxDelay)}."); |
| | | 150 | | |
| | 648 | 151 | | if (options.SubscriberRetryBaseDelay > options.SubscriberRetryMaxDelay) |
| | 2 | 152 | | throw new InvalidOperationException( |
| | 2 | 153 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.SubscriberRetryBaseDelay)} cannot exceed " |
| | 2 | 154 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(options.SubscriberRetryMaxDelay)}."); |
| | 646 | 155 | | } |
| | | 156 | | |
| | | 157 | | /// <summary>Validates the supplied subscriber options together with the transport-wide shutdown budget.</summary> |
| | | 158 | | public static void ValidateSubscriber( |
| | | 159 | | NatsAsyncResponseTransportOptions transportOptions, |
| | | 160 | | NatsSubscriberOptions subscriber, |
| | | 161 | | string role) |
| | | 162 | | { |
| | 870 | 163 | | ValidateSubscriber(subscriber, role); |
| | | 164 | | |
| | 868 | 165 | | if (subscriber.AckMode is not NatsAckMode.AckAfterEnqueue) |
| | 830 | 166 | | return; |
| | | 167 | | |
| | | 168 | | // NATS subscribers spend only the background drain at shutdown; the consume loop stops |
| | | 169 | | // with the host token and the connection teardown is not separately bounded. |
| | 38 | 170 | | ShutdownBudgetValidator.Validate( |
| | 38 | 171 | | "NATS", |
| | 38 | 172 | | $"{nameof(NatsAsyncResponseTransportOptions)}.{nameof(transportOptions.HostShutdownTimeout)}", |
| | 38 | 173 | | transportOptions.HostShutdownTimeout, |
| | 38 | 174 | | ($"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.BackgroundDrainTimeout)} ({role})", subscriber.Backgro |
| | 34 | 175 | | } |
| | | 176 | | |
| | | 177 | | /// <summary>Validates the supplied options.</summary> |
| | | 178 | | public static void ValidateSubscriber(NatsSubscriberOptions subscriber, string role) |
| | | 179 | | { |
| | 888 | 180 | | if (subscriber.BatchSize <= 0) |
| | 2 | 181 | | throw new InvalidOperationException($"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.BatchSize)} ({role} |
| | | 182 | | |
| | 886 | 183 | | if (subscriber.MaxDeliveryAttempts < 0) |
| | 2 | 184 | | throw new InvalidOperationException($"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.MaxDeliveryAttempts |
| | | 185 | | |
| | | 186 | | // The NAK redelivery delay rides the wire as nanoseconds and is honored server-side — |
| | | 187 | | // persistence bound, not the (smaller) in-process timer ceiling. |
| | 884 | 188 | | AsyncResponseChannelOptions.EnsurePersistedTtl(subscriber.RedeliveryDelay, nameof(NatsSubscriberOptions), $"{nam |
| | | 189 | | |
| | 882 | 190 | | switch (subscriber.AckMode) |
| | | 191 | | { |
| | | 192 | | case NatsAckMode.AckAfterHandlerCompletes: |
| | 832 | 193 | | return; |
| | | 194 | | |
| | | 195 | | case NatsAckMode.AckAfterEnqueue: |
| | 48 | 196 | | if (subscriber.BackgroundWorkerCount <= 0) |
| | 4 | 197 | | throw new InvalidOperationException($"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.BackgroundW |
| | 44 | 198 | | if (subscriber.BackgroundQueueCapacity <= 0) |
| | 2 | 199 | | throw new InvalidOperationException($"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.BackgroundQ |
| | 42 | 200 | | AsyncResponseChannelOptions.EnsureTimerBacked(subscriber.BackgroundDrainTimeout, nameof(NatsSubscriberOp |
| | 40 | 201 | | return; |
| | | 202 | | |
| | | 203 | | default: |
| | 2 | 204 | | throw new InvalidOperationException( |
| | 2 | 205 | | $"{nameof(NatsSubscriberOptions)}.{nameof(subscriber.AckMode)} ({role}) has unsupported value '{subs |
| | | 206 | | } |
| | | 207 | | } |
| | | 208 | | } |