Repository navigation
Description
Spun out of the #6127 item 3 re-triage of Issue5279ConcurrentUpdateTest.growingUpdatesUnderContentionKeepTheDatabaseConsistent. That test was being written off as a chronic flake; it is not flaky, and it is not a hole in the slot-merge logic either. It is the engine hitting a regime the merge does not cover at all.
What the measurement showed
The test runs 8 threads over 40 records of a single-bucket type, each round rewriting its own records with a longer payload (400 * (round + 1) chars). Instrumenting every poison site in LocalBucket.updateRecordInternal plus the commit-time outcomes in TransactionContext.commit1stPhase, one failing run gives:
| counter | value |
|---|---|
slot rebases performed (isRebasableSlotPage taken) |
146 |
records spilled out of their page (writeMultiPageRecord / placeholder branch) |
30 |
ConcurrentModificationException reaching the retry loop |
16 |
| threads that ran out of the default 3 retries | 5 of 8 |
While the payloads still fit the shared 64 KB page, the disjoint-slot merge absorbs everything - 146 successful rebases, zero conflicts. From round ~4 on, 40 * 3200 bytes no longer fit, every record becomes a multi-page chunk record, and from then on every update takes the FIRST_CHUNK branch, which poisons the page unconditionally. Eight threads then contend on one page where nothing is mergeable: one commit wins per round and the other seven get a CME. With TX_RETRIES at 3 that is not enough budget, and threads give up.
With a larger budget every run converges, CHECK DATABASE reports zero errors and every record holds exactly its last written value - so nothing is corrupt. The retry budget is the symptom; the unmergeable shape is the cause.
The gap
TX_PAGE_SLOT_MERGE covers, on a bucket page: an insert into a free slot, an in-place overwrite of the same size or smaller, an in-page growth (growRecordInPage, #5279), and the delete of a plain in-place record (#5569). It covers none of the shapes a record takes once it outgrows its page:
- a slot holding a
FIRST_CHUNKheader whose continuation chunks live on other pages; - a slot holding a
RECORD_PLACEHOLDER_POINTER, when the pointed-to content record has to be rebuilt rather than updated in place; - a
RECORD_PLACEHOLDER_CONTENTrecord growing on its own page (excluded by the!isPlaceHolderguard in both growth branches).
Every one of those poisons its page, so on a bucket where records have grown past the page size, concurrent updates of unrelated records conflict for good - which is precisely the false conflict #5279 set out to remove, reappearing one size class up.
Worth investigating
The three shapes are not equally hard, and they should be judged separately rather than as one feature:
- Placeholder content growth looks closest to already-solved. It is a single slot on its own page and
growRecordInPagealready knows the placeholder marker (isPlaceHolder); what is missing is carrying that flag throughSlotRebaseBuffersorebaseRecordOnPageaccepts ars[0] < RECORD_PLACEHOLDER_CONTENTpre-image instead of bailing onrs[0] <= 0. The pointer on the owner page is untouched by an in-place growth, so the RID is stable. - Placeholder pointer rewrite is a fixed-size 8-byte slot write, but it is paired with a delete + create on another page, so the merge would have to reason about more than one page atomically.
- Chunk chains are the hardest: one logical write spans several pages and every one of them would have to rebase for the commit to survive, so the failure probability compounds rather than falls.
(1) alone may be enough to move the needle, and it is the one with a self-contained proof obligation. It is worth measuring what fraction of the conflicts in a realistic large-record workload each shape accounts for before committing to (2) or (3).
Whatever is decided, the payoff is concrete: Issue5279ConcurrentUpdateTest.growingUpdatesUnderContentionKeepTheDatabaseConsistent should pass at the TX_RETRIES default instead of needing an explicit budget, and its sibling growingUpdatesThatStayInsideTheirPageNeverConflict (added in the #6127 PR) should extend to payloads that outgrow the page with the conflict count still asserted at zero.
Not a regression
Nothing here is new behaviour; the shapes have never been rebasable. What is new is knowing that this - and not a race in growRecordInPage - is what the test has been reporting.
Activity
fix(#6127): RESTORE applies the type's schema and fires the create ev…
review(#6129): stop the chain-fingerprint catch from hiding a defect,…
review(#6129): declare the slot-kind constants at the top of the class
review(#6129): bind the spill replay's marker budget to the marker it…
review(#6129): test the shape-change guards, and state what the finge…
perf(#6129): the chain probe reuses the page its caller just pinned
perf(#6129): allocate the slot-kind map only for a page that has a sh…
test(#6129): pin the concurrent chain-length change, and note two thi…
test(#6129): pin the tail guarantee under REPEATABLE_READ, where the …
fix(#6129): the slot merge now covers the records that outgrew their …
lvca commentedon Aug 13, 2026
Closed by #6140 (
c028959092).What the measurement said, since this issue asked for it before committing to any of the three shapes. On the failing workload, 280 of 320 commits conflicted once the records had spilled - and every one of those conflicts was on a single page, the one holding the records' head chunks. The continuation-chunk pages never conflicted at all, because a record update is deferred:
addUpdatedRecordpins the record's own page atsave()time and the write runs incommit1stPhase, after the file's commit lock, so every other page it needs is loaded at the newest committed version. The same run now reports zero conflicts.That reordered the three shapes this issue listed:
One thing the issue did not anticipate. Every replay is gated on a byte-for-byte pre-image check, and for a multi-page record that check can only see the head chunk. A concurrent transaction that rewrote the record only past that chunk would have been replayed over and silently dropped - and, per the above, no page-version check could have caught it either.
LocalBucket.chunkChainTailFingerprintcloses it: a 64-bit FNV-1a of the chain past the head, taken when the record is taken for update and again at commit. Forcing it to pass turns exactly one test red, by committing.Issue5279ConcurrentUpdateTest.growingUpdatesUnderContentionKeepTheDatabaseConsistentnow runs at theTX_RETRIESdefault instead of the explicit budget of 50, which was the payoff this issue named.Spawned #6141: a concurrent update of the same placeholder-backed record is silently lost today, with no conflict raised at all - pre-existing, same root cause, untouched by this change.
fix(#6149): a spilling record asks the page for the chunk header befo…
fix(#6141, #6175, #6178): the three record-shape defects the #6129 me…
test(#6217): the chunked-merge tests count commit conflicts, and say …
fix(#6217): a chunked read validates the record it consumed, not the …