diff --git a/libs/server/Transaction/TransactionManager.cs b/libs/server/Transaction/TransactionManager.cs index 692ea3fd641..cb40f665bc6 100644 --- a/libs/server/Transaction/TransactionManager.cs +++ b/libs/server/Transaction/TransactionManager.cs @@ -554,11 +554,13 @@ void ComputeSublogAccessVector(out ulong physicalSublogAccessVector, out BitVect // Initialize only for multi-log virtualSublogAccessVector = [.. Enumerable.Range(0, appendOnlyFile.Log.Size).Select(_ => new BitVector(AofShardedLogTransactionHeader.ReplayTaskAccessVectorBytes))]; - // If sharded log is enabled calculate sublog access bitmap - for (var i = 0; i < txnKeysParseState.Count; i++) + // Compute the sublog access bitmap from the transaction's locked keys. keyEntries is populated in both + // standalone and cluster mode (via SaveKeyEntryToLock), unlike txnKeysParseState which is only filled during + // cluster slot verification (TxnClusterSlotCheck.SaveKeyArgSlice returns early when !clusterEnabled). The + // stored keyHash equals GarnetLog.HASH of the key, so it is used directly (no re-hashing). + for (var i = 0; i < keyEntries.Count; i++) { - ref var key = ref txnKeysParseState.GetArgSliceByRef(i); - var keyHash = GarnetLog.HASH(key.ReadOnlySpan); + var keyHash = keyEntries.GetKeyHash(i); var physicalSublogIdx = appendOnlyFile.Log.GetPhysicalSublogIdx(keyHash); var replayIdx = appendOnlyFile.Log.GetReplayTaskIdx(keyHash); physicalSublogAccessVector |= 1UL << physicalSublogIdx; diff --git a/libs/server/Transaction/TxnKeyEntry.cs b/libs/server/Transaction/TxnKeyEntry.cs index 23836172cf0..17a3009e8d5 100644 --- a/libs/server/Transaction/TxnKeyEntry.cs +++ b/libs/server/Transaction/TxnKeyEntry.cs @@ -76,6 +76,15 @@ public bool IsReadOnly } } + /// Number of keys registered for locking in this transaction. + internal int Count => keyCount; + + /// + /// Hash of the key at . This is the Garnet key hash (equal to GarnetLog.HASH of the + /// key bytes), so it can be used directly for AOF sublog routing. + /// + internal long GetKeyHash(int index) => keys[index].keyHash; + public void AddKey(PinnedSpanByte keyArgSlice, LockType type) { var keyHash = comparison.UnifiedTransactionalContext.GetKeyHash((FixedSpanByteKey)keyArgSlice); diff --git a/test/standalone/BfTreeInterop.test/BfTreeInterop.test.csproj b/test/standalone/BfTreeInterop.test/BfTreeInterop.test.csproj index f86888c8b47..ada880bece4 100644 --- a/test/standalone/BfTreeInterop.test/BfTreeInterop.test.csproj +++ b/test/standalone/BfTreeInterop.test/BfTreeInterop.test.csproj @@ -18,6 +18,7 @@ + diff --git a/test/standalone/BfTreeInterop.test/TestProjectSetup.cs b/test/standalone/BfTreeInterop.test/TestProjectSetup.cs new file mode 100644 index 00000000000..d19fbfe6ae7 --- /dev/null +++ b/test/standalone/BfTreeInterop.test/TestProjectSetup.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using Garnet.test; +using NUnit.Framework; + +namespace BfTreeInterop.test +{ + [SetUpFixture] + public class TestProjectSetup + { + [OneTimeSetUp] + public void SetPort() => TestUtils.SetTestPort(TestPortAssignment.GarnetTestBfTreeInterop); + } +} \ No newline at end of file diff --git a/test/standalone/Garnet.test/AofShardedTxnRecoveryTests.cs b/test/standalone/Garnet.test/AofShardedTxnRecoveryTests.cs new file mode 100644 index 00000000000..046fed5e279 --- /dev/null +++ b/test/standalone/Garnet.test/AofShardedTxnRecoveryTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using NUnit.Framework; +using NUnit.Framework.Legacy; +using StackExchange.Redis; + +namespace Garnet.test +{ + /// + /// Verifies that an internal (implicit) transaction survives an AOF recovery restart on a standalone server with + /// multiple physical AOF sublogs (AofPhysicalSublogCount > 1). RENAME is used as the transaction: it must route its + /// TxnStart marker to the physical sublog(s) its keys hash to, which ComputeSublogAccessVector derives from the + /// transaction's locked keys. Parameterized over subLogCount: 1 (single physical log) and 2 (sharded). + /// + [TestFixture] + public class AofShardedTxnRecoveryTests : TestBase + { + GarnetServer server; + + GarnetServer CreateShardedAofServer(int subLogCount, bool tryRecover) + { + // Standalone (enableCluster: false) server with subLogCount physical AOF sublogs, built via + // GetGarnetServerOptions (the standalone CreateGarnetServer overload does not expose sublogCount). + var opts = TestUtils.GetGarnetServerOptions( + checkpointDir: TestUtils.MethodTestDir, + logDir: TestUtils.MethodTestDir, + endpoint: TestUtils.EndPoint, + enableCluster: false, + enableAOF: true, + tryRecover: tryRecover, + sublogCount: subLogCount); + return new GarnetServer(opts); + } + + [SetUp] + public void Setup() + { + TestUtils.DeleteDirectory(TestUtils.MethodTestDir, wait: true); + + // The test method carries a [Values(1, 2)] int subLogCount, but [SetUp] cannot receive the test case's + // arguments directly, so read subLogCount from the current test's arguments (see Tsavorite's + // OverflowBucketLockTableTests.Setup). + var subLogCount = 1; + foreach (var arg in TestContext.CurrentContext.Test.Arguments) + { + if (arg is int sc) + { + subLogCount = sc; + break; + } + } + + server = CreateShardedAofServer(subLogCount, tryRecover: false); + server.Start(); + } + + [TearDown] + public void TearDown() + { + server?.Dispose(); + TestUtils.OnTearDown(); + } + + [Test] + public void ShardedStandaloneRenameTransactionSurvivesRecovery([Values(1, 2)] int subLogCount) + { + using (var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig())) + { + var db = redis.GetDatabase(0); + db.StringSet("k1", "v1"); + + // RENAME runs as an internal (implicit) transaction: its TxnStart marker must reach the physical + // sublog(s) its keys hash to. This exercises the standalone sharded transaction path. + ClassicAssert.IsTrue(db.KeyRename("k1", "k2")); + ClassicAssert.AreEqual("v1", (string)db.StringGet("k2")); + + // Commit the AOF to disk so the restart below must replay the RENAME transaction. + db.Execute("COMMITAOF"); + } + + // Restart with recovery (keep the on-disk AOF/checkpoint). + server.Dispose(false); + server = CreateShardedAofServer(subLogCount, tryRecover: true); + server.Start(); + + using (var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig())) + { + var db = redis.GetDatabase(0); + ClassicAssert.AreEqual("v1", (string)db.StringGet("k2"), "renamed key should survive recovery"); + ClassicAssert.IsFalse(db.KeyExists("k1"), "old key should not exist after rename + recovery"); + } + } + } +} \ No newline at end of file diff --git a/test/standalone/Garnet.test/Garnet.test.csproj b/test/standalone/Garnet.test/Garnet.test.csproj index e0c8c833e15..67228527e2e 100644 --- a/test/standalone/Garnet.test/Garnet.test.csproj +++ b/test/standalone/Garnet.test/Garnet.test.csproj @@ -18,6 +18,7 @@ + diff --git a/test/standalone/Garnet.test/TestUtils.cs b/test/standalone/Garnet.test/TestUtils.cs index c81a0898552..42f67d1e45f 100644 --- a/test/standalone/Garnet.test/TestUtils.cs +++ b/test/standalone/Garnet.test/TestUtils.cs @@ -90,6 +90,7 @@ public enum TestPortAssignment GarnetTestRangeIndex = 34700, GarnetTestScripting = 34800, GarnetTestVectorSet = 34900, + GarnetTestBfTreeInterop = 35000, } internal static class TestUtils