All findings · Lucene finding · LFV-455 · payload-filtered-intervals-reject-segment-without-payloads

Intervals.term(term, payloadFilter) throws for the whole search when one segment has no payloads for the field

A payload-filtered interval query, Intervals.term(term, payloadFilter), throws IllegalArgumentException for the whole index as soon as one segment holds only documents without payloads in that field. Two documents in two segments make count throw; the same two documents in one segment give 1 hit.

Summary

Priority
Low, rank 623The rarely used payload-filter overload of Intervals.term throws for the whole index when one segment has no payloads, a loud opt-in crash.
Impact
crash
Since
8.1.0 (LUCENE-8694, 2019)introduced 2019-03-18 · about 7 years
Affected
8.1.0 to 10.6.0 and main (9 releases and main run)
Status
documented
Found by
AI code reading of the intervals package, then a segment-layout test

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 payload-filtered interval query fails for the whole index when one segment happens to hold only documents without payloads in the field.

Background: payloads, segments and payload-filtered intervals

  1. A payload is a few bytes stored with one occurrence of a term. DelimitedPayloadTokenFilter, for example, turns dog|noun into the term dog with the payload noun; a token without the delimiter gets no payload.
  2. An index is a set of segments. Every flush, commit or near-real-time refresh writes a new one, and merges combine them. Each segment records, per field, whether any of its own documents stored a payload.
  3. Intervals.term("dog", filter) matches dog only at positions whose payload passes the filter. A position without a payload is passed to the filter as null.
Summary of the bug: two one-document segments, dog with the payload noun and dog without; count(new IntervalQuery("f", Intervals.term("dog", p -> p != null))) should be 1 but throws IllegalArgumentException. Segment _0 has hasPayloads() = true and doc 0 matches; segment _1 has hasPayloads() = false and throws at PayloadFilteredTermIntervalsSource.java:58, before seekExact, so count and search fail for the whole index and Weight#matches throws for doc 1, the document of segment _1; the same two documents in one segment, or after forceMerge(1), give count 1. The flag is per-segment data, not in the schema check, and merging ORs it. Fix sketch: remove the two per-segment hasPayloads() checks so a missing payload reads as null.
The two-segment repro on main: segment _0 ("dog" with payload noun) reports payloads, segment _1 ("dog" without) does not, so its check throws before the term lookup and the whole count fails; in one segment the same documents give 1. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index two documents into a text field f, with a commit after each so each is its own segment: "dog" with the payload noun, then "dog" with no payload. The second segment records that f has no payloads.
  2. Count IntervalQuery("f", Intervals.term("dog", p -> p != null)). You expect 1: the second "dog" has no payload, so the filter gets null and rejects it.
  3. You get IllegalArgumentException "Cannot create a payload-filtered iterator over field f because it has no indexed payloads". The whole count fails, with assertions on or off.
  4. Merge the same two documents into one segment (forceMerge(1)) and the count is 1: in a segment that has payloads, the payload-less "dog" just reaches the filter as null.
  5. The query checks each segment's "has payloads" flag and throws when it is false, before it even looks up the term. A newly flushed segment gets the flag only if one of its own documents had a payload (a merge ORs it), so any flush of payload-less documents can trigger it.

Who is affected

Applications that call Intervals.term(term, payloadFilter) on a field where only some documents carry payloads, on 8.1.0 to 10.6.0 and main. Any flush, commit or refresh holding only payload-less documents creates the state with default settings; searches and counts of such a query then fail until a merge folds that segment into one with payloads. No Lucene parser builds this query.

Mechanism, terms and numbers482 words

Intervals.term(term, payloadFilter) returns the positions of a term whose payload passes a filter. Its source checks, once per segment, whether the segment's field has payloads, and throws IllegalArgumentException when it has none, before it looks up the term. That flag is per-segment data: a newly flushed segment gets it only if one of its own documents indexed a non-empty payload for the field; segments may differ on it, and a merge ORs it. So when a flush, commit or refresh happens to hold only documents without payloads in that field, a search or count of the query fails for the whole index, even if that segment does not contain the term, until a merge folds the segment into one with payloads; Weight#matches throws for any document of that segment. Two documents, "dog" with payload noun and "dog" without, one segment each: count throws; in one segment the count is 1. Default writer, codec and searcher, assertions on or off; it fails on the 9 releases run, from 8.1.0 to 10.6.0, and on main, and the releases in between have the same checks by a tag sweep.

