Intervals.term(term, payloadFilter) matches a term only where its payload passes your filter. Lucene simplifies queries by dropping parts that equals calls the same, and this source's equals ignores the filter.
- A payload is a few bytes stored with one occurrence (position) of a term, for example a part-of-speech tag or a label.
Intervals.term("a", filter)matchesaonly at positions whose payload passesfilter. Two filters on the same term can match different positions and different documents.- While it builds and rewrites a query, Lucene removes duplicates by
equals/hashCode:orderedandunorderedturn equal neighbours into one repeated source,orkeeps a set,BooleanQuerykeeps FILTER and MUST_NOT clauses in sets, and the query cache looks queries up by equality.
a with different payload filters compare equal, so ordered() treats them as one repeated source and finds 0 hits instead of 1. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document whose field
fholds the wordatwice: the firstacarries payloadx, the second payloady.Intervals.term("a", x::equals)andIntervals.term("a", y::equals)each match it, at positions 0 and 1. - Search
ordered(term("a", x::equals), term("a", y::equals)), that is "ana|xfollowed by ana|y". You expect 1 hit. - You get 0 hits and no error.
equalsandhashCodeof the two sources look only at the terma, so they are equal, andordered()rewrites the pair into "a|xtwice", which the document does not have. - Every other duplicate check merges them the same way. In a 3-document test,
or()of the two filters returns 2 of the 3 matching documents, two FILTER clauses return 2 documents where 1 matches both, MUST one + MUST_NOT the other returns 0 instead of 1, and a query cache serves the first filter's hits for the second.
Applications that call the payload-filter overload of Intervals.term with two different filters on the same term, in one query (default settings, any index size) or behind a query cache (default on 9.9.0 to 10.6.0, opt-in on main). No Lucene query parser builds these sources, and a public code search found only one-argument Intervals.term calls in Elasticsearch, OpenSearch and Solr.
Mechanism, terms and numbers744 words
Intervals.term(String|BytesRef, Predicate<BytesRef>) returns a source that matches the term only at positions whose payload passes the filter. Its hashCode is Objects.hash(term) and its equals compares only the term, so two sources on the same term with different filters are equal, and so are the IntervalQuery objects built from them.
Lucene removes "duplicate" parts by equality while it builds and rewrites queries, so the two filters are merged silently: ordered() and unordered() turn the pair into a repeat of the first filter, Intervals.or keeps one of them, BooleanQuery collapses two FILTER (or MUST) clauses into one and rewrites MUST x + MUST_NOT y to MatchNoDocsQuery, DisjunctionMaxQuery uses the first filter for both disjuncts, and a query cache serves one filter's hits for the other. Results are wrong in both directions, with default settings and any index size; the smallest case is one document on which ordered(a|x, a|y) finds 0 hits instead of 1.
The term-only equality dates from LUCENE-8694 (8.1.0, 2019), written with a wrong cast, so up to 9.8.0 the same calls threw ClassCastException. #12827 (9.9.0) fixed the cast only, which made the wrong equality reachable. Affected: 9.9.0 to 10.6.0 and main. Only applications that use the payload-filter overload with two different filters on one term are hit; no Lucene query parser builds these sources.
- The filter decides which positions match.
Intervals.term(BytesRef, Predicate<BytesRef>)returnsnew PayloadFilteredTermIntervalsSource(term, payloadFilter)(Intervals.java:86-92; theStringoverload at:78-84delegates to it). Its interval iterator skips every position whose payload fails the filter:} while (filter.test(pe.getPayload()) == false);(PayloadFilteredTermIntervalsSource.java:117-126; the matches iterator at:202-212). - Equality ignores the filter.
hashCode()isObjects.hash(term)(:256-259) andequalsends inreturn Objects.equals(term, that.term);(:261-267).toString()isPAYLOAD_FILTERED(a)for both (:269-272), so the merge is invisible in a printed query too. ordered()turns equal neighbours into a repeat.OrderedIntervalsSource.deduplicategroups consecutive sources withcurrent.get(0).equals(source)and replaces each group withRepeatingIntervalsSource.build(current.get(0), n)(OrderedIntervalsSource.java:29-57).ordered(a|x, a|y)becomes "two occurrences ofa|x"; the document has one, so 0 hits. Repeats exist so that "multiple iterators do not match on a single term" (RepeatingIntervalsSource.java:33-41), which is right only for sources that match the same positions.- The same equality is used everywhere else.
unordered()counts sources in aLinkedHashMap(UnorderedIntervalsSource.java:44-57);Intervals.orcollects them in aHashSet(DisjunctionIntervalsSource.java:43-68);IntervalQuery.equals/hashCodedelegate to the field and source (IntervalQuery.java:134-146);BooleanQuerykeeps FILTER and MUST_NOT clauses inHashSets (BooleanQuery.java:126-140), drops duplicates on rewrite (:343-362), returnsMatchNoDocsQuery("MUST clause also in MUST_NOT")(:364-381), removes a FILTER equal to a MUST (:387-394), merges duplicate MUST clauses (:450-455,deduplicateClausesat:714) and duplicate SHOULD clauses only whenminimumShouldMatchis at most 1 (:442-447); two SHOULD clauses withminimumShouldMatch2 are first rewritten to two MUST clauses (:577-588) and then merged.DisjunctionMaxQuerykeeps its disjuncts in aMultiset(DisjunctionMaxQuery.java:47) that keeps the first of two equal keys with a count of 2 (Multiset.java:41-66,:79-84), so its weights use the first filter twice (DisjunctionMaxQuery.java:115-117). A query cache looks entries up by equality.
Adding the filter to equals/hashCode, and nothing else, makes the repro and every other executed shape pass, so the equality is the cause.
The hunt's 7-test JUnit (payload pos: N at position N; documents first = a x, second = x a, both = a a; a@0 and a@1 filter for pos: 0 and pos: 1; each filter alone returns its correct documents), on every release from 9.9.0 to 10.5.2, and on main with assertions on and off:
| query | expected | actual |
|---|---|---|
a@0.equals(a@1) | false | true |
or(a@0, a@1) | [both, first, second] | [both, first] |
ordered(a@0, a@1) | [both] | [] |
FILTER a@0 + FILTER a@1 | [both] | [both, first] |
MUST a@0 + MUST_NOT a@1 | [first] | [] (MatchNoDocsQuery) |
a@1 through an always-cache LRUQueryCache after a@0 ran | [both, second] | [both, first] |
Also executed by the verifiers: unordered(a@0, a@1) returns nothing; two MUST clauses, and two SHOULD clauses with minimumShouldMatch 2, over-match; a DisjunctionMaxQuery of the two returns [both, first], missing second. By reading, atLeast is affected only when minShouldMatch equals the number of sources (it then delegates to unordered, Intervals.java:566-569); with fewer it builds a MinimumShouldMatchIntervalsSource, which does not de-duplicate (:577). atLeast was not executed.
Exposure and likelihood280 words
- Silent, both directions. Missing hits (
ordered,unordered,or, MUST + MUST_NOT,DisjunctionMaxQuery) and extra hits (two FILTERs, two MUSTs, SHOULD withminimumShouldMatch2, the cache), with no exception. - Default settings, any size. The in-query shapes need no cache and no configuration: default
IndexWriterConfigunder Lucene's randomized test framework, a plainIndexSearcher, one document. The minimal test fails in 20 of 20 iterations with derived seeds in one run (a random directory each time, one class-level codec) and with seed DEADBEEF under another codec. - The cache shape. 9.9.0 to 10.6.0 ship a default
LRUQueryCache; it was executed on stock 9.9.0, 9.12.3 and 10.5.2 searchers (a 12,000-document segment, the query seen 8 times), and 10.6.0 has the same default (read). By reading 10.5.2, the default cache skips segments under 10,000 documents and its policy caches such a query after 5 sightings. On main caching is off by default (IndexSearcher.java:82-84, GITHUB#14187), so the application must install a cache. - Who is exposed. The payload-filter overload is opt-in. A natural trigger is
or(term("bank", isNoun), term("bank", isVerb)), or the same term filtered per request. The flexible query parser's interval functions use only the one-argumentIntervals.term, and no in-tree code outside tests calls the overload. A read-only public code search (2026-10-26) found only one-argumentIntervals.termcalls in Elasticsearch, OpenSearch and Solr. The #12827 author wrote that they could not wrap payload-filtered sources in aMinimumShouldMatchIntervalsSourcebecauseUnorderedIntervalsSource.deduplicate()called thisequals, so such combinations do occur; whether their filters differed is not stated. - Likelihood: rare. The recorded public searches found no report of wrong results since 9.9.0. The intervals package "contains experimental classes" (
package-info.java:21).
One JUnit test in lucene/queries, public API only: one document with two canned a tokens (payloads x and y), the default IndexWriterConfig, a plain IndexSearcher, and one count of ordered(a|x, a|y). Deterministic; it fails with assertions on and off.
/*
* 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.queries.intervals;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.analysis.CannedTokenStream;
import org.apache.lucene.tests.analysis.Token;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
public class TestPayloadFilteredTermOrdered extends LuceneTestCase {
public void testOrderedOfSameTermWithDifferentPayloadFilters() throws Exception {
Token ax = new Token("a", 0, 1);
ax.setPayload(new BytesRef("x"));
Token ay = new Token("a", 2, 3);
ay.setPayload(new BytesRef("y"));
try (Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new TextField("f", new CannedTokenStream(ax, ay)));
w.addDocument(doc);
try (DirectoryReader reader = DirectoryReader.open(w)) {
IntervalsSource source =
Intervals.ordered(
Intervals.term("a", new BytesRef("x")::equals),
Intervals.term("a", new BytesRef("y")::equals));
// the doc is "a|x a|y": expected 1 hit, actual 0 (the two sources compare equal)
assertEquals(
"hits of ordered(a|x, a|y)",
1,
new IndexSearcher(reader).count(new IntervalQuery("f", source)));
}
}
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20 (or a later commit where PayloadFilteredTermIntervalsSource.java is unchanged)
cp $REPRO/TestPayloadFilteredTermOrdered.java lucene/queries/src/test/org/apache/lucene/queries/intervals/
./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestPayloadFilteredTermOrdered -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
- Expected
- 1 hit: the document has an
awith payloadxfollowed by anawith payloady. - Observed
hits of ordered(a|x, a|y) expected:<1> but was:<0>on main, 10.6.0, 10.5.2, 9.12.3 and 9.9.0; 9.8.0 throwsClassCastExceptioninstead.
More files, environment, full output175 words
The test builds the two tokens with the test framework's Token and CannedTokenStream, so it needs only the queries module. The filters are new BytesRef("x")::equals and new BytesRef("y")::equals. On main a plain IndexSearcher has no query cache, so the failure comes from ordered() alone. It fails in 20 of 20 iterations with derived seeds in one run (a random directory each time, one class-level codec) and with seed DEADBEEF under another codec. That each filter alone finds the document is not asserted, to keep one assertion; the hunt's control test for that passes on every version.
The same file compiles and fails unchanged on the 9.9.0, 9.12.3, 10.5.2 and 10.6.0 jars; on 9.8.0 it throws ClassCastException from PayloadFilteredTermIntervalsSource.equals, the pre-#12827 form.
| lucene | main 645d4fe14a20: affected; also the 10.6.0, 10.5.2, 9.12.3 and 9.9.0 release jars with this test (9.8.0: ClassCastException) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
TestPayloadFilteredTermOrdered > testOrderedOfSameTermWithDifferentPayloadFilters FAILED
java.lang.AssertionError: hits of ordered(a|x, a|y) expected:<1> but was:<0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:80F198F3455C249A]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:647)
at org.apache.lucene.queries.intervals.TestPayloadFilteredTermOrdered.testOrderedOfSameTermWithDifferentPayloadFilters(TestPayloadFilteredTermOrdered.java:49)Query#equalsexists "so that QueryCache works properly"; equal queries typically have identical "document-filtering properties". These two equal queries match different documents.- The term-only
equalsis a 2019 copy with a wrong cast that threwClassCastException; #12827 fixed only the cast, which exposed the wrong equality. SpanPayloadCheckQuery, the span counterpart, compares its payloads inequals;FunctionMatchQuerycompares its predicate.
The full argument241 words
- The query contract.
Query#equalsis "required so that QueryCache works properly", and "Typically a query will be equal to another only if it's an instance of the same class and its document-filtering properties are identical" (Query.java:95-107). - The factory contracts.
term(term, payloadFilter)is "filtered by the value of the term's payload at each position" (Intervals.java:78-92);ordered"Returns intervals in which the subsources all appear in the given order" (:418);orreturns "the disjunction of a set of sub-sources" (:118-152). The results contradict these for legal inputs. - History. LUCENE-8694 (#9740, romseygeek, 2019) wrote
equals/hashCodeas a copy ofTermIntervalsSource's, with a leftover cast toTermIntervalsSource, so for two distinct instancesequalsthrew and never returned true. #12827 (eumus, 2023) changed only that line. Commit-level A/B: 9.8.0 jars plus the #12827 class give the 9.9.0 wrong results; 9.9.0 jars with #12827 reverted give 6ClassCastExceptions. - Precedents. LUCENE-8694 calls the class "the intervals equivalent of SpanPayloadCheckQuery", whose
equals/hashCodecompare its payload criteria and its wrapped query (SpanPayloadCheckQuery.java:264-280). There, LUCENE-7808 (#8859, 2017) fixed "incomplete .equals and .hashCode methods, causing caching issues": the wrapped span query had not been compared.FunctionMatchQuerycompares itsDoublePredicatewithObjects.equals(FunctionMatchQuery.java:119-129), andFilteredIntervalsSourcecompares its name (FilteredIntervalsSource.java:142-154). - Nothing documents it. No javadoc says that filters are ignored by equality or that two filters on one term must not be combined.
The devil's-advocate check157 words
The strongest case for "intended": the upstream test testPayloadFilteredTermIntervalsSourceEquals (#12827) asserts that two separately written (_) -> true filters give equal sources (TestPayloadFilteredInterval.java:93-101); Java lambdas have no value equality, so for lambda filters a term-only equals is what lets rebuilt queries hit the cache and be recognised as repeats (a Predicate class with a value equals could do it too); repeat detection deliberately relies on equality (romseygeek in LUCENE-10140, #11177); and the package is experimental.
Why it fails: the #12827 test covers only two filters that behave the same, and #12827 never discussed filters that select different positions; equality of two distinct instances never worked before #12827 (it threw), so no earlier release relied on it; repeat detection is right only for sources that match the same positions; "experimental" warns about API changes, not wrong matches. Verdict: not intended, a defect (the independent reproduction, the cross-model review, the intended-behaviour check and the adjudication, high confidence).
Recommended A: include the filter in equals/hashCode
Add the filter to equals and hashCode (Objects.equals(filter, that.filter), Objects.hash(term, filter)), as FunctionMatchQuery does, and change the #12827 test to share one Predicate instance. The repro passes, and so do all 513 lucene/queries tests. Trade-off: separately built identical lambdas are no longer equal, so they are no longer treated as a repeat.
@Override
public int hashCode() {
return Objects.hash(term, filter);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PayloadFilteredTermIntervalsSource that = (PayloadFilteredTermIntervalsSource) o;
return Objects.equals(term, that.term) && Objects.equals(filter, that.filter);
}All options and trade-offs4 options · 619 words
One fix was built and validated through gradle on 645d4fe14a20. Because Java lambdas have no value equality, any fix that compares the filter must change the upstream test from #12827.
+2/-2 in PayloadFilteredTermIntervalsSource.java, plus the #12827 test testPayloadFilteredTermIntervalsSourceEquals changed to share one Predicate instance and to assert that a different filter is not equal (+8/-2 in TestPayloadFilteredInterval.java). For a lambda Objects.equals is identity; a Predicate class with value equals keeps value equality. FunctionMatchQuery compares its predicate the same way.
@Override
public int hashCode() {
return Objects.hash(term, filter);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PayloadFilteredTermIntervalsSource that = (PayloadFilteredTermIntervalsSource) o;
return Objects.equals(term, that.term) && Objects.equals(filter, that.filter);
}Trade-offs
- Two separately built but identical lambdas are no longer equal: rebuilt queries miss the query cache and are not de-duplicated (no wrong result).
- They are also no longer recognised as a repeat: on documents
single=a xanddouble=a a,unordered(term(a, f), term(a, f'))with two separately built "payload ispos: 0" filters returns [double, single] with the fix and [] on main; with one shared always-truePredicateinstance (p -> true), main and the fix both give [double], as plainunordered(term(a), term(a))does (measured by the intended-behaviour check on mainea57360912ed). That is howunorderedtreats any two different sources that can match the same position ("The subsources may overlap",Intervals.java:432), but it changes behaviour for callers who rebuild the same filter; the CHANGES entry should say "reuse one immutablePredicateinstance". - The fix is only as sound as the predicate's own
equals: aPredicateclass with a wrong valueequals, or a predicate whose behaviour changes after the query is built, can still merge different filters or serve stale cache entries. - The upstream test from #12827 must change: with the source fix alone it fails (2 tests, 1 failure).
- No in-tree code outside tests uses the overload.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seed DEADBEEF: the repro fails on stock and passes with the fix, assertions on and off. org.apache.lucene.queries.intervals.*(8 suites including the repro): 118 tests, 0 failures.- The whole
:lucene:queries:test: 513 tests, 5 skipped, 0 failures. checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestof:lucene:queriespass with the fix and the repro test.- Earlier, the same source change made the finder's 7 tests, the independent reproduction's 8, the cross-model review's 5 extra paths and the security probe pass on main
ea57360912ed.
An overload such as Intervals.term(term, String filterName, Predicate<BytesRef>) whose name goes into equals/hashCode, as FilteredIntervalsSource does with its name (FilteredIntervalsSource.java:77-89, :142-154). Not built.
Trade-offs
Keeps cache hits and repeat detection for rebuilt filters with the same name; callers must name distinct filters distinctly. An API addition; the existing overload still needs A or deprecation.
A factory that takes the accepted payload values, as SpanPayloadCheckQuery takes its payload list and match operation and compares them in equals. Not built.
Trade-offs
Exact equality, but covers fewer cases than a Predicate; an API change.
Say in the javadoc that filters are ignored by equality.
Trade-offs
It would document wrong results for ordered, or and BooleanQuery. Until a fix ships, users can keep two payload-filtered sources on the same term out of one query and out of a shared query cache (for example a QueryCachingPolicy that never caches IntervalQuery).
Not a Lucene security issue, but an application that stores access labels as payloads and builds one filter per user on the same term would silently lose a restriction or be served another user's cached hits (executed on a stock 10.5.2 searcher).
A bytecode scan of query components listed fields that the matching code reads but equals/hashCode do not, and flagged this filter. An exhaustive equality check over small queries on a 12-document index then found equal IntervalQuery pairs that match different documents. An AI tool reduced it to a public-API JUnit; no model checker or solver was involved.
The method in full149 words
Method: semi-formal (a hunt unit, query object contracts).
- A bytecode scan of the stock main jars (JDK 25 ClassFile API) compared, for each query component, the fields read by its matching code with the fields read by
equals/hashCode. It reportedEQ-MISS PayloadFilteredTermIntervalsSource.filter, read by the interval iterators'nextIntervalandnextbut not byequals. - A bounded-exhaustive equality-congruence harness then tried every ordered pair of each query family over a small parameter domain on a fixed 12-document index, and witnessed equal
IntervalQuerypairs with different matched documents (payloadpos: 0vspos: 1: documents 0 1 3 6 10 vs 1 4 8). - The finder, an AI tool, wrote the 7-test public-API JUnit; the one-test repro was cut from it on 2026-10-29. The finder tagged the find "formal"; the registry records it as semi-formal because no TLC, SMT or proof output produced the signal.
No prior report: 183 recorded searches over GitHub, the mailing lists, JIRA, downstream trackers and the web, plus the files of all 413 open PRs (2026-10-26), rechecked on 2026-10-31. Closest: #12827, which fixed a ClassCastException in the same equals and never discussed filters that select different positions.
Searches4 searches
Verdict new: 17 hits read in full, none the same bug. Related, not reports of this bug: #12827 (same method, different defect; the closest), #9740 / LUCENE-8694 (origin), #8859 / LUCENE-7808 (span precedent, fixed as a caching bug), #11177 / LUCENE-10140 (repeat detection relies on equal sources), #13715 (open; the opposite equality defect for Intervals.regexp). Gaps: keyword search only; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs | 2026-10-26 | the scripted prior-report search (25 issue/PR, 4 commit and 2 downstream-tracker queries) and 83 GraphQL searches over apache/lucene, the archived lucene-solr repository, Elasticsearch, OpenSearch, Solr and all of GitHub: class names, payload filter, Intervals.term with payload or predicate, IntervalsSource equals/hashCode/cache, deduplicate, RepeatingIntervalsSource, LUCENE-8694, the #12827 author and commenter | only related items; #12827 is the closest |
| Open PRs | 2026-10-26 | the changed files of all 413 open apache/lucene PRs; the diffs of the 4 that touch intervals files (#16, #16847, #16851, #16899) | none changes equality or touches PayloadFilteredTermIntervalsSource.java |
| Lists, JIRA, code search, web | 2026-10-26 | dev@, java-user@, issues@, commits@ (28 tool and 17 direct queries), JIRA (7 tool and 5 direct queries), 4 GitHub code searches, 8 web queries | no report |
| Recheck (GitHub) | 2026-10-31 | PayloadFilteredTermIntervalsSource; payload filter intervals equals; Intervals.term payload filter; payload intervals query cache; the upstream file history | only #12827 or unrelated items; the file's last commit is still #12827 |
Latent since LUCENE-8694 (8.1.0, 2019), whose term-only equals threw ClassCastException; #12827 (9.9.0, 2023) fixed only the cast and made the wrong equality reachable. Executed from 8.0.0 (no such API) to 10.6.0 and main; not fixed upstream.
Versions tested6 rows
Introduced (silent wrong results) by #12827 "Fix PayloadFilteredTermIntervalsSource equals" (eumus, merged by mikemccand 2023-11-21, milestone 9.9.0): main 4991e3a1f47b, branch_9x 96bc866dfed. Latent since LUCENE-8694 "Payload-filtered term intervals" (#9740, romseygeek, committed 2019-03-18): main 5ca25249274, branch_8x 13aac3becde; the API shipped in 8.1.0 (sandbox module) and in the queries module from 8.2.0. Commit-level A/B confirms that the one-line cast change is what turns the crash into wrong results. Upstream main 2f424fd428da (2026-10-30): the file's last commit is still #12827.
| Version or commit | Verdict | Result |
|---|---|---|
| 8.0.0 | no such API | no payload-filter overload of Intervals.term (compile error) |
| 8.1.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.8.0 | ClassCastException | equals of two distinct sources throws; on 9.8.0 this doc's test fails with ClassCastException, no wrong result |
| 9.9.0 to 9.12.3, 10.0.0 to 10.5.2 (22 releases) | first affected: 9.9.0 | the finder's JUnit fails 6 of 7 the same way on each; this doc's test fails on 9.9.0, 9.12.3 and 10.5.2 |
| 10.6.0 | affected | this doc's test fails (0 hits instead of 1) |
| main 645d4fe14a20 | affected | this doc's test fails through gradle and the standalone runner, assertions on and off; passes with fix A |
| 9.8.0 jars + #12827 class / 9.9.0 jars without it | commit A/B | with #12827: the 9.9.0 wrong results; without it: 6 ClassCastExceptions |
An independent reproducer, a root-cause review by another model family (OpenAI GPT-5.6) for which a separate run executed its extra paths, a devil's advocate and an adjudication all rated it real; the adjudication confirmed it with high confidence. The repros still fail on the later main commits a9779ef3c193 and 645d4fe14a20.
Checks and verdicts293 words
Before the candidate was filed, reading-only reviews by OpenAI GPT-5.6 (refute and intended lenses) agreed on the root cause; the refute review's "not real" was procedural, because it could not run Java.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Bytecode scan plus equality-congruence harness; the 7-test JUnit fails 6 of 7 on main (assertions on and off), 10.5.2, 9.12.3 and 9.9.0; 9.8.0 throws ClassCastException; the fix passes 7 of 7. |
| Refute and intended reviews (OpenAI GPT-5.6, reading only) | not real (low, procedural) / real (medium) | Both agree on the root cause and that the inputs are legal; a separate run re-ran the repro each time and it failed. |
| Independent reproducer | real | Own JUnit outside org.apache.lucene fails 7 of 8 on main (assertions on and off), 10.5.2, 9.12.3 and 9.9.0; adds unordered(); its fix passes 8 of 8. |
| Root-cause review (OpenAI GPT-5.6) | real (medium) | Named BooleanQuery MUST/SHOULD de-duplication, DisjunctionMaxQuery and atLeast. Separate runs covered unordered, two MUST, two SHOULD with minimumShouldMatch 2 and DisjunctionMaxQuery (all 4 fail on main and 10.5.2) plus a same-instance control (passes); with the fix 5 of 5 pass. atLeast was not run. |
| Devil's advocate | real (high) | Built the strongest intended case (the #12827 test) and rejected it; measured the repeat-detection trade-off of the fix. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's JUnit (8 run, 7 failed, assertions on and off) and the finder's (7 run, 6 failed). |
| Repro minimization and main refresh | verified; still reproduces | The one-test repro fails on 645d4fe14a20 (seed DEADBEEF, and 20 of 20 iterations with derived seeds in one run) and passes with the fix; the finder's and the independent reproduction's tests still fail on a9779ef3c193. |
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 475 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,665 words
- Registry title
- Intervals.term(term, payloadFilter) sources compare equal whatever their payload filter, so Intervals.or/ordered/unordered, BooleanQuery rewrite and the query cache merge sources that match different positions
- Area
- queries / intervals (PayloadFilteredTermIntervalsSource equality; IntervalQuery dedup and cache keys)
- Priority
- Low, rank 849: Payload-filtered Intervals.term sources compare equal whatever their filter, so dedup and caching silently merge them, but no parser or downstream engine uses that overload, so it follows LFV-321.
- Impact class
- silent wrong results
- Impact note
- Applications that call the public Intervals.term(String|BytesRef, Predicate<BytesRef>) with two filters that select different positions on the same term, either in one query (Intervals.or/ordered/unordered/atLeast, BooleanQuery FILTER, MUST, MUST_NOT or minimum-should-match clauses, DisjunctionMaxQuery) or in two queries behind a shared query cache, silently get wrong matches in both directions (missing hits and extra hits), with -ea and -da, default writer, codec and searcher settings and any index size; the in-query shapes need no cache, the cache shape happens with a stock IndexSearcher on 9.9.0-10.5.2 (default LRUQueryCache: segments of at least 10000 docs, after about 5 uses; executed) and needs an application-installed cache on main (GITHUB#14187); no Lucene query parser and, by public code search, no Elasticsearch, OpenSearch or Solr code builds payload-filtered intervals; 8.1.0-9.8.0 throw ClassCastException on the same calls instead; nothing is written to the index.
- Summary
- You index a field with a payload at each position and build two sources on the same term with different payload filters, for example a@0 = Intervals.term("a", payload is 'pos: 0') and a@1 = Intervals.term("a", payload is 'pos: 1'), which on their own match different documents. You expect Intervals.or(a@0, a@1) to match both sets, ordered(a@0, a@1) to match the document 'a a', FILTER a@0 + FILTER a@1 to match only that document, and a query cache to keep the two queries apart. Instead PayloadFilteredTermIntervalsSource.equals/hashCode compare only the term, so the two sources are equal: or() keeps one of them, ordered() and unordered() become 'a@0 twice' and match nothing, the two FILTER clauses collapse into one (extra hits), MUST a@0 + MUST_NOT a@1 is rewritten to MatchNoDocsQuery, and a cached searcher returns the first filter's hits for the second, all with no error.
- Introduced
- 4991e3a1f47b (main; branch_9x backport 96bc866dfed, the commit in 9.9.0), apache/lucene#12827 'Fix PayloadFilteredTermIntervalsSource equals' by eumus (merged by mikemccand, 2023-11-21): it fixed a ClassCastException in equals (a cast to TermIntervalsSource) by changing only the cast, which made the existing term-only equality reachable and added testPayloadFilteredTermIntervalsSourceEquals (two separately written always-true filters must be equal) [exec: commit-level A/B, 9.8.0 jars plus the #12827 class give the 9.9.0 wrong results with 0 ClassCastException, 9.9.0 jars with #12827 reverted give 6 ClassCastException]; latent since 5ca25249274 (main) / 13aac3becde (branch_8x), LUCENE-8694 'Payload-filtered term intervals' (now apache/lucene#9740, committed by romseygeek 2019-03-18), which wrote hashCode = Objects.hash(term) and a term-only equals copied from TermIntervalsSource with the wrong cast, so before 9.9.0 equals of two distinct instances threw instead of returning true [read: git log --follow, blame -L 256,266 and show in the history clone] (2023-11-21 (#12827 merged; 9.9.0 tagged 2023-12-04); latent equality code since 2019-03-18 (LUCENE-8694, 8.1.0))
- First affected version
- 9.9.0 [exec]; 9.8.0 is the last release without wrong results: the same calls throw ClassCastException from equals there [exec], as in every release from 8.1.0 [exec: 8.1.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.8.0]; 8.0.0 has no payload-filter overload of Intervals.term [exec: compile error]
- Affected versions
- every release from 9.9.0 through 10.6.0 and main ea57360912ed and 645d4fe14a20 [exec: the minimal test fails on the 10.6.0, 10.5.2, 9.12.3 and 9.9.0 release jars (-ea) and on main 645d4fe14a20 (-ea and -da), 2026-10-31; the finder's JUnit fails 6 of 7 the same way on all 22 releases 9.9.0-10.5.2 (-ea) and on main (-ea and -da); the independent reproduction's JUnit fails 7 of 8 on main (-ea, -da), 10.5.2, 9.12.3 and 9.9.0; the follow-up security probe fails on 10.5.2 (-ea, -da), 9.12.3, 9.9.0 and main]; 8.1.0-9.8.0 have the crash form instead (ClassCastException from the same equals, fixed by #12827) [exec: 8.1.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.8.0]; 8.1.x shipped the class in the sandbox module, the queries module has it from 8.2.0; not affected: 8.0.0 and earlier (API absent) [exec]; branch_10x and branch_10_6 have the same equals/hashCode [read]; not fixed upstream [read, 2026-10-26; on 2026-10-31 the file's last commit on upstream main 2f424fd428da is still #12827]; all runs on aarch64 with JDK 25.0.3
- 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
- semi-formal; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-26, the prior-report record, verdict new plus the finder's runs, read-only: 183 recorded queries, namely a scripted prior-report search (25 GitHub issue/PR, 4 commit, 2 downstream-repository, 28 mailing-list over dev@, java-user@, issues@ and commits@, 7 JIRA and 8 web queries), 83 GitHub GraphQL searches over apache/lucene, the archived lucene-solr repository, Elasticsearch, OpenSearch, Solr and all of GitHub, 4 GitHub code searches, 17 direct lists.apache.org and 5 direct JIRA queries; 17 hits read in full, none the same bug; the changed files of all 413 open apache/lucene PRs: 4 touch intervals files (#16, #16847, #16851, #16899) and none changes equality or touches PayloadFilteredTermIntervalsSource.java). Not fixed upstream: on 2026-10-26 GitHub main was ea57360912ed, and equals/hashCode were unchanged on main, branch_10x and branch_10_6; on 2026-10-31 the file's last commit on upstream main 2f424fd428da (2026-10-30) is still #12827. Related, not reports of this bug: #12827 (2023, merged; fixed a ClassCastException in the same equals and added the test that two separately written always-true filters are equal; it never discusses filters that select different positions; its author reached equals by wrapping payload-filtered sources in Intervals.atLeast), #9740 / LUCENE-8694 (origin of the class; equality never discussed), #8859 / LUCENE-7808 (precedent: SpanPayloadCheckQuery equals/hashCode left out its payload criteria, fixed as a caching bug), #11177 / LUCENE-10140 (romseygeek: repeat detection rewrites equal consecutive sub-iterators), #10141 / LUCENE-9099 and #9347 / LUCENE-8300 (repeat handling), #13715 (open: the opposite equality defect for Intervals.regexp, a different class), #7315 / LUCENE-6253 (open: SpanPositionCheckQuery equals misses a field), #16657 (LRUQueryCache keys hash with query.hashCode() on main). Search gaps: keyword search cannot rule out a report worded without these class names, GitHub's search rate limit was saturated (GraphQL search was used as well), forums and Stack Overflow were covered only through web search, and private reports are not visible
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28): this doc's one-method test through gradle with assertions on and off (seed DEADBEEF) and on the standalone runner; the same test on the 10.6.0, 10.5.2, 9.12.3 and 9.9.0 release jars (fails) and on 9.8.0 (ClassCastException). The hunt's tests ran on mainea57360912edand on all 22 releases from 9.9.0 to 10.5.2, and again ona9779ef3c193; the classes involved are unchanged up to645d4fe14a20apart from javadoc lines inIntervals.java. The fix was run through gradle in a scratch worktree of645d4fe14a20on 2026-10-31. Upstream main2f424fd428da(2026-10-30) still has the same code.
- Finding record
lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:35-42 (at ea57360912ed) (package-private class; fields term and filter), :256-266 (hashCode = Objects.hash(term); equals compares only term), :124 and :210 (the interval iterators call filter.test)lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:78-92 (at ea57360912ed) (public factories term(String|BytesRef, Predicate<BytesRef>) 'filtered by the value of the term's payload at each position')lucene/queries/src/java/org/apache/lucene/queries/intervals/DisjunctionIntervalsSource.java:58-68 (at ea57360912ed) (simplify collects sub-sources into a HashSet); OrderedIntervalsSource.java:40-58 (equal neighbours become a RepeatingIntervalsSource); UnorderedIntervalsSource.java:44-58 (equal sources counted in a LinkedHashMap)lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java:135-146 (at ea57360912ed) (equals/hashCode delegate to the field and the source); package-info.java:21 ('This package contains experimental classes')lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:126-140 (at ea57360912ed) (clauseSets: FILTER and MUST_NOT in HashSets, SHOULD and MUST in multisets), :346-361 (rewrite drops duplicate FILTER/MUST_NOT clauses), :365-384 ('MUST clause also in MUST_NOT' -> MatchNoDocsQuery), :387-400 (FILTER clauses that are also MUST clauses removed), :442-451 and :714 (deduplicateClauses for SHOULD and MUST)lucene/core/src/java/org/apache/lucene/search/Query.java:95-107 (at ea57360912ed) (equals must be implemented properly 'so that QueryCache works properly'; equal 'only if ... its document-filtering properties are identical'); IndexSearcher.java:82-84 (main: DEFAULT_QUERY_CACHE = null, caching disabled by default)lucene/queries/src/test/org/apache/lucene/queries/intervals/TestPayloadFilteredInterval.java:93-101 (at ea57360912ed) (testPayloadFilteredTermIntervalsSourceEquals: two separately written (_) -> true sources must be equal)lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:77-89, (at ea57360912ed) :143-153 (user filters carry a name that is part of equals/hashCode); lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:48 (DoublePredicate filter field) and :119-124 (equals compares the predicate with Objects.equals); DisjunctionMaxQuery.java:47 (disjuncts kept in a Multiset)gh pr view 12827 -R apache/lucene (read-only, 2026-10-26: body 'TermIntervalsSource isn't ancestor of PayloadFilteredTermIntervalsSource, so casting to it causes ClassCastException ... I wasn't unable to wrap PayloadFilteredTermIntervalsSources into a MinimumShouldMatchIntervalsSource, as it tries to deduplicate wrapped intervals using equals inside UnorderedIntervalsSource.deduplicate()'; one comment by boris-pichugin 'I've got the same problem.'; merged 2023-11-21)
- The finding in 30 seconds
lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:78-92 (at 645d4fe14a20)lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:37-43, (at 645d4fe14a20) :117-126, :202-212, :256-272lucene/queries/src/java/org/apache/lucene/queries/intervals/OrderedIntervalsSource.java:29-57; (at 645d4fe14a20) UnorderedIntervalsSource.java:44-57; DisjunctionIntervalsSource.java:43-68; RepeatingIntervalsSource.java:33-41; IntervalQuery.java:134-146lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:126-140, (at 645d4fe14a20) :343-394, :443, :451, :714; DisjunctionMaxQuery.java:47lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:442-455, (at 645d4fe14a20) :577-588; Multiset.java:41-66, :79-84; DisjunctionMaxQuery.java:115-117; lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:566-577 (atLeast)
- Who is affected
lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84; (at 645d4fe14a20) a Lucene clone releases/lucene/10.6.0 lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:99 (default LRUQueryCache)lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/ (at 645d4fe14a20) (no payload-filter factory); lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalBuilder.java:128, :159, :191 (one-argument term only); package-info.java:21a Lucene clone releases/lucene/10.5.2 lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:175-197; UsageTrackingQueryCachingPolicy.java:122-137 (cache thresholds)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/Query.java:95-107 (at 645d4fe14a20)lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:78-92, (at 645d4fe14a20) :118-152, :415-424lucene/queries/src/java/org/apache/lucene/queries/payloads/SpanPayloadCheckQuery.java:264-280; (at 645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:119-129; lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:142-154lucene/queries/src/test/org/apache/lucene/queries/intervals/TestPayloadFilteredInterval.java:93-101 (at 645d4fe14a20)
- Suggested fix
lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:77-89, (at 645d4fe14a20) :142-154; lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:119-129; Intervals.java:432
- Prior reports
gh api search/issues and repos/apache/lucene/commits?path=... (read-only, 2026-10-31)
- History
a Lucene clone releases/lucene/10.6.0 lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:256-267 (same equals/hashCode)gh api repos/apache/lucene/commits?path=lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java (read-only, 2026-10-31)
- Diagrams
lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:256-267; (at 645d4fe14a20) OrderedIntervalsSource.java:40-57
- TestPayloadFilteredTermOrdered.java (1 test):
repro/LFV-295/src/org/apache/lucene/queries/intervals/TestPayloadFilteredTermOrdered.java(56 lines)
A picture of the one-document repro and the equality check that merges the two sources, made in parallel with this doc from the code at 645d4fe14a20; the PNG is the summary still.
assets/LFV-295/bug.png(475 KB)
