Skip to content

Commit 6315105

Browse files
authored
fix(spanner): guard rollback when aborted commit cleared session handle (#14218)
Fixes: #14025 Fix a panic in `ReadWriteTransaction.rollback` when a statement-based transaction commit is aborted, commit cleanup clears `t.sh`, and a later rollback is triggered after retry reset fails. ## Root cause `ReadWriteStmtBasedTransaction.CommitWithReturnResp` recycles the session handle and sets `t.sh = nil` even when commit returns `ABORTED`. If a caller then tries to retry with a canceled context, `ResetForRetry` fails before a new transaction is created. Cleanup paths can still call `Rollback` on the original transaction. `ReadWriteTransaction.rollback` dereferenced `t.sh` without checking whether the handle itself was nil, which caused a panic. ## Change - Snapshot `t.sh` inside `ReadWriteTransaction.rollback` - Return early if the session handle is nil - Use the local snapshot for `getID`, `getClient`, and `getMetadata` This keeps rollback a no-op when the session handle has already been cleaned up, which matches the intended behavior for an already-aborted transaction.
1 parent 48c70e2 commit 6315105

File tree

2 files changed

+57
-2
lines changed

2 files changed

+57
-2
lines changed

spanner/transaction.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1932,14 +1932,18 @@ func (t *ReadWriteTransaction) rollback(ctx context.Context) {
19321932
t.mu.Unlock()
19331933
return
19341934
}
1935+
sh := t.sh
19351936
t.mu.Unlock()
19361937
// In case that sessionHandle was destroyed but transaction body fails to
19371938
// report it.
1938-
sid, client := t.sh.getID(), t.sh.getClient()
1939+
if sh == nil {
1940+
return
1941+
}
1942+
sid, client := sh.getID(), sh.getClient()
19391943
if sid == "" || client == nil {
19401944
return
19411945
}
1942-
_ = client.Rollback(contextWithOutgoingMetadata(ctx, t.sh.getMetadata(), t.disableRouteToLeader), &sppb.RollbackRequest{
1946+
_ = client.Rollback(contextWithOutgoingMetadata(ctx, sh.getMetadata(), t.disableRouteToLeader), &sppb.RollbackRequest{
19431947
Session: sid,
19441948
TransactionId: t.tx,
19451949
})

spanner/transaction_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,57 @@ func TestReadWriteStmtBasedTransaction_CommitAbortedErrorReturned(t *testing.T)
728728
}
729729
}
730730

731+
// When a commit is aborted and the retry fails because the context is
732+
// cancelled, calling Rollback on the transaction should not panic even
733+
// though the session handle has been set to nil.
734+
func TestReadWriteStmtBasedTransaction_RollbackAfterAbortedCommitWithNilSessionHandle(t *testing.T) {
735+
t.Parallel()
736+
ctx := context.Background()
737+
server, client, teardown := setupMockedTestServer(t)
738+
defer teardown()
739+
// Make the commit return an Aborted error.
740+
server.TestSpanner.PutExecutionTime(MethodCommitTransaction,
741+
SimulatedExecutionTime{
742+
Errors: []error{status.Errorf(codes.Aborted, "Transaction aborted")},
743+
})
744+
745+
txn, err := NewReadWriteStmtBasedTransaction(ctx, client)
746+
if err != nil {
747+
t.Fatalf("got an error: %v", err)
748+
}
749+
// Commit should return Aborted. This also sets txn.sh = nil internally.
750+
_, err = txn.Commit(ctx)
751+
if status.Code(err) != codes.Aborted {
752+
t.Fatalf("got an incorrect error: %v", err)
753+
}
754+
if txn.sh != nil {
755+
t.Fatal("expected aborted commit cleanup to clear the session handle")
756+
}
757+
758+
// Try to reset for retry with a cancelled context, simulating the
759+
// scenario where the caller's context has been cancelled (e.g. by
760+
// errgroup). This will fail because session acquisition needs a
761+
// valid context.
762+
cancelledCtx, cancel := context.WithCancel(ctx)
763+
cancel()
764+
_, err = txn.ResetForRetry(cancelledCtx)
765+
if g, w := ErrCode(err), codes.Canceled; g != w {
766+
t.Fatalf("ResetForRetry error code mismatch\n Got: %v\nWant: %v", g, w)
767+
}
768+
769+
// Rollback should not panic even though txn.sh is nil.
770+
var recovered any
771+
func() {
772+
defer func() {
773+
recovered = recover()
774+
}()
775+
txn.Rollback(context.Background())
776+
}()
777+
if recovered != nil {
778+
t.Fatalf("Rollback panicked after failed retry reset: %v", recovered)
779+
}
780+
}
781+
731782
// When a non-aborted error happens during a commit, it kicks off a rollback.
732783
func TestReadWriteStmtBasedTransaction_CommitNonAbortedErrorReturned(t *testing.T) {
733784
t.Parallel()

0 commit comments

Comments
 (0)