The bug

  1. Indexing sets the flag per segment, from the data: FreqProxTermsWriterPerField.writeProx sets sawPayloads only for a payload with length > 0 (FreqProxTermsWriterPerField.java:88-93), and finish() calls setStorePayloads() only then (:67-73). FieldInfo.hasPayloads(): "Returns true if any payloads exist for this field" (FieldInfo.java:642).
  2. Segments may differ on it: FieldInfo.verifySameSchema compares index options, norms, term vectors, doc values, points and vectors, not payloads (FieldInfo.java:244-269). A merge ORs the flags (FieldInfos.java:778-780).
  3. PayloadFilteredTermIntervalsSource.intervals() throws IllegalArgumentException when terms.hasPayloads() is false (PayloadFilteredTermIntervalsSource.java:57-62), before seekExact(term) (:63-66); matches() does the same (:163-168, before :169-172).
  4. Nothing catches it: IntervalWeight.scorerSupplier calls the source (IntervalQuery.java:189-190) from IndexSearcher.searchLeaf (IndexSearcher.java:842), so search and count fail for the whole index; IntervalWeight.matches calls matches() (IntervalQuery.java:171-175).
  5. In a segment that has payloads, a position without one reads as null (Lucene104PostingsReader.java:1338-1343) and goes to the filter (PayloadFilteredTermIntervalsSource.java:124). So the same document is searched fine next to a payloaded one and fails the whole search alone in its own segment.

The numbers

  • Two documents, one segment each ("dog" with payload noun, "dog" without), count(IntervalQuery("f", Intervals.term("dog", p -> p != null))): throws. The same two documents after forceMerge(1): 1.
  • DelimitedPayloadTokenFilter text, big|adj dog|noun, dog walks, hot|adj dog|noun, one segment each, filter "payload is noun": throws; after forceMerge(1): 2.
  • The payload-less segment does not contain the queried term: still throws, because the check comes before the term lookup.
  • Default IndexWriterConfig and merge policy, a plain flush() and an NRT reader: count throws.
  • Weight#matches (used for highlighting) throws the same exception from matches() for the document of the payload-less segment; for doc 0, in the segment with payloads, it returns a match.
Exposure and likelihood212 words
  • Who is hit. Callers of the payload-filter overload of Intervals.term on a field with sparse payloads: DelimitedPayloadTokenFilter output where some documents have no delimited tokens, or a NumericPayloadTokenFilter that sets a payload only on tokens of one type (NumericPayloadTokenFilter.java:49). Any flush, commit or NRT refresh that holds only such documents creates the state, with the default IndexWriterConfig, codec and merge policy.
  • Who is not. Applications whose analyzer gives every token a payload, like SimplePayloadFilter in the only upstream test (TestPayloadFilteredInterval.java:42). No Lucene query parser builds this source, and by public code search neither do Elasticsearch, OpenSearch or Solr.
  • Default vs opt-in. Default writer, codec and searcher; the trigger is the opt-in payload-filter overload in the intervals package, which its package-info.java calls experimental.
  • Scale and timing. One payload-less segment fails the whole index, even when it does not contain the queried term. It lasts until a merge folds that segment into one with payloads, and the next such flush brings it back, so the failure comes and goes with the merge schedule.
  • How it fails. Loudly and deterministically: an IllegalArgumentException, assertions on or off. No silent wrong results, nothing written to the index. Likelihood: plausible; the public searches found no report since the API shipped in 2019.

Minimal repro

One JUnit class for lucene/queries/src/test/org/apache/lucene/queries/intervals/, one method, public API plus Lucene's test framework: two one-document segments, "dog" with a payload and "dog" without, and a count of the payload-filtered query. It fails with assertions on and off; it is also the suggested regression test.

TestPayloadFilteredIntervalsSegmentWithoutPayloads.java (JUnit for Lucene's build)58 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.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.index.NoMergePolicy;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
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 TestPayloadFilteredIntervalsSegmentWithoutPayloads extends LuceneTestCase {

  public void testSegmentWithoutPayloads() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) {
      Token withPayload = new Token("dog", 0, 3);
      withPayload.setPayload(new BytesRef("noun"));
      for (Token token : new Token[] {withPayload, new Token("dog", 0, 3)}) {
        Document doc = new Document();
        doc.add(new TextField("f", new CannedTokenStream(token)));
        w.addDocument(doc);
        w.commit(); // one segment per document; the second segment has no payloads for "f"
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IntervalQuery query = new IntervalQuery("f", Intervals.term("dog", p -> p != null));
        try {
          assertEquals(1, new IndexSearcher(reader).count(query));
        } catch (IllegalArgumentException e) {
          throw new AssertionError(
              "expected count 1 (the doc whose 'dog' has a payload), got " + e, e);
        }
      }
    }
  }
}
Command
cp $REPRO/TestPayloadFilteredIntervalsSegmentWithoutPayloads.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/intervals/
cd $LUCENE && ./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestPayloadFilteredIntervalsSegmentWithoutPayloads -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
Expected
count 1: the document whose "dog" carries a payload (the same two documents in one segment give 1).
Observed
IllegalArgumentException "Cannot create a payload-filtered iterator over field f because it has no indexed payloads"; the whole count fails, assertions on and off.

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

