fix(#5664): bound GraphBatch head-chunk RID caches and drain deferred incoming edges early - #5950
Conversation
|
Tick the box to add this pull request to the merge queue (same as
|
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| BestPractice | 2 medium |
🟢 Metrics 37 complexity
Metric Results Complexity 37
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.
ReviewThanks for the detailed writeup and tracking doc — this is a well-motivated fix for a real memory issue. I dug into the actual mechanics of the LRU bound and found one issue serious enough to flag before merge. 🔴 Critical: bounding
|
ReviewI traced how the newly-bounded 1. Bounding the RID caches while leaving
|
ReviewI read through the full diff ( Correctness of the eviction fix - looks rightI verified by hand that:
The new regression tests are good: they reproduce the exact silent-loss symptom (edge count short by one, Design concern: the memory-reduction goal is only partially met for the PR's own flagship scenario
For the workload this PR is written for - bulk-loading a brand-new graph via This isn't a correctness bug and the PR body's "Impact / follow-up" section does mention
Minor / already acknowledged
Test coverageGood on the correctness fix specifically. Nothing exercises the NitBuilder field alignment ( |
ReviewNice, thorough PR: clear root-cause writeup, sensible use of the existing 1. Correctness: silent edge loss survives for "regular" (non-
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5950 +/- ##
==========================================
+ Coverage 68.14% 68.22% +0.07%
==========================================
Files 1797 1797
Lines 153216 153381 +165
Branches 32457 32487 +30
==========================================
+ Hits 104411 104639 +228
+ Misses 35404 35326 -78
- Partials 13401 13416 +15 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The comment said the parallel paths (connectOutEdgesRangeLocal/ connectIncomingEdgesRangeLocal) "do NOT touch these fields directly" - no longer true since the LRU-eviction fix added direct reads of deferredOutHead/deferredInHead there. The reads are safe (pure LongObjectHashMap.get(), no concurrent writer during a parallel round), but a future change could violate that invariant without noticing if the comment still claimed no access at all. Flagged in PR #5950 review.
|
Thanks for the thorough re-review. Checking the timeline: this comment landed 23:17 UTC, but only discusses "commit 3" ( Point 2 (the Point 3 (the stale "do NOT touch these fields directly" comment) was a fair catch and is fixed in |
Review of #5950Solid piece of work overall - the PR description's self-documented review cycle (catching and fixing the One new issue I found that isn't covered by the existing tests: Correctness: early in-flush draining can duplicate incoming edges if
|
Review cycle 3 found that flush()'s early incoming-edge drain (issue #5664) can fail partway through connectDeferredIncomingEdges() after some commitEvery slices (sequential) or destination-bucket tasks (parallel) already committed durably. close()'s unconditional retry then reprocessed the whole stale buffer, duplicating the already-committed work. - Sequential: track a resume cursor into the sort index, advanced only after each successful commit, so a retry skips already-committed groups. - Parallel: track which destination buckets already committed in a completedIncomingBuckets set, marked from the async transaction's post-commit OkCallback (not from inside the bucket lambda, which runs before the commit and could still fail). Both paths also mutate deferredInHead/inChunkRIDCache as a side effect of processing a group, before the containing transaction commits. A rolled back slice previously left those maps pointing at non-durable records, causing a RecordNotFoundException on the very retry meant to fix things. Fixed by: an undo log that restores deferredInHead entries touched by a failed sequential slice (needs LongObjectHashMap.remove(), added here via tombstones since the map only supported put/get); and, for the parallel path, buffering each bucket's head-pointer writes in a local map merged into the shared state only once that bucket's commit actually succeeds. Also includes a small cache-repopulation fix in connectOutEdgesRangeLocal/ connectIncomingEdgesRangeLocal so a vertex touched again later in a parallel round doesn't keep paying for the fallback lookup. Added TEST_BEFORE_INCOMING_EDGE_COMMIT_HOOK to deterministically fault-inject a commit failure mid-drain, and Issue5950IncomingEdgeDrainReentrancyTest covering both flush paths.
Review cycle 3 - fixedConfirmed the reentrancy bug: Fixed in
Building the reproduction test surfaced a second, deeper bug in the same family: New Verification: |
… incoming edges early outChunkRIDCache/inChunkRIDCache were plain ConcurrentHashMaps, never cleared, so a long-lived bulk stream (e.g. the gRPC GraphBatchLoad path from discussion #5597) grew them proportionally to the number of distinct vertices touched over the WHOLE batch lifetime instead of batchSize - 16-18 GB at 100M vertices. The deferred incoming-edge buffer was only drained at close(), adding another 3.6-9 GB at 100M edges. Both call sites for the RID caches already fall back to loading the vertex's head chunk from disk on a miss, so bounding them with an LRU (the same Collections.synchronizedMap(LRUCache<>(..)) pattern already used by CypherStatementCache) is a safe drop-in, configurable via withChunkCacheCapacity(). The deferred incoming-edge buffer now drains early from flush() once it crosses a configurable cap (withMaxDeferredIncomingEdges(), default 5M, 0 to opt back into the old close()-only behavior), amortizing the connection pass over the load instead of paying for it in one unbounded pass at close time.
…LRU-eviction data-loss hole The chunk-cache LRU bound added in the prior commit broke an invariant the known-new-vertex fast path (knownNewVertexKeys, populated by createVertices()) relied on: that a cache miss for such a vertex could only mean "no segment exists yet". Once the LRU evicts that vertex's outChunkRIDCache/inChunkRIDCache entry - easy on a large stream once more than chunkCacheCapacity other vertices are touched between two edges of the same vertex - the known-new fast path in getOrCreateOutSegmentDeferred/getOrCreateInSegmentDeferred and their parallel-path twins connectOutEdgesRangeLocal/connectIncomingEdgesRangeLocal wrongly assumed no segment existed and created a second, unlinked segment, silently orphaning the first segment's already-committed edges. Fix: on a known-new-vertex cache miss, consult deferredOutHead/deferredInHead (unbounded for the batch lifetime, always accurate for the vertex's current head) before assuming brand new. Regression test reproduces the exact loss via GraphBatch.createVertices() + a small chunkCacheCapacity forcing eviction between a vertex's own two edges, for both the sequential and parallel flush paths; verified it fails pre-fix (edge count short + database integrity warnings about disconnected edges) and passes post-fix.
…es too The prior commit only consulted deferredOutHead/deferredInHead as a fallback inside the knownNewVertexKeys branch. But that same on-disk staleness applies to any vertex touched earlier in this batch and not created via createVertices(): its on-disk head chunk is only updated at close(), so a cache miss (from LRU eviction) on a pre-existing vertex can also read a stale-or-null on-disk head and create a second, unlinked segment, orphaning the earlier one's committed edges. Fix: check deferredOutHead/deferredInHead unconditionally, before branching on knownNewVertexKeys, in both the sequential (getOrCreateOutSegmentDeferred/getOrCreateInSegmentDeferred) and parallel (connectOutEdgesRangeLocal/connectIncomingEdgesRangeLocal) call sites. deferredOutHead/deferredInHead are unbounded for the whole batch and always reflect the vertex's true current head once touched, regardless of how it was created. New regression tests (sequential + parallel) reproduce the loss with plain database.newVertex()-created vertices (not createVertices(), so knownNewVertexKeys is never populated) - verified RED against the prior commit's partial fix and GREEN against this one.
The comment said the parallel paths (connectOutEdgesRangeLocal/ connectIncomingEdgesRangeLocal) "do NOT touch these fields directly" - no longer true since the LRU-eviction fix added direct reads of deferredOutHead/deferredInHead there. The reads are safe (pure LongObjectHashMap.get(), no concurrent writer during a parallel round), but a future change could violate that invariant without noticing if the comment still claimed no access at all. Flagged in PR #5950 review.
Review cycle 3 found that flush()'s early incoming-edge drain (issue #5664) can fail partway through connectDeferredIncomingEdges() after some commitEvery slices (sequential) or destination-bucket tasks (parallel) already committed durably. close()'s unconditional retry then reprocessed the whole stale buffer, duplicating the already-committed work. - Sequential: track a resume cursor into the sort index, advanced only after each successful commit, so a retry skips already-committed groups. - Parallel: track which destination buckets already committed in a completedIncomingBuckets set, marked from the async transaction's post-commit OkCallback (not from inside the bucket lambda, which runs before the commit and could still fail). Both paths also mutate deferredInHead/inChunkRIDCache as a side effect of processing a group, before the containing transaction commits. A rolled back slice previously left those maps pointing at non-durable records, causing a RecordNotFoundException on the very retry meant to fix things. Fixed by: an undo log that restores deferredInHead entries touched by a failed sequential slice (needs LongObjectHashMap.remove(), added here via tombstones since the map only supported put/get); and, for the parallel path, buffering each bucket's head-pointer writes in a local map merged into the shared state only once that bucket's commit actually succeeds. Also includes a small cache-repopulation fix in connectOutEdgesRangeLocal/ connectIncomingEdgesRangeLocal so a vertex touched again later in a parallel round doesn't keep paying for the fallback lookup. Added TEST_BEFORE_INCOMING_EDGE_COMMIT_HOOK to deterministically fault-inject a commit failure mid-drain, and Issue5950IncomingEdgeDrainReentrancyTest covering both flush paths.
6d296b2 to
8d7c0e3
Compare
ReviewI read through the current 🔴 The "deliberately out-of-scope" outgoing-side gap is reachable within a single
|
…ombstones, pin the drain prefix Review cycle 4 findings. 1. The outgoing-side gap was NOT out of scope: async.transaction(..., 3, ...) retries the same bucket lambda on a ConcurrentModificationException, so the hazard is reachable within a single flush(), not only across attempts. getOrCreateOutEdgeChunk() persisted a new segment and wrote its RID into the class-level outChunkRIDCache before the bucket's transaction committed; after a rollback the retry read that cache first and dereferenced a RID that was never made durable. Fixed by giving the OUT path the same treatment the IN path already had: per-bucket local head-pointer maps merged into the shared caches only for buckets whose commit succeeded, and new-segment creation deferred to the isNew branch instead of the eager persist-and-cache helper. Also clears each bucket's local maps per attempt on both directions so a CME retry cannot inherit rolled-back entries. 2. LongObjectHashMap.remove() left tombstones uncounted against the resize threshold. Every probe terminates only on an empty slot, so a remove-heavy workload could fill the table with tombstones and live entries, leaving get()/put()/remove()/containsKey() spinning forever on a miss. Tombstones now count toward the threshold, and the rehash sizes from the live entry count so churn reclaims slots instead of doubling capacity. 3. The incoming-edge resume state assumed no intervening newEdge() after a failed drain, which nothing enforced. Since the sort index is a counting sort over the buffer, appended rows reshape it, so the recorded cursor and completed-bucket set applied to a rebuilt index could silently skip groups (edge loss) or reprocess them (duplicates). Each drain pass now pins the row count it covers and a retry reuses that exact pin, so the index shape is identical across attempts; rows appended past the pin drain as a separate pass. Each fix verified RED before and GREEN after: - OUT retry: RecordNotFoundException reading a rolled-back segment RID (new Issue5950OutgoingEdgeRetryCacheTest, driven by a new outgoing-edge commit hook). - tombstones: the test class hung outright; the timeout uses SEPARATE_THREAD because the default same-thread @timeout cannot interrupt an infinite loop and would hang the build. - drain prefix: sequential lost the lower-bucket row its raw cursor skipped past, parallel lost the row added to an already-completed bucket. Full com.arcadedb.graph.*Test plus LongObjectHashMapTest sweep: 232/232 passing.
Review cycle 4 - all three findings fixed in
|
|
Reviewed the current state of the diff (post review-cycle-4). This PR has already been through an unusually thorough internal review loop (the Correctness / design
The whole point of bounding So for a genuinely long-lived stream that touches many millions of distinct vertices (i.e. exactly the scenario #5664 was written for), Performance
Given Everything else
|
Answers the two performance questions left open by this PR's review: the synchronizedMap(LRUCache) chunk-RID cache serialization on the parallel flush hot path (cycle 1) and the boxed-Long incoming-head undo log added on the sequential drain path (cycle 4). Neither shows a measurable regression against the pre-PR baseline at 11-way async parallelism. GraphBatchDrainPerfBenchmark deliberately uses only builder methods that exist both before and after this PR, so the identical source compiles and runs against the pre-PR tree for a true before/after comparison of the default-configured loader. GraphBatchEvictionCostBenchmark varies only withChunkCacheCapacity to quantify what the memory bound costs once it actually binds - the before/after framing does not apply there, since unbounded IS the pre-PR behavior this issue exists to remove. Placed in the performance package and named *Benchmark so the benchmark-tests workflow's `-Dsurefire.includes=**/*Benchmark.java` actually matches them; a *BenchmarkTest name would have been skipped by the main lane's excludedGroups=benchmark AND unmatched by the benchmark lane, running nowhere.
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| BestPractice | 2 medium |
🟢 Metrics 37 complexity
Metric Results Complexity 37
🟢 Coverage 96.95% diff coverage · -6.36% coverage variation
Metric Results Coverage variation ✅ -6.36% coverage variation Diff coverage ✅ 96.95% diff coverage Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (f2754ae) 153216 117852 76.92% Head commit (c31233a) 185724 (+32508) 131052 (+13200) 70.56% (-6.36%) 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 (#5950) 262 254 96.95% 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.
ReviewI read through the full diff ( CorrectnessThe fixes described across review cycles 1-4 hold up under a manual trace:
Minor nit (non-blocking)
Style/process
OverallThis is a large, concurrency-heavy change to a hot data-loading path, but the final state is well-reasoned and the regression tests target the actual failure mechanisms (rolled-back RIDs leaking into shared caches, partial-drain reprocessing) rather than just re-asserting happy-path behavior. I didn't find any correctness issues beyond the tombstone-guard nit above. Nice work tracking down the two "eager write before commit" hazards (IN in cycle 3, OUT in cycle 4) - that's a subtle class of bug that's easy to miss on a first pass since it only manifests under CME retry / partial-commit-failure, not on the happy path exercised by most tests. |
…partial memory bound Review cycle 5 items, both non-blocking. 1. get()/containsKey() did not short-circuit on TOMBSTONE_KEY the way put()/remove() already did. containsKey(TOMBSTONE_KEY) would probe and "match" the first tombstoned slot it walked over, wrongly answering true. Unreachable through this map's own API - put() rejects the key, so it can never be a real mapping - but this is a general-purpose utility and a caller has no reason to know which longs are reserved. The regression test needs care to be able to fail at all: a tombstone placed anywhere is not enough, because the probe stops at the first EMPTY slot and returns false for the wrong reason. The test therefore replicates the map's MurmurHash3 finalizer to plant the tombstone at exactly the slot the sentinel's own probe starts on. Verified RED (expected false but was true) before this fix, GREEN after. 2. The class javadoc listed the two bounded caches under "optimizations" with no statement of what is still unbounded, which reads as though #5664 is fully solved. deferredOutHead/deferredInHead/knownNewVertexKeys still grow with distinct-vertex count, and since #5664 they are also load-bearing rather than an optimization - they are the authoritative fallback that keeps a cache eviction from orphaning an earlier segment. Stated plainly so the remaining ceiling is not rediscovered later.
Review cycle 5 - addressed, plus two corrections to the recordFixed in
The
No measurable regression; both within noise and both trending faster. Harness committed as Two corrections:
|
ReviewI read through the actual diff and the current state of Verified independently
Residual observations (non-blocking)
OverallThe core bug (LRU eviction silently orphaning a segment and losing edges) and the two more subtle ones found in later review cycles (partial-drain reprocessing/duplication, and pre-commit cache writes surviving a CME rollback) are real, well-understood, and the fixes match the documented reasoning when checked against the actual |
Retraction: my "correction" about the benchmark claim was itself wrongIn my previous comment I wrote that a review's statement - "the PR backs the 'no regression' claim with an actual before/after benchmark" - was false because no benchmark existed when it was written. That is incorrect, and I'm withdrawing it. Checked against actual UTC timestamps rather than assumed:
The review was describing the committed The earlier reviews at 11:56:54Z and 09:11:11Z make no benchmark claim at all, so there was never a false statement to correct in the first place. Cause, for the record: I relayed that assertion without independently checking the timestamps - the same class of error I flagged two comments earlier when a timezone comparison produced a wrong "no new reviews" conclusion. Apologies for the noise on the thread. Everything else in that comment stands: the cycle-5 fixes in |
…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.
Closes #5664
Review cycle 1
The
claudebot review found one 🔴 critical, correctness-blocking issue and one 🟡 performanceconcern. Status below.
🔴 Critical (fixed): known-new vertex + LRU eviction could silently orphan a segment and lose edges
Bounding
outChunkRIDCache/inChunkRIDCachewith an LRU broke an invariant the "known-new vertex"fast path (
knownNewVertexKeys, populated only byGraphBatch.createVertices()) relied on: that acache miss for such a vertex could only mean "no segment exists yet for it". Before this PR the two
RID caches were plain, never-evicted
ConcurrentHashMaps with the exact same lifecycle asknownNewVertexKeys, so that was true. Once the RID cache is LRU-bounded, a known-new vertex'sentry can be evicted mid-batch - trivially reached once more than
chunkCacheCapacityotherdistinct vertices are touched between two edges of the same vertex, the exact large-stream scenario
this issue targets. On the next touch,
getOrCreateOutSegmentDeferred/getOrCreateInSegmentDeferred(and the parallel-path twins
connectOutEdgesRangeLocal/connectIncomingEdgesRangeLocal) took the"known-new" branch again, wrongly assumed no segment existed, and created a second, unlinked
segment (no
setPrevious()) - overwriting the pointer to the first segment in both the RID cache anddeferredOutHead/deferredInHead. The first segment's already-committed edges become permanentlyunreachable once
batchUpdateVertexHeadChunks()writes the final head pointer atclose(). Silentdata loss, no exception.
Fix: all four call sites now consult
deferredOutHead/deferredInHead- unbounded for the wholebatch lifetime and always accurate for a vertex's current head - as a fallback before assuming "brand
new" on a known-new-vertex cache miss. If a deferred head is found, the existing segment is reused
(and correctly overflow-linked via the normal
setPrevious()path on the next append) instead of asecond unlinked segment being created.
New regression test (
GraphBatchBoundedStateTest.knownNewVertexSurvivesCacheEvictionBetweenItsOwnEdgesSequential/...Parallel): creates vertices viaimporter.createVertices(...)(notdatabase.newVertex(), whichnever populates
knownNewVertexKeys- why the prior test suite missed this), uses a smallwithChunkCacheCapacity(20), gives a vertex a first OUT and first IN edge, then interleaves 200 edgestouching 400 other distinct vertices (far more than the cache capacity) to force eviction, then gives
the same vertex a second OUT and second IN edge. Verified this fails pre-fix: OUT/IN edge count
came back 1 instead of 2, plus
TestHelper's database-integrity check independently flagged theorphaned edges ("edge ... was not connected from the incoming/outgoing vertex ..."). Passes post-fix
for both the sequential and parallel flush paths.
🟡 Performance (deferred, not fixed):
Collections.synchronizedMap(LRUCache)serializes the parallel-flush hot pathConfirmed as described: previously
outChunkRIDCache/inChunkRIDCachewereConcurrentHashMap,giving lock-free/finely-striped concurrent access from every parallel async slot in
connectOutgoingEdgesParallel/connectIncomingEdgesParallel.Collections.synchronizedMap(new LRUCache<>(...))puts everyget()/put()behind one global intrinsic lock, and becauseLRUCacheis access-order (
LinkedHashMap(..., true)), evenget()mutates the internal linked list - there isno cheap read path left. This could cut into (or negate)
parallelFlush=truethroughput on amulti-core box.
Deferred, not implemented in this cycle, for two reasons: (1) it is explicitly not
correctness-blocking per the reviewer's own framing, and this cycle's budget went to verifying the
critical fix is solid (reproduced pre-fix, fixed, regression-tested, full
com.arcadedb.graph.*Testsweep green - 210/210, 0 failures/errors); (2) the reviewer's own suggested mitigation (a sharded LRU:
N
Collections.synchronizedMap(LRUCache)shards keyed byvertexKey % N, each sizedchunkCacheCapacity / N) is real surgery to a hot path used by everyGraphBatchcaller - it needs abefore/after
parallelFlush=truethroughput benchmark to justify the added complexity, which thiscycle didn't produce. Flagging here as an accepted, tracked follow-up rather than silently dropping
it: worth doing if/when a benchmark shows a real regression on the exact large-
parallelFlushworkloads this issue targets.
What does this PR do?
Bounds two kinds of state in
GraphBatchthat previously grew with the lifetime of the batchinstead of
batchSize:outChunkRIDCache/inChunkRIDCache(head-chunk RID lookup accelerators) were plainConcurrentHashMaps, never cleared. Every distinct vertex an edge touched added ~80-90 bytesacross the two maps that stayed until
close()- 16-18 GB at 100M distinct vertices.drained by
connectDeferredIncomingEdges(), called exclusively fromclose(). 100M edges in onebatch held 3.6 GB steady, up to ~2.5x that during a doubling copy.
batchSizeonly sizes the outgoing edge buffer, reset everyflush()- the knob a user reachesfor touches neither leak, which is exactly the "big batches vs. many streams" false dilemma
reported against the 663M-vertex / 14B-edge gRPC
GraphBatchLoadstream in discussion #5597 (theuser had to recycle the RPC every 4M records to keep the server alive).
Fix
Both call sites for the two RID caches already fall back to reading the vertex's head chunk from
disk on a miss (
getOrCreate{Out,In}{Segment,EdgeChunk}{Deferred,Exact}), so bounding them is asafe drop-in:
Bounded head-chunk RID caches.
outChunkRIDCache/inChunkRIDCacheare nowCollections.synchronizedMap(new LRUCache<>(chunkCacheCapacity))- the same bounded-LRU patternalready used by
CypherStatementCache. Configurable viaGraphBatch.Builder.withChunkCacheCapacity(int),default 1,000,000 entries per cache (~100-150 MB total).
LRUCacheis not thread-safe on itsown; wrapping in
Collections.synchronizedMapis required becausegetOrCreateOutEdgeChunk/getOrCreateInEdgeChunkand the parallel-flush range handlers hitthese maps from multiple async slots.
Fixed by falling back to the unbounded
deferredOutHead/deferredInHeadon a known-new-vertexcache miss.
Periodic draining of the deferred incoming-edge buffer.
flush()now checks, afteraccumulating a flush's edges into the deferred IN buffer, whether
inEdgeCounthas crossed aconfigurable cap (
GraphBatch.Builder.withMaxDeferredIncomingEdges(int), default 5,000,000) andif so calls
connectDeferredIncomingEdges()early instead of waiting forclose(). Thisamortizes the incoming-edge connection pass over the load rather than landing as one
multi-minute pass at the end (issue GraphBatch: Stuck without error after index compacting #5470's 86-second close-time wait is the shape of cost this
removes). Setting the cap to
0opts back into the pre-GraphBatch retains per-vertex state for the whole batch lifetime, so a long-lived bulk stream grows without bound #5664 behavior of deferring everything toclose().connectDeferredIncomingEdges()already null'd out the six deferred-buffer arrays when itfinished (an existing optimization to let them be GC'd at
close()); calling it mid-batchmeant a later
accumulateIncomingEdges()would NPE on the null arrays. Fixed by lazilyre-allocating them (at the same initial
batchSizecapacity the constructor uses) the firsttime
accumulateIncomingEdges()sees them null.deferredOutHead/deferredInHead/knownNewVertexKeyswere intentionally left unbounded for thebatch's lifetime: they already use zero-boxed structures (
LongObjectHashMap/LongHashSet, ~5-7xlighter than the boxed alternative), are already cleared on every
batchUpdateVertexHeadChunks()pass, and their size is bounded by the number of distinct vertices that got a new or overflowed
segment. As of review cycle 1,
deferredOutHead/deferredInHeadare also the correctness-criticalfallback the known-new-vertex fast path depends on, so they cannot be bounded the same way the RID
caches are without reopening this exact bug - any future work to bound them needs a different
mechanism (e.g. draining them via
batchUpdateVertexHeadChunks()early, which persists vertexrecords and clears
knownNewVertexKeystoo - a materially bigger change, out of scope here). See"Impact / follow-up" below.
Changes
engine/src/main/java/com/arcadedb/graph/GraphBatch.javaoutChunkRIDCache/inChunkRIDCache:ConcurrentHashMap-> boundedLRUCachewrapped inCollections.synchronizedMap, capacity configurable via the builder.withChunkCacheCapacity(int),withMaxDeferredIncomingEdges(int).DEFAULT_CHUNK_CACHE_CAPACITY(1,000,000),DEFAULT_MAX_DEFERRED_INCOMING_EDGES(5,000,000).
flush(): drains the deferred incoming-edge buffer early once it crosses the configured cap.accumulateIncomingEdges(): lazily re-allocates the deferred buffer arrays if an earlierin-flush drain freed them.
getOutChunkRIDCacheSize()/getInChunkRIDCacheSize().getOrCreateOutSegmentDeferred,getOrCreateInSegmentDeferred,connectOutEdgesRangeLocal,connectIncomingEdgesRangeLocal- on a known-new-vertex RID-cachemiss, fall back to
deferredOutHead/deferredInHeadbefore assuming "no segment yet".engine/src/test/java/com/arcadedb/graph/GraphBatchBoundedStateTest.java: regression coverage perthe issue's own "Verification" section - runs a
GraphBatchover many more distinct vertices thanthe configured cap and asserts the retained cache/buffer size stays bounded, for both the
sequential and parallel flush paths, plus correctness (every edge still traversable in both
directions despite evictions/early draining), plus builder validation and the
0-disables-early-drain opt-out.knownNewVertexSurvivesCacheEvictionBetweenItsOwnEdgesSequential/...Parallel, exercising thecreateVertices()+ eviction-between-two-edges scenario thecritical finding describes.
Related issues
Closes #5664. Referenced in discussion #5597.
Impact / follow-up for monitoring
extreme (100M+ vertex/edge) stream, while being large enough that typical/bounded imports never
reach them - no behavior or performance change for the common case.
connectDeferredIncomingEdges()firing more often (once per drain instead of once atclose()) -this is expected and is the amortization the fix is for, not a symptom of trouble.
deferredOutHead/deferredInHead/knownNewVertexKeysremain unbounded for the batch's lifetime(bounded only by distinct-vertex count, not edge count) - and, as of review cycle 1, are also
correctness-critical rather than purely an optimization. If a future report shows this scaling to
be a problem in practice, extending the periodic-drain mechanism to call
batchUpdateVertexHeadChunks()early is the natural next step, flagged here for future referencerather than acted on now.
Collections.synchronizedMap(LRUCache)serializes
outChunkRIDCache/inChunkRIDCacheaccess across all parallel-flush async slots where aConcurrentHashMapused to give lock-free access. Worth benchmarkingparallelFlush=truethroughput before/after on a multi-core box; if it regresses meaningfully, shard the LRU (N
Collections.synchronizedMap(LRUCache)shards keyed byvertexKey % N, sizedchunkCacheCapacity / Neach) to restore concurrency while keeping the bound.Test plan
GraphBatchBoundedStateTest(7/7 passed), per the issue's own"Verification" ask plus review cycle 1's critical-bug coverage: runs a
GraphBatchover farmore distinct vertices than the configured cache capacity and asserts the retained OUT/IN
cache sizes and the deferred incoming-edge count stay bounded, for both the sequential and
parallel flush paths; asserts correctness is unaffected (every edge still traversable in both
directions after evictions/early draining, including the known-new-vertex eviction-between-
two-edges scenario); asserts the
0-disables-early-drain opt-out and builder argumentvalidation.
symptom: OUT/IN edge count 1 instead of 2, plus an independent database-integrity check
flagging the orphaned edges) and pass post-fix.
GraphBatchTestsuite - no regression.mvn -pl engine -am -DskipITs=true -Dtest='com.arcadedb.graph.GraphBatch*Test,com.arcadedb.graph.Issue566*Test' test-all passed.
mvn -pl engine -am -DskipITs=true -Dtest='com.arcadedb.graph.*Test' -DexcludedGroups='slow,benchmark' test-210/210 passed, 0 failures/errors, across the full graph test package.
mvn -pl engine -am compile- success.mvn -pl engine,grpcw,server,network,integration -am compile- success (confirms nodownstream break for the gRPC
GraphBatchLoadstream, HTTP batch handler,RemoteGraphBatch,and the Neo4j importer; all new builder methods are additive).
mvn clean package(full build) - left to CI / reviewer per repo convention for this size ofchange.
Review cycle 1
claude[bot]posted two reviews on this PR, both against the same initial commit (landed 22:46 and 22:51 UTC), flagging a 🔴 critical, correctness-blocking issue:Bounding
outChunkRIDCache/inChunkRIDCachewith an LRU while leavingknownNewVertexKeysunbounded broke an invariant the "known-new vertex" fast path relied on - a cache miss for such a vertex used to mean "definitely no segment yet" (the caches were never evicted before this PR). Once the LRU evicts that vertex's entry mid-batch (easy at the 100M+ vertex scale this PR targets), the fast path wrongly re-creates an unlinked segment, silently orphaning the first segment's already-committed edges.Fixed (commit
1dedd8161): consultdeferredOutHead/deferredInHead(unbounded for the whole batch, always authoritative for a touched vertex's current head) before assuming "no segment exists yet" in theknownNewVertexKeysbranch. New regression tests (knownNewVertexSurvivesCacheEvictionBetweenItsOwnEdgesSequential/...Parallel) reproduce the exact loss viacreateVertices()+ a smallchunkCacheCapacityforcing eviction between a vertex's own two edges; verified RED pre-fix (edge count short, database-integrity warnings about disconnected edges), GREEN post-fix.Broadened the fix (commit
529fcd1d6, self-caught re-reading the second review's wording): the same staleness applies to a pre-existing vertex NOT created viacreateVertices()- it's never inknownNewVertexKeys, so it falls to an on-disk read that's equally stale mid-batch (the on-disk head is only persisted atclose()). Restructured all four call sites (getOrCreateOutSegmentDeferred/getOrCreateInSegmentDeferred, and the inline equivalents inconnectOutEdgesRangeLocal/connectIncomingEdgesRangeLocal) to checkdeferredOutHead/deferredInHeadunconditionally, before branching onknownNewVertexKeysat all. Two more regression tests (preExistingVertexSurvivesCacheEvictionBetweenItsOwnEdgesSequential/...Parallel) reproduce this second shape with plaindatabase.newVertex()-created vertices; verified RED against the1dedd8161partial fix, GREEN against529fcd1d6.Full
com.arcadedb.graph.*Testsweep (excludingslow/benchmark): 204/204 passed after the broadened fix.Deferred, not fixed (🟡 non-blocking):
Collections.synchronizedMap(LRUCache)serializesoutChunkRIDCache/inChunkRIDCacheaccess across all parallel-flush async slots, where aConcurrentHashMappreviously gave lock-free access. The reviewer suggested benchmarkingparallelFlush=truethroughput and, if it regresses meaningfully, sharding the LRU (NCollections.synchronizedMap(LRUCache)shards keyed byvertexKey % N) to restore concurrency while keeping the bound. Not implemented in this cycle - correctness took priority over this optimization, and it's independent of the data-loss fix above. Left for a follow-up PR/benchmark ifparallelFlush=truethroughput on a multi-core box shows a real regression.Review cycle 1, follow-up
A third
claude[bot]review comment landed at 23:17 UTC re-raising point 1, but timestamp-checking showed it only reflects commit 3 (1dedd8161) - it doesn't account for commit 4 (529fcd1d6, pushed 23:05:57 UTC, 11 minutes earlier), which already drops theknownNewVertexKeysgate and covers exactly the "regular vertex, sequential flush, eviction between two edges" scenario it asks for. Replied on the PR with the specifics. Fixed the one genuinely new item it raised: the staledeferredOutHead/deferredInHeadclass-level comment claiming the parallel paths "do NOT touch these fields directly" - no longer true since the point-1 fix added direct reads there. Updated in0e7f4b13cto document why those reads are safe.Review cycle 3
claude[bot]found a correctness bug specific to this PR's early-drain feature (issue #5664), separate from and more serious than the minor cache-repopulation nit fixed alongside it.Bug: a partially-failed early drain gets blindly reprocessed by
close(), duplicating already-committed incoming edgesflush()now callsconnectDeferredIncomingEdges()directly onceinEdgeCountcrossesmaxDeferredIncomingEdges. That method commits the deferred buffer in slices - the sequential path everycommitEveryedges, the parallel path one commit per destination-bucketasync.transactiontask - but only resetinEdgeCountand nulled the buffer arrays after the whole method returned successfully. If a slice failed partway through (aNeedRetryExceptionthat exhausts retries, a unique-index violation, disk-full), earlier slices were already durably committed, but the buffer still held the full original set.flush()'s catch block resets only the outgoing buffer, never the incoming one. The class's own documentedtry (GraphBatch batch = ...) { ... }usage then triggersclose()on unwind, which unconditionally re-runsconnectDeferredIncomingEdges()over the same stale buffer - reprocessing (and duplicating) the slices that already committed in the first, failed attempt. This is a real regression introduced by this PR's early-drain feature: before it,connectDeferredIncomingEdges()was only ever called once, fromclose(), so there was no "retry over a partially-succeeded buffer" scenario.Fix
connectIncomingEdgesSequential): a newinEdgesResumeSortIndexfield records how far into the sort index a fully-committed prefix reaches, advanced only right after each successfuldatabase.commit(). A retry starts from this cursor instead of index 0, skipping already-committed groups. Reset to 0 alongside the buffer arrays only on the drain's full success.connectIncomingEdgesParallel): a newcompletedIncomingBucketsset tracks which destination buckets already committed. Each bucket is scheduled as its own independentasync.transaction; a bucket already in the set is skipped on a retry. Marked complete from the transaction'sOkCallback(fired strictly afterdatabase.commit()succeeds), never from inside the bucket-processing lambda, which runs before the surrounding commit and could still fail there.A second, deeper bug found while building the reproduction test
Both
deferredInHead/inChunkRIDCacheget mutated as a side effect of processing a group - before the transaction containing that group's writes commits (persistNewSegment, and the parallel path's inline segment creation). When a slice's transaction rolls back, those maps were left pointing at segment records that were never made durable. On the retry, resuming past the rollback point correctly skipped the committed groups, but the very next group's fallback lookup (getOrCreateInSegmentDeferred/ the parallel path's inline equivalent) hit the phantom RID and crashed withRecordNotFoundException- a different, more severe symptom of the same underlying "stale state survives into a retry" defect class.Fixed with two complementary mechanisms:
deferredInHeadvalue (once per vertex per slice) and restores it - or removes the entry entirely if it was absent before - if the slice fails.LongObjectHashMaponly supportedput/get, soremove(long)was added (tombstone-based deletion, a new reserved sentinelLong.MIN_VALUE + 2, all existing probe/resize/forEach/keysArray logic updated to skip tombstones). 17 unit tests added toLongObjectHashMapTestcovering removal, probe-chain integrity past a tombstone, slot revival, and tombstone reclamation on resize.deferredInHead/inChunkRIDCacheonly in the single-threaded pass afterwaitCompletion(), and only for buckets that are incompletedIncomingBuckets. Also found and fixed the same eager-write-before-commit pattern inconnectIncomingEdgesRangeLocal's fallback branch for a not-yet-touched, not-known-new vertex (previously calledgetOrCreateInEdgeChunk(), which persists and caches inline) - restructured to defer to the same local-map-then-merge-on-success path used by theknownNewVertexKeysbranch.Test
New
TEST_BEFORE_INCOMING_EDGE_COMMIT_HOOK(mirrors the existingTEST_BEFORE_VERTEX_COMMIT_HOOKpattern) fires right before each deferred incoming-edge commit - both the sequential path'scommitEverycommits and the parallel path's per-bucket commit - so a test can force a failure at a precise point. NewIssue5950IncomingEdgeDrainReentrancyTest(engine/src/test/java/com/arcadedb/graph/):sequentialRetryDoesNotDuplicateAlreadyCommittedGroups: 10 destination vertices,commitEvery=2, hook throws on the 3rd commit (after 4 groups already durable). Verified RED pre-fix: destination vertex Bump mongo-java-driver from 3.12.4 to 3.12.10 #4 came back with 2 incoming edges instead of 1 (exact duplicate reproduction). GREEN post-fix.parallelRetryDoesNotDuplicateAlreadyCommittedBuckets: 2 destination buckets,database.async().setParallelLevel(1)for deterministic bucket-task ordering, hook throws on the 2nd bucket's commit. Verified RED pre-fix two ways as the fix was built up: first a duplicate-edge assertion failure (same shape as the sequential case), then - after adding the resume-cursor/completed-bucket-set fix alone - aRecordNotFoundExceptionfrom the second, deeper bug above. GREEN once both fixes were in place.Both tests assert every destination vertex, across both the already-committed-before-failure group and the genuinely-pending group, ends up with exactly one incoming edge after
close().Verification
mvn -pl engine -am test -Dtest='com.arcadedb.graph.GraphBatch*Test,com.arcadedb.graph.Issue5664*Test,com.arcadedb.graph.Issue5665*Test,com.arcadedb.graph.Issue5666*Test,com.arcadedb.graph.Issue5950*Test,com.arcadedb.utility.LongObjectHashMapTest'- 52/52 passed.mvn -pl engine -am test -Dtest='com.arcadedb.graph.*Test' -DexcludedGroups='slow,benchmark'- 206/206 passed, 0 failures/errors.mvn -pl engine -am compile- clean.Also included: the minor cache-repopulation nit from the previous review cycle
connectOutEdgesRangeLocal/connectIncomingEdgesRangeLocal'sdeferredOutHead/deferredInHeadfallback branch now repopulatesoutChunkRIDCache/inChunkRIDCacheon the fallback hit, matching the sequential path's existing behavior - so a vertex touched again later in the same parallel round doesn't keep paying for the fallback lookup. Purely a minor consistency fix, no correctness impact (deferredOutHead/deferredInHeadremain authoritative either way).Related finding on the outgoing side - CORRECTED in review cycle 3, fixed in cycle 4
This section previously argued the same eager-write-before-commit pattern on the outgoing side
(
getOrCreateOutEdgeChunk) was safe to defer, on the grounds that outgoing edges have noretry-across-attempts mechanism. That reasoning was wrong and is corrected in review cycle 4 below:
it only considered retries across
flush()/close()calls and missed thatasync.transaction(..., 3, ...)retries the same bucket lambda on a
ConcurrentModificationExceptionwithin a singleflush(). The gapis fixed in cycle 4.
Commits:
6d296b284.Review cycle 4
Three findings, all verified against the code and all fixed. Commit
d7cf46879.🔴 The outgoing-side gap is reachable within a single
flush()- my cycle-3 deferral was wrongThe reviewer is correct and my earlier reasoning was incomplete. I had only considered retries across
flush()/close()calls. ButconnectOutgoingEdgesParallelschedules each bucket viaasync.transaction(..., 3, ...), andDatabaseAsyncTransaction.execute()retries the same lambda up tothree times on a
ConcurrentModificationException, rolling back and re-invokingconnectOutEdgesRangeLocalover the same range. Since page-level CME is the designed, expected outcome of two edges appending into the
same edge-list page (see
engine/CLAUDE.md), this is a normal-conditions path, not an exotic one.getOrCreateOutEdgeChunk()persisted a new segment and wrote its RID straight into the class-leveloutChunkRIDCache(and flipped the vertex head pointer) before the bucket's transaction committed. That writesurvives the rollback, so attempt 2 reads the cache first and dereferences a RID that was never made durable.
There was a second sub-case too:
parallelDeferredOutHead/parallelOutChunkCachewere merged intodeferredOutHead/outChunkRIDCacheunconditionally afterwaitCompletion(), including entries frombuckets that exhausted all 3 retries - poisoning
deferredOutHead, which is the authoritative head-chunkfallback the cycle-1/2 fix depends on, for the rest of the batch.
Fix: the OUT path now gets exactly the treatment the IN path received in cycle 3 - per-bucket local
head-pointer maps, merged into the shared state only for buckets whose commit succeeded (tracked from the
transaction's post-commit
OkCallback), and new-segment creation deferred to theisNewbranch rather thanthe eager persist-and-cache helper. Caching an existing, already-durable on-disk head chunk stays inline,
since that RID is committed regardless of this transaction's fate. Both directions additionally clear their
per-bucket local maps at the start of each attempt so a CME retry cannot inherit a rolled-back attempt's
entries.
Verified RED pre-fix: new
Issue5950OutgoingEdgeRetryCacheTestfailed withRecordNotFoundException: Record #2:0 not found- the retry dereferencing the rolled-back segment RID,exactly as predicted. GREEN post-fix. Driven by a new
TEST_BEFORE_OUTGOING_EDGE_COMMIT_HOOKthrowing aConcurrentModificationExceptionon the first bucket attempt (forcing a real CME on a chosen bucket is notdeterministic). The test deliberately creates its vertices with plain
database.newVertex(), notGraphBatch.createVertices(), since only then are they absent fromknownNewVertexKeysand routed into thefallback branch this fix changes.
🟡
LongObjectHashMap.remove()tombstones were uncounted against the resize threshold - infinite loopConfirmed as a real hang, not a theoretical one.
resize()triggered off livesizeonly, so tombstonesaccumulated uncounted. Since every probe here terminates only on
EMPTY_KEY, a workload with many moreremoves than size-growing puts can fill the table with tombstones + live entries and leave zero empty
slots, at which point
get(),put(),remove()andcontainsKey()all spin forever on a miss.Fix: tombstones are counted, and the threshold check is
size + tombstones >= threshold-exactly the non-empty slot count - which guarantees an empty slot always remains. The rehash now sizes from
the live entry count rather than always doubling, so a churn-heavy workload reclaims tombstoned slots in
place instead of growing capacity without bound.
Verified RED pre-fix: the test class hung outright (over 400s for a class that normally finishes in
0.08s), running 0 tests to completion. Worth noting: my first attempt at this test used a plain
@Timeout, which does not work here - JUnit's default same-thread timeout can only report a breachafter the method returns, which an infinite loop never does, so the build would hang rather than fail. The
committed tests use
threadMode = SEPARATE_THREADso a regression actually fails the build. 19/19 GREENpost-fix in 0.077s.
🟡 The incoming-drain resume state assumed "no intervening
newEdge()", unenforcedConfirmed: my own javadoc stated the assumption and nothing enforced it. A caller that catches the exception
from a failed early drain and keeps calling
newEdge()- a pattern this class supports elsewhere, e.g.createVerticesWithRetryfor #4724's Raft hiccups - appends onto the still-undrained buffer. SincepartitionIncomingByDestBucketis a counting sort, that changesbucketCounts/bucketOffsetsand thereforewhat a given raw index, and a given bucket's
[from,to)range, refers to. The recordedinEdgesResumeSortIndexandcompletedIncomingBucketsthen apply to a differently-shaped index.I chose the reviewer's option (b), robustness, over (a), fail-fast: for a bulk loader, turning a survivable
transient failure into a dead batch is a worse outcome than making the resume mechanism correct.
Fix: each drain pass pins the row count it covers (
inEdgesDrainPrefix) and a retry reuses that exactpin, so the sort index has an identical shape across attempts and both resume mechanisms stay sound. After a
pinned prefix completes, it is dropped from the buffer and any rows appended past the pin are drained as their
own pass (
connectDeferredIncomingEdges()now loops). On the normal pathprefix == inEdgeCount, so thecompaction is a counter reset with no copying - zero overhead on the hot path.
Verified RED pre-fix with two new tests, each reproducing a distinct symptom, which also required fixing
my first test design (it accidentally appended rows that sorted after the prefix, so it could not fail):
#4:0- the wave-2 row in a bucket sorting below wave 1's, which the raw cursor skipped past;#7:3- the wave-2 row added to a bucket already marked complete, which the bucket-skip dropped.Both GREEN post-fix.
Nits
public static volatiletest hook. Kept, and a third added for the OUT side. It matches theestablished
TEST_BEFORE_VERTEX_COMMIT_HOOKconvention, each is cleared in@AfterEach, and proving aconcurrency fix without fault injection is not feasible - forcing a real CME on a specific bucket is
nondeterministic. On the parallel-execution concern:
forkCount=1and nojunit.jupiter.execution.parallel.enabledanywhere, so tests run sequentially in one JVM; if that everchanges, the pre-existing vertex hook has the identical exposure and they should be addressed together.
setParallelLevel(1)not restored. Verified it cannot leak:parallelLevelis perDatabaseAsyncExecutor, andTestHelperbuilds a fresh database (hence a fresh executor) in itsconstructor for every test method and drops it in
@AfterEach. Restoring would only churn the thread poolof a database about to be dropped. Documented in-place rather than adding a no-op restore that would imply
otherwise.
Verification
mvn -pl engine -am test -Dtest='com.arcadedb.graph.*Test,com.arcadedb.utility.LongObjectHashMapTest' -DexcludedGroups='slow,benchmark'- 232/232 passed, 0 failures/errors.mvn -pl engine -am compile- clean.Note on branch history
This branch was rebased onto current
main(f2754aee3) partway through the review cycles and force-pushed once, to pick up sibling PRs #5948 (#5667 super-node striping) and #5949 (#5665 async WAL policy) after they merged - all three touchedGraphBatch.javaand the branch had developed a real conflict. The#5667promotion routing was merged by hand inconnectIncomingEdgesSequentialandconnectIncomingEdgesRangeLocal, combined with this PR's resume-cursor/local-map machinery;Issue5667GraphBatchSuperNodeResumeTestpasses alongside this PR's own tests. History is linear and all pre-rebase commits remain reachable (6d296b284and earlier) if any earlier review reference needs resolving. Flagged here for transparency since the rebase rewrote SHAs cited in earlier review replies - those old SHAs still resolve on GitHub.Performance verification (closes the cycle-1 and cycle-4 perf questions)
Benchmarked on a 12-core machine (11 async worker threads, so the parallel-flush lock contention under
question is genuinely exercised, not understated). 100k distinct vertices, 1M edges, bidirectional,
median of 5 alternating iterations per configuration. Harness committed as
engine/src/test/java/performance/GraphBatchDrainPerfBenchmark.java- it uses only builder methodsthat exist both before and after this PR, so the identical source runs against the pre-PR tree for a
true before/after.
f2754aee3)parallelFlush=true(chunk-RID cache contention, cycle 1)parallelFlush=false(incoming-head undo log, cycle 4)No measurable regression from either concern. Both deltas are within run-to-run noise (±3%; a repeat
sample put the sequential figure at 1613 ms), and both moved slightly in the faster direction, so
neither the
Collections.synchronizedMap(LRUCache)serialization nor the boxed-Longundo log costsmeasurable throughput at this scale and parallelism.
What the memory bound costs once it actually binds
GraphBatchEvictionCostBenchmarkvaries onlywithChunkCacheCapacityon this tree (a before/afterframing does not apply - unbounded is the pre-PR behavior this issue removes):
chunkCacheCapacityEffectively free, and the ordering is non-monotonic, which is the signature of noise rather than a real
cost. The reason is worth stating explicitly, because it is a consequence of the cycle-1/2 fix and it
bounds how far this result generalizes: a chunk-cache miss now falls back to
deferredOutHead/deferredInHeadbefore reading from disk, and those maps are still unbounded for thebatch's lifetime. Every vertex in this benchmark is created by
createVertices()within the batch, so itis present in the deferred map and an evicted cache entry costs only a second in-memory lookup - never a
disk read.
Two honest limits follow from that:
over a pre-existing graph) would pay a real disk read on an evicted miss. That case is not measured
here.
deferredOutHead/deferredInHead/knownNewVertexKeysremain unbounded by distinct-vertex count. At the 663M-vertex scale fromdiscussion Ingesting Massive DB with 600 Million Vertices and 14 Billion Edges #5597 those maps, not the now-bounded chunk caches, are the remaining memory ceiling.
Full-suite verification
enginemodule unit suite on this HEAD: 11,252 tests, 0 failures, 0 errors, 23 skipped(
mvn -pl engine -am test -DexcludedGroups=slow,benchmark, read from Maven's ownResults:summary).(Addresses the "verification perimeter is narrower than the blast radius" concern - the earlier cycles
had only run the
graphpackage.)com.arcadedb.graph.*Test+LongObjectHashMapTest: 232/232.only that fix.
Review cycle 5
Two non-blocking items from the latest reviews, both fixed in
c31233a02:get()/containsKey()did not short-circuit onTOMBSTONE_KEYthe wayput()/remove()already did, socontainsKey(TOMBSTONE_KEY)would probe and "match" the first tombstoned slot it walked over, wronglyanswering
true. Unreachable through this map's own API (put()rejects the key, so it can never be a realmapping), but guarded anyway since this is a general-purpose utility.
Worth calling out how the regression test was built, because the obvious version of it cannot fail: a
tombstone placed anywhere in the table isn't enough, since the probe stops at the first
EMPTYslot andreturns
falsefor the wrong reason - that test passes with the fix reverted. The committed test thereforereplicates the map's MurmurHash3 finalizer to plant the tombstone at exactly the slot the sentinel's own probe
starts on. Verified RED (
expected false but was true) against the reverted guard, GREEN with it.The class javadoc now states that the memory bound is partial. It previously listed the two bounded caches
under "optimizations" with no statement of what remains unbounded, which reads as though GraphBatch retains per-vertex state for the whole batch lifetime, so a long-lived bulk stream grows without bound #5664 is fully
solved.
deferredOutHead/deferredInHead/knownNewVertexKeysstill grow with distinct-vertex count, andsince this PR they are load-bearing rather than an optimization - they are the authoritative fallback that
keeps a cache eviction from orphaning an earlier segment. Stated plainly so the remaining ceiling isn't
rediscovered later.
Note on the red
integration-testscheckIt is a pre-existing
mainbreakage, not this PR. The same job fails on this branch's own merge-base(
f2754aee3) and ona049d9325,b380e9dca,81362c771. The job log ends partway throughcom.arcadedb.server.HTTPDocumentITwith every class above it green and no assertion failure orBUILD FAILUREline - the shape of a timeout/kill rather than a test failure. Worth its own issue; nothing to fix here.