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/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..01a84bd0e3e 100644 --- a/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs +++ b/test/Transactions/Orleans.Transactions.Tests/TransactionRecoveryLatencyTests.cs @@ -62,6 +62,30 @@ public void FreshRemoteCommitRetainsFirstPingGraceThenUsesBoundedExponentialRetr } } + [Fact] + public async Task CancelBeforePrepareBreaksPrePrepareLockAndRetainsTransactionId() + { + 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, record) = await queue.RWLock.ValidateLock(transactionId, accessCount); + Assert.Equal(TransactionalStatus.BrokenLock, status); + Assert.Equal(transactionId, record.TransactionId); + } + [Fact] public void StorageBatchTracksCommittedTransactionIds() {