Skip to content

fix(#6083): reclaim orphan edge records and report an exact edge count on a failed GraphBatch flush - #6089

Merged
lvca merged 4 commits into
mainfrom
issue-6083-graphbatch-followups
Aug 12, 2026
Merged

fix(#6083): reclaim orphan edge records and report an exact edge count on a failed GraphBatch flush#6089
lvca merged 4 commits into
mainfrom
issue-6083-graphbatch-followups

Conversation

@lvca

@lvca lvca commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes items 1, 2, 4 and 5 of #6083 (follow-ups from #6070 / PR #6076). Items 1, 2 and 4 all sit on the same code path, which is why they are taken together.

Item 2 - a failed edge flush left orphan edge records

A GraphBatch edge is written in two steps that do not share a transaction: PHASE 1 of flush() creates the record, PHASE 3 links it into its source vertex's edge list. Both flush paths commit in between - the parallel one must (the record pages have to be released before the per-bucket tasks touch them), the sequential one does whenever commitEvery > 0. A connect pass dying part-way therefore left edge records behind that no vertex points at: countType() counted them, no traversal reached them, nothing ever reclaimed them.

The issue asked whether CHECK DATABASE detects and reclaims them. It does not, and the detail matters:

  • checkEdges does scan every edge record and probe both endpoints for a back-reference (GraphDatabaseChecker.java:1150-1152, 1183-1185)...
  • ...but a miss only increments the aggregate missingReferenceBack counter. No warning naming the record, no entry in corruptedRecords, so nothing for the FIX variant to delete.
  • That same counter is raised by a perfectly healthy unidirectional edge, legitimately absent from its target's IN list, so it cannot even be read as an orphan count.
  • (FIX does reclaim orphaned edge segments - but those are the internal linked-list chunks, not the edge records at issue.)

So the flush now cleans up after itself before the failure propagates. Deletion goes through database.deleteRecord, which is the one inverse correct for both creation routes this class uses (createRecordsBulk + indexEdgeProperties for a single-bucket flush, an ordinary save() for a multi-type one) and for every index kind, without a second copy of the key-derivation logic to keep in step. Best-effort by construction: what it cannot reclaim is logged and counted (getOrphanEdgeRecordsLeaked()), never allowed to replace the failure the caller is already handling.

A partial failure also left half-edges

Found while writing the test. A partially-failed flush skipped PHASE 4 entirely, so the edges it had durably connected never got their IN back-pointer - half-edges the integrity checker flags. PHASE 3's failure is now captured rather than thrown, so PHASE 4 queues the incoming side for exactly the edges that survived, and the failure is rethrown after. A failed flush now leaves only whole edges.

Item 4 - the partial-commit edge count is now exact, not a lower bound

totalEdgesCreated advanced a whole flush at a time, so a load dying mid-flush reported zero for a flush that had in fact made some of its edges durable. It now advances one durable commit at a time, on the failure path as well as the success one. A caller reconciling a failed bulk load re-sends exactly what is missing instead of over-sending a flush's worth and relying on the duplicates being harmless.

Bonus: the sequential OUT path had no undo log (found by the new test)

connectOutgoingEdgesSorted wrote deferredOutHead/outChunkRIDCache before the transaction holding those segments committed, with nothing to undo it. A rolled-back segment RID survived in the map and batchUpdateVertexHeadChunks() stamped it onto the vertex at close() - CHECK DATABASE reports "out edges record is not valid" and no later read can follow it.

This is exactly the hazard #5950 cycle 3 fixed for the IN direction (inHeadUndoLog) and cycle 4 for the parallel OUT direction (the per-bucket local maps). This path was the one left. Same remedy, and the final commit moved inside the pass so the undo log covers it too.

Item 1 - vertex_batch_size was clamped silently

The cap is right - a caller-supplied buffer must not be able to exhaust the server heap on one request - but it applied with no log line, so a caller who asked for more got something else and was never told. Now a WARNING naming both the requested and the effective value.

Item 5 - the reporting convention is settled and written down

The two contradictory precedents are resolved on graphBatchLoad's javadoc: a failed bulk stream terminates with onError and carries its counters in the trailers, so the caller keeps its status code and generic error handling while still getting what it needs to reconcile. insertStream is documented as the predating exception (its summary is a per-chunk report consumed on the success path too, so failure is not a separate channel there) rather than a second precedent.

Not in this PR

  • Item 3 (the leader's gRPC address is not discoverable) needs a new field in the HA server-list format - a public config-surface change - plus a real 3-node cluster IT to exercise the follower-refusal path. It gets its own PR so that review is not entangled with these defect fixes.
  • Items 6 and 7 were recorded in the issue as deliberately declined; nothing to do.

Testing

Issue6083OrphanEdgeRecordTest (new) covers, for both flush paths:

  • a failed flush leaves no edge record that a traversal cannot reach (countType == reachable, not an upper bound)
  • getTotalEdgesCreated() equals that same number
  • the flush that fails before its first commit has nothing durable to reclaim and must not try
  • a successful multi-flush, multi-internal-commit load still counts every edge exactly once

Every assertion was mutation-checked. Disabling the orphan reclaim, reverting the exact counting, and removing the new OUT undo log each makes a test fail; so does removing the clamp warning. (The issue warned that three tests written during #6076 initially passed against the very bug they were meant to catch.)

Issue6070GraphBatchLoadHardeningIT:

  • the trailer test now asserts the exact count and that countType agrees with a traversal - which is precisely what it could not do before, and why it had been written around the symptom
  • two new tests for the clamp warning (present when clamped, absent when honoured)
  • its shared vertex() helper now also sets the property another test declares MANDATORY, so the class no longer passes or fails depending on method order

Suites run green: all com.arcadedb.graph.*Test and com.arcadedb.database.*Test (engine), all 172 grpcw unit tests + the Issue6070* ITs, RemoteGraphBatchTest (network), RemoteGraphBatchIT (server).

On throughput (correcting the class name from the original description - there is no GraphBatchBenchmark): GraphBatchTest.benchmarkBatchVsStandard covers the parallel path (3.41x over the standard API, unchanged), and performance/GraphBatchDrainPerfBenchmark covers both - parallelFlush=true at ~653K edges/sec and parallelFlush=false at ~742K, also unchanged. So the sequential path, which is where the new per-flush undo-log bookkeeping lives, is represented.

🤖 Generated with Claude Code

…t on a failed GraphBatch flush

Follow-ups 1, 2, 4 and 5 from #6083 (#6070 / PR #6076). Items 1, 2 and 4 all sit
on the same code path and are taken together.

Item 2 - a failed edge flush left orphan edge records.

A GraphBatch edge is written in two steps that do not share a transaction:
PHASE 1 of flush() creates the record, PHASE 3 links it into its source vertex's
edge list. Both flush paths commit in between - the parallel one must (the
record pages have to be released before the per-bucket tasks touch them), the
sequential one does whenever commitEvery > 0. A connect pass dying part-way
therefore left edge RECORDS behind that no vertex points at: countType() counted
them, no traversal reached them, and nothing ever reclaimed them.

CHECK DATABASE does not rescue them either. Its checkEdges pass does scan every
edge record and probe both endpoints for a back-reference, but a miss only
increments the aggregate missingReferenceBack counter - no warning naming the
record, no entry in corruptedRecords, so nothing for FIX to delete. The same
counter is raised by a healthy UNIDIRECTIONAL edge, legitimately absent from its
target's IN list, so it cannot be read as an orphan count. (FIX does reclaim
orphaned edge SEGMENTS, but those are the internal linked-list chunks, not these
records.)

So the flush now cleans up after itself before the failure propagates, through
database.deleteRecord - the one inverse correct for both creation routes
(createRecordsBulk + indexEdgeProperties for a single-bucket flush, an ordinary
save() for a multi-type one) and for every index kind, with no second copy of
the key-derivation logic to keep in step. Best-effort: what it cannot reclaim is
logged and counted, never allowed to replace the caller's failure.

A partially-failed flush also used to skip PHASE 4 entirely, leaving the edges
it HAD connected without their IN back-pointer - half-edges the integrity
checker flags. PHASE 3's failure is now captured rather than thrown so PHASE 4
queues the incoming side for exactly the edges that survived, and the failure is
rethrown after. A failed flush now leaves only whole edges.

Item 4 - the partial-commit edge count was a lower bound, now exact.

totalEdgesCreated advanced a whole flush at a time, so a load dying mid-flush
reported zero for a flush that had made some of its edges durable. It now
advances one durable commit at a time, on the failure path as well as the
success one. A caller reconciling a failed bulk load re-sends exactly what is
missing instead of over-sending a flush's worth and relying on the duplicates
being harmless.

Also fixed, found by the new test: the SEQUENTIAL outgoing path had no undo log
for deferredOutHead, so a rolled-back segment RID survived in the map and
batchUpdateVertexHeadChunks() stamped it onto the vertex at close() - CHECK
DATABASE reports "out edges record is not valid" and no later read can follow
it. This is the hazard #5950 cycle 3 fixed for the IN direction and cycle 4 for
the parallel OUT direction; this path was the one left. Same remedy. The final
commit moved inside the pass so the undo log covers it too.

Item 1 - vertex_batch_size was clamped silently.

The cap is right, but it applied with no log line, so a caller who asked for
more got something else and was never told. Now a WARNING naming the requested
and the effective value.

Item 5 - the two contradictory precedents for reporting a failed bulk stream are
settled and written down on graphBatchLoad: a failed stream terminates with
onError and carries its counters in the trailers; insertStream is documented as
the predating exception, not a second precedent.

Items 3, 6 and 7 are not addressed here. 3 (the leader's gRPC address is not
discoverable) needs a new field in the HA server-list format and a real cluster
test, so it gets its own PR. 6 and 7 were recorded in the issue as deliberately
declined.

Tests: Issue6083OrphanEdgeRecordTest covers both flush paths for orphan cleanup
and exact counting, plus the flush that fails before its first commit and so has
nothing durable to reclaim. Every assertion was mutation-checked - disabling the
reclaim, the exact counting and the new undo log each makes a test fail, as does
removing the clamp warning. Issue6070GraphBatchLoadHardeningIT's trailer test now
asserts the exact count and that countType agrees with a traversal, which is what
it could not do before; it also gains the two clamp-warning tests, and its shared
vertex helper now sets the property another test declares MANDATORY, so the class
no longer depends on method order.
@mergify

mergify Bot commented Aug 12, 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 12, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Coverage 95.68% diff coverage · -6.55% coverage variation

Metric Results
Coverage variation -6.55% coverage variation
Diff coverage 95.68% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (cf6273f) 155184 121450 78.26%
Head commit (b945c83) 187657 (+32473) 134568 (+13118) 71.71% (-6.55%)

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 (#6089) 185 177 95.68%

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 12, 2026

Copy link
Copy Markdown

Review of #6089

This is a carefully engineered fix with unusually thorough in-code rationale for a genuinely subtle two-phase-commit problem (edge record creation vs. linking). The overall design (capturing PHASE 3 failures instead of throwing immediately, reclaiming orphan records, tracking exact durable counts) is sound, and the reasoning in the comments checks out against the transaction/commit semantics elsewhere in the engine. One real accounting bug found, plus a couple of lower-severity notes.

Bug: reclaimOrphanEdgeRecords mis-attributes already-reclaimed records as "leaked" if a later internal commit fails

GraphBatch.java:889-954 (reclaimOrphanEdgeRecords). When commitEvery > 0, the reclaim loop itself commits periodically (GraphBatch.java:925-929) and only folds the local reclaimed/leaked counters into the instance totals after the loop finishes successfully (GraphBatch.java:932-935). If a later commit within that same reclaim pass throws (either a subsequent periodic commit, or the final database.commit() at line 932), control goes to the outer catch at line 942, which does:

totalOrphanEdgeRecordsLeaked += toSortPos - fromSortPos;

This blames the entire [fromSortPos, toSortPos) range as leaked, even though a prefix of it may already have been durably deleted by an earlier periodic commit inside the same call. The local reclaimed counter from that earlier, successful portion is simply discarded, never added to totalOrphanEdgeRecordsReclaimed. Net effect: getOrphanEdgeRecordsReclaimed() under-reports and getOrphanEdgeRecordsLeaked() over-reports (and can double-count records that were, in fact, already durably deleted) whenever a reclaim pass with commitEvery > 0 fails partway through its own cleanup.

This doesn't corrupt data (the records that were durably deleted stay deleted), but it directly undermines the stated goal of items 2/4 - "exact" counts a caller can reconcile against - for the doubly-unlucky case where the original flush fails and the cleanup itself later fails too. Worth folding reclaimed/leaked into the totals incrementally (e.g. after every periodic commit) rather than only on full-loop success. The new Issue6083OrphanEdgeRecordTest only exercises commitEvery of 0 or 1 with a single failure, so this path isn't covered by the new tests either - might be worth a regression test that forces a second failure inside the reclaim pass itself, if that's feasible to simulate.

Minor / non-blocking

  • outHeadUndoLog boxes Long -> RID (GraphBatch.java:313, the new undo log in connectOutgoingEdgesSorted) where deferredOutHead/deferredInHead deliberately use LongObjectHashMap a few lines above with an explicit "zero-boxing, ~5x less memory" comment. LongObjectHashMap supports put/get/containsKey/remove/forEach (just not entrySet()), so the undo log could plausibly use it too, iterating via forEach in the catch block instead of entrySet(). That said, this mirrors the pre-existing localDeferredOutHead/localOutChunkCache scratch maps in connectOutgoingEdgesParallel, which already use plain HashMap, so it's consistent with existing precedent in this file rather than a new regression - just flagging it since it's now allocated (and populated once per distinct source vertex) on every sequential flush, not only on failure, and CLAUDE.md calls out GC pressure as a standing concern for this codebase.
  • The PR description cites GraphBatchBenchmark showing no regression, but that benchmark class isn't in the repo (couldn't find it under engine/src/test), so it's hard to confirm which flush path (parallel vs. sequential) it exercises. If it's parallel-only (the default, per the new orphan-record test's own naming), the sequential path's new per-flush HashMap allocation/undo-log bookkeeping wouldn't be represented in that number.

What I traced through carefully and did not find a problem with

  • The startedTx/rollback interaction between reclaimOrphanEdgeRecords's internal database.rollback() and a caller-owned pre-existing transaction: confirmed that recordsAreDurable (and therefore an internal rollback) is only ever true after a GraphBatch-owned periodic commit has already happened, so the transaction it rolls back is never one a caller opened before calling into GraphBatch. No caller-managed transaction is at risk here.
  • The parallel path's reuse of bucketCounts/bucketOffsets for computing flushDurableOutRanges after async.waitCompletion() - confirmed nothing else in the flush's remaining control flow (accumulateIncomingEdges, the deferred-IN drain) repartitions those shared fields before they're read.
  • The connectOutgoingEdgesSorted undo log correctly snapshots each vertex's pre-slice deferredOutHead value exactly once per commit slice (the containsKey guard) and is cleared at every successful periodic/final commit, so a rollback only undoes what's actually uncommitted.

Other observations

  • Security: no concerns - the new log lines use the existing %d/parameterized LogManager API rather than string concatenation, so there's no injection surface.
  • Test coverage is otherwise solid: both flush paths, the "fails before first commit" edge case, and a successful multi-commit load are all covered, and the description states every assertion was mutation-checked, which is good practice worth keeping up.

…om both head-chunk undo logs

Code review cycle 1 on PR #6089.

Accounting bug in reclaimOrphanEdgeRecords. With commitEvery > 0 the reclaim
pass commits several times, but it folded its reclaimed/leaked counters into the
instance totals only once, after the loop. A commit failing LATER in the same
pass therefore discarded the reclaims an earlier commit had already made
durable, and the outer handler blamed the whole [from,to) range as leaked -
under-reporting reclaims and counting records that were in fact already gone. It
also blamed lightweight edges, which never had a record to leak.

Both counters now advance as each commit returns, and the failure path charges
only the slice that commit was covering, sized by countOrphanCandidates() so
lightweight edges are excluded. This is the one path whose entire purpose is to
leave an accurate count behind, so "close enough" was the wrong answer.

Covered by a new test that injects a failure into the SECOND reclaim commit via
a TEST_BEFORE_ORPHAN_RECLAIM_COMMIT_HOOK, following the fault-injection hooks
this class already carries for the vertex and edge commit paths. It asserts what
the database actually holds: stored-minus-reachable equals the leaked count, and
reclaimed plus leaked accounts for every orphan candidate exactly once.
Mutation-checked - restoring the deferred fold makes it fail. The test
deliberately leaves unreclaimed orphans behind, so it removes them before the
shared teardown's integrity check runs; they trip that check only because the
bogus source bucket used to induce the failure makes them dangling links, not
because they are orphans.

Boxing in the undo logs. outHeadUndoLog is allocated on every sequential flush
and takes an entry per distinct source vertex, which is exactly what
LongObjectHashMap exists for in this class (~16 bytes/entry against 72-90 for
HashMap<Long, RID>). Switched, iterating with forEach() in the catch block. A
null VALUE still records "the vertex had no deferred head" faithfully: occupancy
lives in the key array, so containsKey() answers true for a key put with a null
value.

The IN side's inHeadUndoLog is converted too. It predates this PR and had the
same cost, and leaving two identical structures with different collection types
a few hundred lines apart is a worse trap than the allocation. Covered by the
existing #5950 tests.
@lvca

lvca commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Thanks - all three points were valid and are addressed in d7f2775.

The accounting bug: fixed, and it was slightly worse than described. Confirmed exactly as reported: with commitEvery > 0 the reclaim pass commits several times but folded its counters only after the loop, so a later commit failing discarded the reclaims an earlier one had already made durable and the catch blamed the whole range. Two additions on top of the reported case:

  • the old toSortPos - fromSortPos also counted lightweight edges, which never allocated a record and so can never leak;
  • the local reclaimed counter was itself only durable-by-luck, since it included deletions in the still-open transaction.

Both counters now advance as each commit returns, and the failure path charges only the slice that commit was covering, sized by a new countOrphanCandidates() that excludes lightweight edges.

Covered by aReclaimPassThatFailsPartwayKeepsWhatItAlreadyCommitted, which injects a failure into the second reclaim commit through a new TEST_BEFORE_ORPHAN_RECLAIM_COMMIT_HOOK - following the fault-injection hooks this class already carries for the vertex and edge commit paths, so the pattern is not new. It asserts against what the database actually holds rather than against the counters alone: stored - reachable == leaked, and reclaimed + leaked accounts for every orphan candidate exactly once. Mutation-checked - restoring the deferred fold makes it fail on the "must stay counted as reclaimed" assertion.

(The test deliberately leaves unreclaimed orphans behind, so it deletes them before the shared teardown's integrity check. Worth noting why they trip that check: not because they are orphans - CHECK DATABASE has no finding for that, which is the whole premise of item 2 - but because the bogus source bucket used to induce the failure makes them dangling links.)

The boxing: taken, and extended to the IN side. You were right that it is consistent with existing precedent rather than a new regression, but also right that it is now allocated on every sequential flush. LongObjectHashMap does work here - I checked that occupancy lives in its key array, so containsKey() still answers true for a key put with a null value, which is how "the vertex had no deferred head" is recorded. Switched, iterating with forEach() in the catch block.

I also converted the pre-existing inHeadUndoLog on the IN side. It predates this PR and had the same cost, and leaving two structurally identical undo logs with different collection types a few hundred lines apart seemed a worse trap than the allocation itself. Covered by the existing #5950 tests.

The benchmark: my description was wrong, and I have corrected it. There is no GraphBatchBenchmark class - you were right not to find one. The numbers come from GraphBatchTest.benchmarkBatchVsStandard (parallel path) and performance/GraphBatchDrainPerfBenchmark, and the latter does exercise both paths: parallelFlush=true at ~653K edges/sec and parallelFlush=false at ~742K, both unchanged. So the sequential path, where the new undo-log bookkeeping lives, is represented after all - but the description gave no way to verify that, which was the fair complaint.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review

I read through the full diff and traced the accounting/transaction logic in GraphBatch.flush(), connectOutgoingEdgesSorted(), connectOutgoingEdgesParallel(), and the new reclaimOrphanEdgeRecords()/countOrphanCandidates() pair, plus the two grpcw test/source changes. Note: I could not execute mvn verify/run the new tests in this environment (build tooling was sandboxed), so this is a static review only - please make sure CI is green before merging.

Correctness

This is a genuinely tricky area (two-phase edge write that spans commits, with both a parallel and a sequential connect path, each with their own partial-failure semantics), and the implementation holds together under close tracing:

  • totalEdgesCreated is incremented exactly once per flush, either on the success path (flush() try block, engine/src/main/java/com/arcadedb/graph/GraphBatch.java:841) or on the failure path (catch block, :857) - not both, since the try-block increment is guarded by if (connectFailure != null) throw connectFailure; immediately before it. No double-counting risk from later code in the try block throwing after the increment, since nothing between the increment and the end of the try block (buffer reset, Arrays.fill) can realistically throw.
  • The sequential path's flushDurableOutEdges boundary (j, updated only after database.commit() returns) is correctly reused as a position bound into sortIndex for reclaimOrphanEdgeRecords, accumulateIncomingEdges, and the commitEvery == 0 "nothing committed yet" guard (recordsAreDurable = flushDurableOutEdges > 0). The three all agree on what "durable" means.
  • The parallel path's bucket accounting (connectOutgoingEdgesParallel, :2982-3011) correctly sums only completedOutgoingBuckets, and the survivors array is sized exactly to completedOutgoingBuckets.size() * 2 since every bucket added to that set is guaranteed to have bucketCounts[b] != 0 (only such buckets are ever scheduled) - so the Arrays.copyOf fallback is dead code but harmless defensive coding, not a bug.
  • The review-cycle-1 fix to reclaimOrphanEdgeRecords (folding pendingReclaimed/pendingLeaked into the instance totals after every commit, not just at the end) is correct: a later commit failing in the same pass now only blames the uncommitted slice (uncommittedFrom onward), not the whole range.
  • LongObjectHashMap.containsKey()/put(key, null) genuinely does distinguish "no deferred head" from "not seen yet" (verified in engine/src/main/java/com/arcadedb/utility/LongObjectHashMap.java), so the boxing-removal refactor of outHeadUndoLog/inHeadUndoLog preserves the original HashMap<Long, RID> semantics correctly, including for the null-value case.

I didn't find a correctness bug in the core accounting/reclaim logic. The mutation-testing claims in the PR description are consistent with what I'd expect given how tightly the boundaries are threaded through.

Minor observations

  1. Global logger swap in CapturingLogger (Issue6070GraphBatchLoadHardeningIT.java:1169) - warnsWhenTheRequestedVertexBatchSizeIsClamped/doesNotWarnWhenTheRequestedVertexBatchSizeIsHonoured install a replacement logger via LogManager.instance().setLogger(...), which is a process-wide singleton shared with the embedded server under test. If any other server activity logs concurrently while the logger is swapped, that output is silently swallowed for the duration of the test (and captured, unused, in records). Low risk given these ITs appear to run sequentially in one class, but worth being aware of if grpcw ITs are ever parallelized across classes/JVM-forks that share server state, or if a failure during this window loses diagnostic log output.

  2. Per-chunk clamp warning could repeat - the new clamp-warning log in ArcadeDbGrpcService.java:988-995 fires inside onNext(chunk) for every chunk that carries options with an over-cap vertexBatchSize. This is consistent with pre-existing behavior (options were already re-read per chunk before this PR), but it does mean a client that resends inflated options on many chunks now generates a WARNING log line per chunk rather than per stream. Probably fine at WARNING severity, but worth a passing thought if log volume from untrusted clients is ever a concern.

  3. Reclaim cost on multi-bucket parallel failures - each failed bucket in connectOutgoingEdgesParallel gets its own beginTx()/commit() pair inside reclaimOrphanEdgeRecords (one call per failed bucket). This is only on the failure path and is explicitly called out as an accepted tradeoff in the javadoc, so not a concern - just noting the test suite only exercises a single failed bucket in the parallel case (aFailedParallelFlushLeavesNoUnreachableEdgeRecord); a scenario with two or more simultaneously-failing buckets isn't directly pinned, though the code path is structurally identical per-bucket so this is low risk.

Test coverage

The new Issue6083OrphanEdgeRecordTest is well-targeted: it directly pins the invariant the issue is about (countType() == reachable) rather than a looser upper-bound check, covers both flush paths, the "nothing durable yet" edge case, and the reclaim-pass-itself-fails case. The Issue6070GraphBatchLoadHardeningIT updates correctly tighten a previously-loose assertion (isLessThanOrEqualToisEqualTo) now that the undercount is fixed, and the tag MANDATORY fix removes a real method-order dependency in the existing suite. Good regression coverage overall.

Scope

Item 3 (leader gRPC address discoverability) being split into its own PR is a reasonable call given it needs a public config-surface change and a real cluster IT - keeps this PR focused on the flush/reclaim defect cluster.

Nice work threading the durability boundaries through both connect paths without duplicating the key-derivation logic for cleanup.

…cross more than one failed bucket

Code review cycle 2 on PR #6089. No correctness findings this round; these are
the two actionable observations.

CapturingLogger now forwards every record to a real DefaultLogger as well as
capturing it. The logger is a process-wide singleton shared with the embedded
server under test, so a capturing logger that only captured swallowed anything
the server logged while it was installed - including the diagnostics needed to
understand a failure inside that window. Tee-ing makes the swap invisible to
everything except the assertions, and removes the dependency on these ITs
running sequentially.

Added aParallelFlushReclaimsEveryFailedBucketNotJustTheFirst. The parallel path
reclaims per failed bucket, and the existing test only ever failed one, so the
loop was exercised exactly once and a body that stopped after the first bucket
would have passed. Two distinct missing source buckets now fail together, and
the test pins that both are reclaimed while the buckets that succeeded keep
their edges. Mutation-checked - reclaiming only the first bucket makes it fail.

The third observation, that the clamp warning could repeat per chunk, does not
apply: the block that reads vertexBatchSize sits inside the batch == null
first-chunk initialization, so it runs once per stream. The per-chunk options
re-read the review referred to belongs to insertStream, a different RPC.
@lvca

lvca commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Thanks - two of the three taken, one does not apply. Pushed in 7bead5a.

1. Global logger swap: fixed. Valid, and cheap to remove entirely rather than just note. CapturingLogger now tees - it forwards every record to a real DefaultLogger as well as capturing it - so the swap is invisible to everything except the assertions. That drops the dependency on these ITs running sequentially, and means a failure inside the capture window no longer loses its diagnostics.

2. Per-chunk clamp warning: does not apply. The block that reads vertexBatchSize sits inside the if (batch == null) first-chunk initialization branch (ArcadeDbGrpcService.java:2712, a few lines after dbRef.set(db)), so it runs exactly once per stream no matter how many chunks carry options. getVertexBatchSize has only that one call site in graphBatchLoad. The per-chunk options re-read you're thinking of is in insertStream (:2408/:2437, the "options changed after first chunk" rejection) - a different RPC. So no per-chunk log volume from an inflated request.

3. Multi-bucket reclaim coverage: added. You were right that this was only structurally implied, and the gap was slightly worse than "low risk": with a single failing bucket the reclaim loop runs exactly one iteration, so a body that stopped after the first bucket would have passed the whole suite. aParallelFlushReclaimsEveryFailedBucketNotJustTheFirst now fails two distinct missing source buckets together and pins that both are reclaimed while the buckets that succeeded keep their edges. Mutation-checked - capping the loop at one bucket makes it fail.

Also noted your point about Arrays.copyOf in the survivors sizing being dead code: agreed, every bucket in completedOutgoingBuckets necessarily had bucketCounts[b] != 0, so survivorCount always equals survivors.length. Leaving it as defensive coding rather than asserting the invariant, since it costs nothing on a path that only runs after a failure.

CI is green on my side for the suites this touches: all com.arcadedb.graph.*Test (engine), 172 grpcw unit tests, and the Issue6070* ITs.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review

I traced the durability/accounting logic in GraphBatch.java carefully (PHASE 1/3/4 interaction, reclaimOrphanEdgeRecords, both connectOutgoingEdgesSorted and connectOutgoingEdgesParallel failure paths, and the flushDurableOutEdges/flushDurableOutRanges bookkeeping) and did not find a correctness defect. A few notes:

Correctness (verified, no issues found)

  • The recordsAreDurable = flushDurableOutEdges > 0 guard on the sequential path is correct: PHASE 1's bulk-created edge records share the transaction with PHASE 3's first group, so if flushDurableOutEdges is still 0 when the failure is caught, the rollback already removed everything and the guard correctly skips a second delete attempt (matches aFlushThatFailsBeforeItsFirstCommitReclaimsNothing).
  • The per-commit fold in reclaimOrphanEdgeRecords (added in review cycle 1) is right: charging countOrphanCandidates(uncommittedFrom, toSortPos) only for the slice the failed transaction covered avoids re-blaming records an earlier commit in the same pass already reclaimed.
  • LongObjectHashMap genuinely supports a null value while containsKey() still returns true (occupancy tracked in the parallel keys[] array, confirmed by reading the implementation) - the undo-log comments claiming this are accurate, not just asserted.
  • The parallel path's survivors range construction and the sequential path's [0, flushDurableOutEdges) range both feed accumulateIncomingEdgeRange correctly - ranges are disjoint, monotonic, and index into sortIndex consistently with how PHASE 3 built it.
  • Log call arities in the new warning lines (LogManager.instance().log(...)) resolve to existing fixed-arity overloads correctly, including the CapturingLogger test double's 17-arg override.

Minor observations (non-blocking)

  • In connectOutgoingEdgesParallel, each failed bucket gets its own reclaimOrphanEdgeRecords call, i.e. its own begin/commit cycle. For a flush with many failed buckets this is N small transactions instead of one. Given this only runs on an already-failing path, that's a reasonable trade-off, just flagging it in case a future caller expects the cleanup itself to be fast under heavy partial failure.
  • CapturingLogger in Issue6070GraphBatchLoadHardeningIT swaps the process-wide LogManager singleton logger for the test's duration. The tee to a real DefaultLogger (added in review cycle 2) is a good fix for losing diagnostics, but the swap is still global - if these gRPC ITs ever run with cross-class parallelism, one test's capture window could pick up log lines from unrelated concurrent activity. Probably fine given how this suite currently executes, but worth a comment if that ever changes.
  • reclaimOrphanEdgeRecords's javadoc is upfront that walking both endpoints' edge lists via database.deleteRecord for a record that has no back-reference anywhere is wasted work; that's an accepted, bounded (failure-path-only) cost, not a concern, just noting it's a real second traversal per orphan.

Test coverage

Thorough - both flush paths, the multi-bucket parallel reclaim, the "fails before first commit" edge case, and the reclaim-pass-itself-fails-partway case are all covered, and the PR description states every assertion was mutation-checked. The Issue6070GraphBatchLoadHardeningIT updates correctly tighten a previously-loose isLessThanOrEqualTo assertion to an exact equality now that the undercount is fixed.

Style

Follows existing conventions (no curly braces on one-line ifs, final used consistently, javadoc explaining the non-obvious "why"). No new dependencies. No unnecessary abstractions introduced.

Nice work tracking down the two-phase-commit-without-shared-transaction hazard and its knock-on effects on the exact edge count.

…depends on

Code review cycle 3 on PR #6089, which found no correctness defects. The only
actionable point was a request for exactly this comment: the tee added in cycle
2 stops the swap losing diagnostics, but the swap itself is still process-wide,
and that is sound only because these ITs run sequentially in one forked JVM.
Spelled out what would break under cross-class parallelism - a test asserting
the ABSENCE of a warning could see another test's - so the constraint is
written down rather than rediscovered.

The other two observations are accepted trade-offs already documented in the
code: one begin/commit per failed bucket in the parallel reclaim (per-commit
granularity is what makes the accounting exact, and batching would widen the
blast radius if the cleanup itself failed), and the endpoint walk deleteRecord
performs for a record with no back-reference (the cost of reusing the one
delete path correct for both creation routes and every index kind).
@lvca

lvca commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Thanks - no correctness defects this round, and the one actionable point is addressed in b945c83.

Logger swap still being global: comment added. You asked for it to be written down if the execution model ever changes, so I have spelled out the constraint the helper depends on and, specifically, what would break: a test asserting the absence of a warning could see another test's under cross-class parallelism in a shared JVM (findWarning matches on the template, so it cannot tell them apart). Anything relying on that would need per-test logger isolation rather than a singleton swap.

The other two are accepted trade-offs, and both are already documented in the code - recording the reasoning here so it is not re-derived:

  • One begin/commit per failed bucket in the parallel reclaim. Batching across buckets would be fewer transactions but a wider blast radius: per-commit granularity is exactly what makes the accounting exact after review cycle 1, since a cleanup that fails partway must be able to say which records it already reclaimed. Trading that back for fewer transactions on an already-failing path is the wrong direction.
  • deleteRecord walking both endpoints for a record with no back-reference. Real, and a second traversal per orphan. It buys the one inverse that is correct for both creation routes (createRecordsBulk + indexEdgeProperties, and the plain save() fallback) and every index kind, with no duplicate key-derivation logic to keep in step. On a failure-path-only cleanup that is the right side of the trade.

I consider the review satisfied: three cycles, one real defect found (the reclaim accounting, cycle 1) and fixed with a mutation-checked regression test, plus two test-quality improvements. Local runs are green for every suite this touches - all com.arcadedb.graph.*Test (engine), 172 grpcw unit tests, the Issue6070* ITs, RemoteGraphBatchTest (network) and RemoteGraphBatchIT (server) - and no merge conflicts.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review

I traced the accounting logic by hand across all four flush-outcome combinations (parallel/sequential × success/partial-failure) plus the interrupted-reclaim-pass case, and didn't find a correctness bug. Notes below.

What I checked closely

flush()'s counting is exactly-once in every path I traced. The success path adds flushDurableOutEdges to totalEdgesCreated directly; the failure path always routes through throw connectFailure before reaching that line, so the outer catch is the sole place the addition happens on a failure — no double count. I also checked the case where connectDeferredIncomingEdges() throws inside PHASE 4 (guarded by connectFailure == null, so flushDurableOutEdges is the full committed count at that point) — same single addition via the outer catch.

reclaimOrphanEdgeRecords's per-commit fold looks right, including the doubly-unlucky case the new aReclaimPassThatFailsPartwayKeepsWhatItAlreadyCommitted test targets: uncommittedFrom is only advanced after a commit returns, so if a later commit in the pass throws, countOrphanCandidates(uncommittedFrom, toSortPos) in the catch charges only the undecided slice, not the whole range — matching the "counted for the current transaction only, folded as each commit returns" comment.

Sequential vs. parallel recordsAreDurable handling is correctly asymmetric. Sequential passes flushDurableOutEdges > 0 (right, since PHASE 1 and PHASE 3 can share an uncommitted transaction when commitEvery == 0, so a first-commit failure means the rollback already removed the records — covered by aFlushThatFailsBeforeItsFirstCommitReclaimsNothing). Parallel always passes true (right, since the database.commit() before dispatch unconditionally makes PHASE 1 durable before any bucket task runs).

connectOutgoingEdgesSorted's new undo log is scoped correctly — snapshotted once per vertex per slice, cleared after each successful periodic commit, and only replayed in the catch, so it only ever undoes the last (uncommitted) slice rather than clobbering earlier committed slices.

Survivor-range construction in connectOutgoingEdgesParallel produces non-overlapping, correctly-ordered [from,to) pairs since it walks bucketOffsets in increasing bucket order — no risk of accumulateIncomingEdgeRange double-counting an edge across two ranges.

Compile-time checks (imports of RecordNotFoundException, LongObjectHashMap, LogManager/Level in the grpc service; the CapturingLogger's two log(...) overloads against the actual Logger interface signature) all line up.

Minor, non-blocking observations

  • reclaimOrphanEdgeRecords's javadoc already flags that database.deleteRecord does a wasted endpoint back-reference walk on every reclaimed record. That's a reasonable, well-documented tradeoff for reusing one delete path correct for both creation routes and every index kind — no action needed, just confirming it's the only path this exercises (a failed flush), so the cost is bounded as claimed.
  • CapturingLogger swaps the process-wide LogManager singleton logger; the class javadoc is explicit that this is only sound because the ITs in that class run sequentially in one forked JVM. Worth keeping in mind if this suite's execution model (parallel forks/classes) ever changes — but that's a documented, accepted constraint, not something this PR needs to fix.
  • Test coverage is solid: both flush paths, the zero-durable-work case, the multi-bucket parallel reclaim, the interrupted-reclaim-pass case, and the "still exact on a successful multi-commit load" regression are all covered, and the PR description notes mutation-checking each assertion.

Nice, thorough piece of work — the two-step create/link split and its failure-mode implications (orphan records, half-edges, the missing OUT undo log) are exactly the kind of thing that's easy to get subtly wrong, and the bookkeeping here holds up under a careful trace.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.72973% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.53%. Comparing base (cf6273f) to head (b945c83).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...e/src/main/java/com/arcadedb/graph/GraphBatch.java 89.38% 8 Missing and 11 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6089      +/-   ##
==========================================
+ Coverage   69.50%   69.53%   +0.03%     
==========================================
  Files        1813     1813              
  Lines      155184   155314     +130     
  Branches    32780    32800      +20     
==========================================
+ Hits       107858   107999     +141     
+ Misses      33773    33744      -29     
- Partials    13553    13571      +18     

☔ 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.

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.

1 participant