Skip to content

fix(engine) #5670: an unreadable edge-list chunk retries the delete instead of skipping it - #5678

Merged
lvca merged 8 commits into
mainfrom
fix/5670-edge-delete-dangling-backref
Aug 1, 2026
Merged

lvca merged 8 commits into
mainfrom
fix/5670-edge-delete-dangling-backref

Conversation

@lvca

@lvca lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member

Fixes #5670.

The issue is real, and it reproduces

The reported ConcurrentEdgeAppendMergeTest failure - expected: 3000L but was: 3001L with one
checkDatabaseIntegrity error alongside - is not a test-side race. A tightened version of the same
workload (8 threads x 60 delete+append iterations on a hub, 40 rounds) fails in ~2 of 3 runs with the
identical signature:

[round 8 in-degree] expected: 480L but was: 481L
  Suppressed: AssertionFailedError: expected: 0 but was: 1  (checkDatabaseIntegrity)

Root cause

Instrumenting the swallow points caught it exactly:

IN rid=#3:14336 vertex=#1:17 ex=Record #3:14336 not found

GraphEngine.deleteEdge disconnects the edge from both endpoints and then deletes the edge record.
The disconnection read the endpoint's chain best-effort in three places at once:

  • getEdgeHeadChunk answers null when the head chunk cannot be loaded,
  • the chain hops (EdgeLinkedList.readChunk) used a plain lookup,
  • and deleteEdge wrapped the lot in catch (SchemaException | RecordNotFoundException).

All three read "chunk unreadable" as "nothing to remove here" - and the edge record below was
deleted anyway.

Under concurrency a chunk is regularly unreadable for reasons that say nothing about the graph. A
commit publishes its pages one at a time and a reader takes no commit lock, so a vertex page can
expose a new edge-list head RID a moment before that head's own page becomes visible; and a chunk
emptied by another transaction is relinked out of the chain while a walker is still following a
pointer to it. Hitting either window ended the removal having removed nothing, so the back-reference
outlived its edge: one edge too many in the endpoint's degree, and one broken link. On a hot
vertex the window is narrow, which is why it surfaced as a rare, unexplained off-by-one.

The fix

The issue suggested capturing the integrity-check detail so the next occurrence would say which
RID is surplus. That is a better failure report, not a fix - and the surplus turned out to be
explainable without it. The alternative implemented here is to close the window itself, using the
line the codebase already draws elsewhere: the append path (getOrCreateEdgeList) answers exactly
this transient with a retryable ConcurrentModificationException
, as does
StripedEdgeList.addChain for the per-stripe chains. The removal path never got the same treatment.

  • GraphEngine.getEdgeHeadChunkForWrite - strict counterpart of getEdgeHeadChunk. null now
    means one thing only: the vertex has no edge list in that direction, so there is genuinely nothing
    to remove. A head RID that is present but unreadable is a retryable conflict.
  • GraphEngine.deleteEdge - endpoint resolution split from chain mutation
    (resolveEndpointToDisconnect), so only a vanished endpoint vertex is tolerated: there is
    nothing to disconnect from a vertex that is gone.
  • EdgeLinkedList.readChunk / loadChunkForWrite - an unreadable chunk on a removal walk is a
    retryable conflict. This subsumes StripedEdgeList.loadStripeHead, which is removed rather
    than left behind as dead code.
  • EdgeIteratorFilter - the opportunistic pruning of an already-dangling reference runs inside a
    READ, so it stays best-effort: it absorbs the new retryable conflict and leaves the ghost for a
    later pass instead of failing the iteration.

Read paths are unchanged. Iteration and counting still skip a momentarily unreadable chunk rather
than failing, which is the documented best-effort contract for reads.

Visible behaviour change

An edge.delete() racing a concurrent write on the same endpoint can now raise
ConcurrentModificationException where it previously "succeeded". That is a NeedRetryException, so
database.transaction(...) and the server's auto-retry for single-request commands absorb it. A
client-managed explicit transaction spanning several requests sees it and should retry - the same
contract concurrent updates have always had. Noted in the release notes.

Tests

Issue5670EdgeDeleteDanglingBackRefTest - all three fail on the current code, all three pass with
the fix
:

Test Shape
edgeDeleteRaisesRetryableConflictWhenTheEndpointHeadChunkIsUnreadable deterministic; the field-observed window
edgeDeleteRaisesRetryableConflictWhenAMidChainChunkIsUnreadable deterministic; the relinked-out-from-under-the-walker window
concurrentDeleteAndAppendNeverLeaveADanglingBackReference @Tag("slow"); the reported shape

The deterministic pair asserts its preconditions (the chain really spans several chunks, the victim
really sits behind the hole, the degree is right before the corruption) and matches the offending
chunk RID in the exception message, so it cannot pass on an unrelated throw.

Verification

Full engine suite, three runs. ExplicitLockingTransactionTest.errorOnExplicitLock failed on the
first run, so it was chased down rather than retried away: it passed on a run with this branch's
test class but the main-code fix reverted, and passed again on a second run with the fix. It is a
pre-existing load-sensitive assertion - explicitLock throws only when immutablePages is non-empty,
and under REPEATABLE_READ a page is retained only when pageNumber < file.getTotalPages(), which is
the on-disk file size; a just-committed page whose flush is still queued reads as "new" and is not
retained. Unrelated to edge handling; filed as #5679.

  • baseline at HEAD: 10625 tests, 0 failures
  • with fix: 10587 tests, 0 failures

Left out, deliberately

deleteVertex keeps its documented best-effort edge disconnection (Issue4420TolerantDeleteTest,
Issue4432CorruptVertexDeleteTest pin that a structurally broken vertex stays deletable). It is
exposed to the same transient window when collecting its edges, but making it strict would trade
repairability for strictness - a decision worth taking on its own merits, not as a side effect here.
Filed as #5680. Note that it does get the fix transitively where it matters: each edge it
deletes goes through deleteEdge, so a neighbour's back-reference is no longer left dangling.

…nstead of skipping it

Deleting an edge disconnects it from both endpoints and then deletes the edge record. The
disconnection read the endpoint's chain best-effort: getEdgeHeadChunk answers null when the head
chunk cannot be loaded, the chain hops used a plain lookup, and deleteEdge wrapped both in a
catch (SchemaException | RecordNotFoundException). All three read "chunk unreadable" as "nothing to
remove here" - and the edge record below was deleted anyway.

Under concurrency a chunk is regularly unreadable for reasons that say nothing about the graph. A
commit publishes its pages one at a time and a reader takes no commit lock, so a vertex page can
expose a new edge-list head RID a moment before that head's own page is visible; and a chunk emptied
by another transaction is relinked out of the chain while a walker is still following a pointer to
it. Hitting either window ended the removal having removed nothing, so the back-reference outlived
its edge: the endpoint reported one edge too many and check database reported one broken link. That
is the reported ConcurrentEdgeAppendMergeTest failure - 3001 where 3000 was expected, with one
integrity error alongside it - and instrumenting the null return reproduces it exactly.

The append path (getOrCreateEdgeList) already answered this window with a retryable
ConcurrentModificationException. The removal path now does the same:

- getEdgeHeadChunkForWrite is the strict counterpart of getEdgeHeadChunk. Null means one thing only:
  the vertex has no edge list in that direction, so there is genuinely nothing to remove.
- deleteEdge splits endpoint resolution from chain mutation, so only a vanished endpoint VERTEX is
  tolerated - there is nothing to disconnect from a vertex that is gone.
- EdgeLinkedList.readChunk and loadChunkForWrite map an unreadable chunk to a retryable conflict.
  That subsumes StripedEdgeList.loadStripeHead, which is removed rather than left as dead code.
- EdgeIteratorFilter's opportunistic pruning of an already-dangling reference runs inside a READ and
  stays best-effort: it absorbs the new retryable conflict and leaves the ghost for a later pass.

Read paths are unchanged: iteration and counting still skip a momentarily unreadable chunk rather
than failing.

Tests: two deterministic contract tests (head chunk unreadable, mid-chain chunk unreadable) plus the
reported concurrent shape. All three fail on the current code and pass with the fix.
@mergify

mergify Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Aug 1, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 8 complexity

Metric Results
Complexity 8

View in Codacy

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.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: fix(engine) #5670 - unreadable edge-list chunk retries the delete

Thorough, well-reasoned fix. The root-cause analysis is convincing, the fix aligns the removal path with the contract the append path (getOrCreateEdgeList / StripedEdgeList.addChain) already honors, and the split between read-side best-effort (getEdgeHeadChunk) and write-side strict (getEdgeHeadChunkForWrite) is the right seam. Removing loadStripeHead rather than leaving it as dead code, and the deterministic + concurrent test trio (with precondition assertions and RID-matching on the exception message so they cannot pass on an unrelated throw) are exactly what this kind of change needs.

A few points worth considering:

1. getEdgeHeadChunkForWrite reads the head RID outside its try/catch (minor robustness gap)
The read-side getEdgeHeadChunk deliberately keeps getOutEdgesHeadChunk()/getInEdgesHeadChunk() INSIDE the try, because ImmutableVertex.getOutEdgesHeadChunk() calls checkForLazyLoading(), which can throw RecordNotFoundException if the vertex was deleted concurrently (its FINE-level branch handles exactly that). In getEdgeHeadChunkForWrite that call sits OUTSIDE the try, so a concurrently-deleted endpoint vertex surfacing at this point escapes as a bare RecordNotFoundException - which is NOT a NeedRetryException and would fail the transaction without retry.

In practice resolveEndpointToDisconnect has just materialized the vertex via getOutVertex(), so checkForLazyLoading() is usually a no-op and the window is narrow. But note the ordering: resolveEndpointToDisconnect checks existsRecord plus resolves the vertex, then getEdgeHeadChunkForWrite reads the head - a concurrent delete landing BETWEEN the two slips past both guards. Given the whole point of the PR is to convert exactly these transient windows into retries, it seems inconsistent to leave this one read able to throw a non-retryable exception. Moving the head-RID read inside the try (mapping it to CME, or to null as the read-side does) would close it.

2. Permanent corruption is now converted into a permanent failure (deliberate, worth confirming)
getEdgeHeadChunkForWrite and readChunk/loadChunkForWrite cannot distinguish a TRANSIENT cross-page publication race from a GENUINELY lost/corrupt chunk. Before, a truly corrupt endpoint edge-list let edge.delete() complete best-effort (leaving a dangling back-ref, but succeeding). Now it throws CME on every attempt and fails permanently after TX_RETRIES. That is the correct trade for the concurrency bug, and check database is the repair path, but it is the mirror image of the deleteVertex best-effort tolerance you deliberately kept for repairability. Worth confirming an edge whose endpoint list is genuinely broken (not racing) does not become un-deletable and thus block cleanup - the follow-up you filed for deleteVertex should probably note this coupling.

3. moveEdge inherits the stricter deleteEdge
moveEdge calls deleteEdge(edge) (GraphEngine.java:571), so it now can raise CME too. That is fine inside database.transaction(...) auto-retry, but a client-managed explicit transaction over RemoteDatabase calling a move will now see the CME where it previously did not. The release note covers edge.delete(); a one-line mention that moveEdge shares the contract would make the visible-behavior section complete.

Tests / style

  • Good discipline lowering TX_RETRY_DELAY and restoring it in finally; @Tag("slow") on the concurrent test matches the repo convention.
  • isCheckingDatabaseIntegrity() override with the explanatory comment is the right call for the two deterministic tests that intentionally corrupt the chain.
  • Nit: the worker threads catch Exception and bump a failure counter, then the test asserts failures == 0 - a NeedRetryException that legitimately exhausts 10_000 retries would read as a failure identical to a real bug. Fine given the pool is drained one removal per iteration, but a comment that any surfaced exception here IS the bug would help a future reader.

