From 38021f9c3dc30d2168b4934538cc0b4c196e8e07 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 21 Aug 2026 01:44:42 -0700 Subject: [PATCH 1/3] fix(transactions): release locks for early cancels --- .../State/TransactionQueue.cs | 4 ++++ .../TransactionRecoveryLatencyTests.cs | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/Orleans.Transactions/State/TransactionQueue.cs b/src/Orleans.Transactions/State/TransactionQueue.cs index ce0107c8639..b5a47c63551 100644 --- a/src/Orleans.Transactions/State/TransactionQueue.cs +++ b/src/Orleans.Transactions/State/TransactionQueue.cs @@ -588,6 +588,10 @@ public async Task NotifyOfCancel(Guid transactionId, DateTime timeStamp, Transac if (pos == -1) { + // Cancel can overtake the one-way prepare message. Release the pre-prepare lock so a + // late prepare observes a broken lock and completes without persisting a remote commit. + this.RWLock.Rollback(transactionId); + this.RWLock.Notify(); TransactionDiagnosticEvents.EmitTransactionCancelCompleted( resource, transactionId, diff --git a/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs b/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs index f5a621b2abb..faad716fb42 100644 --- a/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs +++ b/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs @@ -62,6 +62,29 @@ public void FreshRemoteCommitRetainsFirstPingGraceThenUsesBoundedExponentialRetr } } + [Fact] + public async Task CancelBeforePrepareReleasesPrePrepareLock() + { + var resource = CreateParticipant("resource", ParticipantId.Role.Resource); + var queue = new GatedCancelTransactionQueue(resource, new TestActivationLifetime()); + var transactionId = Guid.NewGuid(); + var timeStamp = new DateTime(2026, 8, 8, 12, 0, 0, DateTimeKind.Utc); + var accessCount = new AccessCounter { Writes = 1 }; + + await queue.RWLock.EnterLock( + transactionId, + timeStamp, + default, + isRead: false, + exclusiveLock: false, + static () => 0); + + await queue.NotifyOfCancel(transactionId, timeStamp, TransactionalStatus.CascadingAbort); + + var (status, _) = await queue.RWLock.ValidateLock(transactionId, accessCount); + Assert.Equal(TransactionalStatus.BrokenLock, status); + } + [Fact] public void StorageBatchTracksCommittedTransactionIds() { From b72251cf49570c6b595f5fd701eb95edd1910bba Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 21 Aug 2026 02:00:20 -0700 Subject: [PATCH 2/3] fix(transactions): preserve ids for broken locks --- src/Orleans.Transactions/State/ReaderWriterLock.cs | 2 +- .../TransactionRecoveryLatencyTests.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Orleans.Transactions/State/ReaderWriterLock.cs b/src/Orleans.Transactions/State/ReaderWriterLock.cs index 37efb8f6ccd..1ef30aede09 100644 --- a/src/Orleans.Transactions/State/ReaderWriterLock.cs +++ b/src/Orleans.Transactions/State/ReaderWriterLock.cs @@ -192,7 +192,7 @@ void completion() { if (currentGroup == null || !currentGroup.TryGetValue(transactionId, out TransactionRecord? record)) { - return (TransactionalStatus.BrokenLock, new TransactionRecord()); + return (TransactionalStatus.BrokenLock, new TransactionRecord { TransactionId = transactionId }); } else if (record.NumberReads != accessCount.Reads || record.NumberWrites != accessCount.Writes) diff --git a/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs b/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs index faad716fb42..a317d4e3663 100644 --- a/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs +++ b/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs @@ -63,7 +63,7 @@ public void FreshRemoteCommitRetainsFirstPingGraceThenUsesBoundedExponentialRetr } [Fact] - public async Task CancelBeforePrepareReleasesPrePrepareLock() + public async Task CancelBeforePrepareCausesLatePrepareToRetainTransactionId() { var resource = CreateParticipant("resource", ParticipantId.Role.Resource); var queue = new GatedCancelTransactionQueue(resource, new TestActivationLifetime()); @@ -81,8 +81,9 @@ await queue.RWLock.EnterLock( await queue.NotifyOfCancel(transactionId, timeStamp, TransactionalStatus.CascadingAbort); - var (status, _) = await queue.RWLock.ValidateLock(transactionId, accessCount); + var (status, record) = await queue.RWLock.ValidateLock(transactionId, accessCount); Assert.Equal(TransactionalStatus.BrokenLock, status); + Assert.Equal(transactionId, record.TransactionId); } [Fact] From 5c87e4a2210dc2f04a79e8e7d5d92afc7baf4add Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 21 Aug 2026 07:28:11 -0700 Subject: [PATCH 3/3] test(transactions): clarify cancel-before-prepare case --- .../TransactionRecoveryLatencyTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs b/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs index a317d4e3663..01a84bd0e3e 100644 --- a/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs +++ b/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs @@ -63,7 +63,7 @@ public void FreshRemoteCommitRetainsFirstPingGraceThenUsesBoundedExponentialRetr } [Fact] - public async Task CancelBeforePrepareCausesLatePrepareToRetainTransactionId() + public async Task CancelBeforePrepareBreaksPrePrepareLockAndRetainsTransactionId() { var resource = CreateParticipant("resource", ParticipantId.Role.Resource); var queue = new GatedCancelTransactionQueue(resource, new TestActivationLifetime());