backend: dedupe the write buffer on every path into an existing read bucket - #22298
backend: dedupe the write buffer on every path into an existing read bucket#22298martin-k-m wants to merge 1 commit into
Conversation
txWriteBuffer.writeback handles a non-sequential bucket in two different ways. When the bucket is missing from the read buffer it calls dedupe(), so repeated writes to the same key inside one transaction collapse to the newest value. When the bucket is already present it only sorts, on the assumption that there are no duplicate keys, and leaves deduplication to bucketBuffer.merge. merge only dedupes when the two buffers overlap: if the read buffer's largest key sorts below the write buffer's smallest key it returns early. A duplicate key in the write buffer then survives into the read buffer, and because bucketBuffer.Range binary searches for the first matching key it returns the older of the two values, not the newest. UnsafeForEach and a ranged UnsafeRange emit both entries. No caller writes the same key twice within one lock section today, so this is not reachable from etcd itself, but the invariant is one caller away from being broken and the failure mode is a silent stale read. Call dedupe() in both branches instead. dedupe sorts internally, so the explicit sort is no longer needed. writeback and merge had no direct test coverage; add one that pins both the overlapping and non-overlapping cases. Signed-off-by: Martin Muskov <martinkmuskov@gmail.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: martin-k-m 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 @martin-k-m. 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. |
Framing first, so nobody reads more into this than is there: this is hardening
plus test coverage, not a reported bug. The invariant
writebackmaintains isgenuinely violated on one path, and I could not construct a trigger for it from
etcd's own callers on
main. The reachability section below says exactly howfar I got and where the margin is thin.
writebackandmergecurrently haveno tests at all, and that part of the argument stands on its own.
Problem
txWriteBuffer.writebackhandles a non-sequential bucket two different ways depending on whetherthe read buffer already has that bucket:
The second branch relies on
bucketBuffer.mergeto deduplicate, butmergehas an early return:That early return is correct for its own purpose, which is avoiding a needless dedupe when the
two buffers do not overlap in key range. It is not a substitute for the dedupe the write buffer
still needed on its own. So when the read buffer's largest key for that bucket sorts strictly
below the write buffer's smallest key, a key written twice inside one lock section survives into
the read buffer as two entries.
The consequence is worse than an extra entry.
bucketBuffer.Rangebinary searches for the firstkey greater than or equal to the target, so it returns the older of the two values.
dedupe()keeps the newest (sort.Stablethen keep-last-of-run); skipping it makes the readkeep the oldest.
UnsafeForEachvisits both, stale value first, and a rangedUnsafeRangewitha limit above 1 returns both.
There is a second, smaller problem in the same line:
sort.Sortis not stable, so with duplicatekeys present the surviving order is not even defined.
Fix
Call
dedupe()in both branches.dedupesorts internally, so the explicitsort.Sortis nolonger needed.
The
wb.used > 1guard is dropped becausededupe()already returns immediately whenused <= 1.Sequential buckets are unaffected. The Key bucket, which holds the MVCC revision keys, is marked
sequential and its keys are strictly increasing by construction.
Honesty about reachability
I could not construct a trigger from etcd itself on
main, and I do not want to overstate this.Triggering requires both:
UnsafePutinside oneLock()...Unlock()section, andbuffer's minimum key.
Every non-test
UnsafePutcall site underserver/was checked (storage/mvcc/store.goandstorage/schema/{actions,alarm,auth,auth_roles,auth_users,cindex,confstate,lease,membership,version}.go).Each writes a given key at most once per lock section, so condition 1 does not hold today.
The near miss is worth stating because it shows how thin the margin is.
schema/cindex.gowritesconsistent_indexandterminto the Meta bucket on every apply, via thetxPostLockInsideApplyHook, and repeated applies inside one batch interval do reach therb.merge(wb)branch. That path is safe only because"term"sorts after"consistent_index",so the overlap check fires and
dedupe()runs. It is safe by key ordering, not by design.So: a latent invariant violation, one caller away from being live, whose failure mode is a silent
stale read rather than a crash. Reviewers may reasonably prefer this framed as hardening plus
test coverage rather than as a bug fix. The test coverage argument stands on its own:
tx_buffer_test.gocurrently exercisesdedupeandCopyUsedin isolation and has no test forwritebackormergeat all.Test
TestWritebackDedupesExistingBucketdriveswritebackdirectly with a write buffer holdingzzz=oldthenzzz=new, against a read buffer already holding one key. The two subcases differonly in that seeded key:
"aaa"sorts belowzzzsomergetakes the no-overlap fast path,"zzzz"sorts above so it does not. Both must yield a single entry with valuenew.Having both subcases is the point. It isolates the trigger to
merge's fast path rather thanjust asserting an outcome.
Test evidence
Run on Linux,
golang:1.26container.Without the fix:
Note the overlapping subcase passes without the fix. That is the control.
With the fix:
Package under the race detector:
Full
servermodule unit suite on Linux: no failures.Lint:
Performance note to expect in review
dedupe()sorts withsort.Stablewhere the old path usedsort.Sort. Stable sort is slower andallocates. This runs once per
writebackper non-sequential bucket, and non-sequential bucketsare the small metadata ones, not the Key bucket. I do not expect it to be measurable, but if a
reviewer pushes back, the alternative is to keep
sort.Stableonly when a duplicate is actuallypossible, which would need
bucket2seqto carry more information than it does now.Checklist
merge's fast pathmake verify-lintcleanservermodule unit tests passreport for something with no demonstrated live trigger would overstate it.
mainThis PR was written in part with the assistance of generative AI. Every change was reviewed, built and tested before submitting, and no AI co-author or
assisted-bytrailers are used, per https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#ai-guidance