I did not run the build/suite as part of this review; the PR reports a clean engine run. Overall this looks like a correct and carefully scoped fix - the items above are refinements, not blockers.

…where a lazy vertex can throw

Code review found a real gap in getEdgeHeadChunkForWrite: the head-RID read sat OUTSIDE its
try/catch. On a handle that has not materialised its record,
ImmutableVertex.getOutEdgesHeadChunk()/getInEdgesHeadChunk() calls checkForLazyLoading(), which
loads through LocalBucket.getRecord and raises RecordNotFoundException if the vertex was deleted
concurrently - and RecordNotFoundException is NOT a NeedRetryException, so it failed the transaction
outright rather than retrying it. The read-side getEdgeHeadChunk deliberately keeps that call inside
its try for the same reason.

The window is narrow but reachable: resolveEndpointToDisconnect checks existsRecord and resolves the
vertex, then getEdgeHeadChunkForWrite reads the head, and a concurrent delete landing between the two
slips past both guards. Leaving one non-retryable escape in a change whose whole point is to convert
these transients into retries was inconsistent. The read moves inside the try, and the message now
carries the underlying cause so the missing RID is still named.

Also from the review:

- The strict lookup's javadoc now states the price taken deliberately - a genuinely lost chunk is
  indistinguishable from a transiently invisible one, so it fails the removal on every attempt
  instead of completing it best-effort, with CHECK DATABASE as the repair path (issue #5680 records
  how that couples to the tolerance deleteVertex keeps).
- The release note's visible-behaviour section names moveEdge, which disconnects through deleteEdge
  and so shares the new contract.
- The stress test's worker catch says what it means: any exception surfacing there IS the bug, not a
  tolerated retry.

Tests: headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes pins the gap - it fails
on the previous commit with a bare RecordNotFoundException out of checkForLazyLoading.
@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Thanks - all three points were valid. Addressed in 51c212b.

1. Head RID read outside the try — fixed, and it was reachable. Confirmed the mechanism: ImmutableVertex.getOutEdgesHeadChunk()checkForLazyLoading()ImmutableDocument.checkForLazyLoading()LocalBucket.getRecord(rid), which raises RecordNotFoundException. The read moved inside the try.

Rather than just move it, I pinned it: headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes resolves a handle with lookupByRID(rid, false) (so the record is not materialised), deletes the vertex record, then calls the strict lookup. On the previous commit it fails exactly as you predicted:

Expecting actual throwable to be an instance of:
  com.arcadedb.exception.ConcurrentModificationException
but was:
  com.arcadedb.exception.RecordNotFoundException: Record #1:0 not found
	at com.arcadedb.engine.LocalBucket.getRecord(LocalBucket.java:291)
	at com.arcadedb.database.ImmutableDocument.checkForLazyLoading(ImmutableDocument.java:216)
	at com.arcadedb.graph.ImmutableVertex.checkForLazyLoading(ImmutableVertex.java:251)

The message now carries the underlying cause, so the missing RID is still named.

2. Permanent corruption → permanent failure. Confirmed and deliberate. CHECK DATABASE is the repair path for a chain that cannot be loaded (GraphDatabaseCheckerChainRebuildTest covers exactly the head-chunk-unloadable case), so a genuinely broken graph is still cleanable — but you are right that it now disagrees with deleteVertex. The strict lookup's javadoc states the trade, and I recorded the coupling on #5680 so whoever picks that up settles both paths together instead of leaving edge deletion strict and vertex deletion tolerant by accident.

3. moveEdge. Correct — it disconnects through deleteEdge and shares the contract. The release note's visible-behaviour section now names it.

Test nit — taken: the worker catch now says that any exception surfacing there IS the bug, not a tolerated retry.

Re-ran the graph package after the change: 320 tests, 0 failures, including Issue4420TolerantDeleteTest and Issue4432CorruptVertexDeleteTest.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: #5678 - unreadable edge-list chunk retries the delete instead of skipping it

Thorough, well-argued change. The core idea is sound and consistent with the codebase: the removal path now draws the same line the append path (getOrCreateEdgeList, StripedEdgeList.addChain) already drew, treating a present-but-unreadable chunk as a retryable ConcurrentModificationException rather than as "nothing to remove". The read/write split (getEdgeHeadChunk vs getEdgeHeadChunkForWrite), removing the now-redundant loadStripeHead, and the second commit's fix (moving the head-RID read inside the try so a concurrently-deleted lazy vertex maps to a retryable conflict rather than a raw RecordNotFoundException) are all correct and clearly reasoned. The Javadoc stating the deliberate trade-off is exemplary.

I verified statically that readChunk/loadChunkForWrite are only reached from the three removal walks + the striped write path, and that getEdgeHeadChunkForWrite is only called from deleteEdge. So the new strictness is confined to genuine mutation paths; read/iteration/count paths stay best-effort. Good.

Main point worth a second look: deleteVertex's edge-deletion loop is no longer tolerant of a corrupt neighbour

The PR deliberately preserves deleteVertex's best-effort collection of its own edges (tracked in #5680), but there is a second-order effect that isn't discussed. In GraphEngine.deleteVertex (around lines 657-662):

for (Identifiable edge : edgesToDelete)
  try {
    edge.asEdge().delete();
  } catch (RecordNotFoundException e) {
    // ALREADY DELETED, IGNORE IT
  }

edge.asEdge().delete() now routes through the strict deleteEdge, which disconnects from both endpoints - including the neighbour vertex - via getEdgeHeadChunkForWrite + the strict removal walk. That path can now throw ConcurrentModificationException, which this loop does not catch (only RecordNotFoundException), so it propagates out of deleteVertex.

Issue4432CorruptVertexDeleteTest looks like it only stays green because DELETE VERTEX FROM ... happens to visit the corrupt vertex (rids[0]) before its neighbour (rids[1]): once rids[0] is gone, resolveEndpointToDisconnect sees existsRecord == false and tolerates it. If the scan order were reversed (neighbour first, corrupt vertex still present), getEdgeHeadChunkForWrite on the corrupt vertex would read a bogus/unreadable head and could raise a non-tolerated exception. Worth confirming this is order-independent, or documenting the coupling alongside the #5680 note.

Minor / for consideration

  • Genuinely-lost chunk -> repeated failure. As the Javadoc honestly states, a permanently-lost chunk now fails every retry rather than completing. That is the accepted trade, but since it converts a silent corruption into a hard user-visible failure, consider emitting a distinguishable log/metric on retry exhaustion so operators can tell a retry storm from real corruption vs. healthy contention and reach for CHECK DATABASE.
  • EdgeIteratorFilter.handleCorruption now wraps the pruning tx in try { ... } catch (NeedRetryException). Since that runs with joinCurrent = true, any chunk pages anchored by the aborted removeEdgeRID before the CME remain in the joined (read) transaction. It is caught and best-effort as intended, and strictly better than the previous uncaught behaviour, but worth a sanity check that the partially-anchored pages cannot affect the enclosing read tx's later commit.

Tests

Strong coverage. The two deterministic tests assert their preconditions (chain spans multiple chunks, victim sits behind the hole, degree correct before corruption) and match the offending chunk RID in the exception message, so they cannot pass on an unrelated throw - exactly right. headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes pins the second-commit gap precisely. The @Tag("slow") stress test's worker catch correctly treats any surfacing exception as the bug given the generous retry budget. Good call overriding isCheckingDatabaseIntegrity() since the tests intentionally corrupt chains, and using countEdges(...) (accurate full scan) rather than count(*) (cached) for ground truth.

One suggestion: add a deterministic test for the deleteVertex-deletes-neighbour-of-a-corrupt-vertex ordering above, to lock in whichever behaviour you intend there.

Note: I reviewed statically; Maven could not run in my environment, so I relied on the author's reported suite results (10587 tests, 0 failures) for execution confirmation.

… edge list is unreadable

Second code review raised a second-order effect the change had not stated: deleting a vertex
disconnects its edges from the vertices on the OTHER end too, so the strict removal reaches a
neighbour nobody asked to touch. Measured before/after on the same fixture - a healthy vertex whose
neighbour's IN head chunk is unreadable:

  before: delete SUCCEEDED (edge record gone, neighbour still pointing at it)
  after:  ConcurrentModificationException

Keeping that strict, deliberately. Succeeding there means deleting the edge record while the
neighbour keeps the back-reference, which is precisely the corruption this issue is about - and
inflicting it on a vertex the caller never named. Under the concurrency this fix targets the retry
resolves it; on a genuinely broken neighbour list the delete fails and CHECK DATABASE is the repair
path. Issue #5680 tracks whether vertex deletion should keep a tolerant escape hatch, and now covers
this case too.

Pinned by deletingAVertexWhoseNeighbourListIsUnreadableReportsAConflictRatherThanDanglingTheReference,
and stated in the strict lookup's javadoc and the release note.

