Skip to content

fix(#4908,#4909): retryable Bolt conflicts + nested $param.field in MATCH patterns - #4910

Merged
lvca merged 6 commits into
mainfrom
fix/4908-4909-bolt-retry-nested-param
Jul 3, 2026
Merged

lvca merged 6 commits into
mainfrom
fix/4908-4909-bolt-retry-nested-param

Conversation

@lvca

@lvca lvca commented Jul 3, 2026

Copy link
Copy Markdown
Member

Summary

Two follow-ups to #4905, flagged during review of #4906. Both are root-cause fixes in ArcadeDB for workarounds the getzep/graphiti driver currently carries; landing them lets the driver drop those workarounds. Diagnosed by @agc-63.

Closes #4908, closes #4909.

#4908 - Bolt: optimistic-concurrency conflicts are now retryable

ArcadeDB's MVCC page-version conflicts surfaced to Bolt clients as non-retryable errors (Neo.DatabaseError.General.UnknownError for query execution, Neo.ClientError.Transaction.TransactionNotFound on commit), so a managed-transaction driver (session.execute_write(...)) never auto-retried - even though ArcadeDB's own message says "Please retry the operation".

  • BoltErrorCodes: add TRANSIENT_CONFLICT_ERROR = Neo.TransientError.Transaction.DeadlockDetected - a TransientError classification the Neo4j drivers retry on (avoiding the two excluded titles Transaction.Terminated / Transaction.LockClientStopped).
  • BoltNetworkExecutor.classifyExecutionError(): classify any NeedRetryException in the cause chain (ConcurrentModificationException / LockTimeoutException) to the transient code; applied in the RUN, PULL and COMMIT error paths.
  • Removes the need for the graphiti driver's bespoke error-string-matching retry loop.

#4909 - OpenCypher: nested parameter field access in MATCH patterns

MATCH (source:Entity {uuid: $edge_data.source_uuid}) matched nothing (no error): the pattern property parses to a ChainedPropertyAccessExpression whose base ParameterExpression resolves from the context alone, but MatchNodeStep only evaluated pattern-property expressions when an input row was present. With a bare MATCH (no row) the expression was left unevaluated and never matched - which is why the graphiti driver had to wrap single edge saves in UNWIND [$edge_data] AS edge.

  • MatchNodeStep: evaluate pattern-property expressions with a shared read-only empty result when there is no input row, in both the scan path (matchesProperties) and the index path (tryFindAndUseIndex). Parameter-based expressions resolve; row-dependent ones return null and fall back to scan.

Tests

  • BoltErrorClassificationTest (5) - conflict / lock-timeout / wrapped-cause -> transient, generic -> default, and the transient code is a driver-retryable classification.
  • NestedParameterInMatchTest (3) - scan path, index path, and the graphiti MATCH ... MERGE edge-save shape.

Verification

  • Full com.arcadedb.query.opencypher.**: 6640 tests, 0 failures / 0 errors.
  • Full bolt module: 229 tests, 0 failures / 0 errors.

lvca added 2 commits July 3, 2026 01:05
… Bolt error so drivers auto-retry

ArcadeDB's MVCC page-version conflicts surfaced to Bolt clients as non-retryable errors
(DATABASE_ERROR for query execution, ClientError TransactionNotFound on commit), so a
managed-transaction driver never auto-retried even though ArcadeDB asks the client to retry.

- BoltErrorCodes: add TRANSIENT_CONFLICT_ERROR (Neo.TransientError.Transaction.DeadlockDetected),
  a TransientError classification drivers retry on, avoiding the two excluded titles.
- BoltNetworkExecutor.classifyExecutionError(): classify any NeedRetryException in the cause chain
  (ConcurrentModificationException / LockTimeoutException) to the transient code; applied in the
  RUN, PULL and COMMIT error paths.
- Test: BoltErrorClassificationTest covers conflict/lock-timeout/wrapped-cause -> transient and
  generic -> default.
…ATCH pattern property maps

A pattern property like {uuid: $edge_data.source_uuid} parses to a ChainedPropertyAccessExpression
whose base ParameterExpression resolves from the context alone, but MatchNodeStep only evaluated
pattern-property expressions when an input row was present. With a bare MATCH (no row) the expression
was left unevaluated, so the pattern silently matched nothing (breaking single edge saves in the
graphiti driver).

- MatchNodeStep: evaluate pattern-property expressions with a shared read-only empty result when
  there is no input row, in both the scan path (matchesProperties) and the index path
  (tryFindAndUseIndex); parameter-based expressions resolve, row-dependent ones return null and
  fall back to scan.
- Test: NestedParameterInMatchTest covers scan path, index path, and the graphiti edge-save shape.
@mergify

mergify Bot commented Jul 3, 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 Jul 3, 2026 •

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 minor

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
CodeStyle 1 minor

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces error classification for Bolt transactions to map optimistic-concurrency conflicts to retryable Neo4j transient errors, and fixes an issue in OpenCypher where nested parameter expressions in MATCH patterns failed to resolve when no input row was present. The review feedback highlights two main areas of improvement: first, the static EMPTY_RESULT in MatchNodeStep should be changed to an instance field to avoid thread-safety issues with the mutable ResultInternal class; second, the exception cause traversal in classifyExecutionError should include a depth limit to prevent infinite loops in the event of circular exception references.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +77 to +80
// Read-only empty result used to evaluate context-only pattern property expressions (e.g. a parameter
// map field like $edge_data.uuid) when the pattern has no input row. Row-variable lookups against it
// return null gracefully. Shared because it is only ever read (issue #4909).
private static final Result EMPTY_RESULT = new ResultInternal();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a shared static final instance of ResultInternal (EMPTY_RESULT) across all queries can lead to thread-safety issues. ResultInternal is a mutable class and is not thread-safe. If any query execution or custom function modifies the Result passed to it (e.g., by casting it to ResultInternal and calling setProperty), it could corrupt the state or cause race conditions for other concurrent queries.\n\nSince MatchNodeStep is instantiated once per query execution plan, making emptyResult a non-static instance field completely isolates it to the current query execution thread, eliminating any multi-threading risks with negligible overhead.

Suggested change
// Read-only empty result used to evaluate context-only pattern property expressions (e.g. a parameter
// map field like $edge_data.uuid) when the pattern has no input row. Row-variable lookups against it
// return null gracefully. Shared because it is only ever read (issue #4909).
private static final Result EMPTY_RESULT = new ResultInternal();
// Read-only empty result used to evaluate context-only pattern property expressions (e.g. a parameter\n // map field like $edge_data.uuid) when the pattern has no input row. Row-variable lookups against it\n // return null gracefully. Made an instance field to ensure thread-safety across concurrent queries (issue #4909).\n private final Result emptyResult = new ResultInternal();

Comment on lines 613 to 618
else if (propertyValue instanceof Expression) {
if (currentInputResult != null)
propertyValue = evaluator.evaluate((Expression) propertyValue, currentInputResult, context);
else
return null; // Cannot resolve expression without input row — skip index
propertyValue = evaluator.evaluate((Expression) propertyValue,
currentInputResult != null ? currentInputResult : EMPTY_RESULT, context);
if (propertyValue == null)
return null; // Couldn't resolve (e.g. row-dependent expression with no row) — skip index
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the reference from the static EMPTY_RESULT to the instance-level emptyResult to maintain compilation and thread-safety.

      else if (propertyValue instanceof Expression) {\n        propertyValue = evaluator.evaluate((Expression) propertyValue,\n            currentInputResult != null ? currentInputResult : emptyResult, context);\n        if (propertyValue == null)\n          return null; // Couldn't resolve (e.g. row-dependent expression with no row) — skip index\n      }

Comment on lines +933 to +934
if (expectedValue instanceof Expression)
expectedValue = evaluator.evaluate((Expression) expectedValue, currentResult != null ? currentResult : EMPTY_RESULT, context);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the reference from the static EMPTY_RESULT to the instance-level emptyResult to maintain compilation and thread-safety.

      if (expectedValue instanceof Expression)\n        expectedValue = evaluator.evaluate((Expression) expectedValue, currentResult != null ? currentResult : emptyResult, context);

Comment on lines +1626 to +1632
static String classifyExecutionError(final Throwable error, final String defaultCode) {
for (Throwable t = error; t != null; t = t.getCause()) {
if (t instanceof NeedRetryException)
return BoltErrorCodes.TRANSIENT_CONFLICT_ERROR;
}
return defaultCode;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The cause chain traversal in classifyExecutionError does not guard against circular exception references (where an exception's cause chain eventually points back to itself). While rare, circular references in exception causes can cause an infinite loop, hanging the network executor thread and consuming 100% CPU.\n\nTo prevent this, we should implement a simple depth limit (e.g., 100 levels) or keep track of visited throwables to break any potential cycles.

  static String classifyExecutionError(final Throwable error, final String defaultCode) {\n    int depth = 0;\n    for (Throwable t = error; t != null && depth < 100; t = t.getCause(), depth++) {\n      if (t instanceof NeedRetryException)\n        return BoltErrorCodes.TRANSIENT_CONFLICT_ERROR;\n    }\n    return defaultCode;\n  }

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review: fix(#4908,#4909) - retryable Bolt conflicts + nested $param.field in MATCH

Reviewed both fixes end-to-end. These are well-targeted root-cause fixes with good, focused regression tests (scan + index paths for #4909, and the wrapped-cause case for #4908). A few observations below, mostly minor.

#4908 - Bolt error classification

  • Verified ConcurrentModificationException and LockTimeoutException both extend NeedRetryException, so the cause-chain walk in classifyExecutionError catches both. 👍
  • The swap BoltException.DATABASE_ERROR -> BoltErrorCodes.DATABASE_ERROR (and TRANSACTION_ERROR) is behavior-neutral: BoltException.DATABASE_ERROR is just a deprecated alias for the same string. Good cleanup toward the canonical constants.
  • Minor: the for (Throwable t = error; t != null; t = t.getCause()) loop has no guard against a self-referential / cyclic cause chain (t.getCause() == t), which would spin forever. Very unlikely in practice, but a seen-set or depth cap is cheap insurance since this runs on every error path. Optional if other cause-walks in the codebase already accept this risk.

#4909 - nested parameter access in MATCH

  • The fix is sound: evaluating the pattern-property expression against a shared empty result lets a parameter-only expression ($edge_data.uuid) resolve from context, while row-dependent lookups return null and fall back to scan. I confirmed in matchesProperties that a null expectedValue yields no match (actualValue.equals(null) is false), so there is no false-positive-match risk - matches the "no worse than before" claim.

  • Thread-safety of the shared static EMPTY_RESULT (worth addressing): MatchNodeStep is instantiated per-query, but EMPTY_RESULT is static and shared across all concurrent queries/threads. new ResultInternal() backs it with a mutable LinkedHashMap, and ResultInternal.setProperty(...) writes into that map. The correctness of sharing therefore rests entirely on the invariant that evaluator.evaluate(...) never writes to the result for these expressions. That holds today for parameter/property-access expressions, but it is load-bearing and silent - a future evaluator path that sets a property would introduce a hard-to-find cross-query data race.

    Cheap hardening: construct it as new ResultInternal(Collections.emptyMap()) so the backing map is immutable and any accidental write fails fast with UnsupportedOperationException instead of silently corrupting shared state. Reads (getProperty on empty content) remain null-returning and safe.

  • Behavior change in the index path (tryFindAndUseIndex), flagging for awareness: previously, when an input row was present and the expression evaluated to null, the code put null into properties and used the index. Now propertyValue == null returns early and falls back to scan. Arguably more correct (indexes generally do not index nulls), but it is a real change for the currentInputResult != null case, not only the new no-row case the comment describes. Confirm that is intended.

Tests

  • Coverage is good: BoltErrorClassificationTest exercises direct, wrapped-cause, and negative cases plus the driver-retryable classification assertions; NestedParameterInMatchTest covers scan, index, and the real graphiti MATCH ... MERGE edge-save shape.
  • Nit: NestedParameterInMatchTest.withDatabase builds DB paths under ./target/testnestedparam_<nanoTime>. Most engine tests route through a shared build-directory/TestHelper helper - consider aligning for consistency and guaranteed cleanup, though finally { database.drop() } covers the happy path.
  • Nit: java.util.function.Consumer<Database> is used fully-qualified; per CLAUDE.md ("don't use fully qualified names if possible, always import the class") an import java.util.function.Consumer; would read more in keeping with the style.

Overall this looks solid and the tests give me confidence. The one item I'd genuinely recommend before merge is hardening the shared EMPTY_RESULT against accidental mutation.

@codecov

codecov Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 23.52941% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 36.03%. Comparing base (7ef8ab3) to head (7f8329c).
⚠️ Report is 14 commits behind head on main.

Files with missing lines Patch % Lines
...in/java/com/arcadedb/bolt/BoltNetworkExecutor.java 27.27% 5 Missing and 3 partials ⚠️
...query/opencypher/executor/steps/MatchNodeStep.java 16.66% 4 Missing and 1 partial ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##               main    #4910       +/-   ##
=============================================
- Coverage     65.64%   36.03%   -29.62%     
- Complexity      815     1152      +337     
=============================================
  Files          1681     1681               
  Lines        134059   134095       +36     
  Branches      28646    28660       +14     
=============================================
- Hits          88008    48315    -39693     
- Misses        33932    76772    +42840     
+ Partials      12119     9008     -3111     

☔ 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 self-assigned this Jul 3, 2026
@lvca

lvca commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Thanks for the thorough review (@gemini-code-assist and the Claude reviewer). Addressed all points:

#4909 - shared EMPTY_RESULT hardening (main recommendation) ✅
Constructed as new ResultInternal(Collections.emptyMap()). ResultInternal(Map) uses the passed map directly as its backing store, and setProperty() does content.put(...), so any accidental write now fails fast with UnsupportedOperationException instead of silently racing shared state across concurrent queries. Reads on empty content stay null-returning and safe, so it's still fine to share the static instance.

#4909 - index-path behavior change ✅
Restructured tryFindAndUseIndex so the null -> fall back to scan handling applies only in the no-input-row branch. When currentInputResult != null the original behavior is preserved exactly (evaluate against the row and use the result, even if null). So the only behavior change is the new no-row case, matching the comment.

#4908 - cyclic cause chain ✅
classifyExecutionError now walks with a depth cap (32) so a self-referential/cyclic getCause() can't spin forever. Added a cyclicCauseChainTerminatesAndKeepsDefault test.

Style nit - fully-qualified Consumer ✅
Imported java.util.function.Consumer in NestedParameterInMatchTest (this was also the single Codacy CodeStyle finding).

On the test-path nit (./target/testnestedparam_<nanoTime> vs a shared TestHelper): I kept the direct DatabaseFactory + finally { drop() } pattern to match the sibling ParameterTest/OpenCypherTemporalParameterTest in the same package, but happy to switch if you'd prefer TestHelper.

Re-verified: full com.arcadedb.query.opencypher.** and full bolt module both green.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Thank you for the update, @lvca. The hardening of EMPTY_RESULT with an immutable map is a great defensive improvement that eliminates the potential for cross-query data races. The logic in tryFindAndUseIndex and the depth-cap in classifyExecutionError also look correct and well-reasoned. Your approach to the test paths and imports is also perfectly acceptable given the context of the existing codebase. This looks ready to go.

…eserve index behavior

- MatchNodeStep: back EMPTY_RESULT with an immutable map (new ResultInternal(Collections.emptyMap()))
  so any accidental write fails fast instead of racing shared state across concurrent queries; restore
  the exact previous behavior of the index path when an input row is present (only the no-row case
  gets the null -> fall-back-to-scan handling).
- BoltNetworkExecutor.classifyExecutionError(): bound the cause-chain walk (depth cap) so a cyclic
  getCause() cannot spin forever; add a cyclic-cause regression test.
- NestedParameterInMatchTest: import Consumer instead of the fully-qualified name (also the sole
  Codacy CodeStyle finding).
@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

Code Review — PR #4910

Reviewed both fixes end to end. This is a clean, well-scoped PR: root-cause fixes with focused regression tests, careful backward-compatibility, and defensive hardening in the follow-up commit. Nice work. A few observations below, mostly minor.

Strengths

  • No behavioral regression in the default path. BoltException.DATABASE_ERROR/TRANSACTION_ERROR alias the same string constants as BoltErrorCodes.* (BoltException.java:66,69), so switching the call sites to classifyExecutionError(e, BoltErrorCodes.*) only changes behavior for NeedRetryException, leaving every other error exactly as before.
  • Exception hierarchy is correct. ConcurrentModificationException and LockTimeoutException both extend NeedRetryException, so the single instanceof NeedRetryException check catches both plus any future retryable subtype.
  • classifyExecutionError bounded walk (depth cap 32) is a good defensive touch, and the cyclic-cause regression test (a.initCause(b) with b -> a) is exactly the right test to have.
  • EMPTY_RESULT design is solid. A shared static final backed by Collections.emptyMap() is read-only, thread-safe across concurrent queries, and any accidental write fails fast with UnsupportedOperationException. The explaining comment is helpful.
  • The nested-parameter null fallback is safe. In matchesProperties, if a row-dependent expression evaluates to null against EMPTY_RESULT, the trailing if (actualValue == null) return false; plus !actualValue.equals(null) guarantees no false-positive match — strictly no worse than the previous no-match behavior. Same for the index path, which returns null (fall back to scan) when the value can't resolve.
  • Good test coverage: scan path, index path, and the exact graphiti MATCH ... MERGE edge-save shape; Bolt tests cover direct, wrapped-cause, generic, and cyclic cases.

Minor suggestions (non-blocking)

  1. Log level for expected conflicts. handleRun/handlePull still log at Level.WARNING with the full throwable. Now that MVCC conflicts are expected and auto-retried by the driver under contention (the whole point of this PR), a busy graphiti workload could emit a stream of WARNING stack traces for what is now normal, recoverable flow. Consider demoting the NeedRetryException case to FINE/INFO, keeping WARNING for genuine errors. Not required for correctness, but it matters operationally.

  2. Uncovered fallback catch. The top-level dispatch catch (BoltNetworkExecutor.java:214) and the BEGIN/ROLLBACK error paths still send the raw DATABASE_ERROR/TRANSACTION_ERROR. This is fine in practice — the query-execution paths (RUN/PULL/COMMIT, including implicit auto-commit inside PULL) are the ones that carry NeedRetryException, and they are all classified now. A one-line comment at line 214 noting the classification is intentionally handled in the per-message handlers would help a future reader not think it was missed.

  3. Test cleanup (tiny). NestedParameterInMatchTest.withDatabase drops the DB in finally and uses a System.nanoTime()-suffixed path to avoid cross-test collisions — good. No change needed, just noting it reads well.

Verdict

Looks good to merge. The two log-level/comment items are polish, not blockers. Both fixes are minimal, correct, backward-compatible, and properly tested per the repo's TDD guidance.

…tch is intentional

- BoltNetworkExecutor: extract isRetryableConflict() and, in the RUN/PULL handlers, log a
  NeedRetryException at FINE instead of WARNING - MVCC conflicts are expected under contention and
  auto-retried by the driver, so they should not flood WARNING with recoverable flow; genuine errors
  stay at WARNING.
- Add a comment at the top-level dispatch catch clarifying that NeedRetry classification is handled
  in the per-message RUN/PULL/COMMIT handlers, so the generic DATABASE_ERROR fallback is intentional.
- Test: cover isRetryableConflict directly (drives both classification and log-level demotion).
@lvca

lvca commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Addressed the two operational suggestions in 1560f3d09:

Log level for expected conflicts ✅
Extracted isRetryableConflict() and, in the RUN and PULL handlers, a NeedRetryException now logs at FINE instead of WARNING (genuine errors stay WARNING). So a busy contended graphiti workload no longer emits a stream of WARNING stack traces for what is now normal, driver-retried flow.

Uncovered fallback catch ✅
Added a comment at the top-level dispatch catch noting that NeedRetry (MVCC) classification is intentionally handled in the per-message RUN/PULL/COMMIT handlers where those conflicts actually arise, so the generic DATABASE_ERROR fallback there is deliberate.

Also added a direct isRetryableConflict test (drives both the transient classification and the log-level demotion). Full bolt module green: 231 tests, 0 failures.

Thanks for the operational eye - the FINE/WARNING split is the kind of thing that only bites in production.

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review: fix(#4908,#4909) - retryable Bolt conflicts + nested $param.field in MATCH

Overall this is a clean, well-scoped pair of root-cause fixes. Both changes are targeted, the exception hierarchy is used correctly, the inline comments explain the why (not just the what), and the new tests are focused regression tests. I'd be comfortable merging with minor considerations below.

What's good

  • Correct exception classification. Both ConcurrentModificationException and LockTimeoutException extend NeedRetryException, so isRetryableConflict() catching NeedRetryException in the cause chain covers exactly the retryable cases without over-broadening.
  • Cycle-safe cause walk. The depth-capped (32) loop in isRetryableConflict() correctly guards against self-referential cause chains, and there's an explicit test (cyclicCauseChainTerminatesAndKeepsDefault) proving it. Nice.
  • Package-private statics are testable. Making classifyExecutionError/isRetryableConflict static and package-private lets the unit tests exercise the logic directly without standing up a Bolt session.
  • Log-level demotion is a good touch. Demoting expected MVCC conflicts to FINE avoids flooding WARNING logs under contention while keeping genuine errors visible.
  • EMPTY_RESULT design is sound. Backing the shared static with Collections.emptyMap() makes any accidental write fail fast rather than silently race across concurrent queries, and the comment documents the reasoning. Since matchesProperties treats a null-evaluated expression as a non-match (and tryFindAndUseIndex falls back to scan), the no-row/row-dependent case degrades to exactly the previous behavior.

Considerations / minor nits

  1. EMPTY_RESULT relies on the evaluator being strictly read-only. The shared immutable result is safe as long as no evaluator.evaluate(...) path ever calls setProperty/mutates the passed Result. If some expression path ever does (e.g. a caching side-effect), it would now throw UnsupportedOperationException - a new failure mode for a case that previously silently didn't match. The 6640 passing opencypher tests suggest this holds today; just worth being aware the invariant is now load-bearing. Fail-fast is the right call over silent shared-state corruption.

  2. Import ordering (nit). import com.arcadedb.exception.NeedRetryException; is placed before the com.arcadedb.bolt.message.* block; alphabetically bolt precedes exception, so it's slightly out of order. Cosmetic only.

  3. No end-to-end Bolt wire test. BoltErrorClassificationTest thoroughly unit-tests the classification helpers, but there's no test asserting that a real MVCC conflict over the Bolt protocol actually surfaces Neo.TransientError.Transaction.DeadlockDetected to a driver (RUN/PULL/COMMIT dispatch). The unit coverage of the helper is the higher-value test, so this is optional - but an integration test would lock in the wire-level contract the graphiti driver depends on.

  4. Minor duplication. The RUN and PULL handlers inline isRetryableConflict(e) + the ternary rather than calling classifyExecutionError, because they also need the boolean for the log level. That's reasonable; just noting the classification-to-code mapping now lives in two shapes.

Test coverage

Good. The MATCH tests cover scan path, index path, and the real graphiti MATCH ... MERGE edge-save shape; the Bolt tests cover direct/wrapped/generic/cyclic classification plus the transient-code invariants. TDD-style regression tests as CLAUDE.md prescribes.

Nice work - the fixes address the actual root causes and let the driver shed its workarounds.

… wire test

- BoltNetworkExecutor: move the NeedRetryException import into alphabetical position (after
  exception.CommandParsingException) instead of ahead of the bolt.message block.
- Bolt4908TransientConflictIT: end-to-end wire test that a real optimistic-concurrency conflict over
  Bolt surfaces to the neo4j driver as a TransientException with code
  Neo.TransientError.Transaction.DeadlockDetected. Made deterministic (not racy) via two explicit
  transactions modifying the same record with ordered commits: the first commit bumps the page
  version, the second detects the stale version and conflicts. Locks in the wire-level contract the
  graphiti driver's managed-transaction retry depends on.
@lvca

lvca commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Addressed in 2ec039a0e:

Import ordering ✅
Moved import com.arcadedb.exception.NeedRetryException; into alphabetical position (right after com.arcadedb.exception.CommandParsingException) instead of ahead of the com.arcadedb.bolt.message.* block.

End-to-end Bolt wire test ✅ (took your optional suggestion - it's a contract worth pinning)
Added Bolt4908TransientConflictIT: drives a real MVCC conflict over the Bolt protocol with the neo4j driver and asserts it surfaces as a TransientException with code Neo.TransientError.Transaction.DeadlockDetected. Made it deterministic, not racy - two explicit transactions modify the same record and commit in a fixed order, so the first commit bumps the page version and the second reliably detects the stale version and conflicts. Confirmed stable across repeated runs (4/4). This locks in the RUN/PULL/COMMIT wire-level contract the graphiti driver's managed-transaction retry depends on. It runs in the failsafe/verify phase like the other *IT classes, so it doesn't slow the unit test phase.

On the two awareness-only notes:

  • EMPTY_RESULT read-only invariant - agreed it's now load-bearing; fail-fast (UnsupportedOperationException) beats silent shared-state corruption, and the full opencypher suite confirms no evaluator path mutates it today.
  • Minor duplication (RUN/PULL inline vs classifyExecutionError) - left as-is since the handlers need the boolean for the FINE/WARNING log level anyway; the shared logic lives in isRetryableConflict.

Full bolt unit suite green (231), and the new IT passes deterministically.

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

PR Review: Retryable Bolt conflicts + nested $param.field in MATCH patterns

Reviewed both fixes against CLAUDE.md conventions. Overall this is a clean, well-tested pair of root-cause fixes. Nice work on the hardening iterations (immutable empty result, bounded cause walk, FINE-level logging, end-to-end IT). Findings below, none blocking.

Correctness - verified sound

  • isRetryableConflict hierarchy is correct: both ConcurrentModificationException and LockTimeoutException extend NeedRetryException, so the single instanceof NeedRetryException check covers both, and the depth cap (32) guards the cyclic-cause case (test cyclicCauseChainTerminatesAndKeepsDefault confirms).
  • Scan-path change in matchesProperties is behavior-preserving for the no-row case: when a genuinely row-dependent expression evaluates to null against EMPTY_RESULT, the downstream comparison (actualValue.equals(null) -> false -> return false) yields no match - exactly the previous outcome. Only parameter-resolvable expressions now bind, which is the intended fix. Same for the index path, which falls back to scan on a null resolution.
  • EMPTY_RESULT backed by Collections.emptyMap() is a good call - immutable, so the shared static can't be mutated across concurrent queries, and any accidental write fails fast. Matches the concurrency guidance in CLAUDE.md.

Minor suggestions (non-blocking)

  1. DRY in RUN/PULL handlers. The RUN and PULL paths inline isRetryableConflict(e) + a ternary to pick the code, while COMMIT uses the classifyExecutionError(e, default) helper. RUN/PULL need the boolean for the log level, so that's justified - but the code selection could still reuse the helper:
    ```java
    final boolean retryable = isRetryableConflict(e);
    LogManager.instance().log(this, retryable ? Level.FINE : Level.WARNING, "BOLT query error", e);
    sendFailure(classifyExecutionError(e, BoltErrorCodes.DATABASE_ERROR), errorMsg);
    ```
    Keeps the walk in one place. Purely cosmetic.

  2. Transient-error retry semantics. Mapping MVCC conflicts to Neo.TransientError.Transaction.DeadlockDetected means the neo4j driver auto-retries with backoff up to its max-retry-time (default 30s). If a permanent failure ever wrapped a NeedRetryException deep in its cause chain, it would be retried until timeout rather than failing fast. Unlikely given ArcadeDB's exception wrapping, and the tradeoff is right for the graphiti use case - just worth being aware of.

  3. Naming. TRANSIENT_CONFLICT_ERROR reuses the Neo4j deadlock title for an optimistic-concurrency conflict. The extensive comment justifies it (it's the retryable TransientError classification the drivers honor, avoiding the two excluded titles Transaction.Terminated / Transaction.LockClientStopped), so this is fine - the comment carries the reasoning.

Tests - good coverage

  • BoltErrorClassificationTest (classification, wrapped cause, cyclic guard, log-level demotion, code-family assertions) and NestedParameterInMatchTest (scan path, index path, full graphiti MERGE edge-save shape) both target the exact regressions.
  • The ...IT end-to-end Bolt test makes the conflict deterministic via ordered explicit-transaction commits rather than a race - good, avoids a flaky test and locks in the wire-level contract the graphiti managed-transaction retry depends on.
  • NestedParameterInMatchTest.withDatabase uses a nanoTime()-unique path and drops in finally - no leakage.

Style / conventions

  • final on params/locals, single-statement if without braces, imports (not FQNs), Apache headers, AssertJ assertThat(...) - all consistent with CLAUDE.md. The IT is correctly suffixed IT so it runs in the integration phase, not the regular unit build.

LGTM.

…/PULL handlers

Keep isRetryableConflict(e) only for the FINE/WARNING log level and let classifyExecutionError(e,
default) own the error-code mapping, so the retryable->transient-code selection lives in one place
instead of being inlined. Cosmetic; error-path only.
@lvca

lvca commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Addressed in 7f8329cdc:

DRY in RUN/PULL handlers ✅
Both handlers now keep isRetryableConflict(e) only for the FINE/WARNING log level and delegate the error-code selection to classifyExecutionError(e, BoltErrorCodes.DATABASE_ERROR), so the retryable→transient-code mapping lives in one place (the extra bounded cause-walk is error-path only and negligible).

On the two awareness notes - both left as-is, agreed:

  • Retry semantics - mapping to a TransientError means the driver retries with backoff up to its max-retry-time (~30s default). That's the intended tradeoff for the graphiti managed-transaction use case; a permanent failure wrapping a NeedRetryException deep in its cause chain is not something ArcadeDB produces (these exceptions are terminal conflict signals, not wrappers around permanent errors).
  • Naming - TRANSIENT_CONFLICT_ERROR reuses the Neo4j deadlock title deliberately: it's the retryable TransientError classification the drivers honor while avoiding the two excluded titles. The constant's comment carries that reasoning.

Bolt unit suite green (231) and the MVCC-conflict IT still deterministic.

@codacy-production

codacy-production Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 minor

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
CodeStyle 1 minor

View in Codacy

🟢 Coverage 47.06% diff coverage · -36.59% coverage variation

Metric Results
Coverage variation ✅ -36.59% coverage variation
Diff coverage ✅ 47.06% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (7ef8ab3) 134059 100164 74.72%
Head commit (7f8329c) 165898 (+31839) 63249 (-36915) 38.13% (-36.59%)

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 (#4910) 17 8 47.06%

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 Jul 3, 2026

Copy link
Copy Markdown

Review

Nice, tightly-scoped pair of root-cause fixes. Both are backed by regression tests, the comments explain the why (not just the what), and the iterative hardening across commits (immutable empty result, bounded cause-walk, log-level demotion) shows good attention to review feedback. I verified the exception hierarchy (ConcurrentModificationException/LockTimeoutException both extend NeedRetryException) and the ResultInternal(Map) constructor stores the map by reference, so the Collections.emptyMap() backing does fail fast on accidental writes as the comment claims. LGTM overall - a few minor, non-blocking observations below.

#4908 - Bolt transient conflicts

  • Correctness: isRetryableConflict walking the full cause chain (bounded at 32) is the right call - conflicts are frequently wrapped by the command layer, and the needRetryWrappedAsCause + cyclicCauseChain tests cover both the wrapping and the pathological cycle. Good.
  • Minor - double walk: In handleRun/handlePull, the cause chain is now traversed twice per error (once for isRetryableConflict(e) to pick the log level, once inside classifyExecutionError). This is error-path only and negligible, but a single final boolean retryable = isRetryableConflict(e); reused for both the level and retryable ? TRANSIENT_CONFLICT_ERROR : default would avoid it and read a touch cleaner.
  • Minor - magic number: The depth cap 32 is inline. Consider a named constant (e.g. MAX_CAUSE_CHAIN_DEPTH) to document intent, though 32 is generously safe for real chains.
  • Scope note (good that it's documented): Conflicts that arise outside the RUN/PULL/COMMIT handlers still fall through the top-level dispatch catch as a non-retryable DATABASE_ERROR. The added comment makes this intentional and it is a reasonable boundary, since those are the paths where MVCC conflicts actually surface for a managed transaction.
  • IT nit: In Bolt4908TransientConflictIT, tx1 is committed but never explicitly closed - it relies on s1.close() in the finally. Harmless, but closing it (or a try-with-resources) would mirror the care taken with tx2. The deterministic two-transaction ordering to force the conflict is a good way to avoid a racy test.

#4909 - Nested $param.field in MATCH patterns

  • Correctness: The fix is sound. I traced matchesProperties: when a row-dependent expression cannot resolve against EMPTY_RESULT and returns null, the subsequent actualValue == null / !actualValue.equals(null) logic still yields no match - identical to the prior "left as unevaluated Expression" behavior, so no false positives are introduced. The index path correctly falls back to scan on a null resolution.
  • Concurrency: Sharing one static immutable EMPTY_RESULT across concurrent queries is fine given it is read-only; backing it with Collections.emptyMap() for fail-fast is a nice touch.
  • Test coverage: Scan path, index path, and the real graphiti MATCH ... MERGE edge-save shape are all covered - the index-path test explicitly creates the index to exercise tryFindAndUseIndex, which is exactly the branch that changed. One gap: there is no direct test for the row-dependent expression with no input row falls back to scan branch (the else { evaluate...; if null return null } path). It is exercised indirectly by the broader suite, but a small targeted test would lock in that fallback.

Style / conventions

  • Imports use simple names, final is applied consistently, comments are substantive, and tests use the preferred assertThat(...) AssertJ style per CLAUDE.md. No System.out, no new dependencies. Good adherence.

Nothing here blocks merge.

@lvca
lvca merged commit 23962d1 into main Jul 3, 2026
20 of 25 checks passed
@lvca
lvca deleted the fix/4908-4909-bolt-retry-nested-param branch July 3, 2026 18:26
@lvca lvca added this to the 26.8.1 milestone Jul 3, 2026
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…ATCH patterns (#4910)

* fix(#4908): map optimistic-concurrency conflicts to a Neo4j transient Bolt error so drivers auto-retry

ArcadeDB's MVCC page-version conflicts surfaced to Bolt clients as non-retryable errors
(DATABASE_ERROR for query execution, ClientError TransactionNotFound on commit), so a
managed-transaction driver never auto-retried even though ArcadeDB asks the client to retry.

- BoltErrorCodes: add TRANSIENT_CONFLICT_ERROR (Neo.TransientError.Transaction.DeadlockDetected),
  a TransientError classification drivers retry on, avoiding the two excluded titles.
- BoltNetworkExecutor.classifyExecutionError(): classify any NeedRetryException in the cause chain
  (ConcurrentModificationException / LockTimeoutException) to the transient code; applied in the
  RUN, PULL and COMMIT error paths.
- Test: BoltErrorClassificationTest covers conflict/lock-timeout/wrapped-cause -> transient and
  generic -> default.

* fix(#4909): resolve nested parameter field access ($param.field) in MATCH pattern property maps

A pattern property like {uuid: $edge_data.source_uuid} parses to a ChainedPropertyAccessExpression
whose base ParameterExpression resolves from the context alone, but MatchNodeStep only evaluated
pattern-property expressions when an input row was present. With a bare MATCH (no row) the expression
was left unevaluated, so the pattern silently matched nothing (breaking single edge saves in the
graphiti driver).

- MatchNodeStep: evaluate pattern-property expressions with a shared read-only empty result when
  there is no input row, in both the scan path (matchesProperties) and the index path
  (tryFindAndUseIndex); parameter-based expressions resolve, row-dependent ones return null and
  fall back to scan.
- Test: NestedParameterInMatchTest covers scan path, index path, and the graphiti edge-save shape.

* review(#4908,#4909): harden shared empty result, bound cause-walk, preserve index behavior

- MatchNodeStep: back EMPTY_RESULT with an immutable map (new ResultInternal(Collections.emptyMap()))
  so any accidental write fails fast instead of racing shared state across concurrent queries; restore
  the exact previous behavior of the index path when an input row is present (only the no-row case
  gets the null -> fall-back-to-scan handling).
- BoltNetworkExecutor.classifyExecutionError(): bound the cause-chain walk (depth cap) so a cyclic
  getCause() cannot spin forever; add a cyclic-cause regression test.
- NestedParameterInMatchTest: import Consumer instead of the fully-qualified name (also the sole
  Codacy CodeStyle finding).

* review(#4908): log expected MVCC conflicts at FINE, note top-level catch is intentional

- BoltNetworkExecutor: extract isRetryableConflict() and, in the RUN/PULL handlers, log a
  NeedRetryException at FINE instead of WARNING - MVCC conflicts are expected under contention and
  auto-retried by the driver, so they should not flood WARNING with recoverable flow; genuine errors
  stay at WARNING.
- Add a comment at the top-level dispatch catch clarifying that NeedRetry classification is handled
  in the per-message RUN/PULL/COMMIT handlers, so the generic DATABASE_ERROR fallback is intentional.
- Test: cover isRetryableConflict directly (drives both classification and log-level demotion).

* review(#4908): fix import ordering, add end-to-end Bolt MVCC-conflict wire test

- BoltNetworkExecutor: move the NeedRetryException import into alphabetical position (after
  exception.CommandParsingException) instead of ahead of the bolt.message block.
- Bolt4908TransientConflictIT: end-to-end wire test that a real optimistic-concurrency conflict over
  Bolt surfaces to the neo4j driver as a TransientException with code
  Neo.TransientError.Transaction.DeadlockDetected. Made deterministic (not racy) via two explicit
  transactions modifying the same record with ordered commits: the first commit bumps the page
  version, the second detects the stale version and conflicts. Locks in the wire-level contract the
  graphiti driver's managed-transaction retry depends on.

* review(#4908): reuse classifyExecutionError for code selection in RUN/PULL handlers

Keep isRetryableConflict(e) only for the FINE/WARNING log level and let classifyExecutionError(e,
default) own the error-code mapping, so the retryable->transient-code selection lives in one place
instead of being inlined. Cosmetic; error-path only.

(cherry picked from commit 23962d1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant