| | | 1 | | using StackExchange.Redis; |
| | | 2 | | |
| | | 3 | | namespace AsyncResponse.Channels.Redis; |
| | | 4 | | |
| | | 5 | | /// <summary> |
| | | 6 | | /// The single source of truth for Redis key/channel shapes, shared by the channel and the |
| | | 7 | | /// watchdog. Key shapes are a storage contract: changing them orphans in-flight recovery state. |
| | | 8 | | /// </summary> |
| | | 9 | | internal sealed class RedisKeySchema |
| | | 10 | | { |
| | | 11 | | private readonly string _keyPrefix; |
| | | 12 | | |
| | | 13 | | /// <summary>Validates the prefix once at the single choke point every consumer constructs through.</summary> |
| | 2208 | 14 | | public RedisKeySchema(string keyPrefix) => _keyPrefix = ValidateKeyPrefix(keyPrefix); |
| | | 15 | | |
| | | 16 | | /// <summary> |
| | | 17 | | /// The prefix feeds both literal keys and the recovery SCAN <c>MATCH</c> pattern, where |
| | | 18 | | /// <c>* ? [ ] \</c> are glob metacharacters: a prefix like <c>app[prod]</c> writes keys |
| | | 19 | | /// literally but scans a character class, so the watchdog silently finds nothing — and a |
| | | 20 | | /// <c>*</c> would sweep another deployment's keys instead. |
| | | 21 | | /// </summary> |
| | | 22 | | internal static string ValidateKeyPrefix(string keyPrefix) |
| | | 23 | | { |
| | 1104 | 24 | | if (string.IsNullOrWhiteSpace(keyPrefix)) |
| | | 25 | | { |
| | 2 | 26 | | throw new InvalidOperationException( |
| | 2 | 27 | | $"{nameof(RedisAsyncResponseOptions)}.{nameof(RedisAsyncResponseOptions.KeyPrefix)} must be a non-empty |
| | | 28 | | } |
| | | 29 | | |
| | 34256 | 30 | | foreach (var ch in keyPrefix) |
| | | 31 | | { |
| | 16030 | 32 | | if (ch is '*' or '?' or '[' or ']' or '\\' || char.IsWhiteSpace(ch)) |
| | | 33 | | { |
| | 8 | 34 | | throw new InvalidOperationException( |
| | 8 | 35 | | $"{nameof(RedisAsyncResponseOptions)}.{nameof(RedisAsyncResponseOptions.KeyPrefix)} must not contain |
| | 8 | 36 | | "Redis glob metacharacters '*', '?', '[', ']', '\\': keys are written literally, but the recovery sc |
| | 8 | 37 | | "prefix inside a SCAN MATCH pattern, so a metacharacter makes the watchdog miss this deployment's ke |
| | | 38 | | } |
| | | 39 | | } |
| | | 40 | | |
| | 1094 | 41 | | return keyPrefix; |
| | | 42 | | } |
| | | 43 | | |
| | 89 | 44 | | public string RecoveryKeyPattern => $"{_keyPrefix}:recovery:*"; |
| | | 45 | | |
| | | 46 | | /// <summary> |
| | | 47 | | /// The response pub/sub channel for a correlation id. Key-routed: on Redis Cluster a plain |
| | | 48 | | /// channel lets each client pick its own node, and PUBLISH's reply counts only the |
| | | 49 | | /// subscribers on the node that received it — so a waiter subscribed elsewhere received the |
| | | 50 | | /// message while the publisher read 0, re-published a duplicate into the waiter's predicate |
| | | 51 | | /// (or fired lost-subscriber recovery for a delivered response). Routing SUBSCRIBE and |
| | | 52 | | /// PUBLISH to the channel's slot owner makes the count mean what the code reads it as; on a |
| | | 53 | | /// single node it changes nothing. |
| | | 54 | | /// </summary> |
| | | 55 | | public RedisChannel Channel(string correlationId) |
| | 1151 | 56 | | => new RedisChannel($"{_keyPrefix}:response:{correlationId}", RedisChannel.PatternMode.Literal).WithKeyRouting() |
| | | 57 | | |
| | | 58 | | /// <summary>Runs the RecoveryKey operation.</summary> |
| | 1200 | 59 | | public string RecoveryKey(string correlationId) => $"{_keyPrefix}:recovery:{correlationId}"; |
| | | 60 | | |
| | | 61 | | /// <summary>Runs the CorrelationIdFromRecoveryKey operation.</summary> |
| | | 62 | | public string CorrelationIdFromRecoveryKey(string recoveryKey) |
| | 938 | 63 | | => recoveryKey[$"{_keyPrefix}:recovery:".Length..]; |
| | | 64 | | } |