Skip to content

Slot merge does not cover records that outgrew their page: chunked and placeholder updates poison the page unconditionally #6129

Description

@lvca

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_CHUNK header 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_CONTENT record growing on its own page (excluded by the !isPlaceHolder guard 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:

  1. Placeholder content growth looks closest to already-solved. It is a single slot on its own page and growRecordInPage already knows the placeholder marker (isPlaceHolder); what is missing is carrying that flag through SlotRebaseBuffer so rebaseRecordOnPage accepts a rs[0] < RECORD_PLACEHOLDER_CONTENT pre-image instead of bailing on rs[0] <= 0. The pointer on the owner page is untouched by an in-place growth, so the RID is stable.
  2. 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.
  3. 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

  1. added 8 commits that reference this issue on Aug 13, 2026
  2. added this to the 26.9.1 milestone on Aug 13, 2026
  3. added 2 commits that reference this issue on Aug 13, 2026
  4. lvca commented on Aug 13, 2026

    @lvca
    MemberAuthor

    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: addUpdatedRecord pins the record's own page at save() time and the write runs in commit1stPhase, 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:

    • Chunk chains were called the hardest, on the reasoning that a write spanning several pages would need all of them to rebase. It doesn't: only the head chunk's page is ever held at a stale version, and its footprint is fixed when the record spills, so it is a single-slot write like any other. This turned out to be the whole cost.
    • The spill itself (plain record -> head chunk) was not on the list, and was worth as much as the rest: it is not a one-off cost per record, because a spill that loses the race rolls back and spills again on the retry, so contended records could starve rather than converge. Fixing that shape alone took the Is ArcadeDB ready for a real multi-user environment? The problem of ConcurrentModificationExceptions #5279 workload from 158 conflicts to 7.
    • Placeholder content growth, the one this issue expected to be tractable first, was indeed easy - and worth the least, since the content page is one of those loaded fresh under the lock and rarely conflicts.
    • Placeholder pointer rewrite stays unmergeable, as suggested: it changes two pages at once.

    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.chunkChainTailFingerprint closes 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.growingUpdatesUnderContentionKeepTheDatabaseConsistent now runs at the TX_RETRIES default 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.

  5. self-assigned this
    on Aug 13, 2026
  6. added theissue type on Aug 13, 2026
  7. changed the issue type fromtoon Aug 13, 2026
  8. added 4 commits that reference this issue on Aug 13, 2026
  9. added
    engineCore engine: document/schema API
    storageStorage/paging/WAL engine layer
    on Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugengineCore engine: document/schema APIstorageStorage/paging/WAL engine layer

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions