Skip to content

fix(#6323): membership in a range is a division, and EXPLAIN describes the plan it would run - #6344

Merged
lvca merged 9 commits into
mainfrom
issue-6323
Aug 18, 2026
Merged

fix(#6323): membership in a range is a division, and EXPLAIN describes the plan it would run#6344
lvca merged 9 commits into
mainfrom
issue-6323

Conversation

@lvca

@lvca lvca commented Aug 18, 2026

Copy link
Copy Markdown
Member

Closes #6323.

Three follow-ups from #6307, and two defects the work walked into.

1. The two items #6297 carried that #6307 did not implement

They are refiled as #6343, which is searchable for RESYNC_RETRY_TIMEOUT_MS and for reuseForks, and the comments in BaseRaftHATest that were the only remaining record of them now name it. Neither can be decided inside a PR: the next cut of the budget needs SLOW WAIT lines from runs at the new 5 s threshold, and the fork isolation needs a measurement of what reuseForks=false costs this lane before it is imposed on it. #6343 says what evidence each one is waiting for.

The CI cap is fixed here. slow-unit-tests was capped at 60 min while its honest duration has been measured at 39m27s, 46m and 57m40s on main, and one run was CANCELLED at 1h0m16s while progressing normally, with no test failed. A cap inside its own observed spread goes red on runner speed alone, and it goes red as "the job failed" rather than "the job ran out of time" - which is what made it read as a regression from the PR under test. The cap is a hang detector, so it is now sized above the slowest honest run: 90 min.

ha-integration-tests is not capped at 60: it already has timeout-minutes: 90, so its 1h0m8s red was the 1 error it reported, not the cap. Nothing to change there.

2. IN walked a range that answers in O(1)

The reported cost, timed on this branch's parent:

3066 ms  RETURN 999999998 IN range(0, 999999999) AS r     ->  now 0 ms
   0 ms  RETURN 5 IN range(0, 999999999) AS r

The cost was the position of the match, and a miss walked all of it. A range is an arithmetic progression whose elements are never null, so both the match and the miss are a division - the three-valued logic that forces the walk on a general list has nothing to discover in a range.

The walk is not replaced by List.contains. Membership is equality, and Cypher's = coerces numerically - 5.0 IN range(0, 10) is true, as it is in Neo4j - where equals() does not, which is exactly why LongRangeList.indexOf rejects Double/Float. So the fast path reproduces the comparator's own answer rather than calling a different one:

  • integral operands are compared as longs, as = compares them;
  • any other Number goes through doubleValue(), as = does: NaN and the infinities match nothing, a fractional value matches nothing, and past 2^53 - where a double stops naming a single long - it declines and the walk runs, because there the walk is the definition of the answer;
  • a RID-shaped string is read as the id it denotes, which is the same id() interop coercion = applies, not a second one;
  • everything else is a different type than a Long, and for = that is simply not equal.

null IN <list> is now answered from the list's emptiness alone, for any list: every comparison against null is null, so only the number of elements matters. It used to walk a billion elements to conclude that.

The drift guard is answersExactlyAsTheWalkDoes: 30 operands covering each of those branches, each asserted equal to the same operand against a materialised list. A new coercion in ComparisonExpression that the fast path does not learn turns that test red.

LongRangeList.indexOf also now rejects BigInteger/BigDecimal, as it already rejected Double/Float. It answers the List contract, which is equals(), and no Long equals a BigInteger; truncating them to a long made every value congruent to an element modulo 2^64 answer as that element. Membership by value is the new containsLong, for callers whose own equality coerces.

3. EXPLAIN could not describe the plan it would run

EXPLAIN MATCH (a),(b) WHERE ID(a) = $sourceId AND ID(b) = $targetId RETURN a, b used to answer with a reason and stop. It now answers:

Using Traditional Execution (Non-Optimized)

Reason: Query pattern not yet supported by optimizer

Execution Plan:
+ MATCH NODE (a) [id: #1:0]
+ MATCH NODE (b) [id: #1:1]
+ FILTER WHERE (ID(a) = $sourceId AND ID(b) = $targetId)
+ PROJECT RETURN a, b
+ FINAL PROJECTION [a, b]

The steps are built and never pulled, which is what makes describing a write a description - and is why the workaround of running PROFILE never was one: PROFILE MATCH ... SET ... writes. explainingAWriteDescribesItWithoutPerformingIt pins that down. A UNION is answered branch by branch, so it is described branch by branch. The structured step list is published alongside the text, so a client reading the plan as data sees the same chain, and explainAndProfileDescribeTheSameChain asserts the two texts agree once PROFILE's per-step timings are stripped - the timings being the only thing running the query buys.

This also restores the [id: ...] marker #6307 added for EXPLAIN: it exists so a user can confirm the RID push-down fired, and those queries land on exactly the path that had no plan to show.

Found while doing the above

  • A slice of a range copied it. range(0, 999999999)[0..1000000000] allocated a billion boxed longs and exhausted the heap - the failure the lazy range was introduced to remove (GHSA-xmjm-8q85-g778). A slice of an arithmetic progression is one, and LongRangeList.subList already returns it in constant space. The slicing itself moved into the AST node, which ExpressionEvaluator now calls instead of carrying a second copy of it: there were two, and only one of them was being fixed.
  • Nine steps printed an unbalanced profiling suffix, (511us), 1 rows), closing the parenthesis before the row count joined the same group. Comparing the EXPLAIN and PROFILE chains is what made the two texts comparable enough for it to show.

Verification

  • New: CypherInRangeMembershipTest (7 tests), CypherExplainTraditionalPlanTest (8 tests).
  • Updated: CypherRangeHeapExhaustionTest.hugeRangeIsLazyWhenTheLimitIsDisabled - IN moves inside the stall-discounted measured window it was excluded from in The server and network suites still bound wall clock the way the engine suite did before #6260 #6270, since it is now constant-cost; LongRangeListTest gains the indexOf contract and containsLong cases.
  • mvn test -pl engine -Dtest='com.arcadedb.query.opencypher.**': 8202 tests, 0 failures, 0 errors.
  • PostCommandHandlerProfileIT (server): 9 tests green - the HTTP explainPlan field now carries steps for EXPLAIN too.
  • Full reactor test-compile green.

What the review rounds added

Seven review passes; the last one found no correctness, security or performance issues. What they changed:

  • EXPLAIN of a write the optimizer claims described the scan and never mentioned the write. The optimizer claims the MATCH pattern only - RETURN, ORDER BY and every write clause stay execution steps appended to the operator chain - and printing the physical plan alone left them out. EXPLAIN and PROFILE now name them, under the plan they run after. Found by writing the review's suggested smoke test for SET/REMOVE/DELETE/MERGE/FOREACH/edge-CREATE/CALL.
  • That made CountEdgesReturnStep visible in a plan where a test asserted it was absent. The assertion was stale: the step has taken a targetLabel since the push-down learned to filter by it, so it applies to a labelled counted node and counts what that label reaches - which the test's own correctness assertion had been proving all along. It held only because the plan text never printed the step. The step now prints [target: <label>], for the reason MatchNodeStep prints [id: ...].
  • EXPLAIN of a UNION claimed "not yet supported by optimizer" while showing an optimized branch underneath it. A UNION has no plan of its own; each branch is planned separately. It now says so.
  • The slice dedup had left the bound parsing behind, so a non-numeric bound was a friendly message on one path and a bare ClassCastException on the other - the exact divergence the dedup exists to remove.
  • A plan that cannot be built is now named in the description instead of swallowed into a FINE log. EXPLAIN answers a question about a query, and "this cannot be planned" is an answer to it; the statement would fail the same way when run, so the message tells the caller nothing running it would not. A query that does not parse keeps raising.
  • Declined: relaxing the exact-double bound from >= 2^53 to > 2^53 to match DistinctNumericKey. It would be wrong: 2^53 is representable but 2^53+1 is not, and rounds ties-to-even onto the same double, so two longs already share it there. The fast path would pick one and answer false for a range holding only the other. The bound stays inclusive, with a test that builds both ranges.

…s the plan it would run

Three follow-ups from #6307, plus two defects the work walked into.

1. The two items #6297 carried and #6307 did not implement lost their tracker when the
   `Closes #6297` trailer closed the issue. They are refiled as #6343, and the comments in
   `BaseRaftHATest` that were the only remaining record now name it. The `slow-unit-tests`
   lane was capped at 60 min while its honest duration has been measured between 39m27s and
   57m40s, and one run was CANCELLED at 1h0m16s while progressing normally: a cap inside its
   own spread goes red on runner speed alone, and it goes red as "the job failed". It is a
   hang detector, so it is now sized above the slowest honest run, at 90 min.

2. `IN` walked the list element by element, so against the lazy `range()` of GHSA-xmjm its
   cost was the POSITION of the match - 3 s locally and about 20 s on a CI runner for an
   element near the end of `range(0, 999999999)`, and the full walk for every miss. A range
   is an arithmetic progression whose elements are never null, so both the match and the miss
   are a division. The walk is not replaced by `List.contains`: membership is equality, and
   Cypher's `=` coerces numerically (`5.0 = 5` is true, as in Neo4j) where `equals()` does
   not, so the fast path reproduces the comparator's own answer - the RID-string interop
   included - and declines to the walk past 2^53, where a double stops naming one long.
   `null IN list` is answered from the list's emptiness alone, for any list.

   `LongRangeList.indexOf` keeps answering the `List` contract, which is `equals()`, and now
   rejects `BigInteger`/`BigDecimal` as it already rejected `Double`/`Float`: truncating them
   to a long made every value congruent to an element modulo 2^64 answer as that element.
   Membership by value is `containsLong`, for the callers whose equality coerces.

3. `EXPLAIN` described only the count push-down and the cost-based physical plan, so a query
   the optimizer does not claim got a reason and nothing else, while `PROFILE` of the same
   query printed the whole step chain. That left the one command whose purpose is inspecting
   a plan WITHOUT running it strictly less informative than the one that runs it - no use for
   a slow query, and wrong for a writing one, since `PROFILE MATCH ... SET ...` does write.
   The chain is now built and never pulled, and described; a UNION is described branch by
   branch, and the structured step list is published alongside the text.

Found while doing the above:

- Slicing a range copied the slice into an `ArrayList`, so `range(0, 999999999)[0..1000000000]`
  allocated a billion boxed longs and exhausted the heap - the failure the lazy range was
  introduced to remove. A slice of a range is a range. The slicing itself moves into the AST
  node, which `ExpressionEvaluator` now calls instead of carrying a second copy of it that was
  fixed one at a time.
- Nine steps closed the parenthesis of their profiling suffix before the row count joined it,
  printing `(511us), 1 rows)`. Comparing the EXPLAIN and PROFILE chains is what made the two
  texts comparable enough to notice.
@lvca lvca self-assigned this Aug 18, 2026
@mergify

mergify Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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

  • Queue this pull request

@codacy-production

codacy-production Bot commented Aug 18, 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 81.89% diff coverage · -6.57% coverage variation

Metric Results
Coverage variation -6.57% coverage variation
Diff coverage 81.89% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (34482fd) 160387 127383 79.42%
Head commit (d94b612) 192808 (+32421) 140474 (+13091) 72.86% (-6.57%)

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 (#6344) 127 104 81.89%

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.

@lvca lvca added this to the 26.9.1 milestone Aug 18, 2026
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review

Went through the diff in detail, cross-checking the IN-on-range fast path against ComparisonExpression.compareValuesTernary (the walk it has to stay faithful to), and tracing the EXPLAIN step-building path for side effects. Overall this is a careful, well-tested change; no correctness bugs found. A few observations below, all minor.

InExpression.rangeMembership — verified against the walk

Traced every branch of rangeMembership against ComparisonExpression.compareValuesTernary:

  • Integral types (Long/Integer/Short/Byte) → containsLong(longValue()). Note the comparator's own "compare as longs" fast path only fires for Long/Integer pairs; a Short/Byte operand actually goes through the comparator's doubleValue() branch. That's harmless here because Short/Byte's value domain is tiny and both conversions agree exactly, but it's worth a one-line comment next to the Short/Byte case so a future reader doesn't have to re-derive that equivalence.
  • Number (Double/Float/BigInteger/BigDecimal) → doubleValue() with the 2^53 bailout to the walk — matches the comparator's own doubleValue() path, and deferring past 2^53 is the right conservative call.
  • RID-shaped String → same IdFunction.encodeRidAsLong coercion the comparator applies. Matches.
  • Everything else → false, matching the comparator's "different types are not equal" fallthrough.

The null IN <list>/isEmpty() short-circuit is correct 3VL: no list element can make a null-left comparison "more uncertain" than another, so only emptiness matters, and this applies to all lists, not just ranges. Good general optimization.

The answersExactlyAsTheWalkDoes drift-guard test (30 operands compared against a materialised-list walk) is exactly the right kind of test for this: it'll catch future coercion additions to ComparisonExpression that this fast path doesn't learn.

ListSliceExpression.slice / ExpressionEvaluator.evaluateListSlice dedup

The Integer (nullable, boxed) sentinel for "no bound given" vs. "bound evaluated to null" is handled correctly in both call sites — sliceBound's null return is disambiguated from "no expression" via the fromExpression != null && from == null check in ListSliceExpression.evaluate, and ExpressionEvaluator preserves the same distinction inline. The LongRangeList.subList fast path for slicing correctly avoids re-materializing the GHSA-xmjm-8q85-g778 heap exhaustion.

CypherExecutionPlan EXPLAIN changes

stepsForDescription() builds steps via a fresh BasicCommandContext (own database/parameters/function-resolver setup, not the one execute() would use), and the failure path degrades gracefully (logs at FINE, falls back to the old "step-by-step interpretation" text) rather than breaking EXPLAIN. The claim that "steps are built and never pulled" is directly tested for CREATE (explainingAWriteDescribesItWithoutPerformingIt), which is the case that matters most (a write must not fire from EXPLAIN). I'd suggest at least a smoke test that SET/MERGE/DELETE also don't fire on EXPLAIN, since the safety property is being generalized across every step type in the executor package, not just CreateStep — the risk is fairly low since it's the same lazy-pull architecture already exercised by execute(), but a construction-time side effect hiding in one of the many step constructors would only be caught by that kind of direct test.

One thing worth double-checking: in the "Cost-Based Query Optimizer" branch, describedSteps is populated by a second, independently-built buildExecutionStepsWithOptimizer(context) call (via stepsForDescription()), separate from physicalPlan.getRootOperator(), which is what the printed "Physical Plan:" text actually describes. canUseOptimizedPhysicalPlan() is a pure function of instance state so both calls should agree, but the structured getSteps() data and the printed physical-plan text are technically two independent builds of "the same" plan rather than one build feeding both. explainPublishesTheStepsAsStructuredData only checks non-emptiness for this case, not that the structured steps match the physical-plan text — worth confirming there's no path where they could diverge.

Off-by-parenthesis fix (9 step classes)

Straightforward and correct: moves the closing ) from immediately after cost to after the optional row-count append, turning (511us), 1 rows) into the balanced (511us, 1 rows). Same one-line pattern applied consistently across all 9 files.

LongRangeList.indexOf now rejecting BigInteger/BigDecimal

Correct per the List.indexOf contract (equals()), and the added test (indexOfRejectsTheTypesThatNoElementCanEqual) pins down the specific wraparound bug (a BigInteger congruent to an element mod 2^64 previously matched it) this fixes. Confirmed via grep that LongRangeList has no callers outside the Cypher module + RangeFunction, so tightening this contract can't silently break something else that relied on the old truncating behavior.

CI timeout change

The 90-minute cap for slow-unit-tests is justified with concrete measured run durations in the PR description (39m27s/46m/57m40s, one CANCELLED at 1h0m16s while still progressing normally). This matches the repo's stall-aware-bound guidance: size a hang detector above the slowest honest run rather than the median. No concerns.

Minor nit

import com.arcadedb.log.LogManager; in CypherExecutionPlan.java was inserted after com.arcadedb.query.sql.parser.ExplainResultSet rather than in alphabetical position with the other com.arcadedb.* imports — cosmetic only.

Test coverage

Strong: CypherInRangeMembershipTest and CypherExplainTraditionalPlanTest cover the reported cost regression, 3VL null semantics, numeric coercion edge cases (NaN, infinities, fractional values, the 2^53 boundary via a huge BigInteger operand), RID-string interop, UNION branch description, and the write-safety invariant for EXPLAIN. StallAwareStopwatch usage follows the repo's stall-discounted-timing convention correctly (assertStayedUnder for the complexity claims, @Timeout kept only as a hang detector).

No security concerns. Performance-wise this PR is the fix, and the claims are backed by both the drift-guard correctness test and the constant-cost timing tests. Nice work — the reasoning in the PR description matches what the code actually does.

…y which label the edge count filters on

Review follow-ups on PR #6344.

The suggested smoke test - that SET, REMOVE, DELETE, MERGE, FOREACH and a relationship CREATE
are described by EXPLAIN without firing, not only CREATE - found that EXPLAIN of a write the
optimizer claims described the scan and never mentioned the write at all. The optimizer claims
the MATCH pattern only; every clause after it stays an execution step appended to the operator
chain, and printing the physical plan alone left them out. Both EXPLAIN and PROFILE now name
them, under the physical plan they run after.

That in turn made `CountEdgesReturnStep` visible in a plan where a test asserted it was absent.
The assertion was stale: the step has taken a targetLabel since the push-down learned to filter
by it, so it applies to a labelled counted node and counts what that label reaches - which the
test's own correctness assertion has been proving all along. It held only because the plan text
never printed the step. The step now prints the label it filters on, for the reason
MatchNodeStep prints the id it seeks, and the test asserts what actually happens.

Also from the review: the Short/Byte case in rangeMembership says why converting through a long
and comparing through a double reach the same answer; the optimized branch of explain() says
that its structured steps are built from the same PhysicalPlan instance the text is printed
from, so the two cannot describe different plans, and the test asserts they name the same
operator; the LogManager import moves into alphabetical position.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review

Went through the full diff (workflow timeout, IN-over-range fast path, range slicing, and the EXPLAIN traditional-plan/UNION work). Overall this is a careful, well-tested PR: the range membership fast path is derived methodically from ComparisonExpression.compareValuesTernary's actual branches (verified by reading that method — the Long/Integer-vs-Short/Byte double-comparison reasoning, the RID-string interop, the 2^53 bailout, and the null/NaN handling all check out against the real comparator), the LongRangeList.subList/indexOf/containsLong split correctly fixes the BigInteger/BigDecimal truncation bug, and the nine (cost), N rows) unbalanced-parenthesis fixes are exactly the nine step classes that actually had the bug — I checked the other 20+ steps that print getRowCountFormatted() (SetStep, SubqueryStep, ForeachStep, CallStep, UnionStep, IndexSeekStep, MatchNodeStep, CreateStep, etc.) and they were already correctly balanced, so nothing was missed.

One thing worth a look:

EXPLAIN of a UNION query always says "not yet supported by optimizer," even when a branch is optimized. In CypherExecutionPlan.explain(), canUseOptimizedPhysicalPlan() is always false for the top-level UNION plan (CypherExecutionPlanner.createUnionExecutionPlan passes physicalPlan = null for the union statement itself), so every UNION query falls into the else if (countPushDown == null) branch and unconditionally prints:

Using Traditional Execution (Non-Optimized)

Reason: Query pattern not yet supported by optimizer

before appendTraditionalPlan() recurses into each branch. But each branch is planned independently by CypherExecutionPlanner.createExecutionPlan() and can get its own non-null physicalPlan (e.g. a plain MATCH (n:Person) RETURN n.name branch will pass shouldUseOptimizer()), in which case appendTraditionalPlan()stepsForDescription()buildExecutionStepsWithOptimizer() prints + OPTIMIZED MATCH (physical operators) for that branch. So the output can end up self-contradictory: the top-level reason claims the optimizer wasn't used, while a branch underneath shows it was. This looks pre-existing (the old code printed the same generic reason for any non-count-pushdown, non-optimized top-level plan, including UNIONs), but this PR is specifically about making EXPLAIN describe the plan that will actually run, and it adds enough branch detail to make the inconsistency visible/checkable, so it seemed worth flagging. explainDescribesEveryUnionBranch() in the new test only asserts on UNION/Branch N:/label text, not the reason header, so it wouldn't catch this. Not blocking, just a minor accuracy gap in the new diagnostic text.

Everything else — the CI timeout bump (backed by concrete measured run durations), the ListSliceExpression/ExpressionEvaluator slice-logic dedup, and the CountEdgesReturnStep/OpenCypherCountOptimizationTest label-filter fix — looks correct and is backed by targeted regression tests.

…d of claiming the optimizer was not used

Review follow-up on PR #6344.

A UNION has no plan of its own: the planner leaves its physicalPlan null and plans each branch
separately, so `canUseOptimizedPhysicalPlan()` is always false for the union itself and EXPLAIN
printed "Reason: Query pattern not yet supported by optimizer" for every UNION - directly above
the `+ OPTIMIZED MATCH (physical operators)` that a branch the optimizer DID claim prints
underneath it. The text now says what is actually true, and the test asserts both halves: the
per-branch header, and that each branch reports the path it takes.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review

I went through this PR in depth: the CI timeout fix, the IN-on-LongRangeList fast path, the ListSliceExpression dedup, the EXPLAIN step-chain change, the prettyPrint paren fix, and the LongRangeList.indexOf type-safety fix. Overall this is careful, well-reasoned engineering with strong regression tests. A few notes below, nothing blocking.

Correctness - verified against the real comparator

InExpression.rangeMembership (engine/src/main/java/com/arcadedb/query/opencypher/ast/InExpression.java) reimplements a subset of ComparisonExpression.compareValuesTernary's coercion rules to answer IN against a LongRangeList in O(1). I cross-checked every branch (integral fast path via Short/Byte going through doubleValue() in the real comparator vs. direct longValue() here, the 2^53 exact-double cutoff, NaN/Infinity, the RID-string id() interop, and the "different types are not equal" fallback) against ComparisonExpression.compareValuesTernary, and they line up. CypherInRangeMembershipTest.answersExactlyAsTheWalkDoes is a good drift guard here - it directly diffs the range fast path against a materialized-list walk across a wide operand matrix (longs, doubles, NaN/Infinity, every boxed integral width, BigInteger/BigDecimal, RID strings, non-RID strings, booleans, collections, null). Nice touch pulling the null IN <list> short-circuit out to apply to any list, not just ranges - that's a free O(n)->O(1) win for null IN <hugeList> beyond what the PR description calls out.

LongRangeList.indexOf fix is a real bug fix

Good catch: the old code accepted BigInteger/BigDecimal and truncated via longValue(), so a huge BigInteger congruent to a range element modulo 2^64 would falsely report a match. Restricting indexOf to the types List.indexOf's equals() contract actually allows, and adding containsLong/indexOfLong for the numeric-coercion callers (like IN), is the right fix and is covered by LongRangeListTest.indexOfRejectsTheTypesThatNoElementCanEqual.

prettyPrint double-close-paren fix looks complete

The getCostFormatted()).append(")") -> drop-the-early-close fix is applied consistently across all the step classes that had the actual bug (i.e. where a rowCount > 0 block appends more content before the final )). I checked the other getCostFormatted()).append(")") call sites in the same package (SetStep, SubqueryStep, ForeachStep, CallStep, UnionStep) - none of them have the trailing rowCount append, so they were never buggy and correctly weren't touched.

