Repository navigation
feat(engine): record a want for a part the owner cannot read - #531
Conversation
An unreadable part no longer fails the whole load: it leaves Entries and enters Wanted in the same CAS commit, so the drop and the obligation cannot come apart and a lost race applies neither. Only backend.ErrNotExist qualifies — a backend that failed to answer says nothing about whether the data is there, and over a shared backend one transient fault would strip the index wholesale. Present-but-corrupt parts still fail the load; the orphan sweep spares a wanted part's remaining objects. Refs #387. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed and independently verified. This is the step where the design could have become a data-destruction path, and it doesn't. The permanent-vs-transient guard is correct and genuinely pinned. Verified by ablation rather than reading — widening it to The invariant test is real, not decorative. Ablating the Agreed on corrupt-but-present staying out, and the reason given is the one that decides it: a decoder bug or format skew would convert every part on the node into a want and delete the index naming them. That is the repair-path-becomes-data-destruction failure in one sentence. Different failure, different remedy. Three supporting decisions I want to note because they are load-bearing and easy to have missed:
Leaving the gone prefix out of Ran independently: The |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Step 2 of the design in #387, on top of #530 (
claude/387-v5-foundation). It makes the v5 format mean something: an owner that cannot read a part records aWantfor it, in the same CAS commit that drops it fromEntries. Nothing consumes wants yet — no repair fetch, no reseed threshold, no revocable hole, no read policy.What changed
loadPartsLockedin bothrecordengine/index.goandengine/index.goused to fail the whole engine on the first part it could not open. It now collects the parts whose objects are permanently absent, leaves them out ofpartsand out ofindexed, and commits one index in which they are gone fromEntriesand present inWanted.Because the two halves are the same
CompareAndSwap:Leaving the prefix out of
indexedis what keeps the existing removal diff from also calling it a removal: a loss restated as a deliberate deletion is exactly the ambiguity wants exist to remove.Two smaller consequences of the entry no longer being there:
foreignEntriesrefuses to adopt a wanted part back from a rival's index, which would put it inEntriesagain and discharge its own want while the bytes are still missing;sweepOrphansLockedspares a wanted part's surviving objects. They are the remains of a part repair is owed, not the residue of a failed flush.Only an owner does this. The replica path (
RefreshReplica, no sweep) keeps today's behaviour, because recording a want is committing an index and that is the owner's to write.Which errors are permanent
partGone(err) == errors.Is(err, backend.ErrNotExist)— that and nothing else.Every other failure leaves the part's existence unknown, and a want is a statement that it is gone. Recording one on an unknown drops a live part from the index and starts a repair for data that was never missing; on a shared backend a single transient fault touches every part at once, so the index would be stripped wholesale. This is ClickHouse's
isRetryableExceptionrethrow incheckDataPart.cpp, arrived at from the same direction.A partially readable part — manifest present, a data object gone — classifies as absent, because
block.OpenPartand the eager identity-column read propagateErrNotExistfrom whichever object is missing. That is the right answer: the part cannot answer a read, so it is as lost as one with no objects at all.Corrupt-but-present is deliberately not a want
Objects that exist but do not decode still fail the load. Argument for including them: from the reader's side the part is equally unusable, and repair would fix it. Argument against, which won: it is a different failure with a different remedy (a bit-rot / format problem, not a missing-bytes problem), the objects are still there so nothing is actually lost, and widening the trigger is precisely how a repair path becomes a data-destruction path — a decoder bug or a format skew would then convert every part on the node into a want and delete the index that names them.
TestCorruptPartIsNotAWantpins this.The invariant
TestEntriesLeaveOnlyIntoRemovedOrWanted(both packages) is a property test over the real commit path: a backend wrapper intercepts everyCompareAndSwapon the index key, decodes the previous and the new index, and asserts that every prefix that vanished fromEntrieslanded in exactly one of the two lists. 24 seeds × 12 random steps of ingest+flush / merge / retention-drop / erase-a-part-and-reload, so every flush, merge, retention drop and lossy load in the test is checked.Ablation for the transient guard
With
partGonewidened toerr != nil, a single injected read timeout is enough:Both live parts stripped from
Entries, both recorded as wants, committed. With the guard in place the load fails and the stored index is byte-identical (same generation, same entries, no wants) across timeout / cancelled / ENOSPC / permission-denied / S3 throttle.Tests
Mirrored in
recordengine/lostpart_test.goandengine/lostpart_test.go:TestGonePartBecomesWant— the load succeeds, the remaining part still serves, the want is in the committed index andRemovedis empty.TestPartiallyGonePartBecomesWant— half-erased part; also asserts the sweep spares what is left.TestTransientOpenFailureRecordsNoWant— 5 transient error shapes; no want, entries intact, generation unchanged, load still fails.TestCorruptPartIsNotAWant.TestWantIsRecordedInOneCommit— exactly one CAS on the index key.TestFailedWantCommitAppliesNeither— a refused CAS leaves the entry and no want; the retry re-derives and commits both.TestWantSurvivesLaterCommits— carried through a later flush, a merge, and a restart.TestEntriesLeaveOnlyIntoRemovedOrWanted— the property test above.go build ./...,go test ./...,-raceon./recordengine/... ./engine/... ./backend/... .,golangci-lint run ./...→ 0 issues.Docs
engine/ARCH.mdgains the section (invariant, permanent-vs-transient rule and its reasoning, corrupt-but-present, theindexed/foreignEntries/sweep consequences);recordengine/ARCH.mdpoints at it as a shared behaviour;ARCHITECTURE.mdgains the invariant as a cross-cutting bullet.Refs #387.
🤖 Generated with Claude Code