Skip to content

fix(#6263): one budget for the working set of an algo call, not just its walk buffers - #6285

Merged
lvca merged 7 commits into
mainfrom
issue-6263
Aug 17, 2026
Merged

fix(#6263): one budget for the working set of an algo call, not just its walk buffers#6285
lvca merged 7 commits into
mainfrom
issue-6263

Conversation

@lvca

@lvca lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member

Closes #6263.

The gap

#6065 capped every embedding-dimension knob at MAX_EMBEDDING_DIMENSION (4096); #6216 priced the random-walk buffers against a heap budget. Between them, the largest allocation of these procedures stayed outside every budget: the matrices themselves.

A dimension cap bounds one embedding row at 32 KB and says nothing about a nodeCount x dimension matrix. At algo.node2vec's default embeddingDimension: 128 the two Skip-gram matrices cost ~2080 bytes per node (208 MB at 100k nodes, 2.1 GB at 1M, 21 GB at 10M) - the same order as the walk matrix sitting beside them in the same method, at ~3560 bytes per node, which #6216 already refused up front. One budgeted, one not.

algo.fastrp made it plainest: it has no walk buffer at all, so no budget of any kind applied to it.

The failure mode was an OutOfMemoryError instead of the client error naming a parameter that #6065 and #6216 both exist to produce - and an OOM on a shared server takes down work unrelated to the query that caused it.

What this does

Option 1 from the issue, generalised further.

Rename, no alias: arcadedb.cypher.algoMaxWalkMemory -> arcadedb.cypher.algoMaxWorkingMemory. The key was introduced in this same unreleased version (26.9.1-SNAPSHOT, #6222 merged today), so there is no deprecated alias to carry - the concept it implements was never walk-specific, only its name was. Default max(64MB, maxHeap/8), negative = no limit, IllegalArgumentException -> HTTP 400: all unchanged.

Reservations accumulate over the call rather than being checked one allocation at a time. That is what "how much heap may this call take?" actually asks, and a single call routinely holds several of these at once - node2vec keeps its walk matrix alive while training over two embedding matrices - so a per-component check would let a call exceed the budget by however many components it happens to have. checkWalkBudget is replaced by a per-call MemoryBudget with reserve(bytes, component, detail), plus matrixBytes(rows, cols, elementBytes) and saturatingSum next to the existing saturatingProduct.

algo.node2vec(): the embedding matrices would need 8448 bytes (2 matrices of 4 nodes x embeddingDimension=128),
on top of the 14240 bytes this call already reserved, more than the 20000 bytes allowed.
Set arcadedb.cypher.algoMaxWorkingMemory to raise the limit

Priced, all before anything is allocated:

procedure working set
algo.node2vec walk matrix + 2 x nodeCount x embeddingDimension
algo.fastrp 2 x nodeCount x dimensions
algo.hashgnn 2 x nodeCount x 4*embeddingDimension feature matrices + 1 x nodeCount x embeddingDimension
algo.graphsage 2 x nodeCount x embeddingDimension + the layer's embeddingDimension x 2*initDim projection
algo.apsp nodeCount x nodeCount distance matrix
algo.simRank 2 x nodeCount x nodeCount similarity matrices
algo.maxFlow nodeCount x nodeCount capacity + residual
algo.kShortestPaths nodeCount x nodeCount weight matrix + removed-edge mask
algo.randomWalk walk buffer (unchanged from #6216)

Beyond what the issue asked for, and why

The issue scoped this to the four embedding procedures. The last four rows above are the reason the rename is worth doing at all rather than adding a second walk-shaped key: their matrices are sized by the graph alone, with no knob involved anywhere. algo.simRank(a, b) answers a question about exactly two nodes and allocates two full nodeCount x nodeCount matrices to do it (1.6 GB at 10k nodes), because the similarity of one pair is defined recursively over every pair. algo.apsp documented itself as suitable "up to a few thousand vertices", which was advice, not a bound. Naming a key algoMaxWorkingMemory and then leaving the largest working sets in the package unpriced would over-promise exactly the way algoMaxWalkMemory under-promised.

These algorithms are cubic or worse in time, so the memory bound bites at roughly the scale where the runtime is already impractical; what changes is that the refusal names the node count and the setting instead of arriving as an OOM several minutes in.

Two findings from the same pass:

  • algo.hashgnn's feature matrices, not its embedding matrix, are the larger pair. They are four times as wide, so even as boolean they cost half a byte per dimension per node against the embedding's eight. Priced by name rather than folded into an "embedding matrices" figure that would understate the call by ~2x.
  • algo.graphsage's projection matrix dominates on a small graph (outDim x 2*initDim = 67584 bytes at the defaults). Its peak is per-layer, not per-run - each layer drops the matrix it read - so layers does not multiply the reservation.

AlgoGraphSAGE.java's new double[outDim][concatDim], flagged in the issue as "worth pricing in the same pass", is included.

Verification

New Issue6263AlgoWorkingMemoryBudgetTest, 14 tests. Sensitivity checked the way #6216 was: the eight touched procedures reverted to HEAD (and node2vec's embedding reservation removed by hand, since it cannot compile against the old helper) -> 11 of 14 red. The three that stay green are the over-reach guards, which must pass both ways: the budget must not refuse a call it used to serve, at an ample budget, at a disabled budget, and - everyPricedProcedureStillRunsUnderTheDefaultBudget - at the untouched default across all nine procedures.

  • Algo*Test + Issue6216* + Issue6263*: 325 tests green
  • *Cypher*Test + *Configuration*Test: 3191 tests green
  • full reactor mvn install -DskipTests: green

Release notes appended to docs/release-26.9.1.md, and the #6216 section's mention of the old key updated so the notes never name a key that no release contains.

…its walk buffers

#6065 capped the embedding-dimension knobs at 4096 and #6216 priced the random-walk
buffers against a heap budget. Between them the largest allocation of these procedures
stayed outside every budget: the matrices themselves, sized nodeCount x dimension. A
dimension cap bounds one embedding ROW at 32 KB and says nothing about the matrix - at
node2vec's default dimension of 128 the two Skip-gram matrices cost ~2080 bytes per node,
the same order as the walk matrix beside them that was already refused up front. fastRP
made the gap plainest: no walk buffer at all, so no budget of any kind applied to it.

arcadedb.cypher.algoMaxWalkMemory is renamed arcadedb.cypher.algoMaxWorkingMemory. The key
was introduced in this same unreleased version, so no deprecated alias is carried: the
concept - estimate in saturating long arithmetic, reject as a client error before
allocating, auto-scale the default with the JVM heap - was never walk-specific, only its
name was.

Reservations now accumulate over the call instead of being checked one allocation at a
time, because a single call routinely holds several of these at once and pricing each
separately would let it exceed the budget by however many components it happens to have.
The error names the component, the counts that sized it and what the call had already
reserved.

Priced, all before anything is allocated: node2vec (walks + 2 embedding matrices), fastRP,
hashGNN (its boolean feature matrices are the larger pair, four times as wide as the
embedding), graphSAGE (per-layer peak, including the projection), and the square matrices
that no knob sizes at all - apsp, simRank, maxFlow, kShortestPaths, where the graph alone
decides and simRank allocates two full nodeCount x nodeCount matrices to answer a question
about two nodes.
@lvca lvca self-assigned this Aug 17, 2026
@mergify

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

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 6 complexity

Metric Results
Complexity 6

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review: #6285 - one budget for the working set of an algo call

Solid, well-scoped generalization of the #6216 walk-memory budget into a per-call, accumulating MemoryBudget that now also prices every dense matrix in the algo.* package. I read the full diff and hand-verified the byte arithmetic.

Verification performed

I wasn't able to get mvn approved to run in this sandboxed session, so I verified correctness statically instead of by running the suite:

  • Re-derived the expected byte counts for all 9 new matrixBytes(...)/saturatingProduct/saturatingSum call sites (node2vec walk+embedding, fastrp, hashgnn feature+embedding, graphsage feature+layer, apsp, simRank, maxFlow, kShortestPaths) and matched them against the literal numbers asserted in Issue6263AlgoWorkingMemoryBudgetTest - all matched (e.g. hashgnn's 4352/4224, graphsage's 2176/69760, node2vec's 14240+8448).
  • Confirmed every reservation call happens before the corresponding allocation in each procedure file.
  • Confirmed GraphSAGE's "peak is per-layer, not per-run" claim against the loop: concatDim = curDim * 2, and since initDim = max(outDim, 16) >= outDim, layer 0's projection (2 x initDim wide) is provably the widest, so pricing only layer 0 is correct and not an under-count for layers > 1.
  • Grepped for stray references to the removed CYPHER_ALGO_MAX_WALK_MEMORY / algoMaxWalkMemory / checkWalkBudget / WALK_ENTRY_BYTES symbols across .java/.md; the only surviving hit is the historical "renamed from" sentence in docs/release-26.9.1.md, which is intentional.

I'd still want CI to actually execute Issue6263AlgoWorkingMemoryBudgetTest and the updated Issue6216AlgoWorkKnobBoundsTest before merge, since I couldn't run them myself here.

Design

  • Reservations accumulating over the call rather than per-allocation is the right call, node2vec's own new test (node2VecChargesTheEmbeddingMatricesOnTopOfTheWalkBuffer) demonstrates exactly why a per-component check would under-count.
  • MemoryBudget skipping bookkeeping entirely when limit < 0 is a nice touch, a disabled budget costs nothing on the hot path.
  • The describe() special-case for a saturated Long.MAX_VALUE estimate ("over Long.MAX_VALUE" rather than quoting the ceiling as if exact) is a good detail.
  • Reusing matrixBytes/saturatingSum next to the existing saturatingProduct instead of introducing parallel helpers keeps this consistent with the Unbounded CPU-shaped config knobs in the OpenCypher algo procedures (follow-up to #6065) #6216 house style.

Minor observations (non-blocking)

  1. Adjacency is still built before the budget check in several procedures (AlgoFastRP, AlgoNode2Vec, AlgoHashGNN, AlgoGraphSAGE all call graph.adjacency(...) before newMemoryBudget(db).reserve(...)). A call that's going to be rejected for exceeding the working-memory budget still pays for building the adjacency lists (O(edges)) first. This mirrors the pre-existing Unbounded CPU-shaped config knobs in the OpenCypher algo procedures (follow-up to #6065) #6216 pattern (not a regression from this PR), but since this PR touches every one of these call sites anyway, it might have been a cheap opportunity to reorder the reservation ahead of graph.adjacency(...) where n alone is enough to size it (fastrp, hashgnn, graphsage's node-feature matrix). Not required for this PR's scope.
  2. Error message wording for maxFlow/kShortestPaths describes two differently-shaped matrices as "2 matrices of n x n nodes" (kShortestPaths sums a double weight matrix and a boolean mask, which are legitimately different sizes per element even though both are n x n). The byte math correctly accounts for the different element widths via saturatingSum there rather than saturatingProduct(2L, ...); the message text is just a bit less precise than the arithmetic behind it. Cosmetic only.
  3. Per-call budget, not a global pool: as before Unbounded CPU-shaped config knobs in the OpenCypher algo procedures (follow-up to #6065) #6216, CYPHER_ALGO_MAX_WORKING_MEMORY bounds one call's working set, not aggregate heap pressure across concurrently running algo calls on a shared server. That's explicitly out of scope here and consistent with the existing design, just worth keeping in mind if #6263-style OOMs recur under concurrent load rather than from a single oversized call.

Docs

docs/release-26.9.1.md update is thorough and the #6216 section's key reference was correctly updated in place so the notes never mention a key name that no released version will contain. Good attention to detail.

Nice work, this closes a real gap (algo.fastrp had literally no budget of any kind before this) and the test suite is unusually rigorous about proving the accumulation behavior (not just the individual estimates) with the "over-reach guard" tests.

…is which

Two points from the first review round.

The reservation now happens before graph.adjacency(): in fastRP, node2vec, hashGNN,
graphSAGE and simRank the estimate needs only the node count and the knobs, so a call
that is going to be refused no longer first pays the O(edges) adjacency build. This was
the pre-existing #6216 ordering rather than a regression, but every one of these call
sites is already being touched here.

algo.kShortestPaths sums a double weight matrix and a boolean mask, which are the same
shape but not the same size; the message called them "2 matrices of n x n nodes", which
the arithmetic behind it never assumed. It now names the element types. algo.maxFlow's
two matrices really are both double, so its wording stands.

The third observation - that the budget bounds one call rather than aggregate heap across
concurrent calls - is left as-is: that is the existing design and a cross-query pool is a
different mechanism, not a wording change.
@lvca

lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Round 1 addressed in 1a0d871.

1. Adjacency built before the budget check - valid, applied. The reservation now precedes graph.adjacency() in AlgoFastRP, AlgoNode2Vec, AlgoHashGNN, AlgoGraphSAGE and AlgoSimRank (which had the same shape and wasn't in your list). In all five the estimate needs only the node count and the knobs, so a call that is going to be refused no longer pays the O(edges) build first. You're right that it was the pre-existing #6216 ordering rather than a regression, but since the PR touches every one of these call sites the cost of fixing it is a moved line.

AlgoAPSP, AlgoMaxFlow and AlgoKShortestPaths already reserved before any edge iteration.

2. kShortestPaths message wording - valid, applied. It sums a double weight matrix and a boolean mask: same shape, different size per element, which is exactly why the arithmetic uses saturatingSum there rather than saturatingProduct(2L, ...). The detail now reads "a double matrix and a boolean matrix, each n x n nodes". algo.maxFlow's two matrices really are both double, so "2 matrices of n x n nodes" is accurate there and stands.

3. Per-call budget, not a global pool - correct, and deliberately left alone. Bounding aggregate heap across concurrently running algo calls is a different mechanism (a shared pool with acquire/release semantics and a policy for what a saturated pool does to a waiting query), not a change to this setting's wording or scope. Worth its own issue if concurrent-load OOMs show up; not something to bolt onto a per-call estimate.

On the verification you couldn't run locally: Algo*Test + Issue6216AlgoWorkKnobBoundsTest + Issue6263AlgoWorkingMemoryBudgetTest are 325 tests, green after the reorder as well. The reorder is the kind of change that could have silently changed a seeded result - it does not: Random construction moved, not its consumption order.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review

Solid piece of work overall: the accounting is careful (I hand-verified the byte arithmetic in every priced procedure against the test expectations - node2vec, fastrp, hashgnn, graphsage, apsp, simRank, maxFlow, kShortestPaths all check out exactly), the reservations are placed before the O(edges) adjacency build as claimed, and the "accumulate rather than check-per-component" design is correct and is exactly what the node2VecChargesTheEmbeddingMatricesOnTopOfTheWalkBuffer test is there to pin down. The GraphSAGE per-layer peak reasoning (pricing off initDim since it's >= outDim, so layer 0's projection dominates every later layer) also checks out against the actual allocation pattern - each layer's W/nextEmbed drop their only reference at the end of the loop body, so the reservation is a valid upper bound rather than an undercount.

One gap: algo.steinerTree has the same unbudgeted O(t·n) shape this PR closes elsewhere - and an int-overflow bug in the same class as the ones this PR line of work (#6065/#6216) exists to catch

AlgoSteinerTree.java isn't touched by this PR, but it has exactly the pattern the PR's own rationale (the "beyond what the issue asked for" section) argues should be priced:

final int t = terminals.size();               // caller-controlled, no cap anywhere
...
final double[][] dist = new double[t][n];      // t × n, no budget check
final int[][] prev = new int[t][n];            // t × n, no budget check
...
final int pairCount = t * (t - 1) / 2;         // int arithmetic, overflows around t ≈ 46000+
final int[] pU = new int[pairCount];
final int[] pV = new int[pairCount];
final double[] pW = new double[pairCount];
  • terminals comes from extractVertexList(args[0], "terminalNodes"), which validates element types but never bounds the list length - unlike every knob this PR treats carefully (embeddingDimension, walksPerNode, ...), t has no ceiling at all, not even MAX_EMBEDDING_DIMENSION. A caller can pass a very long terminal list (duplicates of the same vertex work fine for this purpose, so t isn't even bounded by node count) and dist/prev become an unpriced t × n matrix pair - the same failure mode as algo.apsp's n × n distance matrix, just rectangular instead of square.
  • Separately, t * (t - 1) / 2 is computed in int arithmetic. The intermediate product t * (t - 1) overflows int once t gets into the ~46,000+ range (46341 * 46340 ≈ 2.147e9 > Integer.MAX_VALUE), long before the division by 2. That wraps to a negative or nonsensical pairCount, which either throws a bare NegativeArraySizeException from new int[pairCount] or, if the wrapped value happens to be small and positive, lets the pU[pi++]-style fill loop run past the array bound - i.e. exactly the "large but perfectly in-range int reaches the allocator unchecked, or wraps the int product on the way there" scenario the PR description uses to justify this whole line of work for the other procedures.

Given the PR explicitly reasons about generalizing beyond the issue's scope to cover every unpriced dense/quadratic working set in the algo.* package, this looks like a procedure that got missed rather than one deliberately left out. Might be worth a follow-up (or folding into this PR): reserve matrixBytes(t, n, DOUBLE_BYTES) + matrixBytes(t, n, INT_BYTES) against the same budget before allocating dist/prev, and compute pairCount in long (as saturatingProduct/matrixBytes already do elsewhere in this PR) before narrowing, rejecting an oversized t the same way algo.node2vec's totalWalksAsLong check does.

Minor / non-blocking observations

  • The config key rename (algoMaxWalkMemoryalgoMaxWorkingMemory) drops the old key with no deprecated alias. The PR's justification (introduced in this same unreleased snapshot, so nothing shipped depends on the old name) is reasonable, and I confirmed there's no other reference to the old key or checkWalkBudget left anywhere in the tree.
  • As called out in the PR description itself, the budget still bounds one call rather than aggregate concurrent memory - several concurrent calls near the default (maxHeap/8 each) can still collectively exceed the heap. That's an explicitly acknowledged, reasonable scope cut for this PR rather than an oversight.
  • MemoryBudget/WorkGuard are non-static inner classes of AbstractAlgoProcedure purely so they can call getName() on the enclosing procedure. Not a real problem (procedure instances aren't per-call state, and these are short-lived locals), just a small style note - a static class taking the procedure name as a constructor argument would avoid holding the implicit outer reference.

Test coverage

The new Issue6263AlgoWorkingMemoryBudgetTest is thorough: per-procedure over-budget rejection, an over-reach guard (fits-the-budget case) and a disabled-budget case for the procedure that best demonstrates each new mechanism (fastrp for "nothing else prices it", node2vec for accumulation, hashgnn/graphsage for multi-component accumulation), plus a single everyPricedProcedureStillRunsUnderTheDefaultBudget sanity check across all nine procedures. Good use of a 4-node cycle graph to keep the numbers hand-checkable while still exercising the real code paths.

Nothing else stood out - didn't spot any issues in the byte-accounting formulas, the saturating-arithmetic helpers, or the ordering of reservation vs. allocation in the eight touched procedures.

…ger wraps int

Found by the second review round, and it is the same defect in both of its forms.

terminalNodes is the only knob in the package a caller supplies as data rather than as a
number, so nothing validates its length - not even the node count, since repeating the
same vertex is accepted. It sizes a terminals x nodeCount pair of Dijkstra tables and then
t * (t - 1) / 2 terminal pairs across four parallel arrays, none of which was priced.

That expression was evaluated in int. The division by 2 happens after the product, so the
result fitting an int never saved it: at 46342 terminals it wrapped and new int[pairCount]
died as NegativeArraySizeException: -1073716337, naming nothing. It is now computed in
long, reserved against the budget - 1073767311 pairs, about 43 GB, at that length - and
refused outright past the entries a Java array can hold, whatever the heap setting says.

The pair arrays include an Integer[] index array sorted through a Comparator; new
BOXED_INTEGER_BYTES prices it at the 24 bytes an entry it really costs rather than at the
4 of the int it carries. Replacing that boxed sort with a primitive one is a real win but
a different change.

A sweep of every two-dimensional allocation in the package confirms this was the last
unpriced one.
@lvca

lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Round 2 addressed in 590963c. The algo.steinerTree finding was right on both counts, and folded in rather than deferred - the PR's own claim is that no dense working set in the package is left unpriced, and a known exception to that would be a divergence to document rather than a scope line.

Reproduced before fixing. Reverting AlgoSteinerTree to HEAD and running the three new tests gives, verbatim:

Caused by: java.lang.NegativeArraySizeException: -1073716337

at 46342 terminals - exactly the shape you predicted. Worth noting why 46342 rather than 65536: the division by 2 happens after the product, so t * (t - 1) wrapping is not saved by the result fitting an int. The true count there is 1073767311 pairs, which does fit an int; it is the intermediate that wraps.

What landed:

  • pairCount computed in long, reserved against the budget (about 43 GB across the four parallel arrays at that length, so the default budget refuses it - the two defects turn out to be the same defect), and refused outright past Integer.MAX_VALUE entries whatever the heap setting says, mirroring algo.node2vec's totalWalksAsLong guard.
  • matrixBytes(t, n, DOUBLE_BYTES) + matrixBytes(t, n, INT_BYTES) reserved for the Dijkstra tables, before the adjacency build, as with the other procedures.
  • One correction to your suggested estimate: the pair arrays are not three primitive arrays but four, and the fourth is an Integer[] index array sorted through a Comparator. At 24 bytes an entry against the 4 of the int it carries, folding it into a primitive figure would understate that reservation by about 2.5x, so there is a new BOXED_INTEGER_BYTES and it is priced at what it costs. (Replacing the boxed sort with a primitive one is a real win - CLAUDE.md asks for primitive arrays over boxed collections on exactly these paths - but it is a behaviour-preserving refactor of Kruskal's ordering, not a bound, so it is not in this PR.)
  • terminalNodes gets no numeric cap, consistent with the rest: bounded by the resource it spends, not by a guessed maximum.

Sweep, so round 3 doesn't find another. Every two-dimensional allocation in procedures/algo whose first dimension is not the node count: AlgoGraphSAGE's W and AlgoNode2Vec's walks (both already priced here) and AlgoSteinerTree's dist/prev (now priced). AlgoMST's Integer[] sortIdx is sized by the edge count, which is the graph itself rather than a caller-supplied multiplier, so it is deliberately out.

Verification: 328 tests across Algo*Test + Issue6216* + Issue6263*, green. The three new SteinerTree tests are red against HEAD's AlgoSteinerTree (the NegativeArraySizeException above, plus the two budget cases), so none of them is vacuous.

On the two remaining notes: the inner-class one is fair - MemoryBudget/WorkGuard hold the outer reference only to reach getName(), and both are short-lived locals on a procedure instance that carries no per-call state, so I've left them matching WorkGuard's existing shape rather than splitting the two apart. The concurrent-heap one stands as an acknowledged scope cut.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review

Went through the diff in detail, including cross-checking the new memory estimates against the actual allocation patterns in each touched algorithm (not just the reservation call sites). Summary: this is a well-scoped, carefully verified change. Findings below are minor.

Correctness checks performed

  • AlgoGraphSAGE: verified the "peak is per-layer, not per-run" claim by tracing embed = nextEmbed at the end of each loop iteration - the old matrix is dropped, so layer 0 (widest, since initDim >= outDim) is genuinely the peak. The reservation (node feature matrix + layer matrices) sums to exactly that peak: initDim matrix + outDim matrix + outDim x 2*initDim projection. Arithmetic in the test (69760 bytes for layers:2, seed:42) matches by hand.
  • AlgoHashGNN: confirmed features/newFeatures are both live through the message-passing loop and embeddings is allocated while both are still in scope (not nulled), so pricing all three concurrently is correct, not an over-estimate.
  • AlgoKShortestPaths: confirmed the algorithm is single-threaded and removedEdges is a fresh local per spur-node iteration, so "only one mask ever live" holds.
  • AlgoSteinerTree: the intlong fix for t * (t - 1) / 2 is correct - the previous code's overflow was real (division by 2 happens after the product, so bounding the result didn't help), and the new pairCount > Integer.MAX_VALUE check runs unconditionally after reserve(), so it still fires even with the budget disabled (covered by steinerTreeRejectsMoreTerminalPairsThanAJavaArrayCanHoldEvenWithTheBudgetDisabled).
  • Spot-checked matrixBytes/saturatingSum/saturatingProduct nesting for overflow safety - all intermediate sums/products saturate before the outer multiply, so a huge columns * elementBytes can't wrap before being folded into the row cost.

Minor: doc/config description omits algo.steinerTree

GlobalConfiguration.CYPHER_ALGO_MAX_WORKING_MEMORY's own description (engine/src/main/java/com/arcadedb/GlobalConfiguration.java:817-831) lists the procedures it bounds - node2vec, fastrp, hashgnn, graphsage, apsp, simRank, maxFlow, kShortestPaths - but not steinerTree, which was wired up in the third commit after that text was written. The docs/release-26.9.1.md table does include the steinerTree row, so this is just the in-code config description falling slightly behind. Worth a one-line addition for consistency, since this text is what users see via GET /server config docs / HELP output.

Design observations (not blocking)

  • BOXED_INTEGER_BYTES = 24L is a deliberate over-estimate for small terminal counts (Integer cache covers -128..127), which the javadoc already calls out as a heuristic - fine, since erring toward rejecting slightly earlier is the safe direction for a budget check.
  • Good catch generalizing "reserve before graph.adjacency()" across all five touched call sites - this avoids paying for an O(edges) build on a call that's going to be refused anyway, which also happens to be a nice DoS-resistance property (cheap rejection path).
  • The accumulate-per-call design (MemoryBudget.reserve running total vs. the old per-allocation checkWalkBudget) is the right fix for the stated gap (node2vec holding a walk matrix + 2 embedding matrices concurrently) - the node2VecChargesTheEmbeddingMatricesOnTopOfTheWalkBuffer test directly demonstrates the old per-component check would have let 22688 bytes through a 20000-byte budget.

Test coverage

14 new tests in Issue6263AlgoWorkingMemoryBudgetTest, one class per priced component plus overflow/disabled-budget/default-budget guards, plus the existing Issue6216 tests updated for the rename. The description states a revert-based sensitivity check (11/14 red against HEAD), which is good practice for this kind of budget test and worth keeping as the pattern for future budget additions in this package.

Security/performance

No new dependencies. This change strictly improves DoS resilience (turns unbounded-heap OOM into a named IllegalArgumentException → HTTP 400) and doesn't introduce new attack surface. No performance concern - the added reservations are O(1) arithmetic ahead of allocations that were already going to happen.

Nothing here blocks merging; the one suggested change is updating the CYPHER_ALGO_MAX_WORKING_MEMORY description to mention algo.steinerTree.

The description enumerates what the budget bounds, and it is what a user reads through
the server config endpoint rather than something only a maintainer sees, so it falling a
commit behind the code it describes is a defect in its own small way.
@lvca

lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Round 3 addressed in 0607a5e. One point, valid, applied.

CYPHER_ALGO_MAX_WORKING_MEMORY's description omitted algo.steinerTree - correct, and the reason it matters is exactly the one you gave: that text is served to users through the server config endpoint, so it is documentation rather than a comment, and it was a commit behind the code it describes. It now names the terminals x nodeCount tables and the terminal-pair arrays, and the sentence about what would otherwise reach the allocator now includes "a terminal list of any length" alongside the in-range int and the large graph - steinerTree is the one entry in that list whose driver is neither a number nor the graph.

On the two design observations, no change and no disagreement:

  • BOXED_INTEGER_BYTES = 24L over-estimating below the Integer cache threshold is deliberate, and you have the direction right: for a budget check, over-estimating rejects slightly early while under-estimating admits a call that then OOMs, so the asymmetry is the whole point. It is also the same reasoning as MATRIX_ROW_OVERHEAD_BYTES, which is why the javadoc cross-references it rather than restating the caveat.
  • The reserve-before-graph.adjacency() ordering: the cheap-rejection property you name is the better framing of it than the one in my round-1 reply. A refused call now costs one loadGraph and some arithmetic instead of an O(edges) build, which matters more for a hostile caller than for an honest one.

One correction to the numbers in your test-coverage section: Issue6263AlgoWorkingMemoryBudgetTest is now 17 tests, not 14 - the three algo.steinerTree cases landed in the round-2 commit after the PR description was written. The revert-based sensitivity result is 14 of 17 red against HEAD (11 from the original round plus all three steinerTree cases); the three that stay green are the over-reach guards, which must pass both ways or they are not guards.

Full run after this commit: 48 tests across Issue6263* + Issue6216* + AlgoSteinerTreeTest, green; 328 across the whole Algo* set as of the previous commit, unaffected by a description string.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review

Went through the full diff (all touched algo.* procedures, GlobalConfiguration, AbstractAlgoProcedure, and the new Issue6263AlgoWorkingMemoryBudgetTest) and hand-verified the byte math.

Correctness — looks solid. I manually recomputed matrixBytes()/saturatingSum for every scenario the new test asserts on (fastRP's 8448 bytes, hashGNN's 4352/4224, graphSAGE's 2176/69760, node2vec's 14240/8448, the four n x n cases at 256/512/512/400, steinerTree's 224, and the int-wrap arithmetic at exactly 46342 terminals → 1073767311 pairs → the documented -1073716337 NegativeArraySizeException) and every one checks out against the formula in AbstractAlgoProcedure. That level of agreement between the prose, the code, and the test literals is a good sign the accounting is actually right, not just self-consistent.

Ordering. In each touched procedure (fastrp, node2vec, hashgnn, graphsage, simRank, steinerTree) I confirmed the knobs the reservation needs (dimensions, outDim/layers, embDim, dim/walksPerNode/walkLen, t) are all already parsed by the time reserve()/newMemoryBudget() is called, and the reservation genuinely happens before graph.adjacency() — so a call that's going to be refused doesn't first pay for the O(edges) adjacency build, as the commit message claims.

Rename. Grepped for algoMaxWalkMemory / CYPHER_ALGO_MAX_WALK_MEMORY / checkWalkBudget across the repo — the only remaining hit is the historical mention in the #6216 section of the release notes, which is intentional (explains what the key used to be called). No stale references left in code or elsewhere in docs.

GraphSAGE peak accounting. Worth double-checking during review: the per-layer reservation prices outDim x 2*initDim for the projection, which is only the true peak on the first layer (curDim == initDim there; later layers have curDim == outDim <= initDim, so their actual projection is smaller). That means later layers are always over-priced relative to what they actually allocate — safe (never under-counts), just worth knowing it's a conservative bound rather than an exact one, consistent with the "not a guarantee, an order-of-magnitude heuristic" framing already in the MATRIX_ROW_OVERHEAD_BYTES javadoc.

Minor/non-blocking:

  • AlgoKShortestPaths allocates a fresh n x n boolean[][] removedEdges on every spur-node iteration of Yen's algorithm (pre-existing code, not introduced here). The budget check is still correct because only one such matrix is ever live at a time — but it's worth flagging as a separate performance follow-up (repeated large-array churn/GC pressure), unrelated to this PR's scope.
  • The new prose in GlobalConfiguration/javadocs mixes x, ×, and · for multiplication across different files/comments — purely cosmetic, but a single convention would read a little cleaner.
  • A few of the added per-procedure javadoc blocks are quite long (multi-sentence paragraphs); given CLAUDE.md's "don't explain WHAT, only non-obvious WHY" guideline, some of this is closer to WHAT (e.g., restating "estimated in long arithmetic and reserved before anything is allocated" nearly verbatim in every file). Understandable given how much cross-cutting reasoning this change carries, but a shorter shared explanation (pointing at MemoryBudget's javadoc) plus a one-line per-procedure note on what's specifically priced there would probably read just as well with less repetition.

Test coverage. The 14 new tests are well-targeted: one per procedure for the "over budget" case, over-reach guards (fits budget / disabled budget), the accumulation test for node2vec (proving a per-component check would have let 22688 bytes through a 20000-byte budget), and the int-wrap regression for steinerTree. The everyPricedProcedureStillRunsUnderTheDefaultBudget test is a good sanity net against the fix being over-eager. The described sensitivity check (reverting the 8 procedures → 11/14 red) is a solid practice for justifying that the tests are actually pinning the new behavior rather than passing by construction.

No blocking issues found. This is a careful, well-scoped fix with unusually strong self-consistency between docs, code, and tests.

… javadocs

Each priced procedure was restating "estimated in long arithmetic and reserved before
anything is allocated" nearly verbatim - WHAT the shared helper already documents, in six
places, which is the kind of comment CLAUDE.md asks not to write. Each class javadoc now
names only what is specifically priced there and links MemoryBudget for the rest.

Also settles on `x` for multiplication in the prose this PR added, which had drifted
between three characters.
@lvca

lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Round 4 addressed in eb80d4f. Two of the three applied, one declined as a separate concern.

Javadoc repetition - valid, and the sharpest of the three. Six class javadocs were each restating "estimated in long arithmetic and reserved before anything is allocated", which is WHAT MemoryBudget already documents; CLAUDE.md asks for the non-obvious WHY and nothing else. Each now names only what is specifically priced in that procedure and links the helper for the mechanism - e.g. graphSAGE's is down to one sentence that carries the only fact a reader cannot get from MemoryBudget, that the peak is per-layer because each layer drops the matrix it read.

Mixed x / × / · - applied within the prose this PR added; it had drifted across three characters. The remaining × are in pre-existing comment text this PR does not otherwise touch.

AlgoKShortestPaths reallocating the n x n mask per spur node - agreed on both halves of your reading: the budget stays correct because only one is ever live, and the churn is a real but separate performance concern. Not folded in, and for a reason worth stating rather than just deferring: hoisting it means clearing the mask between spur nodes instead of allocating it, and Arrays.fill over n² booleans per iteration is not obviously cheaper than a fresh allocation the JVM can thread-locally bump-allocate and collect young. It needs a measurement, and a measurement makes it a performance PR rather than a bound. I'll file it.

On the GraphSAGE over-pricing you flagged: correct, and deliberate - later layers project from outDim, not initDim, so they are priced above what they allocate. Both directions were available and the conservative one is the only safe one: an exact per-layer estimate would have to be re-reserved each layer, and a budget that admits a call and then refuses it three layers in has spent the work it exists to avoid spending. The over-estimate is bounded by initDim / outDim, which is 1 whenever embeddingDimension >= 16, i.e. every case but the smallest.

Test counts, since the description is now two commits behind: 17 tests in Issue6263AlgoWorkingMemoryBudgetTest, 14 of 17 red under the revert-based sensitivity check. 328 green across Algo*Test + Issue6216* + Issue6263* after this commit - comments and one string, but the run is cheap and the alternative is assuming.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review

Went through the full diff (GlobalConfiguration, AbstractAlgoProcedure's new MemoryBudget, all nine touched Algo* procedures, and the new Issue6263AlgoWorkingMemoryBudgetTest) plus the arithmetic behind each pricing formula. This is a very solid, carefully-reasoned change. Highlights and a couple of nits below.

Correctness

  • Independently recomputed the byte figures the tests assert on (matrixBytes formula: rows * (32 + columns*elementBytes)) against algo.fastrp (8448 bytes), algo.hashgnn (4352 / 4224 bytes), algo.graphsage (2176 / 69760 bytes), algo.apsp/algo.simRank/algo.maxFlow (256/512 bytes), algo.kShortestPaths (400 bytes) and algo.steinerTree (224 bytes) — all check out.
  • The algo.steinerTree int-overflow fix is correct and the numbers in the commit message/tests match: 46341*46340 = 2147441940 (fits int) vs 46342*46341 = 2147534622 (overflows), and the long pair counts (1073767311 at 46342 terminals, 2449965000 at 70000) both verify by hand.
  • Reservation ordering is consistent everywhere: each procedure prices its working set using only the node count + knobs before calling graph.adjacency(...), so a call that's going to be refused doesn't first pay for the O(edges) adjacency build. Confirmed this holds in AlgoAPSP, AlgoFastRP, AlgoGraphSAGE, AlgoHashGNN, AlgoKShortestPaths, AlgoMaxFlow, AlgoNode2Vec, AlgoSimRank, AlgoSteinerTree.
  • AlgoGraphSAGE: verified the "per-layer peak, not per-run" claim by reading the loop — curDim is set to outDim after layer 0 and initDim = max(outDim, 16) >= outDim, so the first layer's 2*initDim-wide projection genuinely is the peak and later layers can't exceed it. Reservation matches the actual W/nextEmbed allocations.
  • AlgoHashGNN: confirmed there really are two boolean[n][numFeatures] allocations (features, newFeatures) plus one double[n][embDim] (embeddings), matching the "feature pair is larger than the embedding" pricing exactly.
  • AlgoKShortestPaths: confirmed removedEdges is boolean[n][n] allocated once per run (reused across spur nodes), matching the "priced once, not per spur node" comment.
  • No stale references to the renamed CYPHER_ALGO_MAX_WALK_MEMORY/algoMaxWalkMemory key or the removed checkWalkBudget/WALK_ENTRY_BYTES/WALK_ROW_OVERHEAD_BYTES symbols remain anywhere in the tree (grepped *.java, *.md) — the rename is complete, including the Issue6216 test and the release notes.
  • MemoryBudget.reserve()'s "negative limit skips bookkeeping entirely" behavior is a deliberate, documented micro-optimization and matches the old checkWalkBudget semantics; fine.

Test coverage

The new Issue6263AlgoWorkingMemoryBudgetTest is thorough: per-procedure over-budget rejection, an over-reach guard (budget comfortably above the estimate must not fire), a disabled-budget guard, the accumulation case (node2VecChargesTheEmbeddingMatricesOnTopOfTheWalkBuffer, which explicitly demonstrates why per-component checking would have been wrong), the int-overflow regression for steinerTree, and a single "nothing legitimate got refused" test across all nine procedures at the untouched default budget. The PR description's claim of "revert to HEAD, 11 of 14 red" is a good sensitivity check that the tests aren't vacuous.

Nits (non-blocking)

  1. Issue6263AlgoWorkingMemoryBudgetTest.java:43-44 — the class Javadoc has a garbled sentence: "at the default dimension of 128 the pair algo.node2vec keeps costs about 2 KB per node". Reads like a leftover from an edit pass (compare with the same sentence done correctly in the PR body / release notes). Purely cosmetic, doesn't affect the tests.
  2. BOXED_INTEGER_BYTES = 24L (8-byte ref + 16-byte boxed Integer) assumes uncompressed-oops-style sizing; with compressed oops (the common case under 32GB heap) the reference is 4 bytes, so the real cost is closer to ~20 bytes. Already explicitly documented as "a heuristic for a budget check... does not need to be exact," consistent with how MATRIX_ROW_OVERHEAD_BYTES is treated elsewhere, so this is intentional and not something to fix.
  3. Per the PR description, the budget bounds a single call rather than aggregate concurrent heap usage across calls on a shared server — acknowledged as a deliberate scope boundary ("a cross-query pool is a different mechanism, not a wording change"), not a gap in this PR.

Verification note

I was not able to run mvn compile/mvn test in this environment (sandboxed, no approval available for the build), so this review is based on careful static reading plus independently recomputing the arithmetic behind the new pricing formulas and cross-checking against the actual allocations in each procedure. Everything I could check by hand was internally consistent; recommend the reported "325 tests green" / "3191 tests green" / full reactor install results in the PR description still be confirmed by CI before merge.

Overall: well-scoped, well-tested, and the "beyond what the issue asked for" extension (pricing the four graph-only-sized procedures plus steinerTree) is well-justified given the stated goal of the rename. No blocking issues found.

…is per spur node

The test class javadoc had lost half a clause in an edit pass. It also predated
algo.steinerTree being priced, so it now mentions the one working set sized by neither a
knob nor the graph.

AlgoKShortestPaths' comment said only that one mask is ever live, and a reader took that
to mean the mask is allocated once and reused. It is allocated per spur node; what is
priced once is the PEAK. Said explicitly, along with why the reallocation is a separate
question a budget does not answer.
@lvca

lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Round 5 addressed in f23799c. One nit applied, and one thing in the review itself worth correcting.

Garbled sentence in the test javadoc - real, mine, fixed. It had lost half a clause in an edit pass, and it also predated algo.steinerTree being priced, so it now names the one working set sized by neither a knob nor the graph.

Correction: removedEdges is not allocated once per run. The review says it is "allocated once per run (reused across spur nodes)"; it is a fresh boolean[n][n] inside the spur-node loop, allocated per spur node. Round 4's review had this right and flagged the churn. The pricing is unaffected - only one is ever live, so the peak is one mask - but the comment said only "only one mask is ever live", which is what allowed the misreading, so it now says both halves: allocated per spur node, priced once because the peak is one. Worth flagging because a reviewer reading that comment as "allocated once" would also read the churn concern as already handled, and it isn't.

BOXED_INTEGER_BYTES under compressed oops - you're right that the reference is 4 bytes below a 32GB heap, so ~20 rather than 24. Left as-is for the reason you name, plus one more: the two errors point in opposite directions and the safe one is bigger. 24 over-estimates a compressed-oops reference by 4 bytes and under-estimates nothing; going to 20 would make the estimate exact on the common configuration and 4 bytes short per entry on the uncommon one, which is the direction that admits a call and then OOMs.

Sensitivity, since two rounds of CI could not run the suite: the numbers in the description are from a local run, but the claim that matters is the negative one, and it is reproducible - revert the nine procedures to HEAD and 14 of 17 tests in the new class fail, including NegativeArraySizeException: -1073716337 from algo.steinerTree. The three that survive are the over-reach guards, which must pass both ways. CI's unit-tests lane runs the class either way.

Latest local run after this commit: 22 tests across Issue6263* + AlgoKShortestPathsTest, green (comment-only changes, but cheaper to run than to assume).

@codacy-production

codacy-production Bot commented Aug 17, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 4 complexity

Metric Results
Complexity 4

View in Codacy

🟢 Coverage 100.00% diff coverage · -6.49% coverage variation

Metric Results
Coverage variation -6.49% coverage variation
Diff coverage 100.00% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (6b20fb1) 159115 125535 78.90%
Head commit (4b7e686) 191480 (+32365) 138647 (+13112) 72.41% (-6.49%)

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 (#6285) 54 54 100.00%

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

Copy link
Copy Markdown

Review

I traced the full diff plus the actual algorithm bodies (not just the new reservation call-sites) to check that every byte estimate matches what's really allocated, and worked through the arithmetic in each new Issue6263AlgoWorkingMemoryBudgetTest case by hand. Summary: this is a well-scoped, well-tested change and I did not find correctness bugs. Notes below are minor/confirmatory.

Verified correct

  • matrixBytes/saturatingSum/MemoryBudget.reserve arithmetic: recomputed every byte figure asserted in the new test class (fastRP 8448, hashGNN 4352/4224, graphSAGE 2176/69760, apsp/simRank/maxFlow 256/512, kShortestPaths 400, steinerTree 224) - all match the matrixBytes(rows, cols, elementBytes) formula (rows * (32 + cols*elementBytes)).
  • Reservation ordering vs. loadGraph/adjacency(): confirmed loadGraph() only loads the vertex/node-count (or a CSR provider handle) - the O(edges) work is in GraphData.adjacency(), called after every new reserve() block. So the "refuse before paying for the adjacency build" claim in the comments and PR description is accurate, not just asserted.
  • AlgoGraphSAGE per-layer-peak reasoning: checked the loop - embed = nextEmbed drops the old matrix each iteration and W/agg/concat are loop-local, so the peak really is one "old" matrix + one "new" matrix + one projection, not O(layers). The upfront "node feature matrix" reservation (n x initDim) is a valid upper bound for the "old" matrix in every later layer too, since initDim = max(outDim, 16) >= outDim always - non-obvious but correct.
  • AlgoHashGNN: two boolean[n][numFeatures] (features/newFeatures) + one double[n][embDim] (embeddings) in the actual code, matching the two reservation calls and the "feature pair is larger despite being boolean" claim (4x width beats 8x element size at the default embeddingDimension=128).
  • AlgoKShortestPaths: removedEdges really is allocated fresh per spur node with only one instance live at a time (old one becomes garbage each loop iteration), so pricing the mask once (not per spur node) as "the peak" is correct, and the comment says so explicitly rather than implying reuse.
  • AlgoSteinerTree int-overflow fix: t * (t - 1) / 2 in int does wrap around t = 46342 (division-after-product doesn't save it), and the new code computes it in long before casting - the new regression test (46342 terminals) and the pre-existing wrap bug both check out.
  • No stray references to the renamed key: grepped the whole tree for algoMaxWalkMemory/CYPHER_ALGO_MAX_WALK_MEMORY/checkWalkBudget/WALK_ROW_OVERHEAD_BYTES - the only remaining occurrence is the historical explanation in docs/release-26.9.1.md, which is intentional.
  • No-alias rename justified: confirmed via gh pr list that CYPHER_ALGO_MAX_WALK_MEMORY was introduced by fix(#6216): a graph-algorithm knob is bounded by the resource it spends, not by a guessed cap #6222, merged 2026-08-16 - one day before this PR, in the same unreleased 26.9.1-SNAPSHOT (per pom.xml). So there's genuinely no released version that shipped the old key, and skipping a deprecated alias is correct per repo convention.
  • Scope check: grepped the algo package for other new T[n][n] allocations beyond what this PR touches (AlgoAPSP, AlgoKShortestPaths, AlgoMaxFlow, AlgoSimRank) - no other procedure has an unpriced dense nodeCount x nodeCount structure, so the "these are the last unpriced allocations" claim holds today.

Minor observations (non-blocking)

  • BOXED_INTEGER_BYTES = 24L is explicitly documented as conservative for indices below 128 (JVM's cached Integer instances cost only the 8-byte reference). Since steinerTree's sortIdx values run 0..pairCount-1, this only under-charges for very small terminal counts, which is the safe direction (never a false negative on the budget) - just flagging that it's a deliberate over-estimate, not a precision bug.
  • MemoryBudget.reserve() mutates this.reserved before checking the limit and throwing; that's harmless today since a thrown IllegalArgumentException aborts the whole call and the budget object is discarded, but worth keeping in mind if MemoryBudget is ever reused across multiple attempts (e.g. a future retry path) instead of being a call-scoped, single-use object as it is now.
  • Nice touch carrying over from the Unbounded CPU-shaped config knobs in the OpenCypher algo procedures (follow-up to #6065) #6216 pattern: negative budget short-circuits reserve() before any bookkeeping, so a disabled budget costs nothing on the hot path.

Test coverage

The 14 new tests plus the reused Issue6216 tests (updated for the rename) look thorough: each priced procedure has an over-budget rejection test, most have an "over-reach guard" (fits-the-budget / disabled-budget) test, and everyPricedProcedureStillRunsUnderTheDefaultBudget guards against the fix itself becoming a regression. The PR description's sensitivity check (revert touched procedures to HEAD -> 11/14 red, 3 stay green as designed) is a good practice worth calling out as a strength.

No changes requested from me.

@lvca

lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Round 6 is clean - no changes requested - and rounds 4, 5 and 6 all closed without a blocking finding, so I'm treating this as done and leaving the branch at f23799c rather than opening a seventh cycle over cosmetics.

The two things this round raised that I did not change, and why:

  • BOXED_INTEGER_BYTES conservative below the Integer cache - deliberate, and you have the direction right. Correction to the framing though: it over-charges for small indices (24 bytes assumed against the 8 a cached instance really costs), not under-charges. That is the safe direction - over-charging refuses slightly early, under-charging admits a call that then OOMs - which is why it is left as-is rather than tuned.
  • MemoryBudget.reserve() mutating reserved before it throws - a fair catch, and correct that it is harmless today: the exception aborts the call and the budget is call-scoped and discarded. It is still the wrong invariant for a reader to inherit, so it is filed rather than dropped.

Deferred work from rounds 4-6 is now #6289: the per-spur-node n x n mask churn in algo.kShortestPaths, the boxed Integer[] index sort in algo.steinerTree (and the identical shape in AlgoMST), and the three-line reserve() tidy. All three are performance or hygiene questions that want a measurement, not bounds - which is the line this PR draws.

Final state: 6 review rounds, 5 commits. Round 1 moved the reservations ahead of the O(edges) adjacency build; round 2 found the one procedure I had missed, algo.steinerTree, with both an unpriced t x n working set and a live int-overflow reaching the allocator as NegativeArraySizeException: -1073716337; rounds 3-6 were documentation and wording. 328 tests green locally across Algo*Test + Issue6216* + Issue6263*; 17 tests in the new class, 14 of them red against the unfixed procedures.

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.23%. Comparing base (6b20fb1) to head (4b7e686).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6285      +/-   ##
==========================================
+ Coverage   70.14%   70.23%   +0.08%     
==========================================
  Files        1840     1840              
  Lines      159115   159127      +12     
  Branches    33556    33559       +3     
==========================================
+ Hits       111619   111757     +138     
+ Misses      33619    33492     -127     
- Partials    13877    13878       +1     

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

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

@lvca

lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Heads-up: #6288 (issue #6264) merged as 6b20fb1, and this PR is now CONFLICTING. That is the collision flagged during #6288's review — apologies for landing on top of you. Here is exactly what overlaps, so the rebase is mechanical.

AbstractAlgoProcedure — three additive changes, none semantic:

  • checkWalkBudget(db, bytes, detail) is now a thin delegate to a new checkBufferBudget(db, bytes, what, detail), where what names the buffer ("random walk buffer", "label memory"). If this PR renames the setting, the rename lands inside checkBufferBudget only — checkWalkBudget never reads the config any more.
  • New saturatingSum(long, long), the companion to saturatingProduct. It exists because saturatingProduct(...) + OVERHEAD with a plain + wraps negative, and a negative estimate satisfies estimatedBytes <= budget for any budget — the guard disabled by exactly the input it exists to refuse. Unreachable with today's callers, but worth keeping as this PR adds callers whose per-entry size is not 4 bytes.
  • WALK_ROW_OVERHEAD_BYTES's javadoc is generalised from "walk matrix" to "per-node int matrix".

AlgoNode2Vec, AlgoFastRP, AlgoGraphSAGE, AlgoHashGNN, AlgoSimRank#6288 touched all five, but only for the iteration knob (a minimum on extractInt, and guard.check()/checkPeriodically() calls). Nothing there reads the memory budget except AlgoNode2Vec's bytesPerWalk, which #6288 rewrote to use saturatingSum. Should be an additive merge in each.

One thing worth knowing rather than merging: if this PR renames arcadedb.cypher.algoMaxWalkMemory, note that #6288 extended that setting's description to cover algo.slpa, whose iterations now sizes a per-node label-memory row and is priced against it. The renamed setting needs to keep describing that, and checkBufferBudget's "label memory" caller needs to keep working.

Also filed #6295 from the same review: algo.hashgnn's MinHash phase is O(nodeCount x embeddingDimension²) and sits outside every checkpoint — 113 s against a 1 s command.timeout on 2000 nodes. That is a time bound and this PR is about heap, so they should not collide, but it is in AlgoHashGNN which this PR also touches.

#6288 (issue #6264) landed first and touched the same three places. Resolved toward one
API rather than two.

AbstractAlgoProcedure: #6288 generalised `checkWalkBudget` into `checkBufferBudget(db,
bytes, what, detail)` so `algo.slpa` could price its label memory. That is the same idea
this branch implements as `MemoryBudget`, which additionally accumulates over the call, so
keeping both would leave two budget APIs on the same setting. `checkBufferBudget` and
`checkWalkBudget` are gone; `algo.slpa`'s reservation goes through `MemoryBudget.reserve`
and reads the same to a caller, since the message shape was already identical. Its
estimate is now `matrixBytes(n, iterations + 1, INT_BYTES)` - the same figure its
hand-rolled expression computed - and moved ahead of the adjacency build like the others.

Both branches added `saturatingSum`; the merge kept both copies and the duplicate would
not compile. #6288's version is kept, javadoc included: it documents why a footprint that
mixes a saturated product with a plain addition must not wrap, which is the better
explanation. Its `@link` now points at `MemoryBudget.reserve`, where the hazard it
describes would land.

GlobalConfiguration: one description covering both, so `algo.slpa`'s label memory is named
alongside the matrices. #6288's own release-notes section referred to the setting by its
old name, which this branch renames; it now says so and points forward.

AlgoGraphSAGE: #6288 gave `layers` a minimum of 1, so the `if (layers > 0)` guard around
the layer-matrix reservation is now unreachable-false. Reserved unconditionally.

AlgoNode2Vec: this branch moved the reservation ahead of the adjacency build and #6288
made `bytesPerWalk` saturating in place. Both kept - the block moved, with the saturating
arithmetic in it.
@lvca

lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main (4b7e686) after #6288 landed. MERGEABLE again.

The overlap was real rather than textual: #6288 needed a heap bound for algo.slpa's label memory and generalised checkWalkBudget into checkBufferBudget(db, bytes, what, detail) to get one. That is the same idea this branch implements as MemoryBudget, which additionally accumulates over the call - so taking both sides literally would have left two budget APIs on the same setting, which is the thing this PR exists to stop being. Resolved toward one:

Verified after the merge, not assumed: 395 tests green across Algo*Test + Issue6216* + Issue6263* + Issue6264* (#6288's own 70), and 3459 green across the Cypher/config/recent-issue sweep. #6288's slpaRejectsALabelMemoryLargerThanTheBudget passes against the new API unchanged apart from the renamed key constant.

@lvca
lvca merged commit bddef2e into main Aug 17, 2026
12 checks passed
@lvca
lvca deleted the issue-6263 branch August 17, 2026 03:52
@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review

Solid, well-scoped follow-up to #6216/#6065. The core idea (a per-call MemoryBudget that accumulates reservations rather than checking each allocation independently) correctly closes the gap the PR describes: algo.node2vec really does hold its walk matrix and both embedding matrices alive simultaneously, so summing is the right model, not maxing.

I checked the arithmetic in every new reserve() call site against the numbers asserted in Issue6263AlgoWorkingMemoryBudgetTest (fastrp: 8448, hashgnn: 4352 + 4224, graphsage: 2176 + 69760, apsp/simRank/maxFlow: 256/512/512, kShortestPaths: 400, steinerTree: 224 and the 46342-terminal int-wrap case) — all reproduce correctly by hand from matrixBytes(). The t * (t - 1) / 2 int-overflow fix for algo.steinerTree is a genuine, well-caught bug (confirmed the wrap happens at 46342 terminals since the /2 happens after the product, so "the result fits an int" never actually saves it), and pricing Integer[] at 24 bytes/entry rather than folding it into the 4-byte primitive figure is the right call.

Things worth a look before merge:

  1. AbstractAlgoProcedure.BOXED_INTEGER_BYTES javadoc is off. It says "Three times the cost of the int it carries," but the value is 8 (reference) + 16 (boxed object) = 24, against INT_BYTES = 4L — that's 6x, not 3x. Doesn't affect the actual pricing (which correctly uses the 24L constant), just the explanatory comment.

  2. Ordering-before-adjacency-build is a nice touch (fastrp/node2vec/hashgnn/graphsage/simRank all reserve before graph.adjacency(...)), and it's consistently applied across every touched procedure — good attention to not paying the O(edges) cost on a call that's about to be refused anyway.

  3. No back-compat alias for the rename (algoMaxWalkMemoryalgoMaxWorkingMemory) is explicitly called out as deliberate since the old key was never released. I wasn't able to grep the full tree at the PR's head commit (tooling in this session only exposed gh pr diff/gh search code, not arbitrary gh api/git fetch), so I couldn't independently confirm there's no stray reference to the old key name outside the files this diff touches (e.g. docs/Configuration.md-style reference lists, if any exist). Worth a final grep -r algoMaxWalkMemory sweep before merge given there's no alias to fall back on.

  4. Test coverage is strong — the sensitivity check described in the PR body (11/14 tests failing when reverted to HEAD) is exactly the right way to validate a test suite actually tests something, and everyPricedProcedureStillRunsUnderTheDefaultBudget is a good "didn't break legitimate calls" counterweight. Minor/optional: only algo.fastrp gets an explicit "budget disabled" test in the new class (fastRpIsUnboundedWhenTheBudgetIsDisabled); hashgnn/graphsage/steinerTree's Dijkstra-table reservation don't get the same explicit negative-limit case, though steinerTreeRejectsMoreTerminalPairsThanAJavaArrayCanHoldEvenWithTheBudgetDisabled covers the pair-count path.

  5. Design scope is well-justified: the PR explicitly declines to make the budget cross-call/aggregate (bounding one call, not concurrent heap usage), which is called out as a deliberate non-goal rather than an oversight — agree that's a different mechanism and out of scope here.

No correctness bugs found in the priced estimates or the reservation accumulation logic itself. No security concerns (error messages only leak size/count numbers, not data). Nice attention to the GC/performance mantra — everything added is cheap long arithmetic ahead of allocation, no new object churn on the hot path.

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.

Embedding matrices in the algo.* procedures are sized nodeCount x dimension and sit outside every budget (follow-up to #6216)

1 participant