One minor nit: the ListSliceExpression/ExpressionEvaluator dedup is incomplete

ListSliceExpression.slice(...) is now correctly shared between ListSliceExpression.evaluate() and ExpressionEvaluator.evaluateListSlice(), which is exactly the point of the refactor per the PR description ("the two used to carry a copy of this each, and only one of them was fixed at a time"). However, the bound-parsing logic wasn't fully unified: ListSliceExpression now validates a non-Number bound via the new sliceBound() helper and throws a friendly IllegalArgumentException("Slice index must be a number, got: ..."), but ExpressionEvaluator.evaluateListSlice (engine/src/main/java/com/arcadedb/query/opencypher/executor/ExpressionEvaluator.java:373-380) still does a raw ((Number) fromValue).intValue() / ((Number) toValue).intValue() cast with no instanceof check, so a non-numeric slice bound reached through that path throws a raw ClassCastException instead. Since this refactor's whole motivation is eliminating exactly this kind of two-copies-diverge risk, it'd be worth routing this path through sliceBound() too (it's private on ListSliceExpression right now, but slice() is already public static, so widening sliceBound the same way - or just inlining the same instanceof check - would close the gap). Low severity: pre-existing behavior (not introduced by this PR), and only affects the error type/message for a malformed query, not correctness of valid ones.

