From 7d570fd5768a9acd11623612b1930a2fd91274f3 Mon Sep 17 00:00:00 2001 From: "ugo.lattanzi" Date: Fri, 22 May 2026 07:43:30 +0200 Subject: [PATCH] Fix SearchKeysAsync ignoring database number and RemoveByTagAsync not cleaning up tag keys (#651, #650) SearchKeysAsync was using IServer.ExecuteAsync("SCAN") which always scans DB0. Replaced with IServer.KeysAsync(database, pattern) which correctly targets the specified database. RemoveByTagAsync was deleting tagged data keys but not the tag Set key itself, leaving empty Sets in Redis indefinitely. Now deletes the tag key after removing its members. Bumped version to 12.2.0. --- Directory.Build.props | 6 ++++- .../Implementations/RedisDatabase.Tags.cs | 6 ++++- .../Implementations/RedisDatabase.cs | 21 ++++------------ .../CacheClientTestBase.Tags.cs | 21 ++++++++++++++++ .../CacheClientTestBase.cs | 25 +++++++++++++++++++ 5 files changed, 61 insertions(+), 18 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 156a95ea..c7f3e83a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ Ugo Lattanzi - 12.1.0 + 12.2.0 @@ -46,6 +46,10 @@ Features: Serializer packages (pick one): System.Text.Json, Newtonsoft, MemoryPack, MsgPack, Protobuf, ServiceStack, Utf8Json. +v12.2.0: +- Fixed SearchKeysAsync ignoring database number — SCAN now correctly targets the specified database instead of always scanning DB0 (#651) +- Fixed RemoveByTagAsync not deleting the tag Set key itself, causing a slow memory leak of empty tag Sets in Redis (#650) + v12.1.0: - Added VectorSet API for AI/ML similarity search (Redis 8.0+): VADD, VSIM, VREM, VCONTAINS, VCARD, VDIM, VGETATTR, VSETATTR, VINFO, VRANDMEMBER, VLINKS - Added llms.txt for AI coding assistant documentation indexing diff --git a/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.Tags.cs b/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.Tags.cs index c51d6fa2..f6ef3f64 100644 --- a/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.Tags.cs +++ b/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.Tags.cs @@ -38,7 +38,11 @@ public async Task RemoveByTagAsync(string tag, CommandFlags flag = Command var keys = await SetMembersAsync(tagKey, flag).ConfigureAwait(false); - return await RemoveAllAsync(keys, flag).ConfigureAwait(false); + var deletedCount = await RemoveAllAsync(keys, flag).ConfigureAwait(false); + + await Database.KeyDeleteAsync(tagKey, flag).ConfigureAwait(false); + + return deletedCount; } private Task ExecuteAddWithTagsAsync( diff --git a/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.cs b/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.cs index 4117c8c2..d1777d4e 100644 --- a/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.cs +++ b/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -482,25 +481,15 @@ public async Task> SearchKeysAsync(string pattern) pattern = $"{keyPrefix}{pattern}"; var keys = new HashSet(); - foreach (var unused in ServerIteratorFactory.GetServers(connectionPoolManager.GetConnection(), serverEnumerationStrategy)) + foreach (var server in ServerIteratorFactory.GetServers(connectionPoolManager.GetConnection(), serverEnumerationStrategy)) { - ulong nextCursor = 0; - do - { - var redisResult = await unused.ExecuteAsync("SCAN", nextCursor.ToString(CultureInfo.InvariantCulture), "MATCH", pattern, "COUNT", "1000").ConfigureAwait(false); - var innerResult = (RedisResult[])redisResult!; - - nextCursor = ulong.Parse((string)innerResult[0]!, CultureInfo.InvariantCulture); - - var resultLines = ((string[])innerResult[1]!).ToArray(); - keys.UnionWith(resultLines); - } - while (nextCursor != 0); + await foreach (var key in server.KeysAsync(dbNumber, pattern, 1000).ConfigureAwait(false)) + keys.Add(key!); } return !string.IsNullOrEmpty(keyPrefix) - ? keys.Select(k => k[keyPrefix.Length..]) - : keys; + ? keys.Select(k => k.ToString()[keyPrefix.Length..]) + : keys.Select(k => k.ToString()); } /// diff --git a/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.Tags.cs b/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.Tags.cs index d742755f..d8534325 100644 --- a/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.Tags.cs +++ b/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.Tags.cs @@ -212,4 +212,25 @@ public async Task RemoveByTagAsync_ShouldReturnOneDeletedValue_Async() Assert.Equal(1, result); } + + [Fact] + [Trait("Category", "Tags")] + public async Task RemoveByTagAsync_ShouldDeleteTagKey_Async() + { + const string testKey = "test_key"; + const string testValue = "test_value"; + var testClass = new Helpers.TestClass(testKey, testValue); + const string testTag = "test_tag"; + + await Sut.GetDefaultDatabase().AddAsync(testKey, testClass, tags: [testTag]); + + var tagKeyName = TagHelper.GenerateTagKey(testTag); + var tagExistsBefore = await db.KeyExistsAsync(tagKeyName); + Assert.True(tagExistsBefore); + + await Sut.GetDefaultDatabase().RemoveByTagAsync(testTag); + + var tagExistsAfter = await db.KeyExistsAsync(tagKeyName); + Assert.False(tagExistsAfter); + } } diff --git a/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.cs b/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.cs index 738b62f5..4964fc1c 100644 --- a/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.cs +++ b/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.cs @@ -338,6 +338,31 @@ public async Task SearchKeys_With_Key_Prefix_Should_Return_Keys_Without_Prefix_A Assert.Equal(keys[i], values[i].Key); } + [Fact] + public async Task SearchKeysAsync_Should_Respect_Database_Number_Async() + { + var db1 = Sut.Db1; + var db1Raw = db1.Database; + + try + { + await db1Raw.StringSetAsync("db1_key1", "value1"); + await db1Raw.StringSetAsync("db1_key2", "value2"); + + await db.StringSetAsync("db0_key1", serializer.Serialize("value_db0")); + + var keysFromDb1 = (await db1.SearchKeysAsync("db1_*")).ToList(); + + Assert.Equal(2, keysFromDb1.Count); + Assert.Contains("db1_key1", keysFromDb1); + Assert.Contains("db1_key2", keysFromDb1); + } + finally + { + await db1Raw.ExecuteAsync("FLUSHDB"); + } + } + [Fact] public async Task Exist_With_Valid_Object_Should_Return_The_Correct_Instance_Async() {