More files, environment, full output230 words

NoMergePolicy and a commit per document only fix the segment layout; a default IndexWriterConfig with an ordinary flush and an NRT reader reaches the same state. CannedTokenStream tokens set the payload directly, so no analysis module is needed. Deterministic, single thread, a plain IndexSearcher. The catch only turns the exception into an expected-versus-actual message and keeps it as the cause. This test was run on main and 10.6.0. On 9.0.0 and older LuceneTestCase is in org.apache.lucene.util and CannedTokenStream and Token are in org.apache.lucene.analysis (on 8.1.x the intervals package is also org.apache.lucene.search.intervals). The older releases ran the finder's DelimitedPayloadTokenFilter test instead, which fails there with only its LuceneTestCase import (9.0.0 and older) and its package line (8.1.x) changed.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the 10.6.0 release fails too; every release since 8.1.0 that was run fails; 8.0.0 has no such API
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

TestPayloadFilteredIntervalsSegmentWithoutPayloads > testSegmentWithoutPayloads FAILED
    java.lang.AssertionError: expected count 1 (the doc whose 'dog' has a payload), got java.lang.IllegalArgumentException: Cannot create a payload-filtered iterator over field f because it has no indexed payloads
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:171531F2198284EE]:0)
        at org.apache.lucene.queries.intervals.TestPayloadFilteredIntervalsSegmentWithoutPayloads.testSegmentWithoutPayloads(TestPayloadFilteredIntervalsSegmentWithoutPayloads.java:52)
        Caused by:
        java.lang.IllegalArgumentException: Cannot create a payload-filtered iterator over field f because it has no indexed payloads
            at org.apache.lucene.queries.intervals.PayloadFilteredTermIntervalsSource.intervals(PayloadFilteredTermIntervalsSource.java:58)
            at org.apache.lucene.queries.intervals.IntervalQuery$IntervalWeight.scorerSupplier(IntervalQuery.java:190)
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.LRUQueryCache$CachingWrapperWeight.scorerSupplier(LRUQueryCache.java:874)
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.searchLeaf(IndexSearcher.java:842)
            ...
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.count(IndexSearcher.java:528)
1 test completed, 1 failed

# the same with -Ptests.asserts=false; with the recommended fix the test passes

Why it looks unintended

  • The javadoc promises filtering "by the value of the term's payload at each position" and documents no exception.
  • The flag is per-segment data, not schema: inside a segment with payloads, a position without one already reaches the filter as null.
  • The same documents give 1 hit after forceMerge(1): the result depends on segment layout.
