A QueryNodeProcessorPipeline hands its config to every processor you put on it with add or set. Put one on through listIterator(), subList() or replaceAll() and it gets none: point queries then match nothing, field boosts and the default phrase slop vanish, or parsing throws NullPointerException.
StandardQueryParserparses the text into a tree of query nodes, runs the tree through a pipeline of processors, then builds the LuceneQueryfrom the tree.- The pipeline,
QueryNodeProcessorPipeline, is ajava.util.Listof processors (21 inStandardQueryParser). Each processor reads its settings, such as which fields hold points, field boosts, the default phrase slop or the analyzer, from one shared config object, theQueryConfigHandler. - A processor finds that config through its own
getQueryConfigHandler(); the pipeline is the one that hands it to each processor on it.
set versus replaceAll inside the pipeline, the swapped-in processor finding no config at parse time, expected versus actual for every edit path, then the workaround and the fix. Select the still to open it at full size. Generated with AI, checked against the code.- Configure
StandardQueryParserso fieldnholds int points and fieldfhas boost 2, index one document withIntPoint n = 5, and take the parser's pipeline of 21 processors. - Swap its
PointQueryNodeProcessorfor a fresh one (in real code: your subclass) with the usual loop:if (it.next() instanceof PointQueryNodeProcessor) it.set(new PointQueryNodeProcessor());. - You expect what
pipeline.set(6, p)gives:n:5becomes the point queryn:[5 TO 5]and finds the 1 document. The class says it hands its config to all processors on it, and its ownaddandsetdo. - You get
n:5as aTermQuery: 0 hits, no error. The iterator writes straight into the pipeline's privateArrayList, so the new processor's config staysnulland it skips its work. - The same through
subList(..).set,replaceAllorCollections.replaceAll(from 11 processors, on JDK 25):f:fooloses its boost of 2, the default phrase slop is dropped, and 9 of the 21 stock processors throwNullPointerExceptionat parse.
Opt-in and rare: code that edits a flexible parser pipeline through ListIterator.add/set, subList, replaceAll or Collections.replaceAll, such as StandardQueryParser's own pipeline after a cast, and does not re-set the config afterwards. Swapped this way, 9 of the 21 stock processors throw on the first parse and 4 (point, point range, boost, default slop) fail silently. Every release since 3.1.0.
Mechanism, terms and numbers805 words
QueryNodeProcessorPipeline, the processor chain of the flexible query parser, implements java.util.List and promises to set its QueryConfigHandler "on all processor on pipeline". Its own add, add(int), addAll, addAll(int) and set do that for each processor they put on the pipeline. But listIterator(), listIterator(int) and subList() return the views of its private ArrayList, and replaceAll is not overridden, so a processor put on the pipeline through ListIterator.add/set, subList(..).add/set, List.replaceAll, Collections.replaceAll on a pipeline of 11 or more processors, or Collections.copy of 10 or more processors into it (JDK 25's thresholds) is not given the pipeline's handler: a fresh processor keeps null. Nothing hands it out again before parsing.
Four stock processors check for a null handler and silently do nothing: on StandardQueryParser with n configured as an int point field, a PointQueryNodeProcessor swapped in through listIterator().set leaves n:5 a TermQuery that finds 0 of the 1 matching document, a swapped BoostQueryNodeProcessor drops field boosts, and the point-range and default-slop processors lose their work too. Nine others throw NullPointerException at parse. It is opt-in (only code that edits a pipeline this way), present since 3.1.0, and the workaround is to call setQueryConfigHandler again after editing. The fix wraps the two views so their add and set hand out the handler.
- Pipeline:
QueryNodeProcessorPipeline implements QueryNodeProcessor, List<QueryNodeProcessor>(QueryNodeProcessorPipeline.java:39). It keeps the processors in a privateArrayList(:41) and runs them in order (process,:75-82). It is notRandomAccess. - Config: the
QueryConfigHandler. Class javadoc: "When aQueryConfigHandlerobject is set on aQueryNodeProcessorPipeline, it also takes care of setting thisQueryConfigHandleron all processor on pipeline" (:36-37).setQueryConfigHandlerloops over the processors (:93-99). - Views:
java.util.Listhands out aListIteratorand asubListview that can add and replace elements; theListdefaultreplaceAlland theCollectionshelpers are built on them.
- Every method the pipeline overrides to put a processor on it also hands it the config:
add(:105-113),add(int)(:119-122),addAll(:128-136),addAll(int)(:142-150),set(:268-276):processor.setQueryConfigHandler(this.queryConfig). listIterator()(:220-222),listIterator(int)(:228-230) andsubList(:290-292) return the privateArrayList's own views. Theiraddandsetwrite straight into theArrayList, so the processor keeps the config it had: none, for a fresh one.- The pipeline does not override
replaceAll; JDK 25's default isListIterator li = this.listIterator(); while (li.hasNext()) li.set(operator.apply(li.next()));(List.java:437-443).Collections.replaceAlluses the list iterator on a list that is notRandomAccessand has 11 or more elements,Collections.copywhen it copies 10 or more elements (Collections.java:111-112,:587,:870);StandardQueryParser's pipeline has 21. These thresholds are JDK 25 implementation choices, not part of theListcontract; another JDK may differ.reversed()inherits the gap through its list iterator andreplaceAll;reversed().setcalls the pipeline'ssetand is fine. - Nothing repairs it before the parse:
QueryParserHelper.parsejust callsprocess(QueryParserHelper.java:213-223). OnlysetQueryConfigHandler(:185-192),setQueryNodeProcessor(:100-103) and the helper's constructor (:71-73) hand the config out. Calling a setter such assetPointsConfigMapagain does not help: it only writes into the config. PointQueryNodeProcessor(:65-67),PointRangeQueryNodeProcessor(:61-63),BoostQueryNodeProcessor(:47-49) andDefaultPhraseSlopQueryNodeProcessor(:51-53) checkif (config != null)and otherwise do nothing. Others dereference it, for exampleAnalyzerQueryNodeProcessor.process(:82), and throwNullPointerException.
StandardQueryParser with n as an int point field and field boost 2 on f; one document with IntPoint n = 5. Each of the 21 stock processors was also replaced through 7 paths and checked on 22 queries, against the same edit through set(k, p) or add(k, p).
| edit | query | result | with set(k, p) |
|---|---|---|---|
PointQueryNodeProcessor via listIterator().set | n:5 | TermQuery n:5, 0 hits | n:[5 TO 5], 1 hit |
BoostQueryNodeProcessor via replaceAll or subList(18, 19).set | f:foo | f:foo | (f:foo)^2.0 |
DefaultPhraseSlopQueryNodeProcessor (default slop 3) via a view | "a b" | (f:"a b")^2.0 | (f:"a b"~3)^2.0 |
PointRangeQueryNodeProcessor via a view | n:[1 TO 10] | TermRangeQuery | point range |
AnalyzerQueryNodeProcessor via a view | any | NullPointerException | parses |
listIterator().add(p) on new QueryNodeProcessorPipeline(config) | - | p.getQueryConfigHandler() is null | add(p): config |
Collections.replaceAll on a pipeline of 10 / 11 processors | - | handler set / null | - |
Per view path, 4 processors go silent (point, point range, default slop, boost), 9 throw NullPointerException (wildcard, fuzzy, regexp, term range, leading wildcard, analyzer, boolean modifier, multi-term rewrite, interval; 4 of them on every query, 5 only on some query kinds) and 8 give the same result on the 22 queries. In total 147 cases: 69 equal, 24 silent, 54 loud. The list contents are always right; only the config is missing.
Exposure and likelihood305 words
- Who is exposed. Code that customises a flexible query parser pipeline through these paths: editing
StandardQueryParser's pipeline in place after a cast ofgetQueryNodeProcessor()(the in-tree idiom inTestQPHelper.java:163-167, and the advice in java-user threads of 2010 and 2014, which useadd), or a pipeline built withnew QueryNodeProcessorPipeline(config)and used directly (likeTestSpanQueryParserSimpleSample.java:98-103, which usesadd). The package docs invite this ("select your favorite query processors and build a pipeline",standard/package-info.java:55-58), and LUCENE-1937 added theListAPI "if you want to extend or modify an existing pipeline". - Natural idioms reach it. "Insert my processor after X" is the
ListIterator.addloop; "swap X for my subclass" isreplaceAll(p -> p instanceof X ? new MyX() : p)orCollections.replaceAll(pipeline, old, new MyX()). - Not affected. An unedited parser; edits through the pipeline's
add,add(int),addAll,set,addFirst/addLast; moves and removals (Collections.swap,reverse,rotate,shuffle,sort,remove);Collections.replaceAllon 10 or fewer processors; a pipeline edited before it is installed withsetQueryNodeProcessoror the parser helper's constructor, which hand the config to every processor. - Loud vs silent. In the stock differential (each of the 21 stock processors swapped through the 6 view paths, 22 queries), 9 processors give a
NullPointerExceptionon the first parse that reaches them and 8 give the same result. Swapping the other 4, the point, point-range, boost or default-slop processor, or inserting a custom processor that tolerates a missing config, gives silently wrong queries: point queries that match nothing, lost boosts (ranking changes), lost default slop (fewer phrase matches). - Scale. None: one edit and one query reproduce it, with assertions on or off.
- Workaround. After editing, call
qp.setQueryConfigHandler(qp.getQueryConfigHandler()),pipeline.setQueryConfigHandler(...)orqp.setQueryNodeProcessor(pipeline), or edit withadd(k, p)/set(k, p).
The test shows that a processor put on a QueryNodeProcessorPipeline through its listIterator() (here replacing StandardQueryParser's PointQueryNodeProcessor with ListIterator.set) never gets the pipeline's QueryConfigHandler, unlike pipeline.set(k, p).
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.queryparser.flexible.core.processors;
import java.text.NumberFormat;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;
import org.apache.lucene.queryparser.flexible.standard.StandardQueryParser;
import org.apache.lucene.queryparser.flexible.standard.config.PointsConfig;
import org.apache.lucene.queryparser.flexible.standard.processors.PointQueryNodeProcessor;
import org.apache.lucene.search.PointRangeQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestPipelineListIteratorSetConfigHandler extends LuceneTestCase {
public void testProcessorSetThroughListIteratorGetsConfigHandler() throws Exception {
StandardQueryParser parser = new StandardQueryParser();
parser.setPointsConfigMap(
Map.of("n", new PointsConfig(NumberFormat.getIntegerInstance(Locale.ROOT), Integer.class)));
ListIterator<QueryNodeProcessor> it =
((QueryNodeProcessorPipeline) parser.getQueryNodeProcessor()).listIterator();
while ((it.next() instanceof PointQueryNodeProcessor) == false) {}
it.set(new PointQueryNodeProcessor());
Query q = parser.parse("n:5", "f");
// expected: point query, as with pipeline.set(k, p); actual: TermQuery (p has no config)
assertTrue(
"expected a PointRangeQuery, got " + q.getClass().getName() + " " + q,
q instanceof PointRangeQuery);
}
}
# in a copy of this finding's repro folder (repro/LFV-966/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/facet/build/libs/lucene-facet-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:$LUCENE/lucene/queryparser/build/libs/lucene-queryparser-$V.jar
CP=$CP:$LUCENE/lucene/sandbox/build/libs/lucene-sandbox-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.queryparser.flexible.core.processors.TestPipelineListIteratorSetConfigHandler
# the module(s) facet also need their third-party runtime jars
- Expected vs actual
- Expected
n:5(a field with a PointsConfig) to parse to a PointRangeQuery; actual:TermQuery n:5(expected a PointRangeQuery, got org.apache.lucene.search.TermQuery n:5), with-eaand-da.
More files, environment, full output35 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected a PointRangeQuery, got org.apache.lucene.search.TermQuery n:5
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.assertTrue(Assert.java:42)
org.apache.lucene.queryparser.flexible.core.processors.TestPipelineListIteratorSetConfigHandler.testProcessorSetThroughListIteratorGetsConfigHandler(TestPipelineListIteratorSetConfigHandler.java:42)- The class promises its config "on all processor on pipeline"; its own five insert and replace methods and the old
addProcessorkeep that. add(k, p)andlistIterator(k).add(p)are the sameListoperation yet parse differently;Collections.replaceAlldiffers between 10 and 11 processors.- LUCENE-1937 added the
ListAPI so users could edit a pipeline, for example insert after a processor.
The full argument225 words
- The class states the rule and keeps it everywhere else. The javadoc promises the pipeline "takes care of setting this QueryConfigHandler on all processor on pipeline" (
QueryNodeProcessorPipeline.java:36-37). Each of the five overriding mutators that put a processor on it callsprocessor.setQueryConfigHandler(this.queryConfig)(:105-150,:268-276), and so did the original single entry point,addProcessor, before LUCENE-1937. The views are the only way in that skips it; no exemption for them was found in the checked source and javadocs, tests, CHANGES/MIGRATE or prior-art searches. - Same operation, different result.
java.utiltreatsadd(k, p)andlistIterator(k).add(p), orset(k, p)andlistIterator().set(p), as the same element operation; here they parse differently.Collections.replaceAllsets the config on a pipeline of 10 processors and not on one of 11. - The API exists for this edit. #3012 (LUCENE-1937): the pipeline was "not very flexible when the user wants to extend/modify an already created pipeline", and the proposed method was
insertAfter(processor, newProcessor), theListIterator.addidiom. CHANGES 3.1: "QueryNodeProcessorPipeline now implements the List interface, this is useful if you want to extend or modify an existing pipeline." - Tests. No queryparser test uses
listIterator,subListorreplaceAllon a pipeline; in-tree edits useadd(TestQPHelper.java:163-167,TestSpanQueryParser.java:102-111,TestSpanQueryParserSimpleSample.java:98-103). No CHANGES or MIGRATE entry mentions the views. With the fix every upstream queryparser suite passes.
The devil's-advocate check217 words
The strongest case for "intended" (built by the devil's-advocate verifier; the cross-model reviews rated it weak): read literally, the javadoc promises propagation only "when a QueryConfigHandler object is set on" the pipeline, not on each insertion; the mutators' javadocs are bare @see List#add and promise nothing; QueryNodeProcessor says the handler "should be set using ... setQueryConfigHandler(QueryConfigHandler) before ... process(QueryNode) is invoked" (QueryNodeProcessor.java:32-34), which puts the duty on the caller; java.util.List does not require views to go through overridden methods; editing StandardQueryParser's pipeline needs a cast; stock processors tolerate a missing config on purpose; and the workaround is one call.
Why it fails: here the pipeline has a config and every neighbouring processor carries it, and the processor arrives through the pipeline's own public List API, so the pipeline is the caller that owes it the config, as its five mutators and addProcessor show. The same failure happens on new QueryNodeProcessorPipeline(config) with no cast. The null tolerance is meant for a pipeline without a config; here it turns a missing config into silently wrong queries. Calling the documented setters again does not repair it, only re-setting the handler does, and none of the checked documentation says so. A maintainer could choose to document a limitation (fix option D), but that is not evidence of intent.
Recommended Wrap listIterator() and subList() so their add and set hand out the config
Wrap the two views: listIterator(int) and subList return thin wrappers around the backing list's own views whose add and set also call processor.setQueryConfigHandler(queryConfig), so they stay fail-fast. The repro and the whole queryparser module (538 tests, 0 failures) pass, with assertions on and off.
@Override
public ListIterator<QueryNodeProcessor> listIterator(int index) {
return new ConfiguringListIterator(this.processors.listIterator(index));
}
@Override
public List<QueryNodeProcessor> subList(int fromIndex, int toIndex) {
return new ConfiguringSubList(this.processors.subList(fromIndex, toIndex));
}
/** Sets this pipeline's handler on a processor that a view put on the pipeline. */
private void configure(QueryNodeProcessor processor) {
processor.setQueryConfigHandler(this.queryConfig);
}All options and trade-offs4 options · 843 words
Option A was written and validated for this doc with gradle on 645d4fe14a20. B is the smallest change and was run against the repro only; C is the hunt's earlier sketch and its suggested variant; D is listed because a reviewer may ask. Add the new test with whichever is chosen. Users can work around it today by calling setQueryConfigHandler or setQueryNodeProcessor again after editing.
Wrap listIterator() and subList() so their add and set hand out the config (validated by the checks) (recommended)
listIterator(int) returns a ConfiguringListIterator around this.processors.listIterator(index): it forwards every method and does in.set(p); configure(p); and in.add(p); configure(p);. subList returns a ConfiguringSubList extends AbstractList<QueryNodeProcessor> implements RandomAccess around this.processors.subList(from, to): get and size forward; set, add(int, p) and remove(int) call the backing sub list, plus configure(p) for set and add and modCount++ for add and remove, so AbstractList builds its iterators, nested sub lists, addAll and replaceAll on them. listIterator() delegates to listIterator(0); replaceAll and the Collections helpers are fixed through it. Because both wrappers delegate to the ArrayList's own views, a stale view still throws ConcurrentModificationException after a direct pipeline.add or remove.
Full diff: +119/-3 in QueryNodeProcessorPipeline.java.
@Override
public ListIterator<QueryNodeProcessor> listIterator(int index) {
return new ConfiguringListIterator(this.processors.listIterator(index));
}
@Override
public List<QueryNodeProcessor> subList(int fromIndex, int toIndex) {
return new ConfiguringSubList(this.processors.subList(fromIndex, toIndex));
}
/** Sets this pipeline's handler on a processor that a view put on the pipeline. */
private void configure(QueryNodeProcessor processor) {
processor.setQueryConfigHandler(this.queryConfig);
}Trade-offs
- About 120 lines of plain delegation (two private nested classes).
- The view's
sethands out the config even when it writes the same processor back (the pipeline's ownsetskips that case), soList.sort, which rewrites every element throughlistIterator().set, re-sets each processor's config to the pipeline's. That only matters to code that gave one processor a different config on purpose. - Like today, insertions through a view do not go through a subclass's override of
addorset. - The sub list stays
RandomAccess, asArrayList's sub list is.
What the checks validated
- Scratch worktree at
645d4fe14a20, gradle, JDK 25: the new test fails 4 of 5 on stock (assertions on and off) and passes 5 of 5 with the fix; the whole:lucene:queryparser:testpasses with assertions on (seed 5EED0966) and off (seed 5EED0967): 31 suites, 538 tests, 0 failures, 1 skipped (the 30 upstream suites includeTestQPHelper,TestStandardQP,TestMultiFieldQPHelper,TestPointQueryParser,TestPrecedenceQueryParser,TestSpanQueryParserandTestSpanQueryParserSimpleSample).:lucene:queryparser:check -x test(checkGoogleJavaFormat,ecjLint,forbiddenApis, the regenerate checksum checks) andrenderJavadocpass. - With the fixed class shadowed in the standalone runner: the finder's test 2 of 2, the independent reproducer's 9 of 9 and its fail-fast test 2 of 2 (a stale
listIterator()andsubList()still throwConcurrentModificationException); the model-based test: 0 of 63 operations break the config invariant, 0 content mismatches (10,125 judged sequences, 30,375 with its two controls; its mutant control is still caught); the stock differential: 147 of 147 cases equal the oracle.
Three lines in QueryNodeProcessorPipeline.process (:77-79): before running each processor, set the pipeline's handler on it if it differs.
for (QueryNodeProcessor processor : this.processors) {
if (processor.getQueryConfigHandler() != this.queryConfig) {
processor.setQueryConfigHandler(this.queryConfig);
}
queryTree = processor.process(queryTree);
}Trade-offs
Covers every insertion path, including future ones, with the smallest change. But it overrides a config that code set on one processor on purpose, writes to processors on every parse, and leaves the state wrong between the edit and the first parse: an inserted processor still reports a null config until then.
What the checks validated
Run against the repro only (shadowed, assertions on): the 3 parse tests pass, testListIteratorAddSetsHandler still fails (4 of 5 pass). The module tests were not run for this option.
Route view edits through the pipeline's own add/set/remove, or make the pipeline extend AbstractList (validated by the checks)
The hunt's sketch returns a fresh AbstractList view per call whose set, add and remove call the pipeline's own methods. A variant the verifiers and the audit suggested: let QueryNodeProcessorPipeline extend AbstractList and bump modCount in its own mutators.
Trade-offs
The sketch loses fail-fast: an independent reproducer showed (executed) that a stale listIterator or subList no longer throws after a direct pipeline.add or remove, and the stale sub list silently returns elements. The AbstractList variant would keep fail-fast but changes equals/hashCode from identity (the pipeline overrides neither today) to element equality.
What the checks validated
The sketch passes the finder's and the reproducer's tests, the history probe, the model-based test (0 violations), the stock differential (147 equal) and 150 upstream flexible-parser tests; its fail-fast check fails 2 of 2. The AbstractList variant was not run.
Add a note to the class and view javadocs, and point to the workaround.
Trade-offs
No code change, but it keeps the silent cases and undercuts the List API that LUCENE-1937 added for editing pipelines.
Applications and search servers that customise the flexible parser's pipeline in place should edit it with add/set or re-set the config afterwards; a query string alone cannot trigger it.
A planning probe read the pipeline's contract and tried 14 ways of putting a processor on it: 6 left the processor without the config. A bounded model-based test of 63 List operations against an ArrayList model, and an exhaustive swap of all 21 stock processors, then found the silent cases.
The method in full223 words
Method: an executed contract probe, then bounded-exhaustive model-based testing (registry: non-formal, because the first signal was the probe; the finder tagged it formal). A hunt unit, flexible processor pipeline insertion paths, 2026-10-28.
- Probe (first signal). A round-13 planner read the class javadoc and the overriding mutators and ran 14 insertion paths on the stock main jars: 8 handed the processor the pipeline's config, 6 (
listIteratoradd/set,subListadd/set,replaceAll) left itnull; onStandardQueryParser's pipeline 4 paths threwNullPointerExceptionat parse. - Model-based test.
PipelineMbtchecks ajava.util.ArrayListreference model plus the invariant "every processor on the pipeline carries the pipeline's config" after every step of every sequence of up to 2 of 63List, view, default-method andCollectionsoperations from 3 start pipelines (10,125 judged sequences; 30,375 with its two controls): 0 content mismatches, invariant violations only on 28 view, default-method andCollectionsoperations. Vacuity controls: routed views give 0 violations, a mutantadd(int, p)is caught. - Stock differential. Each of the 21
StandardQueryParserprocessors replaced through 7 paths, 22 queries, oracle = the same edit throughset(k, p)oradd(k, p): 24 silent, 54 loud, 69 equal of 147 cases. - Then a public-API JUnit on the stock main jars, followed by independent reproduction, cross-model reviews, a devil's advocate, an adjudication and an audit (see the checks).
No prior report found: 52 automated and 22 manual searches over GitHub, the mailing lists, the JIRA archive and the web, plus the changed files of all 414 open PRs, on 2026-10-28; nothing new on 2026-10-29. The only issues naming the class have other roots.
Searches5 searches
No report of this defect was found. The issue that added the List API (#3012) never mentions the views or the config; two open issues name the class for other reasons.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (GitHub issues, PRs, commits, downstream repositories, dev@/java-user@/issues@/commits@, JIRA archive, web) | 2026-10-28 | 52 queries from the class, method, processor and symptom terms, plus the finder's earlier 51-query run | 126 and 208 distinct hits, 0 high; medium only on other roots (LUCENE-4109 and LUCENE-5491 in the first run, LUCENE-6865 in the second) |
| GitHub (apache/lucene, apache/lucenenet, apache/solr, Elasticsearch, OpenSearch, all of GitHub) | 2026-10-28 | 22 manual searches: QueryNodeProcessorPipeline, StandardQueryNodeProcessorPipeline, setQueryConfigHandler, getQueryNodeProcessor, processor names, NullPointerException with the handler, items updated in the last 48 h | only #3012, #6554 and #10345 name the class; none is this bug |
| Open apache/lucene PRs | 2026-10-28 | changed-file lists of all 414 open PRs | none touches QueryNodeProcessorPipeline.java, StandardQueryNodeProcessorPipeline.java or QueryParserHelper.java |
| JIRA (all projects) and lists.apache.org | 2026-10-28 | JQL text search (14 items read); dev@, java-user@, issues@ (41 mails, every java-user@ mail read) | usage advice through add() only (java-user 2010-11-19, 2014-02-20) |
| GitHub refresh | 2026-10-29 | issue and PR search for the class and setQueryConfigHandler; every PR created since 2026-10-28; commits on the file | nothing new; last commit on the file 7335eb0ad96 (#15704); upstream main 645d4fe14a20 |
Introduced in 3.1.0 by LUCENE-1937 (6f66858572c, 2010-10-19), which made the pipeline a List with raw views. It fails on every release tried from 3.1.0 to 10.6.0 (branch_10_6) and on main; 2.9.4 and 3.0.3 have no List API. Not fixed upstream.
Versions tested8 rows
Introduced by 6f66858572c (trunk r1024402) and the branch_3x backport 2ab3fc4ba40 (r1024405), both 2010-10-19, "LUCENE-1937: add methods to manipulate QueryNodeProcessorPipeline elements" (#3012; patch by Adriano Crestani, committed by Robert Muir). It made the pipeline a java.util.List, wrote the propagating add/addAll/set overrides, deprecated addProcessor (which set the handler) and returned the backing list's raw views; blame on main still shows those lines from 6f66858572c. 7335eb0ad96 (#15704, 2026-02-26, main only) swapped the backing LinkedList for an ArrayList and kept the raw views (10.5.2 and branch_10_6 still use LinkedList and fail the same way). The silent point-query case exists since 6.0.0 (PointQueryNodeProcessor, LUCENE-7089, same null guard; in 6.0.0 through the branch_6x commit 306d3fe8524); the lost boost since 3.1.0. Not a regression.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | this doc's test: 4 of 5 fail (gradle and the standalone runner, assertions on and off) |
main a9779ef3c193 | affected | finder's JUnit 2 of 2 fail; reproducer's 7 of 9; history probe: all 6 view paths drop the boost with a null handler |
branch_10_6 4ae946e5a9b | affected | finder's JUnit 2 of 2 fail; history probe: the same 6 view paths |
| 10.5.2, 9.12.3 | affected | this doc's test 4 of 5 fail; finder's 2 of 2; reproducer's 7 of 9 |
| 10.0.0, 9.1.0 | affected | finder's JUnit 2 of 2 fail (10.0.0 also the probe) |
| 3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0 | affected | history probe: all 6 view paths drop the field boost and leave the handler null; both reference paths are right |
| 2.9.4, 3.0.3 | no List API | the pipeline is not a List (only addProcessor, which sets the handler) |
| main with the fix (option A) | not affected | this doc's test 5 of 5; queryparser module 538 tests pass |
Reproduced by the finder, an independent reproducer with its own test, a devil's advocate and the adjudication; cross-model reviews that could only read code agreed on the root cause. This doc's own test and fix were run with gradle on main. Still open: a cross-model review that executes code.
Checks and verdicts288 words
Each check worked from the claim and the code; the verdicts are in the candidate directory.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-API JUnit fails 2 of 2 on main (assertions on and off), branch_10_6, 10.5.2 and 9.12.3; model-based test and stock differential; fix sketch passes. |
| cross-model review, refute lens | not real (low, procedural) | An independent reviewer from another model family (OpenAI GPT-5.6) that could only read code: it could not run its own repro, so it voted not real on procedure, but it agreed with the root cause and rated the intended-behaviour argument weak; a separate run re-ran the repro (2 of 2 fail). |
| independent reproducer | real | Its own 9-test JUnit fails 7 of 9 on main (assertions on and off), 10.5.2 and 9.12.3; controls pass; found that the finder's fix sketch loses fail-fast. |
| cross-model review, works-as-designed lens | real, medium | Rated the intended-behaviour argument weak; its minimal insert-after main() fails with assertions on and off, its add(int, p) control passes. |
| devil's advocate | real, Low | Built the strongest intended-behaviour case and showed why it fails; the same on a user-built pipeline with no cast; re-setting PointsConfig does not repair it, re-setting the handler does. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test with a new seed (7 of 9 fail, assertions on and off); novelty new. |
| Registration check and audit | keep | Re-runs with new seeds (2 of 2 fail; the fix passes); the audit checked the workaround and the score. |
| this doc | reproduced, fix validated | Own 5-test JUnit on main 645d4fe14a20, 10.5.2 and 9.12.3 (4 of 5 fail); option A validated with gradle (538 queryparser tests, static checks) and the shadowed model checks. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 508 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,351 words
- Registry title
- QueryNodeProcessorPipeline's listIterator()/subList() views, and replaceAll / Collections.replaceAll built on them, put processors on the pipeline without its QueryConfigHandler, so the flexible query parser silently skips point, boost and default phrase slop handling or throws NullPointerException
- Area
- queryparser / flexible core: QueryNodeProcessorPipeline (java.util.List views and default methods)
- Priority
- Low, rank 394: It gives silent wrong flexible-parser queries only when a developer edits a processor pipeline through List views or replaceAll and does not reset the handler, and most such edits fail loudly at the first parse, so it starts Low next to the other opt-in silent rows after LFV-908.
- Impact class
- silent wrong results
- Impact note
- Opt-in only: applications that edit a flexible query parser processor pipeline (their own QueryNodeProcessorPipeline, or StandardQueryParser's through getQueryNodeProcessor()) with ListIterator.add/set, subList(..).add/set, List.replaceAll or Collections.replaceAll/copy (the last two only on pipelines of 11 or more processors; StandardQueryParser's has 21) and do not call setQueryConfigHandler again get silent wrong queries from 4 stock processors (Point, PointRange, Boost, DefaultPhraseSlop: a point query matches nothing, field boosts and the default phrase slop are lost) and a NullPointerException at parse from 9 others; an unedited StandardQueryParser and edits through add/set are not affected; ordinary scale.
- Summary
- You customise a flexible query parser pipeline, for example StandardQueryParser's, by inserting or replacing a processor through the pipeline's java.util.List API: pipeline.listIterator() then add or set, subList(..).set, pipeline.replaceAll or Collections.replaceAll. You expect the processor to receive the pipeline's QueryConfigHandler, as it does with add(k, p) or set(k, p), because the class says it 'takes care of setting this QueryConfigHandler on all processor on pipeline'. Instead those paths write through the private ArrayList's own views and leave the processor with a null handler, so a replaced PointQueryNodeProcessor turns n:5 into a TermQuery that finds 0 of 1 matching documents, a replaced BoostQueryNodeProcessor drops field boosts, and most other stock processors throw NullPointerException at parse.
- Introduced
- 6f66858572c0aa9528e5b1e6999c945db5712fce (trunk r1024402, 'LUCENE-1937: add methods to manipulate QueryNodeProcessorPipeline elements', patch by Adriano Crestani, committed by Robert Muir; JIRA patch workflow, no GitHub PR; migrated as apache/lucene#3012); branch_3x backport 2ab3fc4ba40bcdd239d49bbac1b4d2539198170c (r1024405), the commit released in 3.1.0. It made the pipeline implement java.util.List, with add/add(int)/addAll/addAll(int)/set setting the handler and iterator/listIterator/listIterator(int)/subList returning the backing list's raw views [read: git log -S and git blame in the history clone; exec: 2.9.4 and 3.0.3 lack the List API, 3.1.0 fails]. 7335eb0ad96 (#15704, author date 2026-02-26, main only) swapped the backing LinkedList for an ArrayList and kept the raw views (not a regression, not a fix) (2010-10-19 (author and commit date of both commits; the patch was attached to the issue on 2009-10-13))
- First affected version
- 3.1.0 [exec] (first release with the commit; 2.9.4 and 3.0.3 have no List API on the pipeline [exec])
- Affected versions
- every release from 3.1.0 to 10.6.0 and main. [exec] the version-portable history probe fails on 3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 4ae946e5a9b and main a9779ef3c193 (6 of 6 view paths drop the field boost and leave the handler null; the 2 reference paths are correct) and passes on main with the fix shadowed; the candidate JUnit fails 2 of 2 on 9.1.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main (on main with -ea and -da); the independent reproduction's JUnit fails 7 of 9 on main (-ea and -da), 10.5.2 (-ea and -da) and 9.12.3. [read] the silent point-query symptom exists since 6.0.0 (PointQueryNodeProcessor, LUCENE-7089, with the same null guard) and was executed from 9.1.0; the silent boost drop exists since 3.1.0; List.replaceAll exists only on Java 8 and later JVMs. Not fixed upstream: QueryNodeProcessorPipeline.java last changed by 7335eb0ad96 (#15704); upstream main 645d4fe14a2 (2026-10-28) still returns the raw views. All runs on Linux aarch64 with JDK 25.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal; also reached by exhaustive check
- Upstream and prior reports
- none found (the prior-report record, verdict new, searched 2026-10-28, read-only): a scripted prior-report search (52 queries: 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive, 7 web; 126 distinct hits, 0 high, 2 medium on other roots: LUCENE-4109, LUCENE-5491) plus the finder's earlier run (51 queries, 208 hits, 0 high); 22 manual GitHub searches over apache/lucene, apache/lucenenet, apache/solr, Elasticsearch, OpenSearch and all of GitHub (class, method, processor and NullPointerException terms, plus every queryparser or flexible item updated since 2026-10-26); the changed-file lists of all 414 open apache/lucene PRs (none touches QueryNodeProcessorPipeline.java, StandardQueryNodeProcessorPipeline.java or QueryParserHelper.java); JQL over all projects (14 items read); lists.apache.org (41 mails, all java-user mails read); git history of the file; web and Stack Overflow searches (the StackExchange API was quota-refused and replaced by a site-restricted web query; two GitHub searches hit the rate limit and were re-run). The only apache/lucene items naming the class are #3012 (LUCENE-1937, which added the List methods; no mention of views or the handler), #6554 (LUCENE-5491, open, BoostQueryNodeProcessor with a null field name: other root) and #10345 (LUCENE-9305, QueryNodeImpl.set: other root). Related, not reports: #15704 (kept the raw views), #7923 (LUCENE-6865), #4440 (LUCENE-3367), #3013 (LUCENE-1938), #14168 and two java-user threads (2010, 2014) that show pipeline editing through add(). Refresh 2026-10-28 (GitHub search and REST API, read-only): 'repo:apache/lucene QueryNodeProcessorPipeline' still finds only #6554, #3012 and #10345; none of the open PRs created since 2026-10-26 (#16907..#16936) touches the flexible query parser; no new commit on the file (last 7335eb0ad96, author date 2026-02-26); upstream main head 645d4fe14a2. Gaps: keyword search only; a report of the bare NullPointerException under other words cannot be fully excluded.
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28; tested 2026-10-29) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars. The hunt ran on maina9779ef3c193(the flexible parser sources are identical),branch_10_64ae946e5a9b, 12 releases from 3.1.0 to 10.5.2 with a reflection-only probe and 9.1.0 with the finder's JUnit; 2.9.4 and 3.0.3 have noListAPI on the pipeline. The recommended fix was validated with gradle on645d4fe14a20(the whole queryparser module, assertions on and off, plus its static checks).
- Finding record
a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/processors/QueryNodeProcessorPipeline.java:36-39 (class javadoc and 'implements QueryNodeProcessor, List<QueryNodeProcessor>'), :105-150 and :268-276 (add, add(int), addAll, addAll(int) and set call processor.setQueryConfigHandler(this.queryConfig)), :204-206, :220-230 and :290-292 (iterator, listIterator, listIterator(int) and subList return the private ArrayList's views; replaceAll is not overridden); lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/PointQueryNodeProcessor.java:65-67, PointRangeQueryNodeProcessor.java:61-63, BoostQueryNodeProcessor.java:47-49 and DefaultPhraseSlopQueryNodeProcessor.java:51-53 (if (config != null) guards: silent skip); lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/processors/QueryNodeProcessor.java:31-34 (the handler 'should be set ... before process(QueryNode) is invoked'); lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/package-info.java:55-58 (build your own pipeline) [read by the finder, the independent reproduction, the intended-behaviour check and the registration check]
- The finding in 30 seconds
lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/processors/QueryNodeProcessorPipeline.java:36-37, (at 645d4fe14a20) :39, :41, :75-82, :93-99, :105-150, :204-206, :220-230, :268-276, :290-292 (no replaceAll override, no RandomAccess)lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/QueryParserHelper.java:71-73, (at 645d4fe14a20) :100-103, :185-192, :213-223lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/PointQueryNodeProcessor.java:65-67, (at 645d4fe14a20) PointRangeQueryNodeProcessor.java:61-63, BoostQueryNodeProcessor.java:47-49, DefaultPhraseSlopQueryNodeProcessor.java:51-53, AnalyzerQueryNodeProcessor.java:82, StandardQueryNodeProcessorPipeline.java:45-69 (21 processors); standard/StandardQueryParser.java:235-241, :399-401JDK 25.0.3 sources (lib/src.zip): java/util/List.java:437-443 (replaceAll default); java/util/Collections.java:109-112 (thresholds), :552, :587, :870
- Who is affected
lucene/queryparser/src/test/org/apache/lucene/queryparser/flexible/standard/TestQPHelper.java:163-167; (at 645d4fe14a20) flexible/spans/TestSpanQueryParserSimpleSample.java:98-103lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/package-info.java:55-58 (at 645d4fe14a20)
- Why it looks unintended
lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/processors/QueryNodeProcessorPipeline.java:36-37, (at 645d4fe14a20) :105-150, :268-276; lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/processors/QueryNodeProcessor.java:32-34 (at 645d4fe14a20)GitHub, read-only: apache/lucene issue #3012 (LUCENE-1937 description: extend/modify an already created pipeline; insertAfter)lucene/queryparser/src/test/org/apache/lucene/queryparser/flexible/standard/TestQPHelper.java:163-167; (at 645d4fe14a20) flexible/spans/TestSpanQueryParser.java:102-111; flexible/spans/TestSpanQueryParserSimpleSample.java:98-103 (grep: no listIterator/subList/replaceAll in flexible tests)
- Suggested fix
lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/processors/QueryNodeProcessorPipeline.java:1-309 (at 645d4fe14a20) (no equals/hashCode override)
- TestPipelineListIteratorSetConfigHandler.java (JUnit test):
repro/LFV-966/src/org/apache/lucene/queryparser/flexible/core/processors/TestPipelineListIteratorSetConfigHandler.java(46 lines)
A light, step-by-step picture of the repro, made in parallel with this doc from an executed probe on main 645d4fe14a20 (stock, and with the hunt's fix sketch, option C) and the code; it ends on a summary card, which is the PNG.
assets/LFV-966/bug.png(508 KB)
![Diagram of the example: StandardQueryParser's pipeline of 21 processors sharing one QueryConfigHandler. A PointQueryNodeProcessor swapped in with pipeline.set gets the handler, and n:5 parses to the point query n:[5 TO 5] with 1 hit. The same swap through listIterator().set or replaceAll leaves its handler null, so n:5 stays a TermQuery with 0 hits. Re-setting the handler after editing works around it; the fix makes the views hand the handler out.](../assets/LFV-966/bug.png)