Let a published batch receipt own its storage - #390
Merged
Conversation
publish() stored the receipt struct under the write lock, but Results, Files and Summary are reference types: the stored copy kept pointing at the publisher's backing array. runBatchTransaction publishes "prepared", then stamps Results[i].Status = "applied" in place during commit, so those writes landed in memory a concurrent snapshot() was reading under RLock — the lock protected the header, never the elements. Concurrent idempotent retries hit this: a caller classifying an in-flight transaction could observe status "prepared" already carrying applied results, and the race detector flagged the commit loop against snapshot's slice copy. Copy through cloneBatchReceipt at both boundaries so the state owns what it holds and publishers stay free to keep mutating their own receipt.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the data race that turned
mainred on the macOS runner:TestAtomicBatchConcurrentIdempotencyWritesOnce,batch_transaction.go:501(write) againstbatch_transaction.go:82(read) viabatch_transaction_journal.go:84.Root cause
batchTransactionState.publishstored the receipt struct under the write lock, butResults,FilesandSummaryare reference types — the stored copy kept pointing at the publisher's backing array.snapshot()deep-copies on the way out, so it looked symmetric, but nothing stopped the publisher from writing into storage it had already handed over.runBatchTransactiondoes exactly that:"prepared"(results still"validated"),receipt.Results[i].Status = "applied"in place,"committed".Step 3 writes into the array a concurrent
snapshot()reads underRLock. The lock protected the slice header; it never protected the elements.Why it is more than a detector complaint
A concurrent idempotent retry classifies the in-flight transaction by snapshotting its receipt. Between the applied-loop and the
"committed"publish sits a manifest write and an fsync, so a reader reliably observes a torn receipt:status "prepared"already carryingappliedresults. The added test asserts that directly and fails without the fix.Fix
Copy through a single
cloneBatchReceipthelper at both boundaries, so a published receipt is owned solely by the state and publishers stay free to keep mutating their own copy.publishclones before taking the lock — the clone touches only the publisher's own storage.CompletedAtis cloned too, closing the same aliasing hole for the one pointer field.Verification
TestBatchTransactionPublishCopiesReceiptStorage— deterministic, no race detector needed. Fails 3/3 without the fix on all four fields, in both directions.TestAtomicBatchSnapshotIsolatedFromCommitInProgress— drives the reported interleaving through the same call path as the CI stack. Without the fix it reports the race and the torn observation; the write override deliberately signals without waiting, since a back-edge to the reader orders the commit loop behind its reads and hides the bug.-cpu=2,8: clean.go test -race ./internal/mcp/: clean.go build ./...,go vet,golangci-lint: clean.