Repository navigation
Description
SEVERITY: MEDIUM
Summary
A record that shrinks out of its chunk chain has the leftover chunks freed by freeChunkChain. The loop
zeroes the record-table entry of each chunk and only afterwards reads the slot back to confirm it was a NEXT_CHUNK. When it was not, the slot has already been freed and the method logs a WARNING and stops.
The same two conditions, on a walk of the same chain, are treated as a concurrent modification by updateMultiPageRecord: it checks BEFORE writing and throws ConcurrentModificationException so the
transaction retries. So one walker calls the state "retry, somebody changed the chain under me" and the
other calls it "broken chain, carry on", and the second one has already written by the time it decides.
The broken invariant is the one the method's own javadoc states: "Never throws on a chain it cannot walk,
a broken pointer is logged and the walk stops there ... so the chunks left behind are unreachable rather
than dangerous". The chunks left behind are indeed harmless. The slot the walk landed on is not: it was
freed before it was identified.
Consequence when the slot has been reused rather than merely freed: the record occupying it is unlinked
from its page. It disappears from scan(), from count() and from lookupByRID, in a transaction that
commits normally, with a WARNING naming a different RID. Nothing in the record's own bytes is touched, so CHECK DATABASE sees a consistent bucket afterwards.
Where (HEAD 0d9d8ce)
- engine/src/main/java/com/arcadedb/engine/LocalBucket.java:7218
freeChunkChain, the loop at 7222- 7234 the slot-table entry is zeroed
- 7237 to 7241 only now is
recordPositionInPage == 0noticed (slot already free) - 7243 to 7249 only now is
recordSize[0] != NEXT_CHUNKnoticed (slot holds something that is not a
continuation chunk of this chain)
- the sibling walk, which checks the identical two conditions before any write and raises
ConcurrentModificationExceptionon each:
engine/src/main/java/com/arcadedb/engine/LocalBucket.java:6963 to 6976 (messages at 6968 and 6975) - callers: LocalBucket.java:7102 (
updateMultiPageRecord) and LocalBucket.java:7189
(collapseChunkChainToRecord)
Repro
What I executed, on an unmodified build of HEAD plus one System.out.println added at each of the two
warning sites purely to print which condition fired and where (the printlns were removed and the class
rebuilt before the fault-injection run below; the worktree is clean at HEAD).
Probe: one document type in one bucket, 16 KB bucket pages, 25 to 40 records, 6 threads doing
read-modify-write with retry on NeedRetryException, each write re-sizing the record randomly across the
in-page / placeholder / chunk-chain boundaries, plus 4 threads inserting and deleting records in the same
bucket so freed slots are recycled. Final check: every record's counter equals the number of commits that
incremented it, the content is not torn, and CHECK DATABASE is clean.
Result, five seeds, 6 threads x 700 updates each:
seed 99: 8 x "freeChunkChain reached a slot that is not a chunk of this chain" (slot already free)
seed 1234: 6 x
seed 777: 9 x
seed 4242: 12 x
seed 31337: 9 x
records=40 lostOrTorn=0 unexpectedFailures=0
CHECK totalErrors=0 warnings=[] corruptedRecords=[] danglingPlaceholderPointers=0
orphanedChunks=0 crossLinkedChunks=0 staleRecordCounters=0
The engine's own line, verbatim, on the unmodified build:
WARNI [LocalBucket] Error on deleting extra part of multi-page record #1:39 after it shrunk
Sample of the instrumented detail, showing the pointer and the target slot:
PROBE freeChunkChain SLOT-ALREADY-FREE rid=#1:39 ptr=1777664 page=868 slot=0
PROBE freeChunkChain SLOT-ALREADY-FREE rid=#1:39 ptr=1777664 page=868 slot=0
PROBE freeChunkChain SLOT-ALREADY-FREE rid=#1:1 ptr=1689600 page=825 slot=0
PROBE freeChunkChain SLOT-ALREADY-FREE rid=#1:1 ptr=1689600 page=825 slot=0
The same pointer reaching the same already-free slot twice says the pointer the walk follows is not a
transient in-flight value: it is what the committed chain head carries.
The identical probe run SINGLE-THREADED (3300 records, 1500 rounds of mixed update/insert/delete, every
record verified byte for byte and CHECK DATABASE clean) never reaches either condition. So this is a
time-of-check / time-of-use window between the read of the chain and the free of its tail, not a
pre-existing corruption.
What I did NOT reproduce: the destructive variant, where the slot the walk lands on has been reused by a
concurrent insert instead of being left free. In my runs the recycled chunk pages were never re-claimed
inside the window. The branch that would then run is LocalBucket.java:7234 followed by 7245, i.e. the
record-table entry is zeroed and the mismatch noticed afterwards.
Refutation attempted
-
Is the state unreachable because the reader validates the chain first? The reader is strict, and that
is why a chain that is ALREADY dangling cannot get here. Fault injection: I repointed the chain tail of
a 48 KB record at an unrelated live record's slot (a probe in packagecom.arcadedb.enginethat
rewrites the next-chunk pointer throughgetPageToModify), then tried to update the record. The read
refuses before the update runs:BIG head nextChunkPointer=2048 chunk 1 at 1:0 marker=-3 next=4096 ... chunk 6 at 6:0 marker=-3 next=0 injecting victim position 12289 as the chain's tail pointer VICTIM before shrink: tag=VICTIM com.arcadedb.exception.BrokenChunkChainException: Multi-page record #1:0 has a broken chunk chain (unexpected marker at chunk 6): the record is corrupted and cannot be read. at com.arcadedb.engine.LocalBucket.loadMultiPageRecord(LocalBucket.java:5980)That closes the "already corrupt on disk" route and leaves exactly the window the probe above shows is
entered: between the read that validated the chain and the free that walks it again. The engine itself
agrees that window exists, becauseupdateMultiPageRecordguards the same two conditions on the same
chain in the same method call, and calls them a concurrent modification. -
Is it deliberate? The javadoc at LocalBucket.java:7206 to 7216 argues the case for not throwing, and
its argument is about the chunks LEFT BEHIND, which is sound. It says nothing about the slot the walk
is standing on, and the write at 7234 is not mentioned. The two branches also share one message string
that describes neither condition, so an operator cannot tell them apart. -
Does the page-version check save it? No.
getPageToModify(..., false)fetches the newest committed
image of the chunk page, which this transaction pinned nothing on (the same property A concurrent update of the same placeholder-backed record is silently lost, with no conflict raised #6141 is about).
A record inserted into that slot by a transaction that committed before this fetch is in the image
this write modifies, so the commit-time version check passes and the zeroing is published. -
Does CHECK DATABASE catch the outcome? No. The victim's bytes are untouched and its slot reads as a
plain delete, which is exactly what a delete leaves.CHECK DATABASEin my runs reported
totalErrors=0with the warnings firing.
Suggested fix
Move the write. Everything needed to validate is already read before it:
- read
recordSizeand requirerecordPositionInPage != 0andrecordSize[0] == NEXT_CHUNKFIRST, and
only then zero the slot-table entry and callfreedWithoutClaiming; - give the two refusals distinct messages, and make them say which slot was refused.
Whether the refusal should become the ConcurrentModificationException the sibling raises is a separate
call: throwing would make the transaction retry, which is correct but changes behaviour for a chain that
really is corrupt. Leaving it as a WARNING is defensible once nothing has been written.
The guarantee to ask for: no slot-table entry is ever cleared that has not been proven, from the page
itself, to be a continuation chunk of the chain being freed. It is a reordering of four lines, no storage
format change and no new state.
Scope / confidence
Executed: the concurrent probe (5 seeds, both conditions instrumented), the single-threaded control, and
the fault-injection run that establishes the reader is strict. Read, not executed: the reuse of a freed
chunk slot by a concurrent insert inside the window, which is the step from "a slot that is already free"
to "a slot that holds somebody else's record". The ordering defect itself is in the code unconditionally.
Dedup
Checked #10701 (chunk chain linked to slot 0 of page 0, the 0 end-of-chain sentinel, OPEN), #10853
(encode next-chunk links as -(position+1), OPEN), #10859 and #10669/#10700 (small slot on a sealed page),
#9620 (REPEATABLE_READ refusing the first read of a multi-page record). None of them is about the write
order inside freeChunkChain or about the asymmetry with updateMultiPageRecord's guard. Nothing in the
last 1000 titles matches "freeChunkChain", "shrunk" or "extra part".
Activity
fix(#10875): freeChunkChain proves a slot holds a continuation chunk …
robfrank commentedon Oct 29, 2026
Fixed in #10907.
freeChunkChaincleared a slot-table entry before checking that the slot really held a continuation chunk, so a slot reused by a concurrent insert could lose its record. The checks now run before anything is written, each refusal logs its own distinct warning, and the walk still stops with a warning rather than throwing.Still open: no follow-up issue filed. A continuation marker proves a slot is a chunk but not that it belongs to this chain (chunks carry no owner); closing that needs a storage format change, and
updateMultiPageRecordhas the same limit.❤️ Thanks for helping make ArcadeDB better! ArcadeDB is free and open source, sustained by its community. If it's useful to you or your company, please consider becoming a sponsor to keep development going.
ruispereira commentedon Oct 30, 2026
Re-validated at main 21d2a0e. COMPLETE.
The two checks moved above the write and
poisonSlotRebasePagemoved with them, so a refused slot has nothing written on it. The worry we raised in our own report does not apply: in the loop body onegetPageToModifyproduces oneMutablePage, and the slot read, the marker read and the zeroing all run against that one transaction-local image with no refetch between them. Census overengine/src/main/java: two callers,LocalBucket.java:7258and:7345, both through the fixed method, and the third route, the multi-page DELETE walk at:5102-5165, already checked before writing. No sibling.Concurrency re-run at HEAD, 6 updaters by 700 resize rounds plus 4 churn threads, 16 KB pages, 40 records, 3 seeds:
The window is still entered, 10 to 13 times per run over roughly 7000 commits, which is the point: the race is unchanged and only its consequence is gone. That is the right fix for this issue.
One thing worth posting. The class ships two regression tests and has one.
aPointerReachingAFreeSlotDoesNotFailTheTransactionpasses with the production change reverted: it exercises the already-free branch, which was harmless before and after. The other test pins properly, failing withRecordNotFound Record #1:1 not found, which is the defect itself.fix(#10875): freeChunkChain proves a slot holds a continuation chunk …