EXPLAIN building steps without pulling them

The new stepsForDescription() builds the real execution chain via buildExecutionSteps/buildExecutionStepsWithOptimizer and relies on "construction has no side effects, all work happens in syncPull" to make EXPLAIN of a write statement safe. This isn't a new risk introduced by the PR - profile() and execute() already call the exact same builder methods before pulling - but it's worth flagging that the invariant is now depended upon by one more caller. The regression tests (explainingAWriteDescribesItWithoutPerformingIt, explainingAnyWriteDescribesItWithoutPerformingIt) cover SET/REMOVE/DELETE/MERGE/FOREACH/CREATE explicitly, which is good, but obviously can't cover every write-capable step in the executor package exhaustively - probably fine given the shared code path with execute()/profile(), just noting it as the one assumption this change leans on more heavily.

Test coverage

Strong - new tests cover the range-membership fast path against the walk (CypherInRangeMembershipTest), the LongRangeList type-safety fix, the EXPLAIN step-chain behavior including UNION branches and EXPLAIN/PROFILE parity, and the paren-balance regression (profiledStepsAreBalanced). The @Timeout/StallAwareStopwatch usage in CypherInRangeMembershipTest and CypherRangeHeapExhaustionTest follows the repo's stall-aware timing convention correctly (assertStayedUnder for a complexity claim that has no other practical expression).