The full argument278 words
  • The contract. The factory javadoc says only "filtered by the value of the term's payload at each position" (Intervals.java:79-80, :87-88) and documents no exception. IntervalsSource.intervals: "Returns {@code null} if no intervals for this field exist in this segment" (IntervalsSource.java:39). The multiterm factories, by contrast, document their per-segment limit as @throws IndexSearcher.TooManyClauses (Intervals.java:366-367).
  • A schema check copied onto data. The payload check sits right under the hasPositions() check (PayloadFilteredTermIntervalsSource.java:51-56). Positions follow the field's IndexOptions, which verifySameSchema keeps equal in every segment (FieldInfo.java:246); payloads are per segment. TestTermVectors.testMergeWithPayloads builds one segment with and one without payloads as a legal state (TestTermVectors.java:128, :137-171).
  • Null payloads are already normal. In a segment that has payloads, a position without one reaches the filter as null, and Lucene's own DelimitedPayloadTokenFilter produces such positions by design. Removing the check gives the filter no new kind of input.
  • The design reference does not do this. LUCENE-8694 (#9740) asked for "the intervals equivalent of SpanPayloadCheckQuery". SpanPayloadCheckQuery checks only positions (SpanPayloadCheckQuery.java:179-187) and handles a null payload per position (:220-230); TestPayloadTermQuery.testNoPayload (:273) expects a hit on a field without payloads.
  • Tests and history. "no indexed payloads" occurs in no test source; the only upstream test indexes one document whose every token has a payload. The hasPositions guard, in contrast, is asserted (TestIntervals.java:260-269). The check arrived with the class in 2019 (5ca25249274b) and was never discussed on #9740.
  • Precedent. #10794 / LUCENE-9755 (open): a search that fails on a segment without doc values for a field, the same failure class in different code.
The devil's-advocate check214 words

The strongest case for "works as designed": the check is deliberate. It copies the hasPositions guard to fail fast when someone filters on payloads of a field that was never indexed with payloads, and IllegalArgumentException is Lucene's usual signal for a misconfigured field. The multiterm factories also fail per segment, so their failure also changes with merges. On #7333 / LUCENE-6271 (2015) a maintainer wrote that Terms.hasPayloads() and its siblings "should be used for these checks up-front". A user who filters on payloads should give every document a payload, as the upstream test does.

Why it fails: the LUCENE-6271 remark is about checking capabilities up front in general and says nothing about segments that differ; a per-segment check cannot tell a misconfigured field from a segment that by chance holds no payloads, so what ships rejects legitimate indexes and lets the outcome depend on flush timing. The multiterm limit is a documented resource bound; this rejection is undocumented and depends on the data. No javadoc or test requires every document to carry a payload, Lucene's own payload filters emit positions without one, and the class's design reference, SpanPayloadCheckQuery, accepts such segments. The misconfiguration point is real, which is why an index-wide error is listed as an alternative fix. Verdict: not intended, a defect.

Suggested fix

Recommended A: remove the two per-segment checks and document that the filter can see null (validated)

Remove the two per-segment hasPayloads() checks in PayloadFilteredTermIntervalsSource and add one javadoc sentence: positions without a payload reach the filter as null, as they already do in a mixed segment. With it the repro and the queries module's 513 tests pass (0 failures); with assertions off, the intervals and payloads tests pass too.

// PayloadFilteredTermIntervalsSource.java, intervals() :57-62 and matches() :163-168
-    if (terms.hasPayloads() == false) {
-      throw new IllegalArgumentException(
-          "Cannot create a payload-filtered iterator over field "
-              + field
-              + " because it has no indexed payloads");
-    }
// Intervals.java, javadoc of both term(.., Predicate<BytesRef>) overloads (:80, :88)
-   * term's payload at each position
+   * term's payload at each position. Positions without a payload reach the filter as {@code null}.
All options and trade-offs3 options · 626 words

Option A was applied in a scratch worktree of 645d4fe14a20 and run with gradle (the worktree was removed after the runs) and shadowed into the main and 10.6.0 jars. Options B and C were not built.

A: remove the two per-segment checks and document that the filter can see null (validated) (recommended)

-12 lines in PayloadFilteredTermIntervalsSource.java (the hasPayloads() == false blocks at :57-62 in intervals() and :163-168 in matches()), +1 javadoc sentence on each Intervals.term(.., Predicate) overload. A segment without payloads then behaves like a segment whose positions have none: every position reaches the filter as null and the filter decides.

// PayloadFilteredTermIntervalsSource.java, intervals() :57-62 and matches() :163-168
-    if (terms.hasPayloads() == false) {
-      throw new IllegalArgumentException(
-          "Cannot create a payload-filtered iterator over field "
-              + field
-              + " because it has no indexed payloads");
-    }
// Intervals.java, javadoc of both term(.., Predicate<BytesRef>) overloads (:80, :88)
-   * term's payload at each position
+   * term's payload at each position. Positions without a payload reach the filter as {@code null}.

Trade-offs

  • The hit set no longer depends on segment layout; nothing new reaches the filter.
  • A field with no payloads in any segment (a real misconfiguration) no longer errors; it matches what the filter accepts for null, usually nothing.
  • A filter that dereferences the payload without a null check throws NullPointerException on payload-less segments after the fix; it already does on payload-less positions in mixed segments today, and the javadoc sentence says so. The upstream test's own filter (b -> b.utf8ToString().endsWith("5") == false, TestPayloadFilteredInterval.java:68) is such a filter; it stays green only because every token there has a payload.
  • Related registry row LFV-268 (under the default WEIGHT_MATCHES flag the highlighter's re-analysis drops payloads): its exception should turn into a missing highlight (read from the code; not run with this patch).

What the checks validated

  • Gradle, stock 645d4fe14a20: the repro fails with assertions on and off.
  • Gradle with the fix (seed DEADBEEF), assertions on: the whole :lucene:queries suite with the repro, 52 classes, 513 tests, 0 failures, 5 skipped (none of the 5 skipped tests is in the intervals package); the 8 intervals classes hold 118 of the tests, TestPayloadFilteredInterval and the repro included. Assertions off: the repro and the intervals and payloads packages, 152 tests, 0 failures. checkGoogleJavaFormat passes.
  • Shadowed into the stock jars by the standalone runner: the repro passes on main (-ea) and on the 10.6.0 release jars (-da).
  • Earlier, the finder's test and the security probe passed with the finder's patch (both checks removed, a two-line comment in place of each), the probe returning the correct hits; an independent verifier's test, run by the verifier and again by the adjudication, passed with the bare removal of the two checks.

B: keep an error only when no segment of the index has payloads for the field (not built)

Check once over the top-level reader, for example with ReaderUtil.getTopLevelContext(ctx) and FieldInfos.getMergedFieldInfos(reader), or where IntervalQuery.createWeight (IntervalQuery.java:122) sees the searcher.

Trade-offs

  • Keeps a fail-fast for a field never indexed with payloads, and stops the layout dependence once any segment has payloads.
  • IntervalsSource.intervals is also called directly with a leaf context, so a check in the source needs the top-level context on each call (merging field infos costs O(segments) per call); a check in IntervalQuery misses direct callers.
  • An index that so far holds only payload-less documents still throws, including the monitor module's index for a multi-document batch without payloads. It also departs from how a query on missing data usually behaves (no hits).

C: document the exception (not a fix)

Add @throws IllegalArgumentException to the factories and require a payload on every token.

Trade-offs

Keeps the layout-dependent failure; listed for completeness.

Downstream note

It fails closed (no extra hits), but in a shared index anyone who adds only payload-less documents between two refreshes can make searches and counts of payload-filtered interval queries on that field fail until a merge folds that segment into one with payloads.

How it was found

An AI finder extending its reference model of the intervals package to payload-filtered terms read the check and saw that it runs per segment on a flag that depends on the data. A probe with three one-document segments, the middle one without payloads, and a test comparing one segment per document with forceMerge(1) showed the failure.

The method in full124 words

Method: code reading (not a formal-methods finding). A hunt unit (intervals builder and multi-term sources) was extending its reference semantics to payload-filtered leaves; none of its bounded-exhaustive harnesses produced the signal.

  • The finder noticed that PayloadFilteredTermIntervalsSource checks hasPayloads() per segment, while the flag depends on which documents a segment holds.
  • A probe built three one-document segments, the middle one with hasPayloads() == false: the payload-filtered search threw, while a plain term count gave 3.
  • A public-API JUnit then checked that the hit set does not depend on segment layout (one segment per document against forceMerge(1)): it fails on main, 10.5.2, 9.12.3 and 9.1.0, and passes with the two checks removed.
  • The repro was later minimised to the one-method, two-document test shown above.

Prior reports, history and checks

Prior reports

No prior report found: 276 recorded read-only searches over GitHub issues and PRs (incl. the JIRA archive), commits, downstream projects, the mailing lists and the web, one of them a scan of the changed files of all 408 open PRs (2026-10-26); re-checked 2026-10-30. Closest: #9740 (origin, never discussed) and #10794 (same failure class for doc values).

Searches5 searches

Verdict new. The 14 hits read in full are related, not reports of this bug.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. the migrated JIRA archive), commits2026-10-26four scripted prior-report search runs (193 queries) and 43 GraphQL searches: the exception text, the class names, hasPayloads / storePayloads with segment and intervals wording, payload with intervals, filter, exception or segmentno report; closest #9740 / LUCENE-8694 (origin of the class and the check, never discussed), #12827 (a different equals fix in the same class), #10794 / LUCENE-9755 (open; the same failure class for doc values)
Open PRs2026-10-26changed-file lists of all 408 open apache/lucene PRsnone touches PayloadFilteredTermIntervalsSource.java
Downstream (Elasticsearch, OpenSearch, Solr, all of GitHub)2026-10-26GraphQL and REST searchesno report; downstream engines call only the one-argument Intervals.term
Mailing lists and JIRA2026-10-2625 exact-phrase queries over dev@, java-user@, issues@, commits@ (and Solr lists); 7 JIRA queriesonly LUCENE-8694 and #12827 notifications; the 2019 dev@ design thread 'Use of Payloads' says nothing about segments without payloads
Re-check2026-10-30GitHub search for the exception text and the class name; last commit on the file on main, branch_11x and branch_10_6nothing new; the file is unchanged since #12827 (2023)

History

Present since the API was added in 8.1.0 by LUCENE-8694 (13aac3becde7 on branch_8x, 5ca25249274b on master, 2019); the checks have not changed since. Executed failing on 8.1.0, 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main; 8.0.0 has no such API. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-8694 "IntervalsSource that filters by payload" (#9740; a JIRA patch committed by Alan Woodward, 5ca25249274b on master and 13aac3becde7 on branch_8x, committed 2019-03-18). The class, the factory and both checks arrived together. Later commits to the file (LUCENE-8477 f1782d0dd11, the move to the queries module in 8.2.0 by LUCENE-8856 46cc24d77ec, LUCENE-9099 7c1ba1aebee, the LUCENE-9570 reformat 8ef6a0da568 and #12827 4991e3a1f47) left the checks unchanged. Not a regression.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails, gradle and standalone, -ea and -da
10.6.0 affectedminimal repro fails on the release jars, -ea and -da
10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedthe finder's test fails (10.5.2 and 9.12.3 also with an independent verifier's test)
9.0.0, 8.11.4, 8.2.0 affectedfails (the finder's test with the test-framework import adapted); 8.2.0 is the first release in the queries module
8.1.0 first releasefails, -ea and -da (sandbox module, adapted test)
8.0.0 not affectedno Intervals.term(String, Predicate): compile error

Independent checks

Independent verifiers wrote or re-ran tests and both said real; read-only cross-model reviews agreed on the root cause; the adjudication re-ran a verifier's test with a new seed and confirmed it with high confidence.

Checks and verdicts290 words

Every executed check was run by a Claude AI tool; the reviewer from another model family (OpenAI GPT-5.6) could only read.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateProbe and 2-test JUnit: fails 1 of 2 on main (-ea, -da), 10.5.2, 9.12.3, 9.1.0; passes with the two checks removed.
Independent reproducer realOwn 5-test JUnit: fails 4 of 5 on main (-ea, -da), 10.5.2 and 9.12.3; search, count and Weight#matches on the payload-less segment throw, also when that segment lacks the term and with the default merge policy; passes 5 of 5 with only the checks removed.
cross-model review, refute lens (OpenAI GPT-5.6, read-only) not real, low, proceduralCould run nothing, which it needs to vote real; agreed with the root cause and called the static evidence strongly in favour of a defect. A separate re-run fails.
cross-model review, intended lens (OpenAI GPT-5.6, read-only) real, mediumIntended argument weak; cites TestTermVectors (mixed segments are legal) and testNoPayload. An executed supplement showed a payload-less position reaching the filter as null in a payloaded segment.
Intended-behaviour devil's advocate real, highBuilt the strongest "intended" case and showed why it fails; own reach test with a default config, a plain flush and an NRT reader throws.
adjudication confirmed, highRe-ran the independent reproduction's test with a new seed: 4 of 5 fail (-ea, -da); 5 of 5 pass with the checks removed.
this doc's runs reproduced; fix validatedGradle on 645d4fe14a20: the repro fails (-ea, -da); 10.6.0 fails; merged control gives 1; with fix A the repro and the queries module's tests pass.

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,344 words

Finding record

LFV-455, snapshot of 2026-11-06

Registry title
Intervals.term(term, payloadFilter) throws IllegalArgumentException ('no indexed payloads') for every segment in which no document indexed a payload for the field, so the whole search fails and whether it fails depends on segment layout
Area
queries / intervals (PayloadFilteredTermIntervalsSource per-segment hasPayloads check)
Priority
Low, rank 623: The rarely used payload-filter overload of Intervals.term throws for the whole index when one segment has no payloads, a loud opt-in crash.
Impact class
crash
Impact note
Only applications that call Intervals.term(String|BytesRef, Predicate<BytesRef>) (no Lucene query parser and, by public code search, no Elasticsearch, OpenSearch or Solr code builds it) on a field with sparse payloads are hit: once one segment holds only documents without a payload for that field, which ordinary flushes, commits and NRT refreshes produce with the default IndexWriterConfig, codec and merge policy, every search and count with that source throws IllegalArgumentException for the whole index (even when that segment does not contain the queried term) and Weight#matches throws for any document of that segment, and it keeps failing until a merge folds the segment into one that has payloads; -ea and -da, any index size, every release since 8.1.0; no silent wrong results, nothing written to the index.
Summary
Index a text field in which only some tokens carry payloads (for example with DelimitedPayloadTokenFilter: 'big|adj dog|noun' next to 'dog walks') and search it with Intervals.term("dog", payloadFilter). You expect the documents whose 'dog' payload passes the filter, however the documents happen to be split into segments. Instead, as soon as one segment contains only documents without payloads for that field, the whole search throws IllegalArgumentException 'Cannot create a payload-filtered iterator over field f because it has no indexed payloads', while the same documents after forceMerge(1) return the right hits.
Introduced
5ca25249274bd4232b0faa049814d2b3b192abdb (master; branch_8x backport 13aac3becde7770c8c4e6b33102432f7dee99a10), LUCENE-8694 'IntervalsSource that filters by payload' (now apache/lucene#9740), committed by romseygeek from the JIRA patch (no GitHub PR): the class was added together with both hasPayloads() == false checks, modelled on the hasPositions() check above them; the later commits to the file (f1782d0dd11 LUCENE-8477, 46cc24d77ec LUCENE-8856 move to the queries module, 7c1ba1aebee LUCENE-9099, 8ef6a0da568 LUCENE-9570 reformat only, 4991e3a1f47 #12827) left the checks unchanged [read: git blame of the guard lines on main, git log -S 'no indexed payloads' --all in the history clone, release-tag sweep 8.1.0..10.5.2] (2019-03-18 (commit date; author date 2019-02-15))
First affected version
8.1.0 [exec] (sandbox module, package org.apache.lucene.search.intervals; queries module from 8.2.0 [exec], LUCENE-8856); 8.0.0 has no Intervals.term(term, payloadFilter) [exec: compile error]
Affected versions
every release that has Intervals.term(term, payloadFilter): 8.1.0-8.1.1 (sandbox), 8.2.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.6.0 and main ea57360912ed and 645d4fe14a20 [exec: the finder's JUnit fails 1 of 2 on 8.1.0 (-ea, -da), 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 (-da) and main (-ea, -da), the 8.x and 9.0.0 runs with copies that differ only in the package line and test-framework import; the independent reproduction's JUnit fails 4 of 5 on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3 (-da); the minimal repro fails on the 10.6.0 release jars (4ae946e5a9b9, released 2026-10-29) and on main 645d4fe14a20, -ea and -da (2026-10-30)]; the point releases not executed rest on the tag sweep (both checks present in every tag 8.1.0..10.5.2) and, for 8.8.2-8.11.4, on a bytecode check of the release jars [read]; upstream main 49483e82b357 and the branch_10x and branch_10_6 heads still have both checks [read], and on 2026-10-30 the file's last commit on upstream main 716d6f216f52, branch_11x and branch_10_6 is still #12827 [read]; not fixed upstream; 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
non-formal: code reading
Upstream and prior reports
none found (2026-10-26; the prior-report record, verdict new, searched (finder) and (follow-up), all read-only: 276 recorded queries, namely four scripted prior-report searches (193 queries: GitHub issue and PR, commit, downstream-repository, mailing-list over dev@, java-user@, issues@ and commits@, JIRA-archive and web queries), 43 GitHub GraphQL searches over apache/lucene, the archived lucene-solr repository, apache/solr, Elasticsearch, OpenSearch and all of GitHub, 25 exact-phrase mailing-list and 7 JIRA queries, the changed files of all 408 open apache/lucene PRs (none touches PayloadFilteredTermIntervalsSource.java), git pickaxe and log checks; 14 hits read in full, none the same bug). Not fixed upstream: on upstream main 49483e82b357 and the branch_10x and branch_10_6 heads the file's last commit is still #12827 and both checks are present. Related, not reports of this bug: #9740 / LUCENE-8694 (origin of the class and both checks; the checks were never discussed), #12827 (2023, merged; a different equals fix in the same class), the dev@ thread 'Use of Payloads' (2019, the design discussion before LUCENE-8694; nothing on segments without payloads), #10794 / LUCENE-9755 (open; precedent: sorting fails on a segment without doc values for the field, answered by #10374 / LUCENE-9334 schema consistency, which leaves storePayloads per segment), #7333 / LUCENE-6271 and #8795 / LUCENE-7744 (background: fields and positions without payloads are a supported case).
Lucene commit tested
main 645d4fe14a20 (2026-10-28; PayloadFilteredTermIntervalsSource.java has the same last commit, #12827, on upstream main 716d6f216f52, branch_11x and branch_10_6 on 2026-10-30): the repro with gradle in a scratch worktree, stock with assertions on and off, and with the fix together with the whole :lucene:queries test suite (the worktree was removed after the runs); a standalone runner on main and on the 10.6.0 release jars, stock and with the fix; the finder's test on 8.1.0, 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; the independent tests of verifiers, one of them re-run by the adjudication with a new seed. JDK OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:45-68 (at ea57360912ed) (intervals(): hasPositions check :51-56, 'if (terms.hasPayloads() == false) throw new IllegalArgumentException(... no indexed payloads)' :57-62, seekExact only afterwards :63-66), :150-172 (matches(): the same two checks at :157-168, before seekExact at :169-172)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:78-92 (at ea57360912ed) (public factories term(String|BytesRef, Predicate<BytesRef>): 'intervals for a term, filtered by the value of the term's payload at each position'; no exception documented)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalsSource.java:35-45 (at ea57360912ed) (intervals(): 'Returns null if no intervals for this field exist in this segment')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java:170-172 (at ea57360912ed) (Weight#matches), :188-190 (scorerSupplier calls intervalsSource.intervals); lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:842 (searchLeaf calls scorerSupplier)
  • lucene/core/src/java/org/apache/lucene/index/FieldInfo.java:642-645 (at ea57360912ed) (hasPayloads: 'Returns true if any payloads exist for this field'), :244-269 (verifySameSchema checks index options, norms, term vectors, doc values, points and vectors, not payloads)
  • lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:67-73 (at ea57360912ed) (finish(): setStorePayloads only if sawPayloads), :83-93 (writeProx: sawPayloads set only for a non-empty payload); lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:774-780 (merging ORs the flag)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/payloads/DelimitedPayloadTokenFilter.java:59, (at ea57360912ed) :65 (payload set on delimited tokens, null on the others); lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:1338-1343 (getPayload returns null when the payload length is 0)
  • lucene/queries/src/java/org/apache/lucene/queries/payloads/SpanPayloadCheckQuery.java:179-187 (at ea57360912ed) (checks only hasPositions), :218-227 (null payload handled per position)
  • lucene/core/src/test/org/apache/lucene/index/TestTermVectors.java:137-171 (at ea57360912ed) (one segment with and one without payloads as a legal state; forceMerge sets the flag); lucene/queries/src/test/org/apache/lucene/queries/intervals/TestPayloadFilteredInterval.java (the only upstream test; every token has a payload); 'no indexed payloads' occurs in no test source
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:51-68, (at 645d4fe14a20) :124, :150-174
  • lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:67-73, (at 645d4fe14a20) :84-97; FieldInfo.java:642-645, :244-269; FieldInfos.java:771-781
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java:171-175, (at 645d4fe14a20) :189-190; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:842
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:1338-1343; (at 645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/payloads/DelimitedPayloadTokenFilter.java:59, :64-65
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/payloads/NumericPayloadTokenFilter.java:49; (at 645d4fe14a20) DelimitedPayloadTokenFilter.java:64-65
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestPayloadFilteredInterval.java:42; (at 645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:79-80, (at 645d4fe14a20) :87-88, :366-367; IntervalsSource.java:39; lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:51-56
  • lucene/core/src/java/org/apache/lucene/index/FieldInfo.java:244-269 (at 645d4fe14a20) (:246 verifySameIndexOptions)
  • lucene/core/src/test/org/apache/lucene/index/TestTermVectors.java:128, (at 645d4fe14a20) :137-171; lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:260-269; TestPayloadFilteredInterval.java
  • lucene/queries/src/java/org/apache/lucene/queries/payloads/SpanPayloadCheckQuery.java:179-187, (at 645d4fe14a20) :220-230; lucene/queries/src/test/org/apache/lucene/queries/payloads/TestPayloadTermQuery.java:273
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/ReaderUtil.java:37; (at 645d4fe14a20) FieldInfos.java:196; lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java:122
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestPayloadFilteredInterval.java:68 (at 645d4fe14a20) (filter b -> b.utf8ToString()...); lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/MemoryIndexOffsetStrategy.java:47-48, UnifiedHighlighter.java:1219-1224 (LFV-268 precondition)

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

Repro files

About the diagrams

Made in parallel with this page: a summary card (PNG), built from facts checked against the code, the minimal repro and an executed probe.

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.