Repository navigation
fix: edge-append merge reverted concurrent writes on multi-page edge chunks - #5302
Conversation
…chunks The commutative edge-append merge (GRAPH_EDGE_APPEND_MERGE, since 26.7.2) resolved a commit-time page conflict by re-deriving the conflicted page from the current committed version and replaying the transaction's tracked appends. For an edge chunk STORED AS A MULTI-PAGE RECORD (a chunk allocated near the end of a page continues on the next page) the re-derivation was unsound: the record's continuation slice had been drain-written through a page copy created only at drain time, which passes the MVCC version check while carrying the transaction's stale view of those bytes. The rebase then re-read the chunk THROUGH that stale in-transaction copy and committed it, silently reverting concurrently committed appends on the continuation page: zeroed chunk tails on young chunks, shifted/aliased pairs on filled ones, lost edges, and BufferUnderflowException on later traversals of the vertex. Only a record living entirely in place on the conflicted page can be safely re-derived: multi-page and indirected (placeholder) chunk records now fall back to the standard full-transaction retry. Single-page chunks - the vast majority, including all super-node stripe chunks - keep the merge. The regression test forces boundary-straddling chunks with a 16 KB bucket page: pre-fix it fails with zeroed bytes inside the used range on every run; post-fix the raw pair stream is exact across 12k concurrent inserts with thousands of rebases firing.
|
Tick the box to add this pull request to the merge queue (same as
|
There was a problem hiding this comment.
Code Review
This pull request addresses issue #565 by preventing edge-append rebasing on records that span multiple pages, falling back to a full transaction retry instead. It introduces isRecordStoredInSinglePage in LocalBucket to identify these multi-page records and adds several robust concurrent stress and race tests to verify the fix. The review feedback suggests adding defensive checks for null or invalid RIDs in the new method and preserving the original exception cause when wrapping IOException in DatabaseOperationException.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| public boolean isRecordStoredInSinglePage(final RID rid) { | ||
| final int pageId = (int) (rid.getPosition() / maxRecordsInPage); | ||
| final int positionInPage = (int) (rid.getPosition() % maxRecordsInPage); |
There was a problem hiding this comment.
To prevent potential NullPointerException or IndexOutOfBoundsException (due to negative index calculation when reading from the page buffer), we should add defensive checks to ensure the rid is non-null and has a valid non-negative position.
| public boolean isRecordStoredInSinglePage(final RID rid) { | |
| final int pageId = (int) (rid.getPosition() / maxRecordsInPage); | |
| final int positionInPage = (int) (rid.getPosition() % maxRecordsInPage); | |
| public boolean isRecordStoredInSinglePage(final RID rid) { | |
| if (rid == null || rid.getPosition() < 0) | |
| return false; | |
| final int pageId = (int) (rid.getPosition() / maxRecordsInPage); | |
| final int positionInPage = (int) (rid.getPosition() % maxRecordsInPage); |
| } catch (final IOException e) { | ||
| throw new DatabaseOperationException("Error on checking record layout for " + rid); | ||
| } |
There was a problem hiding this comment.
When catching IOException and wrapping it in a DatabaseOperationException, please pass the original exception e as the cause. This preserves the stack trace and makes troubleshooting underlying I/O or page-reading issues much easier.
| } catch (final IOException e) { | |
| throw new DatabaseOperationException("Error on checking record layout for " + rid); | |
| } | |
| } catch (final IOException e) { | |
| throw new DatabaseOperationException("Error on checking record layout for " + rid, e); | |
| } |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 9 |
🟢 Coverage 76.19% diff coverage · -6.93% coverage variation
Metric Results Coverage variation ✅ -6.93% coverage variation Diff coverage ✅ 76.19% diff coverage Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (ce50b05) 140251 104470 74.49% Head commit (996a46c) 172161 (+31910) 116307 (+11837) 67.56% (-6.93%) Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch:
<coverage of head commit> - <coverage of common ancestor commit>Diff coverage details
Coverable lines Covered lines Diff coverage Pull request (#5302) 21 16 76.19% Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified:
<covered lines added or modified>/<coverable lines added or modified> * 100%
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
|
Review: edge-append merge on multi-page edge chunks (PR 5302) Thanks for the very clear write-up. I traced the fix against TransactionContext.rebaseEdgeAppends, the LocalBucket record-layout markers, and the sibling tests. Overall this is a correct, well-scoped, and conservative fix with strong regression coverage. A few points below, one of which is a repo-convention issue worth addressing before merge. Correctness (looks good)
Convention issue: tests should be @tag("slow") The four new tests each drive thousands of concurrent transactions (e.g. EdgeAppendMergeMultiPageChunkTest = 8x1500, SuperNodeDefaultThresholdTest = 12k + 20k edges with two reopens). Per CLAUDE.md, functional regression tests that take noticeably long should carry @tag("slow") so CI can skip them from the fast build. The directly analogous sibling ConcurrentEdgeAppendMergeTest is already annotated @tag("slow") at the class level - these new tests should follow suit (import org.junit.jupiter.api.Tag;). Minor
Test coverage (strong) Good that each test asserts edgeAppendMerges > 0 so a green run cannot pass vacuously, and that EdgeAppendMergeMultiPageChunkTest verifies the raw pair stream byte-exact (no zeroed bytes, no dupes, no alien pairs, exact count) rather than just the aggregate count - that directly targets the reported corruption shape. The full-retry fallback path is exercised implicitly (multi-page chunks fall back while single-page ones rebase) and the end state is verified, which is what matters. Nice fix. The @tag("slow") addition is the one thing I would want before merge. |
… test conventions - isRecordStoredInSinglePage: guard against null/invalid RIDs and preserve the IOException cause when wrapping in DatabaseOperationException - extract readRecordSizeMarker as the single place decoding a record slot's size marker, shared by existsRecord and isRecordStoredInSinglePage so the two stay in lockstep if the page layout ever changes - tag the four new tests @tag("slow") per the project convention and drop the debug System.out left from the investigation
|
Addressed the review feedback in 996a46c:
Re-ran the four new tests plus the full |
Code Review: edge-append merge on multi-page chunks (#5302)Reviewed the fix, the refactor, and the four new tests. This is a well-diagnosed, well-documented, correctness-over-throughput fix. The core reasoning is sound and I'd be comfortable merging it. Notes below. Correctness ✅
Refactor (
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5302 +/- ##
============================================
+ Coverage 65.58% 65.60% +0.02%
+ Complexity 972 969 -3
============================================
Files 1714 1714
Lines 140251 140260 +9
Branches 30023 30025 +2
============================================
+ Hits 91986 92022 +36
+ Misses 35822 35768 -54
- Partials 12443 12470 +27 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…chunks (#5302) * fix: edge-append merge reverted concurrent writes on multi-page edge chunks The commutative edge-append merge (GRAPH_EDGE_APPEND_MERGE, since 26.7.2) resolved a commit-time page conflict by re-deriving the conflicted page from the current committed version and replaying the transaction's tracked appends. For an edge chunk STORED AS A MULTI-PAGE RECORD (a chunk allocated near the end of a page continues on the next page) the re-derivation was unsound: the record's continuation slice had been drain-written through a page copy created only at drain time, which passes the MVCC version check while carrying the transaction's stale view of those bytes. The rebase then re-read the chunk THROUGH that stale in-transaction copy and committed it, silently reverting concurrently committed appends on the continuation page: zeroed chunk tails on young chunks, shifted/aliased pairs on filled ones, lost edges, and BufferUnderflowException on later traversals of the vertex. Only a record living entirely in place on the conflicted page can be safely re-derived: multi-page and indirected (placeholder) chunk records now fall back to the standard full-transaction retry. Single-page chunks - the vast majority, including all super-node stripe chunks - keep the merge. The regression test forces boundary-straddling chunks with a 16 KB bucket page: pre-fix it fails with zeroed bytes inside the used range on every run; post-fix the raw pair stream is exact across 12k concurrent inserts with thousands of rebases firing. * review: defensive RID guard, exception cause, shared marker decoding, test conventions - isRecordStoredInSinglePage: guard against null/invalid RIDs and preserve the IOException cause when wrapping in DatabaseOperationException - extract readRecordSizeMarker as the single place decoding a record slot's size marker, shared by existsRecord and isRecordStoredInSinglePage so the two stay in lockstep if the page layout ever changes - tag the four new tests @tag("slow") per the project convention and drop the debug System.out left from the investigation --------- Co-authored-by: Leonardo Page <l.page@arcadedata.com>
…chunks (#5302) * fix: edge-append merge reverted concurrent writes on multi-page edge chunks The commutative edge-append merge (GRAPH_EDGE_APPEND_MERGE, since 26.7.2) resolved a commit-time page conflict by re-deriving the conflicted page from the current committed version and replaying the transaction's tracked appends. For an edge chunk STORED AS A MULTI-PAGE RECORD (a chunk allocated near the end of a page continues on the next page) the re-derivation was unsound: the record's continuation slice had been drain-written through a page copy created only at drain time, which passes the MVCC version check while carrying the transaction's stale view of those bytes. The rebase then re-read the chunk THROUGH that stale in-transaction copy and committed it, silently reverting concurrently committed appends on the continuation page: zeroed chunk tails on young chunks, shifted/aliased pairs on filled ones, lost edges, and BufferUnderflowException on later traversals of the vertex. Only a record living entirely in place on the conflicted page can be safely re-derived: multi-page and indirected (placeholder) chunk records now fall back to the standard full-transaction retry. Single-page chunks - the vast majority, including all super-node stripe chunks - keep the merge. The regression test forces boundary-straddling chunks with a 16 KB bucket page: pre-fix it fails with zeroed bytes inside the used range on every run; post-fix the raw pair stream is exact across 12k concurrent inserts with thousands of rebases firing. * review: defensive RID guard, exception cause, shared marker decoding, test conventions - isRecordStoredInSinglePage: guard against null/invalid RIDs and preserve the IOException cause when wrapping in DatabaseOperationException - extract readRecordSizeMarker as the single place decoding a record slot's size marker, shared by existsRecord and isRecordStoredInSinglePage so the two stay in lockstep if the page layout ever changes - tag the four new tests @tag("slow") per the project convention and drop the debug System.out left from the investigation --------- Co-authored-by: Leonardo Page <l.page@arcadedata.com> (cherry picked from commit d73789d)
Fixes a silent data-corruption bug in the commutative edge-append merge (
GRAPH_EDGE_APPEND_MERGE, on by default since 26.7.2), found while investigating a customer-reportedBufferUnderflowExceptionon a high-degree vertex. Internal tracking: https://github.com/ArcadeData/arcadedb-operations/issues/565The bug
When concurrent transactions append edges to the same vertex, a commit-time page conflict on the edge chunk is resolved by re-deriving the page from the current committed version and replaying the transaction's appends, instead of retrying the whole transaction.
That re-derivation is unsound when the chunk is stored as a multi-page record (a chunk allocated near the end of a page continues on the next page,
FIRST_CHUNK/NEXT_CHUNK):invalidLinks/corruptedRecordsinCHECK DATABASE, andBufferUnderflowExceptionon later traversals (a shifted region can end mid-varint at theusedboundary).Only chunks that straddle a page boundary are exposed (~1/8 of cap-size chunks at the default 64 KB page), which is why this only shows up at scale, on hot vertices, under concurrent insertion.
The fix
A chunk stored as a multi-page or indirected (placeholder) record is not rebasable:
rebaseEdgeAppendsnow checks the record's layout marker on the current committed page (LocalBucket.isRecordStoredInSinglePage) and falls back to the standard retryableConcurrentModificationException. Single-page chunks - the vast majority, including all super-node stripe chunks - keep the merge.Tests
EdgeAppendMergeMultiPageChunkTest(regression): a 16 KB bucket page makes cap-size chunks straddle a boundary every other chunk; 8 threads x 1500 concurrent inserts on one hub, then the hub's raw pair stream is verified byte-exact (no zeroed bytes, no duplicates, no alien pairs, exact count) with the rebase asserted to have actually fired. Fails on every run without the fix; passes with it.EdgeAppendMergeConcurrentStressTest,EdgeAppendMergeRaceTest: concurrent add/remove/read races on the classic layout with the merge active.SuperNodeDefaultThresholdTest: super-node promotion at the shipped default threshold (4096), which promotes through the bounded chain-walk branch no test covered before.Also verified at customer scale during the investigation: a 300k-edge hub with 8 concurrent writers and ~12k rebases fired ends with a byte-exact pair stream, and the full graph/database/engine test packages pass.
Please review before merging.