CI timeout change

The slow-unit-tests timeout bump to 90 min is well-justified with concrete measured durations (39m27s/46m/57m40s plus a 1h0m16s cancellation while still progressing normally) and is sized as a hang detector above the slowest observed honest run. No concerns there.

No security or dependency concerns - this is pure engine/query-logic and CI config, no new dependencies.

…astException on one of them

Review follow-up on PR #6344.

The slice dedup left the bound parsing behind: the AST node validated a non-numeric bound and
said what was wrong with the query, while the aggregation-aware evaluator cast straight to
Number and raised a bare ClassCastException for the same query. That is the divergence the
refactor exists to remove, so the bound parsing is shared too.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review

Went through the diff in detail, cross-checked the new InExpression.rangeMembership fast path against ComparisonExpression.compareValuesTernary branch-by-branch (RID-string interop, integral vs. double comparison, Identifiable/temporal/list/map fallthroughs), and traced the EXPLAIN/PROFILE step-chain changes in CypherExecutionPlan. This is a careful, well-reasoned change with strong regression coverage (answersExactlyAsTheWalkDoes is a good drift guard). A couple of minor, non-blocking observations:

1. EXACT_DOUBLE_LIMIT boundary is inconsistent with the sibling utility DistinctNumericKey
InExpression.rangeMembership bails to the walk with Math.abs(asDouble) >= EXACT_DOUBLE_LIMIT (2^53), while DistinctNumericKey.canonicalize treats the same magnitude as still-exact with Math.abs(d) <= MAX_EXACT_DOUBLE_INTEGER. 2^53 itself is exactly representable as a double, so the new code is being (harmlessly) over-conservative for that single boundary value — it falls back to the O(n) walk instead of taking the O(1) path for value == 2^53. Not a correctness bug (the walk gives the right answer either way), just a missed optimization at one boundary and a small inconsistency between two pieces of code solving the same "is this double an exact integer" problem with two independently-defined constants. Might be worth a shared helper or at least aligning the two boundary comparisons (> vs >=).

