Repository navigation
fix(bucketindex): scope block identity to the tenure and fence commits on it - #747
Merged
Merged
Conversation
…s on it Blocks are (term, n) under the claim's etcd revision (index format v7), entries carry their writing term, and a clustered engine refuses a commit it can no longer make as the shard's current tenure. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcLq1G8KpWBvM53JaQb1tS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcLq1G8KpWBvM53JaQb1tS
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
… cluster A flush without the claim is now refused before it writes anything, and an engine Close takes that refusal as nothing to flush, so a node that left the cluster closes cleanly with its head in the WAL. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcLq1G8KpWBvM53JaQb1tS
The flush before leaving the cluster is the only one the node may make, so its failure is Close's error rather than a log line over acked rows left only in the WAL. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcLq1G8KpWBvM53JaQb1tS
…mmits anything The tenure check is a local read, so a predecessor's held CAS could land after the claim moved. The new owner's first commit now moves the index version from a fresh load, so the held CAS fails and is refused, or landed first and is the new tenure's starting point instead of being rebased beside its inputs. Over private backends identity stays the only protection. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcLq1G8KpWBvM53JaQb1tS
Ownership went on reporting the revoked tenures, so an engine's final flush after leaving the cluster committed under an ended term. An engine Close now also releases its WAL when the flush failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcLq1G8KpWBvM53JaQb1tS
…xpose it A failed establishment now costs one load and one CAS per operation, like a failing flush, instead of a retry loop under the locks. storage.index.establish_failures counts the attempts and SignalStats.TenureUnestablished shows an engine that commits nothing until one lands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcLq1G8KpWBvM53JaQb1tS
…d cycles A new tenure now makes one establishing attempt per flush and merge, so a slow runner can need more cycles before the old owner has taken the shard back and merged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcLq1G8KpWBvM53JaQb1tS
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.
Part of #725: the first of three PRs. Blocks become term-scoped, and every clustered commit is fenced on the writer's tenure.
Changes
Block{Term, N}. The term is the etcd create revision of the claim that allocated it.Intervalstays a canonical set over the (term, n) order. Held runs never cross terms, and a merge across tenures holds each tenure's blocks, with the rest of the lower term as an ordinary gap.NextBlock(term)allocates within the writer's term, so two tenures that never saw each other's index (a displaced owner and its successor, or diverged replicas) can't collide. A writer with no cluster (term 0) continues the highest term's sequence.AllocatedBlocksis now aBlock.Entry.Term,Want.Term). An identical block set at the same level resolves to the higher term throughLineage.Subsumes, and so throughSupersedes,Satisfying,RevokesandSubsumed.bucketindex.CheckTenure,ErrSuperseded). Both engines check before every CAS attempt. A clustered writer refuses to commit when it holds no claim, when its tenure ended and restarted after a merge picked its inputs, or when a load or rebase shows the index was written by a later term. A rebase never adopts a higher term, so a displaced writer never commits under its successor's term (theGeneration.Nextpath). A clustered engine without the claim now leavesLoadPartswants pending instead of committing them.Closetreats that refusal as nothing to flush: the head stays in the WAL.Storage.Closenow flushes the engines it owns before it leaves the cluster, while it still holds the claims. Before this, the final flush ran after leaving and committed under a stale term. Now it would be refused, and every clean restart would re-flush heads the new owner had already flushed (the Windows CI failure on the first push, from part files still open under a refused commit).establishTenureLocked), and until that commit lands every commit of the term is refused.storage.index.establish_failures{signal, reason=conflict|unavailable}counts failed attempts, andSignalStats.TenureUnestablishedmarks an engine that holds the claim but has not established its tenure. Both are in ADMIN.md.Closereports a failed owned flush.flushOwnedreturns the first failure while still flushing every engine, andStorage.Closereturns it.Ownershipkept reporting the ended terms, so an engine's final flush committed under a term that no longer existed.Ownership.Forgetnow drops the held claims when the node leaves. An engine'sClosealso closes its WAL when the flush fails for any other reason; before this, the WAL handle leaked, which Windows CI caught.bucketindex.Plan,PlanMergeandAllocatorreplace the two identicalblockid.gocopies inengineandrecordengine.backend/,engine/,recordengine/andcluster/ARCH.mdandARCHITECTURE.mdare updated: the new invariant, the v7 format and the v6 migration.v1–v6 indexes still decode. A v6 index reads as term 0 throughout.
FuzzDecodealso found an older defect, fixed here. A v5 hull that names no set (inverted, or touching block 0) was kept verbatim, but it was written back as unset, so decode∘encode was not the identity. It now reads as unset, and the input is pinned in the corpus.Measurements
Index encode/decode and the want trim, over a 100-entry index with 512 tombstones (
backend/bucketindex/bench_test.go). Old is main's package, new is this PR. n=8, alternating runs:Encode and decode cost 1.5µs and 3µs more per commit and load of a 100-part index, because every block now carries a term.
Intervalwalks its runs without materializing them, andcoveredfolds every entry's runs with one sort. Without that, the doubledGapsize madeTrimWants2.2× slower with 6× the allocations.Tests
TestEngineSuite/{DisplacedWriterKeepsItsTerm, SplitBrainMergeSameInputs, SplitBrainMergeOverlappingInputs}, for both engines. The displaced merge and flush now fail withErrSupersededand commit nothing.TenuresAllocateDisjointBlocks: the block-collision reproducer the issue asks for. Two tenures on diverged copies flush the next part.StaleCommitCannotLandAfterTheSuccessorEstablishes: the predecessor's merge is held inside the index CAS, the claim moves, the successor commits, then the held CAS is released. It is refused, and its output never becomes live.PredecessorCommitLandsBeforeTheSuccessorEstablishes: the held merge lands after the claim moved but before the successor's first write. The successor's establishing load takes it as its starting point, so the merged inputs don't come back (this test failed before the change).UnestablishedTenureKeepsRowsInTheHead: establishment fails repeatedly, first on a lost CAS and then on a backend error. Each flush and merge makes exactly one CAS, and the counter rises by reason. No part is written, the rows stay readable from the head, andTenureUnestablishedis set. The first cycle that lands publishes every held row and clears the bit.LapsedClaimCommitsNothing: no claim means no commit, rows are kept, and a merge that outlives its tenure is refused.WritingTermSurvivesTheNextTenure.CheckTenureandAllocatortables, and multi-term seeds forFuzzRoundTripandFuzzDecode.TestClusterHandoffUnsyncedFlushReadsOnce/logsis re-gated from bucketindex: part identity breaks under divergent replica lineage #725 to Deduplication: no signal dedups re-delivered records #572 (dedup), with its assertion unchanged. The old owner's unsynced flush, re-flushed by the new owner, is the duplicate the design accepts. No catch-up can see it, because the old owner is out of the ring.TestClusterCloseFlushesWhileTheClaimIsHeld: an owner's Close drains its head into a part, the node no longer claims the shard once it has left (this assertion failed beforeForget), and a claimless node closes cleanly.TestClusterCloseReportsAFailedOwnedFlush: an injected index-commit failure during the owned flush is Close's error (this test failed before the change).TestClusterHandoffUnsyncedFlushReadsOncenow waits until the old owner has taken the shard back and merged, and the secondary has mirrored it, instead of running exactly three maintenance cycles. An establishing attempt that meets a concurrent index write waits for the next cycle, and on the Windows runner three cycles were sometimes not enough. The assertions are unchanged.TestRecoveredEngineRepairs*now restart the shard's compaction owner and take its claim before merging, since a claimless engine no longer commits.go test -p 2 ./...go test -raceon the changed packages and./internal/...golangci-lint run --concurrency=2 ./...(exit 0)OTELDB_STORAGE_REPRODUCE=1 go test -p 2 ./...: every failure is gated on another issue. They areTestClusterHandoffUnsyncedFlushReadsOnce/logs(Deduplication: no signal dedups re-delivered records #572),TestRebaseBlindToAdoptedPart(engine: rollup guard misses the layout of an unreadable adopted part #745, PR2),TestRerollLateOverwrite(engine: a late overwrite of an already-rolled sample cannot replace it #732), andTestRerollDuplicateSplitByMaxParts,TestRerollOracleDuplicatesandFuzzRerollOracleDuplicates(engine: raw duplicates rolled in separate merges are double-counted by the rollup fold #739).FuzzRoundTrip,FuzzDecode,FuzzLivePart,FuzzPartOf,FuzzDecodeKeyList. The Codex round-1 fixes change no format and no fuzzed code, so these were not re-run for them.Follow-ups: #746 (primary writes should require the claim). PR2 adds the lineage catalog, the rollup layout in the entry (#745) and the nested false hole. PR3 adds the bounded handoff catch-up.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KcLq1G8KpWBvM53JaQb1tS