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