The review also suspected Issue4432CorruptVertexDeleteTest of passing only because the scan happens
to reach the corrupt vertex before its neighbour. Checked, and it does not: LocalBucket detects the
invalid record size on the first read and deletes the record ("Invalid record size 33554444 for
record #1:0: deleting record"), so the corrupt vertex fails at RESOLUTION - which stays tolerant -
and never reaches the strict head lookup. Deleting the neighbour first, with the corrupt vertex still
present, raises the identical RecordNotFoundException with and without this branch.
@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Addressed in ae9a67b. Thanks — the second-order effect was real and worth stating; one supporting claim did not hold up, so I measured both.

The neighbour effect — confirmed, and kept strict deliberately. You are right that deleteVertex disconnects the vertex at the other end too, and that its loop catches only RecordNotFoundException. Measured on the same fixture (a healthy vertex whose only edge points at a hub whose IN head chunk was removed):

before: delete SUCCEEDED   (edge record gone, neighbour still pointing at it)
after:  ConcurrentModificationException

Keeping it strict. Succeeding there deletes the edge record while the neighbour keeps the back-reference — precisely the corruption this PR fixes, inflicted on a vertex the caller never named. Now pinned by deletingAVertexWhoseNeighbourListIsUnreadableReportsAConflictRatherThanDanglingTheReference, and stated in the strict lookup's javadoc and the release note. Recorded on #5680, including the observation that force does not currently help here — the per-edge loop sits outside its promise, which is the natural shape for a fix if that issue resolves toward tolerance.

Issue4432CorruptVertexDeleteTest — the suspicion does not hold. It is not green by scan order. LocalBucket detects the invalid record size on the first read and deletes the record:

SEVER [LocalBucket] Invalid record size 33554444 for record #1:0: deleting record

so the corrupt vertex fails at resolution — which stays tolerant — and never reaches getEdgeHeadChunkForWrite. Deleting the neighbour first with the corrupt vertex still present raises the identical RecordNotFoundException with and without this branch. The reversed ordering you describe is therefore already covered; it just resolves through a different door than expected.

EdgeIteratorFilter partially-anchored pages. Checked, and the behaviour is a bit different from the framing: with joinCurrent = true, LocalDatabase.transaction's catch (NeedRetryException …) calls rollback() on the joined transaction before retrying, so the anchored pages do not survive into a later commit — they are discarded with the tx. The wart is that the enclosing read transaction is rolled back under the caller, but that is pre-existing for any NeedRetryException on this path (promoted super-nodes have been able to raise CME here via StripedEdgeList.addChain), and the new catch strictly improves it by not also propagating the exhausted exception into a read. Not introduced here, so left alone.

Log/metric on retry exhaustion. Agreed in principle, but the right home is the generic retry-exhaustion point in LocalDatabase.transaction, not this path — a warning at the conversion site would fire on ordinary healthy contention, which is exactly the noise it is meant to distinguish from. Better folded into the #5680 decision than bolted on here.

Graph package after the change: 321 tests, 0 failures, including Issue4420TolerantDeleteTest and Issue4432CorruptVertexDeleteTest.

Conflict was docs/release-26.8.1.md only - two independently appended sections (#5662's index-cursor
notes and this branch's). Both kept, main's first.

The graph sources auto-merged, but #5660 landed on main in the meantime and states an invariant this
branch has to honour: every read of an edge-list HEAD goes through getMostUpdatedVertex, because the
head is a pointer inside the vertex record and a handle obtained before an append still points at the
previous head. deleteEdge now reads heads through getEdgeHeadChunkForWrite, so
resolveEndpointToDisconnect routes its resolved endpoint through that method.

It already held by accident - Edge.getInVertex/getOutVertex go through lookupByRID, which consults
the transaction record cache first, the same cache getMostUpdatedVertex reads. Going through the
method that owns the rule makes it an invariant instead of a coincidence.

Graph package on the merged tree: 334 tests, 0 failures.
@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: #5670 unreadable edge-list chunk retries the delete instead of skipping it

Reviewed the diff, the surrounding GraphEngine/EdgeLinkedList/StripedEdgeList code paths, and the new test. This is a careful, well-argued fix. The root-cause analysis holds up: the removal walk read "chunk unreadable" as "nothing to remove" in three places while the edge record was deleted anyway, and the remedy - promote a transient invisibility to a retryable ConcurrentModificationException - is exactly the line the append path (getOrCreateEdgeList, StripedEdgeList.addChain) already draws. Symmetry restored rather than a new mechanism invented.

What I verified

  • readChunk is only reached from the three removal walks (removeEdge, removeEdgeRID, removeVertex in EdgeLinkedList), so converting its miss to a CME does not leak into read/iteration paths. The Javadoc claim checks out.
  • Read/repair callers stay best-effort: GraphDatabaseChecker and CSRBuilder keep using getEdgeHeadChunk (null-on-miss), and EdgeIteratorFilter.handleCorruption now catches NeedRetryException and leaves the ghost for a later pass - correct, since it prunes from inside a READ.
  • Super-node path is covered: buildEdgeList wraps a StripeDirectory head as StripedEdgeList, so deleteEdge -> getEdgeHeadChunkForWrite gets the strict treatment on striped chains too; addChain(strict=true) already threw the matching conflict. Folding loadStripeHead into loadChunkForWrite removes real duplication with no behavior loss.
  • No orphaned imports after the loadStripeHead removal (RecordNotFoundException/ConcurrentModificationException still used by addChain).
  • deleteEdge no longer wraps chain mutation in a swallowing catch - only endpoint vertex resolution is tolerant (resolveEndpointToDisconnect), and no raw RecordNotFoundException escapes the mutation (both chunk-load points convert it), so the delete never fails with a non-retryable exception where a retryable one was intended.

The main thing to weigh (already acknowledged, worth stating plainly)

The deliberate trade in getEdgeHeadChunkForWrite's contract is the real behavioral change: an endpoint edge list that is genuinely broken (not transiently invisible) is indistinguishable from a transient one, so edge.delete() - and, transitively, deleteVertex when a neighbour's list is unreadable - now fails-and-retries until the budget is exhausted instead of best-effort completing. On a pre-existing corrupted database this converts a formerly-succeeding delete into a hard failure whose only exit is CHECK DATABASE. That is the right correctness call and it is documented in the release notes, but two things are worth confirming:

  1. CHECK DATABASE is reachable while deletes are blocked - i.e. the repair path itself uses the best-effort getEdgeHeadChunk (it does, per GraphDatabaseChecker) and is not gated behind the same strict walk. Worth a one-line note in the release doc that repair-then-retry is the recovery, since a user hitting this will see a delete that never succeeds.
  2. Retry pressure on hot super-nodes: promoting the cross-page publication window to a CME means a very hot vertex under heavy delete+append contention will retry more often than before. The reported workload passes with the default budget, and TX_RETRY_DELAY mitigates, but this is a genuine (accepted) shift from "silently wrong" to "occasionally retries" - fine, just flagging that the cost lands on the exact super-node shape the bug targeted.

Minor

  • resolveEndpointToDisconnect does existsRecord(endpointRID) and then getOutVertex()/getInVertex(), i.e. up to two resolutions of the same endpoint. This matches the pre-existing code so it is not a regression, but on a delete-heavy path the existsRecord check is arguably redundant with the subsequent lazy load that would surface the same RecordNotFoundException (now caught and mapped to null). Not blocking.
  • Tests: the deterministic cases assert their preconditions plus the offending RID in the message, so they cannot pass on an unrelated throw - good discipline. The reproduction test is correctly @Tag("slow") and restores TX_RETRY_DELAY. Nice touch overriding isCheckingDatabaseIntegrity() since the fixtures intentionally corrupt chains.

Style / conventions

Consistent with CLAUDE.md: final throughout, imports (no FQNs), no System.out, no Claude attribution, assertThat(...) style, @Tag("slow") on the long test. Docs updated. The Javadoc-as-rationale density is high but genuinely useful here given how subtle the invariants are.

Overall: solid fix with a correctly-scoped blast radius and an honestly-documented trade-off. My only ask before merge is the release-note line making the CHECK DATABASE recovery explicit for anyone who hits the "genuinely broken list -> delete now fails" case, and confirmation that #5680 (deleteVertex tolerance) is the right home for the remaining asymmetry. LGTM otherwise.

…locks a delete

Third code review's one ask before merge. The release note stated the trade - a genuinely broken
endpoint list now fails the delete instead of completing it best-effort - but not what a user who
hits it should DO. It now says: the symptom is a delete that keeps failing however often it retries
(ordinary contention succeeds on a retry), the recovery is CHECK DATABASE ... FIX and then retry, and
the repair is never blocked by the delete being blocked, because CHECK DATABASE reads edge lists
through the best-effort reader. Verified: GraphDatabaseChecker uses getEdgeHeadChunk exclusively, and
getEdgeHeadChunkForWrite has exactly one caller, deleteEdge.

Also records the accepted retry-pressure shift the review flagged: on a hot super-node the transient
publication window is now answered with a retry rather than passing silently, so those transactions
retry slightly more often - from "quietly wrong" to "occasionally repeated", landing on the same
super-node shape the bug affected. Names txRetryDelay/txRetries as the levers.

The review also read the existsRecord check in resolveEndpointToDisconnect as arguably redundant with
the resolution that follows. It is not, and the comment now says why: getOutVertex/getInVertex load
with loadContent=false and hand back a LAZY handle, so a deleted endpoint does not surface there at
all - it surfaces inside getEdgeHeadChunkForWrite, which maps it to a retryable conflict. The check is
what separates "vertex gone, nothing to disconnect" (tolerated) from "vertex present, list unreadable"
(retry). Dropping it would silently convert the first into the second - and
headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes is exactly that path.

Graph package: 334 tests, 0 failures.
@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Both asks addressed in ba56346.

1. CHECK DATABASE recovery is now explicit in the release note. Verified your reading before writing it down: GraphDatabaseChecker uses getEdgeHeadChunk exclusively (4 call sites, all best-effort), and getEdgeHeadChunkForWrite has exactly one caller, deleteEdge — so the repair path is not gated behind the strict walk and cannot be blocked by the delete being blocked. The note now gives the user the whole loop: the symptom (a delete that keeps failing however often it retries, whereas ordinary contention succeeds on a retry), the recovery (CHECK DATABASE ... FIX, then retry), and why the repair still works.

2. Retry pressure on hot super-nodes — recorded in the same section as the accepted shift it is, "from quietly wrong to occasionally repeated", landing on exactly the super-node shape the bug affected, with arcadedb.txRetryDelay / arcadedb.txRetries named as the levers.

3. #5680 is the right home — confirmed, and it already carries the full picture: the original deleteVertex-own-list tolerance, the neighbour case from review 2, and the observation that force does not currently help (the per-edge loop sits outside its promise), which is the natural shape for a fix if that issue resolves toward tolerance.

On the minor point — the existsRecord check turns out not to be redundant, and I have commented it rather than removed it. Edge.getOutVertex()/getInVertex() load with loadContent=false, which returns a lazy handle without touching the bucket, so a deleted endpoint does not surface at that resolution at all. It surfaces later, inside getEdgeHeadChunkForWrite, which maps it to a retryable conflict. So existsRecord is precisely the discriminator between "vertex gone → nothing to disconnect, tolerate" and "vertex present but its list unreadable → retry"; dropping it would silently reclassify the first as the second. headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes exercises exactly that lazy path. Good catch to raise it — it is the kind of thing that reads as dead weight and is load-bearing.

Graph package after the change: 334 tests, 0 failures.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Code review: #5678 - edge-delete dangling back-reference fix

Reviewed all five changed files (GraphEngine, EdgeLinkedList, EdgeIteratorFilter, StripedEdgeList, and the new test), the diff, and the release note. This is a high-quality, well-reasoned fix. Summary and a few observations below.

What's good

  • Root cause is precise and the fix is symmetric with existing code. Turning an unreadable chunk on the removal walk into a retryable ConcurrentModificationException mirrors what the append path (getOrCreateEdgeList, EdgeLinkedList.add head-flip guard, StripedEdgeList.addChain) already does. Making the write path strict while leaving reads best-effort is the right line to draw, and it is drawn consistently across getEdgeHeadChunkForWrite, loadChunkForWrite, and readChunk.
  • The OUT/IN split (resolveEndpointToDisconnect) is the crux and it is correct. Tolerating only a vanished endpoint vertex while letting a failure on a still-existing vertex's list reach the caller is exactly what closes the window. The existsRecord + lazy getOutVertex()/getInVertex() interplay is subtle, but the inline comment justifies why the check is not redundant (lazy handle vs. materialized load), and headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes pins that the lazy-load path maps RNF to a NeedRetryException.
  • Dead code removed (StripedEdgeList.loadStripeHead) rather than left behind - confirmed no remaining references.
  • Test coverage is excellent. Two deterministic reproducers (head-chunk and mid-chain windows) plus the @Tag("slow") concurrent reproducer, plus the vertex-vanish and neighbour-unreadable second-order cases. The deterministic tests assert preconditions (chain spans several chunks, victim sits behind the hole, degree is right) and match the offending chunk RID in the exception message, so they cannot pass on an unrelated throw. isCheckingDatabaseIntegrity() is correctly disabled since the tests deliberately corrupt chains, and the slow test still runs an explicit CHECK DATABASE on its clean rounds.
  • Follows the repo conventions: @Tag("slow"), final params, assertThat(...) style, no new dependencies, authorship credited to the maintainer (not Claude).

Observations / questions (none blocking)

  1. New failure mode: SchemaException on the chain-mutation path now propagates. Previously the whole disconnection sat in catch (SchemaException | RecordNotFoundException); now resolveEndpointToDisconnect catches those only around endpoint resolution, while getEdgeHeadChunkForWrite converts only RecordNotFoundException to CME. A SchemaException surfacing during buildEdgeList/chain walk on a healthy vertex would now fail the delete outright (not retryable). This looks intentional and consistent with the strict design, but it is worth confirming no realistic transient raises SchemaException (rather than RNF) on the write walk under concurrency, since that would fail instead of retry.

  2. Read-side strictness on super-nodes. chainsForNeighbour(..., strict=true) feeds not only removals but the neighbour-keyed reads isVertexConnectedTo/containsVertex/getFirstEdgeConnectedToVertex, so those reads can raise CME on a super-node with a transiently unreadable stripe chain. This is pre-existing (unchanged by this PR), so the release note's "read paths are unchanged" is accurate in context - just flagging it since it is an exception to the general "reads are best-effort" contract for anyone auditing later.

  3. Concurrent test robustness (minor). In concurrentDeleteAndAppendNeverLeaveADanglingBackReference, worker threads catch Exception but not Error, and done.await() has no timeout. An unexpected Error (or an AssertionError thrown off the main thread) would leave the latch un-counted and hang the test rather than failing it. Not a correctness concern for the fix; a bounded done.await(timeout, ...) would make a future regression fail fast instead of hanging CI.

  4. Pre-existing stale comment (drive-by, out of scope). In deleteEdge, the edge-record deletion carries the comment "Use the database's delete method to ensure proper index cleanup instead of directly calling bucket.deleteRecord()" immediately above a direct bucket.deleteRecord(...) call. The comment contradicts the code; not introduced by this PR, but a natural spot to correct it.

Verification note

I could not run the Maven build in this review sandbox (it requires an interactive approval unavailable here), so I did not independently reproduce the suite results. The PR reports the full engine suite green across three runs with the deterministic reproducers failing on main and passing with the fix, and the code and tests read as consistent with that.

Overall: clean, well-documented, correctly scoped fix with strong regression coverage. LGTM pending the maintainer's own green CI run.

…that said the opposite of its code

Fourth code review, both actionable items.

The stress test's workers caught Exception and the main thread awaited the latch without a timeout,
so an Error - or an AssertionError thrown off the main thread - would skip the countDown and hang the
run instead of failing it. Workers now catch Throwable and the await is bounded at 5 minutes against
a round that takes seconds, which is headroom a slow machine cannot exhaust but a wedged worker
will.

The review also spotted that deleteEdge's edge-record removal carried the comment "Use the database's
delete method to ensure proper index cleanup instead of directly calling bucket.deleteRecord()"
directly above a bucket.deleteRecord() call. Checked which of the two was wrong before touching
either: an edge carrying an indexed property was deleted and its index went from 1 entry to 0, so the
CODE is right and the comment was inverted. LocalDatabase.deleteRecordNoLock cleans the index entries
and fires the delete events before dispatching an Edge to deleteEdge, so this call is deliberately
the physical removal alone - going back through the database would repeat that work, not add it. The
comment now says that.
@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Thanks — items 3 and 4 are fixed in e484f23; 1 and 2 I looked into and am leaving as-is, reasoning below.

3. Concurrent test robustness — fixed. Workers now catch Throwable (an Error could previously skip the countDown) and the latch wait is bounded at 5 minutes against a round that takes seconds. A wedged worker now fails the test instead of hanging CI.

4. Stale comment — fixed, after checking which half was wrong. Rather than assume the comment, I deleted an edge carrying an indexed property and watched the index: 1 entry → 0, lookup empty. So the code is right and the comment was inverted. LocalDatabase.deleteRecordNoLock cleans the index entries and fires the delete events before dispatching an Edge to deleteEdge, so this call is deliberately the physical removal alone — routing back through the database would repeat that work, not add it. The comment now says that, with the reason.

1. SchemaException on the chain-mutation path — intentional, and I would rather not map it. The realistic source is the bucket lookup in loadChunkForWrite / buildEdgeList, i.e. the edge bucket no longer resolving — which means the schema genuinely changed (type or bucket dropped), not that a page is momentarily unpublished. A retry cannot fix that, so mapping it to CME would burn TX_RETRIES and then report "concurrent commit in flight" for what is actually a schema error. Failing directly with the real exception is the more useful outcome, and it is strictly better than the old behaviour, which swallowed it and deleted the edge record anyway — the same silent-corruption shape this PR is closing. Worth noting the precedent points the same way: #5608 changed TransactionContext.edgeSegmentPageKey to a null-tolerant bucket lookup specifically so a transient miss became retryable, while leaving genuine schema errors as errors.

2. Read-side strictness via chainsForNeighbour(strict=true) — agreed, and confirmed unchanged by this PR (it predates it and I have not touched addChain). Flagging it in the thread is the right place for it; if it deserves to be reconsidered, that is its own change with its own reasoning, not a rider here.

Graph package after the change: 334 tests, 0 failures. Full engine suite was green on this branch before the merge from main (10587 tests, 0 failures) and CI is now running it on the merged tree.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: fix(engine) #5670 - unreadable edge-list chunk retries the delete instead of skipping it

Thorough, well-reasoned change. The core idea is sound: the append path (getOrCreateEdgeList, StripedEdgeList.addChain) already converts the "cross-file commit publication" window into a retryable ConcurrentModificationException, and the removal path was the asymmetric hole that read the same transient as "nothing to remove" while deleting the edge record anyway. Making the removal path strict is the right line to draw, and the split of endpoint resolution (resolveEndpointToDisconnect) from chain mutation (getEdgeHeadChunkForWrite) is a clean way to keep "vertex gone = tolerated" apart from "vertex present but list unreadable = conflict".

I traced every caller of the now-strict methods (removeEdge, removeEdgeRID, readChunk, loadChunkForWrite, StripedEdgeList.addChain(strict)):

  • GraphEngine.deleteEdge (lines 514/521): intended strict path.
  • EdgeIteratorFilter.handleCorruption (line 108): correctly kept best-effort - it runs the prune inside database.transaction(...) and now catches NeedRetryException, leaving the ghost for a later pass. Good.
  • GraphDatabaseChecker (1114/1150) and CSRBuilder (689): read-only containsEdge/iteration through best-effort getEdgeHeadChunk, unaffected.

Atomicity is preserved: if OUT removeEdge mutates and then IN resolution/getEdgeHeadChunkForWrite throws CME, the whole transaction rolls back before the edge record is deleted, so the retry re-reads a consistent view. Confirmed the head read stays inside the try in getEdgeHeadChunkForWrite so a vertex deleted since resolution surfaces as a retryable conflict rather than a raw RecordNotFoundException (pinned by headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes).

Points worth confirming

  1. Broadened behavior change via deleteVertex -> deleteEdge. The PR frames the visible change as "edge.delete() can now raise CME", but it also reaches DELETE VERTEX: each neighbour-edge disconnect goes through the strict getEdgeHeadChunkForWrite on the neighbour's list, so deleting a healthy vertex whose neighbour list is momentarily unreadable now aborts with CME (correctly pinned by deletingAVertexWhoseNeighbourListIsUnreadable...). Under transient contention the retry resolves it. The concern is a genuinely corrupt (non-transient) neighbour list: such a vertex is now undeletable via the normal path and needs CHECK DATABASE. Issue4420/Issue4432 pin the vertex's own broken list, not a neighbour's. This is exactly the exposure deleteVertex can delete a vertex without collecting its edges when a chunk is transiently unreadable (the #5670 window, vertex side) #5680 tracks, so it is acknowledged, but it is the broadest-impact part of the change and deserves the same prominence in the release notes as the edge-delete case.

  2. Permanent-failure trade-off. A genuinely lost chunk (not transiently invisible) is indistinguishable from a transient one here, so edge.delete() now fails on every retry instead of completing best-effort. This is the deliberate design, documented in the getEdgeHeadChunkForWrite Javadoc and release notes, with CHECK DATABASE as the repair path. Just flagging that this converts a previously-silent "success" into a hard, retry-exhausting failure for that (rare) case - correct, but a real operational change.

  3. Minor performance on the delete path. resolveEndpointToDisconnect adds an existsRecord(endpointRID) per endpoint (2 per edge delete). existsRecord takes the DB read lock and does a page-slot existence check. It is load-bearing for the tolerance distinction, so it is justified, but on a super-node teardown (many edges) it is a new per-edge lock+lookup on top of the chain walks. Likely negligible next to the chain I/O, but worth being aware of.

  4. existsRecord(null) throws IllegalArgumentException. In resolveEndpointToDisconnect, if endpointRID were ever null the call throws IllegalArgumentException, which is not caught by the catch (SchemaException | RecordNotFoundException) and would escape as a non-retryable error. Edges always carry both endpoints, so this is theoretical, but a defensive null check (return null) would match the "nothing to disconnect" intent.

Test coverage

Excellent. The deterministic pair asserts real preconditions (chain spans several chunks, victim sits behind the hole, degree correct before corruption) and matches the offending chunk RID in the exception message, so they cannot pass on an unrelated throw. The @Tag("slow") concurrency test reproduces the reported shape and counts any throwable as a failure rather than tolerating retries. isCheckingDatabaseIntegrity() is correctly disabled since the tests deliberately corrupt chains, and assertIntegrityClean() is asserted explicitly at the end of the concurrent test instead. Tagging follows CLAUDE.md.

Nits

  • The inline comments are extremely verbose, but that matches the established house style in this package/module, so no change requested.

Overall this is a high-quality, well-tested fix that closes a real correctness hole and is honest about the trade-offs it makes and the follow-ups it leaves (#5679, #5680). LGTM, with the release notes ideally calling out the DELETE VERTEX reach (point 1) as explicitly as the edge-delete case.

…lete, and guard a null endpoint

Fifth code review's one ask, plus its theoretical-but-cheap point 4.

The release note led with edge.delete() and mentioned the vertex reach only in passing, at the tail of
the trade-off paragraph - while DELETE VERTEX is in fact the widest-reaching of the three affected
operations, because it disconnects each edge from the vertex at the OTHER end and so lands the strict
read on a NEIGHBOUR's list. A healthy vertex can now fail to delete because of a neighbour. The
visible-effect section now lists the three operations that share the contract and says that outright,
including that a healthy vertex next to a corrupted one is not deletable by the normal path until the
corruption is repaired.

resolveEndpointToDisconnect now returns null for a null endpoint RID instead of reaching
existsRecord, which raises IllegalArgumentException on one - not covered by the catch below, not
retryable, and so a hard failure escaping the one method whose job is to decide what is tolerable.
An edge always carries both endpoints, so this is a guard rather than a case.

Graph package: 334 tests, 0 failures (includes main's new EdgesConnectedToTest).
@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Both actionable items in d73cad5.

1. DELETE VERTEX prominence — agreed, and you are right that it is the widest-reaching of the three. The note led with edge.delete() and mentioned the vertex reach only at the tail of the trade-off paragraph. The visible-effect section now opens with "and it is not limited to deleting an edge", lists the three operations that share the contract (edge.delete()/DELETE EDGE, moveEdge, vertex.delete()/DELETE VERTEX), and calls out that the vertex case lands the strict read on a neighbour's list — so a vertex that is itself perfectly healthy can fail to delete. The trade-off paragraph now states the operational consequence in the same terms: a healthy vertex next to a corrupted one is not deletable by the normal path until the corruption is repaired.

4. Null endpoint — guarded. You are right that existsRecord(null) raises IllegalArgumentException, which the catch (SchemaException | RecordNotFoundException) does not cover and which is not retryable — a hard failure escaping the one method whose job is to decide what is tolerable. resolveEndpointToDisconnect now returns null for a null endpoint RID, matching the "nothing to disconnect" intent. Theoretical since an edge always carries both endpoints, but it is a guard worth having in exactly that method.

2. Permanent-failure trade-off — acknowledged, and now stated in the release note in operational terms alongside the CHECK DATABASE ... FIX recovery and the symptom that distinguishes it from ordinary contention (a delete that keeps failing however often it retries).

3. existsRecord cost on super-node teardown — noted and accepted. It is load-bearing (it is what separates "vertex gone, tolerate" from "list unreadable, retry"), and a page-slot existence check per endpoint is small next to the chain walk that follows it. If it ever shows up in a teardown profile, the fix is to fold the existence answer into the resolution rather than to drop the distinction.

Graph package after the change: 334 tests, 0 failures — including main's new EdgesConnectedToTest from the #5660 merge.

@codacy-production

codacy-production Bot commented Aug 1, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 13 complexity

Metric Results
Complexity 13

View in Codacy

🟢 Coverage 66.67% diff coverage · -7.58% coverage variation

Metric Results
Coverage variation -7.58% coverage variation
Diff coverage 66.67% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (af0dc71) 150057 114451 76.27%
Head commit (b0271ab) 182075 (+32018) 125078 (+10627) 68.70% (-7.58%)

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 (#5678) 60 40 66.67%

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.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: fix(engine) 5670 - unreadable edge-list chunk retries the delete

Thorough, well-reasoned change. The core insight is sound: the removal path was treating 'chunk transiently unreadable' (a timing fact) as 'nothing to remove here' (a graph fact), and then deleting the edge record anyway - dangling the back-reference. Making the write/removal path symmetric with the append path (getOrCreateEdgeList / StripedEdgeList.addChain), which already answered this exact window with a retryable ConcurrentModificationException, is the right line to draw. I verified the claims below against the tree.

Strengths

  • Read/write split is clean. getEdgeHeadChunk stays best-effort (null on a miss) for the many read/count/iterate callers; the new getEdgeHeadChunkForWrite has exactly one caller (deleteEdge), so the strict contract does not leak into read paths. Confirmed GraphDatabaseChecker still uses only the best-effort getEdgeHeadChunk, so CHECK DATABASE ... FIX remains a viable repair path even when the strict delete refuses - the documented recovery actually holds.
  • buildEdgeList extraction removes the duplicated striped-vs-classic head dispatch between the two methods - good reuse, no behavior drift.
  • Removing loadStripeHead rather than leaving it as dead code is the right call; the StripedEdgeList javadoc/comment cross-references were updated to point at loadChunkForWrite consistently.
  • The head-RID read moved inside the strict try (commit 2) is a real catch: on a lazy ImmutableVertex, getInEdgesHeadChunk() lazy-loads and can raise a non-retryable RecordNotFoundException, which would have defeated the whole point. Nice that it is pinned by headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes.
  • Test coverage is excellent. Two deterministic tests that assert their own preconditions (chain really spans multiple chunks, victim sits behind the hole, degree is right) and match the offending chunk RID in the exception message, so they cannot pass on an unrelated throw; plus the reported concurrent shape under Tag('slow'). isCheckingDatabaseIntegrity() correctly disabled since the fixtures deliberately corrupt the chain. Good adherence to the repo assertThat(...) style and Tag conventions.

Points worth considering

  1. EdgeIteratorFilter catches NeedRetryException, which is broader than the CME the comment describes. The comment explains the intent (opportunistic prune inside a READ, leave the ghost for a later pass), and swallowing here is correct for that intent - but the catch will also absorb any other future NeedRetryException subclass surfacing from removeEdgeRID. That is probably fine given the best-effort contract, but a catch (final ConcurrentModificationException ...) would match the stated reasoning more tightly and avoid silently masking an unrelated retryable condition later.

  2. buildEdgeList still casts the head record to EdgeSegment unguarded. Under the very window this PR targets, a deleted chunk slot could in principle be reused, so lookupByRID could return a record that is neither StripeDirectory nor EdgeSegment, yielding a ClassCastException (not retryable) rather than the intended CME. This is pre-existing (the cast was inlined in getEdgeHeadChunk before) and low-risk since edge chunks live in their own bucket, so I would not block on it - but since this change is explicitly about turning transient-window failures into retries on the write path, it may be worth mapping a ClassCastException from the write-side lookup to a CME too, for completeness.

  3. deleteVertex edge-delete loop propagates the new CME uncaught (the for-loop over edgesToDelete only catches RecordNotFoundException). This is intended and documented - the CME is a NeedRetryException so the surrounding transaction rolls back and retries cleanly - but it does mean a healthy vertex can now fail to delete because of a neighbour unreadable list. The release note gives this appropriate prominence (good), and issue 5680 tracks the tolerance question. Worth double-checking that the server-side auto-retry path and any batch/DELETE VERTEX callers that manage their own transactions surface a sensible message rather than a raw stack, given the increased retry pressure on super-nodes you flagged.

Nits

  • The docs and comments are unusually rich - genuinely helpful for a subtle MVCC/visibility bug. No action needed; just noting it lands on the right side of over-documenting for code this timing-sensitive.

Overall: correct root-cause, minimal and symmetric fix, strong deterministic + stress coverage, and the behavioral change is called out with a concrete recovery procedure. The points above are minor/optional. Nice work.

…on and not the narrower CME

Sixth code review read the catch as wider than its comment justified and suggested narrowing it to
ConcurrentModificationException. Checked the other direction first: NeedRetryException has exactly
two subclasses, CME and LockTimeoutException, and the second one says the same thing about this
prune - come back for it later. Narrowing the catch would let a lock timeout escape an OPTIONAL
repair into the read that triggered it, which is the outcome the catch exists to prevent. So the
comment was the half that was wrong, and it now states that the condition being absorbed is "retry
later", not one particular cause.

No behaviour change; the comment now matches the code instead of the code being narrowed to match the
comment.
@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

All three checked; one produced a change (b0271ab), two I am deliberately not making — reasoning below.

1. EdgeIteratorFilter catching NeedRetryException rather than CME. I checked the narrowing before applying it, and it would make behaviour worse. NeedRetryException has exactly two subclasses: ConcurrentModificationException and LockTimeoutException. The second says the same thing about this prune — come back for it later — and letting it escape would fail a read because an optional repair could not get a lock, which is exactly the outcome the catch exists to prevent. So the comment was the half that was wrong: it now says the condition being absorbed is "retry later", not one specific cause. Code unchanged, comment now matches it.

2. Unguarded EdgeSegment cast in buildEdgeList. Agreed it is pre-existing and low-risk (edge chunks live in their own *_in_edges/*_out_edges and stripe-pool buckets, so a reused slot is still an edge-list record). I would rather not map ClassCastException to CME, though: unlike an unreadable chunk, a vertex head pointing at something that is not a chunk is not a timing fact — it is corruption a retry cannot fix. Converting it would burn TX_RETRIES and then report "concurrent commit in flight" for a structural problem, trading a precise stack for a misleading message. The distinction this PR is built on is exactly "transient ⇒ retry, structural ⇒ report", and a CCE falls on the structural side.

3. Server-side surfacing of the new CME. Verified rather than assumed: AbstractServerHttpHandler already catches NeedRetryException and answers HTTP 503 "Cannot execute command", logging at FINE with the exception message — a retryable status and a clean message, not a raw 500 stack. So DELETE VERTEX hitting the new conflict over HTTP surfaces sensibly today, and a client that retries on 503 behaves correctly. Nothing to change.

Graph package: 334 tests, 0 failures.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: #5670 - unreadable edge-list chunk retries the delete instead of skipping it

This is a genuinely high-quality fix. The root-cause narrative is precise, the fix follows a line the codebase already draws on the append path (getOrCreateEdgeList / StripedEdgeList.addChain answering the same transient with a retryable ConcurrentModificationException), and it stays inside the established page-level MVCC/retry contract rather than inventing a new mechanism. I verified the core claims against the tree and they hold up.

Correctness - verified

  • readChunk's three callers are all removal walks (removeEdge, removeEdgeRID, removeVertex). Converting RecordNotFoundException to a retryable CME there does not touch any read/iteration path, so the "reads stay best-effort" contract is intact.
  • getEdgeHeadChunkForWrite has exactly one caller (deleteEdge), and GraphDatabaseChecker reads through the best-effort getEdgeHeadChunk - so the "CHECK DATABASE is not blocked by the strict delete" recovery story is real, not aspirational.
  • The buildEdgeList extraction preserves read-side behaviour - getEdgeHeadChunk still catches only RecordNotFoundException and its lookupByRID cannot surface a CME, so the read side never sees the new exception.
  • deleteVertex's per-edge delete loop catches only RecordNotFoundException, so the new CME from a neighbour's unreadable list correctly propagates out and rolls the whole vertex delete back - exactly what deletingAVertexWhoseNeighbourListIsUnreadableReportsAConflictRatherThanDanglingTheReference pins. The edge collection loops keep their broad catch (Exception) + getEdgeHeadChunk, so deleteVertex retains its documented tolerance while collecting. That split is the right seam.
  • The EdgeIteratorFilter swallow is safe: the CME is raised inside the removal walk before any chunk is mutated (the hop readChunk is a pure read; loadChunkForWrite may anchor a page but never modifies before throwing, and an unmodified anchored page is pruned at commit), so absorbing it and leaving the ghost for a later pass cannot corrupt the enclosing read transaction.
  • No dead imports: StripedEdgeList still uses RecordNotFoundException (line 453) after loadStripeHead was removed; EdgeIteratorFilter's new NeedRetryException import is used and the existing ones remain live.

Test coverage

Strong. The two deterministic tests assert their preconditions (chain really spans several chunks, victim really sits behind the hole, degree is right beforehand) and match the offending chunk RID in the exception message, so they cannot pass on an unrelated throw. headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes closes the non-retryable-escape gap the review caught mid-PR. The @Tag("slow") concurrent test reproduces the reported shape and now catches Throwable with a bounded await, so a wedged worker fails rather than hangs. Good adherence to the @Tag("slow") convention in CLAUDE.md.

Minor observations (non-blocking)

  1. Global config mutation in the slow test. concurrentDeleteAndAppendNeverLeaveADanglingBackReference sets GlobalConfiguration.TX_RETRY_DELAY to 1 and restores it in finally. Since that global is process-wide, a JVM running test classes in parallel could observe the temporary value. It is restored correctly and engine tests generally run sequentially per class, so this is a theoretical note rather than a defect - TX_RETRY_DELAY is SCOPE.DATABASE, so if you wanted full isolation you could set it on the database instance instead of the global.
  2. Comment/doc density. The javadoc and inline commentary here is unusually thorough (a real asset for a subtle concurrency fix), though a few blocks restate the same publication-window explanation across loadChunkForWrite, readChunk, getEdgeHeadChunkForWrite and the release note. Not worth trimming given the subtlety, just noting it.

Style / conventions

Matches the surrounding code: final on locals and params, single-statement if without braces, imported class names rather than FQNs, assertThat(...).isTrue() style in tests, Apache headers on the new file. No System.out, no new dependencies, no author attribution added. The corrected bucket.deleteRecord() comment (index cleanup already done upstream in LocalDatabase.deleteRecordNoLock) is a nice catch - the old comment said the opposite of the code.

Performance

The hot removal walk gains only a try/catch around readChunk/loadChunkForWrite; a try with no exception thrown is free on the JVM, and the retry-pressure shift on hot super-nodes is explicitly acknowledged and bounded by txRetryDelay/txRetries. No new allocations on the success path.

Verdict: LGTM. No blocking issues found. The behaviour change (edge/vertex/move delete can now raise a retryable CME under contention) is correctly surfaced in the release notes with a concrete recovery path, and the "left out deliberately" section (#5680 for deleteVertex's tolerant collection) draws a defensible boundary.

@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Thanks. Checked the one actionable minor; it turns out not to apply, and I am leaving the test as it is.

1. TX_RETRY_DELAY per-database instead of global. Good instinct — the setting is declared SCOPE.DATABASE, and engine/CLAUDE.md even recommends exactly that for the lock timeouts. But the retry loop does not read it that way:

// LocalDatabase.transaction(...), line 1387
final int retryDelay = GlobalConfiguration.TX_RETRY_DELAY.getValueAsInteger();

That is the global static, not the database's ContextConfiguration, so setting it on the instance would have no effect on the retry delay and the test would silently lose the tightening it is asking for. Set-global-and-restore-in-finally is the only form that works here, and it matches the sibling ConcurrentEdgeAppendMergeTest. (The mismatch between the declared SCOPE.DATABASE and the global read is a real if minor inconsistency in the engine — worth its own look, not a rider on this PR.)

2. Comment density — noted, and I agree with your conclusion; leaving it.

That closes every point raised across the review rounds. Summary of what changed as a result of them:

Round Point Outcome
1 Head-RID read outside the strict try Real bug — fixed, pinned by a test that fails on the prior commit
2 deleteVertex reaches a neighbour's list Confirmed by measurement, kept strict, pinned + documented
2 Issue4432 order-fragility Investigated — did not hold up (the corrupt record self-deletes on read)
3 CHECK DATABASE recovery unstated Added, after verifying the checker uses the best-effort reader
4 Unbounded latch / Exception not Throwable Fixed
4 Comment contradicting its code Verified which half was wrong (index 1 → 0), corrected the comment
5 DELETE VERTEX prominence Rewritten to lead the visible-effect section
5 existsRecord(null) Guarded
6 Narrow the catch to CME Declined — would let LockTimeoutException fail a read
7 Per-database TX_RETRY_DELAY Not applicable — the retry loop reads the global

Follow-ups filed: #5679 (load-sensitive ExplicitLockingTransactionTest) and #5680 (deleteVertex tolerance, incl. the neighbour case and that force does not currently help).

@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 55.00000% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.70%. Comparing base (af0dc71) to head (b0271ab).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
.../src/main/java/com/arcadedb/graph/GraphEngine.java 60.00% 9 Missing and 7 partials ⚠️
...in/java/com/arcadedb/graph/EdgeIteratorFilter.java 0.00% 10 Missing ⚠️
.../main/java/com/arcadedb/graph/StripedEdgeList.java 50.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5678      +/-   ##
============================================
- Coverage     67.46%   66.70%   -0.77%     
- Complexity        0     1113    +1113     
============================================
  Files          1770     1770              
  Lines        150057   150083      +26     
  Branches      31806    31811       +5     
============================================
- Hits         101236   100110    -1126     
- Misses        35645    36938    +1293     
+ Partials      13176    13035     -141     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lvca lvca self-assigned this Aug 1, 2026
@lvca lvca added this to the 26.8.1 milestone Aug 1, 2026
@lvca
lvca merged commit 86927bb into main Aug 1, 2026
28 of 31 checks passed
@lvca
lvca deleted the fix/5670-edge-delete-dangling-backref branch August 1, 2026 14:15
lvca added a commit that referenced this pull request Aug 1, 2026
… own record

Second review round. The corrupt-buffer tolerance was keyed on the exception TYPE alone, not on
where the failure happened, so in principle it also covered a mid-chain chunk whose page bytes are
damaged - which would swallow the failure after some edges had already been collected and delete
the vertex with the entries behind the hole stranded.

The tolerance is now scoped by construction: the vertex's own edge-list pointer is read first, in
isOwnRecordUnreadable, and only a damaged buffer THERE is tolerated (#4420/#4432 - there is no list
to find and no retry can produce one). Past that probe a corrupt buffer is a broken chain and gets
no escape. Costs one extra read of an already-materialised buffer per direction.

Also: ConcurrentModificationException gains the (message, cause) constructor NeedRetryException
already had, so the repair-hint rethrow keeps the original stack trace for the run where the retry
budget is exhausted and the exception actually surfaces; and the document arm of the RECORD scope
honours maxWarnings like its sibling branch.

Honest note on the new test: it pins the contract (a damaged mid-chain chunk refuses the delete and
leaves the graph untouched), but it does NOT demonstrate the old classification was exploitable -
every corruption injected was already caught by one of the strict paths #5678 introduced. The scope
fix is kept because it makes the intent explicit rather than incidental.
lvca added a commit that referenced this pull request Aug 1, 2026
…instead of losing its edges (#5707)

* fix(engine) #5680: an unreadable edge list retries the vertex delete instead of losing its edges

deleteVertex collected the edges to remove through the best-effort getEdgeHeadChunk with the whole
walk wrapped in catch (Exception), so any part of the list that could not be read at that instant
was taken for "nothing to remove here" and the vertex record was deleted on top of that empty or
partial view: the edges outlived their endpoint, with a back-reference left on an innocent
neighbour. Three routes reached that outcome - an unreadable head chunk, an unreadable hop mid-chain
(everything behind the hole dropped), and, on a promoted super-node, a stripe chain the read walk is
allowed to skip, which lost a whole stripe with no exception behind it at all. None of them is a
fact about the graph; they are the transient publication window #5670 was about, which deleteEdge
has answered with a retry since #5678.

The collection now reads the list the way a removal must: the head through getEdgeHeadChunkForWrite,
the walk through the new EdgeLinkedList.edgeIteratorForRemoval (strict over stripe chains), and the
chain hops mapped to a retryable ConcurrentModificationException. force is the single escape hatch
and now works end to end: it also absorbs the conflict raised while disconnecting a collected edge
from the vertex at its OTHER end, which previously let a broken neighbour block a forced delete.
Draining the leftover chunk records stays best-effort in both modes on purpose - by then a miss
loses orphaned chunks, never a reference. The heads are also read from the transaction's own vertex
instance (#5660), closing a fourth silent-loss route that needed no race.

Strictness costs no repairability: CHECK DATABASE ... FIX rebuilds an unloadable chain from the
surviving edge records and is never blocked by the delete being blocked.

* docs(engine) #5680: state why a chunk that cannot be DECODED stays tolerated, and where the striped conflict is raised

Review follow-ups on #5707, both documentation.

The blanket catch (Exception) left in collectEdgesToDelete is the residue once the transient window is
split off into the retryable branch: an undecodable buffer, which no retry can fix and which is
tolerated even when force is false. That reads as inconsistent with the strictness around it until you
follow where such a record comes from - LocalDatabase.deleteRecordNoLock catches the same exception
family around the index cleanup and proceeds WITHOUT raising its force flag, so a corrupt-buffer vertex
arrives here with force == false and failing would make it undeletable again (#4420, #4432). Its cost is
larger than a single tolerated dangling entry, so the warning now names it and points at CHECK DATABASE.

hasNextEdgeToDelete's javadoc claimed only a chain hop surfaces through it, which is true on the classic
layout but not the whole story on a promoted super-node: StripedEdgeList.edgeIteratorForRemoval resolves
every stripe head while BUILDING the iterator, so an unloadable stripe raises there instead. Same try,
same handling - the note keeps a future reader from reading the helper as the only strict point.

* test(engine) #5680: pin the self-loop delete, and say what the strict/tolerant split actually keys on

Second review follow-ups on #5707.

A self-loop is the one shape both collection walks yield, so it lands in the delete list twice and the
second removal runs against a chain the first already cleaned. That is unchanged by this fix and
resolves quietly, but the removal walk is stricter now and a self-loop is the shape most likely to
surprise a later change here, so it gets a test rather than an argument.

The two catch arms in collectEdgesToDelete are easy to read as transient-versus-permanent, which is
wrong and would make the next reader draw the line in the wrong place. They key on what a MISS COSTS: a
chunk that cannot be found retries because deleting the vertex on a short list loses references, and
that is applied to a genuinely lost chunk too - no retry recovers it, so the delete now fails once the
retries are spent instead of quietly completing. Stated explicitly, since it is a deliberate behaviour
change for the lost-chunk case and not only for the transient one.

* fix(engine) #5680: close the tolerant catch to named corruption shapes, and scope the concurrent test to what this fix owns

Third review follow-up on #5707, plus a flake the follow-up exposed.

The trailing catch (Exception) in collectEdgesToDelete would have absorbed an NPE or an
IllegalStateException from a future change as "corrupted chunk, delete anyway" - reintroducing exactly
the silent-edge-loss path this issue exists to remove. It now names the shapes it tolerates: the decode
family LocalDatabase already uses for the same purpose, plus ClassCastException and SchemaException,
which are the two further shapes a corrupt EDGE LIST adds (a head RID naming a record that is not an
edge segment, an edge bucket whose type is gone). Anything else propagates.

While re-running, concurrentVertexDeletionAndEdgeAppendingLeaveNoEdgeBehind proved flaky (~1 in 3), and
the cause is not this fix - it reproduces identically with the fix reverted. An edge an APPENDER commits
while its hub is being deleted can outlive that hub: the survivor has an existing out vertex and an in
naming the deleted hub, so it never existed when the deleter's collection walk ran and no vertex-side
strictness can prevent it. That is a separate defect on the append path, reported separately.

The test now asserts the invariant this fix actually owns - every edge that was IN the hub's list when
the deleter started must be gone with it, i.e. the fixture edges - instead of "no LINK edge survives at
all", which folded in the append-side defect. The round-start precondition moves from a global LINK
count to a per-hub degree for the same reason, so a leftover from an earlier round cannot make a healthy
round look wrong, and the end-of-run integrity check is dropped with the reason stated inline (the
non-racing fixtures still assert it). 10 consecutive green runs.

* docs(engine) #5680: record why IllegalArgumentException earns its place in the tolerated set

Fourth review follow-up on #5707, comment only.

IllegalArgumentException is the loosest member of the closed catch list and the one a future change is
most likely to throw for a non-corruption reason. It stays, because Binary raises it ("Invalid
position", Binary.java:179) for a content offset that decodes past the end of the buffer - a genuine
corruption shape, and the reason LocalDatabase carries it in the same family - and dropping it would
make a vertex with that damage undeletable again. Naming the origin means the periodic second look the
review asks for can be done without re-deriving it, and narrowing it later is a decision with the
evidence attached rather than a guess.
lvca added a commit that referenced this pull request Aug 2, 2026
…e delete instead of outliving the vertex (#5744)

* fix(engine) #5725: an edge appended behind a vertex delete retries the delete instead of outliving the vertex

deleteVertex is a read-modify-write over the whole edge list, but only the
write half left an MVCC footprint. The collection walk read the chain through
a plain lookup, which under READ_COMMITTED does not retain the pages, while
the removals and the chunk drain captured each page only later - at whatever
version it had by then. An edge appended in between was therefore already
part of the page the delete rewrote and then deleted: the commit-time version
check compared the newer version against itself, found no conflict, and
committed. The vertex was gone and the edge record survived with a live out
and an in naming nothing, which CHECK DATABASE reports as an invalid link.

Nothing on the removal side could have caught it - the edge did not exist
when the walk ran - so #5670/#5678/#5680 neither caused nor addressed it.

A vertex delete now pins, at the version it reads the list at, everything the
list can grow through: every chunk page (not just the head - an appender with
a handle predating a head flip lands in a chunk that is no longer the head
but is still in the chain), the stripe directory and every chain of every
generation on a promoted super-node, and the vertex's own head pointers,
re-read through their page just before the record is deleted for the append
that creates a brand new chunk and so touches no chunk page at all. Each is a
retryable conflict; all are skipped under force.

The pins cost nothing at the peak: the drain deletes every one of those
chunks anyway, so their pages end up in the transaction regardless.

Issue5680VertexDeleteEdgeCollectionTest can now assert the full invariant it
had to scope around, and ends with a clean CHECK DATABASE.

* docs(engine) #5725: two comments claimed more than the code does

Both from review of #5744, both comment-only.

The head re-read said "a read-only lookup is never tx-cached". Reads never
POPULATE the cache, but lookupByRID still consults it, so a record this
transaction wrote earlier is answered from there - which is exactly the case
the getWrittenRecord guard returns on, and the comment now says so instead
of implying the lookup bypasses the cache unconditionally.

The pin-ordering comment said a pin that fails under force still lets the
walk "collect what it can". It does not: the failure jumps to the catch and
the walk is skipped either way. What the ordering actually buys is a non-null
list, so the direction is still reported as present and the chunk drain runs.

* test(engine) #5725: isolate the cache-bypass assumption the head check rests on

From review of #5744, discussion point 2. checkEdgeListHeadsUnchanged re-reads
the vertex with a plain lookupByRID and trusts that answer to be the committed
one, which is only true because a READ never populates the transaction record
cache. Nothing tested that: every other test here deletes a vertex the delete
transaction never read first, so a caching read would have degraded the check
into comparing a value with itself and every one of them would still pass.

Two tests, because the assumption and the scenario fail differently:

- aPlainReadDoesNotPopulateTheTransactionRecordCache pins the fact itself, so a
  change to the read/cache path fails loudly and locally instead of quietly
  widening the window this issue is about. It passes with or without the fix by
  design - it guards an assumption, not the fix.

- aFreshReadInsideTheDeleteTransactionDoesNotBlindTheHeadCheck is the scenario:
  the delete transaction reads the vertex fresh, and the head it collected from
  is still the stale one, so the two disagree and the delete is refused. Fails
  without the fix.

The concurrent-append variant of this was tried first and dropped: the chunk
pins make the newly created chunk unreadable through the deleting transaction's
pages, so the strict head read of #5670 refuses it before the head comparison is
ever reached. Correct, but it made the test pass identically with the fix
reverted, which is not a regression test.

* fix(engine) #5725: a chain the pin cannot finish stops it, it does not fail it

From review of #5744. The pin walk runs before the collection walk, so a chunk
it could not read failed the whole direction before a single edge had been
collected. A delete over a partially broken chain used to disconnect everything
in front of the break from the vertices at the other end; it now disconnected
nothing and left those far-end pointers dangling on neighbours nobody asked to
touch - the "back-reference survives its edge" defect #5670 fixed, coming back
in through the pin.

The review scoped this to force. It is wider: the decode-family arm of
collectEdgesToDelete is tolerated with force == false too - that is what keeps a
vertex whose list is corrupt deletable at all (#4420/#4432) - so an undecodable
mid-chain chunk regressed the ordinary path the same way.

anchorChain now stops at a hop it cannot follow and leaves the pages up to that
point pinned. It must not be the thing that reports a broken chain: the
collection walk follows the same pointers in the same order, meets the same
wall, and the policy for what to do about it already lives there and is
carefully split between retryable and tolerated. Nothing is hidden by stopping
quietly, because that next walk raises what this one saw.

Two things stay outside the tolerance. The HEAD load still propagates - it is
where an append lands, and the collection walk does not re-read it, so
continuing past a head that could not be pinned would leave the collection
reading an unpinned page, which is the window this mechanism exists to close.
And only "cannot read this chunk" is absorbed: an I/O fault surfacing as
DatabaseOperationException is not a broken chain and must not be mistaken for
one, or the collection could read a chunk this pass failed to pin.

* docs #5725: name the one place the new conflict reaches a caller

From review of #5744. The release note said the standard retry loops absorb the
new ConcurrentModificationException and left the exception to a back-reference
to the #5670 note. Since this is an observable API change, spell it out where a
reader lands: a client-managed explicit transaction over RemoteDatabase spans
several HTTP requests, so its commit is not auto-retried and the exception
reaches the caller.
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…nstead of skipping it (#5678)

* fix(engine) #5670: an unreadable edge-list chunk retries the delete instead of skipping it

Deleting an edge disconnects it from both endpoints and then deletes the edge record. The
disconnection read the endpoint's chain best-effort: getEdgeHeadChunk answers null when the head
chunk cannot be loaded, the chain hops used a plain lookup, and deleteEdge wrapped both in a
catch (SchemaException | RecordNotFoundException). All three read "chunk unreadable" as "nothing to
remove here" - and the edge record below was deleted anyway.

Under concurrency a chunk is regularly unreadable for reasons that say nothing about the graph. A
commit publishes its pages one at a time and a reader takes no commit lock, so a vertex page can
expose a new edge-list head RID a moment before that head's own page is visible; and a chunk emptied
by another transaction is relinked out of the chain while a walker is still following a pointer to
it. Hitting either window ended the removal having removed nothing, so the back-reference outlived
its edge: the endpoint reported one edge too many and check database reported one broken link. That
is the reported ConcurrentEdgeAppendMergeTest failure - 3001 where 3000 was expected, with one
integrity error alongside it - and instrumenting the null return reproduces it exactly.

The append path (getOrCreateEdgeList) already answered this window with a retryable
ConcurrentModificationException. The removal path now does the same:

- getEdgeHeadChunkForWrite is the strict counterpart of getEdgeHeadChunk. Null means one thing only:
  the vertex has no edge list in that direction, so there is genuinely nothing to remove.
- deleteEdge splits endpoint resolution from chain mutation, so only a vanished endpoint VERTEX is
  tolerated - there is nothing to disconnect from a vertex that is gone.
- EdgeLinkedList.readChunk and loadChunkForWrite map an unreadable chunk to a retryable conflict.
  That subsumes StripedEdgeList.loadStripeHead, which is removed rather than left as dead code.
- EdgeIteratorFilter's opportunistic pruning of an already-dangling reference runs inside a READ and
  stays best-effort: it absorbs the new retryable conflict and leaves the ghost for a later pass.

Read paths are unchanged: iteration and counting still skip a momentarily unreadable chunk rather
than failing.

Tests: two deterministic contract tests (head chunk unreadable, mid-chain chunk unreadable) plus the
reported concurrent shape. All three fail on the current code and pass with the fix.

* fix(engine) #5670: read the head RID inside the strict lookup's try, where a lazy vertex can throw

Code review found a real gap in getEdgeHeadChunkForWrite: the head-RID read sat OUTSIDE its
try/catch. On a handle that has not materialised its record,
ImmutableVertex.getOutEdgesHeadChunk()/getInEdgesHeadChunk() calls checkForLazyLoading(), which
loads through LocalBucket.getRecord and raises RecordNotFoundException if the vertex was deleted
concurrently - and RecordNotFoundException is NOT a NeedRetryException, so it failed the transaction
outright rather than retrying it. The read-side getEdgeHeadChunk deliberately keeps that call inside
its try for the same reason.

The window is narrow but reachable: resolveEndpointToDisconnect checks existsRecord and resolves the
vertex, then getEdgeHeadChunkForWrite reads the head, and a concurrent delete landing between the two
slips past both guards. Leaving one non-retryable escape in a change whose whole point is to convert
these transients into retries was inconsistent. The read moves inside the try, and the message now
carries the underlying cause so the missing RID is still named.

Also from the review:

- The strict lookup's javadoc now states the price taken deliberately - a genuinely lost chunk is
  indistinguishable from a transiently invisible one, so it fails the removal on every attempt
  instead of completing it best-effort, with CHECK DATABASE as the repair path (issue #5680 records
  how that couples to the tolerance deleteVertex keeps).
- The release note's visible-behaviour section names moveEdge, which disconnects through deleteEdge
  and so shares the new contract.
- The stress test's worker catch says what it means: any exception surfacing there IS the bug, not a
  tolerated retry.

Tests: headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes pins the gap - it fails
on the previous commit with a bare RecordNotFoundException out of checkForLazyLoading.

* fix(engine) #5670: pin what a vertex delete does when its NEIGHBOUR's edge list is unreadable

Second code review raised a second-order effect the change had not stated: deleting a vertex
disconnects its edges from the vertices on the OTHER end too, so the strict removal reaches a
neighbour nobody asked to touch. Measured before/after on the same fixture - a healthy vertex whose
neighbour's IN head chunk is unreadable:

  before: delete SUCCEEDED (edge record gone, neighbour still pointing at it)
  after:  ConcurrentModificationException

Keeping that strict, deliberately. Succeeding there means deleting the edge record while the
neighbour keeps the back-reference, which is precisely the corruption this issue is about - and
inflicting it on a vertex the caller never named. Under the concurrency this fix targets the retry
resolves it; on a genuinely broken neighbour list the delete fails and CHECK DATABASE is the repair
path. Issue #5680 tracks whether vertex deletion should keep a tolerant escape hatch, and now covers
this case too.

Pinned by deletingAVertexWhoseNeighbourListIsUnreadableReportsAConflictRatherThanDanglingTheReference,
and stated in the strict lookup's javadoc and the release note.

The review also suspected Issue4432CorruptVertexDeleteTest of passing only because the scan happens
to reach the corrupt vertex before its neighbour. Checked, and it does not: LocalBucket detects the
invalid record size on the first read and deletes the record ("Invalid record size 33554444 for
record #1:0: deleting record"), so the corrupt vertex fails at RESOLUTION - which stays tolerant -
and never reaches the strict head lookup. Deleting the neighbour first, with the corrupt vertex still
present, raises the identical RecordNotFoundException with and without this branch.

* docs(engine) #5670: say how to recover when a genuinely broken list blocks a delete

Third code review's one ask before merge. The release note stated the trade - a genuinely broken
endpoint list now fails the delete instead of completing it best-effort - but not what a user who
hits it should DO. It now says: the symptom is a delete that keeps failing however often it retries
(ordinary contention succeeds on a retry), the recovery is CHECK DATABASE ... FIX and then retry, and
the repair is never blocked by the delete being blocked, because CHECK DATABASE reads edge lists
through the best-effort reader. Verified: GraphDatabaseChecker uses getEdgeHeadChunk exclusively, and
getEdgeHeadChunkForWrite has exactly one caller, deleteEdge.

Also records the accepted retry-pressure shift the review flagged: on a hot super-node the transient
publication window is now answered with a retry rather than passing silently, so those transactions
retry slightly more often - from "quietly wrong" to "occasionally repeated", landing on the same
super-node shape the bug affected. Names txRetryDelay/txRetries as the levers.

The review also read the existsRecord check in resolveEndpointToDisconnect as arguably redundant with
the resolution that follows. It is not, and the comment now says why: getOutVertex/getInVertex load
with loadContent=false and hand back a LAZY handle, so a deleted endpoint does not surface there at
all - it surfaces inside getEdgeHeadChunkForWrite, which maps it to a retryable conflict. The check is
what separates "vertex gone, nothing to disconnect" (tolerated) from "vertex present, list unreadable"
(retry). Dropping it would silently convert the first into the second - and
headChunkForWriteRaisesRetryableConflictWhenTheVertexItselfVanishes is exactly that path.

Graph package: 334 tests, 0 failures.

* test(engine) #5670: bound the concurrent wait, and correct a comment that said the opposite of its code

Fourth code review, both actionable items.

The stress test's workers caught Exception and the main thread awaited the latch without a timeout,
so an Error - or an AssertionError thrown off the main thread - would skip the countDown and hang the
run instead of failing it. Workers now catch Throwable and the await is bounded at 5 minutes against
a round that takes seconds, which is headroom a slow machine cannot exhaust but a wedged worker
will.

The review also spotted that deleteEdge's edge-record removal carried the comment "Use the database's
delete method to ensure proper index cleanup instead of directly calling bucket.deleteRecord()"
directly above a bucket.deleteRecord() call. Checked which of the two was wrong before touching
either: an edge carrying an indexed property was deleted and its index went from 1 entry to 0, so the
CODE is right and the comment was inverted. LocalDatabase.deleteRecordNoLock cleans the index entries
and fires the delete events before dispatching an Edge to deleteEdge, so this call is deliberately
the physical removal alone - going back through the database would repeat that work, not add it. The
comment now says that.

* docs(engine) #5670: give DELETE VERTEX the same prominence as edge delete, and guard a null endpoint

Fifth code review's one ask, plus its theoretical-but-cheap point 4.

The release note led with edge.delete() and mentioned the vertex reach only in passing, at the tail of
the trade-off paragraph - while DELETE VERTEX is in fact the widest-reaching of the three affected
operations, because it disconnects each edge from the vertex at the OTHER end and so lands the strict
read on a NEIGHBOUR's list. A healthy vertex can now fail to delete because of a neighbour. The
visible-effect section now lists the three operations that share the contract and says that outright,
including that a healthy vertex next to a corrupted one is not deletable by the normal path until the
corruption is repaired.

resolveEndpointToDisconnect now returns null for a null endpoint RID instead of reaching
existsRecord, which raises IllegalArgumentException on one - not covered by the catch below, not
retryable, and so a hard failure escaping the one method whose job is to decide what is tolerable.
An edge always carries both endpoints, so this is a guard rather than a case.

Graph package: 334 tests, 0 failures (includes main's new EdgesConnectedToTest).

* docs(engine) #5670: say why the ghost-prune catch is NeedRetryException and not the narrower CME

Sixth code review read the catch as wider than its comment justified and suggested narrowing it to
ConcurrentModificationException. Checked the other direction first: NeedRetryException has exactly
two subclasses, CME and LockTimeoutException, and the second one says the same thing about this
prune - come back for it later. Narrowing the catch would let a lock timeout escape an OPTIONAL
repair into the read that triggered it, which is the outcome the catch exists to prevent. So the
comment was the half that was wrong, and it now states that the condition being absorbed is "retry
later", not one particular cause.

No behaviour change; the comment now matches the code instead of the code being narrowed to match the
comment.

(cherry picked from commit 86927bb)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…instead of losing its edges (#5707)

* fix(engine) #5680: an unreadable edge list retries the vertex delete instead of losing its edges

deleteVertex collected the edges to remove through the best-effort getEdgeHeadChunk with the whole
walk wrapped in catch (Exception), so any part of the list that could not be read at that instant
was taken for "nothing to remove here" and the vertex record was deleted on top of that empty or
partial view: the edges outlived their endpoint, with a back-reference left on an innocent
neighbour. Three routes reached that outcome - an unreadable head chunk, an unreadable hop mid-chain
(everything behind the hole dropped), and, on a promoted super-node, a stripe chain the read walk is
allowed to skip, which lost a whole stripe with no exception behind it at all. None of them is a
fact about the graph; they are the transient publication window #5670 was about, which deleteEdge
has answered with a retry since #5678.

The collection now reads the list the way a removal must: the head through getEdgeHeadChunkForWrite,
the walk through the new EdgeLinkedList.edgeIteratorForRemoval (strict over stripe chains), and the
chain hops mapped to a retryable ConcurrentModificationException. force is the single escape hatch
and now works end to end: it also absorbs the conflict raised while disconnecting a collected edge
from the vertex at its OTHER end, which previously let a broken neighbour block a forced delete.
Draining the leftover chunk records stays best-effort in both modes on purpose - by then a miss
loses orphaned chunks, never a reference. The heads are also read from the transaction's own vertex
instance (#5660), closing a fourth silent-loss route that needed no race.

Strictness costs no repairability: CHECK DATABASE ... FIX rebuilds an unloadable chain from the
surviving edge records and is never blocked by the delete being blocked.

* docs(engine) #5680: state why a chunk that cannot be DECODED stays tolerated, and where the striped conflict is raised

Review follow-ups on #5707, both documentation.

The blanket catch (Exception) left in collectEdgesToDelete is the residue once the transient window is
split off into the retryable branch: an undecodable buffer, which no retry can fix and which is
tolerated even when force is false. That reads as inconsistent with the strictness around it until you
follow where such a record comes from - LocalDatabase.deleteRecordNoLock catches the same exception
family around the index cleanup and proceeds WITHOUT raising its force flag, so a corrupt-buffer vertex
arrives here with force == false and failing would make it undeletable again (#4420, #4432). Its cost is
larger than a single tolerated dangling entry, so the warning now names it and points at CHECK DATABASE.

hasNextEdgeToDelete's javadoc claimed only a chain hop surfaces through it, which is true on the classic
layout but not the whole story on a promoted super-node: StripedEdgeList.edgeIteratorForRemoval resolves
every stripe head while BUILDING the iterator, so an unloadable stripe raises there instead. Same try,
same handling - the note keeps a future reader from reading the helper as the only strict point.

* test(engine) #5680: pin the self-loop delete, and say what the strict/tolerant split actually keys on

Second review follow-ups on #5707.

A self-loop is the one shape both collection walks yield, so it lands in the delete list twice and the
second removal runs against a chain the first already cleaned. That is unchanged by this fix and
resolves quietly, but the removal walk is stricter now and a self-loop is the shape most likely to
surprise a later change here, so it gets a test rather than an argument.

The two catch arms in collectEdgesToDelete are easy to read as transient-versus-permanent, which is
wrong and would make the next reader draw the line in the wrong place. They key on what a MISS COSTS: a
chunk that cannot be found retries because deleting the vertex on a short list loses references, and
that is applied to a genuinely lost chunk too - no retry recovers it, so the delete now fails once the
retries are spent instead of quietly completing. Stated explicitly, since it is a deliberate behaviour
change for the lost-chunk case and not only for the transient one.

* fix(engine) #5680: close the tolerant catch to named corruption shapes, and scope the concurrent test to what this fix owns

Third review follow-up on #5707, plus a flake the follow-up exposed.

The trailing catch (Exception) in collectEdgesToDelete would have absorbed an NPE or an
IllegalStateException from a future change as "corrupted chunk, delete anyway" - reintroducing exactly
the silent-edge-loss path this issue exists to remove. It now names the shapes it tolerates: the decode
family LocalDatabase already uses for the same purpose, plus ClassCastException and SchemaException,
which are the two further shapes a corrupt EDGE LIST adds (a head RID naming a record that is not an
edge segment, an edge bucket whose type is gone). Anything else propagates.

While re-running, concurrentVertexDeletionAndEdgeAppendingLeaveNoEdgeBehind proved flaky (~1 in 3), and
the cause is not this fix - it reproduces identically with the fix reverted. An edge an APPENDER commits
while its hub is being deleted can outlive that hub: the survivor has an existing out vertex and an in
naming the deleted hub, so it never existed when the deleter's collection walk ran and no vertex-side
strictness can prevent it. That is a separate defect on the append path, reported separately.

The test now asserts the invariant this fix actually owns - every edge that was IN the hub's list when
the deleter started must be gone with it, i.e. the fixture edges - instead of "no LINK edge survives at
all", which folded in the append-side defect. The round-start precondition moves from a global LINK
count to a per-hub degree for the same reason, so a leftover from an earlier round cannot make a healthy
round look wrong, and the end-of-run integrity check is dropped with the reason stated inline (the
non-racing fixtures still assert it). 10 consecutive green runs.

* docs(engine) #5680: record why IllegalArgumentException earns its place in the tolerated set

Fourth review follow-up on #5707, comment only.

IllegalArgumentException is the loosest member of the closed catch list and the one a future change is
most likely to throw for a non-corruption reason. It stays, because Binary raises it ("Invalid
position", Binary.java:179) for a content offset that decodes past the end of the buffer - a genuine
corruption shape, and the reason LocalDatabase carries it in the same family - and dropping it would
make a vertex with that damage undeletable again. Naming the origin means the periodic second look the
review asks for can be done without re-deriving it, and narrowing it later is a decision with the
evidence attached rather than a guess.

(cherry picked from commit 324eca1)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…e delete instead of outliving the vertex (#5744)

* fix(engine) #5725: an edge appended behind a vertex delete retries the delete instead of outliving the vertex

deleteVertex is a read-modify-write over the whole edge list, but only the
write half left an MVCC footprint. The collection walk read the chain through
a plain lookup, which under READ_COMMITTED does not retain the pages, while
the removals and the chunk drain captured each page only later - at whatever
version it had by then. An edge appended in between was therefore already
part of the page the delete rewrote and then deleted: the commit-time version
check compared the newer version against itself, found no conflict, and
committed. The vertex was gone and the edge record survived with a live out
and an in naming nothing, which CHECK DATABASE reports as an invalid link.

Nothing on the removal side could have caught it - the edge did not exist
when the walk ran - so #5670/#5678/#5680 neither caused nor addressed it.

A vertex delete now pins, at the version it reads the list at, everything the
list can grow through: every chunk page (not just the head - an appender with
a handle predating a head flip lands in a chunk that is no longer the head
but is still in the chain), the stripe directory and every chain of every
generation on a promoted super-node, and the vertex's own head pointers,
re-read through their page just before the record is deleted for the append
that creates a brand new chunk and so touches no chunk page at all. Each is a
retryable conflict; all are skipped under force.

The pins cost nothing at the peak: the drain deletes every one of those
chunks anyway, so their pages end up in the transaction regardless.

Issue5680VertexDeleteEdgeCollectionTest can now assert the full invariant it
had to scope around, and ends with a clean CHECK DATABASE.

* docs(engine) #5725: two comments claimed more than the code does

Both from review of #5744, both comment-only.

The head re-read said "a read-only lookup is never tx-cached". Reads never
POPULATE the cache, but lookupByRID still consults it, so a record this
transaction wrote earlier is answered from there - which is exactly the case
the getWrittenRecord guard returns on, and the comment now says so instead
of implying the lookup bypasses the cache unconditionally.

The pin-ordering comment said a pin that fails under force still lets the
walk "collect what it can". It does not: the failure jumps to the catch and
the walk is skipped either way. What the ordering actually buys is a non-null
list, so the direction is still reported as present and the chunk drain runs.

* test(engine) #5725: isolate the cache-bypass assumption the head check rests on

From review of #5744, discussion point 2. checkEdgeListHeadsUnchanged re-reads
the vertex with a plain lookupByRID and trusts that answer to be the committed
one, which is only true because a READ never populates the transaction record
cache. Nothing tested that: every other test here deletes a vertex the delete
transaction never read first, so a caching read would have degraded the check
into comparing a value with itself and every one of them would still pass.

Two tests, because the assumption and the scenario fail differently:

- aPlainReadDoesNotPopulateTheTransactionRecordCache pins the fact itself, so a
  change to the read/cache path fails loudly and locally instead of quietly
  widening the window this issue is about. It passes with or without the fix by
  design - it guards an assumption, not the fix.

- aFreshReadInsideTheDeleteTransactionDoesNotBlindTheHeadCheck is the scenario:
  the delete transaction reads the vertex fresh, and the head it collected from
  is still the stale one, so the two disagree and the delete is refused. Fails
  without the fix.

The concurrent-append variant of this was tried first and dropped: the chunk
pins make the newly created chunk unreadable through the deleting transaction's
pages, so the strict head read of #5670 refuses it before the head comparison is
ever reached. Correct, but it made the test pass identically with the fix
reverted, which is not a regression test.

* fix(engine) #5725: a chain the pin cannot finish stops it, it does not fail it

From review of #5744. The pin walk runs before the collection walk, so a chunk
it could not read failed the whole direction before a single edge had been
collected. A delete over a partially broken chain used to disconnect everything
in front of the break from the vertices at the other end; it now disconnected
nothing and left those far-end pointers dangling on neighbours nobody asked to
touch - the "back-reference survives its edge" defect #5670 fixed, coming back
in through the pin.

The review scoped this to force. It is wider: the decode-family arm of
collectEdgesToDelete is tolerated with force == false too - that is what keeps a
vertex whose list is corrupt deletable at all (#4420/#4432) - so an undecodable
mid-chain chunk regressed the ordinary path the same way.

anchorChain now stops at a hop it cannot follow and leaves the pages up to that
point pinned. It must not be the thing that reports a broken chain: the
collection walk follows the same pointers in the same order, meets the same
wall, and the policy for what to do about it already lives there and is
carefully split between retryable and tolerated. Nothing is hidden by stopping
quietly, because that next walk raises what this one saw.

Two things stay outside the tolerance. The HEAD load still propagates - it is
where an append lands, and the collection walk does not re-read it, so
continuing past a head that could not be pinned would leave the collection
reading an unpinned page, which is the window this mechanism exists to close.
And only "cannot read this chunk" is absorbed: an I/O fault surfacing as
DatabaseOperationException is not a broken chain and must not be mistaken for
one, or the collection could read a chunk this pass failed to pin.

* docs #5725: name the one place the new conflict reaches a caller

From review of #5744. The release note said the standard retry loops absorb the
new ConcurrentModificationException and left the exception to a back-reference
to the #5670 note. Since this is an observable API change, spell it out where a
reader lands: a client-managed explicit transaction over RemoteDatabase spans
several HTTP requests, so its commit is not auto-retried and the exception
reaches the caller.

(cherry picked from commit 94fa794)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ConcurrentEdgeAppendMergeTest.concurrentAppendsAndRemovesStayConsistent reports one edge too many under load

1 participant