The classic Highlighter keeps one fragmenter and calls its start() before every text. SimpleSpanFragmenter remembers in waitForPos a position before which it must not cut, so a phrase match stays whole; start() resets everything except that.
- The Highlighter walks the text token by token and, from the second token on, asks its fragmenter: start a new fragment here?
SimpleSpanFragmentersays yes when the text so far passes the next multiple of the fragment size (20, 40, 60 characters, ...), unless a query match is in progress: when a match starts, it setswaitForPosto the position after the match and says no until its counter gets there.- Before each text the Highlighter calls the fragmenter's
start(), documented as "Initializes the Fragmenter".
waitForPos = 11; start() for hit 2 keeps it, so the reused Highlighter makes no cut until its counter reaches 11: one 70-character fragment, then one-word fragments, where a new Highlighter cuts about every 20 characters. The fix is one line in start(). Select the still to open it at full size. Generated with AI, checked against the code.- Build one classic
Highlighterwith aQueryScorerandSimpleSpanFragmenter(scorer, 20)for the phrase"search library"(StandardAnalyzer), and reuse it for every hit, asHighlighteris designed for. - Highlight hit 1,
Lucene is an open source project that ships a search library. At the phrase the fragmenter setswaitForPos = 11: no new fragment until its position counter reaches 11. The text ends first, sowaitForPosstays 11. - Highlight hit 2,
Highlighting picks short fragments of text around each match so that a reader of the search library manual .... You expect what a new Highlighter gives:Highlighting picks | short fragments of | text around each | match so that a reader | .... - You get
Highlighting picks short fragments of text around each match so that a(70 characters), thenreader | of | the, one word each:start()reset the counter for hit 2 but notwaitForPos, so no cut came before the counter reached 11, and then the fragment count caught up word by word. - No error, and only snippets change, never hits or scores; they depend on what the same Highlighter saw before. In the test's longer case at the default fragment size 100, the best fragment grows from 114 to 491 characters.
Code that reuses one classic Highlighter with the opt-in SimpleSpanFragmenter for several hits, fields or values, and highlights a phrase, multi-phrase or span query; term queries never trigger it and the default SimpleFragmenter is safe. Snippet layout only: hits and scores are unchanged. Every release since 2.4.0; rare.
Mechanism, terms and numbers763 words
The classic Highlighter keeps one Fragmenter and calls its start(text, tokenStream), documented as "Initializes the Fragmenter", before every text. SimpleSpanFragmenter, the span-aware fragmenter that keeps phrase matches whole, stores in waitForPos the position just after a match that has started and refuses to start a new fragment until its position counter gets there. Its start() resets the counter, the fragment count, the text size and the attributes, but not waitForPos.
So when a phrase, multi-phrase or span match ends a text, the next text highlighted by the same Highlighter inherits the pending position: no fragment boundary until the counter reaches it, then one-word fragments while the fragment count catches up, a different best fragment, and sometimes a phrase split in two. With the phrase "search library" at size 20 the next text's first fragment is 70 characters; at the default size 100 the best fragment is 491 characters instead of 114. Search hits and scores are unaffected. The omission dates from the class's creation in 2008 (2.4.0) and reproduces on every release to 10.6.0 and on main; the fix is one line.
- Fragmenter: the policy that cuts a text into fragments.
Highlighterholds one (Highlighter.java:44, defaultSimpleFragmenter;setTextFragmenter,:423-425) and callsstart(text, tokenStream)at the start of everygetBestTextFragmentscall (:194), which everygetBestFragment(s)method reaches.Fragmenter#start: "Initializes the Fragmenter. You can grab references to the Attributes you are interested in from tokenStream and then access the values inisNewFragment()" (Fragmenter.java:28-35). SimpleSpanFragmenter: "breaks text up into same-size fragments but does not split up {@link Spans}. This is a simple sample class." (SimpleSpanFragmenter.java:26-29). It gets the query's matches in the current text fromQueryScorer, which re-extracts them for every text ininit(QueryScorer.java:170-181, called atHighlighter.java:175) and stores each asPositionSpan(start, end)with an inclusive end (WeightedSpanTermExtractor.java:332).
isNewFragment()adds the token's position increment toposition(SimpleSpanFragmenter.java:63). WhilewaitForPos > positionit returns false at once, before the span lookup and the size test (:65-69).- When a span of the current token starts at
position, it setswaitForPos = positionSpan.end + 1(:76-81). - Otherwise a new fragment starts when the token's end offset reaches
fragmentSize * currentNumFragsand at least half a fragment of text is left (:84-90). - The Highlighter calls
isNewFragment()only when a new token arrives (Highlighter.java:209-224) and flushes the last token group without another call (:241-254). A span that ends the text therefore leaveswaitForPosset. start()resetsposition = -1,currentNumFrags = 1,textSizeand the three attributes (:98-106), notwaitForPos(:36). The next text gets no boundary until its counter reaches the old value; then the size test, still at the first fragment's limit, fires at every token untilcurrentNumFragscatches up; and while the stale wait holds, a match that starts is never registered, so it can be cut in two.
| case | new Highlighter | reused Highlighter |
|---|---|---|
| hit 2 after hit 1 (size 20) | Highlighting picks | short fragments of | text around each | match so that a reader | of the <B>search</B> <B>library</B> manual | sees why a document matched | Highlighting picks short fragments of text around each match so that a (70 characters) | reader | of | the | <B>search</B> <B>library</B> manual | sees why a document matched |
| hit 2 without the phrase | Highlighting picks | short fragments of | text around each | match so that a reader | sees why a document matched | Highlighting picks short fragments of text around each match so that a | reader | sees | why | a document matched |
| hit 1 highlighted twice | 1st call: Lucene is an open | source project that | ships a <B>search</B> <B>library</B> | 2nd call: the whole 60-character text as one fragment |
default size 100, getBestFragment, phrase "quick fox" | 114 characters: x52 ... x59 <B>quick</B> <B>fox</B> y0 ... y16 | 491 characters: x0 x1 ... x59 <B>quick</B> <B>fox</B> y0 ... y61 |
control: SimpleFragmenter(20) reused | same as new | same as new |
In hit 1, "search library" is at token positions 9-10. The fragmenter's counter runs one token behind (the sibling LFV-859), so it sees the match start at library and sets waitForPos = 10 + 1 = 11. In hit 2 the counter reaches 11 at the 13th word, reader: the first 12 words are one fragment.
Exposure and likelihood263 words
- Who is exposed. Code that reuses one classic
HighlighterwithSimpleSpanFragmenter(opt-in; the default isSimpleFragmenter,Highlighter.java:44) for several texts: hits, fields, or the values of a multi-valued field. Reuse is the Highlighter's design (one fragmenter field,start()per text) and the package example's pattern (package-info.java:36-59, one Highlighter for 10 hits and two fields, with the default fragmenter). - What it takes. A positional query (phrase,
MultiPhraseQueryor span query, also inside aBooleanQuery) whose match ends the previous text (its last token, or with the stock counter also the second-to-last), and a previous text long enough that the stale position lies beyond the next text's first fragment boundary (at size 100 roughly more than one fragment of previous text, by reading). A plainTermQueryorSynonymQueryhas no position spans and never setswaitForPos. - What the damage looks like. One oversized fragment (70 characters at size 20; 491 instead of 114 at the default size 100), then one-word fragments, a different best fragment, and sometimes a phrase split across fragments (1,632 such splits in the finder's bounded check of tiny texts). Repeating
getBestFragmentson the same text gives a different answer the second time. Silent: no error. - What is not affected. Search hits, counts and scores;
SimpleFragmenterandNullFragmenterreused (same output as new ones). - How likely. Rare: opt-in fragmenter, reuse, a positional query and a previous text that ends with a match. No user report has been attributed to it in 18 years.
- Scale. Nothing depends on index size or concurrency; two short strings reproduce it.
The test shows that a classic Highlighter with SimpleSpanFragmenter that is reused after a text ending in a phrase match cuts the next text differently from a fresh Highlighter, because SimpleSpanFragmenter.start() does not reset waitForPos.
/*
* 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.search.highlight;
import java.util.Arrays;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.search.PhraseQuery;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestSimpleSpanFragmenterReuse extends LuceneTestCase {
private static Highlighter newHighlighter() {
QueryScorer scorer = new QueryScorer(new PhraseQuery("body", "search", "library"), "body");
Highlighter highlighter = new Highlighter(scorer);
highlighter.setTextFragmenter(new SimpleSpanFragmenter(scorer, 20));
return highlighter;
}
private static String fragments(Highlighter highlighter, Analyzer analyzer, String text)
throws Exception {
TextFragment[] frags =
highlighter.getBestTextFragments(analyzer.tokenStream("body", text), text, false, 100);
Arrays.sort(frags, (a, b) -> Integer.compare(a.getFragNum(), b.getFragNum()));
return Arrays.toString(frags);
}
public void testReusedHighlighterFragmentsLikeAFreshOne() throws Exception {
String first = "Lucene is an open source project that ships a search library";
String second =
"Highlighting picks short fragments of text around each match so that a reader of the"
+ " search library manual sees why a document matched";
try (Analyzer analyzer = new StandardAnalyzer()) {
Highlighter reused = newHighlighter();
fragments(reused, analyzer, first);
// expected: the fragments of a fresh Highlighter; actual: one 70-char fragment, then 1-word ones
assertEquals(
fragments(newHighlighter(), analyzer, second), fragments(reused, analyzer, second));
}
}
}
# in a copy of this finding's repro folder (repro/LFV-861/, 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/highlighter/build/libs/lucene-highlighter-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$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.search.highlight.TestSimpleSpanFragmenterReuse
- Expected vs actual
- Expected the fresh Highlighter's fragments
[Highlighting picks, short fragments of, text around each, ...]; actual[Highlighting picks short fragments of text around each match so that a, reader, of, the, ...](one 70-char fragment, then one-word ones), with-eaand-da.
More files, environment, full output65 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> org.junit.ComparisonFailure: expected:<[Highlighting picks[, short fragments of, text around each, match so that a reader, of the] <B>search</B> <B>li...> but was:<[Highlighting picks[ short fragments of text around each match so that a, reader, of, the, ] <B>search</B> <B>li...>
org.junit.Assert.assertEquals(Assert.java:117)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.search.highlight.TestSimpleSpanFragmenterReuse.testReusedHighlighterFragmentsLikeAFreshOne(TestSimpleSpanFragmenterReuse.java:51)Fragmenter#start"Initializes the Fragmenter";Highlightercalls it on its one fragmenter before every text (Highlighter.java:44,:194).start()resets every other per-text field; an upstream test reuses oneSimpleSpanFragmenterfor two documents; its synonym query has no phrase, so it never sees the bug.- A "fragment way too long"
waitForPosbug was fixed before (LUCENE-2229, 5.4.1).
The full argument261 words
- Contract.
Fragmenter#start: "Initializes the Fragmenter" (Fragmenter.java:28-35).Highlighterkeeps one fragmenter (:44,:423-425) and callsstartfor every text (:194). The contract is implicit: no javadoc says in so many words that a reused fragmenter must cut a text as a new one does; the case rests onstart()being the per-text initializer, the one-fragmenter design and the points below. - The class itself.
start()resetsposition,currentNumFrags,textSizeand the attributes (SimpleSpanFragmenter.java:100-105).waitForPosholds a position of the previous text, which means nothing in the next one:QueryScorer.initre-extracts the spans for every text (QueryScorer.java:170-181).SimpleFragmenter.startresets its own state (SimpleFragmenter.java:47-50). - Tests.
TestHighlighter#testHighlightingSynonymQueryreuses one Highlighter and oneSimpleSpanFragmenterfor documents 2 and 3 (TestHighlighter.java:238-258, reuse at:248-256); aSynonymQueryhas no position spans, so it never sees the bug. No test or javadoc says a fragmenter is single-use. - Intent. Michael McCandless asked on LUCENE-1685 (#2759, 2009-06-12) for a way "to not have to make a SpanScorer (and, by extension, fragmenter) per query, but instead make it up-front", and later "Should we also default the fragmenter to SimpleSpanFragmenter?" LUCENE-1685 made the span-aware
QueryScorerreusable in 2.9.0; the fragmenter's own state was not discussed. - Precedent. LUCENE-2229 (#3305): "Fix Highlighter's SimpleSpanFragmenter when multiple adjacent stop words following a span can unduly make the fragment way too long" (
lucene/CHANGES.txt:9891-9893, 5.4.1), with the test assertion "Fragment must be less than 60 characters long" (TestHighlighter.java:435). It changed the samewaitForPoscheck, within one text.
The devil's-advocate check134 words
The intended-behaviour checker's strongest case: the javadoc calls the class "a simple sample class", and most upstream tests build a new SimpleSpanFragmenter per document, which could suggest single use.
Why it fails: the per-document line in those tests is a leftover of the 2008 design, when the SpanScorer took one text's token stream in its constructor and had to be built per document, together with its fragmenter; in the LUCENE-1685 commit 10b41d2dce2 the HighlighterTest diff moves the scorer and the Highlighter out of the loop and keeps the setTextFragmenter(new SimpleSpanFragmenter(...)) line inside it, changing only its argument. The class is maintained public API (LUCENE-1389 changed it in 2.9.0, LUCENE-2229 fixed it in 5.4.1), "sample" does not license output that depends on an unrelated earlier text, and the cross-model reviewer rated the intended-behaviour argument weak.
Recommended Reset waitForPos in start() (one line)
Add waitForPos = -1; to SimpleSpanFragmenter.start(). All 5 tests then pass, and so does the whole highlighter test task (722 upstream tests), with assertions on and off; the module's lint and format checks pass. A fragmenter's first text behaves as before (the field already starts at -1).
public void start(String originalText, TokenStream tokenStream) {
position = -1;
+ waitForPos = -1;
currentNumFrags = 1;
textSize = originalText.length();All options and trade-offs3 options · 322 words
One fix, validated with gradle on 645d4fe14a20; a workaround for released versions; and one alternative that is not recommended.
one added line after position = -1; in SimpleSpanFragmenter.start(). It makes start() initialize the last per-text field it missed.
public void start(String originalText, TokenStream tokenStream) {
position = -1;
+ waitForPos = -1;
currentNumFrags = 1;
textSize = originalText.length();Trade-offs
None found. The first text of every fragmenter is unchanged; later texts get what a new fragmenter gives; no cost. Independent of the sibling fixes (LFV-858, LFV-859): the finder's test and 2 of the independent reproduction's tests still fail with both sibling fixes applied, and pass with this one.
What the checks validated
- Gradle, scratch worktree at
645d4fe14a20: the fix plus the paste-ready test plus the finder's and the independent reproduction's tests, the whole:lucene:highlighter:testtask with assertions on and off: 37 classes, 733 tests, 0 failures (34 upstream classes with 722 tests,TestHighlighter59; the 3 reproducing classes 5 + 1 + 5 tests). ecjLintMain,ecjLintTestandcheckGoogleJavaFormatof the highlighter module pass (eclintand error-prone were not configured in that run and did not apply).- Before the fix the paste-ready test fails 4 of 5 (the control passes). The same reset shadowed into 2.4.0, 2.9.0 and main makes the history repro pass.
Call highlighter.setTextFragmenter(new SimpleSpanFragmenter(scorer, size)) before each text.
Trade-offs
One allocation per text; every caller has to know about it.
What the checks validated
The history repro's control does exactly this and matched a new Highlighter in every run.
A javadoc note instead of a code change.
Trade-offs
Contradicts Fragmenter#start ("Initializes the Fragmenter"), the one-fragmenter design of Highlighter and the upstream test that reuses one; a one-line reset is simpler than a new usage rule.
Downstream servers whose opt-in plain highlighter keeps one Highlighter with a SimpleSpanFragmenter per field for all hits of a fetch phase, as Elasticsearch's and OpenSearch's do by a reading of their code (not executed), would show it across hits and the values of multi-valued fields.
Found while modelling the classic Highlighter's fragment loop: writing a corrected copy of SimpleSpanFragmenter showed that start() resets every per-text field except waitForPos. A bounded-exhaustive check on the stock jar then found 160,425 of 1,048,320 small text pairs where a reused Highlighter cuts the second text differently from a new one.
The method in full153 words
Method: model-based, bounded-exhaustive differential check on the stock jar (the study's "semi-formal" tier). A hunt unit, a transducer model of Highlighter.getBestTextFragments composed with SimpleSpanFragmenter, 2026-10-28, main a9779ef3c193.
- While writing the fix-sketch shadow class for the model, the finder read that
start()resetspositionandcurrentNumFragsbut notwaitForPos. ReuseProbethen ran every pair (first text of at most 4 tokens, second text of at most 6 tokens, over {a, c, x}) withPhraseQuery(a c)and fragment sizes 1 to 8 through a reused and a new Highlighter: 160,425 of 1,048,320 second-text results differ, 1,632 of them split the phrase only when reused. Smallest example: size 4, first texta c, second texta a a a: reused|a a a a|, new|a a| a a|.- An AI finder then wrote the public-API JUnit; independent AI tools reproduced, reviewed, judged and audited it (see the checks).
No prior report found (searched 2026-10-28, re-checked 2026-10-29): GitHub issues, PRs and commits, the JIRA archive, the dev@ and java-user@ lists, downstream trackers and the web. The nearest item, LUCENE-2229 (#3305), is a different waitForPos bug, fixed in 5.4.1.
Searches4 searches
Nothing reports that SimpleSpanFragmenter keeps waitForPos from one text to the next, or that a reused Highlighter fragments a text differently from a new one. The last commit to SimpleSpanFragmenter.java on main is 54fba99cb13 (2022-09-24, formatting); no open PR touches it.
Related, not the same bug: #3305 / LUCENE-2229 (fixed 5.4.1: the same field, a stop-word gap within one text); #6083 / LUCENE-5019 (open: long fragments after a stop word within one text); #2463 / LUCENE-1389 (2008: changed start() without resetting waitForPos); #2759 / LUCENE-1685 (made QueryScorer reusable); #1869 / LUCENE-794 (introduced the class); #15333 and open PR #15346 (zero-score fragment merging); #6444 / LUCENE-5381; #3661 / LUCENE-2587; java-user threads of 2015-08, 2019-05, 2011-07, 2009-11, 2016-10 and 2010-05 (other causes); elastic/elasticsearch #7944, #11820 and #1072 (LUCENE-2229 era). Lucene.NET's port has the same omission, with no issue there.
Possibly related, unverified: later comments on elastic/elasticsearch#9442 (downstream releases 5.4.1, 5.5.0, 6.2.3) report oversized plain-highlighter fragments that go away with the simple fragmenter, with no repro or cause; they fit this bug and equally other causes.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-28 | three runs of a scripted prior-report search (identifiers, symptoms, downstream; 16 issue/PR and 3 commit queries each) and 27 manual searches, plus the changed-file lists of all 410 open PRs | no report; nearest #3305 (LUCENE-2229), a different mechanism |
| Mailing lists and JIRA archive | 2026-10-28 | 20 list queries per tool run over dev@, java-user@, issues@ and commits@; 4 JIRA queries per run; threads read in full | no report; six java-user threads with other causes |
| Downstream trackers and the web | 2026-10-28 | elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr and apache/lucenenet on GitHub; 18 web queries | no report naming the cause; elastic/elasticsearch#9442 possibly related, unverified |
| Re-check (GitHub REST listing; the search API was rate-limited) | 2026-10-29 | the 25 issues and PRs updated since 2026-10-28 and the files of their PRs; commits to the file and the package | nothing about this bug; #16936 edits the package example only |
Present since the class was written (35c7eb36dfb, LUCENE-794, 2008-04-29) and first released in 2.4.0. A history repro fails on all 149 releases from 2.4.0 to 10.5.2, on 10.6.0 (branch_10_6 4ae946e5a9b9) and on main; never fixed, not a regression.
Versions tested7 rows
Introduced by 35c7eb36dfb "Commit of LUCENE-794 patch - adding phrase/span query support to highlighter" (svn r652164, 2008-04-29; LUCENE-794 is #1869; no CHANGES entry). Git records Mark Harwood (@markharwood) as author; the patch is Mark Miller's (@markrmiller), who opened #1869 with it, and Mark Harwood closed the issue with "Committed as part of r652164". It created SimpleSpanFragmenter with private int waitForPos = -1; and a start() that reset only position and currentNumFrags; git log -S waitForPos finds no later commit that adds a reset.
In 2.4.x the span scorer was bound to one text, so the leak showed when the same text was highlighted again after SpanScorer.reset(). Since 2.9.0 (LUCENE-1695 f73a4f43242, LUCENE-1685 10b41d2dce2 / #2759) one Highlighter is reused across texts without any reset call. LUCENE-1389 (c2f7d554a3e, 2.9.0) and LUCENE-2229 (5.4.1) changed the logic around the field without fixing it.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | this doc's test fails 4 of 5 (gradle and runner, assertions on and off) |
main a9779ef3c193, branch_10_6 4ae946e5a9b9 | affected | the finder's test fails 1 of 1 (assertions on and off) and the independent reproduction's 3 of 5 (main on and off; branch_10_6 with assertions on) |
| 10.5.2, 9.12.3 | affected | this doc's test fails 4 of 5 (10.5.2 also with assertions off); the finder's and the independent reproduction's tests fail too |
| 8.0.0, 8.11.4, 9.0.0, 9.1.0, 10.0.0 | affected | the finder's test fails 1 of 1 (assertions on; 8.11.4 also off; on 8.x and 9.0.0 as a port that changes only the LuceneTestCase import); this doc's test fails 4 of 5 on 9.1.0 |
| 2.4.0 to 10.5.2 (149 releases) | history repro | a version-independent main() repro fails on every release; from 2.9.0 on with byte-identical fragments |
| 2.3.2 | API absent | no SimpleSpanFragmenter; the repro does not compile |
| 2.4.0, 2.9.0, main + fix | fixed | the repros pass with waitForPos = -1 in start() |
An independent reproducer and a credibility audit wrote and ran their own tests, a devil's advocate and the adjudication re-ran them, and all found it real; the adjudication confirmed it with high confidence. Reviews by another model family could not run code; the one with a verdict read the code and agreed.
Checks and verdicts312 words
Every check except the cross-model reviews ran its own test against the stock jars; "(on and off)" marks runs with assertions both on and off.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-API JUnit fails 1 of 1 on main (on and off), branch_10_6, 10.5.2 and 9.12.3, and still with both sibling fixes applied; passes with the one-line fix. |
| Independent reproducer | real | Own JUnit fails 3 of 5 on main (on and off), 10.5.2, 9.12.3 and branch_10_6; its 2 controls pass; the fix passes 5 of 5; 2 of 5 still fail with only the sibling fixes. |
| cross-model review, refute lens | no review (procedural) | An independent reviewer from another model family (OpenAI GPT-5.6): No command ran, so its "not real" is procedural; its own notes call it "a procedural not real, not a substantive refutation" and ask for a rerun when command execution works. |
| cross-model review, intended lens | real (medium), read only | OpenAI GPT-5.6 reading only: root cause agrees, inputs in contract, intended-behaviour argument weak; it ran no code and had seen the independent reproduction and the intended-behaviour check verdicts. |
| Devil's advocate | real (high) | Rebutted "simple sample class", per-document tests, misuse and unreachability; the finder's test fails on main (on and off), 10.5.2 and 9.12.3. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's test with seed C0FFEE31: 3 of 5 fail on and off; the fix passes 5 of 5. Classed minor (layout only). |
| registry re-run and credibility audit | keep | Registry re-runs fail as before (seed 5EED0329); the audit's own default-size main() gives 495 vs 114 characters on main and 10.5.2 and passes with the reset. |
| this doc's runs | reproduced; fix validated | New test fails 4 of 5 on main 645d4fe14a20 (gradle and runner, on and off), 10.5.2 and 9.12.3; with the fix the whole highlighter test task passes. |
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 546 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,427 words
- Registry title
- Classic Highlighter's SimpleSpanFragmenter.start() does not reset waitForPos, so a Highlighter reused for the next text (or for the same text again) keeps suppressing fragment boundaries after a phrase match that ended the previous text: one oversized fragment, then one-word fragments and a different best fragment, depending on what was highlighted before
- Area
- highlighter / classic Highlighter fragmenting (SimpleSpanFragmenter.start, state carried across texts)
- Priority
- Low, rank 545: This sibling of LFV-859 in the opt-in SimpleSpanFragmenter only changes snippet layout, and it fires whenever a reused Highlighter's previous text ended in a phrase match, which is more ordinary than LFV-858's overlapping-phrase case.
- Impact class
- minor
- Impact note
- Applications that reuse one classic Highlighter with QueryScorer and SimpleSpanFragmenter for several texts (opt-in fragmenter; reusing one Highlighter is the pattern of the package example, and a downstream plain highlighter caches one per field, read not executed) and run a phrase, span or multi-phrase query get different fragment boundaries in a text whenever the previous text ended with a match and was long enough for the stale position to pass the next text's first boundary; highlighting output only (hits and scores are unaffected), no error, no scale needed, in every release since 2.4.0.
- Summary
- You reuse one classic Highlighter with a QueryScorer and a SimpleSpanFragmenter for several texts (Highlighter keeps one fragmenter and calls its start(), documented as 'Initializes the Fragmenter', for every text) and expect each text to be cut into the same fragments as with a fresh Highlighter. Instead start() resets the position and fragment count but not waitForPos, the position the fragmenter is still waiting for, so when a phrase match ended the previous text no boundary is allowed in the next text until it reaches that old position: with the phrase 'search library' at the end of the first text and fragment size 20, the next text starts with one 70-character fragment 'Highlighting picks short fragments of text around each match so that a', then one-word fragments ', reader', ', of', ', the', where a fresh Highlighter gives 'Highlighting picks', ' short fragments of', ' text around each', and getBestFragment also differs. Hits and scores are unaffected; only the snippet layout changes, and it depends on what the same Highlighter highlighted before.
- Introduced
- 35c7eb36dfb07ff919147d7570158433e48cd092 (svn r652164, 'Commit of LUCENE-794 patch - adding phrase/span query support to highlighter'; LUCENE-794 = apache/lucene#1869, patch by markrmiller, committed by markharwood; no CHANGES entry) [read: git blame -w -M -C and git log -S/-G waitForPos in the history clone: the commit creates SimpleSpanFragmenter with 'private int waitForPos = -1;' and a start() that resets only position and currentNumFrags, and no later commit on any ref adds a reset; plus a shadow run of the commit's 24 highlighter classes over the 2.4.0 jars, which fails]. Not a regression. Cross-text reuse became the normal usage in 2.9.0 (f73a4f43242 LUCENE-1695, Scorer.init per text; 10b41d2dce2 LUCENE-1685 / #2759, reusable QueryScorer); before that the leak shows on the documented repeat call on the same text after SpanScorer.reset() (2008-04-29 (35c7eb36dfb, author date = commit date 2008-04-29T22:07:18Z))
- First affected version
- 2.4.0 [exec] (tagged 2008-10-08; 2.3.2, the last earlier release, has no SimpleSpanFragmenter: the history repro does not compile there)
- Affected versions
- Every release from 2.4.0 to 10.5.2, the branch_10_6 head 4ae946e5a9b9 (10.6.0, released 2026-10-29) and main a9779ef3c193 [exec: the history repro SpanFragmenterReuseHistory (main(), WhitespaceAnalyzer, PhraseQuery 'search library', fragment size 20; five API-era source variants with the same logic) fails on all 149 releases 2.4.0-10.5.2, on branch_10_6 and on main with -ea (151 runs) and on the 12 versions also run with -da; from 2.9.0 on every run prints byte-identical fragments; in 2.4.x only the same-text-twice leg applies; the finder's JUnit fails on 8.0.0-main (14 runs, -ea and -da), the independent reproduction's JUnit fails 3 of 5 on main (-ea, -da), branch_10_6, 10.5.2 and 9.12.3; the one-line fix shadowed in passes on 2.4.0, 2.9.0 and main]. Not fixed: no upstream commit to SimpleSpanFragmenter.java since 54fba99cb13 (author and commit date 2022-09-24, formatting) on main (head c5ab3e57b9b) or branch_10_6 [read, GitHub API, 2026-10-28].
- 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: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-28, read-only; the prior-report record, verdict new: three scripted prior-report searches (identifiers, symptoms, downstream), each with 16 GitHub issue/PR queries on apache/lucene, 3 commit queries, 2 downstream-repository queries, 20 mailing-list queries over dev@, java-user@, issues@ and commits@ and 4 JIRA-archive queries, plus 18 web queries; 27 distinct manual GitHub searches over apache/lucene, apache/lucenenet, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch; the file lists of all 410 open apache/lucene PRs. Re-check through the REST API (the search API was rate-limited): the 15 apache/lucene PRs created since 2026-10-26 (#16907 to #16926) touch no highlighter file; no title among the 76 issues and PRs updated since 2026-10-26 mentions highlighting, fragments or spans; the last commit to SimpleSpanFragmenter.java is 54fba99cb13 (author and commit date 2022-09-24, formatting), the last to the highlight package bfe5c847d3c (2026-07-27, unrelated) on main and 056feb02df6 (2026-05-14, unrelated) on branch_10_6. No issue, PR, review comment, JIRA entry, list thread or downstream report says that SimpleSpanFragmenter keeps waitForPos from one text to the next, or that a reused Highlighter fragments a text differently from a fresh one. Related, different: #3305 / LUCENE-2229 (fixed 2015, the same field, a within-text stop-word gap, '==' -> '<='), #6083 / LUCENE-5019 (open, long fragments after a stop word within one text), #2463 / LUCENE-1389 (2008, changed start() without resetting waitForPos), #2759 / LUCENE-1685 (made QueryScorer reusable; supports that reuse is intended), #1869 / LUCENE-794 (introduced the class), #15333 and open PR #15346 (zero-score fragment merging), #6444 / LUCENE-5381, #3661 / LUCENE-2587; java-user threads of 2015-08 (end-of-text half-fragment rule), 2019-05, 2011-07, 2009-11, 2016-10 and 2010-05 (other causes); elastic/elasticsearch #7944, #11820 and #1072 (LUCENE-2229 era). Possibly related, unverified: later comments on elastic/elasticsearch#9442 (downstream releases 5.4.1, 5.5.0 and 6.2.3) report oversized plain-highlighter fragments that go away with the simple fragmenter, with no repro or cause; they are symptom-compatible with this bug and equally with other causes. Lucene.NET's port has the same omission, with no issue.
- 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, assertions on and off; the 10.5.2 (on and off) and 9.12.3 release jars. The hunt's own runs: maina9779ef3c193(identical highlighter sources), branch_10_64ae946e5a9b9, seven releases from 8.0.0 to 10.5.2 (JUnit), and a historymain()repro on all 149 releases 2.4.0-10.5.2. The fix was validated with gradle on645d4fe14a20.
- Finding record
lucene/highlighter/src/java/org/apache/lucene/search/highlight/SimpleSpanFragmenter.java:26-29 (at a9779ef3c193) (class javadoc 'breaks text up into same-size fragments but does not split up {@link Spans}. This is a simple sample class.'), :36 ('private int waitForPos = -1;'), :65-69 (while waitForPos > position, return false before the span lookup and the size test), :76-80 (waitForPos = positionSpan.end + 1), :98-106 (start() resets position, currentNumFrags, textSize and the attributes, not waitForPos)lucene/highlighter/src/java/org/apache/lucene/search/highlight/Fragmenter.java:28-35 (at a9779ef3c193) (start: 'Initializes the Fragmenter.'); Highlighter.java:44 (one textFragmenter field), :194 (textFragmenter.start(text, tokenStream) on every getBestTextFragments call), :423-425 (setTextFragmenter); package-info.java:37-52 (example reuses one Highlighter for 10 hits and two fields); lucene/highlighter/src/test/org/apache/lucene/search/highlight/TestHighlighter.java testHighlightingSynonymQuery (one SimpleSpanFragmenter reused for two documents, lines 248-256)
- The finding in 30 seconds
lucene/highlighter/src/java/org/apache/lucene/search/highlight/SimpleSpanFragmenter.java:26-29, (at 645d4fe14a20) :36, :62-93 (:63, :65-69, :76-81, :84-90), :98-106lucene/highlighter/src/java/org/apache/lucene/search/highlight/Fragmenter.java:28-35; (at 645d4fe14a20) Highlighter.java:44, :175, :194, :209-224, :241-254, :423-425; QueryScorer.java:170-181; WeightedSpanTermExtractor.java:332
- Who is affected
lucene/highlighter/src/java/org/apache/lucene/search/highlight/Highlighter.java:44, (at 645d4fe14a20) :194, :423-425; package-info.java:36-59
- Why it looks unintended
lucene/highlighter/src/java/org/apache/lucene/search/highlight/Fragmenter.java:28-35; (at 645d4fe14a20) Highlighter.java:44, :194, :423-425; SimpleSpanFragmenter.java:26-29, :100-105; QueryScorer.java:170-181; SimpleFragmenter.java:47-50lucene/highlighter/src/test/org/apache/lucene/search/highlight/TestHighlighter.java:238-258 (at 645d4fe14a20) (testHighlightingSynonymQuery), :415-437 (LUCENE-2229 test, :435)lucene/CHANGES.txt:9859 (at 645d4fe14a20) (5.4.1 section), :9891-9893 (LUCENE-2229)https://github.com/apache/lucene/issues/2759 (LUCENE-1685 comments of 2009-06-12 and 2009-08-06 by Michael McCandless; read via the GitHub API 2026-10-29)
- History
https://github.com/apache/lucene/issues/1869 (LUCENE-794, migrated from JIRA, by Mark Miller; comment of 2008-04-29 by Mark Harwood "Committed as part of r652164"; read via the GitHub API 2026-10-29)
- TestSimpleSpanFragmenterReuse.java (JUnit test):
repro/LFV-861/src/org/apache/lucene/search/highlight/TestSimpleSpanFragmenterReuse.java(55 lines)
A light, step-by-step picture of the size-20 example, made in parallel with this doc from executed runs on main 645d4fe14a20 and the code.
assets/LFV-861/bug.png(546 KB)
