[bugfix] PathExpr: iterate atomic, context-independent RHS per-item (closes #798)#6418
Merged
duncdrum merged 1 commit intoMay 30, 2026
Conversation
…loses eXist-db#798) XPath 3.1 §3.3.5 (Path Operator) mandates that in E1/E2, E2 is evaluated for *each* item in E1's result — even when E2 doesn't reference the context. PathExpr.eval's persistent-input fast-path short-circuited that iteration for context-independent RHS, on the assumption that node-axis RHS gets de-duplicated by the post-step removeDuplicates() call so a single eval was equivalent. The assumption doesn't hold for *atomic*-returning RHS: there's no de-duplication for atomics, the literal value is the same every iteration, and the missing iterations are exactly what's needed to preserve multiplicity. Reproducer from the 2015 issue, restated by @line-o today: let $data := <a><b/><b/></a> let $doc := xmldb:store('/db', 'test.xml', $data) return [ $data//b/3, doc('/db/test.xml')//b/3 ] Before this commit: `[(3, 3), 3]` — in-memory iterates per item, persistent collapses to a single 3. After this commit: `[(3, 3), (3, 3)]` — both forms iterate, multiplicity preserved on both code paths. The fix adds three guards to the existing iterate/single-eval condition in PathExpr.eval (~line 287): - stepReturnsNonNode: only kicks in when the RHS step's static return type isn't a node, so node-axis fast-paths (the perf-sensitive ones) keep their persistent shortcut. - stepIsContextIndependent: only fires for steps that declare no CONTEXT_ITEM / CONTEXT_POSITION / CONTEXT_SET dependency. Steps with real context dependencies already take the existing iterate branch (e.g. matches(., "regex")) — or, in the index-optimised Predicate.selectByNodeSet path, are intentionally evaluated once against the full node-set; we must not force iteration there. - stepIdx > 0: only applies to RHS positions, not first steps. The parser wraps a bare atomic expression (e.g. the literal pattern arg of matches(SPEAKER, '^HAM.*')) in a single-step PathExpr whose currentContext is the surrounding node-set. Without this guard we'd iterate that wrapper's literal over the outer context and produce a many-item argument where one was expected. Each guard was added because the broader form broke real callers in mvn test (DateTests cardinality errors on date predicates, OptimizerTest regex-predicate index optimisation, ValueIndexTest string-function index optimisation). With all three guards in place those callers pass. Verification: - PathExprAtomicRhsTest: 4 regression tests (issue reproducer, reverse order check, node-RHS dedup sanity). - xquery.xquery3.XQuery3Tests: 1030/1030 pass. - exist-core mvn test: 3 unrelated infrastructure flakes (GetXMLResourceNoLockTest port-bind from another container, RecoverBinary2Test storage flake, EvalWebSocketEndpointTest 30s WebSocket timeout). None touch PathExpr or path semantics; all pre-existing. - XQTS HEAD before/after on 26,014 tests both runs measured: zero per-test transitions (23,405 pass / 1,427 fail / 128 error / 1,054 skip on both sides). Headline numbers differ only because different test sets hit the batched-runner timeout each run. Closes eXist-db#798 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
duncdrum
approved these changes
May 30, 2026
Contributor
|
I like the Design instruction 😉 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[This response was co-authored with Claude Code. -Joe]
Summary
XPath 3.1 §3.3.5 (Path Operator) requires that in
E1/E2, the right-hand stepE2is evaluated once for each item produced byE1— even whenE2doesn't reference the context. eXist'sPathExpr.evalshort-circuited that iteration for context-independent atomic-returning right-hand sides over persistent inputs, collapsing the multiplicity.The bug has been open since 2015 (#798); @line-o confirmed it still reproduces on develop earlier today.
Closes #798.
Reproducer (from #798, restated by @line-o in the latest comment)
Before this PR:
[(3, 3), 3]— in-memory iterates per item (2 results), persistent collapses (1 result).After this PR:
[(3, 3), (3, 3)]— both forms iterate, multiplicity preserved.Root cause
PathExpr.eval(~line 261) chooses between per-item iteration of E2 and a single evaluation. In-memory inputs always iterate (inMemProcessingflag). Persistent inputs only iterate when E2 declares aCONTEXT_ITEMorCONTEXT_POSITIONdependency.The single-eval shortcut is sound when E2 returns nodes: the post-step
result.removeDuplicates()at line ~313 absorbs the missing multiplicity, since each iteration would produce the same node-set. It's not sound when E2 returns atomics — there's no de-duplication step, the literal value is the same every iteration, and the missing iterations are exactly what carries the multiplicity required by §3.3.5.Fix
Three guards bolted on to the iterate/single-eval condition. Each one was needed because a broader form broke real callers — the test suite drove the design.
stepReturnsNonNodestepIsContextIndependent(noCONTEXT_ITEM/CONTEXT_POSITION/CONTEXT_SET)Predicate.selectByNodeSetpath — are intentionally single-evaluated against the full node-set. Forcing iteration there broke regex/date-predicate / value-index optimisations inmvn test.stepIdx > 0PathExpr(e.g. the literal pattern arg ofmatches(SPEAKER, '^HAM.*')) would be iterated over the surrounding node-set and produce a many-item arg where one was expected.Test plan
New regression test (
PathExprAtomicRhsTest, 4 cases):inMemoryAtomicRhsIteratesPerItem— baseline ((<a><b/><b/></a>)//b/3→ 2 items)persistentAtomicRhsIteratesPerItem— the bug (doc('…')//b/3→ 2 items, was 1)inMemoryAndPersistentAgree— sequence equality (both"3,3")nodeRhsStillDedupes— sanity that//b/..still dedups to 1 (no regression in node-axis fast-path)All 4 pass. The first 3 fail on develop.
xquery.xquery3.XQuery3Tests: 1030/1030 pass (1 pre-existing skip).Full
mvn test -pl exist-coreon this branch: 3 failures — all unrelated infrastructure flakes confirmed by inspecting each stack:GetXMLResourceNoLockTest—Failed to bind to 0.0.0.0:8088(another Docker container is holding the port on this machine)RecoverBinary2Test.storeAndRead—Collection /db/test/test2 should exist after store(), a long-running flaky storage testEvalWebSocketEndpointTest.cancellation— 30s WebSocket cancellation timeoutNone of the three touch
PathExpror path semantics; the same three fail without this fix on the same machine.Full XQTS HEAD before/after (
exist-xqts-runner --xqts-version HEAD, i.e. the liveqt3testsmaster / final 3.1 Rec + errata — not the older--xqts-version 3.1archive). Per-test comparison on the 26,014 tests both runs actually measured (excluding tests dropped by batch-runner timeout):Zero per-test transitions. The aggregate headlines differ (23,437 → 23,666 pass) only because different test sets hit the batched-runner timeout each run — that's the same measurement noise we've seen in earlier XQTS comparisons.
Related
CONTEXT_ITEMdependency, which was about a node-axis falsely claiming context-independence). This one is about the optimizer's persistent fast-path being wrong for atomic-RHS regardless of dependency.