This is an automated email from the ASF dual-hosted git repository.
hulk pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git
The following commit(s) were added to refs/heads/unstable by this push:
new 9c9ffb82d fix(scan): pattern-based SCAN iterations may skip remaining
keys (#3146)
9c9ffb82d is described below
commit 9c9ffb82de7fd70e02ae0244faf05677d27f53b0
Author: sryan yuan <[email protected]>
AuthorDate: Wed Aug 27 20:33:32 2025 +0800
fix(scan): pattern-based SCAN iterations may skip remaining keys (#3146)
Pattern-based SCAN iterations may skip remaining keys in a hash slot,
causing incomplete key retrieval.
**Bug Reproduction:**
- 10 keys in the same hash slot: ["119483", "166988", "210695",
"223656", "48063", "59022", "65976", "74937", "88379", "99338"]
- Initial SCAN with pattern `2*`:
- Returns cursor C1 and **empty keyset** (no keys match `2*`)
- Records "119483" as last scanned key
- Subsequent SCAN with cursor C1 and same pattern:
1. RocksDB iterator seeks to "119483"
2. Calls `Next()` → gets "166988" (next key in slot)
3. "166988" ∉ `2*` pattern → no key returned
4. **Error**: Scan incorrectly increments slot index
5. **Result**: Remaining 8 keys in slot are skipped
**Bug Fix Implementation:**
When scanning with a previous scan cursor and match pattern:
1. If the last scanned key is lexicographically before the pattern's
start range:
→ Use the pattern's minimum matching key as the seek key
→ Instead of using the last scanned key
**Example:**
- Pattern: `2*` → Minimum matching key = `"2"` (hex: \x32)
- Last scanned key: `"119483"` (hex: \x31\x31...)
- Since `"119483"` < `"2"` lexically:
✓ **Correct:** Seek to `"2"`
✗ **Buggy:** Seek to `"119483"`
---------
Co-authored-by: Twice <[email protected]>
---
src/storage/redis_db.cc | 11 ++++++++---
tests/gocase/unit/scan/scan_test.go | 29 +++++++++++++++++++++++++++++
2 files changed, 37 insertions(+), 3 deletions(-)
diff --git a/src/storage/redis_db.cc b/src/storage/redis_db.cc
index 1ff52c8b0..6f12f5890 100644
--- a/src/storage/redis_db.cc
+++ b/src/storage/redis_db.cc
@@ -339,9 +339,14 @@ rocksdb::Status Database::Scan(engine::Context &ctx, const
std::string &cursor,
}
if (!cursor.empty()) {
- iter->Seek(ns_cursor);
- if (iter->Valid()) {
- iter->Next();
+ if (storage_->IsSlotIdEncoded() && !ns_prefix.empty() &&
+
metadata_cf_handle_->GetComparator()->Compare(rocksdb::Slice(ns_prefix),
rocksdb::Slice(ns_cursor)) > 0) {
+ iter->Seek(ns_prefix);
+ } else {
+ iter->Seek(ns_cursor);
+ if (iter->Valid()) {
+ iter->Next();
+ }
}
} else if (ns_prefix.empty()) {
iter->SeekToFirst();
diff --git a/tests/gocase/unit/scan/scan_test.go
b/tests/gocase/unit/scan/scan_test.go
index 5d2f9fb9a..5f7f62bea 100644
--- a/tests/gocase/unit/scan/scan_test.go
+++ b/tests/gocase/unit/scan/scan_test.go
@@ -32,6 +32,35 @@ import (
"golang.org/x/exp/slices"
)
+func TestScanSlotRemainingKeys(t *testing.T) {
+ srv := util.StartServer(t, map[string]string{"cluster-enabled": "yes"})
+ defer srv.Close()
+
+ ctx := context.Background()
+ rdb := srv.NewClient()
+ defer func() { require.NoError(t, rdb.Close()) }()
+
+ nodeID := "YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY"
+ require.NoError(t, rdb.Do(ctx, "clusterx", "SETNODEID", nodeID).Err())
+ clusterNodes := fmt.Sprintf("%s %s %d master - 0-16383", nodeID,
srv.Host(), srv.Port())
+ require.NoError(t, rdb.Do(ctx, "clusterx", "SETNODES", clusterNodes,
"1").Err())
+
+ slot100Keys := []string{"119483", "166988", "210695", "223656",
"48063", "59022", "65976", "74937", "88379", "99338"}
+ for _, key := range slot100Keys {
+ require.NoError(t, rdb.Set(ctx, key, "1", 0).Err())
+ }
+ require.Equal(t, slot100Keys, scanAll(t, rdb))
+
+ cursor, keys := scan(t, rdb, "0", "match", "2*", "count", 1)
+ require.Equal(t, []string(nil), keys)
+ cursor, keys = scan(t, rdb, cursor, "match", "2*", "count", 1)
+ require.Equal(t, []string{"210695"}, keys)
+ cursor, keys = scan(t, rdb, cursor, "match", "2*", "count", 1)
+ require.Equal(t, []string{"223656"}, keys)
+ cursor, _ = scan(t, rdb, cursor, "match", "2*", "count", 1)
+ require.Equal(t, "0", cursor)
+}
+
func TestScanEmptyKey(t *testing.T) {
srv := util.StartServer(t, map[string]string{})
defer srv.Close()