| | | 1 | | using Microsoft.Extensions.Hosting; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using Microsoft.Extensions.Options; |
| | | 4 | | using StackExchange.Redis; |
| | | 5 | | |
| | | 6 | | namespace AsyncResponse.Transports.Redis; |
| | | 7 | | |
| | | 8 | | internal abstract class RedisSubscriberService : BackgroundService |
| | | 9 | | { |
| | | 10 | | /// <summary> |
| | | 11 | | /// Whether the payload is within the engine's inbound size budget. Overridden by the response |
| | | 12 | | /// ingress subscriber, which is the only role that parses the BODY to find a correlation id — |
| | | 13 | | /// the worker role reads a header and never touches payload size. Default true so a role |
| | | 14 | | /// without a budget behaves exactly as before. |
| | | 15 | | /// </summary> |
| | 0 | 16 | | protected virtual bool IsWithinInboundBudget(string payload) => true; |
| | | 17 | | |
| | 5 | 18 | | private static readonly string GeneratedConsumerName = CreateGeneratedConsumerName(); |
| | | 19 | | |
| | | 20 | | private readonly IRedisStreamDatabase _database; |
| | | 21 | | |
| | | 22 | | /// <summary>Runs the RedisSubscriberService operation.</summary> |
| | | 23 | | protected RedisSubscriberService( |
| | | 24 | | IOptions<RedisAsyncResponseTransportOptions> options, |
| | | 25 | | IConnectionMultiplexer multiplexer, |
| | | 26 | | ILogger logger) |
| | 390 | 27 | | : this( |
| | 390 | 28 | | options, |
| | 390 | 29 | | new RedisStreamDatabaseAdapter(multiplexer.GetDatabase(), options.Value.OperationTimeout), |
| | 390 | 30 | | logger) |
| | | 31 | | { |
| | 390 | 32 | | } |
| | | 33 | | |
| | | 34 | | /// <summary>Runs the RedisSubscriberService operation.</summary> |
| | 424 | 35 | | protected RedisSubscriberService( |
| | 424 | 36 | | IOptions<RedisAsyncResponseTransportOptions> options, |
| | 424 | 37 | | IRedisStreamDatabase database, |
| | 424 | 38 | | ILogger logger) |
| | | 39 | | { |
| | 424 | 40 | | Options = options.Value; |
| | 424 | 41 | | RedisTransportOptionsValidator.ValidateCommon(Options); |
| | 424 | 42 | | _database = database; |
| | 424 | 43 | | Logger = logger; |
| | 424 | 44 | | } |
| | | 45 | | |
| | 20313 | 46 | | protected RedisAsyncResponseTransportOptions Options { get; } |
| | 836 | 47 | | protected ILogger Logger { get; } |
| | | 48 | | |
| | | 49 | | protected abstract RedisKey Stream { get; } |
| | | 50 | | protected abstract RedisValue ConsumerGroup { get; } |
| | | 51 | | protected abstract RedisSubscriberOptions SubscriberOptions { get; } |
| | | 52 | | protected abstract RedisSubscriberRole SubscriberRole { get; } |
| | | 53 | | /// <summary>Handles the delivered message.</summary> |
| | | 54 | | protected abstract Task HandleMessageAsync(RedisStreamDelivery delivery, CancellationToken cancellationToken); |
| | | 55 | | |
| | | 56 | | /// <summary>Runs this background operation until cancellation is requested.</summary> |
| | | 57 | | /// <summary> |
| | | 58 | | /// Validates subscriber options here rather than at the top of <c>ExecuteAsync</c>: since |
| | | 59 | | /// Microsoft.Extensions.Hosting.Abstractions 10.0.10, <c>BackgroundService.StartAsync</c> no |
| | | 60 | | /// longer runs <c>ExecuteAsync</c> inline, so a throw there surfaces only through the host's |
| | | 61 | | /// background-exception handling — or never, when a fast stop discards the queued work — |
| | | 62 | | /// instead of failing host startup synchronously. |
| | | 63 | | /// </summary> |
| | | 64 | | public override Task StartAsync(CancellationToken cancellationToken) |
| | | 65 | | { |
| | 414 | 66 | | RedisMessageDispatcher.ValidateOptions(Options, SubscriberOptions, SubscriberRole); |
| | 412 | 67 | | return base.StartAsync(cancellationToken); |
| | | 68 | | } |
| | | 69 | | |
| | | 70 | | protected override Task ExecuteAsync(CancellationToken stoppingToken) |
| | 414 | 71 | | => SubscriberSupervisor.RunAsync( |
| | 414 | 72 | | RunSubscriberAsync, |
| | 414 | 73 | | stoppingToken, |
| | 2 | 74 | | failures => AsyncResponseRetry.Backoff( |
| | 2 | 75 | | failures, |
| | 2 | 76 | | Options.SubscriberRetryBaseDelay, |
| | 2 | 77 | | Options.SubscriberRetryMaxDelay), |
| | 416 | 78 | | (ex, retryDelay) => Logger.LogWarning( |
| | 416 | 79 | | ex, |
| | 416 | 80 | | "Redis subscriber failed for stream {Stream} ({Role}); retrying in {RetryDelay}.", |
| | 416 | 81 | | Stream.ToString(), |
| | 416 | 82 | | SubscriberRole, |
| | 416 | 83 | | retryDelay)); |
| | | 84 | | |
| | | 85 | | private async Task RunSubscriberAsync(CancellationToken stoppingToken) |
| | | 86 | | { |
| | 416 | 87 | | if (Options.CreateConsumerGroups) |
| | 414 | 88 | | await EnsureConsumerGroupAsync(stoppingToken).ConfigureAwait(false); |
| | | 89 | | |
| | 416 | 90 | | var consumerName = ResolveConsumerName(Options, SubscriberRole); |
| | 416 | 91 | | await using var dispatcher = RedisMessageDispatcher.Create( |
| | 416 | 92 | | HandleMessageAsync, |
| | 416 | 93 | | _database, |
| | 416 | 94 | | Options, |
| | 416 | 95 | | SubscriberOptions, |
| | 416 | 96 | | Logger, |
| | 416 | 97 | | Stream, |
| | 416 | 98 | | ConsumerGroup, |
| | 416 | 99 | | SubscriberRole); |
| | | 100 | | |
| | 416 | 101 | | Logger.LogInformation( |
| | 416 | 102 | | "Redis subscriber started. Stream: {Stream}. Group: {ConsumerGroup}. Consumer: {ConsumerName}. Role: {Role}. |
| | 416 | 103 | | Stream.ToString(), |
| | 416 | 104 | | ConsumerGroup.ToString(), |
| | 416 | 105 | | consumerName.ToString(), |
| | 416 | 106 | | SubscriberRole, |
| | 416 | 107 | | SubscriberOptions.AckMode); |
| | | 108 | | |
| | 416 | 109 | | var nextPendingClaimAt = DateTimeOffset.UtcNow; |
| | 4247 | 110 | | while (!stoppingToken.IsCancellationRequested) |
| | | 111 | | { |
| | 4228 | 112 | | var processed = 0; |
| | | 113 | | |
| | | 114 | | // When the dispatcher is saturated (ACK-after-enqueue queue full) stop pulling new entries: |
| | | 115 | | // reading them would only move the backlog into the pending-entry list and spin the loop. |
| | | 116 | | // The unread entries stay as new messages in the stream until capacity frees. |
| | 4228 | 117 | | if (dispatcher.CanAcceptMore) |
| | | 118 | | { |
| | 4225 | 119 | | var utcNow = DateTimeOffset.UtcNow; |
| | 4225 | 120 | | if (utcNow >= nextPendingClaimAt) |
| | | 121 | | { |
| | 426 | 122 | | processed += await ClaimPendingAsync(dispatcher, consumerName, stoppingToken).ConfigureAwait(false); |
| | 426 | 123 | | nextPendingClaimAt = utcNow + SubscriberOptions.PendingClaimInterval; |
| | | 124 | | } |
| | | 125 | | |
| | | 126 | | // Clamp the read to the dispatcher's FREE slots (ASB/SQS parity), not merely to |
| | | 127 | | // "is there one": a full BatchSize read into a nearly-full early-ACK queue deferred |
| | | 128 | | // the surplus into the PEL un-ACKed, every reclaim bumped its delivery count, and |
| | | 129 | | // the pre-execution cap eventually dead-lettered healthy jobs whose handler never |
| | | 130 | | // ran. The claim above may have taken the last slot. |
| | 4225 | 131 | | var readCount = Math.Min(SubscriberOptions.BatchSize, dispatcher.FreeCapacity); |
| | 4225 | 132 | | if (readCount > 0) |
| | | 133 | | { |
| | 4225 | 134 | | var entries = await _database.StreamReadGroupAsync( |
| | 4225 | 135 | | Stream, |
| | 4225 | 136 | | ConsumerGroup, |
| | 4225 | 137 | | consumerName, |
| | 4225 | 138 | | readCount, |
| | 4225 | 139 | | stoppingToken).ConfigureAwait(false); |
| | | 140 | | |
| | 4207 | 141 | | processed += await DispatchBatchAsync( |
| | 4207 | 142 | | dispatcher, |
| | 4207 | 143 | | entries, |
| | 4207 | 144 | | consumerName, |
| | 441 | 145 | | static _ => 1, |
| | 4207 | 146 | | stoppingToken).ConfigureAwait(false); |
| | | 147 | | } |
| | | 148 | | } |
| | | 149 | | |
| | | 150 | | // Throttle when nothing advanced — an empty stream, or every entry deferred under backpressure. |
| | 4210 | 151 | | if (processed == 0) |
| | 3784 | 152 | | await Task.Delay(SubscriberOptions.EmptyPollDelay, stoppingToken).ConfigureAwait(false); |
| | | 153 | | } |
| | 19 | 154 | | } |
| | | 155 | | |
| | | 156 | | private async Task<RedisDispatchOutcome> DispatchEntryAsync( |
| | | 157 | | RedisMessageDispatcher dispatcher, |
| | | 158 | | StreamEntry entry, |
| | | 159 | | int attempt, |
| | | 160 | | CancellationToken cancellationToken) |
| | | 161 | | { |
| | | 162 | | RedisStreamDelivery delivery; |
| | | 163 | | try |
| | | 164 | | { |
| | 452 | 165 | | delivery = CreateDelivery(entry, attempt); |
| | 448 | 166 | | } |
| | 4 | 167 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 168 | | { |
| | | 169 | | // A foreign/malformed entry (or a trimmed tombstone) can never be handled; dead-letter and |
| | | 170 | | // ACK it so it drains instead of poisoning the pending-claim loop forever. |
| | | 171 | | // |
| | | 172 | | // Deliberately every non-cancellation exception, not just InvalidDataException: |
| | | 173 | | // CreateDelivery also runs correlation-id extraction, and anything that escapes here |
| | | 174 | | // leaves the entry in the PEL to be reclaimed and re-thrown every PendingClaimInterval, |
| | | 175 | | // abandoning the rest of the claimed batch behind it — MaxDeliveryAttempts cannot help, |
| | | 176 | | // because it is keyed on a delivery this path never constructed. |
| | 4 | 177 | | await dispatcher.DiscardUnprocessableAsync(Stream, ConsumerGroup, entry, ex, cancellationToken).ConfigureAwa |
| | 4 | 178 | | return RedisDispatchOutcome.Processed; |
| | | 179 | | } |
| | | 180 | | |
| | 448 | 181 | | return await dispatcher.HandleAsync(delivery, cancellationToken).ConfigureAwait(false); |
| | 452 | 182 | | } |
| | | 183 | | |
| | | 184 | | private async Task EnsureConsumerGroupAsync(CancellationToken cancellationToken) |
| | | 185 | | { |
| | | 186 | | try |
| | | 187 | | { |
| | 414 | 188 | | await _database.StreamCreateConsumerGroupAsync( |
| | 414 | 189 | | Stream, |
| | 414 | 190 | | ConsumerGroup, |
| | 414 | 191 | | StreamPosition.Beginning, |
| | 414 | 192 | | createStream: true, |
| | 414 | 193 | | cancellationToken).ConfigureAwait(false); |
| | 412 | 194 | | } |
| | 2 | 195 | | catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase)) |
| | | 196 | | { |
| | | 197 | | // The group already exists; this is the expected path after the first app instance. |
| | 2 | 198 | | } |
| | 414 | 199 | | } |
| | | 200 | | |
| | | 201 | | private async Task<int> ClaimPendingAsync( |
| | | 202 | | RedisMessageDispatcher dispatcher, |
| | | 203 | | RedisValue consumerName, |
| | | 204 | | CancellationToken cancellationToken) |
| | | 205 | | { |
| | | 206 | | // Same clamp as the read: reclaiming more than the dispatcher can take defers the rest |
| | | 207 | | // straight back into the PEL with a bumped delivery count. |
| | 426 | 208 | | var claimCount = Math.Min(SubscriberOptions.PendingClaimBatchSize, dispatcher.FreeCapacity); |
| | 426 | 209 | | if (claimCount <= 0) |
| | 0 | 210 | | return 0; |
| | | 211 | | |
| | 426 | 212 | | var minIdleMs = ToPositiveMilliseconds(SubscriberOptions.PendingMessageMinIdleTime); |
| | 426 | 213 | | var pending = await _database.StreamPendingMessagesAsync( |
| | 426 | 214 | | Stream, |
| | 426 | 215 | | ConsumerGroup, |
| | 426 | 216 | | claimCount, |
| | 426 | 217 | | RedisValue.Null, |
| | 426 | 218 | | minId: null, |
| | 426 | 219 | | maxId: null, |
| | 426 | 220 | | minIdleMs, |
| | 426 | 221 | | cancellationToken).ConfigureAwait(false); |
| | | 222 | | |
| | 426 | 223 | | if (pending.Length == 0) |
| | 413 | 224 | | return 0; |
| | | 225 | | |
| | 13 | 226 | | var pendingById = pending.ToDictionary( |
| | 17 | 227 | | item => item.MessageId.ToString(), |
| | 13 | 228 | | StringComparer.Ordinal); |
| | 13 | 229 | | var claimed = await _database.StreamClaimAsync( |
| | 13 | 230 | | Stream, |
| | 13 | 231 | | ConsumerGroup, |
| | 13 | 232 | | consumerName, |
| | 13 | 233 | | minIdleMs, |
| | 17 | 234 | | pending.Select(item => item.MessageId).ToArray(), |
| | 13 | 235 | | cancellationToken).ConfigureAwait(false); |
| | | 236 | | |
| | | 237 | | // Redis 5/6 answer XCLAIM with a nil entry for an id whose message was trimmed while still |
| | | 238 | | // pending (7.x drops it from the PEL instead). A nil entry has no id, so neither the |
| | | 239 | | // dispatch path nor the JUSTID heartbeat can name it, and it stayed in the PEL to be |
| | | 240 | | // re-claimed every cycle. When the reply is complete its order matches the request, so |
| | | 241 | | // the tombstones are ACKed by their pending ids here; a partial reply leaves them for the |
| | | 242 | | // next cycle. Either way only real entries are dispatched. |
| | 26 | 243 | | if (Array.Exists(claimed, static entry => entry.Id.IsNull)) |
| | | 244 | | { |
| | 4 | 245 | | if (claimed.Length == pending.Length) |
| | | 246 | | { |
| | 12 | 247 | | for (var index = 0; index < claimed.Length; index++) |
| | | 248 | | { |
| | 4 | 249 | | if (!claimed[index].Id.IsNull) |
| | | 250 | | continue; |
| | | 251 | | |
| | 2 | 252 | | var tombstoneId = pending[index].MessageId; |
| | | 253 | | try |
| | | 254 | | { |
| | 2 | 255 | | await _database.StreamAcknowledgeAsync(Stream, ConsumerGroup, tombstoneId, CancellationToken.Non |
| | 2 | 256 | | Logger.LogWarning( |
| | 2 | 257 | | "Redis pending entry {MessageId} on {Stream} was trimmed while still pending; ACKed the tomb |
| | 2 | 258 | | tombstoneId.ToString(), |
| | 2 | 259 | | Stream.ToString()); |
| | 2 | 260 | | } |
| | 0 | 261 | | catch (Exception ex) |
| | | 262 | | { |
| | 0 | 263 | | Logger.LogWarning( |
| | 0 | 264 | | ex, |
| | 0 | 265 | | "Failed to ACK trimmed pending entry {MessageId} on {Stream}; it is retried on the next pend |
| | 0 | 266 | | tombstoneId.ToString(), |
| | 0 | 267 | | Stream.ToString()); |
| | 0 | 268 | | } |
| | 2 | 269 | | } |
| | | 270 | | } |
| | | 271 | | |
| | 10 | 272 | | claimed = Array.FindAll(claimed, static entry => !entry.Id.IsNull); |
| | | 273 | | } |
| | | 274 | | |
| | 13 | 275 | | return await DispatchBatchAsync( |
| | 13 | 276 | | dispatcher, |
| | 13 | 277 | | claimed, |
| | 13 | 278 | | consumerName, |
| | 13 | 279 | | entry => |
| | 13 | 280 | | { |
| | 11 | 281 | | var priorDeliveries = pendingById.TryGetValue(entry.Id.ToString(), out var info) |
| | 11 | 282 | | ? info.DeliveryCount |
| | 11 | 283 | | : 1; |
| | 11 | 284 | | return Math.Max(1, priorDeliveries + 1); |
| | 13 | 285 | | }, |
| | 13 | 286 | | cancellationToken).ConfigureAwait(false); |
| | 426 | 287 | | } |
| | | 288 | | |
| | | 289 | | private async Task<int> DispatchBatchAsync( |
| | | 290 | | RedisMessageDispatcher dispatcher, |
| | | 291 | | StreamEntry[] entries, |
| | | 292 | | RedisValue consumerName, |
| | | 293 | | Func<StreamEntry, int> attemptFor, |
| | | 294 | | CancellationToken stoppingToken) |
| | | 295 | | { |
| | 4220 | 296 | | if (entries.Length == 0) |
| | 3794 | 297 | | return 0; |
| | | 298 | | |
| | | 299 | | // The batch is dispatched serially, and XREADGROUP/XCLAIM stamped every entry's idle |
| | | 300 | | // clock at read time — so a slow handler lets the idle time of the later (still |
| | | 301 | | // unprocessed) entries cross PendingMessageMinIdleTime, where a sibling's pending-claim |
| | | 302 | | // scan steals and re-runs them concurrently, bumping their PEL delivery count toward the |
| | | 303 | | // dead-letter cap on work that never once failed. While the batch is in flight, a |
| | | 304 | | // heartbeat claims the unprocessed entries back to this consumer with XCLAIM JUSTID, |
| | | 305 | | // which resets idle WITHOUT bumping the delivery count. |
| | 426 | 306 | | var progress = new BatchProgress(); |
| | 426 | 307 | | using var renewalCancellation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); |
| | 426 | 308 | | var renewalTask = RenewClaimLoopAsync(entries, consumerName, progress, renewalCancellation.Token); |
| | 426 | 309 | | var processed = 0; |
| | | 310 | | try |
| | | 311 | | { |
| | 1756 | 312 | | foreach (var entry in entries) |
| | | 313 | | { |
| | | 314 | | try |
| | | 315 | | { |
| | 452 | 316 | | if (await DispatchEntryAsync(dispatcher, entry, attemptFor(entry), stoppingToken).ConfigureAwait(fal |
| | 452 | 317 | | == RedisDispatchOutcome.Processed) |
| | | 318 | | { |
| | 452 | 319 | | processed++; |
| | | 320 | | } |
| | 452 | 321 | | } |
| | | 322 | | finally |
| | | 323 | | { |
| | | 324 | | // Also counts Deferred entries: they were left pending ON PURPOSE, so the |
| | | 325 | | // heartbeat must stop touching them and let their idle accrue toward reclaim. |
| | 452 | 326 | | progress.MarkSettled(); |
| | | 327 | | } |
| | | 328 | | } |
| | | 329 | | } |
| | | 330 | | finally |
| | | 331 | | { |
| | 426 | 332 | | renewalCancellation.Cancel(); |
| | 426 | 333 | | await renewalTask.ConfigureAwait(false); |
| | | 334 | | } |
| | | 335 | | |
| | 426 | 336 | | return processed; |
| | 4220 | 337 | | } |
| | | 338 | | |
| | | 339 | | private async Task RenewClaimLoopAsync( |
| | | 340 | | StreamEntry[] entries, |
| | | 341 | | RedisValue consumerName, |
| | | 342 | | BatchProgress progress, |
| | | 343 | | CancellationToken cancellationToken) |
| | | 344 | | { |
| | | 345 | | // ~PendingMessageMinIdleTime/3: two chances to land an idle reset inside every reclaim |
| | | 346 | | // window even when one sweep is delayed by a slow round trip. |
| | 426 | 347 | | var interval = TimeSpan.FromMilliseconds(Math.Max(1, SubscriberOptions.PendingMessageMinIdleTime.TotalMillisecon |
| | | 348 | | try |
| | | 349 | | { |
| | 6 | 350 | | while (true) |
| | | 351 | | { |
| | 432 | 352 | | await Task.Delay(interval, cancellationToken).ConfigureAwait(false); |
| | | 353 | | |
| | | 354 | | // Claim from the first unsettled entry onward: that covers the entry currently in |
| | | 355 | | // the handler plus everything still waiting its turn. minIdle 0 resets the idle |
| | | 356 | | // clock unconditionally, and the races are harmless — an entry ACKed while this |
| | | 357 | | // sweep is in flight has left the PEL (the claim simply skips it), and a failed |
| | | 358 | | // entry is settled before MarkSettled runs, so its post-failure idle countdown is |
| | | 359 | | // never stretched. |
| | 6 | 360 | | var settled = progress.SettledCount; |
| | 6 | 361 | | if (settled >= entries.Length) |
| | 0 | 362 | | return; |
| | | 363 | | |
| | 6 | 364 | | var remaining = new RedisValue[entries.Length - settled]; |
| | 28 | 365 | | for (var i = settled; i < entries.Length; i++) |
| | 8 | 366 | | remaining[i - settled] = entries[i].Id; |
| | | 367 | | |
| | | 368 | | try |
| | | 369 | | { |
| | 6 | 370 | | await _database.StreamClaimIdsOnlyAsync( |
| | 6 | 371 | | Stream, |
| | 6 | 372 | | ConsumerGroup, |
| | 6 | 373 | | consumerName, |
| | 6 | 374 | | minIdleTimeInMilliseconds: 0, |
| | 6 | 375 | | remaining, |
| | 6 | 376 | | cancellationToken).ConfigureAwait(false); |
| | 6 | 377 | | } |
| | 0 | 378 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 379 | | { |
| | 0 | 380 | | Logger.LogWarning( |
| | 0 | 381 | | ex, |
| | 0 | 382 | | "Failed to refresh the pending idle time of {EntryCount} Redis entries on {Stream}; a sibling ma |
| | 0 | 383 | | remaining.Length, |
| | 0 | 384 | | Stream.ToString()); |
| | 0 | 385 | | } |
| | 6 | 386 | | } |
| | | 387 | | } |
| | 426 | 388 | | catch (OperationCanceledException) |
| | | 389 | | { |
| | | 390 | | // The batch finished or the subscriber is stopping. |
| | 426 | 391 | | } |
| | 426 | 392 | | } |
| | | 393 | | |
| | | 394 | | private sealed class BatchProgress |
| | | 395 | | { |
| | | 396 | | // Settled only ever increments, so a monotonic volatile read is enough — no lock, and a |
| | | 397 | | // stale read only claims an already-settled entry once more (an ACKed id is simply |
| | | 398 | | // absent from the PEL, and a failed one gets its reclaim delayed by one sweep). |
| | | 399 | | private int _settledCount; |
| | | 400 | | |
| | 6 | 401 | | public int SettledCount => Volatile.Read(ref _settledCount); |
| | | 402 | | |
| | 452 | 403 | | public void MarkSettled() => Interlocked.Increment(ref _settledCount); |
| | | 404 | | } |
| | | 405 | | |
| | | 406 | | private RedisStreamDelivery CreateDelivery(StreamEntry entry, int attempt) |
| | | 407 | | { |
| | 452 | 408 | | var payload = RedisCorrelationIdExtractor.TryReadField(entry, Options.PayloadField); |
| | 452 | 409 | | if (string.IsNullOrWhiteSpace(payload)) |
| | | 410 | | { |
| | 4 | 411 | | throw new InvalidDataException( |
| | 4 | 412 | | $"Redis stream entry {entry.Id} on {Stream.ToString()} does not contain payload field '{Options.PayloadF |
| | | 413 | | } |
| | | 414 | | |
| | | 415 | | // Body-path extraction parses the whole payload, so it is gated on the inbound budget; |
| | | 416 | | // the field/header path reads metadata only and is unaffected by payload size. |
| | 448 | 417 | | var correlationId = SubscriberRole is RedisSubscriberRole.ResponseIngress |
| | 448 | 418 | | ? IsWithinInboundBudget(payload) |
| | 448 | 419 | | ? RedisCorrelationIdExtractor.Extract(entry, payload, Options) |
| | 448 | 420 | | : null |
| | 448 | 421 | | : RedisCorrelationIdExtractor.TryReadField(entry, Options.CorrelationIdField); |
| | | 422 | | |
| | 448 | 423 | | return new RedisStreamDelivery( |
| | 448 | 424 | | Stream, |
| | 448 | 425 | | ConsumerGroup, |
| | 448 | 426 | | entry.Id, |
| | 448 | 427 | | payload, |
| | 448 | 428 | | correlationId, |
| | 448 | 429 | | attempt, |
| | 448 | 430 | | entry); |
| | | 431 | | } |
| | | 432 | | |
| | | 433 | | private static long ToPositiveMilliseconds(TimeSpan value) |
| | 426 | 434 | | => Math.Max(1, (long)Math.Ceiling(value.TotalMilliseconds)); |
| | | 435 | | |
| | | 436 | | private static RedisValue ResolveConsumerName( |
| | | 437 | | RedisAsyncResponseTransportOptions options, |
| | | 438 | | RedisSubscriberRole role) |
| | | 439 | | { |
| | | 440 | | // Append the role even to an explicitly configured name so the worker and response subscribers |
| | | 441 | | // never share a consumer identity (and therefore a pending-entry list) within their groups. |
| | 416 | 442 | | var baseName = !string.IsNullOrWhiteSpace(options.ConsumerName) |
| | 416 | 443 | | ? options.ConsumerName |
| | 416 | 444 | | : GeneratedConsumerName; |
| | | 445 | | |
| | 416 | 446 | | return $"{baseName}-{role.ToString().ToLowerInvariant()}"; |
| | | 447 | | } |
| | | 448 | | |
| | | 449 | | private static string CreateGeneratedConsumerName() |
| | 5 | 450 | | => ComposeGeneratedConsumerName(Environment.MachineName, Environment.ProcessId, Guid.NewGuid()); |
| | | 451 | | |
| | | 452 | | /// <summary>Length budget of the generated consumer name, before the role suffix.</summary> |
| | | 453 | | internal const int MaxGeneratedConsumerNameLength = 64; |
| | | 454 | | |
| | | 455 | | /// <summary> |
| | | 456 | | /// <c>{machine}-{pid}-{guid}</c> within <see cref="MaxGeneratedConsumerNameLength"/>, with the |
| | | 457 | | /// MACHINE NAME giving up the characters. The process id and the GUID are the only parts that |
| | | 458 | | /// make the name unique, and they sit at the end: the old head-keeping cut |
| | | 459 | | /// (<c>name[..64]</c>) removed them first, so a 63-character host name — a Kubernetes pod |
| | | 460 | | /// name, or any host at Linux's HOST_NAME_MAX — left every process on that host with the SAME |
| | | 461 | | /// consumer identity. Consumers that share a name share one pending-entry list inside the |
| | | 462 | | /// group: each could claim and acknowledge entries the other was still handling. The suffix is |
| | | 463 | | /// at most 44 characters, so the machine name always keeps at least 20. |
| | | 464 | | /// </summary> |
| | | 465 | | internal static string ComposeGeneratedConsumerName(string machineName, int processId, Guid instance) |
| | | 466 | | { |
| | 27 | 467 | | var suffix = $"-{processId.ToString(System.Globalization.CultureInfo.InvariantCulture)}-{instance:N}"; |
| | 27 | 468 | | return PortableText.TruncateWellFormed(machineName, MaxGeneratedConsumerNameLength - suffix.Length) + suffix; |
| | | 469 | | } |
| | | 470 | | } |
| | | 471 | | |
| | | 472 | | internal sealed class RedisWorkerSubscriber : RedisSubscriberService |
| | | 473 | | { |
| | | 474 | | private readonly IAsyncResponseIngress _ingress; |
| | | 475 | | private readonly RedisTransportKeySchema _keys; |
| | | 476 | | |
| | | 477 | | /// <summary>Runs the RedisWorkerSubscriber operation.</summary> |
| | | 478 | | public RedisWorkerSubscriber( |
| | | 479 | | IOptions<RedisAsyncResponseTransportOptions> options, |
| | | 480 | | IConnectionMultiplexer multiplexer, |
| | | 481 | | IAsyncResponseIngress ingress, |
| | | 482 | | ILogger<RedisWorkerSubscriber> logger) |
| | | 483 | | : base(options, multiplexer, logger) |
| | | 484 | | { |
| | | 485 | | _ingress = ingress; |
| | | 486 | | _keys = new RedisTransportKeySchema(options.Value); |
| | | 487 | | } |
| | | 488 | | |
| | | 489 | | internal RedisWorkerSubscriber( |
| | | 490 | | IOptions<RedisAsyncResponseTransportOptions> options, |
| | | 491 | | IRedisStreamDatabase database, |
| | | 492 | | IAsyncResponseIngress ingress, |
| | | 493 | | ILogger<RedisWorkerSubscriber> logger) |
| | | 494 | | : base(options, database, logger) |
| | | 495 | | { |
| | | 496 | | _ingress = ingress; |
| | | 497 | | _keys = new RedisTransportKeySchema(options.Value); |
| | | 498 | | } |
| | | 499 | | |
| | | 500 | | protected override RedisKey Stream => _keys.WorkerStream; |
| | | 501 | | protected override RedisValue ConsumerGroup => Options.WorkerConsumerGroup; |
| | | 502 | | protected override RedisSubscriberOptions SubscriberOptions => Options.WorkerSubscriber; |
| | | 503 | | protected override RedisSubscriberRole SubscriberRole => RedisSubscriberRole.Worker; |
| | | 504 | | |
| | | 505 | | /// <summary>Handles the delivered message.</summary> |
| | | 506 | | protected override Task HandleMessageAsync(RedisStreamDelivery delivery, CancellationToken cancellationToken) |
| | | 507 | | => _ingress.HandleWorkerMessageAsync(delivery.Payload); |
| | | 508 | | } |
| | | 509 | | |
| | | 510 | | internal sealed class RedisResponseIngressSubscriber : RedisSubscriberService |
| | | 511 | | { |
| | | 512 | | private readonly IAsyncResponseIngress _ingress; |
| | | 513 | | private readonly RedisTransportKeySchema _keys; |
| | | 514 | | |
| | | 515 | | /// <summary>Runs the RedisResponseIngressSubscriber operation.</summary> |
| | | 516 | | public RedisResponseIngressSubscriber( |
| | | 517 | | IOptions<RedisAsyncResponseTransportOptions> options, |
| | | 518 | | IConnectionMultiplexer multiplexer, |
| | | 519 | | IAsyncResponseIngress ingress, |
| | | 520 | | ILogger<RedisResponseIngressSubscriber> logger) |
| | | 521 | | : base(options, multiplexer, logger) |
| | | 522 | | { |
| | | 523 | | _ingress = ingress; |
| | | 524 | | _keys = new RedisTransportKeySchema(options.Value); |
| | | 525 | | } |
| | | 526 | | |
| | | 527 | | internal RedisResponseIngressSubscriber( |
| | | 528 | | IOptions<RedisAsyncResponseTransportOptions> options, |
| | | 529 | | IRedisStreamDatabase database, |
| | | 530 | | IAsyncResponseIngress ingress, |
| | | 531 | | ILogger<RedisResponseIngressSubscriber> logger) |
| | | 532 | | : base(options, database, logger) |
| | | 533 | | { |
| | | 534 | | _ingress = ingress; |
| | | 535 | | _keys = new RedisTransportKeySchema(options.Value); |
| | | 536 | | } |
| | | 537 | | |
| | | 538 | | protected override RedisKey Stream => _keys.ResponseStream; |
| | | 539 | | protected override RedisValue ConsumerGroup => Options.ResponseConsumerGroup; |
| | | 540 | | protected override RedisSubscriberOptions SubscriberOptions => Options.ResponseSubscriber; |
| | | 541 | | protected override RedisSubscriberRole SubscriberRole => RedisSubscriberRole.ResponseIngress; |
| | | 542 | | |
| | | 543 | | /// <inheritdoc /> |
| | | 544 | | protected override bool IsWithinInboundBudget(string payload) => !_ingress.IsOverInboundBudget(payload); |
| | | 545 | | |
| | | 546 | | /// <summary>Handles the delivered message.</summary> |
| | | 547 | | protected override Task HandleMessageAsync(RedisStreamDelivery delivery, CancellationToken cancellationToken) |
| | | 548 | | => _ingress.HandleResponseMessageAsync(delivery.Payload, delivery.CorrelationId); |
| | | 549 | | } |