diff --git a/test/Garnet.test/RespRangeIndexTests.cs b/test/Garnet.test/RespRangeIndexTests.cs index bbf78010546..eff1c84b782 100644 --- a/test/Garnet.test/RespRangeIndexTests.cs +++ b/test/Garnet.test/RespRangeIndexTests.cs @@ -2,9 +2,11 @@ // Licensed under the MIT license. using System; +using System.IO; using System.Threading; using System.Threading.Tasks; using Allure.NUnit; +using Garnet.server; using NUnit.Framework; using NUnit.Framework.Legacy; using StackExchange.Redis; @@ -1954,6 +1956,173 @@ public void RIConcurrentOpsWithCheckpointTest() } } + /// + /// After checkpoint recovery, if an RI key is never accessed before a second + /// checkpoint, PurgeOldCheckpointSnapshots deletes the only snapshot file. + /// The tree was never restored (not in liveIndexes), so no new snapshot + /// was created. Accessing the key after the purge should still return data. + /// + [Test] + public void RIUnaccessedKeyAfterRecoveryAndSecondCheckpointTest() + { + server.Dispose(); + TestUtils.DeleteDirectory(TestUtils.MethodTestDir, wait: true); + server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, enableRangeIndexPreview: true, enableAOF: true); + server.Start(); + + var rangeIndexManager = server.Provider.StoreWrapper.rangeIndexManager; + var rangeIndexDir = Path.Combine(TestUtils.MethodTestDir, "checkpoints", "rangeindex"); + var idx1Dir = Path.Combine(rangeIndexDir, RangeIndexManager.HashKeyToDirectoryName("idx1"u8)); + var idx2Dir = Path.Combine(rangeIndexDir, RangeIndexManager.HashKeyToDirectoryName("idx2"u8)); + + using (var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig(allowAdmin: true))) + { + var db = redis.GetDatabase(0); + + // Create two RI keys with data + db.Execute("RI.CREATE", "idx1", "DISK", "CACHESIZE", "65536", "MINRECORD", "8"); + db.Execute("RI.SET", "idx1", "key-a", "val-a"); + + db.Execute("RI.CREATE", "idx2", "DISK", "CACHESIZE", "65536", "MINRECORD", "8"); + db.Execute("RI.SET", "idx2", "key-b", "val-b"); + + // First checkpoint — both trees get snapshot files + db.Execute("SAVE"); + } + + // Both key directories should have data.bftree + snapshot file + ClassicAssert.IsTrue(Directory.Exists(idx1Dir), "idx1 directory should exist after first checkpoint"); + ClassicAssert.IsTrue(Directory.Exists(idx2Dir), "idx2 directory should exist after first checkpoint"); + ClassicAssert.IsTrue(File.Exists(Path.Combine(idx1Dir, "data.bftree")), "idx1 data.bftree should exist"); + ClassicAssert.IsTrue(File.Exists(Path.Combine(idx2Dir, "data.bftree")), "idx2 data.bftree should exist"); + var idx1Snapshots = Directory.GetFiles(idx1Dir, "snapshot.*.bftree"); + var idx2Snapshots = Directory.GetFiles(idx2Dir, "snapshot.*.bftree"); + ClassicAssert.AreEqual(1, idx1Snapshots.Length, "idx1 should have 1 snapshot after first checkpoint"); + ClassicAssert.AreEqual(1, idx2Snapshots.Length, "idx2 should have 1 snapshot after first checkpoint"); + + // Recover — both stubs get FlagRecovered=true, TreeHandle=0 + server.Dispose(); + server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, enableRangeIndexPreview: true, enableAOF: true, tryRecover: true); + server.Start(); + + rangeIndexManager = server.Provider.StoreWrapper.rangeIndexManager; + ClassicAssert.AreEqual(0, rangeIndexManager.LiveIndexCount, "No trees should be live right after recovery"); + + using (var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig(allowAdmin: true))) + { + var db = redis.GetDatabase(0); + + // Access idx1 only — this restores it and registers in liveIndexes + var val = db.Execute("RI.GET", "idx1", "key-a"); + ClassicAssert.AreEqual("val-a", (string)val); + ClassicAssert.AreEqual(1, rangeIndexManager.LiveIndexCount, "Only idx1 should be live after restore"); + + // Do NOT access idx2 — it stays unrestored (TreeHandle=0, FlagRecovered=true) + + // Second checkpoint: idx1 gets a new snapshot, idx2 does not. + // PurgeOldCheckpointSnapshots deletes the old checkpoint snapshot files. + db.Execute("SAVE"); + + // idx1: old snapshot purged, new snapshot created + idx1Snapshots = Directory.GetFiles(idx1Dir, "snapshot.*.bftree"); + ClassicAssert.AreEqual(1, idx1Snapshots.Length, "idx1 should have 1 snapshot after second checkpoint (old purged, new created)"); + + // idx2: old snapshot purged, NO new snapshot created (tree was never restored) + idx2Snapshots = Directory.GetFiles(idx2Dir, "snapshot.*.bftree"); + ClassicAssert.AreEqual(0, idx2Snapshots.Length, + "idx2 old snapshot should be purged and no new snapshot created (tree was never restored)"); + + // idx2 data.bftree should still exist (working file from initial creation) + ClassicAssert.IsTrue(File.Exists(Path.Combine(idx2Dir, "data.bftree")), + "idx2 data.bftree should still exist (not deleted by purge)"); + + // Now access idx2 — its old checkpoint snapshot was purged, + // and no flush.bftree was ever written. + val = db.Execute("RI.GET", "idx2", "key-b"); + ClassicAssert.AreEqual("val-b", (string)val, + "Unaccessed RI key should still be readable after second checkpoint purges old snapshots"); + } + } + + /// + /// After checkpoint + recovery, accessing only one RI key and taking a second + /// checkpoint causes PurgeOldCheckpointSnapshots to delete the unaccessed + /// key's only snapshot (no new one was created because it was never restored). + /// A second recovery then loses the unaccessed key entirely. + /// + [Test] + public void RIUnaccessedKeyLostAfterSecondRecoveryTest() + { + server.Dispose(); + TestUtils.DeleteDirectory(TestUtils.MethodTestDir, wait: true); + server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, enableRangeIndexPreview: true, enableAOF: true); + server.Start(); + + var rangeIndexDir = Path.Combine(TestUtils.MethodTestDir, "checkpoints", "rangeindex"); + var idx1Dir = Path.Combine(rangeIndexDir, RangeIndexManager.HashKeyToDirectoryName("idx1"u8)); + var idx2Dir = Path.Combine(rangeIndexDir, RangeIndexManager.HashKeyToDirectoryName("idx2"u8)); + + // Step 1-3: Create two RI keys with data, then checkpoint + using (var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig(allowAdmin: true))) + { + var db = redis.GetDatabase(0); + + db.Execute("RI.CREATE", "idx1", "DISK", "CACHESIZE", "65536", "MINRECORD", "8"); + db.Execute("RI.SET", "idx1", "key-a", "val-a"); + + db.Execute("RI.CREATE", "idx2", "DISK", "CACHESIZE", "65536", "MINRECORD", "8"); + db.Execute("RI.SET", "idx2", "key-b", "val-b"); + + db.Execute("SAVE"); + } + + // Both keys have snapshots + ClassicAssert.AreEqual(1, Directory.GetFiles(idx1Dir, "snapshot.*.bftree").Length, "idx1 should have 1 snapshot"); + ClassicAssert.AreEqual(1, Directory.GetFiles(idx2Dir, "snapshot.*.bftree").Length, "idx2 should have 1 snapshot"); + + // Step 4: Restart and recover from checkpoint + server.Dispose(); + server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, enableRangeIndexPreview: true, enableAOF: true, tryRecover: true); + server.Start(); + + // Step 5: Access only idx1, then take a second checkpoint + using (var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig(allowAdmin: true))) + { + var db = redis.GetDatabase(0); + + var val = db.Execute("RI.GET", "idx1", "key-a"); + ClassicAssert.AreEqual("val-a", (string)val); + + // Do NOT access idx2 + + db.Execute("SAVE"); + } + + // idx1 has a new snapshot; idx2's old snapshot was purged with no replacement + ClassicAssert.AreEqual(1, Directory.GetFiles(idx1Dir, "snapshot.*.bftree").Length, + "idx1 should have 1 snapshot after second checkpoint"); + ClassicAssert.AreEqual(0, Directory.GetFiles(idx2Dir, "snapshot.*.bftree").Length, + "idx2 snapshot should have been purged with no replacement"); + + // Step 6: Second restart and recover + server.Dispose(); + server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, enableRangeIndexPreview: true, enableAOF: true, tryRecover: true); + server.Start(); + + // Step 7: idx1 should exist, idx2 should be lost + using (var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig())) + { + var db = redis.GetDatabase(0); + + var val = db.Execute("RI.GET", "idx1", "key-a"); + ClassicAssert.AreEqual("val-a", (string)val, "idx1 should survive second recovery"); + + val = db.Execute("RI.GET", "idx2", "key-b"); + ClassicAssert.AreEqual("val-b", (string)val, + "idx2 should survive second recovery (unaccessed key must not be lost by snapshot purge)"); + } + } + /// /// Verifies pure AOF-only recovery (no checkpoint). RI.CREATE is replayed to /// recreate the BfTree, then RI.SET/RI.DEL operations rebuild the data.