feat(#5000): populate Bolt ResultSummary counters for write queries#5016
Conversation
|
Tick the box to add this pull request to the merge queue (same as
|
There was a problem hiding this comment.
Code Review
This pull request implements Bolt ResultSummary counters for write queries by introducing a QueryStatistics accumulator on CommandContext and exposing it via ResultSet.getStatistics(). It instruments Cypher mutation steps and the DDL path to track CRUD and schema changes, mapping them to the Neo4j Bolt SUCCESS metadata map. The review feedback highlights a potential NullPointerException in OpenCypherQueryEngine if a type name does not exist in the schema, as well as potential overcounting of propertiesSet in SetStep and MergeStep when handling maps with null values for non-existent properties.
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.
| private static boolean indexExistsOnProperties(final Schema schema, final String typeName, final String[] propertyNames) { | ||
| final DocumentType type = schema.getType(typeName); | ||
| return type.getIndexByProperties(propertyNames) != null || type.getPolymorphicIndexByProperties(propertyNames) != null; | ||
| } |
There was a problem hiding this comment.
If typeName does not exist in the schema, schema.getType(typeName) will return null. Calling getIndexByProperties or getPolymorphicIndexByProperties on a null reference will throw a NullPointerException. Add a defensive null check for type before accessing its methods.
private static boolean indexExistsOnProperties(final Schema schema, final String typeName, final String[] propertyNames) {\n final DocumentType type = schema.getType(typeName);\n if (type == null)\n return false;\n return type.getIndexByProperties(propertyNames) != null || type.getPolymorphicIndexByProperties(propertyNames) != null;\n }There was a problem hiding this comment.
Guarded with an existsType check in 46fef7d. Two clarifications on the mechanism: in this codebase LocalSchema.getType(...) throws SchemaException on a missing type rather than returning null, so a type == null check would be dead code; and both callers (executeCreateIndex, executeCreateConstraint) auto-create the type via getOrCreateVertexType/EdgeType before this helper runs, so the path was not reachable. The existsType guard hardens it against a future caller regardless - a missing type now correctly reports "no index".
| // Every map entry mutates a property, whether set (non-null) or removed (null value). | ||
| final QueryStatistics stats = context.getStatistics(); | ||
| stats.addPropertiesSet(map.size()); |
There was a problem hiding this comment.
Using map.size() unconditionally can lead to overcounting if the map contains null values for properties that did not previously exist on the document (since removing a non-existent property is a no-op and should not increment the propertiesSet counter). Consider only counting non-null entries and null entries for properties that actually existed before the merge, similar to how it is handled in the replace-map branch (applyReplaceMap).
There was a problem hiding this comment.
Correct - fixed in 46fef7d. The += branch now counts each non-null assignment and each null-removal only when the property actually existed (mutableDoc.has(...) before remove), matching applyReplaceMap. Added a regression test (SET n += {c:3, b:null, z:null} on a node with b but not z asserts propertiesSet == 2).
| // Every map entry mutates a property, whether set (non-null) or removed (null value). | ||
| context.getStatistics().addPropertiesSet(map.size()); |
There was a problem hiding this comment.
Using map.size() unconditionally can lead to overcounting if the map contains null values for properties that did not previously exist on the document (since removing a non-existent property is a no-op and should not increment the propertiesSet counter). Consider only counting non-null entries and null entries for properties that actually existed before the merge, similar to how it is handled in the replace-map branch.
There was a problem hiding this comment.
Same fix applied to the MERGE_MAP branch in 46fef7d, with a regression test through MERGE ... ON MATCH SET n += {a:null, absent:null, b:2} asserting propertiesSet == 2 (removal of the absent key is not counted).
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 54 |
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.
Review:
|
|
Responses to the review, by item: 1. UNION / CALL 2. 3. DETACH DELETE double-count. Real edge case - 4. Thread-safety. Added a class Javadoc line to 5. 6. Also addressed Gemini's inline findings in 46fef7d (the |
|
Code Review: Bolt ResultSummary counters for write queries (issue 5000) Reviewed the full diff (27 files, engine accumulator + Bolt consumption + conformance/e2e flips). Overall this is a high-quality, well-tested change: clean layering ( A few points worth considering before merge. Correctness / potential bugs
Maintainability
Minor
Tests Coverage is strong: unit ( Net: solid to merge once the null-inline-property counting behavior is confirmed (point 1) and ideally the UNION-write conformance question (point 2) is resolved. Points 3-6 are follow-up-worthy but not blockers. Generated with Claude Code |
|
Second-pass responses: 1. Null-inline property on CREATE. Verified this is already Neo4j-correct: 2. UNION / CALL conformance hole. Confirmed no conformance scenario in 3. Parent-context delegation in 4. Replace-map rule duplicated. After the 5. Thread-safety Javadoc. Added in 46fef7d: "Not thread-safe: a single command context is written by one query thread at a time; counters are plain ints with no synchronization." Confirmed no write path dispatches mutation steps onto the async/parallel pools. 6. |
|
Review: Bolt Reviewed the engine accumulator, the five mutation steps, the DDL path, the Bolt consumption layer, and the tests. Clean, well-scoped, and unusually well-tested. Threading a lazily-allocated Strengths
Observations / minor concerns
Nits
Overall: solid, closes a real conformance gap with genuine end-to-end verification. My only substantive ask is item 1 (verify/cover the DETACH DELETE relationship count); the rest are minor. |
|
Third-pass responses: 1. DETACH DELETE relationship undercount - confirmed and fixed (0333322). You are right: the immediate path ( 2. SET vs MERGE ON MATCH SET on unchanged values - aligned (0333322). The MergeStep PROPERTY branch counted inside the 3. 4. Docs footprint. Intentional and per repo convention (spec/plan docs are committed with feature PRs here).
|
Code Review: PR #5016 - Bolt ResultSummary countersReviewed the engine accumulator, the Cypher mutation/DDL counting, the A few things worth a look: 1. Correctness - counters can double-count on a transaction retry (CreateStep, likely MergeStep)In This is narrow (only when the CREATE opens its own tx and actually retries), but it produces wrong counters exactly in the contended case. 2. CALL { } and UNION write subqueries report zero (documented, but worth confirming the direction)
3. Minor - constraint/index "existed before" edge casesIn PerformanceGood. Primitive SecurityNo new surface. No sensitive data logged. Fine. Test coverageStrong - 21 engine unit cases plus the real-driver NitThe two Nothing here is a blocker except item 1, which is a real (if narrow) correctness bug worth addressing before merge. |
|
Fourth-pass responses: 1. Retry double-count in CreateStep - confirmed and fixed (7886b29). Verified: 2. CALL / UNION zero counters + parent-walk suggestion. Deferred to #5015, where I already captured exactly your suggestion: have 3. Constraint "existed before" edge case (plain index present, then UNIQUE/KEY added -> constraints-added not incremented). Real edge case in the counter semantics; added it to #5015 alongside the NOT_NULL/KEY/TYPED coverage gap. Nit (docs in-tree). Intentional, per repo convention for feature PRs.
|
Review: Bolt
|
|
Fifth-pass responses: 1. Self-loop DETACH DELETE double-count - confirmed and fixed (8b26b0b). Good catch. Nit (contains-updates). Added a note at 2. Read-only Nit (Bolt read-after-close helper) + item 3 (QueryStatistics package). The
|
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 54 |
🟢 Coverage 92.97% diff coverage
Metric Results Coverage variation Report missing for 5ec2aa61 Diff coverage ✅ 92.97% diff coverage Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (5ec2aa6) Report Missing Report Missing Report Missing Head commit (9976879) 166590 111958 67.21% 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 (#5016) 256 238 92.97% 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%1 Codacy didn't receive coverage data for the commit, or there was an error processing the received data. Check your integration for errors and validate that your coverage setup is correct.
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.
|
Code Review: Bolt ResultSummary counters (PR 5016) Reviewed the full diff. This is a well-structured, carefully thought-out change. The design (a lazy, primitive-int accumulator on Findings ranked by importance. 1. Latent fragility: getStatistics() does not follow the parent chain, and copy() drops it (medium)
Today this is safe because every mutation step in a single plan runs against the same context instance passed to syncPull, and the top-level read in Suggestion: have 2. Retry double-count protection is only in CreateStep (low/question)
3. Test coverage gaps (low)
Things done well
Overall: solid, mergeable work. The main thing I would like addressed before merge is a short answer/guard on point 2 (MERGE retry) and ideally hardening point 1 so the accumulator cannot silently disappear across a context boundary later. |
|
Sixth-pass responses: 1. 2. MERGE retry over-count - confirmed safe, no guard needed. Verified: 3a. Retry/restore test. Already present as 3b. Constraint-kind counting tests (NOT NULL / KEY / TYPED, and the no-op
|
Review: Bolt ResultSummary counters for write queries (#5016)Reviewed the engine accumulator, the five Cypher mutation steps, the DDL path, and the Bolt consumption layer. This is a clean, well-scoped implementation with genuinely good attention to Neo4j semantics (no-op removals not counted, distinct-label dedup, replace-map removed-property counting, retry-safe snapshots). The lazy allocation keeps the read hot-path allocation-free, and the tests cover the important cases. Nice work. A few observations, roughly in order of importance. Correctness1. 2. MERGE path-create collision may leave stale counts ( Performance3. Per-record Minor4. 5. TestsCoverage is solid: Overall: approve-worthy pending a look at items 1 and 2, which are the only two with real (if latent/edge-case) correctness impact. Automated review - please verify findings against the code before acting on them. |
Bolt write-summary counters (nodesCreated/relationshipsCreated/propertiesSet) now work end-to-end per #5000. Flip RESULT-004 from expected-fail to a direct assertion in each language suite: unwrap Java's assertExpectedFailure, JS's it.failing, Python's pytest.mark.xfail, Go's assertStillFails, and C#'s KnownGapAssertions.AssertStillFailsAsync wrappers, keeping the same query and counter assertions. Shared gap helpers are untouched since PROTO-002 and CONN-004 still rely on them.
…LL stats limitation REMOVE n.doesNotExist and SET n.absent = null now report properties-set: 0, matching Neo4j, instead of counting a no-op removal. RemoveStep and SetStep now check Document.has(property) before removal and only increment the counter when the property actually existed (MergeStep already had this guard). Also documents, at the top of executeUnion() and executeWithSeedRow(), that write statistics from inside UNION branches and CALL subqueries are not aggregated into the caller's ResultSet.
…rden DDL type lookup SetStep.applyMergeMap and MergeStep's ON CREATE/ON MATCH SET += (MERGE_MAP) branch counted every entry of a `+=` map toward properties-set, including null-valued entries whose key was never present on the document. Only count a null-valued entry when the property actually existed, matching the already-fixed applyPropertySet/applyReplaceMap paths and Neo4j semantics. Also hardens OpenCypherQueryEngine.indexExistsOnProperties against a missing type (schema.getType throws rather than returning null); both current callers auto-create the type first, so this is defensive only. Documents QueryStatistics' single-threaded-per-context contract and clarifies why CreateStep's property counts are exactly the user-set properties. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… unchanged MERGE SET
CreateStep wraps vertex/edge creation in database.transaction(..., true), which auto-retries the block on MVCC/duplicate-key conflicts. The QueryStatistics counter increments performed inside that block were never reset between retries, so a retried standalone CREATE reported inflated counts. Add QueryStatistics.copy()/restore() and snapshot the counters before each transaction attempt in createPatterns/createPatternsBatch, restoring at the top of the lambda so a retry starts from the pre-attempt baseline. MergeStep/SetStep/DeleteStep use manual begin/commit with no retry loop and are unaffected.
ccf0250 to
dcf8577
Compare
Review: Populate Bolt
|
Review: Bolt
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5016 +/- ##
============================================
+ Coverage 65.40% 65.43% +0.02%
+ Complexity 824 823 -1
============================================
Files 1685 1687 +2
Lines 134582 134787 +205
Branches 28797 28825 +28
============================================
+ Hits 88026 88200 +174
- Misses 34493 34509 +16
- Partials 12063 12078 +15 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
… DDL-in-explicit-tx)
PR Review: Bolt ResultSummary counters for write queries (#5016)Reviewed the engine accumulator, the Cypher mutation/DDL wiring, and the Bolt consumption layer. This is a well-engineered, carefully-documented change. The design is thoughtful: lazy allocation so read queries pay nothing, primitive 👍 Strengths
🔧 Suggestions / concerns
Minor
Overall: solid, production-ready feature work. My only actionable ask is #1 (split the unrelated Python/format churn); the rest are small comments-for-clarity nits. Nice attention to the MVCC-retry double-counting trap in particular. 🤖 Generated with Claude Code |
|
Review: Bolt ResultSummary write counters (#5016) Reviewed the engine accumulator, the five Cypher mutation steps, the DDL path, and the Bolt consumption layer against the repo conventions. Overall this is high-quality, carefully-thought-out work - the Neo4j-semantics handling (absent-property removals count 0, replace-map removals counted, distinct-label dedup, self-loop dedup in DETACH DELETE, transaction-retry counter snapshot/restore) is exactly the kind of edge-case coverage that usually gets missed, and the tests reflect it. Comments are excellent, and the allocation-light / lazy-allocation design respects the performance-first mantra (read queries never allocate a QueryStatistics). A few observations, none blocking: 1. InternalResultSet.copy() silently drops the new statistics field (low). 2. Defensive gate in BoltResultStats is correct but fragile by contract (nit). 3. Per-row QueryStatistics.copy() allocation in unbatched CREATE (very minor). 4. Scope: unrelated Python-bindings reformatting bundled in. The diff includes bindings/python/** prettier/formatting-only changes and the e2e container-log-dump helper that are not part of the counters feature. Harmless, but separating pure-formatting churn from feature PRs makes bisecting and reviewing easier. Correctness spot-checks that passed
Tests. Coverage is strong: 21 engine unit cases, the real-driver Bolt5000ResultCountersIT (write, read-no-updates, DISCARD-path), and the RESULT-004 flip across all five language suites. The read-allocates-nothing regression is a good guard for the design intent. Nice work overall - documenting the follow-ups (HTTP/gRPC surfacing, UNION/CALL write aggregation) in-code and tracking them in #5015 is the right call. |
…lution RemoteDatabaseJavaApiIT renames the shared beer DB's Beer type to Birra (ALTER TYPE NAME), which corrupts Beer's edge buckets for later IT classes on the static shared container. RESULT-004's CREATE (:Beer)-[:BREWED_BY]->(:Brewery) then failed with 'Bucket Birra_0_out_edges not found' only when the full suite ran (CI), not when the Bolt IT ran alone (local). Fix: RESULT-004 uses dedicated types (order-independent), and the rename test restores the Beer type name so it no longer leaks.
Code Review: Bolt
|
Closes #5000. Part of #4890 (Group C protocol/type-fidelity gaps), epic #4882 (Bolt Driver Compatibility Certification).
Problem
ResultSummarycounters (nodesCreated,relationshipsCreated,propertiesSet, ...) were never populated for Bolt write queries. Two layers were missing: (1)BoltNetworkExecutorbuilt nostatsmap in the SUCCESS metadata, and (2) the native Cypher engine had no CRUD-counter tracking to source it from.Approach
A reusable, allocation-light engine capability, consumed by the Bolt layer:
QueryStatistics- a mutable accumulator of primitiveintcounters (nodes/relationships created/deleted, properties-set, labels added/removed, indexes/constraints added/removed) withcontainsUpdates().CommandContext.getStatistics()- lazily allocated, so pure read queries (MATCH/RETURN) never create one and take no new hot-path branch.CreateStep,SetStep,DeleteStep,MergeStep,RemoveStep) increment the accumulator with Neo4j-compatible semantics; the DDL path (OpenCypherQueryEngine.executeDDL) counts index/constraint add/remove, guarded soIF [NOT] EXISTSno-ops don't over-count.ResultSet.getStatistics()- a newdefaultmethod mirroringgetExecutionPlan()(zero blast radius on other implementations);CypherExecutionPlan.execute()andexecuteDDL()attach the accumulator to the returned result set.BoltNetworkExecutor-handlePull/handleDiscardread it and emit a Neo4j hyphenatedstatsmap (non-zero counters +contains-updates) on the terminal SUCCESS, only for writes.Counters reflect the statement's work regardless of a later transaction rollback (Neo4j-correct).
Conformance
Flips
RESULT-004inbolt/conformance/spec.yamlfromexpected-failtopassing, and unwraps the expected-fail harness in all five per-language e2e suites (Java, JS, Python, Go, C#) so each asserts the real counters.Testing
QueryStatisticsTest,ResultSetStatisticsTest,CypherQueryStatisticsTest): 21 cases covering CREATE/SET/DELETE/MERGE/REMOVE,SET/REMOVElabel,CREATE/DROP INDEX/CONSTRAINT, distinct-label dedup,SET n = {map}replace semantics, absent-property removal (counts 0), and a read-query-allocates-nothing regression.Bolt5000ResultCountersIT) via the realneo4j-java-driver: assertssummary.counters()for a write and no updates for a read.com.arcadedb.query.opencypherpackage: green.bolt/conformance/validate_spec.py): 39 scenarios OK.Scope / follow-up
Scoped to the engine accumulator + Bolt consumption. Surfacing the same counters over HTTP
/commandand gRPC, aggregating counts across top-levelUNIONwrites andCALL { ... }write subqueries (currently documented in-code as returning zero to the caller), and constraint-kind test coverage are tracked in #5015.🤖 Generated with Claude Code