Skip to content

[bugfix] Candidate B for #6690 — keep axis duplicate-suppression state per call - #6702

Open
joewiz wants to merge 1 commit into
eXist-db:developfrom
joewiz:bugfix/6690-b-local-dedupe-state
Open

joewiz wants to merge 1 commit into
eXist-db:developfrom
joewiz:bugfix/6690-b-local-dedupe-state

Conversation

@joewiz

@joewiz joewiz commented Sep 13, 2026

Copy link
Copy Markdown
Member

[This PR was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]

This is one of two candidate fixes for #6690. See #6700 for the design question, the trade-offs, and a link to the alternative candidate. Please do not merge this without reading that issue — the two PRs are deliberately competing, not stacked.

Addresses #6690

Summary

An axis step with a name test returned the correct result on its first evaluation and an empty sequence on every evaluation after that, within a single query, whenever the context node carried a predicate context item. Silent — no error, no warning, data loss proportional to loop length.

What Changed

exist-core/src/main/java/org/exist/dom/persistent/NewArrayNodeSet.java

The duplicate-suppression guard in selectPrecedingSiblings, selectFollowingSiblings, selectPreceding and selectFollowing decided whether it had already handled a node by reading a context stamp back off the NodeProxy. Those proxies are shared with LocationStep's cached structural-index result, which is held for a whole query execution — so on the second evaluation the guard was reading stamps left by the first, and suppressed every node.

This change stops the guard reading the proxy. Each call now records which nodes it has stamped and with which context id, in a map local to that call, and the guard consults that instead. Within one call the behavior is identical to before; across calls there is nothing to leak, whatever the caller does with the cache.

Two small private helpers, alreadyStampedInThisCall and recordStamp, keep the four methods readable and the transformation obviously faithful — the recorded value is exactly what the guard used to read off the proxy.

Note the preceding/following guards carry an extra contextId != NO_CONTEXT_ID condition that the sibling ones do not; that asymmetry is preserved at the call sites rather than folded into the helper.

Why not simply remove the guard

Removing it is not an option. With it disabled the new regression test passes, but ten existing tests fail across axes.xql, npt.xqm and positional-nested.xql — including the file the guard's original 2019 commit added its tests to. It suppresses genuine duplicates between different reference nodes within one evaluation. The bug is where that state was stored, not the guard.

Cost

A small map allocated per call when a context id is in play. It is bounded by the number of nodes actually stamped, not by the size of the cached set, so it scales with the result rather than the index — but it is an allocation in a hot path where there was none, and that is a fair thing to weigh against candidate A's smaller footprint. Discussed in #6700.

Test Plan

  • New SiblingAxisRepeatedEvaluationRegressionTest fails on unfixed develop, passes with the fix
  • Evaluates each affected axis four times over the same node; pins the wildcard form, a node reached without a predicate, an unaffected axis, and a non-matching name alongside
  • xquery.CoreTests — the whole src/test/xquery corpus, including the ten tests naive guard removal breaks — green
  • Full mvn test on exist-core green
  • Codacy PMD clean on the changed file
  • license:check clean

Scope

One class, four methods, no behavior change within a single evaluation.

🤖 Generated with Claude Code

An axis step with a name test returned the correct result on its first evaluation and an
empty sequence on every evaluation after that, within a single query, whenever the
context node carried a predicate context item. Silent -- no error, no warning, and data
loss proportional to loop length.

The duplicate-suppression guard in selectPrecedingSiblings, selectFollowingSiblings,
selectPreceding and selectFollowing decided whether it had already handled a node by
reading a context stamp back off the NodeProxy. Those proxies are shared with
LocationStep's cached structural-index result, which is held for a whole query execution,
so on the second evaluation the guard was reading stamps left by the first and suppressed
every node.

Each call now records which nodes it has stamped and with which context id, in a map local
to that call, and the guard consults that instead of the proxy. Within one call the
behavior is identical to before; across calls there is nothing to leak, whatever the
caller does with the cache. The recorded value is exactly what the guard used to read off
the proxy, so the transformation is faithful rather than a reinterpretation.

The preceding/following guards carry an extra contextId != NO_CONTEXT_ID condition that
the sibling ones do not; that asymmetry is preserved at the call sites rather than folded
into the helper.

The guard itself is still needed: with it disabled the new regression test passes, but ten
existing tests fail across axes.xql, npt.xqm and positional-nested.xql. It suppresses
genuine duplicates between different reference nodes within one evaluation. The bug is
where that state was stored, not the guard.

Cost: a small map allocated per call when a context id is in play, bounded by the number
of nodes actually stamped rather than by the size of the cached set. Extracting the
five-clause guard into a named helper also drops PMD NPath complexity in every method it
touches -- selectPrecedingSiblings 27865 to 13285, selectFollowingSiblings 11125 to 5293,
selectFollowing 831 to 471, and selectPreceding below the reporting threshold entirely.

The range index named in the report is not required to trigger this; any predicate
produces the context item that does. Nor is it a regression: both the caching and the
guard are identical in the eXist-6.4.1 tag and current develop.

SiblingAxisRepeatedEvaluationRegressionTest evaluates each affected axis four times over
the same node and asserts stability, pinning the wildcard form, a node reached without a
predicate, an unaffected axis, and a non-matching name alongside. Two of its eight cases
fail on unfixed develop; the preceding:: and following:: cases pass either way and are
pins rather than demonstrations.

