SimplePatternTokenizer tries its pattern at each position and keeps the longest match. When nothing matches, it should move one character on and try again. It does, unless the failed attempt ran all the way to the end of the text: then it stops, and every later token is lost.
don't stop me runs off the end of the text without a match; line 152 ends tokenization, so t, stop and me are never tried, while a dead state would retry one character later. Select the still to open it at full size. Generated with AI, checked against the code.- Index
don't stop mewithSimplePatternTokenizer(factory namesimplePattern) and the pattern'[^']*'|[a-z]+: a single-quoted phrase or a word. The apostrophe at offset 3 opens a quote that never closes. - You expect
don,t,stop,me: the javadoc says the longest match at each start point is the next token, and the class's own comment says that where nothing matches it skips one character and tries again.PatternTokenizerwith the same regex gives exactly these four. - You get only
don. From the apostrophe the scan stays alive to the end of the text without ever matching, and the end-of-input branch returns false instead of retrying att. ATermQueryforstopfinds 0 documents instead of 1. - The smallest case: pattern
abc|bonabgives no token instead ofb. Onabx, where the scan dies atxbefore the end, it correctly givesb. SimplePatternSplitTokenizerhas the same branch: separators\<[^\>]*\>|[ ]+(<and>are escaped in Lucene's RegExp syntax) onx < ygivex,< yinstead ofx,<,y. Tokenization raises no exception, and the stored value still has all the text.
Users of the opt-in, experimental SimplePatternTokenizer or SimplePatternSplitTokenizer (factory names simplePattern, simplePatternSplit) whose pattern pairs a quote, bracket or tag alternative with a word or separator alternative. Ordinary text then triggers it, such as don't, a < b or <3, and every later token of that value is silently lost or merged.
Mechanism, terms and numbers780 words
SimplePatternTokenizer runs its DFA from each start point and keeps the longest accepted match; when nothing matches, it pushes back all but the first character and retries one character later (SimplePatternTokenizer.java:153-156). That retry runs only when the lookahead dies before the end of the input. When the lookahead stays alive to the end without ever accepting, as an unclosed quote does in '[^']*'|[a-z]+, the branch } else if (ch == -1) { return false; } (:151-152) ends tokenization, so don't stop me yields only don and say "hello world (with \"[^\"]*\"|[a-z]+) only say. SimplePatternSplitTokenizer has the same branch (SimplePatternSplitTokenizer.java:156-162) and emits everything from the current token start to the end as one token: separators \<[^\>]*\>|[ ]+ on x < y give x, < y. (Patterns are written as the Lucene RegExp strings that were run: in that syntax ", < and > are reserved and escaped; unescaped, those two patterns throw before tokenizing anything.) Tokenization raises no exception or warning, and end() still reports the full final offset. The branch dates from the first commit of both classes (LUCENE-7465, 6.5.0) and fails on every release run, up to 10.5.2 and main. A fix that retries at the end of the input too, with a small memo that kept many unclosed openers linear on the measured inputs, passes the module's tests.
SimplePatternTokenizer.incrementToken() (SimplePatternTokenizer.java:104-162 at a9779ef3c193):
- It takes the next code point as a possible token start and steps the pattern's DFA (
:116-121). If the DFA can start there (state != -1,:123), an inner loop reads ahead and records the longest accepted length (:126-132), until the DFA dies (:139) or the input ends (ch == -1, thenbreak,:134-137). - If something was accepted, the longest match is the token (
:141-150), also when the lookahead then ran into the end of the input: the scanned rest is pushed back. This accepting end-of-input path is correct on stock (itspushBackwas fixed in 2017 by2d03aa21a2b) and both fix options keep it. - If nothing was accepted and the DFA died, it is a "false alarm":
pushBack(tokenUpto - 1)returns all but the first scanned character and the loop retries one character later (:153-156). - If nothing was accepted and the input ended,
} else if (ch == -1) { return false; }(:151-152) ends tokenization. That assumes that no match before the end means no later token, which is false: a later start point inside the scanned run can still match a shorter string.
Pattern '[^']*'|[a-z]+, text don't stop me (13 chars; executed trace on main): start 0 accepts d, do, don and dies at the apostrophe, so don is a token. Start 3, the apostrophe, can begin a quoted phrase; [^']* keeps the DFA alive over t stop me to the end of the text, never accepting because no closing quote comes. Step 4 returns false, and starts 4-12 (t, stop, me) are never tried.
SimplePatternSplitTokenizer (SimplePatternSplitTokenizer.java:99-170) scans for separators the same way. Its end-of-input branch (:156-162) emits everything from the current token start to the end as one token, instead of the false-alarm retry (:163-166), so the separators after an unclosed < are never found: \<[^\>]*\>|[ ]+ on x < y gives x, < y. (The branch's return false arm cannot be reached from a lookahead: the scanned characters are already appended to the pending token, :213-219.)
A pattern alternative that can stay alive without accepting up to the true end of the value, plus a later start point inside that run that could match: a quoted-string, bracket or tag alternative next to a word or separator alternative (\"[^\"]*\"|[a-z]+, '[^']*'|[a-z]+, \<[^\>]*\>|[ ]+, in Lucene RegExp syntax), or abc|b, ab*c|b. A pattern made only of a quote alternative loses nothing (no later match exists inside the run). Plain character-class patterns such as [^ ]+ or [ ]+ are unaffected, and so are whitespace classes like the factories' javadoc examples when the tab, CR and LF in them are real characters (executed in the fact check). As the javadoc prints them, with backslash escapes inside the class, [^ \t\r\n]+ and [ \t\r\n]+ are rejected by RegExp ("invalid character class") on main, 10.5.2 and 9.12.3: a separate documentation matter. Tokenization raises no exception or warning; end() still reports the full final offset (:164-169).
Exposure and likelihood279 words
- Who is exposed. Users of either tokenizer: directly, through the factories (SPI names
simplePatternandsimplePatternSplit, for example inCustomAnalyzeror a Solr schema), or through downstream tokenizers built on them (Elasticsearch's and OpenSearch'ssimple_patternandsimple_pattern_split; read in their sources, not run there). - Opt-in. Both classes and factories are
@lucene.experimental, and no Lucene analyzer uses them by default. The bug needs no special setting beyond a pattern of the affected shape. - What it takes. A pattern alternative that can stay open to the end of the value (a quoted string, bracket group or tag) next to one that can match later inside it. With such a pattern, one unclosed opener in ordinary text is enough: the apostrophe in
don't,a < b, the emoticon<3. Plain character-class patterns are unaffected. - Effect. Every token after the unclosed opener is missing from the index (tokenizer) or merged into one term (split tokenizer), at any index size, with assertions on or off. Tokenization raises no exception or warning;
end()reports the full offset; the stored value keeps all the text; other values, fields and documents are unaffected. - A sharper case. The split tokenizer's merged rest is one term; past 32,766 UTF-8 bytes
IndexWriterrejects the whole document as containing an immense term (non-aborting):"Bob <3 wrote: "plus 40,000 chars of words was rejected on main, 10.5.2 and 9.12.3, and is indexed with either fix option. - How likely. Rare, by judgement: the affected pattern shape seems uncommon among simple-pattern patterns, and no report of it since 6.5.0 (2017) was found in the public sources searched. How often it appears in real deployments was not surveyed.
The test shows that SimplePatternTokenizer stops scanning when a lookahead that never matched reaches the end of the input, instead of retrying at the next character as it does when the lookahead dies earlier.
/*
* 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.analysis.pattern;
import java.io.StringReader;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
public class TestSimplePatternLookaheadAtEnd extends BaseTokenStreamTestCase {
public void testLookaheadReachesEnd() throws Exception {
Tokenizer t = new SimplePatternTokenizer("abc|b");
t.setReader(new StringReader("ab"));
// expected [b] (as for "abx"); actual [] because the lookahead "ab" hits the end and ends the scan
assertTokenStreamContents(t, new String[] {"b"});
}
}
# in a copy of this finding's repro folder (repro/LFV-525/, 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/analysis/common/build/libs/lucene-analysis-common-$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.analysis.pattern.TestSimplePatternLookaheadAtEnd
- Expected vs actual
- Expected pattern
abc|bonabto give[b](as onabx); actual[](token 0 does not exist), with-eaand-da.
More files, environment, full output39 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: token 0 does not exist
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.assertTrue(Assert.java:42)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:257)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:513)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:598)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:622)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:689)
org.apache.lucene.analysis.pattern.TestSimplePatternLookaheadAtEnd.testLookaheadReachesEnd(TestSimplePatternLookaheadAtEnd.java:29)- Javadoc: "the longest match at a given start point will be the next token"; class comment: "we skip one character forward and try again", which the dead-state branch does.
PatternTokenizerwith the same regex, andString.split, give the full token lists.- One extra non-token character brings tokens back:
ab*c|bgives nothing onabbbutb,bonabbx.
The full argument359 words
- Contract. Class javadoc (
SimplePatternTokenizer.java:34-35): "The matching is greedy: the longest match at a given start point will be the next token." Factory javadoc (SimplePatternTokenizerFactory.java:41-43): "the matching is greedy such that the longest token matching at a given point is created." The class comment states the algorithm (:44-45): "on failing to match a token, we skip one character forward and try again." The split tokenizer is "just like SimplePatternTokenizer except that the pattern should make valid token separator characters, like String.split" (SimplePatternSplitTokenizer.java:33-34). - Two no-match branches of one loop disagree. The dead-state branch retries one character later (
:153-156); the end-of-input branch gives up (:151-152). No code comment, javadoc, CHANGES or MIGRATE entry explains the difference. - The documented comparisons give the full lists.
PatternTokenizer(java.util.regex) with the same regex givesdon,t,stop,meandsay,hello,world; in split mode (group -1) and withString.split,x < ygivesx,<,y. - Prefix-dependent output.
ab*c|bgives no token onabbbutb,bonabbx: whether the tokens of a prefix exist depends on whether one more character, which belongs to no token, follows. That is strong evidence the behaviour is accidental rather than a chosen rule. - Tests do not pin it. Two upstream tests reach the no-accept end-of-input branch (a verifier found them):
testBigLookahead(the literal pattern of 100as and ab, over 200as; start points 0-99 die, start point 100 runs to the end) andtestEndLookahead((ab)+onaba: the first lookahead acceptsab, then the trailingatakes the no-accept branch). Neither has a later start point that could match; both pass with the fixes, as does the whole analysis-common module. - History. The branch is in the first commit (
93fa72f77bd, LUCENE-7465) without a comment. The only later fix here,2d03aa21a2b(2017-02-21, "fix corner case in SimplePattern/SplitTokenizer when lookahead hits end of input"), handled the accepting case and addedtestEndLookahead; it left this branch alone. The 31 comments of LUCENE-7465 (#8517) never discuss it.
The devil's-advocate check261 words
The strongest case for "intended": the end-of-input branch is explicit, symmetric code from the first commit; the classes are @lucene.experimental; the javadoc only says the longest match at a start point is the token, not that every start point is tried; and the shortcut keeps an unclosed opener to one linear scan, while retrying makes each unclosed opener rescan to the end.
Why it fails: the class's own comment says it skips one character and tries again, and the dead-state path does exactly that; output that changes when one non-token character is appended is strong evidence against a chosen rule; PatternTokenizer and String.split give the full lists; @lucene.experimental marks API instability, not permission for wrong output. The cost point is real for the minimal fix (quadratic on many unclosed openers) but not on the measured inputs of the recommended one. The class TODO (:41-53) says the naive matcher "does have N^2 adversarial cases" because it skips one character and retries, and such cases exist on stock through dead states; it does not say whether rescanning at the end of the input was considered, so it supports the retry design, not a deliberate end-of-input shortcut. None of this proves the shortcut was not a deliberate optimization, but together it makes an accident much more likely. Every check that ran code judged it a defect; the reading-only refute review said "not real" only because it could not run code, and the cross-model fact check rated the intended-behaviour case moderate for the tokenizer and weak to moderate for the split tokenizer.
Recommended Retry at the end of the input, and remember lookaheads that ran to the end (validated; linear on the measured inputs)
Let a lookahead that reaches the end without a match take the existing retry, stepping one code point, and remember (every 16th) the DFA states such a run passed through, so later retries stop early. All tests pass, the whole module too; 64,000 chars of "< " take 5 ms instead of 5.5-9.4 s with the minimal retry-only fix.
// in the lookahead loop of incrementToken(); the split tokenizer is analogous
if (noAcceptAhead.isEmpty() == false && noAcceptAhead.contains(pair(offset, state))) {
noMoreAccepts = true; // an earlier lookahead went from here to the end without an accept
break;
}
ch = nextCodePoint();
if (ch == -1) {
noMoreAccepts = true;
break;
}
// after the loop: replay the lookahead, record every 16th (offset, state) after its last accept
if (noMoreAccepts) {
rememberNoAccept(termAtt.buffer(), 0, tokenUpto, offsetStart, lastAcceptLength);
}All options and trade-offs4 options · 1,090 words
Two options were built at a9779ef3c193 and validated with gradle (the repro, the pattern package, the whole analysis-common module, assertions on and off) and against the stock jars with the patched classes shadowed. Both also change the retry step from one UTF-16 unit to one code point; without that, sending the end of input into the retry would spread a separate defect of the retry path (a supplementary first character is split) to the new path. Timings: single thread, assertions off, on a shared host; up to 64,000 chars 30 warm-up runs and the best of 20 (WarmCost), the 1,024,000-char scaling 10 warm-up runs and the best of 10 (WarmScale); heap from the thread's allocation counter and used heap after GC (MemProbe).
Retry at the end of the input, and remember lookaheads that ran to the end (validated; linear on the measured inputs) (recommended)
optionB-retry-at-end-linear.diff, +113/-14 over both classes. The minimal option's change, plus a LongHashSet of (offset, state) pairs (from org.apache.lucene.internal.hppc, a package analysis-common already uses; LongHashSet itself is new there):
- when a lookahead reaches the end of the input (or a recorded pair), the tokenizer replays its buffered code points through the DFA and records every 16th pair after its last accept. No accept is reachable from such a pair: the DFA is deterministic, the rest of the input is fixed, and the earlier lookahead walked it to the end. This needs a deterministic automaton; every instance that can be constructed has one, because the
Automatonconstructors reject a non-deterministic one (SimplePatternTokenizer.java:97-99,SimplePatternSplitTokenizer.java:87-89) and the factories determinize; - every later lookahead checks the set at each step, only while it is non-empty, and stops at a recorded pair as if the input ended there; a lookahead that joins a recorded path after that path's last accept stops within 16 steps;
reset()releases the set when it is not empty.
"Linear" is measured, not proven in general: from 64,000 to 1,024,000 chars of many unclosed openers the time grows 1.50-2.15x per doubling (split tag-or-space) and 1.85-2.12x with one 3.25x step (tag-or-word). The set is keyed by (offset, state), so several states can be recorded at one offset, and no general bound on its size is established. The class's other N^2 cases remain.
// in the lookahead loop of incrementToken(); the split tokenizer is analogous
if (noAcceptAhead.isEmpty() == false && noAcceptAhead.contains(pair(offset, state))) {
noMoreAccepts = true; // an earlier lookahead went from here to the end without an accept
break;
}
ch = nextCodePoint();
if (ch == -1) {
noMoreAccepts = true;
break;
}
// after the loop: replay the lookahead, record every 16th (offset, state) after its last accept
if (noMoreAccepts) {
rememberNoAccept(termAtt.buffer(), 0, tokenUpto, offsetStart, lastAcceptLength);
}Trade-offs
- About 100 more lines than the minimal option, and a small set that lives until the next value.
- Inputs without an unclosed opener at the end: within noise of stock (plain words 8.7-8.9 ns/char vs 8.3-8.6; closed tags 10.1-10.4 vs 11.0-11.3, one run 15.6).
- One unclosed opener, then 200,000 chars of words: 22.8-27.4 ns/char (minimal option 13.5-17.9; stock 5.5-5.6 but 0 tokens).
- Many unclosed openers:
"< "repeated to 64,000 chars 5.0 ms (minimal option 9,444 ms in this run, 5.5-9.4 s across runs; stock 0.38 ms with 1 token); to 1,024,000 chars 87 ms, about 2x per doubling. - Heap for one 1,000,000-char value with one unclosed opener: 2.1 MB allocated and held until the next
reset()(stock and the minimal option: none beyond the term buffer). Recording every pair instead of every 16th held 17.8 MB. - The class's existing dead-state N^2 cases are unchanged (
[a-z]+\@[a-z]+on one 64,000-char word: about 10-15 s on stock and on both options).
What the checks validated
- gradle: the repro 5 of 5;
org.apache.lucene.analysis.pattern.*87 tests; the whole analysis-common module 1,914 tests, 18 skipped, 0 failures; assertions on and off.check -x test(lint, forbidden APIs, formatting) passes;TestRandomChainspasses with 3 seeds. - The new upstream-suite methods pass; on stock all 4 fail.
- Shadowed on the stock jars: the hunt's bounded-exhaustive check (1,523,418 runs of the two tokenizers, part of 4,140,572 checks with
PatternTokenizercontrols) 0 mismatches; its long-input check (1,560 inputs through both tokenizers, 3,120 checks, chunked reads) 0; an opener-dense random differential 0 of 32,000 (stock 6,024); the finder's and a verifier's repros and the hunt's reach probe all pass.
optionA-retry-at-end.diff, +12/-14: delete the end-of-input branch in both classes so that the lookahead takes the false-alarm path, and push back all but the first code point. The same change as the hunt's combined fix, re-made at a9779ef3c193.
final int firstLen = Character.charCount(ch); // after runDFA.step(0, ch)
...
- } else if (ch == -1) {
- return false;
} else {
- pushBack(tokenUpto - 1);
+ // false alarm (also when the lookahead reached the end of the input)
+ if (tokenUpto > firstLen) {
+ pushBack(tokenUpto - firstLen);
+ }
tokenUpto = 0;
}Trade-offs
- Smallest diff.
- Each unclosed opener whose run reaches the end rescans the rest of the value, so many unclosed openers become quadratic where stock is linear (and wrong):
"< "repeated to 64,000 chars takes 5.5-9.4 s on a shared host (9.4 s best of 20 here, 5.53 s in the hunt, 5.56 s in the fact check), about 4x per doubling from 8,000 chars. Quote-or-word patterns on paired quotes stay linear (only the last unpaired quote scans to the end). - Proposing it means stating that cost. The class TODO already says the naive matcher has N^2 adversarial cases (it does not discuss the end of the input).
What the checks validated
- gradle: the repro 5 of 5; the pattern package 87 tests; the whole module 1,914 tests, 0 failures, assertions on and off; the new upstream-suite methods pass.
- Shadowed: the bounded-exhaustive check and the opener-dense differential find 0 mismatches; the reach probe passes 10 of 10.
Compile something like .* | <pattern> so that one pass finds every match, as the TODO (:46-53) suggests.
Trade-offs
Would also remove the dead-state N^2 cases, but the TODO notes it needs sub-group capture that Lucene's regexps lack; a much larger change.
State in the javadoc that tokenization ends when a lookahead reaches the end of the input without a match.
Trade-offs
Keeps the prefix-dependent output and the split tokenizer's merged term; neither fix needs it.
Elasticsearch and OpenSearch build their simple_pattern and simple_pattern_split tokenizers on these classes, so their users with a quote- or tag-style pattern lose the same tokens (read in their sources, not run there).
A bounded-exhaustive check ran both real tokenizers on every string of up to 6 code points over a 5-symbol alphabet with 39 patterns (1,523,418 runs) against the javadoc's longest-match rule with the class comment's skip-and-retry. 3,916 tokenizer runs lost their trailing tokens; reading the code for the model then located the end-of-input branch.
The method in full185 words
Method: bounded-exhaustive translation validation (the hunt's semi-formal tier). The checker ran the real SimplePatternTokenizer and SimplePatternSplitTokenizer on every string of up to 6 code points over the alphabet {a, b, space, -, U+1F600}, for 39 RegExp patterns, and compared each output with a reference written from the class javadoc and its implementation comment: the longest non-empty match at each start point, otherwise skip one code point (membership checked with java.util.regex). Of 761,709 tokenizer runs, 3,916 lost trailing tokens, and 3,916 of 761,709 split runs merged tokens, all on the patterns with a live non-accepting run before a later match. A second mismatch class (lone surrogates) is a separate bug of the retry step. (The checker's total of 4,140,572 checks also covers PatternTokenizer in three modes on the same patterns and a 17-pattern PatternTokenizer pool.)
The finder then wrote a JUnit through the public factories (3 of 3 fail on main with assertions on and off, 10.5.2 and 9.12.3) and a fix, which removes the end-of-input class from the exhaustive check; with the code-point retry as well, the check finds 0 mismatches. Independent checks followed (below).
No prior report: 150 tool queries over GitHub issues, PRs and commits, the mailing lists and JIRA, 33 direct GitHub searches (Lucene, Elasticsearch, OpenSearch, Solr), 15 web searches and the files of all 408 open PRs (2026-10-26). Closest: the 2017 follow-up that fixed only the accepting end-of-input case.
Searches5 searches
No report of this bug by anyone was found; the nearest items concern the classes' introduction, the accepting end-of-input case and determinization. Limits: some web and downstream queries were throttled, closed unmerged PRs that never name the classes are not searchable, and private security-list reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists, JIRA archive | 2026-10-26 | the hunt's prior-report tool in three runs, 150 queries: class, factory and SPI names, simple_pattern, simple_pattern_split, CharacterRunAutomaton, pushBack, symptom phrasings | no report; the top hit was always #8517 |
| GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr) | 2026-10-26 | 33 direct searches | no report |
| Web | 2026-10-26 | 15 answered searches (forums, Q&A, reference docs) | no report; the Elasticsearch and OpenSearch reference pages describe no end-of-input rule |
| Open PRs | 2026-10-26 | the changed files of all 408 open apache/lucene PRs | none touches the SimplePattern* files |
| Related items (read) | 2026-10-26 | issue threads and commits read in full | #8517 (LUCENE-7465, added the classes; 31 comments, none on this branch); commit 2d03aa21a2b (2017, the accepting end-of-input case); OpenSearch #20349 / PR #20350 (determinization, a separate bug); Elasticsearch #23363 / PR #25159 (exposing the tokenizers) |
Introduced with both classes by LUCENE-7465 (93fa72f77bd, 2017-02-13), first released in 6.5.0. Fails on all 19 releases run from 6.5.0 to 10.5.2 and on main; the branch has never changed.
Versions tested7 rows
Introduced by 93fa72f77bd (2017-02-13, LUCENE-7465 "add SimplePatternTokenizer and SimpleSplitPatternTokenizer", @mikemccand; a JIRA patch, no PR; issue migrated as #8517) and the branch_6x backport c24e03e6bf4 (same file blobs). git blame credits 9a1702a8f5b, a byte-identical restore after an accidental merge revert. The follow-up 2d03aa21a2b (2017-02-21) fixed pushBack after the end of input in the accepting case only. The branch is in all 74 release tags from 6.5.0 to 10.5.2 of the history clone (the clone has no tags for 8.8.2 and 8.9.0-8.11.4; those releases are covered by the executed jars, not by the tag scan); no later commit touched it.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test 4 of 5 fail (gradle with assertions on and off; standalone -ea and -da); TermQuery body:stop finds 0 documents |
main ea57360912ed | affected | the finder's test 3 of 3, a verifier's 6 of 10 (its controls pass), the portable probe 5 of 5 bug cases (-ea and -da) |
| 10.5.2, 9.12.3 | affected | this doc's test 4 of 5 (-ea and -da); the finder's 3 of 3; the portable probe 5 of 5 |
| 6.5.0 to 10.0.0 (17 releases: 6.5.0, 6.5.1, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 10.0.0) | affected | the portable probe: 5 of 5 bug cases fail, 0 of 4 controls |
6.5.0 with the 93fa72f sources | origin | the original commit's code fails the same way |
| 6.4.2 | not affected | no SimplePattern* classes (the probe does not compile) |
| main + fix (either option) | not affected | the repro passes; the analysis-common module, 1,914 tests, passes with assertions on and off |
An independent reproducer, a devil's advocate and an adjudication said real and re-ran it with assertions on and off. Reviews by another model family (reading only) agreed on the root cause; one said "not real" only because it could not run code. An audit kept it; the doc's fact checks found text errors only, now fixed.
Checks and verdicts532 words
Each check ran or re-ran a repro on the stock jars, except the reading-only reviews, for which a separate run re-ran the finder's repro; the verdicts below are each check's own.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-factory JUnit fails 3 of 3 on main (-ea and -da), 10.5.2 and 9.12.3, controls pass; with the fix 3 of 3 pass and the upstream TestSimplePatternTokenizer and TestSimplePatternSplitTokenizer pass 29 of 29. |
| cross-model review, refute lens | not real (low), procedural | An independent reviewer from another model family (OpenAI GPT-5.6, reading only) could not run code, so it voted not real on procedure only; it agreed with the root cause, judged the inputs in contract and remarked that the retry should step by code point. A re-run of the repro failed (3 of 3). |
| cross-model review, intended lens | real (medium) | The same reviewer family, reading only: the works-as-designed case (symmetric branches, experimental API, the existing end-of-input tests) fails because no test puts a valid later start inside the consumed run. |
| The cross-model review, executed the intended lens's cases | real | abc|b on ab gives nothing and split xab gives xab on main and 10.5.2, with the dead-state controls correct; PatternTokenizer gives the full lists; both pass with the fix. |
| Independent reproducer | real (high) | Own 10-test JUnit: 6 of 10 fail on main (-ea, -da), 10.5.2 and 9.12.3, including a random differential (130 and 131 of 2,000 mismatches on affected shapes, 0 of 4,000 on controls); its own fix and the finder's pass 10 of 10. |
| Devil's advocate | real (high) | Built the strongest intended-behaviour case and showed why it fails; its probe found the prefix-dependent output (abb vs abbx). |
| adjudication | confirmed (high) | Re-ran the independent reproduction's JUnit with -ea and -da (6 of 10 fail) and with the finder's fix (10 of 10 pass); flagged the fix cost and the code-point retry. |
| audit | keep | Own probe through the Automaton constructors on main and 10.5.2 reproduced every case with correct controls; the finder's JUnit with a fresh seed fails 3 of 3 and passes with the fix. |
| fact check A (independent AI tool, executed) | real; 1 notation error and 9 minor findings, fixed | Ran the brief's gradle command verbatim (5 tests, 4 failed, assertions on and off), the standalone test on main, 10.5.2 and 9.12.3 with -ea and -da, both options with the new upstream methods (38 tests, 0 failures) and its own probe. Found one text error: the unescaped quote and tag patterns in the doc throw in Lucene's RegExp. |
| fact check B (OpenAI GPT-5.6, reading only) | real; 8 errors, 6 overclaims, 4 caveats, fixed or answered | An independent reviewer from another model family confirmed the root cause, the traces, both fixes and the counts it could check, and rated the intended-behaviour case moderate. Most findings were fixed in the text; two were partly rejected with evidence from the code and the kept runs. |
| diagram check (independent AI tool) | pass, no errors | Checked every code line, value and order of events on screen against a9779ef3c193 and the executed probe; 4 minor notes, addressed in the caption text. |
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 459 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,360 words
- Registry title
- SimplePatternTokenizer and SimplePatternSplitTokenizer stop scanning when a non-matching lookahead reaches the end of the input, so later tokens are dropped (or later separators ignored)
- Area
- analysis / pattern tokenizers (analysis-common: SimplePatternTokenizer, SimplePatternSplitTokenizer and their simplePattern / simplePatternSplit factories)
- Priority
- Medium, rank 144: The opt-in simple-pattern tokenizers silently drop all later tokens after an unclosed opener, but only with an uncommon delimited-span pattern shape, so it closes the opt-in analysis text-loss group.
- Impact class
- silent wrong results
- Impact note
- Hits applications that choose SimplePatternTokenizer or SimplePatternSplitTokenizer (directly, through the simplePattern / simplePatternSplit factories, or a downstream simple_pattern / simple_pattern_split tokenizer) with a pattern in which one alternative can stay live without matching up to the end of the value (typically a quoted-string, bracket or tag alternative such as "[^"]" or <[^>]>) next to an alternative that can match later inside that run: for every value with one unclosed opener (the apostrophe in "don't", 'a < b', '<3') all later tokens are silently not indexed (tokenizer) or merged into one term (split tokenizer), at any scale, with -ea and -da, with no exception and with end() still reporting the full final offset; patterns without such an alternative (plain character classes, a lone quote pattern) are not affected.
- Summary
- You tokenize 'say "hello world' with SimplePatternTokenizer and the pattern "[^"]"|[a-z]+ (a quoted string or a word) and expect [say, hello, world], because the javadoc says the longest match at each start point is the next token and the class otherwise skips one character and tries again (PatternTokenizer with the same regex gives these three tokens). Instead you get only [say]: the lookahead from the unclosed quote runs to the end of the input without ever matching, and that end-of-input branch ends tokenization instead of retrying at the next character, so 'hello' and 'world' are never indexed (minimal case: abc|b on 'ab' gives [] instead of [b]). SimplePatternSplitTokenizer has the same branch and emits the whole rest as one token: separator <[^>]>|[ ]+ on 'x < y' gives [x, '< y'] instead of String.split's [x, <, y]; nothing throws.
- Introduced
- 93fa72f77bd024aa09eef043c65c64a6524613dc (LUCENE-7465 'add SimplePatternTokenizer and SimpleSplitPatternTokenizer', by GitHub mikemccand; JIRA patch, no pull request; issue migrated as apache/lucene#8517; branch_6x backport c24e03e6bf4d09e6f31eee8192bb6c0c4b2b6d27 with identical file blobs) [exec: 6.5.0 jars with the 93fa72f sources shadowed fail the portable probe 5 of 5; read: git log -S/-G/-L on both files shows no later change to the EOF branch; git blame credits 9a1702a8f5b, a byte-identical restore after the accidental merge revert 325cbf00185; the 2017 follow-up 2d03aa21a2b fixed only the accepting end-of-input case]. Not a regression: the branch is as old as the classes (2017-02-13 (master 93fa72f and branch_6x c24e03e6bf4))
- First affected version
- 6.5.0 [exec] (6.4.2 has no SimplePattern* classes [exec: compile error])
- Affected versions
- 6.5.0 through 10.5.2 (the last release before 10.6.0; latest 9.x 9.12.3, latest 8.x 8.11.4) and main ea57360912ed; never fixed. [exec] the follow-up's portable main() probe (5 bug cases, 4 controls) fails 5 of 5 with all controls passing on 19 releases: 6.5.0, 6.5.1, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, and on main with -ea and -da; the finder's JUnit fails 3 of 3 on 6.5.0, 8.11.4 and 9.0.0 (old test-framework import adapted), 9.12.3, 10.0.0, 10.5.2 and main (-ea, -da); the independent reproduction's JUnit fails 6 of 10 on main (-ea, -da), 10.5.2 and 9.12.3. [read] the exact EOF branch is in both files at all 74 release tags 6.5.0-10.5.2 of the history clone (8.8.2-8.11.4 are untagged there and were executed instead).
- 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: exhaustive check
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record, verdict new; plus the finder's and the independent reproduction's and the intended-behaviour check's searches): a scripted prior-report search in three runs (51 + 50 + 49 queries) over apache/lucene GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 33 manual GitHub searches on apache/lucene, Elasticsearch, OpenSearch and Solr (class and factory names, simple_pattern, simple_pattern_split, CharacterRunAutomaton tokenizer); 15 answered web searches (1 unanswered); the changed files of all 408 open apache/lucene PRs (none touches the files). Related, not this bug: apache/lucene#8517 (LUCENE-7465, closed: added both classes; all 31 comments read, none about ending the scan at EOF after a non-accepting run), commit 2d03aa21a2b (2017, 'fix corner case ... when lookahead hits end of input': pushBack in the accepting case, testEndLookahead), opensearch-project/OpenSearch#20349 and PR #20350 (determinization at construction, related to LFV-526), elastic/elasticsearch#23363 and PR #25159 (exposing the tokenizers), apache/lucene#8780 (open, unrelated)
- Lucene commit tested
- main
a9779ef3c193(2026-10-27): this doc's test through gradle in a scratch worktree with assertions on and off, and through the standalone JUnit runner with-eaand-da; the 10.5.2 and 9.12.3 release jars (-dain the brief's runs,-eaand-dain the fact check's re-runs). Earlier pipeline runs (2026-10-26): mainea57360912ed, whose fourSimplePattern*files are byte-identical, and a portable probe on 19 releases from 6.5.0 to 10.5.2 (6.4.2 has no such classes). Both fix options were validated with the whole analysis-common test module.
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:30-39 (at ea57360912ed) (class javadoc: 'The matching is greedy: the longest match at a given start point will be the next token'; @lucene.experimental), :41-53 (TODO: 'on failing to match a token, we skip one character forward and try again', N^2 adversarial cases accepted), :121-157 (incrementToken lookahead; :135-137 break at end of input; :151-152 'else if (ch == -1) { return false; }'; :153-156 false-alarm pushBack(tokenUpto - 1) and retry)lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:30-38 (at ea57360912ed) (javadoc: 'just like SimplePatternTokenizer except that the pattern should make valid token separator characters, like String.split'), :156-162 (EOF branch: fillToken of the whole remainder or return false), :163-166 (false-alarm pushBack(sepUpto - 1))lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizerFactory.java:54, (at ea57360912ed) :62, :71-75 and SimplePatternSplitTokenizerFactory.java:56, :64, :73-77 (@lucene.experimental, SPI names simplePattern and simplePatternSplit, the factories determinize the RegExp)
- The finding in 30 seconds
a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:104-162 (incrementToken; 116-121 start, 123-139 lookahead, 141-150 accept, 151-152 end-of-input branch, 153-157 false alarm), 164-169 (end)a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:99-170 (incrementToken; 156-162 end-of-input branch, 163-166 false alarm)a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:213-219 (appendToToken: the lookahead's characters join the pending token)
- Who is affected
a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizerFactory.java:41-43, 54-62 and SimplePatternSplitTokenizerFactory.java:43-45, 56-64 (javadoc, @lucene.experimental, SPI names)a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:38 and lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:37 (@lucene.experimental)a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/resources/META-INF/services/org.apache.lucene.analysis.TokenizerFactory:25-26 (only SPI registration; no analyzer uses the classes)
- Why it looks unintended
a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:30-53 (javadoc, TODO with the retry algorithm), 151-157a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizerFactory.java:41-43; lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:30-38a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/test/org/apache/lucene/analysis/pattern/TestSimplePatternTokenizer.java:41-56, 207-211 and TestSimplePatternSplitTokenizer.java:49-67, 246-250 (testBigLookahead, testEndLookahead)
- Suggested fix
a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:41-53 (TODO: 'does have N^2 adversarial cases', the .* | pattern idea); a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:97-99 and lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:87-89 (non-deterministic automata rejected)
- TestSimplePatternLookaheadAtEnd.java (JUnit test):
repro/LFV-525/src/org/apache/lucene/analysis/pattern/TestSimplePatternLookaheadAtEnd.java(31 lines)
A light summary card, made in parallel with this doc from the code at a9779ef3c193 and probes executed on main. Patterns on screen are the escaped RegExp strings that were run.
The fix panel shows the hunt's first candidate. That is not Option A of this doc: Option A also retries by code point. Its 5.35 s (64,000 chars of "< ", split tokenizer, best of 3 on the hunt's run) and Option A's 5.5-9.4 s measure the same quadratic shape on different runs; the recommended option keeps the corrected output without that cost.
The summary card's code panel is shortened (no .build(), no writer.addDocument(doc)).
assets/LFV-525/bug.png(459 KB)
![Summary card in three panels. 1: you index don't stop me with CustomAnalyzer simplePattern and the pattern '[^']*'|[a-z]+, a quoted phrase or a word; the apostrophe opens a quote that never closes; the contract says the longest match at a start point is the next token and the tokenizer otherwise skips one character and tries again. 2: the text as 13 tiles; a bar marks don, a second bar runs from the apostrophe to the end of the text, where ch is -1; nothing is accepted from the apostrophe on, line 152 returns false, while a dead state would retry one char later at line 155. 3: expected tokens don, t, stop, me against actual don only; TermQuery body:stop finds 1 document expected, 0 actual; the split tokenizer with separators \<[^\>]*\>|[ ]+ (the RegExp string, with < and > escaped) gives x, < y instead of x, <, y; silent, no exception. Right column: the hunt's end-of-input fix candidate (drop lines 151-152) restores all four tokens and passes the 29 upstream tests, with its cost to state (64,000 chars of '< ' through the split tokenizer: 0.39 ms on main, 5.35 s with that candidate); patterns that cannot stay open are unaffected.](../assets/LFV-525/bug.png)