From 9f3101ed40edc79c23cd95bd6bf300dfb12425f2 Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Tue, 23 Sep 2025 09:45:19 -0700 Subject: [PATCH 01/11] Add timeout command support Signed-off-by: Alex Rehnby-Martin --- .../BaseClient.GenericCommands.cs | 16 ++++- .../Commands/IGenericBaseCommands.cs | 62 +++++++++++++++++-- .../Internals/Request.GenericCommands.cs | 47 +++++++++++--- .../Pipeline/BaseBatch.GenericCommands.cs | 16 ++++- .../Pipeline/IBatchGenericCommands.cs | 16 ++++- .../BatchTestUtils.cs | 6 ++ .../GenericCommandTests.cs | 41 +++++++++++- tests/Valkey.Glide.UnitTests/CommandTests.cs | 8 ++- 8 files changed, 191 insertions(+), 21 deletions(-) diff --git a/sources/Valkey.Glide/BaseClient.GenericCommands.cs b/sources/Valkey.Glide/BaseClient.GenericCommands.cs index c0fb7f2..4d46b72 100644 --- a/sources/Valkey.Glide/BaseClient.GenericCommands.cs +++ b/sources/Valkey.Glide/BaseClient.GenericCommands.cs @@ -44,13 +44,19 @@ public async Task KeyExistsAsync(ValkeyKey[] keys, CommandFlags flags = Co return await Command(Request.KeyExistsAsync(keys)); } - public async Task KeyExpireAsync(ValkeyKey key, TimeSpan? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + public async Task KeyExpireAsync(ValkeyKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None) + => await KeyExpireAsync(key, expiry, ExpireWhen.Always, flags); + + public async Task KeyExpireAsync(ValkeyKey key, TimeSpan? expiry, ExpireWhen when, CommandFlags flags = CommandFlags.None) { Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); return await Command(Request.KeyExpireAsync(key, expiry, when)); } - public async Task KeyExpireAsync(ValkeyKey key, DateTime? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + public async Task KeyExpireAsync(ValkeyKey key, DateTime? expiry, CommandFlags flags = CommandFlags.None) + => await KeyExpireAsync(key, expiry, ExpireWhen.Always, flags); + + public async Task KeyExpireAsync(ValkeyKey key, DateTime? expiry, ExpireWhen when, CommandFlags flags = CommandFlags.None) { Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); return await Command(Request.KeyExpireAsync(key, expiry, when)); @@ -116,6 +122,12 @@ public async Task KeyTouchAsync(ValkeyKey[] keys, CommandFlags flags = Com return await Command(Request.KeyTouchAsync(keys)); } + public async Task KeyExpireTimeAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None) + { + Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); + return await Command(Request.KeyExpireTimeAsync(key)); + } + public async Task KeyCopyAsync(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace = false, CommandFlags flags = CommandFlags.None) { Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); diff --git a/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs b/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs index 971201d..c631177 100644 --- a/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs +++ b/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs @@ -127,6 +127,26 @@ public interface IGenericBaseCommands /// Task KeyExistsAsync(ValkeyKey[] keys, CommandFlags flags = CommandFlags.None); + /// + /// Set a timeout on key. After the timeout has expired, the key will automatically be deleted.
+ /// If key already has an existing expire set, the time to live is updated to the new value. + /// If expiry is a non-positive value, the key will be deleted rather than expired. + /// The timeout will only be cleared by commands that delete or overwrite the contents of key. + ///
+ /// + /// The key to expire. + /// Duration for the key to expire. + /// The flags to use for this operation. Currently flags are ignored. + /// if the timeout was set. if key does not exist or the timeout could not be set. + /// + /// + /// + /// bool result = await client.KeyExpireAsync(key, TimeSpan.FromSeconds(10)); + /// + /// + /// + Task KeyExpireAsync(ValkeyKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None); + /// /// Set a timeout on key. After the timeout has expired, the key will automatically be deleted.
/// If key already has an existing expire set, the time to live is updated to the new value. @@ -146,16 +166,34 @@ public interface IGenericBaseCommands /// /// /// - Task KeyExpireAsync(ValkeyKey key, TimeSpan? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None); + Task KeyExpireAsync(ValkeyKey key, TimeSpan? expiry, ExpireWhen when, CommandFlags flags = CommandFlags.None); /// /// Sets a timeout on key. It takes an absolute Unix timestamp (seconds since January 1, 1970) instead of /// specifying the duration. A timestamp in the past will delete the key immediately. After the timeout has /// expired, the key will automatically be deleted.
/// If key already has an existing expire set, the time to live is updated to the new value. - /// The timeout will only be cleared by commands that delete or overwrite the contents of key + /// The timeout will only be cleared by commands that delete or overwrite the contents of key. + ///
+ /// + /// The key to expire. + /// The timestamp for expiry. + /// The flags to use for this operation. Currently flags are ignored. + /// if the timeout was set. if key does not exist or the timeout could not be set. + /// + /// + /// + /// bool result = await client.KeyExpireAsync(key, DateTime.UtcNow.AddMinutes(5)); + /// + /// + /// + Task KeyExpireAsync(ValkeyKey key, DateTime? expiry, CommandFlags flags = CommandFlags.None); + + /// + /// Sets a timeout on key. It takes an absolute Unix timestamp (seconds since January 1, 1970) instead of + /// specifying the duration. A timestamp in the past will delete the key immediately. After the timeout has + /// expired, the key will automatically be deleted.
/// If key already has an existing expire set, the time to live is updated to the new value. - /// If expiry is a non-positive value, the key will be deleted rather than expired. /// The timeout will only be cleared by commands that delete or overwrite the contents of key. ///
/// @@ -171,7 +209,7 @@ public interface IGenericBaseCommands /// /// /// - Task KeyExpireAsync(ValkeyKey key, DateTime? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None); + Task KeyExpireAsync(ValkeyKey key, DateTime? expiry, ExpireWhen when, CommandFlags flags = CommandFlags.None); /// /// Returns the remaining time to live of a key that has a timeout. @@ -359,6 +397,22 @@ public interface IGenericBaseCommands /// Task KeyTouchAsync(ValkeyKey[] keys, CommandFlags flags = CommandFlags.None); + /// + /// Returns the absolute time at which the given key will expire. + /// + /// + /// The key to determine the expiration value of. + /// The flags to use for this operation. Currently flags are ignored. + /// The expiration time, or when key does not exist or key exists but has no associated expiration. + /// + /// + /// + /// DateTime? expiration = await client.KeyExpireTimeAsync(key); + /// + /// + /// + Task KeyExpireTimeAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None); + /// /// Copies the value stored at the source to the destination key. When /// replace is true, removes the destination key first if it already diff --git a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs index 6073441..03e13d5 100644 --- a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs +++ b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs @@ -33,7 +33,17 @@ public static Cmd KeyExpireAsync(ValkeyKey key, TimeSpan? expiry, Ex if (expiry.HasValue) { - args.Add(((long)expiry.Value.TotalSeconds).ToGlideString()); + long milliseconds = (long)expiry.Value.TotalMilliseconds; + if (milliseconds % 1000 == 0) + { + // Use seconds precision + args.Add((milliseconds / 1000).ToGlideString()); + } + else + { + // Use milliseconds precision + args.Add(milliseconds.ToGlideString()); + } } else { @@ -45,7 +55,12 @@ public static Cmd KeyExpireAsync(ValkeyKey key, TimeSpan? expiry, Ex args.Add(when.ToLiteral().ToGlideString()); } - return Simple(RequestType.Expire, [.. args]); + // Choose command based on precision + var command = expiry.HasValue && (long)expiry.Value.TotalMilliseconds % 1000 != 0 + ? RequestType.PExpire + : RequestType.Expire; + + return Simple(command, [.. args]); } public static Cmd KeyExpireAsync(ValkeyKey key, DateTime? expiry, ExpireWhen when = ExpireWhen.Always) @@ -54,8 +69,17 @@ public static Cmd KeyExpireAsync(ValkeyKey key, DateTime? expiry, Ex if (expiry.HasValue) { - long unixTimestamp = ((DateTimeOffset)expiry.Value).ToUnixTimeSeconds(); - args.Add(unixTimestamp.ToGlideString()); + long unixMilliseconds = ((DateTimeOffset)expiry.Value).ToUnixTimeMilliseconds(); + if (unixMilliseconds % 1000 == 0) + { + // Use seconds precision + args.Add((unixMilliseconds / 1000).ToGlideString()); + } + else + { + // Use milliseconds precision + args.Add(unixMilliseconds.ToGlideString()); + } } else { @@ -67,12 +91,17 @@ public static Cmd KeyExpireAsync(ValkeyKey key, DateTime? expiry, Ex args.Add(when.ToLiteral().ToGlideString()); } - return Simple(RequestType.ExpireAt, [.. args]); + // Choose command based on precision + var command = expiry.HasValue && ((DateTimeOffset)expiry.Value).ToUnixTimeMilliseconds() % 1000 != 0 + ? RequestType.PExpireAt + : RequestType.ExpireAt; + + return Simple(command, [.. args]); } public static Cmd KeyTimeToLiveAsync(ValkeyKey key) - => new(RequestType.TTL, [key.ToGlideString()], true, response => - response is -1 or -2 ? null : TimeSpan.FromSeconds(response)); + => new(RequestType.PTTL, [key.ToGlideString()], true, response => + response is -1 or -2 ? null : TimeSpan.FromMilliseconds(response)); public static Cmd KeyTypeAsync(ValkeyKey key) => new(RequestType.Type, [key.ToGlideString()], false, response => @@ -181,6 +210,10 @@ public static Cmd KeyCopyAsync(ValkeyKey sourceKey, ValkeyKey destin return Simple(RequestType.Copy, [.. args]); } + public static Cmd KeyExpireTimeAsync(ValkeyKey key) + => new(RequestType.PExpireTime, [key.ToGlideString()], true, response => + response is -1 or -2 ? null : DateTimeOffset.FromUnixTimeMilliseconds(response).DateTime); + public static Cmd KeyMoveAsync(ValkeyKey key, int database) => Simple(RequestType.Move, [key.ToGlideString(), database.ToGlideString()]); } diff --git a/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs b/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs index 3d7cc8f..4ba8bcc 100644 --- a/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs +++ b/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs @@ -29,11 +29,17 @@ public abstract partial class BaseBatch /// public T KeyExists(ValkeyKey[] keys) => AddCmd(KeyExistsAsync(keys)); + /// + public T KeyExpire(ValkeyKey key, TimeSpan? expiry) => AddCmd(KeyExpireAsync(key, expiry)); + /// - public T KeyExpire(ValkeyKey key, TimeSpan? expiry, ExpireWhen when = ExpireWhen.Always) => AddCmd(KeyExpireAsync(key, expiry, when)); + public T KeyExpire(ValkeyKey key, TimeSpan? expiry, ExpireWhen when) => AddCmd(KeyExpireAsync(key, expiry, when)); + + /// + public T KeyExpire(ValkeyKey key, DateTime? expiry) => AddCmd(KeyExpireAsync(key, expiry)); /// - public T KeyExpire(ValkeyKey key, DateTime? expiry, ExpireWhen when = ExpireWhen.Always) => AddCmd(KeyExpireAsync(key, expiry, when)); + public T KeyExpire(ValkeyKey key, DateTime? expiry, ExpireWhen when) => AddCmd(KeyExpireAsync(key, expiry, when)); /// public T KeyTimeToLive(ValkeyKey key) => AddCmd(KeyTimeToLiveAsync(key)); @@ -65,6 +71,9 @@ public abstract partial class BaseBatch /// public T KeyTouch(ValkeyKey[] keys) => AddCmd(KeyTouchAsync(keys)); + /// + public T KeyExpireTime(ValkeyKey key) => AddCmd(KeyExpireTimeAsync(key)); + /// public T KeyCopy(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace = false) => AddCmd(KeyCopyAsync(sourceKey, destinationKey, replace)); @@ -75,7 +84,9 @@ public abstract partial class BaseBatch IBatch IBatchGenericCommands.KeyUnlink(ValkeyKey[] keys) => KeyUnlink(keys); IBatch IBatchGenericCommands.KeyExists(ValkeyKey key) => KeyExists(key); IBatch IBatchGenericCommands.KeyExists(ValkeyKey[] keys) => KeyExists(keys); + IBatch IBatchGenericCommands.KeyExpire(ValkeyKey key, TimeSpan? expiry) => KeyExpire(key, expiry); IBatch IBatchGenericCommands.KeyExpire(ValkeyKey key, TimeSpan? expiry, ExpireWhen when) => KeyExpire(key, expiry, when); + IBatch IBatchGenericCommands.KeyExpire(ValkeyKey key, DateTime? expiry) => KeyExpire(key, expiry); IBatch IBatchGenericCommands.KeyExpire(ValkeyKey key, DateTime? expiry, ExpireWhen when) => KeyExpire(key, expiry, when); IBatch IBatchGenericCommands.KeyTimeToLive(ValkeyKey key) => KeyTimeToLive(key); IBatch IBatchGenericCommands.KeyType(ValkeyKey key) => KeyType(key); @@ -87,5 +98,6 @@ public abstract partial class BaseBatch IBatch IBatchGenericCommands.KeyRestoreDateTime(ValkeyKey key, byte[] value, DateTime? expiry, RestoreOptions? restoreOptions) => KeyRestoreDateTime(key, value, expiry, restoreOptions); IBatch IBatchGenericCommands.KeyTouch(ValkeyKey key) => KeyTouch(key); IBatch IBatchGenericCommands.KeyTouch(ValkeyKey[] keys) => KeyTouch(keys); + IBatch IBatchGenericCommands.KeyExpireTime(ValkeyKey key) => KeyExpireTime(key); IBatch IBatchGenericCommands.KeyCopy(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace) => KeyCopy(sourceKey, destinationKey, replace); } diff --git a/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs b/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs index ccdf281..27f2cf2 100644 --- a/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs +++ b/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs @@ -31,13 +31,21 @@ internal interface IBatchGenericCommands /// Command Response - IBatch KeyExists(ValkeyKey[] keys); + /// + /// Command Response - + IBatch KeyExpire(ValkeyKey key, TimeSpan? expiry); + /// /// Command Response - - IBatch KeyExpire(ValkeyKey key, TimeSpan? expiry, ExpireWhen when = ExpireWhen.Always); + IBatch KeyExpire(ValkeyKey key, TimeSpan? expiry, ExpireWhen when); + + /// + /// Command Response - + IBatch KeyExpire(ValkeyKey key, DateTime? expiry); /// /// Command Response - - IBatch KeyExpire(ValkeyKey key, DateTime? expiry, ExpireWhen when = ExpireWhen.Always); + IBatch KeyExpire(ValkeyKey key, DateTime? expiry, ExpireWhen when); /// /// Command Response - @@ -79,6 +87,10 @@ internal interface IBatchGenericCommands /// Command Response - IBatch KeyTouch(ValkeyKey[] keys); + /// + /// Command Response - + IBatch KeyExpireTime(ValkeyKey key); + /// /// Command Response - IBatch KeyCopy(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace = false); diff --git a/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs b/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs index 9bbb577..2728e0e 100644 --- a/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs +++ b/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs @@ -459,6 +459,12 @@ public static List CreateGenericTest(Pipeline.IBatch batch, bool isAto _ = batch.KeyTimeToLive(genericKey1); testData.Add(new(null, "KeyTimeToLive(genericKey1) after persist")); + _ = batch.KeyExpire(genericKey1, TimeSpan.FromSeconds(120)); + testData.Add(new(true, "KeyExpire(genericKey1, 120s)")); + + _ = batch.KeyExpireTime(genericKey1); + testData.Add(new(DateTime.UtcNow.AddSeconds(120), "KeyExpireTime(genericKey1)", true)); + _ = batch.KeyTouch(genericKey1); testData.Add(new(true, "KeyTouch(genericKey1)")); diff --git a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs index ea5fb28..b52b61a 100644 --- a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs @@ -93,7 +93,7 @@ public async Task TestKeyExpire_KeyTimeToLive(BaseClient client) // Set a key await client.StringSetAsync(key, value); - // Set expiry + // Set expiry with seconds precision (should use EXPIRE) Assert.True(await client.KeyExpireAsync(key, TimeSpan.FromSeconds(10))); // Check TTL @@ -101,7 +101,15 @@ public async Task TestKeyExpire_KeyTimeToLive(BaseClient client) Assert.NotNull(ttl); Assert.True(ttl.Value.TotalSeconds > 0 && ttl.Value.TotalSeconds <= 10); - // Test with DateTime + // Test with millisecond precision (should use PEXPIRE) + Assert.True(await client.KeyExpireAsync(key, TimeSpan.FromMilliseconds(5500))); + + ttl = await client.KeyTimeToLiveAsync(key); + Assert.NotNull(ttl); + // Now with PTTL support, we should get millisecond precision + Assert.True(ttl.Value.TotalMilliseconds > 0 && ttl.Value.TotalMilliseconds <= 5500); + + // Test with DateTime (should use EXPIREAT or PEXPIREAT based on precision) DateTime expireTime = DateTime.UtcNow.AddSeconds(15); Assert.True(await client.KeyExpireAsync(key, expireTime)); @@ -110,6 +118,35 @@ public async Task TestKeyExpire_KeyTimeToLive(BaseClient client) Assert.True(ttl.Value.TotalSeconds > 10); } + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] + public async Task TestKeyExpireTime(BaseClient client) + { + string key = Guid.NewGuid().ToString(); + string value = "test_value"; + + // Set a key + await client.StringSetAsync(key, value); + + // Key without expiry should return null + DateTime? expireTime = await client.KeyExpireTimeAsync(key); + Assert.Null(expireTime); + + // Set expiry and check expire time + DateTime futureTime = DateTime.UtcNow.AddSeconds(30); + Assert.True(await client.KeyExpireAsync(key, futureTime)); + + expireTime = await client.KeyExpireTimeAsync(key); + Assert.NotNull(expireTime); + // Should be close to the set time (within a few seconds tolerance) + Assert.True(Math.Abs((expireTime.Value - futureTime).TotalSeconds) < 5); + + // Non-existent key should return null + string nonExistentKey = Guid.NewGuid().ToString(); + expireTime = await client.KeyExpireTimeAsync(nonExistentKey); + Assert.Null(expireTime); + } + [Theory(DisableDiscoveryEnumeration = true)] [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] public async Task TestKeyType(BaseClient client) diff --git a/tests/Valkey.Glide.UnitTests/CommandTests.cs b/tests/Valkey.Glide.UnitTests/CommandTests.cs index 3ae3324..27b2aa7 100644 --- a/tests/Valkey.Glide.UnitTests/CommandTests.cs +++ b/tests/Valkey.Glide.UnitTests/CommandTests.cs @@ -131,7 +131,7 @@ public void ValidateCommandArgs() () => Assert.Equal(["EXPIRE", "key", "60"], Request.KeyExpireAsync("key", TimeSpan.FromSeconds(60)).GetArgs()), () => Assert.Equal(["EXPIRE", "key", "60", "NX"], Request.KeyExpireAsync("key", TimeSpan.FromSeconds(60), ExpireWhen.HasNoExpiry).GetArgs()), () => Assert.Equal(["EXPIREAT", "key", "1609459200"], Request.KeyExpireAsync("key", new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc)).GetArgs()), - () => Assert.Equal(["TTL", "key"], Request.KeyTimeToLiveAsync("key").GetArgs()), + () => Assert.Equal(["PTTL", "key"], Request.KeyTimeToLiveAsync("key").GetArgs()), () => Assert.Equal(["TYPE", "key"], Request.KeyTypeAsync("key").GetArgs()), () => Assert.Equal(["RENAME", "oldkey", "newkey"], Request.KeyRenameAsync("oldkey", "newkey").GetArgs()), () => Assert.Equal(["RENAMENX", "oldkey", "newkey"], Request.KeyRenameNXAsync("oldkey", "newkey").GetArgs()), @@ -157,6 +157,7 @@ public void ValidateCommandArgs() () => Assert.Equal(["TOUCH", "key1", "key2"], Request.KeyTouchAsync(["key1", "key2"]).GetArgs()), () => Assert.Equal(["COPY", "src", "dest"], Request.KeyCopyAsync("src", "dest").GetArgs()), () => Assert.Equal(["COPY", "src", "dest", "DB", "1", "REPLACE"], Request.KeyCopyAsync("src", "dest", 1, true).GetArgs()), + () => Assert.Equal(["PEXPIRETIME", "key"], Request.KeyExpireTimeAsync("key").GetArgs()), () => Assert.Equal(["MOVE", "key", "1"], Request.KeyMoveAsync("key", 1).GetArgs()), // List Commands @@ -323,7 +324,7 @@ public void ValidateCommandConverters() () => Assert.Equal(2L, Request.KeyExistsAsync(["key1", "key2"]).Converter(2L)), () => Assert.True(Request.KeyExpireAsync("key", TimeSpan.FromSeconds(60)).Converter(true)), () => Assert.False(Request.KeyExpireAsync("key", TimeSpan.FromSeconds(60)).Converter(false)), - () => Assert.Equal(TimeSpan.FromSeconds(30), Request.KeyTimeToLiveAsync("key").Converter(30L)), + () => Assert.Equal(TimeSpan.FromMilliseconds(30), Request.KeyTimeToLiveAsync("key").Converter(30L)), () => Assert.Null(Request.KeyTimeToLiveAsync("key").Converter(-1L)), () => Assert.Null(Request.KeyTimeToLiveAsync("key").Converter(-2L)), () => Assert.Equal(ValkeyType.String, Request.KeyTypeAsync("key").Converter("string")), @@ -346,6 +347,9 @@ public void ValidateCommandConverters() () => Assert.Equal(2L, Request.KeyTouchAsync(["key1", "key2"]).Converter(2L)), () => Assert.True(Request.KeyCopyAsync("src", "dest").Converter(true)), () => Assert.False(Request.KeyCopyAsync("src", "dest").Converter(false)), + () => Assert.Equal(new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc), Request.KeyExpireTimeAsync("key").Converter(1609459200000L)), + () => Assert.Null(Request.KeyExpireTimeAsync("key").Converter(-1L)), + () => Assert.Null(Request.KeyExpireTimeAsync("key").Converter(-2L)), () => Assert.True(Request.KeyMoveAsync("key", 1).Converter(true)), () => Assert.False(Request.KeyMoveAsync("key", 1).Converter(false)), From 4eac200cf12f9383a150a3a2c41c66d0c7ab65ec Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Tue, 23 Sep 2025 11:29:30 -0700 Subject: [PATCH 02/11] Add object commands Signed-off-by: Alex Rehnby-Martin --- .../BaseClient.GenericCommands.cs | 30 ++++ .../Commands/IGenericBaseCommands.cs | 79 +++++++++ sources/Valkey.Glide/Internals/Cmd.cs | 11 +- .../Internals/Request.GenericCommands.cs | 15 ++ .../Pipeline/BaseBatch.GenericCommands.cs | 20 +++ .../Pipeline/IBatchGenericCommands.cs | 20 +++ .../BatchTestUtils.cs | 18 +++ .../GenericCommandTests.cs | 153 ++++++++++++++++++ tests/Valkey.Glide.UnitTests/CommandTests.cs | 15 ++ 9 files changed, 360 insertions(+), 1 deletion(-) diff --git a/sources/Valkey.Glide/BaseClient.GenericCommands.cs b/sources/Valkey.Glide/BaseClient.GenericCommands.cs index 4d46b72..8fcb8f7 100644 --- a/sources/Valkey.Glide/BaseClient.GenericCommands.cs +++ b/sources/Valkey.Glide/BaseClient.GenericCommands.cs @@ -128,10 +128,40 @@ public async Task KeyTouchAsync(ValkeyKey[] keys, CommandFlags flags = Com return await Command(Request.KeyExpireTimeAsync(key)); } + public async Task KeyEncodingAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None) + { + Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); + return await Command(Request.KeyEncodingAsync(key)); + } + + public async Task KeyFrequencyAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None) + { + Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); + return await Command(Request.KeyFrequencyAsync(key)); + } + + public async Task KeyIdleTimeAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None) + { + Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); + return await Command(Request.KeyIdleTimeAsync(key)); + } + + public async Task KeyRefCountAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None) + { + Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); + return await Command(Request.KeyRefCountAsync(key)); + } + public async Task KeyCopyAsync(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace = false, CommandFlags flags = CommandFlags.None) { Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); return await Command(Request.KeyCopyAsync(sourceKey, destinationKey, replace)); } + public async Task KeyRandomAsync(CommandFlags flags = CommandFlags.None) + { + Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); + return await Command(Request.KeyRandomAsync()); + } + } diff --git a/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs b/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs index c631177..d70080e 100644 --- a/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs +++ b/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs @@ -413,6 +413,70 @@ public interface IGenericBaseCommands /// Task KeyExpireTimeAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None); + /// + /// Returns the internal encoding for the object stored at key. + /// + /// + /// The key to determine the encoding of. + /// The flags to use for this operation. Currently flags are ignored. + /// The encoding of the object, or when key does not exist. + /// + /// + /// + /// string? encoding = await client.KeyEncodingAsync(key); + /// + /// + /// + Task KeyEncodingAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None); + + /// + /// Returns the logarithmic access frequency counter for the object stored at key. + /// + /// + /// The key to determine the frequency of. + /// The flags to use for this operation. Currently flags are ignored. + /// The frequency counter, or when key does not exist. + /// + /// + /// + /// long? frequency = await client.KeyFrequencyAsync(key); + /// + /// + /// + Task KeyFrequencyAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None); + + /// + /// Returns the time in seconds since the object stored at key was last accessed. + /// + /// + /// The key to determine the idle time of. + /// The flags to use for this operation. Currently flags are ignored. + /// The idle time in seconds, or when key does not exist. + /// + /// + /// + /// long? idleTime = await client.KeyIdleTimeAsync(key); + /// + /// + /// + Task KeyIdleTimeAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None); + + /// + /// Returns the reference count of the object stored at key. + /// + /// + /// The key to determine the reference count of. + /// The flags to use for this operation. Currently flags are ignored. + /// The reference count, or when key does not exist. + /// + /// + /// + /// long? refCount = await client.KeyRefCountAsync(key); + /// + /// + /// + Task KeyRefCountAsync(ValkeyKey key, CommandFlags flags = CommandFlags.None); + /// /// Copies the value stored at the source to the destination key. When /// replace is true, removes the destination key first if it already @@ -434,4 +498,19 @@ public interface IGenericBaseCommands /// /// Task KeyCopyAsync(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace = false, CommandFlags flags = CommandFlags.None); + + /// + /// Returns a random key from the database. + /// + /// + /// The flags to use for this operation. Currently flags are ignored. + /// A random key, or when the database is empty. + /// + /// + /// + /// string? randomKey = await client.KeyRandomAsync(); + /// + /// + /// + Task KeyRandomAsync(CommandFlags flags = CommandFlags.None); } diff --git a/sources/Valkey.Glide/Internals/Cmd.cs b/sources/Valkey.Glide/Internals/Cmd.cs index c2c4c95..f457be7 100644 --- a/sources/Valkey.Glide/Internals/Cmd.cs +++ b/sources/Valkey.Glide/Internals/Cmd.cs @@ -85,7 +85,16 @@ public Cmd> ToClusterValue(bool isSingleValue) /// public string[] GetArgs() => Request == RequestType.CustomCommand ? ArgsArray.Args.ToStrings() - : [.. ArgsArray.Args.ToStrings().Prepend(Request.ToString().ToUpper())]; + : [.. GetCommandParts(Request).Concat(ArgsArray.Args.ToStrings())]; + + private static string[] GetCommandParts(RequestType requestType) => requestType switch + { + RequestType.ObjectEncoding => ["OBJECT", "ENCODING"], + RequestType.ObjectFreq => ["OBJECT", "FREQ"], + RequestType.ObjectIdleTime => ["OBJECT", "IDLETIME"], + RequestType.ObjectRefCount => ["OBJECT", "REFCOUNT"], + _ => [requestType.ToString().ToUpper()] + }; } internal record ArgsArray diff --git a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs index 03e13d5..b3f2cdc 100644 --- a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs +++ b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs @@ -214,6 +214,21 @@ public static Cmd KeyCopyAsync(ValkeyKey sourceKey, ValkeyKey destin => new(RequestType.PExpireTime, [key.ToGlideString()], true, response => response is -1 or -2 ? null : DateTimeOffset.FromUnixTimeMilliseconds(response).DateTime); + public static Cmd KeyEncodingAsync(ValkeyKey key) + => new(RequestType.ObjectEncoding, [key.ToGlideString()], true, response => response?.ToString()); + + public static Cmd KeyFrequencyAsync(ValkeyKey key) + => new(RequestType.ObjectFreq, [key.ToGlideString()], true, response => response == -1 ? null : response); + + public static Cmd KeyIdleTimeAsync(ValkeyKey key) + => new(RequestType.ObjectIdleTime, [key.ToGlideString()], true, response => response == -1 ? null : response); + + public static Cmd KeyRefCountAsync(ValkeyKey key) + => new(RequestType.ObjectRefCount, [key.ToGlideString()], true, response => response == -1 ? null : response); + + public static Cmd KeyRandomAsync() + => new(RequestType.RandomKey, [], true, response => response?.ToString()); + public static Cmd KeyMoveAsync(ValkeyKey key, int database) => Simple(RequestType.Move, [key.ToGlideString(), database.ToGlideString()]); } diff --git a/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs b/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs index 4ba8bcc..8c9250d 100644 --- a/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs +++ b/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs @@ -74,9 +74,24 @@ public abstract partial class BaseBatch /// public T KeyExpireTime(ValkeyKey key) => AddCmd(KeyExpireTimeAsync(key)); + /// + public T KeyEncoding(ValkeyKey key) => AddCmd(KeyEncodingAsync(key)); + + /// + public T KeyFrequency(ValkeyKey key) => AddCmd(KeyFrequencyAsync(key)); + + /// + public T KeyIdleTime(ValkeyKey key) => AddCmd(KeyIdleTimeAsync(key)); + + /// + public T KeyRefCount(ValkeyKey key) => AddCmd(KeyRefCountAsync(key)); + /// public T KeyCopy(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace = false) => AddCmd(KeyCopyAsync(sourceKey, destinationKey, replace)); + /// + public T KeyRandom() => AddCmd(KeyRandomAsync()); + // Explicit interface implementations for IBatchGenericCommands IBatch IBatchGenericCommands.KeyDelete(ValkeyKey key) => KeyDelete(key); IBatch IBatchGenericCommands.KeyDelete(ValkeyKey[] keys) => KeyDelete(keys); @@ -99,5 +114,10 @@ public abstract partial class BaseBatch IBatch IBatchGenericCommands.KeyTouch(ValkeyKey key) => KeyTouch(key); IBatch IBatchGenericCommands.KeyTouch(ValkeyKey[] keys) => KeyTouch(keys); IBatch IBatchGenericCommands.KeyExpireTime(ValkeyKey key) => KeyExpireTime(key); + IBatch IBatchGenericCommands.KeyEncoding(ValkeyKey key) => KeyEncoding(key); + IBatch IBatchGenericCommands.KeyFrequency(ValkeyKey key) => KeyFrequency(key); + IBatch IBatchGenericCommands.KeyIdleTime(ValkeyKey key) => KeyIdleTime(key); + IBatch IBatchGenericCommands.KeyRefCount(ValkeyKey key) => KeyRefCount(key); IBatch IBatchGenericCommands.KeyCopy(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace) => KeyCopy(sourceKey, destinationKey, replace); + IBatch IBatchGenericCommands.KeyRandom() => KeyRandom(); } diff --git a/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs b/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs index 27f2cf2..17676ac 100644 --- a/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs +++ b/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs @@ -91,7 +91,27 @@ internal interface IBatchGenericCommands /// Command Response - IBatch KeyExpireTime(ValkeyKey key); + /// + /// Command Response - + IBatch KeyEncoding(ValkeyKey key); + + /// + /// Command Response - + IBatch KeyFrequency(ValkeyKey key); + + /// + /// Command Response - + IBatch KeyIdleTime(ValkeyKey key); + + /// + /// Command Response - + IBatch KeyRefCount(ValkeyKey key); + /// /// Command Response - IBatch KeyCopy(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace = false); + + /// + /// Command Response - + IBatch KeyRandom(); } diff --git a/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs b/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs index 2728e0e..3017334 100644 --- a/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs +++ b/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs @@ -465,6 +465,24 @@ public static List CreateGenericTest(Pipeline.IBatch batch, bool isAto _ = batch.KeyExpireTime(genericKey1); testData.Add(new(DateTime.UtcNow.AddSeconds(120), "KeyExpireTime(genericKey1)", true)); + _ = batch.KeyEncoding(genericKey1); + testData.Add(new("embstr", "KeyEncoding(genericKey1)", true)); + + // KeyFrequency requires LFU maxmemory policy to be configured + // Since we can't guarantee this in test environment, we skip this test in batch mode + // The functionality is tested in integration tests with proper exception handling + // _ = batch.KeyFrequency(genericKey1); + // testData.Add(new(1L, "KeyFrequency(genericKey1)", true)); + + _ = batch.KeyIdleTime(genericKey1); + testData.Add(new(0L, "KeyIdleTime(genericKey1)", true)); + + _ = batch.KeyRefCount(genericKey1); + testData.Add(new(1L, "KeyRefCount(genericKey1)", true)); + + _ = batch.KeyRandom(); + testData.Add(new(genericKey1, "KeyRandom()", true)); + _ = batch.KeyTouch(genericKey1); testData.Add(new(true, "KeyTouch(genericKey1)")); diff --git a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs index b52b61a..aa24998 100644 --- a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs @@ -147,6 +147,122 @@ public async Task TestKeyExpireTime(BaseClient client) Assert.Null(expireTime); } + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] + public async Task TestKeyEncoding(BaseClient client) + { + string key = Guid.NewGuid().ToString(); + string value = "test_value"; + + // Set a string key + await client.StringSetAsync(key, value); + + // Get encoding for string key + string? encoding = await client.KeyEncodingAsync(key); + Assert.NotNull(encoding); + // String encoding can be "raw", "embstr", or "int" depending on the value + Assert.Contains(encoding, new[] { "raw", "embstr", "int" }); + + // Test with different data types + string listKey = Guid.NewGuid().ToString(); + await client.ListLeftPushAsync(listKey, "item"); + string? listEncoding = await client.KeyEncodingAsync(listKey); + Assert.NotNull(listEncoding); + + string setKey = Guid.NewGuid().ToString(); + await client.SetAddAsync(setKey, "member"); + string? setEncoding = await client.KeyEncodingAsync(setKey); + Assert.NotNull(setEncoding); + + // Non-existent key should return null + string nonExistentKey = Guid.NewGuid().ToString(); + string? nonExistentEncoding = await client.KeyEncodingAsync(nonExistentKey); + Assert.Null(nonExistentEncoding); + } + + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] + public async Task TestKeyFrequency(BaseClient client) + { + string key = Guid.NewGuid().ToString(); + string value = "test_value"; + + // Set a string key + await client.StringSetAsync(key, value); + + try + { + // Get frequency for string key + long? frequency = await client.KeyFrequencyAsync(key); + Assert.NotNull(frequency); + Assert.True(frequency >= 0); + + // Non-existent key should return null + string nonExistentKey = Guid.NewGuid().ToString(); + long? nonExistentFrequency = await client.KeyFrequencyAsync(nonExistentKey); + Assert.Null(nonExistentFrequency); + } + catch (Errors.RequestException ex) when (ex.Message.Contains("LFU maxmemory policy is not selected")) + { + // This is expected when LFU eviction policy is not configured + // The command implementation is correct, but server doesn't track frequency + } + } + + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] + public async Task TestKeyIdleTime(BaseClient client) + { + string key = Guid.NewGuid().ToString(); + string value = "test_value"; + + // Set a string key + await client.StringSetAsync(key, value); + + // Get idle time for string key + long? idleTime = await client.KeyIdleTimeAsync(key); + Assert.NotNull(idleTime); + Assert.True(idleTime >= 0); + + // Wait a bit and check that idle time increases + await Task.Delay(1000); + long? idleTime2 = await client.KeyIdleTimeAsync(key); + Assert.NotNull(idleTime2); + Assert.True(idleTime2 >= idleTime); + + // Access the key to reset idle time + await client.StringGetAsync(key); + long? idleTimeAfterAccess = await client.KeyIdleTimeAsync(key); + Assert.NotNull(idleTimeAfterAccess); + Assert.True(idleTimeAfterAccess < idleTime2); + + // Non-existent key should return null + string nonExistentKey = Guid.NewGuid().ToString(); + long? nonExistentIdleTime = await client.KeyIdleTimeAsync(nonExistentKey); + Assert.Null(nonExistentIdleTime); + } + + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] + public async Task TestKeyRefCount(BaseClient client) + { + string key = Guid.NewGuid().ToString(); + string value = "test_value"; + + // Set a string key + await client.StringSetAsync(key, value); + + // Get reference count for string key + long? refCount = await client.KeyRefCountAsync(key); + Assert.NotNull(refCount); + Assert.True(refCount >= 1); + + // Non-existent key should return null + string nonExistentKey = Guid.NewGuid().ToString(); + long? nonExistentRefCount = await client.KeyRefCountAsync(nonExistentKey); + Assert.Null(nonExistentRefCount); + } + [Theory(DisableDiscoveryEnumeration = true)] [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] public async Task TestKeyType(BaseClient client) @@ -359,4 +475,41 @@ public async Task TestKeyCopy(BaseClient client) Assert.Equal(value, await client.StringGetAsync(destKey)); } + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] + public async Task TestKeyRandom(BaseClient client) + { + // Test with empty database + string? randomKey = await client.KeyRandomAsync(); + // May be null if database is empty, or return an existing key + + // Set some keys to ensure we have data + string key1 = Guid.NewGuid().ToString(); + string key2 = Guid.NewGuid().ToString(); + string key3 = Guid.NewGuid().ToString(); + + await client.StringSetAsync(key1, "value1"); + await client.StringSetAsync(key2, "value2"); + await client.StringSetAsync(key3, "value3"); + + // Now we should get a random key + randomKey = await client.KeyRandomAsync(); + Assert.NotNull(randomKey); + Assert.True(await client.KeyExistsAsync(randomKey)); + + // Call multiple times to verify it can return different keys + HashSet seenKeys = new(); + for (int i = 0; i < 10; i++) + { + string? key = await client.KeyRandomAsync(); + if (key != null) + { + seenKeys.Add(key); + } + } + + // We should have seen at least one key + Assert.NotEmpty(seenKeys); + } + } diff --git a/tests/Valkey.Glide.UnitTests/CommandTests.cs b/tests/Valkey.Glide.UnitTests/CommandTests.cs index 27b2aa7..3baf343 100644 --- a/tests/Valkey.Glide.UnitTests/CommandTests.cs +++ b/tests/Valkey.Glide.UnitTests/CommandTests.cs @@ -158,6 +158,11 @@ public void ValidateCommandArgs() () => Assert.Equal(["COPY", "src", "dest"], Request.KeyCopyAsync("src", "dest").GetArgs()), () => Assert.Equal(["COPY", "src", "dest", "DB", "1", "REPLACE"], Request.KeyCopyAsync("src", "dest", 1, true).GetArgs()), () => Assert.Equal(["PEXPIRETIME", "key"], Request.KeyExpireTimeAsync("key").GetArgs()), + () => Assert.Equal(["OBJECT", "ENCODING", "key"], Request.KeyEncodingAsync("key").GetArgs()), + () => Assert.Equal(["OBJECT", "FREQ", "key"], Request.KeyFrequencyAsync("key").GetArgs()), + () => Assert.Equal(["OBJECT", "IDLETIME", "key"], Request.KeyIdleTimeAsync("key").GetArgs()), + () => Assert.Equal(["OBJECT", "REFCOUNT", "key"], Request.KeyRefCountAsync("key").GetArgs()), + () => Assert.Equal(["RANDOMKEY"], Request.KeyRandomAsync().GetArgs()), () => Assert.Equal(["MOVE", "key", "1"], Request.KeyMoveAsync("key", 1).GetArgs()), // List Commands @@ -350,6 +355,16 @@ public void ValidateCommandConverters() () => Assert.Equal(new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc), Request.KeyExpireTimeAsync("key").Converter(1609459200000L)), () => Assert.Null(Request.KeyExpireTimeAsync("key").Converter(-1L)), () => Assert.Null(Request.KeyExpireTimeAsync("key").Converter(-2L)), + () => Assert.Equal("embstr", Request.KeyEncodingAsync("key").Converter(new GlideString("embstr"))), + () => Assert.Null(Request.KeyEncodingAsync("key").Converter(null)), + () => Assert.Equal(5L, Request.KeyFrequencyAsync("key").Converter(5L)), + () => Assert.Null(Request.KeyFrequencyAsync("key").Converter(-1L)), + () => Assert.Equal(10L, Request.KeyIdleTimeAsync("key").Converter(10L)), + () => Assert.Null(Request.KeyIdleTimeAsync("key").Converter(-1L)), + () => Assert.Equal(3L, Request.KeyRefCountAsync("key").Converter(3L)), + () => Assert.Null(Request.KeyRefCountAsync("key").Converter(-1L)), + () => Assert.Equal("randomkey", Request.KeyRandomAsync().Converter(new GlideString("randomkey"))), + () => Assert.Null(Request.KeyRandomAsync().Converter(null)), () => Assert.True(Request.KeyMoveAsync("key", 1).Converter(true)), () => Assert.False(Request.KeyMoveAsync("key", 1).Converter(false)), From b06ed389b8337181ffd02e0d950d959341978a2a Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Wed, 24 Sep 2025 09:46:14 -0700 Subject: [PATCH 03/11] Implement sort Signed-off-by: Alex Rehnby-Martin --- .../BaseClient.GenericCommands.cs | 6 +++ .../Commands/Constants/Constants.cs | 10 +++++ .../Commands/IGenericBaseCommands.cs | 24 ++++++++++ .../Internals/Request.GenericCommands.cs | 39 ++++++++++++++++ .../Pipeline/BaseBatch.GenericCommands.cs | 4 ++ .../Pipeline/IBatchGenericCommands.cs | 4 ++ .../GenericCommandTests.cs | 45 +++++++++++++++++++ 7 files changed, 132 insertions(+) diff --git a/sources/Valkey.Glide/BaseClient.GenericCommands.cs b/sources/Valkey.Glide/BaseClient.GenericCommands.cs index 8fcb8f7..cca392a 100644 --- a/sources/Valkey.Glide/BaseClient.GenericCommands.cs +++ b/sources/Valkey.Glide/BaseClient.GenericCommands.cs @@ -164,4 +164,10 @@ public async Task KeyCopyAsync(ValkeyKey sourceKey, ValkeyKey destinationK return await Command(Request.KeyRandomAsync()); } + public async Task SortAsync(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null, CommandFlags flags = CommandFlags.None) + { + Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); + return await Command(Request.SortAsync(key, skip, take, order, sortType, by, get)); + } + } diff --git a/sources/Valkey.Glide/Commands/Constants/Constants.cs b/sources/Valkey.Glide/Commands/Constants/Constants.cs index 0e5e442..35d4e2c 100644 --- a/sources/Valkey.Glide/Commands/Constants/Constants.cs +++ b/sources/Valkey.Glide/Commands/Constants/Constants.cs @@ -77,4 +77,14 @@ public static class Constants /// The lowest bound in the sorted set for score operations. /// public const string NegativeInfinityScore = "-inf"; + + /// + /// Keywords for SORT command. + /// + public const string AlphaKeyword = "ALPHA"; + public const string AscKeyword = "ASC"; + public const string DescKeyword = "DESC"; + public const string ByKeyword = "BY"; + public const string GetKeyword = "GET"; + public const string StoreKeyword = "STORE"; } diff --git a/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs b/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs index d70080e..0e9e5fa 100644 --- a/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs +++ b/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs @@ -513,4 +513,28 @@ public interface IGenericBaseCommands /// /// Task KeyRandomAsync(CommandFlags flags = CommandFlags.None); + + /// + /// Sorts the elements in the list, set, or sorted set at key and returns the result. + /// + /// + /// The key of the list, set, or sorted set to be sorted. + /// The number of elements to skip. + /// The number of elements to take. -1 means take all. + /// The sort order. + /// The sort type. + /// The pattern to sort by external keys. + /// The patterns to retrieve external keys' values. + /// The flags to use for this operation. Currently flags are ignored. + /// An array of sorted elements. + /// + /// + /// + /// await client.ListLeftPushAsync("mylist", ["3", "1", "2"]); + /// ValkeyValue[] result = await client.SortAsync("mylist"); + /// // result is ["1", "2", "3"] + /// + /// + /// + Task SortAsync(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null, CommandFlags flags = CommandFlags.None); } diff --git a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs index b3f2cdc..aac79f5 100644 --- a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs +++ b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs @@ -229,6 +229,45 @@ public static Cmd KeyCopyAsync(ValkeyKey sourceKey, ValkeyKey destin public static Cmd KeyRandomAsync() => new(RequestType.RandomKey, [], true, response => response?.ToString()); + public static Cmd SortAsync(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null) + { + List args = [key.ToGlideString()]; + + if (!by.IsNull) + { + args.Add(Constants.ByKeyword); + args.Add(by.ToGlideString()); + } + + if (skip != 0 || take != -1) + { + args.Add(Constants.LimitKeyword); + args.Add(skip.ToGlideString()); + args.Add(take.ToGlideString()); + } + + if (get != null) + { + foreach (var pattern in get) + { + args.Add(Constants.GetKeyword); + args.Add(pattern.ToGlideString()); + } + } + + if (order == Order.Descending) + { + args.Add(Constants.DescKeyword); + } + + if (sortType == SortType.Alphabetic) + { + args.Add(Constants.AlphaKeyword); + } + + return new(RequestType.Sort, [.. args], false, response => response?.Cast().Select(item => (ValkeyValue)item).ToArray() ?? []); + } + public static Cmd KeyMoveAsync(ValkeyKey key, int database) => Simple(RequestType.Move, [key.ToGlideString(), database.ToGlideString()]); } diff --git a/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs b/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs index 8c9250d..ea019f8 100644 --- a/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs +++ b/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs @@ -92,6 +92,9 @@ public abstract partial class BaseBatch /// public T KeyRandom() => AddCmd(KeyRandomAsync()); + /// + public T Sort(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null) => AddCmd(SortAsync(key, skip, take, order, sortType, by, get)); + // Explicit interface implementations for IBatchGenericCommands IBatch IBatchGenericCommands.KeyDelete(ValkeyKey key) => KeyDelete(key); IBatch IBatchGenericCommands.KeyDelete(ValkeyKey[] keys) => KeyDelete(keys); @@ -120,4 +123,5 @@ public abstract partial class BaseBatch IBatch IBatchGenericCommands.KeyRefCount(ValkeyKey key) => KeyRefCount(key); IBatch IBatchGenericCommands.KeyCopy(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace) => KeyCopy(sourceKey, destinationKey, replace); IBatch IBatchGenericCommands.KeyRandom() => KeyRandom(); + IBatch IBatchGenericCommands.Sort(ValkeyKey key, long skip, long take, Order order, SortType sortType, ValkeyValue by, ValkeyValue[]? get) => Sort(key, skip, take, order, sortType, by, get); } diff --git a/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs b/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs index 17676ac..9bfab04 100644 --- a/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs +++ b/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs @@ -114,4 +114,8 @@ internal interface IBatchGenericCommands /// /// Command Response - IBatch KeyRandom(); + + /// + /// Command Response - + IBatch Sort(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null); } diff --git a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs index aa24998..a921681 100644 --- a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs @@ -512,4 +512,49 @@ public async Task TestKeyRandom(BaseClient client) Assert.NotEmpty(seenKeys); } + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] + public async Task TestSort(BaseClient client) + { + string key = Guid.NewGuid().ToString(); + + // Test with list + await client.ListLeftPushAsync(key, ["3", "1", "2"]); + ValkeyValue[] result = await client.SortAsync(key); + Assert.Equal(["1", "2", "3"], result.Select(v => v.ToString()).ToArray()); + + // Test with descending order + result = await client.SortAsync(key, order: Order.Descending); + Assert.Equal(["3", "2", "1"], result.Select(v => v.ToString()).ToArray()); + + // Test with limit + result = await client.SortAsync(key, skip: 1, take: 1); + Assert.Single(result); + Assert.Equal("2", result[0].ToString()); + + // Test alphabetic sort + string alphaKey = Guid.NewGuid().ToString(); + await client.ListLeftPushAsync(alphaKey, ["b", "a", "c"]); + result = await client.SortAsync(alphaKey, sortType: SortType.Alphabetic); + Assert.Equal(["a", "b", "c"], result.Select(v => v.ToString()).ToArray()); + + string userKey = Guid.NewGuid().ToString(); + + // Test with BY pattern (skip for cluster clients as BY option is denied in cluster mode) + if (client is not GlideClusterClient) + { + await client.HashSetAsync("user:1", [new HashEntry("age", "30")]); + await client.HashSetAsync("user:2", [new HashEntry("age", "25")]); + await client.ListLeftPushAsync(userKey, ["2", "1"]); + result = await client.SortAsync(userKey, by: "user:*->age"); + Assert.Equal(["2", "1"], result.Select(v => v.ToString()).ToArray()); + + // Test with GET pattern + await client.HashSetAsync("user:1", [new HashEntry("name", "Alice")]); + await client.HashSetAsync("user:2", [new HashEntry("name", "Bob")]); + result = await client.SortAsync(userKey, by: "user:*->age", get: ["user:*->name"]); + Assert.Equal(["Bob", "Alice"], result.Select(v => v.ToString()).ToArray()); + } + } + } From 5137a6225c21966dac529a058ecde3b08cc5d870 Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Fri, 26 Sep 2025 10:42:25 -0700 Subject: [PATCH 04/11] SCAN/WAIT Signed-off-by: Alex Rehnby-Martin --- .../BaseClient.GenericCommands.cs | 6 ++ .../Commands/IGenericBaseCommands.cs | 18 +++++ sources/Valkey.Glide/GlideClient.cs | 23 ++++++ .../Internals/Request.GenericCommands.cs | 26 ++++++ .../Pipeline/BaseBatch.GenericCommands.cs | 4 + .../Pipeline/IBatchGenericCommands.cs | 4 + .../abstract_Enums/ValkeyCommand.cs | 2 + .../BatchTestUtils.cs | 10 +++ .../GenericCommandTests.cs | 79 +++++++++++++++++++ tests/Valkey.Glide.UnitTests/CommandTests.cs | 37 +++++++++ 10 files changed, 209 insertions(+) diff --git a/sources/Valkey.Glide/BaseClient.GenericCommands.cs b/sources/Valkey.Glide/BaseClient.GenericCommands.cs index cca392a..6eb4f29 100644 --- a/sources/Valkey.Glide/BaseClient.GenericCommands.cs +++ b/sources/Valkey.Glide/BaseClient.GenericCommands.cs @@ -170,4 +170,10 @@ public async Task SortAsync(ValkeyKey key, long skip = 0, long ta return await Command(Request.SortAsync(key, skip, take, order, sortType, by, get)); } + public async Task WaitAsync(long numreplicas, long timeout, CommandFlags flags = CommandFlags.None) + { + Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); + return await Command(Request.WaitAsync(numreplicas, timeout)); + } + } diff --git a/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs b/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs index 0e9e5fa..485ed96 100644 --- a/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs +++ b/sources/Valkey.Glide/Commands/IGenericBaseCommands.cs @@ -537,4 +537,22 @@ public interface IGenericBaseCommands /// /// Task SortAsync(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null, CommandFlags flags = CommandFlags.None); + + /// + /// Blocks the current client until all the previous write commands are successfully transferred and acknowledged by at least numreplicas replicas. + /// If the timeout is reached, the command returns even if the specified number of replicas were not yet reached. + /// + /// + /// The number of replicas to wait for. + /// The timeout in milliseconds. + /// The flags to use for this operation. Currently flags are ignored. + /// The number of replicas that acknowledged the write commands. + /// + /// + /// + /// long result = await client.WaitAsync(1, 1000); + /// + /// + /// + Task WaitAsync(long numreplicas, long timeout, CommandFlags flags = CommandFlags.None); } diff --git a/sources/Valkey.Glide/GlideClient.cs b/sources/Valkey.Glide/GlideClient.cs index c69847f..288f0ec 100644 --- a/sources/Valkey.Glide/GlideClient.cs +++ b/sources/Valkey.Glide/GlideClient.cs @@ -120,4 +120,27 @@ public async Task SelectAsync(long index, CommandFlags flags = CommandFl Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); return await Command(Request.Select(index)); } + + public async IAsyncEnumerable KeysAsync(int database = -1, ValkeyValue pattern = default, int pageSize = 250, long cursor = 0, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + { + Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); + + long currentCursor = cursor; + int currentOffset = pageOffset; + + do + { + (long nextCursor, ValkeyKey[] keys) = await Command(Request.ScanAsync(currentCursor, pattern, pageSize)); + + IEnumerable keysToYield = currentOffset > 0 ? keys.Skip(currentOffset) : keys; + + foreach (ValkeyKey key in keysToYield) + { + yield return key; + } + + currentCursor = nextCursor; + currentOffset = 0; + } while (currentCursor != 0); + } } diff --git a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs index aac79f5..ab69612 100644 --- a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs +++ b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs @@ -270,4 +270,30 @@ public static Cmd SortAsync(ValkeyKey key, long skip = public static Cmd KeyMoveAsync(ValkeyKey key, int database) => Simple(RequestType.Move, [key.ToGlideString(), database.ToGlideString()]); + + public static Cmd ScanAsync(long cursor, ValkeyValue pattern = default, long pageSize = 0) + { + List args = [cursor.ToGlideString()]; + + if (!pattern.IsNull) + { + args.AddRange([Constants.MatchKeyword.ToGlideString(), pattern.ToGlideString()]); + } + + if (pageSize > 0) + { + args.AddRange([Constants.CountKeyword.ToGlideString(), pageSize.ToGlideString()]); + } + + return new(RequestType.Scan, [.. args], false, arr => + { + object[] scanArray = arr; + long nextCursor = scanArray[0] is long l ? l : long.Parse(scanArray[0].ToString()); + ValkeyKey[] keys = [.. ((object[])scanArray[1]).Cast().Select(gs => new ValkeyKey(gs))]; + return (nextCursor, keys); + }); + } + + public static Cmd WaitAsync(long numreplicas, long timeout) + => Simple(RequestType.Wait, [numreplicas.ToGlideString(), timeout.ToGlideString()]); } diff --git a/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs b/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs index ea019f8..b35c44b 100644 --- a/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs +++ b/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs @@ -95,6 +95,9 @@ public abstract partial class BaseBatch /// public T Sort(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null) => AddCmd(SortAsync(key, skip, take, order, sortType, by, get)); + /// + public T Wait(long numreplicas, long timeout) => AddCmd(WaitAsync(numreplicas, timeout)); + // Explicit interface implementations for IBatchGenericCommands IBatch IBatchGenericCommands.KeyDelete(ValkeyKey key) => KeyDelete(key); IBatch IBatchGenericCommands.KeyDelete(ValkeyKey[] keys) => KeyDelete(keys); @@ -124,4 +127,5 @@ public abstract partial class BaseBatch IBatch IBatchGenericCommands.KeyCopy(ValkeyKey sourceKey, ValkeyKey destinationKey, bool replace) => KeyCopy(sourceKey, destinationKey, replace); IBatch IBatchGenericCommands.KeyRandom() => KeyRandom(); IBatch IBatchGenericCommands.Sort(ValkeyKey key, long skip, long take, Order order, SortType sortType, ValkeyValue by, ValkeyValue[]? get) => Sort(key, skip, take, order, sortType, by, get); + IBatch IBatchGenericCommands.Wait(long numreplicas, long timeout) => Wait(numreplicas, timeout); } diff --git a/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs b/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs index 9bfab04..f7d4b3c 100644 --- a/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs +++ b/sources/Valkey.Glide/Pipeline/IBatchGenericCommands.cs @@ -118,4 +118,8 @@ internal interface IBatchGenericCommands /// /// Command Response - IBatch Sort(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null); + + /// + /// Command Response - + IBatch Wait(long numreplicas, long timeout); } diff --git a/sources/Valkey.Glide/abstract_Enums/ValkeyCommand.cs b/sources/Valkey.Glide/abstract_Enums/ValkeyCommand.cs index efb3be0..7bef8b4 100644 --- a/sources/Valkey.Glide/abstract_Enums/ValkeyCommand.cs +++ b/sources/Valkey.Glide/abstract_Enums/ValkeyCommand.cs @@ -203,6 +203,7 @@ internal enum ValkeyCommand UNSUBSCRIBE, UNWATCH, + WAIT, WATCH, XACK, @@ -464,6 +465,7 @@ internal static bool IsPrimaryOnly(this ValkeyCommand command) case ValkeyCommand.TYPE: case ValkeyCommand.UNSUBSCRIBE: case ValkeyCommand.UNWATCH: + case ValkeyCommand.WAIT: case ValkeyCommand.WATCH: case ValkeyCommand.XINFO: case ValkeyCommand.XLEN: diff --git a/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs b/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs index 3017334..7ec1496 100644 --- a/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs +++ b/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs @@ -528,6 +528,16 @@ public static List CreateGenericTest(Pipeline.IBatch batch, bool isAto _ = batch.KeyUnlink([genericKey1, renamedKey, genericKey3]); testData.Add(new(1L, "KeyUnlink([genericKey1, renamedKey, genericKey3])")); + // WAIT command tests + _ = batch.StringSet(prefix + "waitkey", "value"); + testData.Add(new(true, "StringSet(prefix + waitkey, value)")); + + _ = batch.Wait(0, 1000); + testData.Add(new(0L, "Wait(0, 1000)", true)); + + _ = batch.Wait(1, 0); + testData.Add(new(0L, "Wait(1, 0)", true)); + return testData; } diff --git a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs index a921681..e4a793e 100644 --- a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs @@ -512,6 +512,61 @@ public async Task TestKeyRandom(BaseClient client) Assert.NotEmpty(seenKeys); } + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestStandaloneClients), MemberType = typeof(TestConfiguration))] + public async Task TestKeysAsync_Scan(GlideClient client) + { + string prefix = Guid.NewGuid().ToString(); + string key1 = $"{prefix}:key1"; + string key2 = $"{prefix}:key2"; + string key3 = $"{prefix}:key3"; + string otherKey = "other:key"; + + // Set up test keys + await client.StringSetAsync(key1, "value1"); + await client.StringSetAsync(key2, "value2"); + await client.StringSetAsync(key3, "value3"); + await client.StringSetAsync(otherKey, "other"); + + // Test scanning all keys with pattern + List keys = new(); + await foreach (ValkeyKey key in client.KeysAsync(pattern: $"{prefix}:*")) + { + keys.Add(key); + } + + Assert.Equal(3, keys.Count); + Assert.Contains(key1, keys.Select(k => k.ToString())); + Assert.Contains(key2, keys.Select(k => k.ToString())); + Assert.Contains(key3, keys.Select(k => k.ToString())); + Assert.DoesNotContain(otherKey, keys.Select(k => k.ToString())); + + // Test scanning with pageSize + keys.Clear(); + await foreach (ValkeyKey key in client.KeysAsync(pattern: $"{prefix}:*", pageSize: 1)) + { + keys.Add(key); + } + Assert.Equal(3, keys.Count); + + // Test scanning with pageOffset + keys.Clear(); + await foreach (ValkeyKey key in client.KeysAsync(pattern: $"{prefix}:*", pageOffset: 1)) + { + keys.Add(key); + } + // Should get 2 keys (skipping first one) + Assert.True(keys.Count >= 2); + + // Test scanning non-existent pattern + keys.Clear(); + await foreach (ValkeyKey key in client.KeysAsync(pattern: "nonexistent:*")) + { + keys.Add(key); + } + Assert.Empty(keys); + } + [Theory(DisableDiscoveryEnumeration = true)] [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] public async Task TestSort(BaseClient client) @@ -557,4 +612,28 @@ public async Task TestSort(BaseClient client) } } + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] + public async Task TestWait(BaseClient client) + { + string key = Guid.NewGuid().ToString(); + string value = "test_value"; + + // Set a key to create a write operation + await client.StringSetAsync(key, value); + + // Test WAIT with 0 replicas (should return immediately) + long replicaCount = await client.WaitAsync(0, 1000); + Assert.True(replicaCount >= 0); + + // Test WAIT with timeout 0 (should return immediately) + replicaCount = await client.WaitAsync(1, 0); + Assert.True(replicaCount >= 0); + + // Test WAIT with reasonable parameters + // In a single-node setup, this should return 0 replicas + replicaCount = await client.WaitAsync(1, 100); + Assert.True(replicaCount >= 0); + } + } diff --git a/tests/Valkey.Glide.UnitTests/CommandTests.cs b/tests/Valkey.Glide.UnitTests/CommandTests.cs index 3baf343..15ccd30 100644 --- a/tests/Valkey.Glide.UnitTests/CommandTests.cs +++ b/tests/Valkey.Glide.UnitTests/CommandTests.cs @@ -165,6 +165,21 @@ public void ValidateCommandArgs() () => Assert.Equal(["RANDOMKEY"], Request.KeyRandomAsync().GetArgs()), () => Assert.Equal(["MOVE", "key", "1"], Request.KeyMoveAsync("key", 1).GetArgs()), + // SCAN Commands + () => Assert.Equal(["SCAN", "0"], Request.ScanAsync(0).GetArgs()), + () => Assert.Equal(["SCAN", "10"], Request.ScanAsync(10).GetArgs()), + () => Assert.Equal(["SCAN", "0", "MATCH", "pattern*"], Request.ScanAsync(0, "pattern*").GetArgs()), + () => Assert.Equal(["SCAN", "5", "MATCH", "test*"], Request.ScanAsync(5, "test*").GetArgs()), + () => Assert.Equal(["SCAN", "0", "COUNT", "10"], Request.ScanAsync(0, pageSize: 10).GetArgs()), + () => Assert.Equal(["SCAN", "5", "COUNT", "20"], Request.ScanAsync(5, pageSize: 20).GetArgs()), + () => Assert.Equal(["SCAN", "0", "MATCH", "pattern*", "COUNT", "10"], Request.ScanAsync(0, "pattern*", 10).GetArgs()), + () => Assert.Equal(["SCAN", "10", "MATCH", "*suffix", "COUNT", "5"], Request.ScanAsync(10, "*suffix", 5).GetArgs()), + + // WAIT Commands + () => Assert.Equal(["WAIT", "1", "1000"], Request.WaitAsync(1, 1000).GetArgs()), + () => Assert.Equal(["WAIT", "0", "0"], Request.WaitAsync(0, 0).GetArgs()), + () => Assert.Equal(["WAIT", "3", "5000"], Request.WaitAsync(3, 5000).GetArgs()), + // List Commands () => Assert.Equal(["LPOP", "a"], Request.ListLeftPopAsync("a").GetArgs()), () => Assert.Equal(["LPOP", "a", "3"], Request.ListLeftPopAsync("a", 3).GetArgs()), @@ -368,6 +383,28 @@ public void ValidateCommandConverters() () => Assert.True(Request.KeyMoveAsync("key", 1).Converter(true)), () => Assert.False(Request.KeyMoveAsync("key", 1).Converter(false)), + // SCAN Commands Converters + () => { + var result = Request.ScanAsync(0).Converter(new object[] { 0L, new object[] { (gs)"key1", (gs)"key2" } }); + Assert.Equal(0L, result.Item1); + Assert.Equal(["key1", "key2"], result.Item2.Select(k => k.ToString()).ToArray()); + }, + () => { + var result = Request.ScanAsync(10).Converter(new object[] { 5L, new object[] { (gs)"test" } }); + Assert.Equal(5L, result.Item1); + Assert.Equal(["test"], result.Item2.Select(k => k.ToString()).ToArray()); + }, + () => { + var result = Request.ScanAsync(0).Converter(new object[] { 0L, Array.Empty() }); + Assert.Equal(0L, result.Item1); + Assert.Empty(result.Item2); + }, + + // WAIT Commands Converters + () => Assert.Equal(2L, Request.WaitAsync(1, 1000).Converter(2L)), + () => Assert.Equal(0L, Request.WaitAsync(0, 0).Converter(0L)), + () => Assert.Equal(1L, Request.WaitAsync(3, 5000).Converter(1L)), + () => Assert.Equal("one", Request.ListLeftPopAsync("a").Converter("one")), () => Assert.Equal(["one", "two"], Request.ListLeftPopAsync("a", 2).Converter([(gs)"one", (gs)"two"])), () => Assert.Null(Request.ListLeftPopAsync("a", 2).Converter(null)), From 163a0b6b8d8de3ca2824c78751cd795c7c5f2a31 Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Fri, 26 Sep 2025 15:16:03 -0700 Subject: [PATCH 05/11] Add/fix tests Signed-off-by: Alex Rehnby-Martin --- .../BaseClient.GenericCommands.cs | 6 + .../Internals/Request.GenericCommands.cs | 44 +++++- .../GenericCommandTests.cs | 127 +++++++++++++++++- 3 files changed, 170 insertions(+), 7 deletions(-) diff --git a/sources/Valkey.Glide/BaseClient.GenericCommands.cs b/sources/Valkey.Glide/BaseClient.GenericCommands.cs index 6eb4f29..3da6576 100644 --- a/sources/Valkey.Glide/BaseClient.GenericCommands.cs +++ b/sources/Valkey.Glide/BaseClient.GenericCommands.cs @@ -170,6 +170,12 @@ public async Task SortAsync(ValkeyKey key, long skip = 0, long ta return await Command(Request.SortAsync(key, skip, take, order, sortType, by, get)); } + public async Task SortAndStoreAsync(ValkeyKey destination, ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null, CommandFlags flags = CommandFlags.None) + { + Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); + return await Command(Request.SortAndStoreAsync(destination, key, skip, take, order, sortType, by, get)); + } + public async Task WaitAsync(long numreplicas, long timeout, CommandFlags flags = CommandFlags.None) { Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); diff --git a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs index ab69612..6f92fcc 100644 --- a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs +++ b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs @@ -265,7 +265,49 @@ public static Cmd SortAsync(ValkeyKey key, long skip = args.Add(Constants.AlphaKeyword); } - return new(RequestType.Sort, [.. args], false, response => response?.Cast().Select(item => (ValkeyValue)item).ToArray() ?? []); + return new(RequestType.SortReadOnly, [.. args], false, response => response?.Cast().Select(item => (ValkeyValue)item).ToArray() ?? []); + } + + public static Cmd SortAndStoreAsync(ValkeyKey destination, ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null) + { + List args = [key.ToGlideString()]; + + if (!by.IsNull) + { + args.Add(Constants.ByKeyword); + args.Add(by.ToGlideString()); + } + + if (skip != 0 || take != -1) + { + args.Add(Constants.LimitKeyword); + args.Add(skip.ToGlideString()); + args.Add(take.ToGlideString()); + } + + if (get != null) + { + foreach (var pattern in get) + { + args.Add(Constants.GetKeyword); + args.Add(pattern.ToGlideString()); + } + } + + if (order == Order.Descending) + { + args.Add(Constants.DescKeyword); + } + + if (sortType == SortType.Alphabetic) + { + args.Add(Constants.AlphaKeyword); + } + + args.Add(Constants.StoreKeyword); + args.Add(destination.ToGlideString()); + + return Simple(RequestType.Sort, [.. args]); } public static Cmd KeyMoveAsync(ValkeyKey key, int database) diff --git a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs index e4a793e..972a0cd 100644 --- a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs @@ -567,6 +567,8 @@ public async Task TestKeysAsync_Scan(GlideClient client) Assert.Empty(keys); } + + [Theory(DisableDiscoveryEnumeration = true)] [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] public async Task TestSort(BaseClient client) @@ -612,6 +614,54 @@ public async Task TestSort(BaseClient client) } } + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] + public async Task TestSortAndStore(BaseClient client) + { + string sourceKey = "{prefix}-" + Guid.NewGuid().ToString(); + string destKey = "{prefix}-" + Guid.NewGuid().ToString(); + + // Test basic sort and store + await client.ListLeftPushAsync(sourceKey, ["3", "1", "2"]); + long count = await client.SortAndStoreAsync(destKey, sourceKey); + Assert.Equal(3, count); + + // Verify destination contains sorted values + ValkeyValue[] result = await client.ListRangeAsync(destKey); + Assert.Equal(["1", "2", "3"], result.Select(v => v.ToString()).ToArray()); + + // Test with descending order + string destKey2 = "{prefix}-" + Guid.NewGuid().ToString(); + count = await client.SortAndStoreAsync(destKey2, sourceKey, order: Order.Descending); + Assert.Equal(3, count); + result = await client.ListRangeAsync(destKey2); + Assert.Equal(["3", "2", "1"], result.Select(v => v.ToString()).ToArray()); + + // Test with limit + string destKey3 = "{prefix}-" + Guid.NewGuid().ToString(); + count = await client.SortAndStoreAsync(destKey3, sourceKey, skip: 1, take: 1); + Assert.Equal(1, count); + result = await client.ListRangeAsync(destKey3); + Assert.Single(result); + Assert.Equal("2", result[0].ToString()); + + // Test alphabetic sort + string alphaKey = "{prefix}-" + Guid.NewGuid().ToString(); + string alphaDestKey = "{prefix}-" + Guid.NewGuid().ToString(); + await client.ListLeftPushAsync(alphaKey, ["b", "a", "c"]); + count = await client.SortAndStoreAsync(alphaDestKey, alphaKey, sortType: SortType.Alphabetic); + Assert.Equal(3, count); + result = await client.ListRangeAsync(alphaDestKey); + Assert.Equal(["a", "b", "c"], result.Select(v => v.ToString()).ToArray()); + + // Test overwriting existing destination + await client.StringSetAsync(destKey, "existing_value"); + count = await client.SortAndStoreAsync(destKey, sourceKey); + Assert.Equal(3, count); + // Destination should now be a list, not a string + Assert.Equal(ValkeyType.List, await client.KeyTypeAsync(destKey)); + } + [Theory(DisableDiscoveryEnumeration = true)] [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] public async Task TestWait(BaseClient client) @@ -622,18 +672,83 @@ public async Task TestWait(BaseClient client) // Set a key to create a write operation await client.StringSetAsync(key, value); + // Test WAIT with different expected behavior for cluster vs standalone + long replicaCount = await client.WaitAsync(1, 1000); + if (client is GlideClusterClient) + { + Assert.True(replicaCount >= 1); // Cluster mode + } + else + { + Assert.True(replicaCount >= 0); // Standalone mode + } + // Test WAIT with 0 replicas (should return immediately) - long replicaCount = await client.WaitAsync(0, 1000); + replicaCount = await client.WaitAsync(0, 1000); Assert.True(replicaCount >= 0); // Test WAIT with timeout 0 (should return immediately) replicaCount = await client.WaitAsync(1, 0); Assert.True(replicaCount >= 0); + } - // Test WAIT with reasonable parameters - // In a single-node setup, this should return 0 replicas - replicaCount = await client.WaitAsync(1, 100); - Assert.True(replicaCount >= 0); + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] + public async Task TestWait_NegativeTimeout(BaseClient client) + { + // Test negative timeout should throw exception + var exception = await Assert.ThrowsAsync( + () => client.WaitAsync(1, -1)); + Assert.Contains("Timeout cannot be negative", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestStandaloneClients), MemberType = typeof(TestConfiguration))] + public async Task TestSortAndStore_WithPatterns(GlideClient client) + { + // Test with BY and GET patterns (only for standalone clients) + string userKey = "{prefix}-" + Guid.NewGuid().ToString(); + string destKey = "{prefix}-" + Guid.NewGuid().ToString(); + + // Set up test data + await client.HashSetAsync("user:1", [new HashEntry("age", "30"), new HashEntry("name", "Alice")]); + await client.HashSetAsync("user:2", [new HashEntry("age", "25"), new HashEntry("name", "Bob")]); + await client.ListLeftPushAsync(userKey, ["2", "1"]); + + // Test with BY pattern + long count = await client.SortAndStoreAsync(destKey, userKey, by: "user:*->age"); + Assert.Equal(2, count); + ValkeyValue[] result = await client.ListRangeAsync(destKey); + Assert.Equal(["2", "1"], result.Select(v => v.ToString()).ToArray()); + + // Test with GET pattern + string destKey2 = "{prefix}-" + Guid.NewGuid().ToString(); + count = await client.SortAndStoreAsync(destKey2, userKey, by: "user:*->age", get: ["user:*->name"]); + Assert.Equal(2, count); + result = await client.ListRangeAsync(destKey2); + Assert.Equal(["Bob", "Alice"], result.Select(v => v.ToString()).ToArray()); + } + + [Theory(DisableDiscoveryEnumeration = true)] + [MemberData(nameof(Config.TestStandaloneClients), MemberType = typeof(TestConfiguration))] + public async Task TestKeysAsync_LargeDataset(GlideClient client) + { + string prefix = Guid.NewGuid().ToString(); + + var tasks = Enumerable.Range(0, 25000).Select(i => + client.StringSetAsync($"{prefix}:key{i}", $"value{i}")); + await Task.WhenAll(tasks); + + int count = 0; + await foreach (var key in client.KeysAsync(pattern: $"{prefix}:*")) + { + count++; + } + Assert.Equal(25000, count); + + var sampleKeys = Enumerable.Range(0, 100).Select(i => (ValkeyKey)$"{prefix}:key{i}").ToArray(); + long sampleCount = await client.KeyExistsAsync(sampleKeys); + Assert.Equal(100L, sampleCount); } -} +} \ No newline at end of file From 7ec0b5f20aa71cb746ea241d25521d8fb48101fe Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Wed, 1 Oct 2025 09:34:11 -0700 Subject: [PATCH 06/11] Fix format Signed-off-by: Alex Rehnby-Martin --- .../Valkey.Glide/Internals/Request.GenericCommands.cs | 6 +++--- .../Valkey.Glide.IntegrationTests/GenericCommandTests.cs | 7 +++---- tests/Valkey.Glide.UnitTests/CommandTests.cs | 9 ++++++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs index 6f92fcc..411c598 100644 --- a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs +++ b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs @@ -56,10 +56,10 @@ public static Cmd KeyExpireAsync(ValkeyKey key, TimeSpan? expiry, Ex } // Choose command based on precision - var command = expiry.HasValue && (long)expiry.Value.TotalMilliseconds % 1000 != 0 - ? RequestType.PExpire + var command = expiry.HasValue && (long)expiry.Value.TotalMilliseconds % 1000 != 0 + ? RequestType.PExpire : RequestType.Expire; - + return Simple(command, [.. args]); } diff --git a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs index 972a0cd..2a0d660 100644 --- a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs @@ -734,8 +734,8 @@ public async Task TestSortAndStore_WithPatterns(GlideClient client) public async Task TestKeysAsync_LargeDataset(GlideClient client) { string prefix = Guid.NewGuid().ToString(); - - var tasks = Enumerable.Range(0, 25000).Select(i => + + var tasks = Enumerable.Range(0, 25000).Select(i => client.StringSetAsync($"{prefix}:key{i}", $"value{i}")); await Task.WhenAll(tasks); @@ -750,5 +750,4 @@ public async Task TestKeysAsync_LargeDataset(GlideClient client) long sampleCount = await client.KeyExistsAsync(sampleKeys); Assert.Equal(100L, sampleCount); } - -} \ No newline at end of file +} diff --git a/tests/Valkey.Glide.UnitTests/CommandTests.cs b/tests/Valkey.Glide.UnitTests/CommandTests.cs index 15ccd30..a53bc50 100644 --- a/tests/Valkey.Glide.UnitTests/CommandTests.cs +++ b/tests/Valkey.Glide.UnitTests/CommandTests.cs @@ -384,17 +384,20 @@ public void ValidateCommandConverters() () => Assert.False(Request.KeyMoveAsync("key", 1).Converter(false)), // SCAN Commands Converters - () => { + () => + { var result = Request.ScanAsync(0).Converter(new object[] { 0L, new object[] { (gs)"key1", (gs)"key2" } }); Assert.Equal(0L, result.Item1); Assert.Equal(["key1", "key2"], result.Item2.Select(k => k.ToString()).ToArray()); }, - () => { + () => + { var result = Request.ScanAsync(10).Converter(new object[] { 5L, new object[] { (gs)"test" } }); Assert.Equal(5L, result.Item1); Assert.Equal(["test"], result.Item2.Select(k => k.ToString()).ToArray()); }, - () => { + () => + { var result = Request.ScanAsync(0).Converter(new object[] { 0L, Array.Empty() }); Assert.Equal(0L, result.Item1); Assert.Empty(result.Item2); From 9f715eb4c73d9ad9709303b29a3bb0eba51ba01c Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Wed, 1 Oct 2025 09:42:51 -0700 Subject: [PATCH 07/11] Fix lint issues Signed-off-by: Alex Rehnby-Martin --- sources/Valkey.Glide/Internals/Cmd.cs | 3 +++ sources/Valkey.Glide/Internals/Request.GenericCommands.cs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/sources/Valkey.Glide/Internals/Cmd.cs b/sources/Valkey.Glide/Internals/Cmd.cs index f457be7..a4045d7 100644 --- a/sources/Valkey.Glide/Internals/Cmd.cs +++ b/sources/Valkey.Glide/Internals/Cmd.cs @@ -87,14 +87,17 @@ public string[] GetArgs() => Request == RequestType.CustomCommand ? ArgsArray.Args.ToStrings() : [.. GetCommandParts(Request).Concat(ArgsArray.Args.ToStrings())]; +#pragma warning disable IDE0072 // Populate switch private static string[] GetCommandParts(RequestType requestType) => requestType switch { RequestType.ObjectEncoding => ["OBJECT", "ENCODING"], RequestType.ObjectFreq => ["OBJECT", "FREQ"], RequestType.ObjectIdleTime => ["OBJECT", "IDLETIME"], RequestType.ObjectRefCount => ["OBJECT", "REFCOUNT"], + RequestType.Command_ => ["COMMAND"], _ => [requestType.ToString().ToUpper()] }; +#pragma warning restore IDE0072 // Populate switch } internal record ArgsArray diff --git a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs index 411c598..d113879 100644 --- a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs +++ b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs @@ -330,7 +330,7 @@ public static Cmd KeyMoveAsync(ValkeyKey key, int database) return new(RequestType.Scan, [.. args], false, arr => { object[] scanArray = arr; - long nextCursor = scanArray[0] is long l ? l : long.Parse(scanArray[0].ToString()); + long nextCursor = scanArray[0] is long l ? l : long.Parse(scanArray[0].ToString() ?? "0"); ValkeyKey[] keys = [.. ((object[])scanArray[1]).Cast().Select(gs => new ValkeyKey(gs))]; return (nextCursor, keys); }); From 14628a76df55eca9d8b49c3e6923cef5e74f9dc4 Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Wed, 1 Oct 2025 09:59:51 -0700 Subject: [PATCH 08/11] fix Signed-off-by: Alex Rehnby-Martin --- .../BatchTestUtils.cs | 7 +++-- .../GenericCommandTests.cs | 30 +++++++++++-------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs b/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs index 7ec1496..42b9a99 100644 --- a/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs +++ b/tests/Valkey.Glide.IntegrationTests/BatchTestUtils.cs @@ -462,8 +462,11 @@ public static List CreateGenericTest(Pipeline.IBatch batch, bool isAto _ = batch.KeyExpire(genericKey1, TimeSpan.FromSeconds(120)); testData.Add(new(true, "KeyExpire(genericKey1, 120s)")); - _ = batch.KeyExpireTime(genericKey1); - testData.Add(new(DateTime.UtcNow.AddSeconds(120), "KeyExpireTime(genericKey1)", true)); + if (TestConfiguration.SERVER_VERSION > new Version("7.0.0")) // KeyExpireTime added in 7.0.0 + { + _ = batch.KeyExpireTime(genericKey1); + testData.Add(new(DateTime.UtcNow.AddSeconds(120), "KeyExpireTime(genericKey1)", true)); + } _ = batch.KeyEncoding(genericKey1); testData.Add(new("embstr", "KeyEncoding(genericKey1)", true)); diff --git a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs index 2a0d660..04812c1 100644 --- a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs @@ -122,6 +122,10 @@ public async Task TestKeyExpire_KeyTimeToLive(BaseClient client) [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] public async Task TestKeyExpireTime(BaseClient client) { + Assert.SkipWhen( + TestConfiguration.SERVER_VERSION < new Version("7.0.0"), + "SetIntersectionLength is supported since 7.0.0" + ); string key = Guid.NewGuid().ToString(); string value = "test_value"; @@ -498,7 +502,7 @@ public async Task TestKeyRandom(BaseClient client) Assert.True(await client.KeyExistsAsync(randomKey)); // Call multiple times to verify it can return different keys - HashSet seenKeys = new(); + HashSet seenKeys = []; for (int i = 0; i < 10; i++) { string? key = await client.KeyRandomAsync(); @@ -529,7 +533,7 @@ public async Task TestKeysAsync_Scan(GlideClient client) await client.StringSetAsync(otherKey, "other"); // Test scanning all keys with pattern - List keys = new(); + List keys = []; await foreach (ValkeyKey key in client.KeysAsync(pattern: $"{prefix}:*")) { keys.Add(key); @@ -578,11 +582,11 @@ public async Task TestSort(BaseClient client) // Test with list await client.ListLeftPushAsync(key, ["3", "1", "2"]); ValkeyValue[] result = await client.SortAsync(key); - Assert.Equal(["1", "2", "3"], result.Select(v => v.ToString()).ToArray()); + Assert.Equal(["1", "2", "3"], [.. result.Select(v => v.ToString())]); // Test with descending order result = await client.SortAsync(key, order: Order.Descending); - Assert.Equal(["3", "2", "1"], result.Select(v => v.ToString()).ToArray()); + Assert.Equal(["3", "2", "1"], [.. result.Select(v => v.ToString())]); // Test with limit result = await client.SortAsync(key, skip: 1, take: 1); @@ -593,7 +597,7 @@ public async Task TestSort(BaseClient client) string alphaKey = Guid.NewGuid().ToString(); await client.ListLeftPushAsync(alphaKey, ["b", "a", "c"]); result = await client.SortAsync(alphaKey, sortType: SortType.Alphabetic); - Assert.Equal(["a", "b", "c"], result.Select(v => v.ToString()).ToArray()); + Assert.Equal(["a", "b", "c"], [.. result.Select(v => v.ToString())]); string userKey = Guid.NewGuid().ToString(); @@ -604,13 +608,13 @@ public async Task TestSort(BaseClient client) await client.HashSetAsync("user:2", [new HashEntry("age", "25")]); await client.ListLeftPushAsync(userKey, ["2", "1"]); result = await client.SortAsync(userKey, by: "user:*->age"); - Assert.Equal(["2", "1"], result.Select(v => v.ToString()).ToArray()); + Assert.Equal(["2", "1"], [.. result.Select(v => v.ToString())]); // Test with GET pattern await client.HashSetAsync("user:1", [new HashEntry("name", "Alice")]); await client.HashSetAsync("user:2", [new HashEntry("name", "Bob")]); result = await client.SortAsync(userKey, by: "user:*->age", get: ["user:*->name"]); - Assert.Equal(["Bob", "Alice"], result.Select(v => v.ToString()).ToArray()); + Assert.Equal(["Bob", "Alice"], [.. result.Select(v => v.ToString())]); } } @@ -628,14 +632,14 @@ public async Task TestSortAndStore(BaseClient client) // Verify destination contains sorted values ValkeyValue[] result = await client.ListRangeAsync(destKey); - Assert.Equal(["1", "2", "3"], result.Select(v => v.ToString()).ToArray()); + Assert.Equal(["1", "2", "3"], [.. result.Select(v => v.ToString())]); // Test with descending order string destKey2 = "{prefix}-" + Guid.NewGuid().ToString(); count = await client.SortAndStoreAsync(destKey2, sourceKey, order: Order.Descending); Assert.Equal(3, count); result = await client.ListRangeAsync(destKey2); - Assert.Equal(["3", "2", "1"], result.Select(v => v.ToString()).ToArray()); + Assert.Equal(["3", "2", "1"], [.. result.Select(v => v.ToString())]); // Test with limit string destKey3 = "{prefix}-" + Guid.NewGuid().ToString(); @@ -652,7 +656,7 @@ public async Task TestSortAndStore(BaseClient client) count = await client.SortAndStoreAsync(alphaDestKey, alphaKey, sortType: SortType.Alphabetic); Assert.Equal(3, count); result = await client.ListRangeAsync(alphaDestKey); - Assert.Equal(["a", "b", "c"], result.Select(v => v.ToString()).ToArray()); + Assert.Equal(["a", "b", "c"], [.. result.Select(v => v.ToString())]); // Test overwriting existing destination await client.StringSetAsync(destKey, "existing_value"); @@ -719,14 +723,14 @@ public async Task TestSortAndStore_WithPatterns(GlideClient client) long count = await client.SortAndStoreAsync(destKey, userKey, by: "user:*->age"); Assert.Equal(2, count); ValkeyValue[] result = await client.ListRangeAsync(destKey); - Assert.Equal(["2", "1"], result.Select(v => v.ToString()).ToArray()); + Assert.Equal(["2", "1"], [.. result.Select(v => v.ToString())]); // Test with GET pattern string destKey2 = "{prefix}-" + Guid.NewGuid().ToString(); count = await client.SortAndStoreAsync(destKey2, userKey, by: "user:*->age", get: ["user:*->name"]); Assert.Equal(2, count); result = await client.ListRangeAsync(destKey2); - Assert.Equal(["Bob", "Alice"], result.Select(v => v.ToString()).ToArray()); + Assert.Equal(["Bob", "Alice"], [.. result.Select(v => v.ToString())]); } [Theory(DisableDiscoveryEnumeration = true)] @@ -746,7 +750,7 @@ public async Task TestKeysAsync_LargeDataset(GlideClient client) } Assert.Equal(25000, count); - var sampleKeys = Enumerable.Range(0, 100).Select(i => (ValkeyKey)$"{prefix}:key{i}").ToArray(); + ValkeyKey[] sampleKeys = [.. Enumerable.Range(0, 100).Select(i => (ValkeyKey)$"{prefix}:key{i}")]; long sampleCount = await client.KeyExistsAsync(sampleKeys); Assert.Equal(100L, sampleCount); } From cdc3699fc4351382ca5059ebfcbd2a108d71bb7e Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Wed, 1 Oct 2025 10:31:16 -0700 Subject: [PATCH 09/11] Add server version check Signed-off-by: Alex Rehnby-Martin --- .../BaseClient.GenericCommands.cs | 2 +- sources/Valkey.Glide/BaseClient.cs | 32 +++++++++++++++++-- .../Internals/Request.GenericCommands.cs | 9 ++++-- .../Pipeline/BaseBatch.GenericCommands.cs | 2 +- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/sources/Valkey.Glide/BaseClient.GenericCommands.cs b/sources/Valkey.Glide/BaseClient.GenericCommands.cs index 3da6576..185ab7f 100644 --- a/sources/Valkey.Glide/BaseClient.GenericCommands.cs +++ b/sources/Valkey.Glide/BaseClient.GenericCommands.cs @@ -167,7 +167,7 @@ public async Task KeyCopyAsync(ValkeyKey sourceKey, ValkeyKey destinationK public async Task SortAsync(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null, CommandFlags flags = CommandFlags.None) { Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); - return await Command(Request.SortAsync(key, skip, take, order, sortType, by, get)); + return await Command(Request.SortAsync(key, skip, take, order, sortType, by, get, _serverVersion)); } public async Task SortAndStoreAsync(ValkeyKey destination, ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null, CommandFlags flags = CommandFlags.None) diff --git a/sources/Valkey.Glide/BaseClient.cs b/sources/Valkey.Glide/BaseClient.cs index e761de6..fa47640 100644 --- a/sources/Valkey.Glide/BaseClient.cs +++ b/sources/Valkey.Glide/BaseClient.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; +using Valkey.Glide.Commands.Options; using Valkey.Glide.Internals; using Valkey.Glide.Pipeline; @@ -52,9 +53,15 @@ protected static async Task CreateClient(BaseClientConfiguration config, F Message message = client._messageContainer.GetMessageForCall(); CreateClientFfi(request.ToPtr(), successCallbackPointer, failureCallbackPointer); client._clientPointer = await message; // This will throw an error thru failure callback if any - return client._clientPointer != IntPtr.Zero - ? client - : throw new ConnectionException("Failed creating a client"); + + if (client._clientPointer != IntPtr.Zero) + { + // Initialize server version after successful connection + await client.InitializeServerVersionAsync(); + return client; + } + + throw new ConnectionException("Failed creating a client"); } protected BaseClient() @@ -139,6 +146,24 @@ private void FailureCallback(ulong index, IntPtr strPtr, RequestErrorType errTyp internal void SetInfo(string info) => _clientInfo = info; + private async Task InitializeServerVersionAsync() + { + try + { + var infoResponse = await Command(Request.Info([InfoOptions.Section.SERVER])); + var versionMatch = System.Text.RegularExpressions.Regex.Match(infoResponse, @"(?:valkey_version|redis_version):([\d\.]+)"); + if (versionMatch.Success) + { + _serverVersion = new Version(versionMatch.Groups[1].Value); + } + } + catch + { + // If we can't get version, assume newer version (use SORT_RO) + _serverVersion = new Version(8, 0, 0); + } + } + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void SuccessAction(ulong index, IntPtr ptr); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] @@ -160,6 +185,7 @@ private void FailureCallback(ulong index, IntPtr strPtr, RequestErrorType errTyp private readonly MessageContainer _messageContainer; private readonly object _lock = new(); private string _clientInfo = ""; // used to distinguish and identify clients during tests + private Version? _serverVersion; // cached server version #endregion private fields } diff --git a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs index d113879..0ec9881 100644 --- a/sources/Valkey.Glide/Internals/Request.GenericCommands.cs +++ b/sources/Valkey.Glide/Internals/Request.GenericCommands.cs @@ -229,7 +229,7 @@ public static Cmd KeyCopyAsync(ValkeyKey sourceKey, ValkeyKey destin public static Cmd KeyRandomAsync() => new(RequestType.RandomKey, [], true, response => response?.ToString()); - public static Cmd SortAsync(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null) + public static Cmd SortAsync(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null, Version? serverVersion = null) { List args = [key.ToGlideString()]; @@ -265,7 +265,12 @@ public static Cmd SortAsync(ValkeyKey key, long skip = args.Add(Constants.AlphaKeyword); } - return new(RequestType.SortReadOnly, [.. args], false, response => response?.Cast().Select(item => (ValkeyValue)item).ToArray() ?? []); + // Use SORT_RO for version 7.0.0+ if server version is available, otherwise use SORT_RO as default + var requestType = serverVersion != null && serverVersion < new Version(7, 0, 0) + ? RequestType.Sort + : RequestType.SortReadOnly; + + return new(requestType, [.. args], false, response => response?.Cast().Select(item => (ValkeyValue)item).ToArray() ?? []); } public static Cmd SortAndStoreAsync(ValkeyKey destination, ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null) diff --git a/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs b/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs index b35c44b..bc65848 100644 --- a/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs +++ b/sources/Valkey.Glide/Pipeline/BaseBatch.GenericCommands.cs @@ -93,7 +93,7 @@ public abstract partial class BaseBatch public T KeyRandom() => AddCmd(KeyRandomAsync()); /// - public T Sort(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null) => AddCmd(SortAsync(key, skip, take, order, sortType, by, get)); + public T Sort(ValkeyKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, ValkeyValue by = default, ValkeyValue[]? get = null) => AddCmd(SortAsync(key, skip, take, order, sortType, by, get, null)); /// public T Wait(long numreplicas, long timeout) => AddCmd(WaitAsync(numreplicas, timeout)); From 6cc585c30e9824a8b595b5d12ad67f31f4e5a65f Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Wed, 1 Oct 2025 11:38:47 -0700 Subject: [PATCH 10/11] Fix Signed-off-by: Alex Rehnby-Martin --- sources/Valkey.Glide/BaseClient.cs | 20 ++------------------ sources/Valkey.Glide/GlideClient.cs | 18 ++++++++++++++++++ sources/Valkey.Glide/GlideClusterClient.cs | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/sources/Valkey.Glide/BaseClient.cs b/sources/Valkey.Glide/BaseClient.cs index fa47640..eb677b3 100644 --- a/sources/Valkey.Glide/BaseClient.cs +++ b/sources/Valkey.Glide/BaseClient.cs @@ -146,23 +146,7 @@ private void FailureCallback(ulong index, IntPtr strPtr, RequestErrorType errTyp internal void SetInfo(string info) => _clientInfo = info; - private async Task InitializeServerVersionAsync() - { - try - { - var infoResponse = await Command(Request.Info([InfoOptions.Section.SERVER])); - var versionMatch = System.Text.RegularExpressions.Regex.Match(infoResponse, @"(?:valkey_version|redis_version):([\d\.]+)"); - if (versionMatch.Success) - { - _serverVersion = new Version(versionMatch.Groups[1].Value); - } - } - catch - { - // If we can't get version, assume newer version (use SORT_RO) - _serverVersion = new Version(8, 0, 0); - } - } + protected abstract Task InitializeServerVersionAsync(); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void SuccessAction(ulong index, IntPtr ptr); @@ -185,7 +169,7 @@ private async Task InitializeServerVersionAsync() private readonly MessageContainer _messageContainer; private readonly object _lock = new(); private string _clientInfo = ""; // used to distinguish and identify clients during tests - private Version? _serverVersion; // cached server version + protected Version? _serverVersion; // cached server version #endregion private fields } diff --git a/sources/Valkey.Glide/GlideClient.cs b/sources/Valkey.Glide/GlideClient.cs index 288f0ec..777c252 100644 --- a/sources/Valkey.Glide/GlideClient.cs +++ b/sources/Valkey.Glide/GlideClient.cs @@ -143,4 +143,22 @@ public async IAsyncEnumerable KeysAsync(int database = -1, ValkeyValu currentOffset = 0; } while (currentCursor != 0); } + + protected override async Task InitializeServerVersionAsync() + { + try + { + var infoResponse = await Command(Request.Info([InfoOptions.Section.SERVER])); + var versionMatch = System.Text.RegularExpressions.Regex.Match(infoResponse, @"(?:valkey_version|redis_version):([\d\.]+)"); + if (versionMatch.Success) + { + _serverVersion = new Version(versionMatch.Groups[1].Value); + } + } + catch + { + // If we can't get version, assume newer version (use SORT_RO) + _serverVersion = new Version(8, 0, 0); + } + } } diff --git a/sources/Valkey.Glide/GlideClusterClient.cs b/sources/Valkey.Glide/GlideClusterClient.cs index 72e8af8..a9db341 100644 --- a/sources/Valkey.Glide/GlideClusterClient.cs +++ b/sources/Valkey.Glide/GlideClusterClient.cs @@ -135,4 +135,22 @@ public async Task SelectAsync(long index, CommandFlags flags = CommandFl Utils.Requires(flags == CommandFlags.None, "Command flags are not supported by GLIDE"); return await Command(Request.Select(index), Route.Random); } + + protected override async Task InitializeServerVersionAsync() + { + try + { + var infoResponse = await Command(Request.Info([InfoOptions.Section.SERVER]).ToClusterValue(true), Route.Random); + var versionMatch = System.Text.RegularExpressions.Regex.Match(infoResponse.SingleValue, @"(?:valkey_version|redis_version):([\d\.]+)"); + if (versionMatch.Success) + { + _serverVersion = new Version(versionMatch.Groups[1].Value); + } + } + catch + { + // If we can't get version, assume newer version (use SORT_RO) + _serverVersion = new Version(8, 0, 0); + } + } } From 7c6044294ee3396ae58545dac1802d2d1b7df3d5 Mon Sep 17 00:00:00 2001 From: Alex Rehnby-Martin Date: Thu, 2 Oct 2025 09:05:22 -0700 Subject: [PATCH 11/11] cleanup Signed-off-by: Alex Rehnby-Martin --- sources/Valkey.Glide/BaseClient.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/sources/Valkey.Glide/BaseClient.cs b/sources/Valkey.Glide/BaseClient.cs index eb677b3..1bd68a4 100644 --- a/sources/Valkey.Glide/BaseClient.cs +++ b/sources/Valkey.Glide/BaseClient.cs @@ -2,7 +2,6 @@ using System.Runtime.InteropServices; -using Valkey.Glide.Commands.Options; using Valkey.Glide.Internals; using Valkey.Glide.Pipeline;