2. stepsForDescription() catches Exception broadly

} catch (final Exception e) {
  LogManager.instance().log(this, Level.FINE, "Error on building the execution plan to describe it", e);
  return null;
}

This is documented and defensible (a bad plan should degrade to "no steps shown" rather than fail EXPLAIN outright), but catching Exception this broadly during plan construction will also silently swallow genuine engine bugs (e.g. an NPE from a step constructor) behind a FINE-level log a user will never see. Since EXPLAIN never used to build the full step chain at all, this is new blast radius worth being aware of — might be worth narrowing to the exceptions actually expected from statement validation, or at least confirming FINE-level logging is sufficient for catching regressions in CI.

Strengths worth calling out:

  • The range-membership fast path is symmetric with ='s actual coercion rules rather than reinventing them (long-vs-long for Long/Integer, doubleValue() for everything else, RID-string interop, NaN/Infinity handling) — and the test file pins that equivalence directly against a materialized-list walk rather than just asserting expected booleans, which is exactly the kind of test that catches future drift in ComparisonExpression.
  • LongRangeList.indexOf rejecting BigInteger/BigDecimal (previously truncated via longValue(), silently matching wrong elements modulo 2^64) is a real correctness fix, cleanly separated from the new containsLong for coercing callers.
  • The range(...)[...] slice fix directly addresses a live heap-exhaustion regression (GHSA-xmjm-8q85-g778 reopened via slicing) — good catch, and consolidating the two duplicate slice implementations (AST node + aggregation evaluator) behind one ListSliceExpression.slice/sliceBound removes the exact kind of "fixed in one place, not the other" divergence that caused the ClassCastException bug fixed in the last commit.
  • EXPLAIN now genuinely never executes steps (confirmed by reading a few step constructors, e.g. DeleteStep — construction just stores fields, all work is in syncPull, which stepsForDescription() never calls), and explainingAnyWriteDescribesItWithoutPerformingIt is a solid safety-net test for that invariant across step types.
  • The slow-unit-tests timeout bump and the profiling-output parenthesis fix are both small, well-justified, and low-risk.
  • Good test hygiene: StallAwareStopwatch is used per CLAUDE.md guidance instead of raw wall-clock assertions, and the @Timeout is explicitly framed as a hang detector rather than a latency bound.

No functional issues found in the core logic. The two points above are polish, not blockers.

…e 2^53 bound says why it is inclusive

Review follow-ups on PR #6344.

The suggestion to relax the exact-double bound from `>= 2^53` to `> 2^53`, to match
DistinctNumericKey, would have introduced a wrong answer: 2^53 is exactly representable, but
2^53+1 is NOT, and rounds ties-to-even down onto the same double, so at that magnitude two longs
already share one double. Taking the fast path there would pick one of them and answer false for
a range that holds only the other. The bound stays inclusive, the comment now says why, and a
test builds both ranges and pins each answer against the walk's.

EXPLAIN no longer swallows a plan it could not build into a FINE log nobody has enabled. The
failure is named in the description instead: EXPLAIN answers a question about a query, and a
query that cannot be planned is an answer to it - the statement would fail the same way when
run. A query that does not PARSE keeps raising, since there is no plan to describe and the parse
error already says why.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review

I read through the full diff (workflow timeout, InExpression/LongRangeList fast-path membership, the ListSliceExpression/ExpressionEvaluator slice-bound dedup, and the CypherExecutionPlan.explain() rework) plus the associated new tests. This is a well-scoped, thoroughly-tested PR, and the five commits show the author already worked through a round of self-review (BigInteger/BigDecimal rejection in indexOf, EXPLAIN now naming post-MATCH write steps, UNION-aware EXPLAIN wording, shared slice-bound error message, inclusive 2^53 boundary). I did not find any correctness bugs. A few observations, all minor:

