fix(engine) #5670: an unreadable edge-list chunk retries the delete instead of skipping it - #5678
Conversation
…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.
|
Tick the box to add this pull request to the merge queue (same as
|
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 8 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
|
Review: 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 ( A few points worth considering: 1. In practice 2. Permanent corruption is now converted into a permanent failure (deliberate, worth confirming) 3. Tests / style
I did not run the build/suite as part of this review; the PR reports a clean |
…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.
|
Thanks - all three points were valid. Addressed in 51c212b. 1. Head RID read outside the try — fixed, and it was reachable. Confirmed the mechanism: Rather than just move it, I pinned it: The message now carries the underlying cause, so the missing RID is still named. 2. Permanent corruption → permanent failure. Confirmed and deliberate. 3. Test nit — taken: the worker Re-ran the graph package after the change: 320 tests, 0 failures, including |
Review: #5678 - unreadable edge-list chunk retries the delete instead of skipping itThorough, well-argued change. The core idea is sound and consistent with the codebase: the removal path now draws the same line the append path ( I verified statically that Main point worth a second look:
|
… 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.
|
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 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
so the corrupt vertex fails at resolution — which stays tolerant — and never reaches
Log/metric on retry exhaustion. Agreed in principle, but the right home is the generic retry-exhaustion point in Graph package after the change: 321 tests, 0 failures, including |
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.
Review: #5670 unreadable edge-list chunk retries the delete instead of skipping itReviewed the diff, the surrounding What I verified
The main thing to weigh (already acknowledged, worth stating plainly)The deliberate trade in
Minor
Style / conventionsConsistent with CLAUDE.md: 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 |
…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.
|
Both asks addressed in ba56346. 1. 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 3. On the minor point — the Graph package after the change: 334 tests, 0 failures. |
Code review: #5678 - edge-delete dangling back-reference fixReviewed all five changed files ( What's good
Observations / questions (none blocking)
Verification noteI 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 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.
|
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 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. 1. 2. Read-side strictness via Graph package after the change: 334 tests, 0 failures. Full |
Review: fix(engine) #5670 - unreadable edge-list chunk retries the delete instead of skipping itThorough, well-reasoned change. The core idea is sound: the append path ( I traced every caller of the now-strict methods (
Atomicity is preserved: if OUT Points worth confirming
Test coverageExcellent. 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 Nits
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 |
…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).
|
Both actionable items in d73cad5. 1. 4. Null endpoint — guarded. You are right that 2. Permanent-failure trade-off — acknowledged, and now stated in the release note in operational terms alongside the 3. Graph package after the change: 334 tests, 0 failures — including main's new |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 13 |
🟢 Coverage 66.67% diff coverage · -7.58% coverage variation
Metric Results Coverage variation ✅ -7.58% coverage variation Diff coverage ✅ 66.67% diff coverage 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.
|
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
Points worth considering
Nits
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.
|
All three checked; one produced a change (b0271ab), two I am deliberately not making — reasoning below. 1. 2. Unguarded 3. Server-side surfacing of the new CME. Verified rather than assumed: Graph package: 334 tests, 0 failures. |
Review: #5670 - unreadable edge-list chunk retries the delete instead of skipping itThis 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 ( Correctness - verified
Test coverageStrong. 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. Minor observations (non-blocking)
Style / conventionsMatches the surrounding code: PerformanceThe hot removal walk gains only a 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 |
|
Thanks. Checked the one actionable minor; it turns out not to apply, and I am leaving the test as it is. 1. // LocalDatabase.transaction(...), line 1387
final int retryDelay = GlobalConfiguration.TX_RETRY_DELAY.getValueAsInteger();That is the global static, not the database's 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:
Follow-ups filed: #5679 (load-sensitive |
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
… 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.
…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.
…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.
…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)
…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)
…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)
Fixes #5670.
The issue is real, and it reproduces
The reported
ConcurrentEdgeAppendMergeTestfailure -expected: 3000L but was: 3001Lwith onecheckDatabaseIntegrityerror alongside - is not a test-side race. A tightened version of the sameworkload (8 threads x 60 delete+append iterations on a hub, 40 rounds) fails in ~2 of 3 runs with the
identical signature:
Root cause
Instrumenting the swallow points caught it exactly:
GraphEngine.deleteEdgedisconnects 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:
getEdgeHeadChunkanswersnullwhen the head chunk cannot be loaded,EdgeLinkedList.readChunk) used a plain lookup,deleteEdgewrapped the lot incatch (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 exactlythis transient with a retryable
ConcurrentModificationException, as doesStripedEdgeList.addChainfor the per-stripe chains. The removal path never got the same treatment.GraphEngine.getEdgeHeadChunkForWrite- strict counterpart ofgetEdgeHeadChunk.nullnowmeans 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 isnothing to disconnect from a vertex that is gone.
EdgeLinkedList.readChunk/loadChunkForWrite- an unreadable chunk on a removal walk is aretryable conflict. This subsumes
StripedEdgeList.loadStripeHead, which is removed ratherthan left behind as dead code.
EdgeIteratorFilter- the opportunistic pruning of an already-dangling reference runs inside aREAD, 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 raiseConcurrentModificationExceptionwhere it previously "succeeded". That is aNeedRetryException, sodatabase.transaction(...)and the server's auto-retry for single-request commands absorb it. Aclient-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 withthe fix:
edgeDeleteRaisesRetryableConflictWhenTheEndpointHeadChunkIsUnreadableedgeDeleteRaisesRetryableConflictWhenAMidChainChunkIsUnreadableconcurrentDeleteAndAppendNeverLeaveADanglingBackReference@Tag("slow"); the reported shapeThe 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
enginesuite, three runs.ExplicitLockingTransactionTest.errorOnExplicitLockfailed on thefirst 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 -
explicitLockthrows only whenimmutablePagesis non-empty,and under
REPEATABLE_READa page is retained only whenpageNumber < file.getTotalPages(), which isthe 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.
Left out, deliberately
deleteVertexkeeps its documented best-effort edge disconnection (Issue4420TolerantDeleteTest,Issue4432CorruptVertexDeleteTestpin that a structurally broken vertex stays deletable). It isexposed 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.