Repository navigation
Description
Summary
TransactionContext.rebaseEdgeAppends resolves a commit-time page conflict by dropping this transaction's whole modified page and replaying only the tracked in-chunk appends on top of the reloaded committed image:
// Drop the stale copies so the reload observes the current committed version of the page.
modifiedPages.remove(pageId);
immutablePages.remove(pageId);That is sound only if every byte this transaction wrote to that page is accounted for by a tracked append. Nothing verifies it. The invariant is instead maintained by hand, through poisonEdgeAppendPage calls scattered across the writers - currently 9 call sites in LocalDatabase, EdgeLinkedList, StripedEdgeList and (since #5569) LocalBucket. Miss one, and the merge silently commits a page from which this transaction's other writes have vanished. No exception, no log line: the write is simply gone.
This has already bitten three times
- feat(#5381): disjoint-slot commit merge for page-level false conflicts #5385 review round 5:
writeMultiPageRecord/updateMultiPageRecordwroteNEXT_CHUNKrecords to reused pages through inline record-table writes that bypassedcreate/update/deleteRecordInternal, so they were neither tracked nor poisoned. Fixed by making both writers poison. - feat(#5381): disjoint-slot commit merge for page-level false conflicts #5385 review round 7: a type-3 in-place segment update left no trace in the slot map, and a co-located
StripeDirectorywrite made the page look rebasable. Fixed by poisoning on the non-candidate update. - Extend the disjoint-slot merge to plain record deletes (follow-up to #5279)Â #5569 (this week): a plain record delete on a page that also received a tracked append would have been dropped. Fixed by adding
poisonEdgeAppendPage(fileId, pageNumber)todeleteRecordInternal.
Three independent instances of one bug class, each found by a human or a reviewer reading the code rather than by a test. The fourth is a matter of time: the merge is opt-out, so a new writer is rebasable by default and has to remember to exclude itself.
Proposed change
Flip it to opt-in: before accepting a rebase, require the page to prove that its modified byte range is fully explained by the tracked appends.
MutablePage.getModifiedRange() already gives the [from, to) span this transaction dirtied, and the commit path reads it a few lines above the rebase decision. For each tracked segment on the page, the append knows the record's extent (slot-table entry -> record position, plus its on-page size) and the page-header fields it may touch. The union of those extents is the span the merge can legitimately reproduce.
In isRebasableEdgeAppendPage, accept the page only when its modified range is contained in that union. A delete elsewhere on the page, an untracked inline record-table write, a new chunk, a stripe-directory update - all of them widen the modified range past the appends' extents, so the check refuses and the transaction falls back to a normal retry. Conservative in exactly the right direction: the worst outcome of a false negative is a retry that would have happened anyway before the merge existed.
The existing poisonEdgeAppendPage calls stay as the fast, precise path; the containment check is the backstop that makes forgetting one non-fatal.
The same guard is nearly free on the slot merge
rebaseSlots has the identical shape and would benefit from the identical check, comparing the modified range against the union of the tracked slots' extents. It is better covered today - all three hooks live in the central LocalBucket.create/update/deleteRecordInternal, so a new writer has to go out of its way to bypass them - but the check costs one comparison on a path that only runs on a conflict, and it would have caught the multi-page-writer gap above without anyone noticing it by reading.
Tests
- The three historical bugs, as regression tests, with their poison call removed: each must now fall back cleanly instead of dropping the write.
Issue5381FalseConflictTest.multiPageRecordCoLocatedWithTrackedInsertStaysIntactandstripedDirectoryUpdateCoLocatedWithSegmentAppendFallsBackare the templates. - A synthetic writer that dirties a byte outside every tracked extent: the merge must refuse.
- The existing merge-counter assertions must still show the merge firing on the genuinely-clean workloads, so the containment check is not so tight that it refuses everything.
Why it is worth doing
Every bug in this class is a silent lost write on the leader, which then replicates faithfully to every follower - the failure mode with the worst blast radius and the least chance of being noticed. The check converts it into a retry.
Activity
fix(engine) #5596: the commit-time page merges prove their coverage i…
fix(engine) #5596: the commit-time page merges prove their coverage i…
fix(engine) #5596: count coverage-declined merges and pin the clean s…
fix(engine) #5596: the commit-time page merges prove their coverage i…
fix(engine) #5596: count coverage-declined merges and pin the clean s…
fix(engine) #5596: guard against a leaked declaration, and report a c…
fix(engine) #5596: document the decline counter's semantics, fix a da…
fix(engine) #5596: the commit-time page merges prove their coverage i…
lvca commentedon Jul 31, 2026
Fixed in #5599 (merged as 49206db) - but not with the byte-range containment check proposed here, so recording the divergence for future readers.
The proposal was to compare
MutablePage.getModifiedRange()against a union of tracked extents reconstructed from the slot table. That reconstruction has to model every layout the writers can produce: an in-page growth shifts the whole content tail and rewrites several slot-table entries, andcompressPagedefrags before the version check runs. For exactly those shapes the "allowed union" degenerates to almost the whole page, so the check stops discriminating precisely where the layout gets interesting.What shipped instead: the writer declares the extent at the write site.
MutablePage.beginCoveredWrite(mechanism)opens a window; every byte dirtied outside such a window permanently disqualifies the page for the mechanisms that did not declare it. Same opt-in inversion the issue asks for, but:LocalBucketdeclares exactly what each merge replays - the single-slot insert, the in-page update (overwrite and growth), the plain-record delete (#5569), the in-place rewrite behind a tracked edge append, andcompressPage(page, false), whose defrag the commit path re-runs on the rebased page. Everything else is undeclared and falls back to the ordinary retry: the multi-page writers' inline record-table writes, placeholders, a record spilling out of its page, aGraphBatchbulk load, and any writer added in future. The explicit poison calls stay as the fast, precise path.Two side effects worth noting:
GraphBatchcaveat ("safe only because a GraphBatch tx never also drivesEdgeLinkedList.addon the same page") is now structural rather than an implicit invariant, and the comment saying "if that ever changes, poison these pages" is gone.PageManager.Stats.mergesDeclinedByCoveragecounts commit attempts that failed on a coverage decline, so a writer that forgets a declaration shows up as a metric shift and a red test rather than only a FINE log.On the issue's test plan: the three historical bugs could not be replayed with their poison calls removed (the calls are load-bearing in the current tree, and #5594 landed mid-review adding another), so instead each merge is driven by the synthetic writer the issue also asks for - a transaction that dirties bytes belonging to no tracked write. Both were verified to fail with the coverage check disabled and are poison-independent, so they keep holding whatever poison calls exist. The clean-merge counter assertions the issue asks for are in the default (non-slow) build.
fix(engine) #5608: the merge counters reach the operator, and the com…
fix(engine) #5608: the merge counters reach the operator, and the com…
fix(engine) #5596: the commit-time page merges prove their coverage i…
fix(engine) #5608: the merge counters reach the operator, and the com…
fix(jdk17): replace List.getFirst()/getLast() with get(0)/get(size()-1)
fix(#6396): a write that describes bytes it did not write is now caug…