Addresses eXist-db#6690

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📊 XQTS result comparison

Comparison of this run against develop.

Warning

70 test cases were recorded in only one of the two runs (0 only in the previous run, 70 only in the current run). The runner's JUnit output is not fully deterministic (see eXist-db/exist-xqts-runner#74), so totals and per-category deltas include recording noise; the newly passing/failing lists count only tests recorded in both runs.

Metric develop this run Change
🟢 Passed 28,780 (90.66%) 28,845 (90.66%) +65 (+0.00 pp)
🔴 Failures 1,564 1,569 +5
➖ Errors 134 134 0
➖ Skipped 1,267 1,267 0
🧪 Total tests 31,745 31,815 +70

Relative to develop: 0 newly passing, 1 newly failing, 0 new errors, 0 newly skipped — counting only tests recorded in both runs whose outcome changed.

🔴 Newly failing tests (1)
  • Constr-inscope-2 (was passing)
⚪ Recorded only in this run (70)
  • raytracer (failing)
  • fo-test-fn-string-005 (failing)
  • fo-test-fn-ends-with-007 (failing)
  • fo-test-fn-element-with-id-002 (failing)
  • tree-queries-results-q2 (passing)
  • functx-fn-year-from-dateTime-1 (passing)
  • fo-test-fn-substring-002 (passing)
  • fo-test-math-sin-004 (passing)
  • fo-test-fn-replace-007 (passing)
  • fo-test-math-asin-001 (passing)
  • fo-test-math-pow-018 (passing)
  • fo-test-fn-tokenize-002 (passing)
  • fo-test-math-sin-003 (passing)
  • fo-test-fn-days-from-duration-003 (passing)
  • fo-test-fn-adjust-time-to-timezone-007 (passing)
  • fo-test-fn-concat-002 (passing)
  • fo-test-math-sin-009 (passing)
  • fo-test-fn-data-004 (passing)
  • fo-test-math-log10-005 (passing)
  • fo-test-math-tan-008 (passing)
  • fo-test-fn-adjust-date-to-timezone-004 (passing)
  • fo-test-fn-max-003 (passing)
  • fo-test-fn-sort-002 (passing)
  • fo-test-math-sqrt-004 (passing)
  • fo-test-fn-min-005 (passing)
  • fo-test-fn-deep-equal-004 (passing)
  • fo-test-fn-starts-with-005 (passing)
  • fo-test-fn-matches-006 (passing)
  • fo-test-fn-avg-002 (passing)
  • fo-test-math-exp-006 (passing)
  • fo-test-fn-insert-before-005 (passing)
  • fo-test-math-tan-001 (passing)
  • fo-test-fn-timezone-from-dateTime-002 (passing)
  • fo-test-fn-round-half-to-even-005 (passing)
  • fo-test-fn-number-002 (passing)
  • fo-test-fn-hours-from-dateTime-003 (passing)
  • fo-test-fn-substring-after-002 (passing)
  • fo-test-array-get-002 (passing)
  • fo-test-math-exp-001 (passing)
  • fo-test-fn-not-003 (passing)
  • fo-test-math-pow-008 (passing)
  • fo-test-fn-abs-002 (passing)
  • fo-test-math-atan-007 (passing)
  • fo-test-fn-min-003 (passing)
  • fo-test-math-pow-034 (passing)
  • fo-test-fn-year-from-date-003 (passing)
  • fo-test-fn-head-003 (passing)
  • fo-test-math-cos-006 (passing)
  • fo-test-fn-tokenize-006 (passing)
  • fo-test-fn-max-001 (passing)
  • … and 20 more

Runtime: 467.3s (+58.32s vs develop).

@duncdrum duncdrum 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.

I prefer this approach compared to option A

@duncdrum duncdrum 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.

Thanks for this fix! I read through the diff and found nothing blocking. Two small suggestions inline.

* @param idx the index into {@link #nodes} of the node just stamped
*/
private void recordStamp(final Map<Integer, Integer> stamped, final int idx) {
if (nodes[idx].getContext() != null) {

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.

Small thought: this records whatever the proxy's context is after the stamping block, not the context id this call actually applied. When contextId == IGNORE_CONTEXT the block is skipped, and for NO_CONTEXT_ID propagatePredicateContextFrom may leave the head context unchanged. In both cases the map could hold a stale id from an earlier evaluation, and a later reference in the same call carrying that id would be suppressed as a duplicate (the #6690 symptom, just narrower).

It looks limited to the NO_CONTEXT_ID path of the sibling selectors, so maybe not a big deal. Recording the contextId that was actually stamped, and only when addContextNode ran, would sidestep it. WDYT?

}

@Test
public void precedingSiblingNameTestIsStableAcrossEvaluations() throws XMLDBException {

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.

Nice coverage of the repeated-evaluation case! These all use a single context node per call, with the context id coming from a predicate. It might be worth adding a case with two context nodes sharing a sibling in one call, e.g. (//a, //b)/following-sibling::c, since that is what the per-call stamped map is there for. A NO_CONTEXT_ID case would also cover the point above.

This branch has not been deployed

No deployments
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.

2 participants