Correctness (verified, no issues found)

  • InExpression.rangeMembership was cross-checked against ComparisonExpression.compareValuesTernary: the Long/Integer branch, the generic-Number doubleValue() branch (which also correctly covers BigInteger/BigDecimal, since those aren't Long/Integer in the real = operator either), the RID-string interop, and the "different types not equal" fallback all match what the walk would produce. LongRangeListTest/CypherInRangeMembershipTest cover this well including the 2^53 / 2^53+1 tie-to-even edge case, which is genuinely subtle and correctly reasoned about (both longs round to the same double, so the boundary has to stay inclusive rather than moving to >).
  • LongRangeList.subList's start + fromIndex * step computation can only be reached with fromIndex == size when the resulting slice is empty (size 0), so a latent overflow there is never observable - fine as is.
  • Confirmed explain()/stepsForDescription() only builds steps (schema reads only) and never calls syncPull, so the new "describe a write without running it" behavior is sound; the constructor-vs-syncPull invariant is explicitly tested against SET/REMOVE/DELETE/MERGE/FOREACH/edge-CREATE in explainingAnyWriteDescribesItWithoutPerformingIt.

Minor / nit

  • CypherExplainTraditionalPlanTest.stepLines's regex (\s*\(\d.*$) strips everything from the first (<digit> onward to compare EXPLAIN vs PROFILE output. That's test-only code, but it means a legitimate non-timing suffix that happens to start with (<digit> (e.g. a step's own (N items) annotation) would be silently dropped from the comparison along with the timing, slightly weakening the "same chain" assertion beyond that point on a line. Not a real risk given current step formats, just worth knowing if a future step's prettyPrint gains a numeric-prefixed suffix.
  • appendPlanBuildFailure logs at Level.FINE, which per the PR description is intentional ("nobody has enabled" that level) since the message is surfaced in the plan text itself - reasonable tradeoff, just flagging that anyone debugging via server logs alone (rather than the EXPLAIN output) won't see it by default.
  • The CI timeout bump (slow-unit-tests: 60 to 90 min) is well justified with measured run data in the PR description and comment; no concerns there.

Test coverage

Strong - CypherInRangeMembershipTest (drift guard against ComparisonExpression, null semantics, numeric coercion, the 2^53 boundary, huge-range constant-cost timing via StallAwareStopwatch, indirect range access through variables/slices) and CypherExplainTraditionalPlanTest (traditional plan description, RID push-down marker, EXPLAIN/PROFILE chain equality, non-execution of writes for every write-clause kind, UNION per-branch description, structured-steps/text agreement, balanced-parenthesis regression) both look comprehensive for the stated scope.

Performance & security

No concerns. The range fast-path avoids boxing/copies as intended (verified against the actual comparator semantics rather than just List.contains, which was the right call given Cypher's numeric coercion). No new dependencies, no user-input-driven code paths that look unsafe.

Nice work - this is a solid fix with the reasoning for each non-obvious decision (2^53 inclusivity, why List.contains wasn't used, why EXPLAIN can't just call PROFILE) captured directly in code comments, which will help future maintainers.

…is starting with a digit

Review follow-up on PR #6344. The EXPLAIN/PROFILE chain comparison stripped from the first
`(<digit>` to the end of the line, so a step whose own text ended in one would have dropped out
of the comparison along with the timing. The pattern now matches the shape
AbstractExecutionStep actually appends: a duration in microseconds, optionally followed by a
row count.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review

Went through the full diff (all 6 commits) plus the current state of the touched files. This is a very well-executed PR: the reasoning is carefully laid out in the commit messages/comments, the fast path for IN-over-range() is validated against the walk with an explicit drift-guard test (answersExactlyAsTheWalkDoes) rather than trusted by inspection, the 2^53 boundary and BigInteger/BigDecimal handling are backed by dedicated tests, and the earlier review round already caught and fixed real issues (the missing "steps after an optimized MATCH", the UNION mislabeling, the slice ClassCastException divergence, the unbalanced-parenthesis regex). Nothing else jumped out as a correctness bug after tracing the InExpression/ComparisonExpression/LongRangeList coercions against each other and the explain()/profile() step-chain plumbing.

A few minor/non-blocking observations:

  1. appendPlanBuildFailure surfaces cause.getMessage() directly in EXPLAIN output (CypherExecutionPlan.java). For most planning failures (unresolved property, unsupported pattern) this is fine and is the whole point of the change, but if a RuntimeException message ever includes something like a stack-trace-ish detail or an internal file path, it'd now be visible to anyone who can run EXPLAIN (rather than only through a FINE log line). Given EXPLAIN doesn't require elevated privileges beyond normal query access, this is a low-severity concern, but worth a quick mental check that no planning-path exception embeds anything sensitive (e.g. absolute paths, connection strings) in its message.

  2. stepsForDescription() builds steps solely to describe them, and the PR's safety argument rests on "construction reads the schema, the work is all in syncPull" — this is now empirically backed by explainingAnyWriteDescribesItWithoutPerformingIt covering SET/REMOVE/DELETE/MERGE/FOREACH/CREATE(relationship), which is good coverage. One case not in that list: a CALL to a stored procedure/custom function inside a MATCH — if any built-in step type does eager work in its constructor (e.g. opening a cursor, acquiring a lock) rather than lazily in syncPull, EXPLAIN would now trigger it. Given the existing test's breadth this is likely already fine, just flagging it as the one class of step not explicitly exercised by the regression test.

  3. CI timeout bump to 90 min for slow-unit-tests is well-justified with concrete measured run times in the comment. No concerns there.

  4. LongRangeList.indexOfLong's BigInteger overflow fallback path (for Math.subtractExact overflow) is unit-tested (containsHandlesOverflowingDistance, pre-existing) and untouched by this PR beyond the extraction into a private method — no new risk introduced there.

Overall this looks solid and ready to merge from a correctness/testing standpoint; the two notes above are things I'd only ask about, not block on.

…y its failure message is safe to print

Review follow-ups on PR #6344, both non-blocking.

A CALL is the one step whose body is a plan of its own, so it is the one that could do eager
work while being built rather than while being pulled. Both shapes join the write-safety test:
a CALL subquery that writes, and a procedure call.

The message appendPlanBuildFailure prints tells the caller nothing that running the same
statement would not - execute() builds this very chain and lets what it throws reach the client,
and EXPLAIN needs no privilege beyond running the query it describes. Said in the javadoc, since
the question is a fair one to ask of any text that reaches a user.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review

I read through the full diff (engine changes, the two new test classes, and the CI timeout change). Overall this is a well-scoped PR: each of the three fixes is backed by a targeted regression test, and the fast paths are explicitly cross-checked against the semantics of the general-case code they bypass rather than reimplementing them independently. I traced the key correctness claims by hand against the current source rather than taking the PR description at face value:

InExpression.rangeMembership (IN over a LongRangeList) — I compared this against ComparisonExpression.compareValuesTernary directly. The claim that a Long right operand can only reach the RID-string branch, the numeric branch, or "different types are not equal" checks out (Identifiable, temporal coercion, and MultiValue.getMultiValueAsList all correctly can't fire against a bare Long). The Long/Integer-only long-vs-long fast branch in ComparisonExpression (line ~191) vs. the doubleValue() fallback for Short/Byte is real, and rangeMembership's decision to still convert Short/Byte via longValue() is safe because both are well within the exact-double range. The 2^53 inclusive-boundary reasoning (double ties-to-even rounding 2^53+1 onto 2^53) is correct and is exercised by theExactDoubleBoundaryIsAnsweredAsTheWalkAnswersIt.

LongRangeList.indexOf/containsLong — the overflow-safe BigInteger fallback in indexOfLong for Math.subtractExact overflow looks right, and rejecting BigInteger/BigDecimal in indexOf (the List.equals() contract) while giving coercing callers containsLong is a clean way to keep both contracts honest.

ListSliceExpression/ExpressionEvaluator slice unification — confirmed the two previously-duplicated slicing implementations are now genuinely one code path, and that the LongRangeList branch (avoiding the range(0, 999999999)[0..1000000000] heap exhaustion from GHSA-xmjm-8q85-g778) is only reached after the from >= to empty case, which is correct since an empty result doesn't need to preserve laziness.

CypherExecutionPlan.explain()/profile() refactor — this is the largest and riskiest change. I traced the branch structure (count push-down → optimized physical plan → UNION → traditional) and it's mutually exclusive and consistent with countPushDownForDescription() returning null for unions and canUseOptimizedPhysicalPlan() being false for a union's own (null) physicalPlan. The important safety property — that stepsForDescription() builds but never pulls the step chain, so EXPLAIN of a write is genuinely non-executing — is exercised well by explainingAWriteDescribesItWithoutPerformingIt and the per-step-kind sweep in explainingAnyWriteDescribesItWithoutPerformingIt (SET/REMOVE/DELETE/MERGE/FOREACH/CREATE/CALL). Good catch on CALL being singled out as the one step whose body is itself a plan that could theoretically do eager work while being built.

Minor/non-blocking observations:

  • appendPlanDescription's rootStep == null branch reuses the old "Execution will use step-by-step interpretation" message, which previously only meant "no push-down, falling back to traditional." Now that this is the terminal branch, it's worth double-checking there's no realistic query shape where buildExecutionSteps/buildExecutionStepsWithOptimizer legitimately return null without throwing — if that ever happens, this message would be shown as if it were still a route-selection message rather than "nothing to describe." Not blocking since I couldn't find a live path that returns null without throwing, just flagging for awareness.
  • CypherExplainTraditionalPlanTest.stepLines's timing-strip regex (\s*\([\d,.]+\s*[μµ]s(, [\d,.]+ rows)?\)$) is anchored to end-of-line and handles both µ variants (U+00B5 and U+03BC) — good attention to detail given the earlier bug it was written to avoid (issue mentioned in commit 8d8204e).

CI timeout change — the slow-unit-tests 60→90 min bump is well-justified with concrete measured durations (39m27s/46m/57m40s, one CANCELLED at 1h0m16s mid-progress) and correctly framed as a hang detector rather than a median-latency bound, consistent with the repo's stall-aware testing conventions.

Test coverage — thorough: the answersExactlyAsTheWalkDoes drift guard (30 operands including NaN, infinities, BigInteger/BigDecimal, RID-strings, non-RID strings, lists, maps, null) is exactly the right shape of test for a fast path that must not silently diverge from the general path as ComparisonExpression evolves. explainAndProfileDescribeTheSameChain closing the loop between EXPLAIN and PROFILE text is a good regression guard for the "two texts describing two different plans" class of bug this PR fixes.

No correctness, security, or performance issues found. Nice work threading the needle between the fast path being a genuine optimization and not being a second, independently-maintained copy of the comparison/slicing semantics.

…nouncing the route it would have taken

Review follow-up on PR #6344, non-blocking. "Execution will use step-by-step interpretation" was
a route-selection message, true when EXPLAIN reached that line without ever building anything.
It is now the branch for a statement that built no chain at all, where announcing the route
describes nothing. It says what is true of such a statement instead, and the one test that
asserted the old sentence asks for the step chain that replaced it.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review

I read through the full diff (engine changes + new/updated tests + the CI timeout change) and traced the key claims against the actual code rather than just the PR description. Overall this is a well-scoped, well-tested PR: each of the three fixes is backed by a regression test that pins the specific defect, and the "found while doing the above" fixes (range-slice heap exhaustion, unbalanced profiling parens) are real, narrowly-targeted bugs.

Correctness

  • InExpression.rangeMembership (fast-path membership in a LongRangeList): I traced every branch against ComparisonExpression.compareValuesTernary to check the "reproduces the comparator's own answer" claim, since IN's walk calls valuesCompare(value, element) where element is always a boxed Long:

    • Long/Integer/Short/Byte → containsLong(longValue()): matches the walk's long-vs-long branch for Long/Integer, and the doubleValue() fallback for Short/Byte (correct, since a value of at most 15 bits is always exactly representable as a double).
    • Other Number → NaN/Infinite → false; >= 2^53 → decline (null, walk runs); non-integral → false; otherwise containsLong((long) asDouble). This matches the walk's numeric branch exactly, including the subtle inclusive 2^53 boundary (verified: 2^53+1 is unrepresentable as a double and rounds ties-to-even onto 2^53, so treating 2^53 as unambiguous would give a wrong answer, the test theExactDoubleBoundaryIsAnsweredAsTheWalkAnswersIt pins this down well).
    • RID-shaped String → matches the left instanceof String && right instanceof Number && RID.is(leftStr) branch in compareValuesTernary (order matters here since value is always the left operand against the Long element on the right, and I checked this is the branch that actually fires).
    • Everything else → false, matching the final "different types are not equal" fallback (Long is never Identifiable, coerceTemporal only touches Temporal/Date, so those branches are correctly ruled unreachable).
    • null short-circuit before this is called is correct: a range never contains null, so only list emptiness matters for 3VL.

    I did not find a divergence. The answersExactlyAsTheWalkDoes test is a genuinely good drift guard for future changes to compareValuesTernary.

  • LongRangeList.indexOf/containsLong/subList: rejecting BigInteger/BigDecimal in indexOf (previously truncated via longValue(), which silently aliased huge values to the wrong element modulo 2^64) is a correct fix to an actual List.equals() contract violation. containsLong is a sensible, clearly-named escape hatch for callers with coercing equality. subList returning a lazy LongRangeList instead of a materialized copy is the right fix for the GHSA-xmjm-8q85-g778 regression the PR describes (a billion-element slice would otherwise re-exhaust the heap).

  • ListSliceExpression/ExpressionEvaluator slice dedup: the two paths now share sliceBound/slice, which correctly closes the ClassCastException-vs-IllegalArgumentException divergence called out in the description. ExpressionEvaluator.evaluateListSlice still resolves operands through evaluate(...) so aggregation overrides apply before handing off to the shared slicer, behavior preserved.

  • CypherExecutionPlan.explain(): confirmed stepsForDescription() mirrors execute()'s dispatch order (canUseOptimizedPhysicalPlan() ? buildExecutionStepsWithOptimizer : buildExecutionSteps), and that building a step chain does not pull it (syncPull is never called on the description path), which is what makes explainingAnyWriteDescribesItWithoutPerformingIt safe. canUseOptimizedPhysicalPlan() requires physicalPlan != null, which the union path leaves null, so the isUnion()/optimized-plan branches in explain() are correctly mutually exclusive, good to see this is actually enforced rather than assumed.

  • CountEdgesReturnStep [target: ...] marker: the targetLabel filtering logic (Labels.hasLabel/instanceOf checks) already existed prior to this PR; this change only adds it to the printed plan text. The updated test (countEdgesReturnFiltersByTheTargetLabel) still asserts the actual query result (cnt == 1L, filtered to Person targets only) in addition to the plan text, so this isn't just a cosmetic-test-only change, the underlying correctness was already covered, and the stale "step must be absent" assertion is legitimately replaced.

  • Unbalanced-parenthesis fix across the nine step classes: verified the before/after in DeleteStep, the fix is exactly right (single append(")") after the optional row-count instead of one immediately after the cost, then a second one after row count).

Minor observations (not blocking)

  • appendPlanBuildFailure catches Exception broadly and converts it into plan text rather than propagating. The PR's reasoning (this is exactly what execute() would throw anyway, and EXPLAIN needs no extra privilege) is sound, but it's worth double-checking that no code path relies on EXPLAIN raising for malformed/unsupported queries elsewhere (e.g., server-side error handling that expects an exception rather than a 200 with an error string embedded in the plan text). The new test explainAnswersWithAPlanWhateverTheQueryAsksFor only checks a few "doesn't crash the planner" cases, not an actually-unplannable one, might be worth one more test that forces the appendPlanBuildFailure path itself, if that's easy to construct.
  • slow-unit-tests timeout bumped from 60 to 90 minutes based on observed durations (39m27s-57m40s, one cancellation at 1h0m16s past 60 with no failures), reasonable, well-justified hang-detector sizing, and ha-integration-tests is correctly left alone since it was never actually capped at 60.

Test coverage

Strong: CypherInRangeMembershipTest (operand-by-operand drift guard against the walk, null semantics, the 2^53 boundary, huge-range constant-cost timing via StallAwareStopwatch, indirect range access through variables/slices) and CypherExplainTraditionalPlanTest (write-safety across every step kind, UNION branch description, EXPLAIN/PROFILE chain equality, balanced parens, structured step list) both directly target the reported defects and the "found while doing the above" issues. LongRangeListTest additions are appropriately narrow (rejects the two new types, containsLong including negative-step and overflow cases).

No functional issues found. Nice, disciplined use of StallAwareStopwatch/assertStayedUnder per the repo's guidance on not asserting raw wall-clock time.

One conflict: #6348 deleted EagerDistinctCollectOptionalMatchStep, which this branch had touched
for the unbalanced profiling parenthesis. The deletion wins - nothing on main references the
class any more, and the fix dies with the file. The eight remaining paren fixes are unaffected,
and no step added by #6348 carries the pattern.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Reviewed the full diff (all 8 commits) plus the current state of the touched files. This is a very well-constructed PR: TDD throughout, extensive drift-guard tests (answersExactlyAsTheWalkDoes, explainAndProfileDescribeTheSameChain), and the 8-commit review history already closes out several sharp edge cases (Short/Byte precision, UNION per-branch planning, malformed slice bounds, the 2^53 boundary, CALL eager-execution safety). A few notes below, none blocking.

Code quality / correctness

  • InExpression.rangeMembership (ast/InExpression.java): the fast path is carefully built to mirror ComparisonExpression.compareValuesTernary operand-by-operand (long fast path, double coercion, RID-string interop, NaN/Infinity), and the comments explain why for each branch rather than what. The 2^53 inclusive-bound reasoning (ties-to-even collapsing 2^53+1 onto 2^53) is subtle and is correctly pinned down with theExactDoubleBoundaryIsAnsweredAsTheWalkAnswersIt. Good catch.
  • LongRangeList.indexOf/containsLong split is the right shape: indexOf keeps the List contract (equals()), and containsLong is the numeric-coercion entry point for callers like IN. Rejecting BigInteger/BigDecimal in indexOf fixes a real correctness bug (values congruent mod 2^64 falsely matching via truncation).
  • ListSliceExpression.slice/sliceBound dedup between the AST node and ExpressionEvaluator is a solid simplification, and commit 4 correctly caught that the dedup initially dropped the friendly "Slice index must be a number" validation on one of the two paths (would've been a raw ClassCastException otherwise) - good that it was caught before merge.
  • CypherExecutionPlan.appendPlanBuildFailure: catching plan-build failures and reporting the message in the EXPLAIN text instead of a FINE-level log is a nice usability fix. One thing worth a second look: this catches a fairly broad Exception around stepsForDescription(). The javadoc argues the message is safe to expose because execute() would surface the same exception to the same caller anyway - that's true for legitimate query-shape errors, but it also means a genuine internal bug (NPE in a step constructor, say) now gets silently swallowed into an "Execution plan not available: ..." line in EXPLAIN output rather than propagating as a loud failure during normal test runs that exercise EXPLAIN. Probably fine given execute() has the same exposure already, but worth confirming no test relies on an exception propagating out of explain()/profile() for a genuinely malformed/buggy plan.

Performance

  • The IN-over-range fix is the headline win here (O(position) to O(1)), well justified with before/after timings and a dedicated @Timeout-guarded regression test (membershipInAHugeRangeIsConstantCost) using StallAwareStopwatch per the repo's stall-discounting convention rather than raw wall clock. Good adherence to CLAUDE.md guidance there.
  • The ListSliceExpression range-slice fix (range(...)[a..b] returning a LongRangeList.subList instead of materializing an ArrayList) closes a real heap-exhaustion regression (GHSA-xmjm-8q85-g778) that had crept back in for the slice path specifically. Good that it's covered by rangeReachedIndirectlyIsStillFast.
  • EXPLAIN now builds (but never pulls) the full execution-step chain for the traditional path, which is strictly more work than the old "print a reason and stop" behavior. That's an intentional, reasonable tradeoff for a diagnostic command, and the CALL-subquery/procedure-call safety concern (steps doing eager work in their constructor rather than syncPull) was specifically tested for (explainingAnyWriteDescribesItWithoutPerformingIt now includes CALL cases per commit 7). Good coverage of what's otherwise an easy invariant to silently break in a future step implementation - might be worth a one-line note on AbstractExecutionStep (or wherever new step types are added) that construction must stay side-effect-free, since that invariant is now load-bearing for EXPLAIN write-safety and isn't obviously documented at the step base class itself. Not required, just a durability suggestion since a future step author won't necessarily see this PR's test.

Test coverage

  • Excellent: CypherInRangeMembershipTest (drift guard against the walk, null semantics, numeric coercion, huge-range constant-cost, indirect range references, malformed slice bounds) and CypherExplainTraditionalPlanTest (write-safety across SET/REMOVE/DELETE/MERGE/FOREACH/CREATE/CALL, UNION branches, EXPLAIN/PROFILE chain equality, balanced-parenthesis regression, plan-build-failure reporting, non-parseable vs. non-plannable distinction) are both thorough and well-targeted at the actual failure modes described in the PR body rather than incidental coverage.
  • LongRangeListTest additions for indexOf rejecting BigInteger/BigDecimal and containsLong (including overflow-adjacent cases) are appropriately narrow unit tests for the utility class itself, separate from the Cypher-level behavior tests.
  • CI timeout change (slow-unit-tests 60 to 90 min) is backed by actual measured run durations (39m27s/46m/57m40s plus a 1h0m16s cancellation) rather than a guess, which matches the repo's stated preference for evidence-based bounds over convenience numbers.

Minor / nit

  • appendStepsAfterTheOptimizedMatch and appendPlanDescription/isUnion recursion look correct; the chain.size() < 2 skip-first-step logic is a little implicit (relies on knowing the first entry is always the physical-plan wrapper) but is documented in the surrounding comment, so it's fine as is.
  • No security concerns identified - the EXPLAIN failure-message exposure was explicitly reasoned through and matches execute()'s existing exposure.

Overall: solid, well-tested change with unusually thorough self-review already baked into the commit history. Nothing here blocks merging.

@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.01575% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.73%. Comparing base (34482fd) to head (d94b612).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...query/opencypher/executor/CypherExecutionPlan.java 72.72% 14 Missing and 4 partials ⚠️
...dedb/query/opencypher/ast/ListSliceExpression.java 52.38% 5 Missing and 5 partials ⚠️
...db/query/opencypher/executor/steps/DeleteStep.java 0.00% 1 Missing ⚠️
...uery/opencypher/executor/steps/ExpandIntoStep.java 0.00% 1 Missing ⚠️
...db/query/opencypher/executor/steps/RemoveStep.java 0.00% 1 Missing ⚠️
...ry/opencypher/executor/steps/ShortestPathStep.java 0.00% 1 Missing ⚠️
.../main/java/com/arcadedb/utility/LongRangeList.java 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6344      +/-   ##
==========================================
+ Coverage   70.69%   70.73%   +0.04%     
==========================================
  Files        1850     1850              
  Lines      160387   160443      +56     
  Branches    33713    33722       +9     
==========================================
+ Hits       113384   113491     +107     
+ Misses      33045    32999      -46     
+ Partials    13958    13953       -5     

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-ups from #6307: two of #6297's items lost their tracker, IN walks a range that answers in O(1), and EXPLAIN cannot describe the plan it would run

1 participant