All findings · Lucene finding · LFV-966 · processor-pipeline-list-views-skip-config-handler

Flexible query parser: a processor put on the pipeline through listIterator(), subList() or replaceAll() gets no config

A processor put on the flexible query parser's QueryNodeProcessorPipeline through listIterator(), subList() or replaceAll() does not get the pipeline's config, unlike with add or set. A fresh PointQueryNodeProcessor swapped in that way makes n:5 silently find 0 of 1 matching documents; 9 of the 21 stock processors throw NullPointerException instead.

Summary

Priority
Low, rank 394It 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
silent wrong results
Since
3.1.0 (2010; LUCENE-1937 made the pipeline a List)introduced 2010-10-19 · over 16 years
Affected
3.1.0 to 10.6.0 and main
Status
documented
Found by
a probe of every insertion path, then a bounded model-based test of the List API

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: how the flexible query parser processes a query

  1. StandardQueryParser parses the text into a tree of query nodes, runs the tree through a pipeline of processors, then builds the Lucene Query from the tree.
  2. The pipeline, QueryNodeProcessorPipeline, is a java.util.List of processors (21 in StandardQueryParser). 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, the QueryConfigHandler.
  3. A processor finds that config through its own getQueryConfigHandler(); the pipeline is the one that hands it to each processor on it.
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.
Step by step: the edit in user code, what should happen, 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.

The finding, step by step

  1. Configure StandardQueryParser so field n holds int points and field f has boost 2, index one document with IntPoint n = 5, and take the parser's pipeline of 21 processors.
  2. Swap its PointQueryNodeProcessor for a fresh one (in real code: your subclass) with the usual loop: if (it.next() instanceof PointQueryNodeProcessor) it.set(new PointQueryNodeProcessor());.
  3. You expect what pipeline.set(6, p) gives: n:5 becomes the point query n:[5 TO 5] and finds the 1 document. The class says it hands its config to all processors on it, and its own add and set do.
  4. You get n:5 as a TermQuery: 0 hits, no error. The iterator writes straight into the pipeline's private ArrayList, so the new processor's config stays null and it skips its work.
  5. The same through subList(..).set, replaceAll or Collections.replaceAll (from 11 processors, on JDK 25): f:foo loses its boost of 2, the default phrase slop is dropped, and 9 of the 21 stock processors throw NullPointerException at parse.

Who is affected

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.

Terms

  • Pipeline: QueryNodeProcessorPipeline implements QueryNodeProcessor, List<QueryNodeProcessor> (QueryNodeProcessorPipeline.java:39). It keeps the processors in a private ArrayList (:41) and runs them in order (process, :75-82). It is not RandomAccess.
  • Config: the QueryConfigHandler. Class javadoc: "When a QueryConfigHandler object is set on a QueryNodeProcessorPipeline, it also takes care of setting this QueryConfigHandler on all processor on pipeline" (:36-37). setQueryConfigHandler loops over the processors (:93-99).
  • Views: java.util.List hands out a ListIterator and a subList view that can add and replace elements; the List default replaceAll and the Collections helpers are built on them.

The bug

  1. 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).
  2. listIterator() (:220-222), listIterator(int) (:228-230) and subList (:290-292) return the private ArrayList's own views. Their add and set write straight into the ArrayList, so the processor keeps the config it had: none, for a fresh one.
  3. The pipeline does not override replaceAll; JDK 25's default is ListIterator li = this.listIterator(); while (li.hasNext()) li.set(operator.apply(li.next())); (List.java:437-443). Collections.replaceAll uses the list iterator on a list that is not RandomAccess and has 11 or more elements, Collections.copy when 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 the List contract; another JDK may differ. reversed() inherits the gap through its list iterator and replaceAll; reversed().set calls the pipeline's set and is fine.
  4. Nothing repairs it before the parse: QueryParserHelper.parse just calls process (QueryParserHelper.java:213-223). Only setQueryConfigHandler (:185-192), setQueryNodeProcessor (:100-103) and the helper's constructor (:71-73) hand the config out. Calling a setter such as setPointsConfigMap again does not help: it only writes into the config.
  5. PointQueryNodeProcessor (:65-67), PointRangeQueryNodeProcessor (:61-63), BoostQueryNodeProcessor (:47-49) and DefaultPhraseSlopQueryNodeProcessor (:51-53) check if (config != null) and otherwise do nothing. Others dereference it, for example AnalyzerQueryNodeProcessor.process (:82), and throw NullPointerException.

The numbers

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).

editqueryresultwith set(k, p)
PointQueryNodeProcessor via listIterator().setn:5TermQuery n:5, 0 hitsn:[5 TO 5], 1 hit
BoostQueryNodeProcessor via replaceAll or subList(18, 19).setf:foof: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 viewn:[1 TO 10]TermRangeQuerypoint range
AnalyzerQueryNodeProcessor via a viewanyNullPointerExceptionparses
listIterator().add(p) on new QueryNodeProcessorPipeline(config)-p.getQueryConfigHandler() is nulladd(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 of getQueryNodeProcessor() (the in-tree idiom in TestQPHelper.java:163-167, and the advice in java-user threads of 2010 and 2014, which use add), or a pipeline built with new QueryNodeProcessorPipeline(config) and used directly (like TestSpanQueryParserSimpleSample.java:98-103, which uses add). The package docs invite this ("select your favorite query processors and build a pipeline", standard/package-info.java:55-58), and LUCENE-1937 added the List API "if you want to extend or modify an existing pipeline".
  • Natural idioms reach it. "Insert my processor after X" is the ListIterator.add loop; "swap X for my subclass" is replaceAll(p -> p instanceof X ? new MyX() : p) or Collections.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.replaceAll on 10 or fewer processors; a pipeline edited before it is installed with setQueryNodeProcessor or 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 NullPointerException on 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(...) or qp.setQueryNodeProcessor(pipeline), or edit with add(k, p) / set(k, p).

Minimal repro

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).

