mvcc: keep the live value of a key re-created in the compacted revision - #22377
mvcc: keep the live value of a key re-created in the compacted revision#22377cristifalcas wants to merge 1 commit into
Conversation
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: cristifalcas The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Hi @cristifalcas. Thanks for your PR. I'm waiting for a etcd-io member to verify that this patch is reasonable to test. If it is, they should reply with Regular contributors should join the org to skip this step. Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
A key that is deleted and re-created within a single main revision is alive at the end of that revision: the later sub revision supersedes the tombstone, as keyIndex.since() already encodes when it hides superseded revisions from watchers. keyIndex.doCompact stopped at the generation ending with the tombstone whenever its main revision equalled atRev, so the following generation -- holding the re-creation at a higher sub revision of the same main revision -- was never walked. The live revision was left out of the `available` set and scheduleCompaction deleted its row from the backend, while compact() kept that generation in the index still pointing at it. The next range over the key hit the "range failed to find revision pair" fatal, and the restarted member rebuilt an empty index from a backend that no longer held the rows, serving an empty keyspace while reporting itself healthy. Advance past a generation whose tombstone sits on atRev only when the next generation was created in that same main revision. A tombstone that is the key's last word at atRev is still kept, preserving etcd-io#18274. keep() is unaffected: over an exhaustive enumeration of key index shapes its output is identical to the pre-etcd-io#18274 behaviour, so HashByRev values are unchanged. Every revision whose membership in `available` changes has Main == atRev, which the compaction hasher never gates on, so compaction hashes are unchanged as well. Fixes etcd-io#22376 Signed-off-by: Cristian Falcas <cristian@aspect.build>
cce4fe2 to
edf9435
Compare
This was done by Claude.
Fixes #22376.
A key deleted and re-created within a single main revision loses its value when the store is compacted at exactly that revision. The next range over the key hits the
range failed to find revision pairfatal atmvcc/kvstore_txn.go:128, the process exits, and the restarted member rebuilds an empty index from a backend that no longer holds the rows — serving an empty keyspace while reporting itself healthy.This is a regression from #18274, which changed the generation-selection condition in
keyIndex.doCompactfromtomb > atRevtotomb >= atRev. Verified by building the unit reproducer atbbdc94181^(passes) andbbdc94181(fails).The defect
For a key that existed before the rewrite, one transaction that deletes and re-creates it produces:
doCompact(7)breaks ongen0because its tombstone's main revision equalsatRev, sogen1is never walked and7.5never reachesavailable.scheduleCompactionthen deletes that row from the backend, whilecompact()leavesgen1in the index still referencing it.The fix
Advance past a generation whose tombstone sits on
atRevonly when the next generation was created in the same main revision — i.e. the key was re-created and the tombstone is superseded.keyIndex.since()already encodes this rule, hiding superseded revisions of a main revision from watchers, so the key is alive at the end ofatRevand its put must survive.A tombstone that is the key's last word at
atRevis still kept, so #18274's behaviour is unchanged.Compaction hash values do not change
keyIndex.keep()feedsnewKVHasherand is required to stay consistent across versions. I enumerated every put/tombstone sequence of length 4 over a small main/sub revision space and comparedkeep()andcompact()'savailablefor everyatRev, against both the pre-#18274 algorithm and currentmain(5285 cases):keep()diffscompact()available diffsmainkeep()is byte-identical to pre-*: keep tombstone if revision == compactAtRev #18274 in every case, soHashByRevis restored to exactly the value older versions produce. The 126 differences against currentmainare precisely the affected shape, wheremainhas already deleted live data.compact()differences against pre-*: keep tombstone if revision == compactAtRev #18274 are *: keep tombstone if revision == compactAtRev #18274's own intentional change (keeping the tombstone at the compaction revision), already compensated for inhash.go; this PR does not add to them.availablechanges hasMain == atRev(asserted in the same sweep). The compaction hasher only gates membership for revisions withMain <= prevCompactRev < compactMainRev, so it never consults the entries that changed.Empirically, compaction hashes over workloads that do not contain the affected shape — including #18274's own tombstone-at-compaction-revision shape and repeated delete/re-create across revisions — are bit-identical before and after this change (22 (workload, revision) pairs compared).
There are 5 shapes in the sweep where the result differs from both prior versions, all the same one: delete, re-create and delete again inside one revision. There the fix keeps the revision's final tombstone; current
mainkeeps a superseded earlier one and pre-#18274 kept neither. That is what #18274 intended, and it is not a data-loss case.Tests
TestKeyIndexCompactAndKeepOnRecreatedRev— the generation selection, assertingcompact()andkeep()separately, with the *: keep tombstone if revision == compactAtRev #18274 tombstone case as an explicit regression guard.TestStoreCompactRecreatedKeyInSameRevision— store level over a real backend:DeleteRange+Putin one write txn, compact at that revision, range the keys, then re-open the store over the same backend to prove the index still rebuilds.TestReproduce22376— e2e, via the public client API.All three fail on
mainand pass with the fix.make verify-lint,go test -race ./storage/...,tests/common(integration), and the integration compaction/corruption/hash/watch suites are green.Backports
The regressing commit is on every supported branch (
release-3.5from v3.5.16,release-3.6,release-3.7;release-3.4is EOL). I confirmed the defect reproduces onorigin/release-3.4andorigin/release-3.5with an equivalent unit reproducer. Happy to open backport PRs for 3.5, 3.6 and 3.7 with their CHANGELOG entries once this is reviewed.Note on reachability
checkIntervalsinserver/etcdserver/api/v3rpc/key.gois meant to reject a txn that deletes and puts the same key, but it builds the delete's interval as[key, range_end). Forrange_end == "\x00"—clientv3.WithFromKey(), the idiomatic "delete to the end of the keyspace" — that interval is empty and intersects nothing, so the overlapping puts are accepted. Details and a measured table of which forms are caught are in #22376. Whether to tighten that check is a separate question; the mvcc layer needs to be correct either way, since the apply path does not validate and the same entry applies unchecked on followers and on WAL replay.