diff --git a/.gitignore b/.gitignore index c0024fb1f..b59d46676 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ launchSettings.json *.diagsession TestResults/ BenchmarkDotNet.Artifacts/ +.codex diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index d9c4f26a1..ac657d0ee 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -21,6 +21,21 @@ notify-keyspace-events AKE The two types of event (keyspace and keyevent) encode the same information, but in different formats. To simplify consumption, StackExchange.Redis provides a unified API for both types of event, via the `KeyNotification` type. +**From Redis 8.8**, you can also optionally enable sub-key (hash field) notifications, using additional tokens: + +``` conf +notify-keyspace-events AKESTIV +``` + +- **S** - SubKeySpace notifications (`__subkeyspace@__:`) +- **T** - SubKeyEvent notifications (`__subkeyevent@__:`) +- **I** - SubKeySpaceItem notifications (`__subkeyspaceitem@__:\n`) +- **V** - SubKeySpaceEvent notifications (`__subkeyspaceevent@__:|`) + +These sub-key notification types allow you to monitor operations on hash fields (subkeys) in addition to key-level operations. +The different formats provide the same information but organized differently, and StackExchange.Redis provides a unified API +via the same `KeyNotification` type. + ### Event Broadcasting in Redis Cluster Importantly, in Redis Cluster, keyspace notifications are **not** broadcast to all nodes - they are only received by clients connecting to the @@ -48,6 +63,17 @@ Note that there are a range of other `KeySpace...` and `KeyEvent...` methods for The `KeySpace*` methods are similar, and are presented separately to make the intent clear. For example, `KeySpacePattern("foo*")` is equivalent to `KeySpacePrefix("foo")`, and will subscribe to all keys beginning with `"foo"`. +**From Redis 8.8**, there are corresponding `SubKeySpace...` and `SubKeyEvent...` methods for sub-key (hash field) notifications: + +- `SubKeySpaceSingleKey` - subscribe to sub-key notifications for a single key in a specific database +- `SubKeySpacePattern` - subscribe to sub-key notifications for a key pattern, optionally in a specific database +- `SubKeySpacePrefix` - subscribe to sub-key notifications for all keys with a specific prefix, optionally in a specific database +- `SubKeySpaceItem` - subscribe to sub-key notifications for a specific key and field combination in a specific database +- `SubKeyEvent` - subscribe to sub-key notifications for a specific event type, optionally in a specific database +- `SubKeySpaceEvent` - subscribe to sub-key notifications for a specific event type and key, optionally in a specific database + +These work similarly to their key-level counterparts, but monitor hash field operations instead of key operations. + Next, we subscribe to the channel and process the notifications using the normal pub/sub subscription API; there are two main approaches: queue-based and callback-based. @@ -79,6 +105,21 @@ sub.Subscribe(channel, (recvChannel, recvValue) => Console.WriteLine($"Key: {notification.GetKey()}"); Console.WriteLine($"Type: {notification.Type}"); Console.WriteLine($"Database: {notification.Database}"); + Console.WriteLine($"Kind: {notification.Kind}"); + + // For sub-key notifications (Redis 8.8+), you can access sub-keys in a uniform way, + // regardless of the notification type + if (notification.HasSubKey) + { + // Get the first sub-key + Console.WriteLine($"First SubKey: {notification.GetSubKeys().First()}"); + + // Or iterate all sub-keys (for notifications with multiple fields) + foreach (var subKey in notification.GetSubKeys()) + { + Console.WriteLine($"SubKey: {subKey}"); + } + } } }); ``` @@ -86,12 +127,12 @@ sub.Subscribe(channel, (recvChannel, recvValue) => Note that the channels created by the `KeySpace...` and `KeyEvent...` methods cannot be used to manually *publish* events, only to subscribe to them. The events are published automatically by the Redis server when keys are modified. If you want to simulate keyspace notifications by publishing events manually, you should use regular pub/sub channels that avoid -the `__keyspace@` and `__keyevent@` prefixes. +the `__keyspace@` and `__keyevent@` prefixes (and similarly for sub-key events). ## Performance considerations for KeyNotification The `KeyNotification` struct provides parsed notification data, including (as already shown) the key, event type, -database, etc. Note that using `GetKey()` will allocate a copy of the key bytes; to avoid allocations, +database, kind, etc. Note that using `GetKey()` will allocate a copy of the key bytes; to avoid allocations, you can use `TryCopyKey()` to copy the key bytes into a provided buffer (potentially with `GetKeyByteCount()`, `GetKeyMaxCharCount()`, etc in order to size the buffer appropriately). Similarly, `KeyStartsWith()` can be used to efficiently check the key prefix without allocating a string. This approach is designed to be efficient for high-volume @@ -105,6 +146,66 @@ for the key entirely, and instead just copy the bytes into a buffer. If we consi contain the key for the majority of notifications (since they are for cache invalidation), this can be a significant performance win. +## Working with Sub-Key (Hash Field) Notifications + +**From Redis 8.8**, Redis supports notifications for hash field (sub-key) operations. These notifications provide +more granular monitoring of hash operations, allowing you to observe changes to individual hash fields rather than +just key-level operations. + +### Understanding Sub-Key Notification Types + +There are four sub-key notification kinds, analogous to the two key-level notification kinds: + +- **SubKeySpace** (`__subkeyspace@__:`) - Notifications for a specific hash key, with the event type and sub-key in the payload +- **SubKeyEvent** (`__subkeyevent@__:`) - Notifications for a specific event type, with the key and sub-key in the payload +- **SubKeySpaceItem** (`__subkeyspaceitem@__:\n`) - Notifications for a specific hash key and field combination +- **SubKeySpaceEvent** (`__subkeyspaceevent@__:|`) - Notifications for a specific event and key, with the sub-key in the payload + +In most cases, the application code already knows the kind of event being consumed, but if that logic is centralized, +you can determine the notification family using the `notification.Kind` property (which returns a +`KeyNotificationKind` enum value), and optionally extract sub-keys using `notification.GetSubKeys()`. + +### Example: Monitoring Hash Field Changes + +```csharp +// Subscribe to all sub-key changes for hashes with prefix "user:" +var channel = RedisChannel.SubKeySpacePrefix("user:", database: 0); + +sub.Subscribe(channel, (recvChannel, recvValue) => +{ + if (KeyNotification.TryParse(recvChannel, recvValue, out var notification)) + { + Console.WriteLine($"Hash Key: {notification.GetKey()}"); + Console.WriteLine($"Operation: {notification.Type}"); + Console.WriteLine($"Kind: {notification.Kind}"); + + // Process all affected fields + foreach (var field in notification.GetSubKeys()) + { + Console.WriteLine($"Field: {field}"); + } + + // Or get just the first field for single-field operations + var firstField = notification.GetSubKeys().FirstOrDefault(); + + // Utility methods available: + // - Count() - get the number of fields + // - First() / FirstOrDefault() - get the first field + // - Single() / SingleOrDefault() - get the only field (throws if multiple) + // - ToArray() / ToList() - convert to collection + // - CopyTo(Span) - copy to a span (allocation-free) + } +}); + +// Or subscribe to specific hash field events (e.g., HSET operations) +var eventChannel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, database: 0); +``` + +### Sub-Key and Key Prefix Filtering + +When using key-prefix filtering with sub-key notifications, the prefix is applied to the **key** only, not to the +sub-key (hash field). The sub-key is always returned as-is from the notification, without any prefix stripping. + ## Considerations when using database isolation Database isolation is controlled either via the `ConfigurationOptions.DefaultDatabase` option when connecting to Redis, @@ -123,6 +224,14 @@ For example: - `RedisChannel.KeyEvent(KeyNotificationType.Set, 0)` maps to `SUBSCRIBE __keyevent@0__:set` - `RedisChannel.KeyEvent(KeyNotificationType.Set)` maps to `PSUBSCRIBE __keyevent@*__:set` +**From Redis 8.8**, the sub-key notification methods work similarly: + +- `RedisChannel.SubKeySpaceSingleKey("myhash", 0)` maps to `SUBSCRIBE __subkeyspace@0__:myhash` +- `RedisChannel.SubKeySpacePrefix("hash:", 0)` maps to `PSUBSCRIBE __subkeyspace@0__:hash:*` +- `RedisChannel.SubKeySpaceItem("myhash", "field1", 0)` maps to `SUBSCRIBE __subkeyspaceitem@0__:myhash\nfield1` +- `RedisChannel.SubKeyEvent(KeyNotificationType.HSet, 0)` maps to `SUBSCRIBE __subkeyevent@0__:hset` +- `RedisChannel.SubKeySpaceEvent(KeyNotificationType.HSet, "myhash", 0)` maps to `SUBSCRIBE __subkeyspaceevent@0__:hset|myhash` + Additionally, note that while most of these examples require multi-node subscriptions on Redis Cluster, `KeySpaceSingleKey` is an exception, and will only subscribe to the single node that owns the key `foo`. diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index 32ec98884..23739a627 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -12,6 +12,7 @@ Current package versions: - Add Redis 8.8 stream negative acknowledgements (`XNACK`) ([#3058 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3058)) - Update experimental `GCRA` APIs and wire protocol terminology from "requests" to "tokens", to match server change ([#3051 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3051)) - Add experimental `Aggregate.Count` support for sorted-set combination operations against Redis 8.8 ([#3059 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3059)) +- Support sub-key (hash field) notifications ([#3062 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3062)) ## 2.12.14 diff --git a/src/StackExchange.Redis/FrameworkShims.cs b/src/StackExchange.Redis/FrameworkShims.cs index ce954406d..8907d33dc 100644 --- a/src/StackExchange.Redis/FrameworkShims.cs +++ b/src/StackExchange.Redis/FrameworkShims.cs @@ -6,6 +6,8 @@ #else // To support { get; init; } properties using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; namespace System.Runtime.CompilerServices @@ -35,9 +37,9 @@ internal static class EncodingExtensions { public static unsafe int GetBytes(this Encoding encoding, ReadOnlySpan source, Span destination) { - fixed (byte* bPtr = destination) + fixed (byte* bPtr = &MemoryMarshal.GetReference(destination)) { - fixed (char* cPtr = source) + fixed (char* cPtr = &MemoryMarshal.GetReference(source)) { return encoding.GetBytes(cPtr, source.Length, bPtr, destination.Length); } @@ -46,9 +48,9 @@ public static unsafe int GetBytes(this Encoding encoding, ReadOnlySpan sou public static unsafe int GetChars(this Encoding encoding, ReadOnlySpan source, Span destination) { - fixed (byte* bPtr = source) + fixed (byte* bPtr = &MemoryMarshal.GetReference(source)) { - fixed (char* cPtr = destination) + fixed (char* cPtr = &MemoryMarshal.GetReference(destination)) { return encoding.GetChars(bPtr, source.Length, cPtr, destination.Length); } @@ -57,7 +59,7 @@ public static unsafe int GetChars(this Encoding encoding, ReadOnlySpan sou public static unsafe int GetCharCount(this Encoding encoding, ReadOnlySpan source) { - fixed (byte* bPtr = source) + fixed (byte* bPtr = &MemoryMarshal.GetReference(source)) { return encoding.GetCharCount(bPtr, source.Length); } @@ -65,7 +67,7 @@ public static unsafe int GetCharCount(this Encoding encoding, ReadOnlySpan public static unsafe string GetString(this Encoding encoding, ReadOnlySpan source) { - fixed (byte* bPtr = source) + fixed (byte* bPtr = &MemoryMarshal.GetReference(source)) { return encoding.GetString(bPtr, source.Length); } diff --git a/src/StackExchange.Redis/KeyNotification.SubKeys.cs b/src/StackExchange.Redis/KeyNotification.SubKeys.cs new file mode 100644 index 000000000..31e653f2a --- /dev/null +++ b/src/StackExchange.Redis/KeyNotification.SubKeys.cs @@ -0,0 +1,435 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using RESPite; + +namespace StackExchange.Redis; + +public readonly ref partial struct KeyNotification +{ + /// + /// Gets all sub-keys in this notification. For notifications without sub-keys, returns an empty enumerable. + /// + /// This method is available for SubKeySpace, SubKeyEvent, SubKeySpaceItem, and SubKeySpaceEvent notification types. + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public SubKeyEnumerable GetSubKeys() + { + return new SubKeyEnumerable(this); + } + + /// + /// Provides enumeration over sub-keys in a keyspace notification. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public readonly ref struct SubKeyEnumerable + { + private static readonly RedisValue[] Empty = []; + private static readonly RedisValue[] Multiple = [RedisValue.Null, RedisValue.Null]; + private readonly KeyNotification _notification; + + internal SubKeyEnumerable(KeyNotification notification) + { + _notification = notification; + } + + /// + /// Gets an enumerator for the sub-keys. + /// + public SubKeyEnumerator GetEnumerator() => new SubKeyEnumerator(_notification); + + /// + /// Gets the number of sub-keys in this notification. + /// + public int Count() + { + var count = 0; + using var enumerator = GetEnumerator(); + while (enumerator.TryMoveNext(setCurrent: false)) + { + count++; + } + return count; + } + + /// + /// Gets the first sub-key in this notification. + /// + /// The sequence contains no elements. + public RedisValue First() + { + foreach (var subKey in this) + { + return subKey; + } + return Empty.First(); // for error consistency + } + + /// + /// Gets the first sub-key in this notification, or a null value if the sequence is empty. + /// + public RedisValue FirstOrDefault() + { + foreach (var subKey in this) + { + return subKey; + } + return RedisValue.Null; + } + + /// + /// Gets the only sub-key in this notification. + /// + /// The sequence contains no elements, or more than one element. + public RedisValue Single() + { + using var enumerator = GetEnumerator(); + if (!enumerator.MoveNext()) + { + return Empty.Single(); // for error consistency + } + var result = enumerator.Current; + if (enumerator.MoveNext()) + { + return Multiple.Single(); // for error consistency + } + return result; + } + + /// + /// Gets the only sub-key in this notification, or a null value if the sequence is empty. + /// + /// The sequence contains more than one element. + public RedisValue SingleOrDefault() + { + using var enumerator = GetEnumerator(); + if (!enumerator.MoveNext()) + { + return RedisValue.Null; + } + var result = enumerator.Current; + if (enumerator.MoveNext()) + { + return Multiple.Single(); // for error consistency + } + return result; + } + + /// + /// Tries to copy all sub-keys to the specified span. + /// + /// The span to copy sub-keys into. + /// The number of sub-keys copied. + /// true if all sub-keys were copied; false if the destination was too small (partial copy). + public bool TryCopyTo(Span destination, out int count) + { + count = 0; + foreach (var subKey in this) + { + if (count >= destination.Length) + { + return false; // Destination too small, partial copy + } + destination[count++] = subKey; + } + return true; // All sub-keys copied + } + + /// + /// Copies all sub-keys to the specified span. + /// + /// The span to copy sub-keys into. + /// The number of sub-keys copied. If the destination is too small, only as many sub-keys as will fit are copied. + public int CopyTo(Span destination) + { + _ = TryCopyTo(destination, out var count); + return count; + } + + /// + /// Converts the sub-keys to an array. + /// + public RedisValue[] ToArray() + { + var count = Count(); + if (count == 0) return Empty; + + var array = new RedisValue[count]; + var index = 0; + foreach (var subKey in this) + { + array[index++] = subKey; + } + return array; + } + + /// + /// Converts the sub-keys to a list. + /// + public List ToList() + { + var count = Count(); + var list = new List(count); + foreach (var subKey in this) + { + list.Add(subKey); + } + return list; + } + } + + /// + /// Enumerator for sub-keys in a keyspace notification. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public ref struct SubKeyEnumerator + { + private readonly KeyNotificationKind _kind; + private ReadOnlySpan _data; + private byte[]? _lease; + private int _position; + private int _currentOffset; + private int _currentLength; + private bool _hasCurrent; + private RedisValue _current; + + internal SubKeyEnumerator(scoped KeyNotification notification) + { + _kind = notification._kind; + _lease = null; + _position = 0; + _currentOffset = 0; + _currentLength = 0; + _hasCurrent = false; + _current = default; + + // Always copy the relevant data to a leased buffer to avoid lifetime issues + switch (_kind) + { + case KeyNotificationKind.SubKeySpace: + case KeyNotificationKind.SubKeyEvent: + // Payload: |:,:,... or :|:,:,... + // We need to skip to the first | and then iterate through comma-separated length-prefixed subkeys + _data = CopyAndLeaseValue(notification._value, out _lease); + + // Find the first pipe to skip the event/key part + var firstPipe = _data.IndexOf((byte)'|'); + if (firstPipe >= 0 && firstPipe + 1 < _data.Length) + { + _position = firstPipe + 1; // Start after the first | + } + else + { + _position = _data.Length; // No subkeys + } + break; + + case KeyNotificationKind.SubKeySpaceItem: + // Channel: __subkeyspaceitem@__:\n + // Single subkey only - extract from channel suffix after \n + var suffix = notification.ChannelSuffix; + var newlineIndex = suffix.IndexOf((byte)'\n'); + if (newlineIndex >= 0 && newlineIndex + 1 < suffix.Length) + { + // Copy the subkey part to a leased buffer + var subkeySpan = suffix.Slice(newlineIndex + 1); + var buffer = _lease = ArrayPool.Shared.Rent(subkeySpan.Length); + subkeySpan.CopyTo(buffer); + _data = buffer.AsSpan(0, subkeySpan.Length); + _position = 0; // Will return this single value + } + else + { + _data = default; + _position = 0; + } + break; + + case KeyNotificationKind.SubKeySpaceEvent: + // Payload: :,:,... + _data = CopyAndLeaseValue(notification._value, out _lease); + _position = 0; + break; + + default: + // No subkeys for other notification types + _data = default; + _position = 0; + break; + } + } + + private static ReadOnlySpan CopyAndLeaseValue(RedisValue value, out byte[] lease) + { + // Always lease a buffer and copy - we can't return a span directly from RedisValue + // because it may reference data in the notification parameter which has limited lifetime + var byteCount = value.GetByteCount(); + var buffer = lease = ArrayPool.Shared.Rent(byteCount); + var written = value.CopyTo(buffer); + return buffer.AsSpan(0, written); + } + + private ReadOnlySpan CurrentBytes => _hasCurrent ? _data.Slice(_currentOffset, _currentLength) : default; + + /// + /// Gets the current sub-key. + /// + public RedisValue Current + { + get + { + if (!_hasCurrent) return default; + if (_current.IsNull) + { + _current = CurrentBytes.ToArray(); + } + return _current; + } + } + + /// + /// Gets the raw bytes of the current sub-key. + /// + public ReadOnlySpan CurrentSpan => CurrentBytes; + + /// + /// Gets the byte length of the current sub-key. + /// + public int CurrentByteCount => _hasCurrent ? _currentLength : 0; + + /// + /// Gets the maximum number of UTF-8 characters in the current sub-key. + /// + public int CurrentMaxCharCount => _hasCurrent ? Encoding.UTF8.GetMaxCharCount(_currentLength) : 0; + + /// + /// Gets the actual number of UTF-8 characters in the current sub-key. + /// + public int GetCurrentCharCount() => _hasCurrent ? Encoding.UTF8.GetCharCount(CurrentBytes) : 0; + + /// + /// Attempts to copy the current sub-key bytes into the destination span. + /// + public bool TryCopyTo(scoped Span destination, out int bytesWritten) + { + var span = CurrentBytes; + bytesWritten = span.Length; + if (bytesWritten <= destination.Length) + { + span.CopyTo(destination); + return true; + } + return false; + } + + /// + /// Attempts to copy the current sub-key as UTF-8 characters into the destination span. + /// + public bool TryCopyTo(scoped Span destination, out int charsWritten) + { + var span = CurrentBytes; + if (Encoding.UTF8.GetMaxCharCount(span.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(span) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(span, destination); + return true; + } + charsWritten = 0; + return false; + } + + /// + /// Advances to the next sub-key. + /// + public bool MoveNext() => TryMoveNext(setCurrent: true); + + internal bool TryMoveNext(bool setCurrent) + { + _hasCurrent = false; + _current = default; + _currentOffset = 0; + _currentLength = 0; + + if (_position >= _data.Length) + { + return false; + } + + switch (_kind) + { + case KeyNotificationKind.SubKeySpaceItem: + // Single subkey - return it once + if (_position == 0) + { + _hasCurrent = true; + _currentLength = _data.Length; + if (setCurrent) _ = Current; + _position = _data.Length; // Mark as consumed + return true; + } + return false; + + case KeyNotificationKind.SubKeySpace: + case KeyNotificationKind.SubKeyEvent: + case KeyNotificationKind.SubKeySpaceEvent: + // Length-prefixed format: : + var remaining = _data.Slice(_position); + if (!TryGetLengthPrefixedRange(remaining, out var valueOffset, out var valueLength)) + { + return false; + } + + _hasCurrent = true; + _currentOffset = _position + valueOffset; + _currentLength = valueLength; + if (setCurrent) _ = Current; + + // Move position forward: skip the length prefix + colon + value + pipe (if present) + _position += valueOffset + valueLength; + + // Skip the separator if present (| or ,) + if (_position < _data.Length && (_data[_position] == (byte)'|' || _data[_position] == (byte)',')) + { + _position++; + } + + return true; + + default: + return false; + } + } + + /// + /// Releases any leased buffers. + /// + public void Dispose() + { + if (_lease is not null) + { + ArrayPool.Shared.Return(_lease); + _lease = null; + } + } + + private static bool TryGetLengthPrefixedRange(ReadOnlySpan span, out int valueOffset, out int valueLength) + { + var colonIndex = span.IndexOf((byte)':'); + if (colonIndex > 0 && Utf8Parser.TryParse(span.Slice(0, colonIndex), out int length, out _)) + { + valueOffset = colonIndex + 1; + if (valueOffset + length <= span.Length) + { + valueLength = length; + return true; + } + } + valueOffset = valueLength = 0; + return false; + } + } +} diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index d435c9382..899775090 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -1,54 +1,186 @@ using System; using System.Buffers; using System.Buffers.Text; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text; using RESPite; using static StackExchange.Redis.KeyNotificationChannels; + namespace StackExchange.Redis; +/// +/// Represents the type of keyspace notification channel. +/// +public enum KeyNotificationKind +{ + /// + /// Unknown or invalid notification type. + /// + Unknown = 0, + + /// + /// Standard keyspace notification: __keyspace@{db}__:{key} with payload containing the event. + /// + KeySpace = 1, + + /// + /// Standard keyevent notification: __keyevent@{db}__:{event} with payload containing the key. + /// + KeyEvent = 2, + + /// + /// Subkey keyspace notification: __subkeyspace@{db}__:{key} with payload containing event|subkey. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + SubKeySpace = 3, + + /// + /// Subkey keyevent notification: __subkeyevent@{db}__:{event} with payload containing key|subkey. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + SubKeyEvent = 4, + + /// + /// Subkey keyspaceitem notification: __subkeyspaceitem@{db}__:{key}\n{subkey} with payload containing the event. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + SubKeySpaceItem = 5, + + /// + /// Subkey keyspaceevent notification: __subkeyspaceevent@{db}__:{event}|{key} with payload containing the subkey. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + SubKeySpaceEvent = 6, +} + /// /// Represents keyspace and keyevent notifications, with utility methods for accessing the component data. Additionally, /// since notifications can be high volume, a range of utility APIs is provided for avoiding allocations, in particular /// to assist in filtering and inspecting the key without performing string allocations and substring operations. /// In particular, note that this allows use with the alt-lookup (span-based) APIs on dictionaries. /// -public readonly ref struct KeyNotification +public readonly ref partial struct KeyNotification { // effectively we just wrap a channel, but: we've pre-validated that things make sense private readonly RedisChannel _channel; private readonly RedisValue _value; private readonly int _keyOffset; // used to efficiently strip key prefixes + private readonly KeyNotificationKind _kind; // the type of notification // this type has been designed with the intent of being able to move the entire thing alloc-free in some future // high-throughput callback, potentially with a ReadOnlySpan field for the key fragment; this is // not implemented currently, but is why this is a ref struct /// - /// If the channel is either a keyspace or keyevent notification, resolve the key and event type. + /// Gets the kind of keyspace notification this represents. + /// + public KeyNotificationKind Kind => _kind; + + /// + /// Indicates whether this notification includes a sub-key (hash field). + /// + /// This is true for SubKeySpace, SubKeyEvent, SubKeySpaceItem, and SubKeySpaceEvent notifications (Redis 8.8+). + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public bool HasSubKey + { + get => _kind is KeyNotificationKind.SubKeySpace + or KeyNotificationKind.SubKeyEvent + or KeyNotificationKind.SubKeySpaceItem + or KeyNotificationKind.SubKeySpaceEvent; + } + + /// + /// If the channel is a keyspace, keyevent, subkeyspace, subkeyevent, subkeyspaceitem, or subkeyeventitem notification, resolve the key and event type. /// public static bool TryParse(scoped in RedisChannel channel, scoped in RedisValue value, out KeyNotification notification) { // validate that it looks reasonable var span = channel.Span; - // KeySpaceStart and KeyEventStart are the same size, see KeyEventPrefix_KeySpacePrefix_Length_Matches + // Check for SubKeySpaceEvent prefix first (it's the longest: 20 chars) + if (span.Length >= SubKeySpaceEventPrefix.Length + MinSuffixBytes) + { + var prefix = span.Slice(0, SubKeySpaceEventPrefix.Length); + var hashCS = AsciiHash.HashCS(prefix); + if (SubKeySpaceEventPrefix.IsCS(prefix, hashCS)) + { + // __subkeyspaceevent@__:| - check for __: followed by something + if (span.Slice(SubKeySpaceEventPrefix.Length).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value, KeyNotificationKind.SubKeySpaceEvent); + return true; + } + } + } + + // Check for SubKeySpaceItem prefix (19 chars) + if (span.Length >= SubKeySpaceItemPrefix.Length + MinSuffixBytes) + { + var prefix = span.Slice(0, SubKeySpaceItemPrefix.Length); + var hashCS = AsciiHash.HashCS(prefix); + if (SubKeySpaceItemPrefix.IsCS(prefix, hashCS)) + { + // __subkeyspaceitem@__:\n - check for __: followed by something + if (span.Slice(SubKeySpaceItemPrefix.Length).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value, KeyNotificationKind.SubKeySpaceItem); + return true; + } + } + } + + // Check for the subkey prefixes (14 chars: __subkeyspace@, __subkeyevent@) + if (span.Length >= SubKeySpacePrefix.Length + MinSuffixBytes) + { + var prefix = span.Slice(0, SubKeySpacePrefix.Length); + var hashCS = AsciiHash.HashCS(prefix); + switch (hashCS) + { + case SubKeySpacePrefix.HashCS when SubKeySpacePrefix.IsCS(prefix, hashCS): + // check that there is *something* non-empty after the prefix, with __: as the suffix + if (span.Slice(SubKeySpacePrefix.Length).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value, KeyNotificationKind.SubKeySpace); + return true; + } + break; + + case SubKeyEventPrefix.HashCS when SubKeyEventPrefix.IsCS(prefix, hashCS): + // check that there is *something* non-empty after the prefix, with __: as the suffix + if (span.Slice(SubKeySpacePrefix.Length).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value, KeyNotificationKind.SubKeyEvent); + return true; + } + break; + } + } + + // Check for basic keyspace/keyevent prefixes (11 chars: __keyspace@, __keyevent@) if (span.Length >= KeySpacePrefix.Length + MinSuffixBytes) { - // check that the prefix is valid, i.e. "__keyspace@" or "__keyevent@" + // check that the prefix is valid, i.e. "__keyspace@", "__keyevent@" var prefix = span.Slice(0, KeySpacePrefix.Length); var hashCS = AsciiHash.HashCS(prefix); switch (hashCS) { case KeyEventPrefix.HashCS when KeyEventPrefix.IsCS(prefix, hashCS): - case KeySpacePrefix.HashCS when KeySpacePrefix.IsCS(prefix, hashCS): // check that there is *something* non-empty after the prefix, with __: as the suffix (we don't verify *what*) if (span.Slice(KeySpacePrefix.Length).IndexOf("__:"u8) > 0) { - notification = new KeyNotification(in channel, in value); + notification = new KeyNotification(in channel, in value, KeyNotificationKind.KeyEvent); return true; } + break; + case KeySpacePrefix.HashCS when KeySpacePrefix.IsCS(prefix, hashCS): + // check that there is *something* non-empty after the prefix, with __: as the suffix (we don't verify *what*) + if (span.Slice(KeySpacePrefix.Length).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value, KeyNotificationKind.KeySpace); + return true; + } break; } } @@ -92,11 +224,12 @@ internal KeyNotification WithKeySlice(int keyPrefixLength) /// public RedisValue GetValue() => _value; - internal KeyNotification(scoped in RedisChannel channel, scoped in RedisValue value) + internal KeyNotification(scoped in RedisChannel channel, scoped in RedisValue value, KeyNotificationKind kind) { _channel = channel; _value = value; _keyOffset = 0; + _kind = kind; } internal int KeyOffset => _keyOffset; @@ -110,7 +243,18 @@ public int Database { // prevalidated format, so we can just skip past the prefix (except for the default value) if (_channel.IsNull) return -1; - var span = _channel.Span.Slice(KeySpacePrefix.Length); // also works for KeyEventPrefix + + // Determine the prefix length based on the notification kind + var fullSpan = _channel.Span; + int prefixLength = _kind switch + { + KeyNotificationKind.SubKeySpaceEvent => SubKeySpaceEventPrefix.Length, + KeyNotificationKind.SubKeySpaceItem => SubKeySpaceItemPrefix.Length, + KeyNotificationKind.SubKeySpace or KeyNotificationKind.SubKeyEvent => SubKeySpacePrefix.Length, + _ => KeySpacePrefix.Length, // KeySpace, KeyEvent, and Unknown + }; + + var span = fullSpan.Slice(prefixLength); var end = span.IndexOf((byte)'_'); // expecting "__:foo" - we'll just stop at the underscore if (end <= 0) return -1; @@ -127,80 +271,123 @@ public int Database /// the , , and APIs can be used. public RedisKey GetKey() { - if (IsKeySpace) + switch (_kind) { - // then the channel contains the key, and the payload contains the event-type - return ChannelSuffix.Slice(_keyOffset).ToArray(); // create an isolated copy + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpace: + // Channel contains the key, payload contains the event-type + return ChannelSuffix.Slice(_keyOffset).ToArray(); + + case KeyNotificationKind.KeyEvent: + // Channel contains the event-type, payload contains the key + byte[]? blob = _value; + if (_keyOffset != 0 & blob is not null) + { + return blob.AsSpan(_keyOffset).ToArray(); + } + return blob; + + case KeyNotificationKind.SubKeyEvent: + // __subkeyevent@__: with payload :|: + var value = ExtractLengthPrefixedValue(_value, 0); + if (value.IsNull) return RedisKey.Null; + byte[]? bytes = value; + if (_keyOffset != 0 && bytes is not null) + { + return bytes.AsSpan(_keyOffset).ToArray(); + } + return bytes; + + case KeyNotificationKind.SubKeySpaceItem: + // __subkeyspaceitem@__:\n with payload + var suffix = ChannelSuffix; + var newlineIndex = suffix.IndexOf((byte)'\n'); + if (newlineIndex > 0) + { + return suffix.Slice(_keyOffset, newlineIndex - _keyOffset).ToArray(); + } + return RedisKey.Null; + + case KeyNotificationKind.SubKeySpaceEvent: + // __subkeyspaceevent@__:| with payload : + var suffixEvent = ChannelSuffix; + var pipeIndex = suffixEvent.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < suffixEvent.Length) + { + return suffixEvent.Slice(pipeIndex + 1 + _keyOffset).ToArray(); + } + return RedisKey.Null; + + default: + return RedisKey.Null; } + } - if (IsKeyEvent) + // Helper to extract a value prefixed with its length, e.g., "5:hello" -> "hello" + internal static RedisValue ExtractLengthPrefixedValue(in RedisValue value, int offset) + { + if (value.TryGetSpan(out var span)) { - // then the channel contains the event-type, and the payload contains the key - byte[]? blob = _value; - if (_keyOffset != 0 & blob is not null) + return ExtractLengthPrefixedValue(span.Slice(offset)); + } + + // Slower path for non-contiguous values + const int MAX_STACK = 256; + byte[]? lease = null; + var maxCount = value.GetMaxByteCount(); + Span buffer = maxCount <= MAX_STACK + ? stackalloc byte[MAX_STACK] + : (lease = ArrayPool.Shared.Rent(maxCount)); + var bytesWritten = value.CopyTo(buffer); + var result = ExtractLengthPrefixedValue(buffer.Slice(offset, bytesWritten - offset)); + if (lease is not null) ArrayPool.Shared.Return(lease); + return result; + } + + internal static RedisValue ExtractLengthPrefixedValue(ReadOnlySpan span) + { + var colonIndex = span.IndexOf((byte)':'); + if (colonIndex > 0 && Utf8Parser.TryParse(span.Slice(0, colonIndex), out int length, out _)) + { + var startIndex = colonIndex + 1; + if (startIndex + length <= span.Length) { - return blob.AsSpan(_keyOffset).ToArray(); + return span.Slice(startIndex, length).ToArray(); } - return blob; } - - return RedisKey.Null; + return RedisValue.Null; } /// /// Get the number of bytes in the key. /// /// If a scratch-buffer is required, it may be preferable to use , which is less expensive. - public int GetKeyByteCount() + public int GetKeyByteCount() => _kind switch { - if (IsKeySpace) - { - return ChannelSuffix.Length - _keyOffset; - } - - if (IsKeyEvent) - { - return _value.GetByteCount() - _keyOffset; - } - - return 0; - } + KeyNotificationKind.KeySpace or KeyNotificationKind.SubKeySpace => ChannelSuffix.Length - _keyOffset, + KeyNotificationKind.KeyEvent or KeyNotificationKind.SubKeyEvent => _value.GetByteCount() - _keyOffset, + _ => 0, + }; /// /// Get the maximum number of bytes in the key. /// - public int GetKeyMaxByteCount() + public int GetKeyMaxByteCount() => _kind switch { - if (IsKeySpace) - { - return ChannelSuffix.Length - _keyOffset; - } - - if (IsKeyEvent) - { - return _value.GetMaxByteCount() - _keyOffset; - } - - return 0; - } + KeyNotificationKind.KeySpace or KeyNotificationKind.SubKeySpace => ChannelSuffix.Length - _keyOffset, + KeyNotificationKind.KeyEvent or KeyNotificationKind.SubKeyEvent => _value.GetMaxByteCount() - _keyOffset, + _ => 0, + }; /// /// Get the maximum number of characters in the key, interpreting as UTF8. /// - public int GetKeyMaxCharCount() + public int GetKeyMaxCharCount() => _kind switch { - if (IsKeySpace) - { - return Encoding.UTF8.GetMaxCharCount(ChannelSuffix.Length - _keyOffset); - } - - if (IsKeyEvent) - { - return _value.GetMaxCharCount() - _keyOffset; - } - - return 0; - } + KeyNotificationKind.KeySpace or KeyNotificationKind.SubKeySpace => Encoding.UTF8.GetMaxCharCount(ChannelSuffix.Length - _keyOffset), + KeyNotificationKind.KeyEvent or KeyNotificationKind.SubKeyEvent => _value.GetMaxCharCount() - _keyOffset, + _ => 0, + }; /// /// Get the number of characters in the key, interpreting as UTF8. @@ -208,17 +395,19 @@ public int GetKeyMaxCharCount() /// If a scratch-buffer is required, it may be preferable to use , which is less expensive. public int GetKeyCharCount() { - if (IsKeySpace) + switch (_kind) { - return Encoding.UTF8.GetCharCount(ChannelSuffix.Slice(_keyOffset)); - } + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpace: + return Encoding.UTF8.GetCharCount(ChannelSuffix.Slice(_keyOffset)); - if (IsKeyEvent) - { - return _keyOffset == 0 ? _value.GetCharCount() : SlowMeasure(in this); - } + case KeyNotificationKind.KeyEvent: + case KeyNotificationKind.SubKeyEvent: + return _keyOffset == 0 ? _value.GetCharCount() : SlowMeasure(in this); - return 0; + default: + return 0; + } static int SlowMeasure(in KeyNotification value) { @@ -255,58 +444,139 @@ private static void Return(byte[]? lease) /// public bool TryCopyKey(Span destination, out int bytesWritten) { - if (IsKeySpace) + switch (_kind) { - var suffix = ChannelSuffix.Slice(_keyOffset); - bytesWritten = suffix.Length; // assume success - if (bytesWritten <= destination.Length) - { - suffix.CopyTo(destination); - return true; - } - } - - if (IsKeyEvent) - { - if (_value.TryGetSpan(out var direct)) - { - bytesWritten = direct.Length - _keyOffset; // assume success + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpace: + // Key is in the channel suffix + var suffix = ChannelSuffix.Slice(_keyOffset); + bytesWritten = suffix.Length; // assume success if (bytesWritten <= destination.Length) { - direct.Slice(_keyOffset).CopyTo(destination); + suffix.CopyTo(destination); return true; } - bytesWritten = 0; return false; - } - if (_keyOffset == 0) - { - // get the value to do the hard work - bytesWritten = _value.GetByteCount(); + case KeyNotificationKind.KeyEvent: + // Key is in the value/payload (plain key, not length-prefixed) + if (_value.TryGetSpan(out var direct)) + { + bytesWritten = direct.Length - _keyOffset; + if (bytesWritten <= destination.Length) + { + direct.Slice(_keyOffset).CopyTo(destination); + return true; + } + bytesWritten = 0; + return false; + } + + if (_keyOffset == 0) + { + bytesWritten = _value.GetByteCount(); + if (bytesWritten <= destination.Length) + { + _value.CopyTo(destination); + return true; + } + bytesWritten = 0; + return false; + } + + return SlowCopy(in this, destination, out bytesWritten); + + case KeyNotificationKind.SubKeyEvent: + // Key is length-prefixed in payload: :|: + var keyValue = ExtractLengthPrefixedValue(_value, 0); + if (keyValue.IsNull) + { + bytesWritten = 0; + return false; + } + + if (keyValue.TryGetSpan(out var keySpan)) + { + var slicedSpan = keySpan.Slice(_keyOffset); + bytesWritten = slicedSpan.Length; + if (bytesWritten <= destination.Length) + { + slicedSpan.CopyTo(destination); + return true; + } + return false; + } + + byte[]? keyBytes = keyValue; + if (_keyOffset != 0 && keyBytes is not null) + { + bytesWritten = keyBytes.Length - _keyOffset; + if (bytesWritten <= destination.Length) + { + keyBytes.AsSpan(_keyOffset).CopyTo(destination); + return true; + } + return false; + } + + bytesWritten = keyValue.GetByteCount(); if (bytesWritten <= destination.Length) { - _value.CopyTo(destination); + keyValue.CopyTo(destination); return true; } bytesWritten = 0; return false; - } - return SlowCopy(in this, destination, out bytesWritten); + case KeyNotificationKind.SubKeySpaceItem: + // Key is in channel: __subkeyspaceitem@__:\n + var suffixItem = ChannelSuffix; + var newlineIndex = suffixItem.IndexOf((byte)'\n'); + if (newlineIndex > 0) + { + var keySpanItem = suffixItem.Slice(_keyOffset, newlineIndex - _keyOffset); + bytesWritten = keySpanItem.Length; + if (bytesWritten <= destination.Length) + { + keySpanItem.CopyTo(destination); + return true; + } + return false; + } + bytesWritten = 0; + return false; + + case KeyNotificationKind.SubKeySpaceEvent: + // Key is in channel: __subkeyspaceevent@__:| + var suffixEvent = ChannelSuffix; + var pipeIndex = suffixEvent.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < suffixEvent.Length) + { + var keySpanEvent = suffixEvent.Slice(pipeIndex + 1 + _keyOffset); + bytesWritten = keySpanEvent.Length; + if (bytesWritten <= destination.Length) + { + keySpanEvent.CopyTo(destination); + return true; + } + return false; + } + bytesWritten = 0; + return false; - static bool SlowCopy(in KeyNotification value, Span destination, out int bytesWritten) - { - var span = value.GetKeySpan(out var lease, stackalloc byte[128]); - bool result = span.TryCopyTo(destination); - bytesWritten = result ? span.Length : 0; - Return(lease); - return result; - } + default: + bytesWritten = 0; + return false; } - bytesWritten = 0; - return false; + static bool SlowCopy(in KeyNotification value, Span destination, out int bytesWritten) + { + var span = value.GetKeySpan(out var lease, stackalloc byte[128]); + bool result = span.TryCopyTo(destination); + bytesWritten = result ? span.Length : 0; + Return(lease); + return result; + } } /// @@ -314,48 +584,122 @@ static bool SlowCopy(in KeyNotification value, Span destination, out int b /// public bool TryCopyKey(Span destination, out int charsWritten) { - if (IsKeySpace) + switch (_kind) { - var suffix = ChannelSuffix.Slice(_keyOffset); - if (Encoding.UTF8.GetMaxCharCount(suffix.Length) <= destination.Length || - Encoding.UTF8.GetCharCount(suffix) <= destination.Length) - { - charsWritten = Encoding.UTF8.GetChars(suffix, destination); - return true; - } - } - - if (IsKeyEvent) - { - if (_keyOffset == 0) // can use short-cut - { - if (_value.GetMaxCharCount() <= destination.Length || _value.GetCharCount() <= destination.Length) + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpace: + // Key is in the channel suffix + var suffix = ChannelSuffix.Slice(_keyOffset); + if (Encoding.UTF8.GetMaxCharCount(suffix.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(suffix) <= destination.Length) { - charsWritten = _value.CopyTo(destination); + charsWritten = Encoding.UTF8.GetChars(suffix, destination); return true; } - } - var span = GetKeySpan(out var lease, stackalloc byte[128]); - charsWritten = 0; - bool result = false; - if (Encoding.UTF8.GetMaxCharCount(span.Length) <= destination.Length || - Encoding.UTF8.GetCharCount(span) <= destination.Length) - { - charsWritten = Encoding.UTF8.GetChars(span, destination); - result = true; - } - Return(lease); - return result; - } + charsWritten = 0; + return false; - charsWritten = 0; - return false; + case KeyNotificationKind.KeyEvent: + // Key is in the value/payload (plain key, not length-prefixed) + if (_keyOffset == 0) // can use short-cut + { + if (_value.GetMaxCharCount() <= destination.Length || _value.GetCharCount() <= destination.Length) + { + charsWritten = _value.CopyTo(destination); + return true; + } + } + var span = GetKeySpan(out var lease, stackalloc byte[128]); + charsWritten = 0; + bool result = false; + if (Encoding.UTF8.GetMaxCharCount(span.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(span) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(span, destination); + result = true; + } + Return(lease); + return result; + + case KeyNotificationKind.SubKeyEvent: + // Key is length-prefixed in payload: :|: + var keyValue = ExtractLengthPrefixedValue(_value, 0); + if (keyValue.IsNull) + { + charsWritten = 0; + return false; + } + + if (_keyOffset == 0) + { + if (keyValue.GetMaxCharCount() <= destination.Length || keyValue.GetCharCount() <= destination.Length) + { + charsWritten = keyValue.CopyTo(destination); + return true; + } + } + else + { + // Need to slice the extracted key value + byte[]? keyBytes = keyValue; + if (keyBytes is not null) + { + var keySpan = keyBytes.AsSpan(_keyOffset); + if (Encoding.UTF8.GetMaxCharCount(keySpan.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(keySpan) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(keySpan, destination); + return true; + } + } + } + charsWritten = 0; + return false; + + case KeyNotificationKind.SubKeySpaceItem: + // Key is in channel: __subkeyspaceitem@__:\n + var suffixItem = ChannelSuffix; + var newlineIndex = suffixItem.IndexOf((byte)'\n'); + if (newlineIndex > 0) + { + var keyBytes = suffixItem.Slice(_keyOffset, newlineIndex - _keyOffset); + if (Encoding.UTF8.GetMaxCharCount(keyBytes.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(keyBytes) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(keyBytes, destination); + return true; + } + } + charsWritten = 0; + return false; + + case KeyNotificationKind.SubKeySpaceEvent: + // Key is in channel: __subkeyspaceevent@__:| + var suffixEvent = ChannelSuffix; + var pipeIndex = suffixEvent.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < suffixEvent.Length) + { + var keyBytes = suffixEvent.Slice(pipeIndex + 1 + _keyOffset); + if (Encoding.UTF8.GetMaxCharCount(keyBytes.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(keyBytes) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(keyBytes, destination); + return true; + } + } + charsWritten = 0; + return false; + + default: + charsWritten = 0; + return false; + } } /// /// Get the portion of the channel after the "__{keyspace|keyevent}@{db}__:". /// - private ReadOnlySpan ChannelSuffix + internal ReadOnlySpan ChannelSuffix { get { @@ -372,31 +716,67 @@ private ReadOnlySpan ChannelSuffix /// a single successful call to . public bool IsType(ReadOnlySpan type) { - if (IsKeySpace) + switch (_kind) { - if (_value.TryGetSpan(out var direct)) - { - return direct.SequenceEqual(type); - } + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpaceItem: + // Type is in the payload/value + if (_value.TryGetSpan(out var direct)) + { + return direct.SequenceEqual(type); + } - const int MAX_STACK = 64; - byte[]? lease = null; - var maxCount = _value.GetMaxByteCount(); - Span localCopy = maxCount <= MAX_STACK - ? stackalloc byte[MAX_STACK] - : (lease = ArrayPool.Shared.Rent(maxCount)); - var count = _value.CopyTo(localCopy); - bool result = localCopy.Slice(0, count).SequenceEqual(type); - if (lease is not null) ArrayPool.Shared.Return(lease); - return result; - } + const int MAX_STACK = 64; + byte[]? lease = null; + var maxCount = _value.GetMaxByteCount(); + Span localCopy = maxCount <= MAX_STACK + ? stackalloc byte[MAX_STACK] + : (lease = ArrayPool.Shared.Rent(maxCount)); + var count = _value.CopyTo(localCopy); + bool result = localCopy.Slice(0, count).SequenceEqual(type); + if (lease is not null) ArrayPool.Shared.Return(lease); + return result; - if (IsKeyEvent) - { - return ChannelSuffix.SequenceEqual(type); - } + case KeyNotificationKind.KeyEvent: + case KeyNotificationKind.SubKeyEvent: + // Type is in the channel suffix + return ChannelSuffix.SequenceEqual(type); - return false; + case KeyNotificationKind.SubKeySpace: + // Type is before the | in the payload + if (_value.TryGetSpan(out var directSub)) + { + var pipeIndex = directSub.IndexOf((byte)'|'); + if (pipeIndex > 0) + { + return directSub.Slice(0, pipeIndex).SequenceEqual(type); + } + } + + byte[]? leaseSub = null; + var maxCountSub = _value.GetMaxByteCount(); + Span localCopySub = maxCountSub <= MAX_STACK + ? stackalloc byte[MAX_STACK] + : (leaseSub = ArrayPool.Shared.Rent(maxCountSub)); + var countSub = _value.CopyTo(localCopySub); + var pipeIndexSub = localCopySub.Slice(0, countSub).IndexOf((byte)'|'); + bool resultSub = pipeIndexSub > 0 && localCopySub.Slice(0, pipeIndexSub).SequenceEqual(type); + if (leaseSub is not null) ArrayPool.Shared.Return(leaseSub); + return resultSub; + + case KeyNotificationKind.SubKeySpaceEvent: + // Type is in the channel suffix before the | + var suffix = ChannelSuffix; + var pipeIndexEvent = suffix.IndexOf((byte)'|'); + if (pipeIndexEvent > 0) + { + return suffix.Slice(0, pipeIndexEvent).SequenceEqual(type); + } + return false; + + default: + return false; + } } /// @@ -407,53 +787,81 @@ public KeyNotificationType Type { get { - if (IsKeySpace) + switch (_kind) { - // then the channel contains the key, and the payload contains the event-type - if (_value.TryGetSpan(out var direct)) - { - return KeyNotificationTypeMetadata.Parse(direct); - } + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpaceItem: + // Payload contains the event-type + if (_value.TryGetSpan(out var direct)) + { + return KeyNotificationTypeMetadata.Parse(direct); + } - if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) - { - Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; - var len = _value.CopyTo(localCopy); - return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, len)); - } - } - else if (IsKeyEvent) - { - // then the channel contains the event-type, and the payload contains the key - return KeyNotificationTypeMetadata.Parse(ChannelSuffix); + if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) + { + Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; + var len = _value.CopyTo(localCopy); + return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, len)); + } + return KeyNotificationType.Unknown; + + case KeyNotificationKind.KeyEvent: + case KeyNotificationKind.SubKeyEvent: + // Channel contains the event-type + return KeyNotificationTypeMetadata.Parse(ChannelSuffix); + + case KeyNotificationKind.SubKeySpace: + // Payload contains |:,:,... + if (_value.TryGetSpan(out var directSub)) + { + var pipeIndexSub = directSub.IndexOf((byte)'|'); + if (pipeIndexSub > 0) + { + return KeyNotificationTypeMetadata.Parse(directSub.Slice(0, pipeIndexSub)); + } + } + + // Need to copy the value to find the pipe - the event type is before the first | + byte[]? leaseSub = null; + var byteCountSub = _value.GetByteCount(); + Span localCopySub = byteCountSub <= KeyNotificationTypeMetadata.BufferBytes + ? stackalloc byte[KeyNotificationTypeMetadata.BufferBytes] + : (leaseSub = ArrayPool.Shared.Rent(byteCountSub)); + var lenSub = _value.CopyTo(localCopySub); + var pipeIndexSub2 = localCopySub.Slice(0, lenSub).IndexOf((byte)'|'); + KeyNotificationType resultSub = pipeIndexSub2 > 0 + ? KeyNotificationTypeMetadata.Parse(localCopySub.Slice(0, pipeIndexSub2)) + : KeyNotificationType.Unknown; + if (leaseSub is not null) ArrayPool.Shared.Return(leaseSub); + return resultSub; + + case KeyNotificationKind.SubKeySpaceEvent: + // Channel contains event|key - extract the event part before the pipe + var suffix = ChannelSuffix; + var pipeIndexEvent = suffix.IndexOf((byte)'|'); + if (pipeIndexEvent > 0) + { + return KeyNotificationTypeMetadata.Parse(suffix.Slice(0, pipeIndexEvent)); + } + return KeyNotificationType.Unknown; + + default: + return KeyNotificationType.Unknown; } - return KeyNotificationType.Unknown; } } /// /// Indicates whether this notification originated from a keyspace notification, for example __keyspace@4__:mykey with payload set. /// - public bool IsKeySpace - { - get - { - var span = _channel.Span; - return span.Length >= KeySpacePrefix.Length + MinSuffixBytes && KeySpacePrefix.IsCS(span.Slice(0, KeySpacePrefix.Length), AsciiHash.HashCS(span)); - } - } + [Obsolete($"Prefer {nameof(KeyNotification)}.{nameof(Kind)}", error: false)] + public bool IsKeySpace => _kind == KeyNotificationKind.KeySpace; /// /// Indicates whether this notification originated from a keyevent notification, for example __keyevent@4__:set with payload mykey. /// - public bool IsKeyEvent - { - get - { - var span = _channel.Span; - return span.Length >= KeyEventPrefix.Length + MinSuffixBytes && KeyEventPrefix.IsCS(span.Slice(0, KeyEventPrefix.Length), AsciiHash.HashCS(span)); - } - } + [Obsolete($"Prefer {nameof(KeyNotification)}.{nameof(Kind)}", error: false)] + public bool IsKeyEvent => _kind == KeyNotificationKind.KeyEvent; /// /// Indicates whether the key associated with this notification starts with the specified prefix. @@ -461,22 +869,61 @@ public bool IsKeyEvent /// This API is intended as a high-throughput filter API. public bool KeyStartsWith(ReadOnlySpan prefix) // intentionally leading people to the BLOB API { - if (IsKeySpace) + switch (_kind) { - return ChannelSuffix.Slice(_keyOffset).StartsWith(prefix); - } + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpace: + // Key is in the channel suffix + return ChannelSuffix.Slice(_keyOffset).StartsWith(prefix); - if (IsKeyEvent) - { - if (_keyOffset == 0) return _value.StartsWith(prefix); + case KeyNotificationKind.KeyEvent: + // Key is in the value/payload (plain key, not length-prefixed) + if (_keyOffset == 0) return _value.StartsWith(prefix); - var span = GetKeySpan(out var lease, stackalloc byte[128]); - bool result = span.StartsWith(prefix); - Return(lease); - return result; - } + var span = GetKeySpan(out var lease, stackalloc byte[128]); + bool result = span.StartsWith(prefix); + Return(lease); + return result; - return false; + case KeyNotificationKind.SubKeyEvent: + // Key is length-prefixed in payload: :|: + var keyValue = ExtractLengthPrefixedValue(_value, 0); + if (keyValue.IsNull) return false; + if (_keyOffset == 0) return keyValue.StartsWith(prefix); + + // Need to check the sliced portion + byte[]? keyBytes = keyValue; + if (keyBytes is not null && _keyOffset < keyBytes.Length) + { + return keyBytes.AsSpan(_keyOffset).StartsWith(prefix); + } + return false; + + case KeyNotificationKind.SubKeySpaceItem: + // Key is in channel: __subkeyspaceitem@__:\n + var suffixItem = ChannelSuffix; + var newlineIndex = suffixItem.IndexOf((byte)'\n'); + if (newlineIndex > 0) + { + var keySpan = suffixItem.Slice(_keyOffset, newlineIndex - _keyOffset); + return keySpan.StartsWith(prefix); + } + return false; + + case KeyNotificationKind.SubKeySpaceEvent: + // Key is in channel: __subkeyspaceevent@__:| + var suffixEvent = ChannelSuffix; + var pipeIndex = suffixEvent.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < suffixEvent.Length) + { + var keySpan = suffixEvent.Slice(pipeIndex + 1 + _keyOffset); + return keySpan.StartsWith(prefix); + } + return false; + + default: + return false; + } } } @@ -491,4 +938,24 @@ internal static partial class KeySpacePrefix internal static partial class KeyEventPrefix { } + + [AsciiHash("__subkeyspace@")] + internal static partial class SubKeySpacePrefix + { + } + + [AsciiHash("__subkeyevent@")] + internal static partial class SubKeyEventPrefix + { + } + + [AsciiHash("__subkeyspaceitem@")] + internal static partial class SubKeySpaceItemPrefix + { + } + + [AsciiHash("__subkeyspaceevent@")] + internal static partial class SubKeySpaceEventPrefix + { + } } diff --git a/src/StackExchange.Redis/KeyNotificationType.cs b/src/StackExchange.Redis/KeyNotificationType.cs index d45d11e47..bf0db0991 100644 --- a/src/StackExchange.Redis/KeyNotificationType.cs +++ b/src/StackExchange.Redis/KeyNotificationType.cs @@ -1,4 +1,5 @@ -using RESPite; +using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; @@ -111,6 +112,8 @@ public enum KeyNotificationType ZRemByScore = 48, [AsciiHash("zrem")] ZRem = 49, + [AsciiHash("hexpire")] + HExpire = 50, // side-effect notifications [AsciiHash("expired")] diff --git a/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs b/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs index 594fd29c2..ff11f4092 100644 --- a/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs +++ b/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs @@ -67,6 +67,7 @@ public static KeyNotificationType Parse(ReadOnlySpan value) KeyNotificationType.ZRemByRank => "zrembyrank"u8, KeyNotificationType.ZRemByScore => "zrembyscore"u8, KeyNotificationType.ZRem => "zrem"u8, + KeyNotificationType.HExpire => "hexpire"u8, KeyNotificationType.Expired => "expired"u8, KeyNotificationType.Evicted => "evicted"u8, KeyNotificationType.New => "new"u8, diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ab058de62..57486c144 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,45 @@ #nullable enable +[SER006]static StackExchange.Redis.RedisChannel.SubKeyEvent(StackExchange.Redis.KeyNotificationType type, int? database = null) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeyEvent(System.ReadOnlySpan type, int? database) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceEvent(StackExchange.Redis.KeyNotificationType type, in StackExchange.Redis.RedisKey key, int? database = null) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceEvent(System.ReadOnlySpan type, in StackExchange.Redis.RedisKey key, int? database) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceItem(in StackExchange.Redis.RedisKey key, in StackExchange.Redis.RedisKey subkey, int database) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePattern(in StackExchange.Redis.RedisKey pattern, int? database = null) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePrefix(in StackExchange.Redis.RedisKey prefix, int? database = null) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePrefix(System.ReadOnlySpan prefix, int? database = null) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceSingleKey(in StackExchange.Redis.RedisKey key, int database) -> StackExchange.Redis.RedisChannel +[SER006]StackExchange.Redis.KeyNotification.GetSubKeys() -> StackExchange.Redis.KeyNotification.SubKeyEnumerable +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.SubKeyEnumerable() -> void +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.CopyTo(System.Span destination) -> int +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.Count() -> int +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.First() -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.FirstOrDefault() -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.GetEnumerator() -> StackExchange.Redis.KeyNotification.SubKeyEnumerator +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.Single() -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.SingleOrDefault() -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.ToArray() -> StackExchange.Redis.RedisValue[]! +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.ToList() -> System.Collections.Generic.List! +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.TryCopyTo(System.Span destination, out int count) -> bool +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.SubKeyEnumerator() -> void +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.CurrentByteCount.get -> int +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.Current.get -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.CurrentMaxCharCount.get -> int +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.CurrentSpan.get -> System.ReadOnlySpan +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.Dispose() -> void +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.GetCurrentCharCount() -> int +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.MoveNext() -> bool +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.TryCopyTo(scoped System.Span destination, out int bytesWritten) -> bool +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.TryCopyTo(scoped System.Span destination, out int charsWritten) -> bool +StackExchange.Redis.KeyNotification.HasSubKey.get -> bool +StackExchange.Redis.KeyNotification.Kind.get -> StackExchange.Redis.KeyNotificationKind +StackExchange.Redis.KeyNotificationKind +StackExchange.Redis.KeyNotificationKind.KeyEvent = 2 -> StackExchange.Redis.KeyNotificationKind +StackExchange.Redis.KeyNotificationKind.KeySpace = 1 -> StackExchange.Redis.KeyNotificationKind +[SER006]StackExchange.Redis.KeyNotificationKind.SubKeyEvent = 4 -> StackExchange.Redis.KeyNotificationKind +[SER006]StackExchange.Redis.KeyNotificationKind.SubKeySpace = 3 -> StackExchange.Redis.KeyNotificationKind +[SER006]StackExchange.Redis.KeyNotificationKind.SubKeySpaceEvent = 6 -> StackExchange.Redis.KeyNotificationKind +[SER006]StackExchange.Redis.KeyNotificationKind.SubKeySpaceItem = 5 -> StackExchange.Redis.KeyNotificationKind +StackExchange.Redis.KeyNotificationKind.Unknown = 0 -> StackExchange.Redis.KeyNotificationKind +StackExchange.Redis.KeyNotificationType.HExpire = 50 -> StackExchange.Redis.KeyNotificationType diff --git a/src/StackExchange.Redis/RawResult.cs b/src/StackExchange.Redis/RawResult.cs index 2b2b3989a..300535ad4 100644 --- a/src/StackExchange.Redis/RawResult.cs +++ b/src/StackExchange.Redis/RawResult.cs @@ -209,7 +209,9 @@ internal RedisChannel AsRedisChannel(byte[]? channelPrefix, RedisChannel.RedisCh // we shouldn't get unexpected events, so to get here: we've received a notification // on a channel that doesn't match our prefix; this *should* be limited to // key notifications (see: IgnoreChannelPrefix), but: we need to be sure - if (StartsWith("__keyspace@"u8) || StartsWith("__keyevent@"u8)) + if (StartsWith("__"u8) && (StartsWith("__keyspace@"u8) || StartsWith("__keyevent@"u8) || + StartsWith("__subkeyspace@"u8) || StartsWith("__subkeyevent@"u8) || + StartsWith("__subkeyspaceitem@"u8) || StartsWith("__subkeyspaceevent@"u8))) { // use as-is return new RedisChannel(GetBlob(), options); diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index 2327d0a0c..4f2eda7c3 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -1,7 +1,9 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text; +using RESPite; namespace StackExchange.Redis { @@ -39,6 +41,43 @@ internal static ReadOnlySpan StripKeySpacePrefix(ReadOnlySpan span) int end = subspan.IndexOf("__:"u8); if (end >= 0) return subspan.Slice(end + 3); } + else if (span.Length >= 19 && span.StartsWith("__subkeyspace@"u8)) + { + // Format: __subkeyspace@{db}__:{key} + // We want to extract {key} for routing + var subspan = span.Slice(14); // Skip "__subkeyspace@" + int end = subspan.IndexOf("__:"u8); + if (end >= 0) return subspan.Slice(end + 3); + } + else if (span.Length >= 20 && span.StartsWith("__subkeyspaceitem@"u8)) + { + // Format: __subkeyspaceitem@{db}__:{key}\n{field} + // We want to extract {key} for routing + var subspan = span.Slice(18); // Skip "__subkeyspaceitem@" + int end = subspan.IndexOf("__:"u8); + if (end >= 0) + { + subspan = subspan.Slice(end + 3); // Skip "{db}__:" + // Find the newline that separates key from field + int newline = subspan.IndexOf((byte)'\n'); + if (newline >= 0) return subspan.Slice(0, newline); // Return just the key + return subspan; // No newline found, return rest + } + } + else if (span.Length >= 23 && span.StartsWith("__subkeyspaceevent@"u8)) + { + // Format: __subkeyspaceevent@{db}__:{event}|{key} + // We want to extract {key} for routing + var subspan = span.Slice(19); // Skip "__subkeyspaceevent@" + int end = subspan.IndexOf("__:"u8); + if (end >= 0) + { + subspan = subspan.Slice(end + 3); // Skip "{db}__:" + // Find the pipe that separates event from key + int pipe = subspan.IndexOf((byte)'|'); + if (pipe >= 0) return subspan.Slice(pipe + 1); // Return just the key + } + } return span; } @@ -213,13 +252,13 @@ public static RedisChannel Sharded(string value) => /// public static RedisChannel KeySpaceSingleKey(in RedisKey key, int database) // note we can allow patterns, because we aren't using PSUBSCRIBE - => BuildKeySpaceChannel(key, database, RedisChannelOptions.KeyRouted, default, false, true); + => BuildKeySpaceChannel(key, database, RedisChannelOptions.KeyRouted, default, false, true, subkey: false); /// /// Create a key-notification channel for a pattern, optionally in a specified database. /// public static RedisChannel KeySpacePattern(in RedisKey pattern, int? database = null) - => BuildKeySpaceChannel(pattern, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, appendStar: pattern.IsNull, allowKeyPatterns: true); + => BuildKeySpaceChannel(pattern, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, appendStar: pattern.IsNull, allowKeyPatterns: true, subkey: false); #pragma warning disable RS0026 // competing overloads - disambiguated via OverloadResolutionPriority /// @@ -228,7 +267,7 @@ public static RedisChannel KeySpacePattern(in RedisKey pattern, int? database = public static RedisChannel KeySpacePrefix(in RedisKey prefix, int? database = null) { if (prefix.IsEmpty) Throw(); - return BuildKeySpaceChannel(prefix, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, true, false); + return BuildKeySpaceChannel(prefix, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, true, false, subkey: false); static void Throw() => throw new ArgumentNullException(nameof(prefix)); } @@ -239,7 +278,7 @@ public static RedisChannel KeySpacePrefix(in RedisKey prefix, int? database = nu public static RedisChannel KeySpacePrefix(ReadOnlySpan prefix, int? database = null) { if (prefix.IsEmpty) Throw(); - return BuildKeySpaceChannel(RedisKey.Null, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, prefix, true, false); + return BuildKeySpaceChannel(RedisKey.Null, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, prefix, true, false, subkey: false); static void Throw() => throw new ArgumentNullException(nameof(prefix)); } #pragma warning restore RS0026 // competing overloads - disambiguated via OverloadResolutionPriority @@ -268,13 +307,16 @@ private static ReadOnlySpan AppendDatabase(Span target, int? databas #pragma warning disable RS0027 public static RedisChannel KeyEvent(KeyNotificationType type, int? database = null) #pragma warning restore RS0027 - => KeyEvent(KeyNotificationTypeMetadata.GetRawBytes(type), database); + => CreateKeyEvent(KeyNotificationTypeMetadata.GetRawBytes(type), database, subkey: false); /// /// Create an event-notification channel for a given event type, optionally in a specified database. /// /// This API is intended for use with custom/unknown event types; for well-known types, use . public static RedisChannel KeyEvent(ReadOnlySpan type, int? database) + => CreateKeyEvent(type, database, subkey: false); + + private static RedisChannel CreateKeyEvent(ReadOnlySpan type, int? database, bool subkey) { if (type.IsEmpty) throw new ArgumentNullException(nameof(type)); @@ -282,10 +324,10 @@ public static RedisChannel KeyEvent(ReadOnlySpan type, int? database) if (database is null) options |= RedisChannelOptions.Pattern; var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); - // __keyevent@{db}__:{type} - var arr = new byte[14 + db.Length + type.Length]; + // __keyevent@{db}__:{type} or __subkeyevent@{db}__:{type} + var arr = new byte[(subkey ? 17 : 14) + db.Length + type.Length]; - var target = AppendAndAdvance(arr.AsSpan(), "__keyevent@"u8); + var target = AppendAndAdvance(arr.AsSpan(), subkey ? "__subkeyevent@"u8 : "__keyevent@"u8); target = AppendAndAdvance(target, db); target = AppendAndAdvance(target, "__:"u8); target = AppendAndAdvance(target, type); @@ -294,13 +336,153 @@ public static RedisChannel KeyEvent(ReadOnlySpan type, int? database) return new RedisChannel(arr, options | RedisChannelOptions.IgnoreChannelPrefix); } + /// + /// Create a subkey (hash) notification channel for a single key in a single database. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpaceSingleKey(in RedisKey key, int database) + // note we can allow patterns, because we aren't using PSUBSCRIBE + => BuildKeySpaceChannel(key, database, RedisChannelOptions.KeyRouted, default, false, true, subkey: true); + + /// + /// Create a subkey (hash) notification channel for a pattern, optionally in a specified database. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpacePattern(in RedisKey pattern, int? database = null) + => BuildKeySpaceChannel(pattern, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, appendStar: pattern.IsNull, allowKeyPatterns: true, subkey: true); + +#pragma warning disable RS0026 // competing overloads - disambiguated via OverloadResolutionPriority + /// + /// Create a subkey (hash) notification channel using a raw prefix, optionally in a specified database. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpacePrefix(in RedisKey prefix, int? database = null) + { + if (prefix.IsEmpty) Throw(); + return BuildKeySpaceChannel(prefix, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, true, false, subkey: true); + static void Throw() => throw new ArgumentNullException(nameof(prefix)); + } + + /// + /// Create a key-notification channel using a raw prefix, optionally in a specified database. + /// + [OverloadResolutionPriority(1)] + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpacePrefix(ReadOnlySpan prefix, int? database = null) + { + if (prefix.IsEmpty) Throw(); + return BuildKeySpaceChannel(RedisKey.Null, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, prefix, true, false, subkey: true); + static void Throw() => throw new ArgumentNullException(nameof(prefix)); + } + + /// + /// Create a subkey (hash) event-notification channel for a given event type, optionally in a specified database. + /// +#pragma warning disable RS0027 + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeyEvent(KeyNotificationType type, int? database = null) +#pragma warning restore RS0027 + => CreateKeyEvent(KeyNotificationTypeMetadata.GetRawBytes(type), database, subkey: true); + + /// + /// Create a subkey (hash) event-notification channel for a given event type, optionally in a specified database. + /// + /// This API is intended for use with custom/unknown event types; for well-known types, use . + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeyEvent(ReadOnlySpan type, int? database) + => CreateKeyEvent(type, database, subkey: true); + + /// + /// Create a subkey (hash) notification channel for a specific key and subkey in a single database. + /// + /// Format: __subkeyspaceitem@{db}__:{key}\n{subkey}. + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpaceItem(in RedisKey key, in RedisKey subkey, int database) + { + if (key.IsEmpty) throw new ArgumentNullException(nameof(key)); + if (subkey.IsEmpty) throw new ArgumentNullException(nameof(subkey)); + + // Redis does not issue notifications for keys containing \n to avoid ambiguity in SubKeySpaceItem format + // Check by converting to string and looking for \n + var keyString = key.ToString(); + if (keyString?.IndexOf('\n') >= 0) + { + throw new ArgumentException("Keys containing newline characters are not supported for SubKeySpaceItem notifications", nameof(key)); + } + + var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, RedisChannelOptions.None); + + // __subkeyspaceitem@{db}__:{key}\n{subkey} + var arr = new byte[21 + db.Length + key.TotalLength() + 1 + subkey.TotalLength()]; + + var target = AppendAndAdvance(arr.AsSpan(), "__subkeyspaceitem@"u8); + target = AppendAndAdvance(target, db); + target = AppendAndAdvance(target, "__:"u8); + var keyLen = key.CopyTo(target); + target = target.Slice(keyLen); + target[0] = (byte)'\n'; + target = target.Slice(1); + var subkeyLen = subkey.CopyTo(target); + target = target.Slice(subkeyLen); + Debug.Assert(target.IsEmpty, "length calculated incorrectly"); + + return new RedisChannel(arr, RedisChannelOptions.KeyRouted | RedisChannelOptions.IgnoreChannelPrefix); + } + + /// + /// Create a subkey (hash) event-notification channel for a specific event type and key, optionally in a specified database. + /// + /// Format: __subkeyspaceevent@{db}__:{event}|{key}. +#pragma warning disable RS0027 // competing overloads - disambiguated via parameter types + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpaceEvent(KeyNotificationType type, in RedisKey key, int? database = null) +#pragma warning restore RS0027 + => SubKeySpaceEvent(KeyNotificationTypeMetadata.GetRawBytes(type), key, database); + + /// + /// Create a subkey (hash) event-notification channel for a specific event type and key, optionally in a specified database. + /// + /// This API is intended for use with custom/unknown event types; for well-known types, use . + /// Format: __subkeyspaceevent@{db}__:{event}|{key}. + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpaceEvent(ReadOnlySpan type, in RedisKey key, int? database) + { + if (type.IsEmpty) throw new ArgumentNullException(nameof(type)); + if (key.IsEmpty) throw new ArgumentNullException(nameof(key)); + + // Redis rejects events containing | to avoid ambiguity in SubKeySpaceEvent format + if (type.IndexOf((byte)'|') >= 0) + { + throw new ArgumentException("Event types containing pipe characters are not supported for SubKeySpaceEvent notifications", nameof(type)); + } + + RedisChannelOptions options = RedisChannelOptions.MultiNode; + if (database is null) options |= RedisChannelOptions.Pattern; + var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); + + // __subkeyspaceevent@{db}__:{event}|{key} + var arr = new byte[22 + db.Length + type.Length + 1 + key.TotalLength()]; + + var target = AppendAndAdvance(arr.AsSpan(), "__subkeyspaceevent@"u8); + target = AppendAndAdvance(target, db); + target = AppendAndAdvance(target, "__:"u8); + target = AppendAndAdvance(target, type); + target[0] = (byte)'|'; + target = target.Slice(1); + var keyLen = key.CopyTo(target); + target = target.Slice(keyLen); + Debug.Assert(target.IsEmpty, "length calculated incorrectly"); + + return new RedisChannel(arr, options | RedisChannelOptions.IgnoreChannelPrefix); + } + private static Span AppendAndAdvance(Span target, scoped ReadOnlySpan value) { value.CopyTo(target); return target.Slice(value.Length); } - private static RedisChannel BuildKeySpaceChannel(in RedisKey key, int? database, RedisChannelOptions options, ReadOnlySpan suffix, bool appendStar, bool allowKeyPatterns) + private static RedisChannel BuildKeySpaceChannel(in RedisKey key, int? database, RedisChannelOptions options, ReadOnlySpan suffix, bool appendStar, bool allowKeyPatterns, bool subkey) { int fullKeyLength = key.TotalLength() + suffix.Length + (appendStar ? 1 : 0); if (appendStar & (options & RedisChannelOptions.Pattern) == 0) throw new ArgumentNullException(nameof(key)); @@ -308,10 +490,10 @@ private static RedisChannel BuildKeySpaceChannel(in RedisKey key, int? database, var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); - // __keyspace@{db}__:{key}[*] - var arr = new byte[14 + db.Length + fullKeyLength]; + // __keyspace@{db}__:{key}[*] or __subkeyspace@{db}__:{key}[*] + var arr = new byte[(subkey ? 17 : 14) + db.Length + fullKeyLength]; - var target = AppendAndAdvance(arr.AsSpan(), "__keyspace@"u8); + var target = AppendAndAdvance(arr.AsSpan(), subkey ? "__subkeyspace@"u8 : "__keyspace@"u8); target = AppendAndAdvance(target, db); target = AppendAndAdvance(target, "__:"u8); var keySpan = target; // remember this for if we need to check for patterns diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index 334a1d7b9..53757b428 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,4 @@ -ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:8.8-m02 +ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:unstable-24805570909-debian FROM ${CLIENT_LIBS_TEST_IMAGE} COPY --from=configs ./Basic /data/Basic/ diff --git a/tests/RedisConfigs/Basic/primary-6379.conf b/tests/RedisConfigs/Basic/primary-6379.conf index 2da592601..0128e5e22 100644 --- a/tests/RedisConfigs/Basic/primary-6379.conf +++ b/tests/RedisConfigs/Basic/primary-6379.conf @@ -8,4 +8,4 @@ appendonly no dbfilename "primary-6379.rdb" save "" enable-debug-command yes -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Basic/replica-6380.conf b/tests/RedisConfigs/Basic/replica-6380.conf index 0c1650513..5cd671f6d 100644 --- a/tests/RedisConfigs/Basic/replica-6380.conf +++ b/tests/RedisConfigs/Basic/replica-6380.conf @@ -8,4 +8,4 @@ appendonly no dir "../Temp" dbfilename "replica-6380.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Basic/secure-6381.conf b/tests/RedisConfigs/Basic/secure-6381.conf index ad2e380ad..5037accd8 100644 --- a/tests/RedisConfigs/Basic/secure-6381.conf +++ b/tests/RedisConfigs/Basic/secure-6381.conf @@ -5,4 +5,4 @@ maxmemory 512mb dir "../Temp" dbfilename "secure-6381.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Basic/tls-ciphers-6384.conf b/tests/RedisConfigs/Basic/tls-ciphers-6384.conf index 857d5c741..374f272b3 100644 --- a/tests/RedisConfigs/Basic/tls-ciphers-6384.conf +++ b/tests/RedisConfigs/Basic/tls-ciphers-6384.conf @@ -9,4 +9,4 @@ tls-protocols "TLSv1.2 TLSv1.3" tls-cert-file /Certs/redis.crt tls-key-file /Certs/redis.key tls-ca-cert-file /Certs/ca.crt -notify-keyspace-events AKE +notify-keyspace-events AKESTIV diff --git a/tests/RedisConfigs/Cluster/cluster-7000.conf b/tests/RedisConfigs/Cluster/cluster-7000.conf index ad11a23fd..033dcba9d 100644 --- a/tests/RedisConfigs/Cluster/cluster-7000.conf +++ b/tests/RedisConfigs/Cluster/cluster-7000.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7000.rdb" appendfilename "appendonly-7000.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7001.conf b/tests/RedisConfigs/Cluster/cluster-7001.conf index 589f9ea23..29e3f3de2 100644 --- a/tests/RedisConfigs/Cluster/cluster-7001.conf +++ b/tests/RedisConfigs/Cluster/cluster-7001.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7001.rdb" appendfilename "appendonly-7001.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7002.conf b/tests/RedisConfigs/Cluster/cluster-7002.conf index 66a376865..1e4320095 100644 --- a/tests/RedisConfigs/Cluster/cluster-7002.conf +++ b/tests/RedisConfigs/Cluster/cluster-7002.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7002.rdb" appendfilename "appendonly-7002.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7003.conf b/tests/RedisConfigs/Cluster/cluster-7003.conf index 1f4883023..dca308929 100644 --- a/tests/RedisConfigs/Cluster/cluster-7003.conf +++ b/tests/RedisConfigs/Cluster/cluster-7003.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7003.rdb" appendfilename "appendonly-7003.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7004.conf b/tests/RedisConfigs/Cluster/cluster-7004.conf index 93d75f38a..290a37464 100644 --- a/tests/RedisConfigs/Cluster/cluster-7004.conf +++ b/tests/RedisConfigs/Cluster/cluster-7004.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7004.rdb" appendfilename "appendonly-7004.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7005.conf b/tests/RedisConfigs/Cluster/cluster-7005.conf index c9b5d55e2..0f936ee4e 100644 --- a/tests/RedisConfigs/Cluster/cluster-7005.conf +++ b/tests/RedisConfigs/Cluster/cluster-7005.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7005.rdb" appendfilename "appendonly-7005.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Failover/primary-6382.conf b/tests/RedisConfigs/Failover/primary-6382.conf index 6055c0347..de55eea89 100644 --- a/tests/RedisConfigs/Failover/primary-6382.conf +++ b/tests/RedisConfigs/Failover/primary-6382.conf @@ -7,4 +7,4 @@ dir "../Temp" appendonly no dbfilename "primary-6382.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Failover/replica-6383.conf b/tests/RedisConfigs/Failover/replica-6383.conf index e07f5a69d..8e3987de2 100644 --- a/tests/RedisConfigs/Failover/replica-6383.conf +++ b/tests/RedisConfigs/Failover/replica-6383.conf @@ -8,4 +8,4 @@ appendonly no dir "../Temp" dbfilename "replica-6383.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Sentinel/redis-7010.conf b/tests/RedisConfigs/Sentinel/redis-7010.conf index 878160632..566492e55 100644 --- a/tests/RedisConfigs/Sentinel/redis-7010.conf +++ b/tests/RedisConfigs/Sentinel/redis-7010.conf @@ -6,4 +6,4 @@ appendonly no dir "../Temp" dbfilename "sentinel-target-7010.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Sentinel/redis-7011.conf b/tests/RedisConfigs/Sentinel/redis-7011.conf index 08b8dad1a..45598a933 100644 --- a/tests/RedisConfigs/Sentinel/redis-7011.conf +++ b/tests/RedisConfigs/Sentinel/redis-7011.conf @@ -7,4 +7,4 @@ appendonly no dir "../Temp" dbfilename "sentinel-target-7011.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/docker-compose.yml b/tests/RedisConfigs/docker-compose.yml index 59ff825d8..bdcb8625b 100644 --- a/tests/RedisConfigs/docker-compose.yml +++ b/tests/RedisConfigs/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: .docker/Redis args: - CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.8-m02} + CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:unstable-24805570909-debian} additional_contexts: configs: . platform: linux diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs deleted file mode 100644 index 0a70aa739..000000000 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ /dev/null @@ -1,698 +0,0 @@ -using System; -using System.Buffers; -using System.Text; -using Xunit; -using Xunit.Sdk; - -namespace StackExchange.Redis.Tests; - -public class KeyNotificationTests(ITestOutputHelper log) -{ - [Theory] - [InlineData("foo", "foo")] - [InlineData("__foo__", "__foo__")] - [InlineData("__keyspace@4__:", "__keyspace@4__:")] // not long enough - [InlineData("__keyspace@4__:f", "f")] - [InlineData("__keyspace@4__:fo", "fo")] - [InlineData("__keyspace@4__:foo", "foo")] - [InlineData("__keyspace@42__:foo", "foo")] // check multi-char db - [InlineData("__keyevent@4__:foo", "__keyevent@4__:foo")] // key-event - [InlineData("__keyevent@42__:foo", "__keyevent@42__:foo")] // key-event - public void RoutingSpan_StripKeySpacePrefix(string raw, string routed) - { - ReadOnlySpan srcBytes = Encoding.UTF8.GetBytes(raw); - var strippedBytes = RedisChannel.StripKeySpacePrefix(srcBytes); - var result = Encoding.UTF8.GetString(strippedBytes); - Assert.Equal(routed, result); - } - - [Fact] - public void Keyspace_Del_ParsesCorrectly() - { - // __keyspace@1__:mykey with payload "del" - var channel = RedisChannel.Literal("__keyspace@1__:mykey"); - Assert.False(channel.IgnoreChannelPrefix); // because constructed manually - RedisValue value = "del"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeySpace); - Assert.False(notification.IsKeyEvent); - Assert.Equal(1, notification.Database); - Assert.Equal(KeyNotificationType.Del, notification.Type); - Assert.True(notification.IsType("del"u8)); - Assert.Equal("mykey", (string?)notification.GetKey()); - Assert.Equal(5, notification.GetKeyByteCount()); - Assert.Equal(5, notification.GetKeyMaxByteCount()); - Assert.Equal(5, notification.GetKeyCharCount()); - Assert.Equal(6, notification.GetKeyMaxCharCount()); - } - - [Fact] - public void Keyevent_Del_ParsesCorrectly() - { - // __keyevent@42__:del with value "mykey" - var channel = RedisChannel.Literal("__keyevent@42__:del"); - RedisValue value = "mykey"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.False(notification.IsKeySpace); - Assert.True(notification.IsKeyEvent); - Assert.Equal(42, notification.Database); - Assert.Equal(KeyNotificationType.Del, notification.Type); - Assert.True(notification.IsType("del"u8)); - Assert.Equal("mykey", (string?)notification.GetKey()); - Assert.Equal(5, notification.GetKeyByteCount()); - Assert.Equal(18, notification.GetKeyMaxByteCount()); - Assert.Equal(5, notification.GetKeyCharCount()); - Assert.Equal(5, notification.GetKeyMaxCharCount()); - } - - [Fact] - public void Keyspace_Set_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyspace@0__:testkey"); - RedisValue value = "set"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeySpace); - Assert.Equal(0, notification.Database); - Assert.Equal(KeyNotificationType.Set, notification.Type); - Assert.True(notification.IsType("set"u8)); - Assert.Equal("testkey", (string?)notification.GetKey()); - Assert.Equal(7, notification.GetKeyByteCount()); - Assert.Equal(7, notification.GetKeyMaxByteCount()); - Assert.Equal(7, notification.GetKeyCharCount()); - Assert.Equal(8, notification.GetKeyMaxCharCount()); - } - - [Fact] - public void Keyevent_Expire_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyevent@5__:expire"); - RedisValue value = "session:12345"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeyEvent); - Assert.Equal(5, notification.Database); - Assert.Equal(KeyNotificationType.Expire, notification.Type); - Assert.True(notification.IsType("expire"u8)); - Assert.Equal("session:12345", (string?)notification.GetKey()); - Assert.Equal(13, notification.GetKeyByteCount()); - Assert.Equal(42, notification.GetKeyMaxByteCount()); - Assert.Equal(13, notification.GetKeyCharCount()); - Assert.Equal(13, notification.GetKeyMaxCharCount()); - } - - [Fact] - public void Keyspace_Expired_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyspace@3__:cache:item"); - RedisValue value = "expired"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeySpace); - Assert.Equal(3, notification.Database); - Assert.Equal(KeyNotificationType.Expired, notification.Type); - Assert.True(notification.IsType("expired"u8)); - Assert.Equal("cache:item", (string?)notification.GetKey()); - Assert.Equal(10, notification.GetKeyByteCount()); - Assert.Equal(10, notification.GetKeyMaxByteCount()); - Assert.Equal(10, notification.GetKeyCharCount()); - Assert.Equal(11, notification.GetKeyMaxCharCount()); - } - - [Fact] - public void Keyevent_LPush_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyevent@0__:lpush"); - RedisValue value = "queue:tasks"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeyEvent); - Assert.Equal(0, notification.Database); - Assert.Equal(KeyNotificationType.LPush, notification.Type); - Assert.True(notification.IsType("lpush"u8)); - Assert.Equal("queue:tasks", (string?)notification.GetKey()); - Assert.Equal(11, notification.GetKeyByteCount()); - Assert.Equal(36, notification.GetKeyMaxByteCount()); - Assert.Equal(11, notification.GetKeyCharCount()); - Assert.Equal(11, notification.GetKeyMaxCharCount()); - } - - [Fact] - public void Keyspace_HSet_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyspace@2__:user:1000"); - RedisValue value = "hset"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeySpace); - Assert.Equal(2, notification.Database); - Assert.Equal(KeyNotificationType.HSet, notification.Type); - Assert.True(notification.IsType("hset"u8)); - Assert.Equal("user:1000", (string?)notification.GetKey()); - Assert.Equal(9, notification.GetKeyByteCount()); - Assert.Equal(9, notification.GetKeyMaxByteCount()); - Assert.Equal(9, notification.GetKeyCharCount()); - Assert.Equal(10, notification.GetKeyMaxCharCount()); - } - - [Fact] - public void Keyevent_ZAdd_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyevent@7__:zadd"); - RedisValue value = "leaderboard"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeyEvent); - Assert.Equal(7, notification.Database); - Assert.Equal(KeyNotificationType.ZAdd, notification.Type); - Assert.True(notification.IsType("zadd"u8)); - Assert.Equal("leaderboard", (string?)notification.GetKey()); - Assert.Equal(11, notification.GetKeyByteCount()); - Assert.Equal(36, notification.GetKeyMaxByteCount()); - Assert.Equal(11, notification.GetKeyCharCount()); - Assert.Equal(11, notification.GetKeyMaxCharCount()); - } - - [Fact] - public void CustomEventWithUnusualValue_Works() - { - var channel = RedisChannel.Literal("__keyevent@7__:flooble"); - RedisValue value = 17.5; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeyEvent); - Assert.Equal(7, notification.Database); - Assert.Equal(KeyNotificationType.Unknown, notification.Type); - Assert.False(notification.IsType("zadd"u8)); - Assert.True(notification.IsType("flooble"u8)); - Assert.Equal("17.5", (string?)notification.GetKey()); - Assert.Equal(4, notification.GetKeyByteCount()); - Assert.Equal(40, notification.GetKeyMaxByteCount()); - Assert.Equal(4, notification.GetKeyCharCount()); - Assert.Equal(40, notification.GetKeyMaxCharCount()); - } - - [Fact] - public void TryCopyKey_WorksCorrectly() - { - var channel = RedisChannel.Literal("__keyspace@0__:testkey"); - RedisValue value = "set"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - var lease = ArrayPool.Shared.Rent(20); - Span buffer = lease.AsSpan(0, 20); - Assert.True(notification.TryCopyKey(buffer, out var bytesWritten)); - Assert.Equal(7, bytesWritten); - Assert.Equal("testkey", Encoding.UTF8.GetString(lease, 0, bytesWritten)); - ArrayPool.Shared.Return(lease); - } - - [Fact] - public void TryCopyKey_FailsWithSmallBuffer() - { - var channel = RedisChannel.Literal("__keyspace@0__:testkey"); - RedisValue value = "set"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Span buffer = stackalloc byte[3]; // too small - Assert.False(notification.TryCopyKey(buffer, out var bytesWritten)); - Assert.Equal(0, bytesWritten); - } - - [Fact] - public void InvalidChannel_ReturnsFalse() - { - var channel = RedisChannel.Literal("regular:channel"); - RedisValue value = "data"; - - Assert.False(KeyNotification.TryParse(in channel, in value, out var notification)); - } - - [Fact] - public void InvalidKeyspaceChannel_MissingDelimiter_ReturnsFalse() - { - var channel = RedisChannel.Literal("__keyspace@0__"); // missing the key part - RedisValue value = "set"; - - Assert.False(KeyNotification.TryParse(in channel, in value, out var notification)); - } - - [Fact] - public void Keyspace_UnknownEventType_ReturnsUnknown() - { - var channel = RedisChannel.Literal("__keyspace@0__:mykey"); - RedisValue value = "unknownevent"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeySpace); - Assert.Equal(0, notification.Database); - Assert.Equal(KeyNotificationType.Unknown, notification.Type); - Assert.False(notification.IsType("del"u8)); - Assert.Equal("mykey", (string?)notification.GetKey()); - } - - [Fact] - public void Keyevent_UnknownEventType_ReturnsUnknown() - { - var channel = RedisChannel.Literal("__keyevent@0__:unknownevent"); - RedisValue value = "mykey"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeyEvent); - Assert.Equal(0, notification.Database); - Assert.Equal(KeyNotificationType.Unknown, notification.Type); - Assert.False(notification.IsType("del"u8)); - Assert.Equal("mykey", (string?)notification.GetKey()); - } - - [Fact] - public void Keyspace_WithColonInKey_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyspace@0__:user:session:12345"); - RedisValue value = "del"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeySpace); - Assert.Equal(0, notification.Database); - Assert.Equal(KeyNotificationType.Del, notification.Type); - Assert.True(notification.IsType("del"u8)); - Assert.Equal("user:session:12345", (string?)notification.GetKey()); - } - - [Fact] - public void Keyevent_Evicted_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyevent@1__:evicted"); - RedisValue value = "cache:old"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeyEvent); - Assert.Equal(1, notification.Database); - Assert.Equal(KeyNotificationType.Evicted, notification.Type); - Assert.True(notification.IsType("evicted"u8)); - Assert.Equal("cache:old", (string?)notification.GetKey()); - } - - [Fact] - public void Keyspace_New_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyspace@0__:newkey"); - RedisValue value = "new"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeySpace); - Assert.Equal(0, notification.Database); - Assert.Equal(KeyNotificationType.New, notification.Type); - Assert.True(notification.IsType("new"u8)); - Assert.Equal("newkey", (string?)notification.GetKey()); - } - - [Fact] - public void Keyevent_XGroupCreate_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyevent@0__:xgroup-create"); - RedisValue value = "mystream"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeyEvent); - Assert.Equal(0, notification.Database); - Assert.Equal(KeyNotificationType.XGroupCreate, notification.Type); - Assert.True(notification.IsType("xgroup-create"u8)); - Assert.Equal("mystream", (string?)notification.GetKey()); - } - - [Fact] - public void Keyspace_TypeChanged_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyspace@0__:mykey"); - RedisValue value = "type_changed"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeySpace); - Assert.Equal(0, notification.Database); - Assert.Equal(KeyNotificationType.TypeChanged, notification.Type); - Assert.True(notification.IsType("type_changed"u8)); - Assert.Equal("mykey", (string?)notification.GetKey()); - } - - [Fact] - public void Keyevent_HighDatabaseNumber_ParsesCorrectly() - { - var channel = RedisChannel.Literal("__keyevent@999__:set"); - RedisValue value = "testkey"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeyEvent); - Assert.Equal(999, notification.Database); - Assert.Equal(KeyNotificationType.Set, notification.Type); - Assert.True(notification.IsType("set"u8)); - Assert.Equal("testkey", (string?)notification.GetKey()); - } - - [Fact] - public void Keyevent_NonIntegerDatabase_ParsesWellEnough() - { - var channel = RedisChannel.Literal("__keyevent@abc__:set"); - RedisValue value = "testkey"; - - Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - - Assert.True(notification.IsKeyEvent); - Assert.Equal(-1, notification.Database); - Assert.Equal(KeyNotificationType.Set, notification.Type); - Assert.True(notification.IsType("set"u8)); - Assert.Equal("testkey", (string?)notification.GetKey()); - } - - [Fact] - public void DefaultKeyNotification_HasExpectedProperties() - { - var notification = default(KeyNotification); - - Assert.False(notification.IsKeySpace); - Assert.False(notification.IsKeyEvent); - Assert.Equal(-1, notification.Database); - Assert.Equal(KeyNotificationType.Unknown, notification.Type); - Assert.False(notification.IsType("del"u8)); - Assert.True(notification.GetKey().IsNull); - Assert.Equal(0, notification.GetKeyByteCount()); - Assert.Equal(0, notification.GetKeyMaxByteCount()); - Assert.Equal(0, notification.GetKeyCharCount()); - Assert.Equal(0, notification.GetKeyMaxCharCount()); - Assert.True(notification.GetChannel().IsNull); - Assert.True(notification.GetValue().IsNull); - - // TryCopyKey should return false and write 0 bytes - Span buffer = stackalloc byte[10]; - Assert.False(notification.TryCopyKey(buffer, out var bytesWritten)); - Assert.Equal(0, bytesWritten); - } - - [Theory] - [InlineData("append", KeyNotificationType.Append)] - [InlineData("copy", KeyNotificationType.Copy)] - [InlineData("del", KeyNotificationType.Del)] - [InlineData("expire", KeyNotificationType.Expire)] - [InlineData("hdel", KeyNotificationType.HDel)] - [InlineData("hexpired", KeyNotificationType.HExpired)] - [InlineData("hincrbyfloat", KeyNotificationType.HIncrByFloat)] - [InlineData("hincrby", KeyNotificationType.HIncrBy)] - [InlineData("hpersist", KeyNotificationType.HPersist)] - [InlineData("hset", KeyNotificationType.HSet)] - [InlineData("incrbyfloat", KeyNotificationType.IncrByFloat)] - [InlineData("incrby", KeyNotificationType.IncrBy)] - [InlineData("linsert", KeyNotificationType.LInsert)] - [InlineData("lpop", KeyNotificationType.LPop)] - [InlineData("lpush", KeyNotificationType.LPush)] - [InlineData("lrem", KeyNotificationType.LRem)] - [InlineData("lset", KeyNotificationType.LSet)] - [InlineData("ltrim", KeyNotificationType.LTrim)] - [InlineData("move_from", KeyNotificationType.MoveFrom)] - [InlineData("move_to", KeyNotificationType.MoveTo)] - [InlineData("persist", KeyNotificationType.Persist)] - [InlineData("rename_from", KeyNotificationType.RenameFrom)] - [InlineData("rename_to", KeyNotificationType.RenameTo)] - [InlineData("restore", KeyNotificationType.Restore)] - [InlineData("rpop", KeyNotificationType.RPop)] - [InlineData("rpush", KeyNotificationType.RPush)] - [InlineData("sadd", KeyNotificationType.SAdd)] - [InlineData("set", KeyNotificationType.Set)] - [InlineData("setrange", KeyNotificationType.SetRange)] - [InlineData("sortstore", KeyNotificationType.SortStore)] - [InlineData("srem", KeyNotificationType.SRem)] - [InlineData("spop", KeyNotificationType.SPop)] - [InlineData("xadd", KeyNotificationType.XAdd)] - [InlineData("xdel", KeyNotificationType.XDel)] - [InlineData("xgroup-createconsumer", KeyNotificationType.XGroupCreateConsumer)] - [InlineData("xgroup-create", KeyNotificationType.XGroupCreate)] - [InlineData("xgroup-delconsumer", KeyNotificationType.XGroupDelConsumer)] - [InlineData("xgroup-destroy", KeyNotificationType.XGroupDestroy)] - [InlineData("xgroup-setid", KeyNotificationType.XGroupSetId)] - [InlineData("xsetid", KeyNotificationType.XSetId)] - [InlineData("xtrim", KeyNotificationType.XTrim)] - [InlineData("zadd", KeyNotificationType.ZAdd)] - [InlineData("zdiffstore", KeyNotificationType.ZDiffStore)] - [InlineData("zinterstore", KeyNotificationType.ZInterStore)] - [InlineData("zunionstore", KeyNotificationType.ZUnionStore)] - [InlineData("zincr", KeyNotificationType.ZIncr)] - [InlineData("zrembyrank", KeyNotificationType.ZRemByRank)] - [InlineData("zrembyscore", KeyNotificationType.ZRemByScore)] - [InlineData("zrem", KeyNotificationType.ZRem)] - [InlineData("expired", KeyNotificationType.Expired)] - [InlineData("evicted", KeyNotificationType.Evicted)] - [InlineData("new", KeyNotificationType.New)] - [InlineData("overwritten", KeyNotificationType.Overwritten)] - [InlineData("type_changed", KeyNotificationType.TypeChanged)] - public unsafe void FastHashParse_AllKnownValues_ParseCorrectly(string raw, KeyNotificationType parsed) - { - var arr = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(raw.Length)); - int bytes; - fixed (byte* bPtr = arr) // encode into the buffer - { - fixed (char* cPtr = raw) - { - bytes = Encoding.UTF8.GetBytes(cPtr, raw.Length, bPtr, arr.Length); - } - } - - var result = KeyNotificationTypeMetadata.Parse(arr.AsSpan(0, bytes)); - log.WriteLine($"Parsed '{raw}' as {result}"); - Assert.Equal(parsed, result); - - // and the other direction: - var fetchedBytes = KeyNotificationTypeMetadata.GetRawBytes(parsed); - string fetched; - fixed (byte* bPtr = fetchedBytes) - { - fetched = Encoding.UTF8.GetString(bPtr, fetchedBytes.Length); - } - - log.WriteLine($"Fetched '{raw}'"); - Assert.Equal(raw, fetched); - - ArrayPool.Shared.Return(arr); - } - - [Fact] - public void CreateKeySpaceNotification_Valid() - { - var channel = RedisChannel.KeySpaceSingleKey("abc", 42); - Assert.Equal("__keyspace@42__:abc", channel.ToString()); - Assert.False(channel.IsMultiNode); - Assert.True(channel.IsKeyRouted); - Assert.False(channel.IsSharded); - Assert.False(channel.IsPattern); - Assert.True(channel.IgnoreChannelPrefix); - } - - [Theory] - [InlineData(null, null, "__keyspace@*__:*")] - [InlineData("abc*", null, "__keyspace@*__:abc*")] - [InlineData(null, 42, "__keyspace@42__:*")] - [InlineData("abc*", 42, "__keyspace@42__:abc*")] - public void CreateKeySpaceNotificationPattern(string? pattern, int? database, string expected) - { - var channel = RedisChannel.KeySpacePattern(pattern, database); - Assert.Equal(expected, channel.ToString()); - Assert.True(channel.IsMultiNode); - Assert.False(channel.IsKeyRouted); - Assert.False(channel.IsSharded); - Assert.True(channel.IsPattern); - Assert.True(channel.IgnoreChannelPrefix); - } - - [Theory] - [InlineData("abc", null, "__keyspace@*__:abc*")] - [InlineData("abc", 42, "__keyspace@42__:abc*")] - public void CreateKeySpaceNotificationPrefix_Key(string prefix, int? database, string expected) - { - var channel = RedisChannel.KeySpacePrefix((RedisKey)prefix, database); - Assert.Equal(expected, channel.ToString()); - Assert.True(channel.IsMultiNode); - Assert.False(channel.IsKeyRouted); - Assert.False(channel.IsSharded); - Assert.True(channel.IsPattern); - Assert.True(channel.IgnoreChannelPrefix); - } - - [Theory] - [InlineData("abc", null, "__keyspace@*__:abc*")] - [InlineData("abc", 42, "__keyspace@42__:abc*")] - public void CreateKeySpaceNotificationPrefix_Span(string prefix, int? database, string expected) - { - var channel = RedisChannel.KeySpacePrefix((ReadOnlySpan)Encoding.UTF8.GetBytes(prefix), database); - Assert.Equal(expected, channel.ToString()); - Assert.True(channel.IsMultiNode); - Assert.False(channel.IsKeyRouted); - Assert.False(channel.IsSharded); - Assert.True(channel.IsPattern); - Assert.True(channel.IgnoreChannelPrefix); - } - - [Theory] - [InlineData("a?bc", null)] - [InlineData("a?bc", 42)] - [InlineData("a*bc", null)] - [InlineData("a*bc", 42)] - [InlineData("a[bc", null)] - [InlineData("a[bc", 42)] - public void CreateKeySpaceNotificationPrefix_DisallowGlob(string prefix, int? database) - { - var bytes = Encoding.UTF8.GetBytes(prefix); - var ex = Assert.Throws(() => - RedisChannel.KeySpacePrefix((RedisKey)bytes, database)); - Assert.StartsWith("The supplied key contains pattern characters, but patterns are not supported in this context.", ex.Message); - - ex = Assert.Throws(() => - RedisChannel.KeySpacePrefix((ReadOnlySpan)bytes, database)); - Assert.StartsWith("The supplied key contains pattern characters, but patterns are not supported in this context.", ex.Message); - } - - [Theory] - [InlineData(KeyNotificationType.Set, null, "__keyevent@*__:set", true)] - [InlineData(KeyNotificationType.XGroupCreate, null, "__keyevent@*__:xgroup-create", true)] - [InlineData(KeyNotificationType.Set, 42, "__keyevent@42__:set", false)] - [InlineData(KeyNotificationType.XGroupCreate, 42, "__keyevent@42__:xgroup-create", false)] - public void CreateKeyEventNotification(KeyNotificationType type, int? database, string expected, bool isPattern) - { - var channel = RedisChannel.KeyEvent(type, database); - Assert.Equal(expected, channel.ToString()); - Assert.True(channel.IsMultiNode); - Assert.False(channel.IsKeyRouted); - Assert.False(channel.IsSharded); - Assert.True(channel.IgnoreChannelPrefix); - if (isPattern) - { - Assert.True(channel.IsPattern); - } - else - { - Assert.False(channel.IsPattern); - } - } - - [Theory] - [InlineData("abc", "__keyspace@42__:abc")] - [InlineData("a*bc", "__keyspace@42__:a*bc")] // pattern-like is allowed, since not using PSUBSCRIBE - public void Cannot_KeyRoute_KeySpace_SingleKeyIsKeyRouted(string key, string pattern) - { - var channel = RedisChannel.KeySpaceSingleKey(key, 42); - Assert.Equal(pattern, channel.ToString()); - Assert.False(channel.IsMultiNode); - Assert.False(channel.IsPattern); - Assert.False(channel.IsSharded); - Assert.True(channel.IgnoreChannelPrefix); - Assert.True(channel.IsKeyRouted); - Assert.True(channel.WithKeyRouting().IsKeyRouted); // no change, still key-routed - Assert.Equal(RedisCommand.PUBLISH, channel.GetPublishCommand()); - } - - [Fact] - public void Cannot_KeyRoute_KeySpacePattern() - { - var channel = RedisChannel.KeySpacePattern("abc", 42); - Assert.True(channel.IsMultiNode); - Assert.False(channel.IsKeyRouted); - Assert.True(channel.IgnoreChannelPrefix); - Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); - Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); - } - - [Fact] - public void Cannot_KeyRoute_KeyEvent() - { - var channel = RedisChannel.KeyEvent(KeyNotificationType.Set, 42); - Assert.True(channel.IsMultiNode); - Assert.False(channel.IsKeyRouted); - Assert.True(channel.IgnoreChannelPrefix); - Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); - Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); - } - - [Fact] - public void Cannot_KeyRoute_KeyEvent_Custom() - { - var channel = RedisChannel.KeyEvent("foo"u8, 42); - Assert.True(channel.IsMultiNode); - Assert.False(channel.IsKeyRouted); - Assert.True(channel.IgnoreChannelPrefix); - Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); - Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); - } - - [Fact] - public void KeyEventPrefix_KeySpacePrefix_Length_Matches() - { - // this is a sanity check for the parsing step in KeyNotification.TryParse - Assert.Equal(KeyNotificationChannels.KeySpacePrefix.Length, KeyNotificationChannels.KeyEventPrefix.Length); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void KeyNotificationKeyStripping(bool asString) - { - Span blob = stackalloc byte[32]; - Span clob = stackalloc char[32]; - - RedisChannel channel = RedisChannel.Literal("__keyevent@0__:sadd"); - RedisValue value = asString ? "mykey:abc" : "mykey:abc"u8.ToArray(); - KeyNotification.TryParse(in channel, in value, out var notification); - Assert.Equal("mykey:abc", (string?)notification.GetKey()); - Assert.True(notification.KeyStartsWith("mykey:"u8)); - Assert.Equal(0, notification.KeyOffset); - - Assert.Equal(9, notification.GetKeyByteCount()); - Assert.Equal(asString ? 30 : 9, notification.GetKeyMaxByteCount()); - Assert.Equal(9, notification.GetKeyCharCount()); - Assert.Equal(asString ? 9 : 10, notification.GetKeyMaxCharCount()); - - Assert.True(notification.TryCopyKey(blob, out var bytesWritten)); - Assert.Equal(9, bytesWritten); - Assert.Equal("mykey:abc", Encoding.UTF8.GetString(blob.Slice(0, bytesWritten))); - - Assert.True(notification.TryCopyKey(clob, out var charsWritten)); - Assert.Equal(9, charsWritten); - Assert.Equal("mykey:abc", clob.Slice(0, charsWritten).ToString()); - - // now with a prefix - notification = notification.WithKeySlice("mykey:"u8.Length); - Assert.Equal("abc", (string?)notification.GetKey()); - Assert.False(notification.KeyStartsWith("mykey:"u8)); - Assert.Equal(6, notification.KeyOffset); - - Assert.Equal(3, notification.GetKeyByteCount()); - Assert.Equal(asString ? 24 : 3, notification.GetKeyMaxByteCount()); - Assert.Equal(3, notification.GetKeyCharCount()); - Assert.Equal(asString ? 3 : 4, notification.GetKeyMaxCharCount()); - - Assert.True(notification.TryCopyKey(blob, out bytesWritten)); - Assert.Equal(3, bytesWritten); - Assert.Equal("abc", Encoding.UTF8.GetString(blob.Slice(0, bytesWritten))); - - Assert.True(notification.TryCopyKey(clob, out charsWritten)); - Assert.Equal(3, charsWritten); - Assert.Equal("abc", clob.Slice(0, charsWritten).ToString()); - } -} diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs new file mode 100644 index 000000000..eba9d9e4b --- /dev/null +++ b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs @@ -0,0 +1,1770 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; +using Xunit; +using Xunit.Sdk; + +namespace StackExchange.Redis.Tests; + +public class KeyNotificationUnitTests(ITestOutputHelper log) +{ + [Theory] + [InlineData("foo", "foo")] + [InlineData("__foo__", "__foo__")] + [InlineData("__keyspace@4__:", "__keyspace@4__:")] // not long enough + [InlineData("__keyspace@4__:f", "f")] + [InlineData("__keyspace@4__:fo", "fo")] + [InlineData("__keyspace@4__:foo", "foo")] + [InlineData("__keyspace@42__:foo", "foo")] // check multi-char db + [InlineData("__keyevent@4__:foo", "__keyevent@4__:foo")] // key-event + [InlineData("__keyevent@42__:foo", "__keyevent@42__:foo")] // key-event + public void RoutingSpan_StripKeySpacePrefix(string raw, string routed) + { + ReadOnlySpan srcBytes = Encoding.UTF8.GetBytes(raw); + var strippedBytes = RedisChannel.StripKeySpacePrefix(srcBytes); + var result = Encoding.UTF8.GetString(strippedBytes); + Assert.Equal(routed, result); + } + + [Fact] + public void Keyspace_Del_ParsesCorrectly() + { + // __keyspace@1__:mykey with payload "del" + var channel = RedisChannel.Literal("__keyspace@1__:mykey"); + Assert.False(channel.IgnoreChannelPrefix); // because constructed manually + RedisValue value = "del"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + Assert.Equal(1, notification.Database); + Assert.Equal(KeyNotificationType.Del, notification.Type); + Assert.True(notification.IsType("del"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal(5, notification.GetKeyByteCount()); + Assert.Equal(5, notification.GetKeyMaxByteCount()); + Assert.Equal(5, notification.GetKeyCharCount()); + Assert.Equal(6, notification.GetKeyMaxCharCount()); + + // Test TryCopyKey (bytes) + Span keyBuffer = stackalloc byte[10]; + Assert.True(notification.TryCopyKey(keyBuffer, out var bytesWritten)); + Assert.Equal(5, bytesWritten); + Assert.Equal("mykey", Encoding.UTF8.GetString(keyBuffer.Slice(0, bytesWritten))); + + // Test TryCopyKey (chars) + Span charBuffer = stackalloc char[10]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(5, charsWritten); + Assert.Equal("mykey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void Keyevent_Del_ParsesCorrectly() + { + // __keyevent@42__:del with value "mykey" + var channel = RedisChannel.Literal("__keyevent@42__:del"); + RedisValue value = "mykey"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(42, notification.Database); + Assert.Equal(KeyNotificationType.Del, notification.Type); + Assert.True(notification.IsType("del"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal(5, notification.GetKeyByteCount()); + Assert.Equal(18, notification.GetKeyMaxByteCount()); + Assert.Equal(5, notification.GetKeyCharCount()); + Assert.Equal(5, notification.GetKeyMaxCharCount()); + } + + [Fact] + public void Keyspace_Set_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@0__:testkey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.Set, notification.Type); + Assert.True(notification.IsType("set"u8)); + Assert.Equal("testkey", (string?)notification.GetKey()); + Assert.Equal(7, notification.GetKeyByteCount()); + Assert.Equal(7, notification.GetKeyMaxByteCount()); + Assert.Equal(7, notification.GetKeyCharCount()); + Assert.Equal(8, notification.GetKeyMaxCharCount()); + } + + [Fact] + public void Keyevent_Expire_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@5__:expire"); + RedisValue value = "session:12345"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(5, notification.Database); + Assert.Equal(KeyNotificationType.Expire, notification.Type); + Assert.True(notification.IsType("expire"u8)); + Assert.Equal("session:12345", (string?)notification.GetKey()); + Assert.Equal(13, notification.GetKeyByteCount()); + Assert.Equal(42, notification.GetKeyMaxByteCount()); + Assert.Equal(13, notification.GetKeyCharCount()); + Assert.Equal(13, notification.GetKeyMaxCharCount()); + } + + [Fact] + public void Keyspace_Expired_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@3__:cache:item"); + RedisValue value = "expired"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + Assert.Equal(3, notification.Database); + Assert.Equal(KeyNotificationType.Expired, notification.Type); + Assert.True(notification.IsType("expired"u8)); + Assert.Equal("cache:item", (string?)notification.GetKey()); + Assert.Equal(10, notification.GetKeyByteCount()); + Assert.Equal(10, notification.GetKeyMaxByteCount()); + Assert.Equal(10, notification.GetKeyCharCount()); + Assert.Equal(11, notification.GetKeyMaxCharCount()); + } + + [Fact] + public void Keyevent_LPush_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@0__:lpush"); + RedisValue value = "queue:tasks"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.LPush, notification.Type); + Assert.True(notification.IsType("lpush"u8)); + Assert.Equal("queue:tasks", (string?)notification.GetKey()); + Assert.Equal(11, notification.GetKeyByteCount()); + Assert.Equal(36, notification.GetKeyMaxByteCount()); + Assert.Equal(11, notification.GetKeyCharCount()); + Assert.Equal(11, notification.GetKeyMaxCharCount()); + } + + [Fact] + public void Keyspace_HSet_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@2__:user:1000"); + RedisValue value = "hset"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + Assert.Equal(2, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.True(notification.IsType("hset"u8)); + Assert.Equal("user:1000", (string?)notification.GetKey()); + Assert.Equal(9, notification.GetKeyByteCount()); + Assert.Equal(9, notification.GetKeyMaxByteCount()); + Assert.Equal(9, notification.GetKeyCharCount()); + Assert.Equal(10, notification.GetKeyMaxCharCount()); + } + + [Fact] + public void Keyevent_ZAdd_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@7__:zadd"); + RedisValue value = "leaderboard"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(7, notification.Database); + Assert.Equal(KeyNotificationType.ZAdd, notification.Type); + Assert.True(notification.IsType("zadd"u8)); + Assert.Equal("leaderboard", (string?)notification.GetKey()); + Assert.Equal(11, notification.GetKeyByteCount()); + Assert.Equal(36, notification.GetKeyMaxByteCount()); + Assert.Equal(11, notification.GetKeyCharCount()); + Assert.Equal(11, notification.GetKeyMaxCharCount()); + } + + [Fact] + public void CustomEventWithUnusualValue_Works() + { + var channel = RedisChannel.Literal("__keyevent@7__:flooble"); + RedisValue value = 17.5; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(7, notification.Database); + Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.False(notification.IsType("zadd"u8)); + Assert.True(notification.IsType("flooble"u8)); + Assert.Equal("17.5", (string?)notification.GetKey()); + Assert.Equal(4, notification.GetKeyByteCount()); + Assert.Equal(40, notification.GetKeyMaxByteCount()); + Assert.Equal(4, notification.GetKeyCharCount()); + Assert.Equal(40, notification.GetKeyMaxCharCount()); + } + + [Fact] + public void TryCopyKey_WorksCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@0__:testkey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + var lease = ArrayPool.Shared.Rent(20); + Span buffer = lease.AsSpan(0, 20); + Assert.True(notification.TryCopyKey(buffer, out var bytesWritten)); + Assert.Equal(7, bytesWritten); + Assert.Equal("testkey", Encoding.UTF8.GetString(lease, 0, bytesWritten)); + ArrayPool.Shared.Return(lease); + } + + [Fact] + public void TryCopyKey_FailsWithSmallBuffer() + { + var channel = RedisChannel.Literal("__keyspace@0__:testkey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Span buffer = stackalloc byte[3]; // too small + Assert.False(notification.TryCopyKey(buffer, out var bytesWritten)); + Assert.Equal(7, bytesWritten); // Should report the actual size needed (length of "testkey") + } + + [Fact] + public void InvalidChannel_ReturnsFalse() + { + var channel = RedisChannel.Literal("regular:channel"); + RedisValue value = "data"; + + Assert.False(KeyNotification.TryParse(in channel, in value, out var notification)); + } + + [Fact] + public void InvalidKeyspaceChannel_MissingDelimiter_ReturnsFalse() + { + var channel = RedisChannel.Literal("__keyspace@0__"); // missing the key part + RedisValue value = "set"; + + Assert.False(KeyNotification.TryParse(in channel, in value, out var notification)); + } + + [Fact] + public void Keyspace_UnknownEventType_ReturnsUnknown() + { + var channel = RedisChannel.Literal("__keyspace@0__:mykey"); + RedisValue value = "unknownevent"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.False(notification.IsType("del"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_UnknownEventType_ReturnsUnknown() + { + var channel = RedisChannel.Literal("__keyevent@0__:unknownevent"); + RedisValue value = "mykey"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.False(notification.IsType("del"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyspace_WithColonInKey_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@0__:user:session:12345"); + RedisValue value = "del"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.Del, notification.Type); + Assert.True(notification.IsType("del"u8)); + Assert.Equal("user:session:12345", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_Evicted_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@1__:evicted"); + RedisValue value = "cache:old"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(1, notification.Database); + Assert.Equal(KeyNotificationType.Evicted, notification.Type); + Assert.True(notification.IsType("evicted"u8)); + Assert.Equal("cache:old", (string?)notification.GetKey()); + } + + [Fact] + public void Keyspace_New_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@0__:newkey"); + RedisValue value = "new"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.New, notification.Type); + Assert.True(notification.IsType("new"u8)); + Assert.Equal("newkey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_XGroupCreate_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@0__:xgroup-create"); + RedisValue value = "mystream"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.XGroupCreate, notification.Type); + Assert.True(notification.IsType("xgroup-create"u8)); + Assert.Equal("mystream", (string?)notification.GetKey()); + } + + [Fact] + public void Keyspace_TypeChanged_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@0__:mykey"); + RedisValue value = "type_changed"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.TypeChanged, notification.Type); + Assert.True(notification.IsType("type_changed"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_HighDatabaseNumber_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@999__:set"); + RedisValue value = "testkey"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(999, notification.Database); + Assert.Equal(KeyNotificationType.Set, notification.Type); + Assert.True(notification.IsType("set"u8)); + Assert.Equal("testkey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_NonIntegerDatabase_ParsesWellEnough() + { + var channel = RedisChannel.Literal("__keyevent@abc__:set"); + RedisValue value = "testkey"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(-1, notification.Database); + Assert.Equal(KeyNotificationType.Set, notification.Type); + Assert.True(notification.IsType("set"u8)); + Assert.Equal("testkey", (string?)notification.GetKey()); + } + + [Fact] + public void DefaultKeyNotification_HasExpectedProperties() + { + var notification = default(KeyNotification); + + Assert.Equal(KeyNotificationKind.Unknown, notification.Kind); + Assert.Equal(-1, notification.Database); + Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.False(notification.IsType("del"u8)); + Assert.True(notification.GetKey().IsNull); + Assert.True(notification.GetSubKeys().FirstOrDefault().IsNull); + Assert.Equal(0, notification.GetKeyByteCount()); + Assert.Equal(0, notification.GetKeyMaxByteCount()); + Assert.Equal(0, notification.GetKeyCharCount()); + Assert.Equal(0, notification.GetKeyMaxCharCount()); + Assert.True(notification.GetChannel().IsNull); + Assert.True(notification.GetValue().IsNull); + + // TryCopyKey should return false and write 0 bytes + Span buffer = stackalloc byte[10]; + Assert.False(notification.TryCopyKey(buffer, out var bytesWritten)); + Assert.Equal(0, bytesWritten); + } + + [Theory] + [InlineData("append", KeyNotificationType.Append)] + [InlineData("copy", KeyNotificationType.Copy)] + [InlineData("del", KeyNotificationType.Del)] + [InlineData("expire", KeyNotificationType.Expire)] + [InlineData("hdel", KeyNotificationType.HDel)] + [InlineData("hexpired", KeyNotificationType.HExpired)] + [InlineData("hincrbyfloat", KeyNotificationType.HIncrByFloat)] + [InlineData("hincrby", KeyNotificationType.HIncrBy)] + [InlineData("hpersist", KeyNotificationType.HPersist)] + [InlineData("hset", KeyNotificationType.HSet)] + [InlineData("incrbyfloat", KeyNotificationType.IncrByFloat)] + [InlineData("incrby", KeyNotificationType.IncrBy)] + [InlineData("linsert", KeyNotificationType.LInsert)] + [InlineData("lpop", KeyNotificationType.LPop)] + [InlineData("lpush", KeyNotificationType.LPush)] + [InlineData("lrem", KeyNotificationType.LRem)] + [InlineData("lset", KeyNotificationType.LSet)] + [InlineData("ltrim", KeyNotificationType.LTrim)] + [InlineData("move_from", KeyNotificationType.MoveFrom)] + [InlineData("move_to", KeyNotificationType.MoveTo)] + [InlineData("persist", KeyNotificationType.Persist)] + [InlineData("rename_from", KeyNotificationType.RenameFrom)] + [InlineData("rename_to", KeyNotificationType.RenameTo)] + [InlineData("restore", KeyNotificationType.Restore)] + [InlineData("rpop", KeyNotificationType.RPop)] + [InlineData("rpush", KeyNotificationType.RPush)] + [InlineData("sadd", KeyNotificationType.SAdd)] + [InlineData("set", KeyNotificationType.Set)] + [InlineData("setrange", KeyNotificationType.SetRange)] + [InlineData("sortstore", KeyNotificationType.SortStore)] + [InlineData("srem", KeyNotificationType.SRem)] + [InlineData("spop", KeyNotificationType.SPop)] + [InlineData("xadd", KeyNotificationType.XAdd)] + [InlineData("xdel", KeyNotificationType.XDel)] + [InlineData("xgroup-createconsumer", KeyNotificationType.XGroupCreateConsumer)] + [InlineData("xgroup-create", KeyNotificationType.XGroupCreate)] + [InlineData("xgroup-delconsumer", KeyNotificationType.XGroupDelConsumer)] + [InlineData("xgroup-destroy", KeyNotificationType.XGroupDestroy)] + [InlineData("xgroup-setid", KeyNotificationType.XGroupSetId)] + [InlineData("xsetid", KeyNotificationType.XSetId)] + [InlineData("xtrim", KeyNotificationType.XTrim)] + [InlineData("zadd", KeyNotificationType.ZAdd)] + [InlineData("zdiffstore", KeyNotificationType.ZDiffStore)] + [InlineData("zinterstore", KeyNotificationType.ZInterStore)] + [InlineData("zunionstore", KeyNotificationType.ZUnionStore)] + [InlineData("zincr", KeyNotificationType.ZIncr)] + [InlineData("zrembyrank", KeyNotificationType.ZRemByRank)] + [InlineData("zrembyscore", KeyNotificationType.ZRemByScore)] + [InlineData("zrem", KeyNotificationType.ZRem)] + [InlineData("hexpire", KeyNotificationType.HExpire)] + [InlineData("expired", KeyNotificationType.Expired)] + [InlineData("evicted", KeyNotificationType.Evicted)] + [InlineData("new", KeyNotificationType.New)] + [InlineData("overwritten", KeyNotificationType.Overwritten)] + [InlineData("type_changed", KeyNotificationType.TypeChanged)] + public unsafe void FastHashParse_AllKnownValues_ParseCorrectly(string raw, KeyNotificationType parsed) + { + var arr = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(raw.Length)); + int bytes; + fixed (byte* bPtr = arr) // encode into the buffer + { + fixed (char* cPtr = raw) + { + bytes = Encoding.UTF8.GetBytes(cPtr, raw.Length, bPtr, arr.Length); + } + } + + var result = KeyNotificationTypeMetadata.Parse(arr.AsSpan(0, bytes)); + log.WriteLine($"Parsed '{raw}' as {result}"); + Assert.Equal(parsed, result); + + // and the other direction: + var fetchedBytes = KeyNotificationTypeMetadata.GetRawBytes(parsed); + string fetched; + fixed (byte* bPtr = fetchedBytes) + { + fetched = Encoding.UTF8.GetString(bPtr, fetchedBytes.Length); + } + + log.WriteLine($"Fetched '{raw}'"); + Assert.Equal(raw, fetched); + + ArrayPool.Shared.Return(arr); + } + + [Fact] + public void CreateKeySpaceNotification_Valid() + { + var channel = RedisChannel.KeySpaceSingleKey("abc", 42); + Assert.Equal("__keyspace@42__:abc", channel.ToString()); + Assert.False(channel.IsMultiNode); + Assert.True(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.False(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData(null, null, "__keyspace@*__:*")] + [InlineData("abc*", null, "__keyspace@*__:abc*")] + [InlineData(null, 42, "__keyspace@42__:*")] + [InlineData("abc*", 42, "__keyspace@42__:abc*")] + public void CreateKeySpaceNotificationPattern(string? pattern, int? database, string expected) + { + var channel = RedisChannel.KeySpacePattern(pattern, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData("abc", null, "__keyspace@*__:abc*")] + [InlineData("abc", 42, "__keyspace@42__:abc*")] + public void CreateKeySpaceNotificationPrefix_Key(string prefix, int? database, string expected) + { + var channel = RedisChannel.KeySpacePrefix((RedisKey)prefix, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData("abc", null, "__keyspace@*__:abc*")] + [InlineData("abc", 42, "__keyspace@42__:abc*")] + public void CreateKeySpaceNotificationPrefix_Span(string prefix, int? database, string expected) + { + var channel = RedisChannel.KeySpacePrefix((ReadOnlySpan)Encoding.UTF8.GetBytes(prefix), database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData("a?bc", null)] + [InlineData("a?bc", 42)] + [InlineData("a*bc", null)] + [InlineData("a*bc", 42)] + [InlineData("a[bc", null)] + [InlineData("a[bc", 42)] + public void CreateKeySpaceNotificationPrefix_DisallowGlob(string prefix, int? database) + { + var bytes = Encoding.UTF8.GetBytes(prefix); + var ex = Assert.Throws(() => + RedisChannel.KeySpacePrefix((RedisKey)bytes, database)); + Assert.StartsWith("The supplied key contains pattern characters, but patterns are not supported in this context.", ex.Message); + + ex = Assert.Throws(() => + RedisChannel.KeySpacePrefix((ReadOnlySpan)bytes, database)); + Assert.StartsWith("The supplied key contains pattern characters, but patterns are not supported in this context.", ex.Message); + } + + [Theory] + [InlineData(KeyNotificationType.Set, null, "__keyevent@*__:set", true)] + [InlineData(KeyNotificationType.XGroupCreate, null, "__keyevent@*__:xgroup-create", true)] + [InlineData(KeyNotificationType.Set, 42, "__keyevent@42__:set", false)] + [InlineData(KeyNotificationType.XGroupCreate, 42, "__keyevent@42__:xgroup-create", false)] + public void CreateKeyEventNotification(KeyNotificationType type, int? database, string expected, bool isPattern) + { + var channel = RedisChannel.KeyEvent(type, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IgnoreChannelPrefix); + if (isPattern) + { + Assert.True(channel.IsPattern); + } + else + { + Assert.False(channel.IsPattern); + } + } + + [Fact] + public void CreateSubKeySpaceNotification_Valid() + { + var channel = RedisChannel.SubKeySpaceSingleKey("myhash", 42); + Assert.Equal("__subkeyspace@42__:myhash", channel.ToString()); + Assert.False(channel.IsMultiNode); + Assert.True(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.False(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData(null, null, "__subkeyspace@*__:*")] + [InlineData("hash*", null, "__subkeyspace@*__:hash*")] + [InlineData(null, 42, "__subkeyspace@42__:*")] + [InlineData("hash*", 42, "__subkeyspace@42__:hash*")] + public void CreateSubKeySpaceNotificationPattern(string? pattern, int? database, string expected) + { + var channel = RedisChannel.SubKeySpacePattern(pattern, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData("hash:", null, "__subkeyspace@*__:hash:*")] + [InlineData("hash:", 42, "__subkeyspace@42__:hash:*")] + public void CreateSubKeySpaceNotificationPrefix_Key(string prefix, int? database, string expected) + { + var channel = RedisChannel.SubKeySpacePrefix((RedisKey)prefix, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData("hash:", null, "__subkeyspace@*__:hash:*")] + [InlineData("hash:", 42, "__subkeyspace@42__:hash:*")] + public void CreateSubKeySpaceNotificationPrefix_Span(string prefix, int? database, string expected) + { + var channel = RedisChannel.SubKeySpacePrefix((ReadOnlySpan)Encoding.UTF8.GetBytes(prefix), database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData("hash?", null)] + [InlineData("hash?", 42)] + [InlineData("hash*", null)] + [InlineData("hash*", 42)] + [InlineData("hash[", null)] + [InlineData("hash[", 42)] + public void CreateSubKeySpaceNotificationPrefix_DisallowGlob(string prefix, int? database) + { + var bytes = Encoding.UTF8.GetBytes(prefix); + var ex = Assert.Throws(() => + RedisChannel.SubKeySpacePrefix((RedisKey)bytes, database)); + Assert.StartsWith("The supplied key contains pattern characters, but patterns are not supported in this context.", ex.Message); + + ex = Assert.Throws(() => + RedisChannel.SubKeySpacePrefix((ReadOnlySpan)bytes, database)); + Assert.StartsWith("The supplied key contains pattern characters, but patterns are not supported in this context.", ex.Message); + } + + [Theory] + [InlineData(KeyNotificationType.HSet, null, "__subkeyevent@*__:hset", true)] + [InlineData(KeyNotificationType.HDel, null, "__subkeyevent@*__:hdel", true)] + [InlineData(KeyNotificationType.HSet, 42, "__subkeyevent@42__:hset", false)] + [InlineData(KeyNotificationType.HDel, 42, "__subkeyevent@42__:hdel", false)] + public void CreateSubKeyEventNotification(KeyNotificationType type, int? database, string expected, bool isPattern) + { + var channel = RedisChannel.SubKeyEvent(type, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IgnoreChannelPrefix); + if (isPattern) + { + Assert.True(channel.IsPattern); + } + else + { + Assert.False(channel.IsPattern); + } + } + + [Fact] + public void CreateSubKeySpaceItemNotification_Valid() + { + var channel = RedisChannel.SubKeySpaceItem("myhash", "field1", 42); + Assert.Equal("__subkeyspaceitem@42__:myhash\nfield1", channel.ToString()); + Assert.False(channel.IsMultiNode); + Assert.True(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.False(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData(KeyNotificationType.HSet, "myhash", null, "__subkeyspaceevent@*__:hset|myhash", true)] + [InlineData(KeyNotificationType.HDel, "myhash", null, "__subkeyspaceevent@*__:hdel|myhash", true)] + [InlineData(KeyNotificationType.HSet, "myhash", 42, "__subkeyspaceevent@42__:hset|myhash", false)] + [InlineData(KeyNotificationType.HDel, "myhash", 42, "__subkeyspaceevent@42__:hdel|myhash", false)] + public void CreateSubKeySpaceEventNotification(KeyNotificationType type, string key, int? database, string expected, bool isPattern) + { + var channel = RedisChannel.SubKeySpaceEvent(type, key, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IgnoreChannelPrefix); + if (isPattern) + { + Assert.True(channel.IsPattern); + } + else + { + Assert.False(channel.IsPattern); + } + } + + [Theory] + [InlineData("abc", "__keyspace@42__:abc")] + [InlineData("a*bc", "__keyspace@42__:a*bc")] // pattern-like is allowed, since not using PSUBSCRIBE + public void Cannot_KeyRoute_KeySpace_SingleKeyIsKeyRouted(string key, string pattern) + { + var channel = RedisChannel.KeySpaceSingleKey(key, 42); + Assert.Equal(pattern, channel.ToString()); + Assert.False(channel.IsMultiNode); + Assert.False(channel.IsPattern); + Assert.False(channel.IsSharded); + Assert.True(channel.IgnoreChannelPrefix); + Assert.True(channel.IsKeyRouted); + Assert.True(channel.WithKeyRouting().IsKeyRouted); // no change, still key-routed + Assert.Equal(RedisCommand.PUBLISH, channel.GetPublishCommand()); + } + + [Fact] + public void Cannot_KeyRoute_KeySpacePattern() + { + var channel = RedisChannel.KeySpacePattern("abc", 42); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.True(channel.IgnoreChannelPrefix); + Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); + Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); + } + + [Fact] + public void Cannot_KeyRoute_KeyEvent() + { + var channel = RedisChannel.KeyEvent(KeyNotificationType.Set, 42); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.True(channel.IgnoreChannelPrefix); + Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); + Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); + } + + [Fact] + public void Cannot_KeyRoute_KeyEvent_Custom() + { + var channel = RedisChannel.KeyEvent("foo"u8, 42); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.True(channel.IgnoreChannelPrefix); + Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); + Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); + } + + [Fact] + public void KeyEventPrefix_KeySpacePrefix_Length_Matches() + { + // this is a sanity check for the parsing step in KeyNotification.TryParse + Assert.Equal(KeyNotificationChannels.KeySpacePrefix.Length, KeyNotificationChannels.KeyEventPrefix.Length); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void KeyNotificationKeyStripping(bool asString) + { + Span blob = stackalloc byte[32]; + Span clob = stackalloc char[32]; + + RedisChannel channel = RedisChannel.Literal("__keyevent@0__:sadd"); + RedisValue value = asString ? "mykey:abc" : "mykey:abc"u8.ToArray(); + KeyNotification.TryParse(in channel, in value, out var notification); + Assert.Equal("mykey:abc", (string?)notification.GetKey()); + Assert.True(notification.KeyStartsWith("mykey:"u8)); + Assert.Equal(0, notification.KeyOffset); + + Assert.Equal(9, notification.GetKeyByteCount()); + Assert.Equal(asString ? 30 : 9, notification.GetKeyMaxByteCount()); + Assert.Equal(9, notification.GetKeyCharCount()); + Assert.Equal(asString ? 9 : 10, notification.GetKeyMaxCharCount()); + + Assert.True(notification.TryCopyKey(blob, out var bytesWritten)); + Assert.Equal(9, bytesWritten); + Assert.Equal("mykey:abc", Encoding.UTF8.GetString(blob.Slice(0, bytesWritten))); + + Assert.True(notification.TryCopyKey(clob, out var charsWritten)); + Assert.Equal(9, charsWritten); + Assert.Equal("mykey:abc", clob.Slice(0, charsWritten).ToString()); + + // now with a prefix + notification = notification.WithKeySlice("mykey:"u8.Length); + Assert.Equal("abc", (string?)notification.GetKey()); + Assert.False(notification.KeyStartsWith("mykey:"u8)); + Assert.Equal(6, notification.KeyOffset); + + Assert.Equal(3, notification.GetKeyByteCount()); + Assert.Equal(asString ? 24 : 3, notification.GetKeyMaxByteCount()); + Assert.Equal(3, notification.GetKeyCharCount()); + Assert.Equal(asString ? 3 : 4, notification.GetKeyMaxCharCount()); + + Assert.True(notification.TryCopyKey(blob, out bytesWritten)); + Assert.Equal(3, bytesWritten); + Assert.Equal("abc", Encoding.UTF8.GetString(blob.Slice(0, bytesWritten))); + + Assert.True(notification.TryCopyKey(clob, out charsWritten)); + Assert.Equal(3, charsWritten); + Assert.Equal("abc", clob.Slice(0, charsWritten).ToString()); + } + + [Theory] + [InlineData("hset|6:field1", "field1", "Single subkey")] + [InlineData("hset|6:field1|6:field2", "field1", "Multiple subkeys - returns first only")] + [InlineData("hset|6:field1|6:field2|6:field3", "field1", "Three subkeys - returns first only")] + public void SubKeySpace_HSet_ParsesCorrectly(string payload, string expectedFirstSubKey, string description) + { + // __subkeyspace@4__:mykey with payload like hset|6:field1 or hset|6:field1|6:field2 + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = payload; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification), description); + + Assert.Equal(KeyNotificationKind.SubKeySpace, notification.Kind); + Assert.Equal(4, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.True(notification.IsType("hset"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal(expectedFirstSubKey, (string?)notification.GetSubKeys().First()); + } + + [Theory] + [InlineData("hset|6:field1", new[] { "field1" })] + [InlineData("hset|6:field1|6:field2", new[] { "field1", "field2" })] + [InlineData("hset|6:field1|6:field2|6:field3", new[] { "field1", "field2", "field3" })] + [InlineData("hset|4:key1|5:key22|6:key333", new[] { "key1", "key22", "key333" })] + public void SubKeySpace_GetSubKeys(string payload, string[] expectedSubKeys) + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = payload; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Equal(expectedSubKeys, subKeys); + } + + [Theory] + [InlineData("5:mykey|6:field1", "field1", "Single subkey")] + [InlineData("5:mykey|6:field1|6:field2", "field1", "Multiple subkeys - returns first only")] + [InlineData("5:mykey|6:field1|6:field2|6:field3", "field1", "Three subkeys - returns first only")] + public void SubKeyEvent_HSet_ParsesCorrectly(string payload, string expectedFirstSubKey, string description) + { + // __subkeyevent@4__:hset with payload like 5:mykey|6:field1 or 5:mykey|6:field1|6:field2 + var channel = RedisChannel.Literal("__subkeyevent@4__:hset"); + RedisValue value = payload; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification), description); + + Assert.Equal(KeyNotificationKind.SubKeyEvent, notification.Kind); + Assert.Equal(4, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.True(notification.IsType("hset"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal(expectedFirstSubKey, (string?)notification.GetSubKeys().First()); + } + + [Theory] + [InlineData("5:mykey|6:field1", new[] { "field1" })] + [InlineData("5:mykey|6:field1|6:field2", new[] { "field1", "field2" })] + [InlineData("5:mykey|6:field1|6:field2|6:field3", new[] { "field1", "field2", "field3" })] + public void SubKeyEvent_GetSubKeys(string payload, string[] expectedSubKeys) + { + var channel = RedisChannel.Literal("__subkeyevent@4__:hset"); + RedisValue value = payload; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Equal(expectedSubKeys, subKeys); + } + + [Fact] + public void SubKeySpaceItem_HSet_ParsesCorrectly() + { + // __subkeyspaceitem@4__:mykey\nfield1 with payload hset + var channel = RedisChannel.Literal("__subkeyspaceitem@4__:mykey\nfield1"); + RedisValue value = "hset"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeySpaceItem, notification.Kind); + Assert.Equal(4, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.True(notification.IsType("hset"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal("field1", (string?)notification.GetSubKeys().First()); + } + + [Theory] + [InlineData("6:field1", "field1", "Single subkey")] + [InlineData("6:field1|6:field2", "field1", "Multiple subkeys - returns first only")] + [InlineData("6:field1|6:field2|6:field3", "field1", "Three subkeys - returns first only")] + public void SubKeySpaceEvent_HSet_ParsesCorrectly(string payload, string expectedFirstSubKey, string description) + { + // __subkeyspaceevent@4__:hset|mykey with payload like 6:field1 or 6:field1|6:field2 + var channel = RedisChannel.Literal("__subkeyspaceevent@4__:hset|mykey"); + RedisValue value = payload; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification), description); + + Assert.Equal(KeyNotificationKind.SubKeySpaceEvent, notification.Kind); + Assert.Equal(4, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.True(notification.IsType("hset"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal(expectedFirstSubKey, (string?)notification.GetSubKeys().First()); + } + + [Theory] + [InlineData("6:field1", new[] { "field1" })] + [InlineData("6:field1|6:field2", new[] { "field1", "field2" })] + [InlineData("6:field1|6:field2|6:field3", new[] { "field1", "field2", "field3" })] + public void SubKeySpaceEvent_GetSubKeys(string payload, string[] expectedSubKeys) + { + var channel = RedisChannel.Literal("__subkeyspaceevent@4__:hset|mykey"); + RedisValue value = payload; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Equal(expectedSubKeys, subKeys); + } + + [Fact] + public void SubKeySpaceItem_GetSingleSubKey() + { + // __subkeyspaceitem@4__:mykey\nfield1 + var channel = RedisChannel.Literal("__subkeyspaceitem@4__:mykey\nfield1"); + RedisValue value = RedisValue.EmptyString; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Single(subKeys); + Assert.Equal("field1", subKeys[0]); + } + + [Fact] + public void GetSubKeys_DefaultEnumerable_ReturnsEmpty() + { + // Test that default SubKeyEnumerable returns empty set + var enumerable = default(KeyNotification.SubKeyEnumerable); + + var subKeys = new List(); + foreach (var subKey in enumerable) + { + subKeys.Add((string?)subKey); + } + + Assert.Empty(subKeys); + } + + [Fact] + public void GetSubKeys_DefaultEnumerator_MoveNextReturnsFalse() + { + // Test that default SubKeyEnumerator's MoveNext returns false + var enumerator = default(KeyNotification.SubKeyEnumerator); + + Assert.False(enumerator.MoveNext()); + } + + [Fact] + public void GetSubKeys_NonSubKeyNotification_ReturnsEmpty() + { + // Regular keyspace notification (not sub-key) should return empty + var channel = RedisChannel.Literal("__keyspace@4__:mykey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Empty(subKeys); + } + + [Fact] + public void GetSubKeys_Count_ReturnsCorrectCount() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.Equal(3, notification.GetSubKeys().Count()); + } + + [Fact] + public void GetSubKeys_First_ReturnsFirstElement() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.Equal("field1", (string?)notification.GetSubKeys().First()); + } + + [Fact] + public void GetSubKeys_First_ThrowsOnEmpty() + { + var channel = RedisChannel.Literal("__keyspace@4__:mykey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + try + { + notification.GetSubKeys().First(); + Assert.Fail("Expected InvalidOperationException"); + } + catch (InvalidOperationException) + { + // Expected + } + } + + [Fact] + public void GetSubKeys_FirstOrDefault_ReturnsFirstElement() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.Equal("field1", (string?)notification.GetSubKeys().FirstOrDefault()); + } + + [Fact] + public void GetSubKeys_FirstOrDefault_ReturnsNullOnEmpty() + { + var channel = RedisChannel.Literal("__keyspace@4__:mykey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.True(notification.GetSubKeys().FirstOrDefault().IsNull); + } + + [Fact] + public void GetSubKeys_CopyTo_CopiesAllElements() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var destination = new RedisValue[5]; + var count = notification.GetSubKeys().CopyTo(destination); + + Assert.Equal(3, count); + Assert.Equal("field1", (string?)destination[0]); + Assert.Equal("field2", (string?)destination[1]); + Assert.Equal("field3", (string?)destination[2]); + } + + [Fact] + public void GetSubKeys_CopyTo_TruncatesWhenTooSmall() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var destination = new RedisValue[2]; + var count = notification.GetSubKeys().CopyTo(destination); + + Assert.Equal(2, count); + Assert.Equal("field1", (string?)destination[0]); + Assert.Equal("field2", (string?)destination[1]); + } + + [Fact] + public void GetSubKeys_ToArray_ReturnsArray() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var array = notification.GetSubKeys().ToArray(); + + Assert.Equal(3, array.Length); + Assert.Equal("field1", (string?)array[0]); + Assert.Equal("field2", (string?)array[1]); + Assert.Equal("field3", (string?)array[2]); + } + + [Fact] + public void GetSubKeys_ToList_ReturnsList() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var list = notification.GetSubKeys().ToList(); + + Assert.Equal(3, list.Count); + Assert.Equal("field1", (string?)list[0]); + Assert.Equal("field2", (string?)list[1]); + Assert.Equal("field3", (string?)list[2]); + } + + [Fact] + public void GetSubKeys_Enumerator_CurrentSpanAndCopy_WorksWithoutCurrent() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + using var enumerator = notification.GetSubKeys().GetEnumerator(); + Assert.True(enumerator.MoveNext()); + + Assert.Equal(6, enumerator.CurrentByteCount); + Assert.Equal("field1"u8.ToArray(), enumerator.CurrentSpan.ToArray()); + Assert.Equal(6, enumerator.GetCurrentCharCount()); + + Span byteBuffer = stackalloc byte[16]; + Assert.True(enumerator.TryCopyTo(byteBuffer, out var bytesWritten)); + Assert.Equal(6, bytesWritten); + Assert.Equal("field1", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + Span charBuffer = stackalloc char[16]; + Assert.True(enumerator.TryCopyTo(charBuffer, out var charsWritten)); + Assert.Equal(6, charsWritten); + Assert.Equal("field1", charBuffer.Slice(0, charsWritten).ToString()); + + Assert.True(enumerator.MoveNext()); + Assert.Equal("field2"u8.ToArray(), enumerator.CurrentSpan.ToArray()); + } + + [Fact] + public void GetSubKeys_Enumerator_CurrentSurvivesDispose() + { + var channel = RedisChannel.Literal("__subkeyspaceevent@4__:hset|mykey"); + RedisValue value = "6:field1|6:field2"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + RedisValue first; + using (var enumerator = notification.GetSubKeys().GetEnumerator()) + { + Assert.True(enumerator.MoveNext()); + first = enumerator.Current; + Assert.Equal("field1", (string?)enumerator.Current); + } + + Assert.Equal("field1", (string?)first); + } + + [Fact] + public void ExtractLengthPrefixedValue_ParsesCorrectly() + { + // Test the length-prefixed value extraction helper + var result1 = KeyNotification.ExtractLengthPrefixedValue("6:field1"u8); + Assert.Equal("field1", (string?)result1); + + var result2 = KeyNotification.ExtractLengthPrefixedValue("5:mykey"u8); + Assert.Equal("mykey", (string?)result2); + + var result3 = KeyNotification.ExtractLengthPrefixedValue("11:hello world"u8); + Assert.Equal("hello world", (string?)result3); + + // Test invalid formats + var result4 = KeyNotification.ExtractLengthPrefixedValue("invalid"u8); + Assert.True(result4.IsNull); + + var result5 = KeyNotification.ExtractLengthPrefixedValue("10:short"u8); // Length mismatch + Assert.True(result5.IsNull); + } + + [Fact] + public void SubKeySpace_GetSubKey_ReturnsCorrectValue() + { + // Test that GetSubKey returns the expected value for SubKeySpace + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + Assert.Equal(KeyNotificationKind.SubKeySpace, notification.Kind); + + var subKey = notification.GetSubKeys().First(); + Assert.False(subKey.IsNull, $"SubKey should not be null. Value: {value}"); + Assert.Equal("field1", (string?)subKey); + } + + [Fact] + public void ChannelSuffix_SubKeyEvent_ReturnsCorrectValue() + { + // Test that ChannelSuffix returns the expected value for SubKeyEvent + var channel = RedisChannel.Literal("__subkeyevent@4__:hset"); + RedisValue value = "5:mykey|6:field1"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeyEvent, notification.Kind); + + var suffix = notification.ChannelSuffix; + var expected = "hset"u8; + + Assert.Equal(expected.Length, suffix.Length); + Assert.True(suffix.SequenceEqual(expected), "ChannelSuffix should equal 'hset'"); + } + + [Fact] + public void SubKeySpace_HExpire_ParsesCorrectly() + { + // __subkeyspace@0__:hash with payload hexpire|5:field + var channel = RedisChannel.Literal("__subkeyspace@0__:hash"); + RedisValue value = "hexpire|5:field"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeySpace, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.HExpire, notification.Type); + Assert.True(notification.IsType("hexpire"u8)); + Assert.Equal("hash", (string?)notification.GetKey()); + Assert.Equal("field", (string?)notification.GetSubKeys().First()); + } + + [Fact] + public void NonSubKeyNotifications_ReturnNullSubKey() + { + // Regular keyspace notification + var channel = RedisChannel.Literal("__keyspace@4__:mykey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + Assert.True(notification.GetSubKeys().FirstOrDefault().IsNull); + + // Regular keyevent notification + channel = RedisChannel.Literal("__keyevent@4__:del"); + value = "mykey"; + + Assert.True(KeyNotification.TryParse(channel, value, out notification)); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.True(notification.GetSubKeys().FirstOrDefault().IsNull); + } + + [Fact] + public void KeyPrefix_KeySpace_MatchingPrefix_ParsesAndStrips() + { + // __keyspace@1__:foo:bar with payload "set" + // Key prefix is "foo:" + var channel = RedisChannel.Literal("__keyspace@1__:foo:bar"); + RedisValue value = "set"; + ReadOnlySpan keyPrefix = "foo:"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + Assert.Equal(1, notification.Database); + Assert.Equal(KeyNotificationType.Set, notification.Type); + + // The key should NOT include the prefix + Assert.Equal("bar", (string?)notification.GetKey()); + Assert.Equal(3, notification.GetKeyByteCount()); + Assert.Equal(3, notification.GetKeyCharCount()); + } + + [Fact] + public void KeyPrefix_KeySpace_NonMatchingPrefix_ReturnsFalse() + { + // __keyspace@1__:other:bar with payload "set" + // Key prefix is "foo:" + var channel = RedisChannel.Literal("__keyspace@1__:other:bar"); + RedisValue value = "set"; + ReadOnlySpan keyPrefix = "foo:"u8; + + // Should return false because the key doesn't start with "foo:" + Assert.False(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + } + + [Fact] + public void KeyPrefix_KeyEvent_MatchingPrefix_ParsesAndStrips() + { + // __keyevent@1__:set with payload "foo:bar" + // Key prefix is "foo:" + var channel = RedisChannel.Literal("__keyevent@1__:set"); + RedisValue value = "foo:bar"; + ReadOnlySpan keyPrefix = "foo:"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); + Assert.Equal(1, notification.Database); + Assert.Equal(KeyNotificationType.Set, notification.Type); + + // The key should NOT include the prefix + Assert.Equal("bar", (string?)notification.GetKey()); + Assert.Equal(3, notification.GetKeyByteCount()); + Assert.Equal(3, notification.GetKeyCharCount()); + } + + [Fact] + public void KeyPrefix_KeyEvent_NonMatchingPrefix_ReturnsFalse() + { + // __keyevent@1__:set with payload "other:bar" + // Key prefix is "foo:" + var channel = RedisChannel.Literal("__keyevent@1__:set"); + RedisValue value = "other:bar"; + ReadOnlySpan keyPrefix = "foo:"u8; + + // Should return false because the key doesn't start with "foo:" + Assert.False(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + } + + [Fact] + public void KeyPrefix_KeySpace_EmptyPrefix_ParsesWithoutStripping() + { + // __keyspace@1__:mykey with payload "set" + // Empty prefix + var channel = RedisChannel.Literal("__keyspace@1__:mykey"); + RedisValue value = "set"; + ReadOnlySpan keyPrefix = ""u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + + // The key should be unchanged + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal(5, notification.GetKeyByteCount()); + } + + [Fact] + public void KeyPrefix_KeySpace_PrefixLongerThanKey_ReturnsFalse() + { + // __keyspace@1__:foo with payload "set" + // Key prefix is "foo:bar" which is longer than the actual key + var channel = RedisChannel.Literal("__keyspace@1__:foo"); + RedisValue value = "set"; + ReadOnlySpan keyPrefix = "foo:bar"u8; + + // Should return false because prefix is longer than the key + Assert.False(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + } + + [Fact] + public void KeyPrefix_KeySpace_ExactMatch_ReturnsEmptyKey() + { + // __keyspace@1__:foo with payload "set" + // Key prefix is exactly "foo" + var channel = RedisChannel.Literal("__keyspace@1__:foo"); + RedisValue value = "set"; + ReadOnlySpan keyPrefix = "foo"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + + // The key should be empty after stripping the prefix + Assert.Equal("", (string?)notification.GetKey()); + Assert.Equal(0, notification.GetKeyByteCount()); + Assert.Equal(0, notification.GetKeyCharCount()); + } + + [Fact] + public void KeyPrefix_MultiTenantScenario_IsolatesCorrectly() + { + // Simulate a multi-tenant scenario with client prefixes + ReadOnlySpan client1Prefix = "client1234:"u8; + ReadOnlySpan client5678Prefix = "client5678:"u8; + + // Client 1's notification + var channel1 = RedisChannel.Literal("__keyspace@0__:client1234:order/123"); + RedisValue value1 = "set"; + + // Client 2's notification (different client) + var channel2 = RedisChannel.Literal("__keyspace@0__:client5678:order/456"); + RedisValue value2 = "set"; + + // Client 1 should only see their own notifications + Assert.True(KeyNotification.TryParse(client1Prefix, in channel1, in value1, out var notification1)); + Assert.Equal("order/123", (string?)notification1.GetKey()); + + Assert.False(KeyNotification.TryParse(client1Prefix, in channel2, in value2, out _)); + + // Client 2 should only see their own notifications + Assert.True(KeyNotification.TryParse(client5678Prefix, in channel2, in value2, out var notification2)); + Assert.Equal("order/456", (string?)notification2.GetKey()); + + Assert.False(KeyNotification.TryParse(client5678Prefix, in channel1, in value1, out _)); + } + + [Fact] + public void TryCopyKey_KeySpace_Works() + { + var channel = RedisChannel.Literal("__keyspace@1__:testkey"); + RedisValue value = "set"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(7, bytesWritten); + Assert.Equal("testkey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(7, charsWritten); + Assert.Equal("testkey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_KeyEvent_Works() + { + var channel = RedisChannel.Literal("__keyevent@1__:set"); + RedisValue value = "testkey"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(7, bytesWritten); + Assert.Equal("testkey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(7, charsWritten); + Assert.Equal("testkey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_SubKeySpace_Works() + { + var channel = RedisChannel.Literal("__subkeyspace@1__:mykey"); + RedisValue value = "hset|6:field1"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(5, bytesWritten); + Assert.Equal("mykey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(5, charsWritten); + Assert.Equal("mykey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_SubKeyEvent_Works() + { + var channel = RedisChannel.Literal("__subkeyevent@1__:hset"); + RedisValue value = "5:mykey|6:field1"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(5, bytesWritten); + Assert.Equal("mykey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(5, charsWritten); + Assert.Equal("mykey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_SubKeySpaceItem_Works() + { + var channel = RedisChannel.Literal("__subkeyspaceitem@1__:mykey\nfield1"); + RedisValue value = "hset"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(5, bytesWritten); + Assert.Equal("mykey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(5, charsWritten); + Assert.Equal("mykey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_SubKeySpaceEvent_Works() + { + var channel = RedisChannel.Literal("__subkeyspaceevent@1__:hset|mykey"); + RedisValue value = "6:field1"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(5, bytesWritten); + Assert.Equal("mykey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(5, charsWritten); + Assert.Equal("mykey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_BufferTooSmall_ReturnsFalse() + { + var channel = RedisChannel.Literal("__keyspace@1__:verylongkeyname"); + RedisValue value = "set"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test with buffer that's too small + Span tinyBuffer = stackalloc byte[5]; + Assert.False(notification.TryCopyKey(tinyBuffer, out var bytesWritten)); + Assert.Equal(15, bytesWritten); // Should report the actual size needed + + // Test char buffer too small + Span tinyCharBuffer = stackalloc char[5]; + Assert.False(notification.TryCopyKey(tinyCharBuffer, out var charsWritten)); + } + + [Fact] + public void SubKey_SubKeySpace_SubkeyNotAffectedByKeyPrefix() + { + // Test that subkey contains its own prefix and is not affected by the key prefix + var channel = RedisChannel.Literal("__subkeyspace@1__:user:123"); + RedisValue value = "hset|12:email:123456"; // subkey has different prefix "email:" + ReadOnlySpan keyPrefix = "user:"u8; // key prefix is "user:" + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + // The key should have the "user:" prefix stripped + Assert.Equal("123", (string?)notification.GetKey()); + + // The subkey should be returned as-is with its own "email:" prefix intact + var subkey = notification.GetSubKeys().First(); + Assert.Equal("email:123456", (string?)subkey); + Assert.Equal(12, subkey.GetByteCount()); + } + + [Fact] + public void SubKey_SubKeyEvent_SubkeyNotAffectedByKeyPrefix() + { + // Test that subkey is independent of key prefix + var channel = RedisChannel.Literal("__subkeyevent@1__:hset"); + RedisValue value = "8:user:123|12:email:123456"; // key has "user:" prefix, subkey has "email:" prefix + ReadOnlySpan keyPrefix = "user:"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + // The key should have the "user:" prefix stripped + Assert.Equal("123", (string?)notification.GetKey()); + + // The subkey should be returned as-is with its own "email:" prefix intact + var subkey = notification.GetSubKeys().First(); + Assert.Equal("email:123456", (string?)subkey); + Assert.Equal(12, subkey.GetByteCount()); + } + + [Fact] + public void SubKey_SubKeySpaceItem_SubkeyNotAffectedByKeyPrefix() + { + // Test that subkey in channel is independent of key prefix + var channel = RedisChannel.Literal("__subkeyspaceitem@1__:user:123\nemail:123456"); + RedisValue value = "hset"; + ReadOnlySpan keyPrefix = "user:"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + // The key should have the "user:" prefix stripped + Assert.Equal("123", (string?)notification.GetKey()); + + // The subkey should be returned as-is with its own "email:" prefix intact + var subkey = notification.GetSubKeys().First(); + Assert.Equal("email:123456", (string?)subkey); + } + + [Fact] + public void SubKey_SubKeySpaceEvent_SubkeyNotAffectedByKeyPrefix() + { + // Test that subkey in payload is independent of key prefix + var channel = RedisChannel.Literal("__subkeyspaceevent@1__:hset|user:123"); + RedisValue value = "12:email:123456"; + ReadOnlySpan keyPrefix = "user:"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + // The key should have the "user:" prefix stripped + Assert.Equal("123", (string?)notification.GetKey()); + + // The subkey should be returned as-is with its own "email:" prefix intact + var subkey = notification.GetSubKeys().First(); + Assert.Equal("email:123456", (string?)subkey); + Assert.Equal(12, subkey.GetByteCount()); + } + + [Fact] + public void SubKeyEvent_MultipleFields_ParsesCorrectly() + { + // __subkeyevent@0__:hset with payload "1:k|6:field1,6:field2,6:field3" + // This represents an HSET on key "k" with fields "field1", "field2", "field3" + // Note: fields are separated by commas, not pipes (as per Redis 8.8 actual behavior) + var channel = RedisChannel.Literal("__subkeyevent@0__:hset"); + RedisValue value = "1:k|6:field1,6:field2,6:field3"; + + log.WriteLine($"Testing channel: '{channel}', value: '{value}'"); + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeyEvent, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.Equal("k", (string?)notification.GetKey()); + + // Test sub-keys + var subKeys = notification.GetSubKeys(); + int count = subKeys.Count(); + log.WriteLine($"Sub-key count: {count}"); + + Assert.Equal(3, count); + + var fieldsList = subKeys.ToList(); + Assert.Equal(3, fieldsList.Count); + Assert.Equal("field1", (string?)fieldsList[0]); + Assert.Equal("field2", (string?)fieldsList[1]); + Assert.Equal("field3", (string?)fieldsList[2]); + } + + [Fact] + public void SubKeyEvent_RealWorldPayload_ParsesCorrectly() + { + // Real payload observed from Redis 8.8 server + var channel = RedisChannel.Literal("__subkeyevent@0__:hset"); + RedisValue value = "41:d7213ec1-e834-4fb7-9a4d-a0d8d6bfbc7e/hash|6:field1,6:field2,6:field3"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeyEvent, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.Equal("d7213ec1-e834-4fb7-9a4d-a0d8d6bfbc7e/hash", (string?)notification.GetKey()); + + // Test sub-keys + var subKeys = notification.GetSubKeys(); + Assert.Equal(3, subKeys.Count()); + + var fieldsList = subKeys.ToArray(); + Assert.Equal("field1", (string?)fieldsList[0]); + Assert.Equal("field2", (string?)fieldsList[1]); + Assert.Equal("field3", (string?)fieldsList[2]); + } + + [Fact] + public void SubKeySpace_MultipleFields_ParsesCorrectly() + { + // __subkeyspace@0__:mykey with payload "hset|6:field1,6:field2" + // Format: |:,:... + var channel = RedisChannel.Literal("__subkeyspace@0__:mykey"); + RedisValue value = "hset|6:field1,6:field2"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeySpace, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.Equal("mykey", (string?)notification.GetKey()); + + var subKeys = notification.GetSubKeys(); + Assert.Equal(2, subKeys.Count()); + + var fieldsList = subKeys.ToArray(); + Assert.Equal("field1", (string?)fieldsList[0]); + Assert.Equal("field2", (string?)fieldsList[1]); + } + + [Fact] + public void SubKeySpaceEvent_MultipleFields_ParsesCorrectly() + { + // __subkeyspaceevent@0__:hset|mykey with payload "6:field1,6:field2,6:field3" + var channel = RedisChannel.Literal("__subkeyspaceevent@0__:hset|mykey"); + RedisValue value = "6:field1,6:field2,6:field3"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeySpaceEvent, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.Equal("mykey", (string?)notification.GetKey()); + + var subKeys = notification.GetSubKeys(); + Assert.Equal(3, subKeys.Count()); + + var fieldsList = subKeys.ToArray(); + Assert.Equal("field1", (string?)fieldsList[0]); + Assert.Equal("field2", (string?)fieldsList[1]); + Assert.Equal("field3", (string?)fieldsList[2]); + } +} diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index a65d0c631..75ee4f9b4 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -56,26 +56,43 @@ private IInternalConnectionMultiplexer Create(bool withChannelPrefix) => private static Random SharedRandom { get; } = new(); #endif - [Fact] - public async Task KeySpace_Events_Enabled() + /// + /// Creates a connection for notification tests and asserts that the required notification tokens are available. + /// Uses Assert.SkipUnless to skip the test if the configuration is not available. + /// + /// The kind of notification to check support for. + /// Whether to use a channel prefix. + /// A connection multiplexer configured for the specified notification kind. + private async Task ConnectAsync(KeyNotificationKind kind, bool withChannelPrefix = false) { - // see https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/#configuration - await using var conn = Create(allowAdmin: true); - int failures = 0; - foreach (var ep in conn.GetEndPoints()) + var conn = Create(channelPrefix: withChannelPrefix ? "prefix:" : null, allowAdmin: true); + var muxer = conn; + + var requiredTokens = kind switch + { + KeyNotificationKind.KeySpace => "AK", // A = all events, K = keyspace + KeyNotificationKind.KeyEvent => "AE", // A = all events, E = keyevent + KeyNotificationKind.SubKeySpace => "AS", // A = all events, S = sub-keyspace + KeyNotificationKind.SubKeyEvent => "AT", // A = all events, T = sub-keyevent + KeyNotificationKind.SubKeySpaceItem => "AI", // A = all events, I = sub-keyspace-item + KeyNotificationKind.SubKeySpaceEvent => "AV", // A = all events, V = sub-keyspace-event + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unsupported KeyNotificationKind"), + }; + + foreach (var ep in muxer.GetEndPoints()) { - var server = conn.GetServer(ep); + var server = muxer.GetServer(ep); var config = (await server.ConfigGetAsync("notify-keyspace-events")).Single(); - Log($"[{Format.ToString(ep)}] notify-keyspace-events: '{config.Value}'"); + var value = config.Value.ToString() ?? ""; - // this is a very broad config, but it's what we use in CI (and probably a common basic config) - if (config.Value != "AKE") + // Check that the config contains all required tokens + foreach (var token in requiredTokens) { - failures++; + Assert.SkipUnless(value.Contains(token), $"Server {ep} notify-keyspace-events config '{value}' missing required token '{token}' for {kind}"); } } - // for details, check the log output - Assert.Equal(0, failures); + + return conn; } [Fact] @@ -119,7 +136,7 @@ private sealed class Counter [InlineData(false)] public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler(bool withChannelPrefix) { - await using var conn = Create(withChannelPrefix); + await using var conn = await ConnectAsync(KeyNotificationKind.KeyEvent, withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -142,7 +159,9 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => { callbackCount.Increment(); if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) +#pragma warning disable CS0618 // Type or member is obsolete && notification is { IsKeyEvent: true, Type: KeyNotificationType.SAdd }) +#pragma warning restore CS0618 // Type or member is obsolete { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } @@ -157,7 +176,7 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => [InlineData(false)] public async Task KeyEvent_CanObserveSimple_ViaQueue(bool withChannelPrefix) { - await using var conn = Create(withChannelPrefix); + await using var conn = await ConnectAsync(KeyNotificationKind.KeyEvent, withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -183,7 +202,9 @@ public async Task KeyEvent_CanObserveSimple_ViaQueue(bool withChannelPrefix) { callbackCount.Increment(); if (msg.TryParseKeyNotification(out var notification) +#pragma warning disable CS0618 // Type or member is obsolete && notification is { IsKeyEvent: true, Type: KeyNotificationType.SAdd }) +#pragma warning restore CS0618 // Type or member is obsolete { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } @@ -199,7 +220,7 @@ public async Task KeyEvent_CanObserveSimple_ViaQueue(bool withChannelPrefix) [InlineData(false)] public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler(bool withChannelPrefix) { - await using var conn = Create(withChannelPrefix); + await using var conn = await ConnectAsync(KeyNotificationKind.KeySpace, withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -225,7 +246,9 @@ public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler(bool withC { callbackCount.Increment(); if (msg.TryParseKeyNotification(out var notification) +#pragma warning disable CS0618 // Type or member is obsolete && notification is { IsKeySpace: true, Type: KeyNotificationType.SAdd }) +#pragma warning restore CS0618 // Type or member is obsolete { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } @@ -241,7 +264,7 @@ public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler(bool withC [InlineData(false)] public async Task KeyNotification_CanObserveSimple_ViaQueue(bool withChannelPrefix) { - await using var conn = Create(withChannelPrefix); + await using var conn = await ConnectAsync(KeyNotificationKind.KeySpace, withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -264,7 +287,9 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => { callbackCount.Increment(); if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) +#pragma warning disable CS0618 // Type or member is obsolete && notification is { IsKeySpace: true, Type: KeyNotificationType.SAdd }) +#pragma warning restore CS0618 // Type or member is obsolete { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } @@ -281,7 +306,7 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => [InlineData(false, true)] public async Task KeyNotification_CanObserveSingleKey_ViaQueue(bool withChannelPrefix, bool withKeyPrefix) { - await using var conn = Create(withChannelPrefix); + await using var conn = await ConnectAsync(KeyNotificationKind.KeySpace, withChannelPrefix); string keyPrefix = withKeyPrefix ? "isolated:" : ""; byte[] keyPrefixBytes = Encoding.UTF8.GetBytes(keyPrefix); var db = conn.GetDatabase().WithKeyPrefix(keyPrefix); @@ -312,7 +337,9 @@ public async Task KeyNotification_CanObserveSingleKey_ViaQueue(bool withChannelP { callbackCount.Increment(); if (msg.TryParseKeyNotification(keyPrefixBytes, out var notification) +#pragma warning disable CS0618 // Type or member is obsolete && notification is { IsKeySpace: true, Type: KeyNotificationType.SAdd }) +#pragma warning restore CS0618 // Type or member is obsolete { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } @@ -416,4 +443,666 @@ private async Task SendAndObserveAsync( return counter; } #endif + + // ========== Sub-Key (Hash Field) Notification Tests ========== + + /// + /// Helper to send hash operations and observe field-level notifications. + /// + private async Task SendHashOperationsAndObserveAsync( + RedisKey hashKey, + string[] fields, + IDatabase db, + TaskCompletionSource allDone, + Counter callbackCount, + ConcurrentDictionary observedFieldCounts, + int operationCount = DefaultEventCount) + { + await Task.Delay(300).ForAwait(); // give it a moment to settle + + Dictionary sentCounts = new(fields.Length); + foreach (var field in fields) + { + sentCounts[field] = new(); + } + + for (int i = 0; i < operationCount; i++) + { + var field = fields[SharedRandom.Next(0, fields.Length)]; + sentCounts[field].Increment(); + await db.HashSetAsync(hashKey, field, i); + } + + // Wait for all events to be observed + try + { + Assert.True(await allDone.Task.WithTimeout(5000)); + } + catch (TimeoutException) when (callbackCount.Count == 0) + { + Assert.Fail($"Timeout with zero events; are sub-keyspace events enabled?"); + } + + foreach (var field in fields) + { + Assert.Equal(sentCounts[field].Count, observedFieldCounts[field].Count); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SubKeyEvent_CanObserveHashFields_ViaCallback(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeyEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fields = new[] { "field1", "field2", "field3", "field4", "field5" }; + var channel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, db.Database); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); // Keyspace notifications should ignore channel prefix + Log($"Monitoring channel: {channel}"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), matchingEventCount = new(); + TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedFieldCounts = new(); + foreach (var field in fields) + { + observedFieldCounts[field] = new(); + } + // withChannelPrefix: true, "SUBSCRIBE" "__subkeyevent@0__:hset" + // withChannelPrefix: false, "SUBSCRIBE" "__subkeyevent@0__:hset" + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeyEvent: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeyEvent + && notification.Type == KeyNotificationType.HSet + && notification.GetKey() == hashKey) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var fieldName = subKey.ToString(); + if (observedFieldCounts.TryGetValue(fieldName, out var counter)) + { + int currentCount = matchingEventCount.Increment(); + counter.Increment(); + Log($"Observed field: '{fieldName}' after {currentCount} events"); + + if (currentCount == DefaultEventCount) + { + allDone.TrySetResult(true); + } + } + } + } + }); + + await SendHashOperationsAndObserveAsync(hashKey, fields, db, allDone, callbackCount, observedFieldCounts); + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SubKeySpace_CanObserveHashFields_ViaPrefix(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpace, withChannelPrefix); + var db = conn.GetDatabase(); + + var prefix = $"{Guid.NewGuid()}/"; + var hashKey = $"{prefix}myhash"; + var fields = new[] { "field1", "field2", "field3" }; + var channel = RedisChannel.SubKeySpacePrefix(prefix, db.Database); + Assert.True(channel.IsMultiNode); + Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); // Keyspace notifications should ignore channel prefix + Log($"Monitoring channel: {channel}"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), matchingEventCount = new(); + TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedFieldCounts = new(); + foreach (var field in fields) + { + observedFieldCounts[field] = new(); + } + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeySpace: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpace + && notification.Type == KeyNotificationType.HSet) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var fieldName = subKey.ToString(); + if (observedFieldCounts.TryGetValue(fieldName, out var counter)) + { + int currentCount = matchingEventCount.Increment(); + counter.Increment(); + Log($"Observed field: '{fieldName}' in key '{notification.GetKey()}' after {currentCount} events"); + + if (currentCount == DefaultEventCount) + { + allDone.TrySetResult(true); + } + } + } + } + }); + + await SendHashOperationsAndObserveAsync(hashKey, fields, db, allDone, callbackCount, observedFieldCounts); + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SubKeySpaceItem_CanObserveSpecificHashField(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpaceItem, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var targetField = "field2"; + var fields = new[] { "field1", targetField, "field3" }; + var channel = RedisChannel.SubKeySpaceItem(hashKey, targetField, db.Database); + Assert.False(channel.IsMultiNode); // Single key subscription can route to specific node + Assert.False(channel.IsPattern); + Log($"Monitoring channel: {channel}"); + + // Use seeded random to get deterministic field selection + int seed = SharedRandom.Next(); + var countRandom = new Random(seed); + int expectedCount = 0; + for (int i = 0; i < DefaultEventCount; i++) + { + var field = fields[countRandom.Next(0, fields.Length)]; + if (field == targetField) expectedCount++; + } + Log($"Using seed {seed}, expecting exactly {expectedCount} hits on '{targetField}' out of {DefaultEventCount} operations"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), targetFieldCount = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeySpaceItem: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpaceItem + && notification.Type == KeyNotificationType.HSet) + { + var subKey = notification.GetSubKeys().FirstOrDefault(); + var fieldName = subKey.ToString(); + Assert.Equal(targetField, fieldName); // Should only observe the specific field + targetFieldCount.Increment(); + Log($"Observed target field: '{fieldName}' ({targetFieldCount.Count} of exactly {expectedCount} times)"); + + if (targetFieldCount.Count >= expectedCount) + { + allDone.TrySetResult(true); + } + } + }); + + // Verify subscription is active by doing a test operation on a DIFFERENT field + // This ensures subscription is ready without affecting the target field count + Log("Verifying subscription is active..."); + var testField = "test_field_verify"; + await db.HashSetAsync(hashKey, testField, "test"); + await Task.Delay(300).ForAwait(); // Give subscription time to activate + + Log($"Subscription verified. Starting {DefaultEventCount} HSET operations, expecting exactly {expectedCount} notifications for '{targetField}'"); + + // Set various fields using the same seeded random, but only targetField should trigger notifications + var operationRandom = new Random(seed); + for (int i = 0; i < DefaultEventCount; i++) + { + var field = fields[operationRandom.Next(0, fields.Length)]; + await db.HashSetAsync(hashKey, field, i); + } + + Log($"Completed all HSET operations, waiting for notifications..."); + Assert.True(await allDone.Task.WithTimeout(5000), $"Timed out waiting for notifications. Received {targetFieldCount.Count} of expected {expectedCount}"); + Assert.Equal(expectedCount, targetFieldCount.Count); // Exact match since we used seeded random + Log($"Test completed successfully with {targetFieldCount.Count} notifications (exactly as expected)"); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SubKeySpaceEvent_CanObserveHashFields_SingleKey_SpecificEvent(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpaceEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fields = new[] { "field1", "field2", "field3" }; + var channel = RedisChannel.SubKeySpaceEvent(KeyNotificationType.HSet, hashKey, db.Database); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsPattern); + Log($"Monitoring channel: {channel}"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), matchingEventCount = new(); + TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedFieldCounts = new(); + foreach (var field in fields) + { + observedFieldCounts[field] = new(); + } + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeySpaceEvent: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpaceEvent + && notification.Type == KeyNotificationType.HSet) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var fieldName = subKey.ToString(); + if (observedFieldCounts.TryGetValue(fieldName, out var counter)) + { + int currentCount = matchingEventCount.Increment(); + counter.Increment(); + Log($"Observed field: '{fieldName}' after {currentCount} events"); + + if (currentCount == DefaultEventCount) + { + allDone.TrySetResult(true); + } + } + } + } + }); + + await SendHashOperationsAndObserveAsync(hashKey, fields, db, allDone, callbackCount, observedFieldCounts); + await sub.UnsubscribeAsync(channel); + } + + [Fact] + public async Task SubKeyEvent_MultipleFields_SingleOperation() + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeyEvent); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var channel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, db.Database); + Log($"Monitoring channel: {channel}"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(); + TaskCompletionSource allDone = new(); + + HashSet observedFields = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeyEvent + && notification.Type == KeyNotificationType.HSet + && notification.GetKey() == hashKey) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + observedFields.Add(subKey.ToString()); + } + + if (observedFields.Count >= 3) + { + allDone.TrySetResult(true); + } + } + }); + + await Task.Delay(300).ForAwait(); // give it a moment to settle + + // Set multiple fields in a single operation + await db.HashSetAsync(hashKey, new HashEntry[] + { + new("field1", "value1"), + new("field2", "value2"), + new("field3", "value3"), + }); + + Assert.True(await allDone.Task.WithTimeout(5000)); + Assert.Contains("field1", observedFields); + Assert.Contains("field2", observedFields); + Assert.Contains("field3", observedFields); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeyEvent_HandlesNewlineInKey(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeyEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/key\nwith\nnewlines"; + var fields = new[] { "field1", "field2" }; + var channel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + Counter callbackCount = new(); + HashSet observedKeys = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeyEvent_HandlesNewlineInKey: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeyEvent + && notification.Type == KeyNotificationType.HSet) + { + var key = notification.GetKey().ToString(); + Log($" Parsed key: '{key}'"); + observedKeys.Add(key!); + if (key == hashKey) allDone.TrySetResult(true); + } + }); + + await db.HashSetAsync(hashKey, fields[0], "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for key with newlines"); + Assert.Contains(hashKey, observedKeys); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeyEvent_HandlesNewlineInField(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeyEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fieldWithNewline = "field\nwith\nnewlines"; + var channel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + Counter callbackCount = new(); + HashSet observedFields = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeyEvent_HandlesNewlineInField: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeyEvent + && notification.Type == KeyNotificationType.HSet + && notification.GetKey() == hashKey) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var field = subKey.ToString(); + Log($" Parsed field: '{field}'"); + observedFields.Add(field!); + if (field == fieldWithNewline) allDone.TrySetResult(true); + } + } + }); + + await db.HashSetAsync(hashKey, fieldWithNewline, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for field with newlines"); + Assert.Contains(fieldWithNewline, observedFields); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeySpace_HandlesNewlineInKey(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpace, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/key\nwith\nnewlines"; + var field = "field1"; + var channel = RedisChannel.SubKeySpaceSingleKey(hashKey, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + Counter receivedCount = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + receivedCount.Increment(); + Log($"SubKeySpace_HandlesNewlineInKey: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpace + && notification.Type == KeyNotificationType.HSet) + { + Log($" Parsed successfully, Type={notification.Type}"); + allDone.TrySetResult(true); + } + }); + + await db.HashSetAsync(hashKey, field, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for key with newlines"); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeySpace_HandlesNewlineInField(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpace, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fieldWithNewline = "field\nwith\nnewlines"; + var channel = RedisChannel.SubKeySpaceSingleKey(hashKey, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + HashSet observedFields = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + Log($"SubKeySpace_HandlesNewlineInField: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpace + && notification.Type == KeyNotificationType.HSet) + { + Log($" Parsed successfully, Type={notification.Type}, Key='{notification.GetKey()}'"); + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var field = subKey.ToString(); + Log($" Parsed field: '{field}'"); + observedFields.Add(field!); + if (field == fieldWithNewline) allDone.TrySetResult(true); + } + } + }); + + await db.HashSetAsync(hashKey, fieldWithNewline, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for field with newlines"); + Assert.Contains(fieldWithNewline, observedFields); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeySpaceEvent_HandlesNewlineInKey(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpaceEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/key\nwith\nnewlines"; + var field = "field1"; + var channel = RedisChannel.SubKeySpaceEvent(KeyNotificationType.HSet, hashKey, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + Counter receivedCount = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + receivedCount.Increment(); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpaceEvent + && notification.Type == KeyNotificationType.HSet) + { + allDone.TrySetResult(true); + } + }); + + await db.HashSetAsync(hashKey, field, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for key with newlines"); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeySpaceEvent_HandlesNewlineInField(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpaceEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fieldWithNewline = "field\nwith\nnewlines"; + var channel = RedisChannel.SubKeySpaceEvent(KeyNotificationType.HSet, hashKey, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + HashSet observedFields = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpaceEvent + && notification.Type == KeyNotificationType.HSet) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var field = subKey.ToString(); + observedFields.Add(field!); + if (field == fieldWithNewline) allDone.TrySetResult(true); + } + } + }); + + await db.HashSetAsync(hashKey, fieldWithNewline, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for field with newlines"); + Assert.Contains(fieldWithNewline, observedFields); + + await sub.UnsubscribeAsync(channel); + } + + [Fact] + public async Task SubKeySpaceItem_HandlesNewlineInField() + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpaceItem, withChannelPrefix: false); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fieldWithNewline = "field\nwith\nnewlines"; + var channel = RedisChannel.SubKeySpaceItem(hashKey, fieldWithNewline, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + Counter receivedCount = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + receivedCount.Increment(); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpaceItem + && notification.Type == KeyNotificationType.HSet) + { + var subKey = notification.GetSubKeys().FirstOrDefault(); + if (subKey.ToString() == fieldWithNewline) + { + allDone.TrySetResult(true); + } + } + }); + + await db.HashSetAsync(hashKey, fieldWithNewline, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for field with newlines"); + + await sub.UnsubscribeAsync(channel); + } + + [Fact] + public void SubKeySpaceItem_RejectsKeyWithNewline() + { + var keyWithNewline = (RedisKey)"key\nwith\nnewlines"; + var field = (RedisKey)"field1"; + + var ex = Assert.Throws(() => + RedisChannel.SubKeySpaceItem(keyWithNewline, field, 0)); + + Assert.Contains("newline", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("key", ex.ParamName); + } + + [Fact] + public void SubKeySpaceEvent_RejectsEventWithPipe() + { + byte[] eventWithPipe = "event|with|pipes"u8.ToArray(); + var key = (RedisKey)"mykey"; + + var ex = Assert.Throws(() => + RedisChannel.SubKeySpaceEvent(eventWithPipe, key, 0)); + + Assert.Contains("pipe", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("type", ex.ParamName); + } }