TestPipelineListIteratorSetConfigHandler.java (JUnit test)46 lines
/*
 * 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);
  }
}
Command
# 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 -ea and -da.

All files of this repro: repro/LFV-966/

More files, environment, full output35 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • The class promises its config "on all processor on pipeline"; its own five insert and replace methods and the old addProcessor keep that.
  • add(k, p) and listIterator(k).add(p) are the same List operation yet parse differently; Collections.replaceAll differs between 10 and 11 processors.
  • LUCENE-1937 added the List API 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 calls processor.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.util treats add(k, p) and listIterator(k).add(p), or set(k, p) and listIterator().set(p), as the same element operation; here they parse differently. Collections.replaceAll sets 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), the ListIterator.add idiom. 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, subList or replaceAll on a pipeline; in-tree edits use add (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.

Suggested fix

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 set hands out the config even when it writes the same processor back (the pipeline's own set skips that case), so List.sort, which rewrites every element through listIterator().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 add or set.
  • The sub list stays RandomAccess, as ArrayList'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:test passes with assertions on (seed 5EED0966) and off (seed 5EED0967): 31 suites, 538 tests, 0 failures, 1 skipped (the 30 upstream suites include TestQPHelper, TestStandardQP, TestMultiFieldQPHelper, TestPointQueryParser, TestPrecedenceQueryParser, TestSpanQueryParser and TestSpanQueryParserSimpleSample). :lucene:queryparser:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis, the regenerate checksum checks) and renderJavadoc pass.
  • 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() and subList() still throw ConcurrentModificationException); 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.

Hand the config out again in process() (validated by the checks)

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.

Document that only the pipeline's own add and set hand out the config

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.

Downstream note

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.

How it was found

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 (listIterator add/set, subList add/set, replaceAll) left it null; on StandardQueryParser's pipeline 4 paths threw NullPointerException at parse.
  • Model-based test. PipelineMbt checks a java.util.ArrayList reference 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 63 List, view, default-method and Collections operations 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 and Collections operations. Vacuity controls: routed views give 0 violations, a mutant add(int, p) is caught.
  • Stock differential. Each of the 21 StandardQueryParser processors replaced through 7 paths, 22 queries, oracle = the same edit through set(k, p) or add(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).

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (GitHub issues, PRs, commits, downstream repositories, dev@/java-user@/issues@/commits@, JIRA archive, web)2026-10-2852 queries from the class, method, processor and symptom terms, plus the finder's earlier 51-query run126 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-2822 manual searches: QueryNodeProcessorPipeline, StandardQueryNodeProcessorPipeline, setQueryConfigHandler, getQueryNodeProcessor, processor names, NullPointerException with the handler, items updated in the last 48 honly #3012, #6554 and #10345 name the class; none is this bug
Open apache/lucene PRs2026-10-28changed-file lists of all 414 open PRsnone touches QueryNodeProcessorPipeline.java, StandardQueryNodeProcessorPipeline.java or QueryParserHelper.java
JIRA (all projects) and lists.apache.org2026-10-28JQL 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 refresh2026-10-29issue and PR search for the class and setQueryConfigHandler; every PR created since 2026-10-28; commits on the filenothing new; last commit on the file 7335eb0ad96 (#15704); upstream main 645d4fe14a20

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthis doc's test: 4 of 5 fail (gradle and the standalone runner, assertions on and off)
main a9779ef3c193 affectedfinder'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 affectedfinder's JUnit 2 of 2 fail; history probe: the same 6 view paths
10.5.2, 9.12.3 affectedthis doc's test 4 of 5 fail; finder's 2 of 2; reproducer's 7 of 9
10.0.0, 9.1.0 affectedfinder'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 affectedhistory 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 APIthe pipeline is not a List (only addProcessor, which sets the handler)
main with the fix (option A) not affectedthis doc's test 5 of 5; queryparser module 538 tests pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-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 realIts 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, mediumRated 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, LowBuilt 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, highRe-ran the independent reproduction's test with a new seed (7 of 9 fail, assertions on and off); novelty new.
Registration check and audit keepRe-runs with new seeds (2 of 2 fail; the fix passes); the audit checked the workaround and the score.
this doc reproduced, fix validatedOwn 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.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,351 words

Finding record

LFV-966, snapshot of 2026-11-06

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 main a9779ef3c193 (the flexible parser sources are identical), branch_10_6 4ae946e5a9b, 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 no List API on the pipeline. The recommended fix was validated with gradle on 645d4fe14a20 (the whole queryparser module, assertions on and off, plus its static checks).

Lucene and upstream sources

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-223
  • lucene/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-401
  • JDK 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-103
  • lucene/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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.