One document and one query are enough: the very first seek already walks into the dead state.
- An automaton is a set of states joined by labelled transitions. A term matches if reading its letters leads from the start state to an accepting state. In a deterministic automaton (DFA) each letter leads to at most one next state; a non-deterministic one (NFA) may branch.
- A dead state is reachable but cannot reach any accepting state, so a term that enters it can never match. It may still have transitions (to itself or other dead states); a dead end is one with none. Most of Lucene's automaton operations (concatenate, repeat, union, intersection, complement, reverse) remove dead states from their results;
optionalanddeterminizedo not. - Since 10.0,
AutomatonQueryruns an NFA directly, turning it into DFA states on the fly. On the doc-values,MemoryIndexand other non-default paths,AutomatonTermsEnumjumps through the sorted terms by building the next possible term from the smallest transitions.
- Build a 4-state NFA by hand. From the start, "b" leads both to an accept state and to a state that accepts after "c", so it matches {b, bc}. A third branch, "a", leads to state 1, from which no accept state can be reached and which has no transitions at all: a dead end.
- Index one document with the doc value "b" and count
new AutomatonQuery(new Term("f"), nfa, false, MultiTermQuery.DOC_VALUES_REWRITE). - You expect 1. The javadoc forbids dead states only for deterministic automata, and the same NFA over a
StringFieldwith the default codec counts 1. - Instead the count throws
ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5. The term enumerator builds candidate terms from the smallest transitions, so it follows "a" into state 1, assumes every non-accepting state has a way out, and reads past the end of its 5-entry transition table. SortedSetDocValues.intersect,MemoryIndex,FilterTermswrappers and the SimpleText codec fail the same way (doc values andMemoryIndexalso with assertions off). In a probe, the same NFA with a self-loop added on the dead state did not crash and counted correctly: the crash needs a dead end that the walk reaches. Lucene's own regexp, wildcard and fuzzy queries did not build such an automaton in the study's checks.
Only applications that build a non-deterministic automaton themselves (or return one from an AutomatonProvider) with a dead end the term walk reaches, and only on paths that enumerate with AutomatonTermsEnum: doc-values rewrite or intersect, MemoryIndex, FilterTerms wrappers, SimpleText. The default inverted index answers correctly. The query fails loudly; no wrong result was seen.
Mechanism, terms and numbers713 words
Since 10.0.0 CompiledAutomaton runs non-deterministic automata (NFAs) directly, and its javadoc forbids dead states only for deterministic ones. It does not remove them from an NFA it runs that way. AutomatonTermsEnum, which the doc-values rewrite, SortedSetDocValues.intersect, MemoryIndex and every Terms without its own intersect use, builds the next candidate term from the smallest transitions and assumes every non-accepting state has one. When the walk reaches a dead end, a dead state with no transitions, NFARunAutomaton.getNextTransition reads past the end of its transition table: ArrayIndexOutOfBoundsException, with assertions on and off. The default codec with the default rewrite (block tree) returns the right answer. Only applications that build such an automaton themselves are affected; calling Operations.removeDeadStates for NFAs in the CompiledAutomaton constructor fixes it.
CompiledAutomaton: the compiled form everyAutomatonQuery(andRegexpQuery,WildcardQuery, ...) runs. For an NFA it keeps anNFARunAutomaton, which determinizes lazily: each DFA state is the set of NFA states the input could be in.AutomatonTermsEnum: the generic term enumerator for an automaton. From the current term it constructs the next term that could still match, by appending the smallest transition of each state, and seeks the terms dictionary there. Block tree has its ownIntersectTermsEnuminstead.
AutomatonQuerycompiles the NFA withnew CompiledAutomaton(automaton, false, true, isBinary)(AutomatonQuery.java:99). The NONE/ALL/SINGLE checks run only for DFAs ("simplify requires a DFA",CompiledAutomaton.java:159-160), so the NFA isNORMAL. Its UTF-8 formbinaryis non-deterministic too, so it is wrapped as is:new NFARunAutomaton(binary, 0x100)(:253-257; if either automaton were deterministic, the constructor would determinizebinaryinstead,:258-261). Nothing removes dead states. The javadoc allows this: "For deterministic automaton, it must not have dead states ... For non-deterministic automaton, it will be executed using NFARunAutomaton" (:36-40).DOC_VALUES_REWRITEgives the query an anonymousTermsover the doc values (DocValuesRewriteMethod.java:98-104);getTermsEnumcallsterms.intersect(compiled, null)(CompiledAutomaton.java:348-357), and the defaultTerms.intersectbuilds anAutomatonTermsEnum(Terms.java:64-86).SortedSetDocValues.intersectbuilds one directly (SortedSetDocValues.java:109-119).- In
NFARunAutomaton, the dead NFA state 1, a dead end, becomes the DFA state {1}. It has no outgoing transitions, so all 5 entries of its transition table (one per character class) areMISSING(NFARunAutomaton.java:399-403), andinitTransitionreturns 0. - The first seek: the empty term does not match, so
nextString(called at:223) takes the smallest transition out of the start state, "a", into {1} (AutomatonTermsEnum.java:283-291). For a state that is neither visited nor accepting it then takes the next smallest transition without checking the count (:294-302), trusting the comment "we work with a DFA with no transitions to dead states ... if it is not an accept state, then there MUST be at least one transition" (:297-299). getNextTransitionscanswhile (dState.transitions[++t.transitionUpto] == MISSING)(NFARunAutomaton.java:214) and reads index 5 of a 5-entry table. Its comment states the rule that was broken: "this shouldn't throw AIOOBE as long as this function is only called numTransitions times" (:215-216).
Which dead states trigger it: the walk stops at a state it has already visited, so the crash needs a dead end that the walk reaches. In the fact check's probe (doc-values rewrite, assertions on and off), the repro NFA throws; the same NFA with a self-loop on state 1 counts correctly (1, and 2 for the docs ax, axx, b, bc); {b, bc} with a dead end on "z", which sorts after every match, counts 1 for the doc "b" but throws for the docs "b" and "c"; an empty-language NFA ending in a dead end throws.
Block tree is fine for this repro because IntersectTermsEnum walks the terms that exist and takes a transition only when the state has one (IntersectTermsEnumFrame.java:123-137); here the dead end simply rejects.
NFA 0 -a-> 1, 0 -b-> 2, 0 -b-> 3, 3 -c-> 2, state 2 accepting (language {b, bc}); one document with SortedSetDocValuesField("f", "b"). Expected count 1; actual ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5 at NFARunAutomaton.getNextTransition(NFARunAutomaton.java:214) from AutomatonTermsEnum.nextString(AutomatonTermsEnum.java:302). Controls: the same NFA over StringField("f", "b") with the default codec and default rewrite counts 1; DOC_VALUES_REWRITE over Operations.removeDeadStates(nfa) counts 1.
Exposure and likelihood317 words
- Trigger. A non-deterministic automaton with a reachable dead end (a dead state with no transitions) that the term walk reaches, built by the application:
Automaton/Automaton.Builder, anAutomatonProviderfor RegExp<name>leaves, orIntervals.multitermover aCompiledAutomaton. On main, since #15939 (11.0.0, unreleased),RegexpQueryno longer determinizes, so a provider-supplied NFA reachesNFARunAutomatonunchanged; the study'sRegexpQuery+ provider test fails the same way. That is most direct for a bare<name>leaf, whose automaton is returned as is; in composed expressions the cleaning operations usually remove dead states (code reading). - Paths. Everything that enumerates through
AutomatonTermsEnum:MultiTermQuery.DOC_VALUES_REWRITE(the repro),SortedSetDocValues.intersect/SortedDocValues.intersect,MemoryIndex,FilterLeafReader.FilterTermswrappers without their ownintersect, and postings formats without one. The default rewrite on aStringFieldwith the SimpleText codec throws the same exception. The test framework's random codec can route even the default rewrite here: the cross-model reviewer's own default-rewrite control failed under it. - Not affected. The default codec with the default rewrite (block tree) counts 1. Lucene's own builders, in the study's checks: no reachable dead state in any RegExp AST up to size 5 (1,127,357 ASTs) or in about 100k RegExp and 173k
WildcardQueryNFAs from a random scan;LevenshteinAutomata(fuzzy) builds DFAs without dead states.concatenate,repeat,complement,intersection,unionandreversereturnremoveDeadStates(...)of their result;optional("This may create a dead state") anddeterminizedo not, and the exhaustive RegExp check includes?. Bounded evidence, not an API guarantee. - Effect. An unchecked
ArrayIndexOutOfBoundsExceptionfor that query, with assertions on and off. No wrong results, corruption or hang in the recorded runs; the probe's variants that do not throw (a self-loop on the dead state, a dead end the walk never reaches) return the right count. An empty-language NFA that ends in a dead end throws too. - Scale. One document and one term are enough.
One JUnit test, one method: build the 4-state NFA, index one document with the doc value "b", count with DOC_VALUES_REWRITE. Public API only, deterministic, single thread, about 1.5 s; fails with assertions on and off. It catches the exception only to print expected vs actual.
/*
* 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;
import java.util.List;
import org.apache.lucene.document.SortedSetDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.automaton.Automaton;
public class TestAutomatonQueryNfaDeadState extends LuceneTestCase {
public void testDocValuesRewriteOfNfaWithReachableDeadState() throws Exception {
Automaton nfa = new Automaton();
int s0 = nfa.createState();
int dead = nfa.createState();
int accept = nfa.createState();
int s3 = nfa.createState();
nfa.setAccept(accept, true);
nfa.addTransition(s0, dead, 'a');
nfa.addTransition(s0, accept, 'b');
nfa.addTransition(s0, s3, 'b');
nfa.addTransition(s3, accept, 'c');
nfa.finishState();
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(List.of(new SortedSetDocValuesField("f", new BytesRef("b"))));
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
Query q = new AutomatonQuery(new Term("f"), nfa, false, MultiTermQuery.DOC_VALUES_REWRITE);
int count;
try {
count = new IndexSearcher(reader).count(q);
} catch (ArrayIndexOutOfBoundsException e) {
// Expected: count 1 (the doc with "b"); actual: AutomatonTermsEnum throws AIOOBE.
throw new AssertionError("expected count 1, got " + e, e);
}
assertEquals(1, count);
}
}
}
}
cp TestAutomatonQueryNfaDeadState.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestAutomatonQueryNfaDeadState -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (same result)
- Expected
- Count 1: the document with "b", as block tree returns for the same NFA and doc values return once the dead state is removed.
- Observed
ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5atNFARunAutomaton.getNextTransition(NFARunAutomaton.java:214), with assertions on and off.
More files, environment, full output216 words
The test sits in org.apache.lucene.search, next to the upstream TestAutomatonQuery; unchanged, it is the suggested regression test (it fits as a new method of TestAutomatonQuery). The doc-values field keeps it independent of the randomized postings format: DOC_VALUES_REWRITE always enumerates through AutomatonTermsEnum. It fails on stock main and on the 10.0.0, 10.5.2 and 10.6.0 release jars, and passes with fix A or fix B.
Not shown by the test (same AutomatonTermsEnum code, executed elsewhere): SortedSetDocValues.intersect, MemoryIndex, a FilterTerms wrapper, RegexpQuery with an AutomatonProvider (the study's 10-test file) and the SimpleText codec with the default rewrite (a path probe).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same test also fails on the 10.0.0, 10.5.2 and 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
cp TestAutomatonQueryNfaDeadState.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestAutomatonQueryNfaDeadState -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (same result)
# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (the same with =false)
TestAutomatonQueryNfaDeadState > testDocValuesRewriteOfNfaWithReachableDeadState FAILED
java.lang.AssertionError: expected count 1, got java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5
at __randomizedtesting.SeedInfo.seed([DEADBEEF:D6B9123FC978E9DF]:0)
at org.apache.lucene.search.TestAutomatonQueryNfaDeadState.testDocValuesRewriteOfNfaWithReachableDeadState(TestAutomatonQueryNfaDeadState.java:56)
Caused by:
java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5
at org.apache.lucene.util.automaton.NFARunAutomaton.getNextTransition(NFARunAutomaton.java:214)
at org.apache.lucene.index.AutomatonTermsEnum.nextString(AutomatonTermsEnum.java:302)
at org.apache.lucene.index.AutomatonTermsEnum.nextString(AutomatonTermsEnum.java:223)
at org.apache.lucene.index.AutomatonTermsEnum.nextSeekTerm(AutomatonTermsEnum.java:142)
at org.apache.lucene.index.FilteredTermsEnum.next(FilteredTermsEnum.java:230)
at org.apache.lucene.search.DocValuesRangeIterator.buildOrdinalSet(DocValuesRangeIterator.java:152)
at org.apache.lucene.search.DocValuesRangeIterator.forOrdinalSet(DocValuesRangeIterator.java:194)
at org.apache.lucene.search.DocValuesRewriteMethod$MultiTermQueryDocValuesWrapper$1$2.get(DocValuesRewriteMethod.java:174)
...
1 test completed, 1 failed- The javadoc allows it:
CompiledAutomatonforbids dead states only in deterministic automata; non-deterministic ones "will be executed using NFARunAutomaton". - The answer depends on the path: block tree counts 1, doc values throw; without the dead state both count 1.
- LUCENE-10010 (#225) dropped the old dead-state rule for NFAs and added no cleanup.
The full argument216 words
- Public contract.
CompiledAutomaton(@lucene.experimental): "The Automaton could either be deterministic or non-deterministic, For deterministic automaton, it must not have dead states ... For non-deterministic automaton, it will be executed using NFARunAutomaton" (CompiledAutomaton.java:36-40).AutomatonQuery: the automaton "can be constructed with the org.apache.lucene.util.automaton API", and "terms that are accepted are considered a match" (AutomatonQuery.java:29-42,:59-65), with no restriction. MIGRATE.md 10.0: these classes "no longer determinize behind the scenes. It is the responsibility of the caller to callOperations.determinize()for DFA execution" (lucene/MIGRATE.md:922-926), so an undeterminized NFA is meant to run as an NFA. - Path-dependent result. The same query counts 1 on block tree and throws on doc values, SimpleText or
MemoryIndex. Nothing documents that. - History. Before #225 the javadoc read "The Automaton must be deterministic, must not have dead states but is not necessarily minimal" and the constructor rejected NFAs. #225 (
c972c6a7754e, LUCENE-10010) removed the check, scoped the dead-state rule to DFAs and wrapped NFAs as they are; the "MUST be at least one transition" loop stayed. In the LUCENE-10010 discussion rmuir wrote that the current algorithm "requires not just DFA, but also no dead states" (#11049). - Maintainers clean dead states out of NFAs. #14218 added
AutomatonTestUtil.assertCleanNFA; #14211/#14212 stoppedreverse()from adding dead states.
The devil's-advocate check158 words
Strongest case for "intended": AutomatonTermsEnum is @lucene.internal and its precondition is written in the code (private nextString javadoc, AutomatonTermsEnum.java:190-191, and the comment at :297-299). For DFAs the same input is a documented violation, Lucene's own producers did not create such an NFA in the study's checks, and the same input threw in every sampled release from 4.0.0 to 9.12.3. A maintainer could call it garbage in and only document the rule.
Why it fails: users do not call AutomatonTermsEnum directly; they call public AutomatonQuery, CompiledAutomaton or SortedSetDocValues.intersect, whose javadoc limits the rule to DFAs. In 4.x to 9.x the javadoc did forbid dead states; #225 dropped that rule for NFAs without adding cleanup. Block tree accepts the same input, and the fix costs one liveness pass that returns the input instance when nothing is dead. At the least, code and docs disagree; the cross-model reviewer reached the same verdict. Verdict: a defect, low severity.
Recommended A. Remove dead states from non-deterministic input in CompiledAutomaton
In the CompiledAutomaton constructor, call Operations.removeDeadStates when the automaton is not deterministic (one if); it returns the same instance when nothing is dead. The repro then passes with assertions on and off, and 22 related core test classes, the memory module and the core static checks pass.
public CompiledAutomaton(
Automaton automaton, boolean finite, boolean simplify, boolean isBinary) {
+ // AutomatonTermsEnum needs an automaton without dead states; an NFA is executed via lazy
+ // determinization, whose DFA states inherit any NFA dead state.
+ if (automaton.isDeterministic() == false) {
+ automaton = Operations.removeDeadStates(automaton);
+ }
+
if (automaton.getNumStates() == 0) {All options and trade-offs3 options ยท 747 words
Fix A and fix B were both run through gradle in a scratch worktree of 645d4fe14a20 (2026-11-01). A is recommended: it restores the guarantee AutomatonTermsEnum relies on for every consumer at once and makes the NFA path match the javadoc. B is cheaper on the normal path but fixes only the enumerator. C only documents the rule.
A. Remove dead states from non-deterministic input in CompiledAutomaton (validated by the checks) (recommended)
At the top of the 4-argument constructor, before the existing 0-state check (the study verifier's fix, re-applied to 645d4fe14a20).
public CompiledAutomaton(
Automaton automaton, boolean finite, boolean simplify, boolean isBinary) {
+ // AutomatonTermsEnum needs an automaton without dead states; an NFA is executed via lazy
+ // determinization, whose DFA states inherit any NFA dead state.
+ if (automaton.isDeterministic() == false) {
+ automaton = Operations.removeDeadStates(automaton);
+ }
+
if (automaton.getNumStates() == 0) {Trade-offs
- Covers every consumer at once: doc values,
MemoryIndex,FilterTerms, SimpleText,Intervals. Before the 0-state check, an all-dead NFA (empty language) reuses that check; the study's empty-language NFA then returns 0. - Cost: one liveness pass per compiled NFA (forward reachability, plus backward reachability over a reversed copy). The pass allocates these even when nothing is dead; only then does it return the input instance instead of a cleaned copy. Once per query construction, not per segment or term. On main, where #15939 and #15961 made
RegexpQueryandWildcardQueryrun NFAs directly, every non-deterministic regexp or wildcard automaton pays it although built-in NFAs are already clean. - If removing dead states makes the automaton deterministic, the DFA path and its NONE/ALL/SINGLE simplification take over, which is correct.
- Scope: only non-deterministic input is cleaned; a DFA with dead states stays outside the documented DFA domain and can still fail. Cleaning before the UTF-8 conversion appears sufficient (
UTF32ToUTF8.convertfollows only reachable states; code reading, no dedicated test).AutomatonQuerykeeps the caller's automaton forequals/toString, and itssharesAutomatoncheck counts a cleaned copy in RAM accounting.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20: the repro fails on stock main and passes with A, with-Ptests.asserts=trueand=false. - With A: 22 core test classes run 222 tests, 0 failures, 2 skipped (
@Nightly): the repro,TestAutomatonQuery,TestAutomatonQueryUnicode,TestRegexpQuery,TestRegexpRandom,TestRegexpRandom2,TestWildcardQuery,TestWildcardRandom,TestPrefixQuery,TestPrefixRandom,TestFuzzyQuery,TestDocValuesRewriteMethod,TestMultiTermQueryRewrites,TestTermRangeQuery,TestCompiledAutomaton,TestNFARunAutomaton,TestOperations,TestAutomaton,TestRegExp,TestDeterminism,TestTermsEnum,TestTermsEnum2. The memory module runs 51 tests, 0 failures.:lucene:core:check -x testpasses. The full core suite was not run. - Earlier: the study's 10-test file passes 10/10; the cross-model reviewer's own test and
TestCompiledAutomaton,TestAutomatonQueryandTestNFARunAutomatonpass with the fix shadowed.
In AutomatonTermsEnum.nextString(int, int) (:301), seek to the prefix that led into a dead state instead of taking a transition it does not have.
- transitionAccessor.initTransition(state, transition);
+ if (transitionAccessor.initTransition(state, transition) == 0) {
+ // a dead state, which an NFA may have: seek to this prefix, the terms dictionary and
+ // backtracking take it from there
+ return true;
+ }
transitionAccessor.getNextTransition(transition);Trade-offs
One comparison on the normal path. By code reasoning the prefix is still a valid lower bound: the terms dictionary seeks to the next real term, which is checked as usual, and the next nextString backtracks past the dead branch. Narrower: it fixes only AutomatonTermsEnum, its "no dead states" comment and javadoc need rewording, and a dead branch costs an extra seek. The tested cases pass; repeated seeks or loops are not ruled out by those suites, so broader randomized testing would be prudent before preferring B.
What the checks validated
Gradle, same worktree: the repro passes with B, with assertions on and off; the same 22 core test classes run 222 tests, 0 failures, 2 skipped. The memory module and the static checks were not run with B.
Say in the CompiledAutomaton and AutomatonQuery javadoc that NFAs must not have dead states either (callers can use Operations.removeDeadStates), optionally with a check that throws IllegalArgumentException (Operations.hasDeadStatesFromInitial; hasDeadStates would also reject harmless unreachable states).
Trade-offs
Least behaviour change, but without the check the result still differs between block tree and the other paths; the check costs the same liveness pass as A. Not tested.
Downstream code that builds automata itself and runs them on doc values, MemoryIndex or wrapped readers can call Operations.removeDeadStates first until a fix ships; built-in regexp, wildcard and fuzzy parsing did not produce such automata in the study's checks.
Semi-formal: the study checked CompiledAutomaton's term enumeration on a real index against an exact reference semantics for every automaton with at most 2 states over three labels (16,401 automata). 10 failed, all NFAs with a reachable dead state. An independent verifier and a cross-model reviewer then reproduced it.
The method in full140 words
Method: semi-formal (bounded-exhaustive end-to-end translation validation). The prior study's automaton harness compares Lucene's automaton code with an exact reference semantics. One check indexes 161 terms and, for every automaton with at most 2 states and labels {a}, {b}, [a-b] (16,401 automata) and their determinizations, compares the terms that getTermsEnum, AutomatonTermsEnum and Terms.intersect return with the exact accepted set. 10 of the 16,401 failed in 4 s; over all scopes the failures (158 code-point and 66 binary cases) were all NFAs with a reachable dead state.
The study's verifier wrote its own 10-test file (block tree correct; doc-values rewrite, SortedSetDocValues.intersect, MemoryIndex, a FilterTerms wrapper and RegexpQuery + provider fail), a plain main() without assertions, a scan of built-in producers, and the fix. The hunt then reduced it to the one-test repro and re-verified it on main and releases.
No prior report found in GitHub issues, PRs and commits (including all 409 open PRs), the JIRA archive, the Lucene mailing lists, downstream repositories or the web (2026-11-01). Not fixed upstream as of 2026-11-01.
Searches4 searches
Closest items, none a report of this bug: #11049 (LUCENE-10010 design discussion: the algorithm "requires not just DFA, but also no dead states"); #225 (the introducing PR; its review does not cover dead states in NFAs); #14218, #14211/#14212, #14210 (dead states created by Operations, not user NFAs); #12906/#12909 (a different NFARunAutomaton transition bug); OpenSearch #19319 (a concurrency AIOOBE in NFARunAutomaton, fixed under #15939); #8678, #8697, #8627 (SINGLE/ALL/NONE automata on the same paths); #13706/#13707, #11020 (isTotal, getCommonSuffix with dead states). Open PRs #16801, #16564, #13072, #16361 and #16816 touch the same files without changing the failing path.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch and Solr repositories; the web | 2026-11-01 | a scripted prior-report search (60 issue/PR, 6 commit, 5 downstream and 6 web queries; 366 distinct hits read by overlap) and 63 GraphQL phrasings, including the symptom texts ("Index 5 out of bounds", "at least one transition", "transitions to dead states") | no report |
| Open PRs | 2026-11-01 | changed files of all 409 open apache/lucene PRs | 20 touch the affected files; none changes the NFA dead-state path |
| JIRA archive and mailing lists | 2026-11-01 | 6 JIRA-archive and 32 mailing-list queries (dev@, java-user@, issues@, commits@); full threads of #225, #11049, #485, #13715, #14210, #14211, #14218, #15939, #15961 | dead states in NFAs reaching AutomatonTermsEnum are not discussed |
| Study search | 2026-10-22 | about 20 phrasings in GitHub issue and PR search and two web queries | no report |
Introduced in 10.0.0 by LUCENE-10010 (#225, c972c6a7754e, 2021), which let CompiledAutomaton run NFAs directly without removing dead states; 10.0.0 to 10.6.0 and main are affected. In 4.0.0 to 9.12.3 the same input also threw, but the javadoc then forbade dead states.
Versions tested6 rows
Introduced by c972c6a7754e "LUCENE-10010 Introduce NFARunAutomaton to run NFA directly (#225)" (by zhaih, merged 2021-12-20; CHANGES.txt 10.0.0, New Features). It removed the constructor's "Automaton must be deterministic" check, rewrote the javadoc so that only DFAs "must not have dead states", and wrapped NFAs as they are. Its parent e79012550444 rejects the NFA with IllegalArgumentException when the query is built; c972c6a7754e throws the AIOOBE. git tag --contains c972c6a7754e lists 10.0.0 to 10.6.0 and no 9.x tag.
Before 10.0: every sampled release from 4.0.0 to 9.12.3 also throws for the same input (AIOOBE in AutomatonTermsEnum in 4.0.0 to 4.9.1; AssertionError in Automaton.getNextTransition from 4.10.4, and AIOOBE Index -1 on 9.12.3 with assertions off), but there the javadoc said the automaton "must not have dead states". That crash dates from LUCENE-3030 (b7846ffaa1f, 2011, first released in 4.0.0-alpha), which dropped the minimize() call that used to remove dead states in the AutomatonQuery constructor. So on the same input it is not a regression; #225 made the input documented-legal.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (gradle and standalone); passes with fix A or B |
| 10.0.0, 10.5.2, 10.6.0 | first affected 10.0.0 | minimal repro fails, the same AIOOBE (getNextTransition:196) |
c972c6a7754e (#225) | introducing commit | AIOOBE on the MemoryIndex and doc-values probes, assertions on and off |
e79012550444 (parent of #225, unreleased) | fails loudly | IllegalArgumentException: Automaton must be deterministic when the query is built |
| 4.0.0 to 9.12.3 (sampled) | fails loudly | the same input also throws, but the javadoc then forbade dead states |
2011 trunk ea946d6d434d (before LUCENE-3030, unreleased) | not affected | correct: minimize() removed dead states |
Confirmed: the study's independent verifier, the main refresh, a re-verification, a refute-lens review by a reviewer from another model family (OpenAI GPT-5.6, real, high confidence) and this doc's gradle runs all reproduce it. Everyone agrees on the root cause and that the impact is minor: a loud exception for a hand-built automaton.
Checks and verdicts300 words
The cross-model reviewer wrote an independent test (language {m, mn} with a dead branch on "c"); it failed on main with assertions on and off and another seed, and the cleaned-NFA control passed. Its attempted default-rewrite control also failed, because the test framework's random codec picked a postings format that enumerates with AutomatonTermsEnum; re-run on 645d4fe14a20 it fails the same way.
| Check | Verdict | What it found |
|---|---|---|
| study verifier | real, low severity | Own 10-test file: 5 fail (doc-values rewrite, SortedSetDocValues.intersect, MemoryIndex, FilterTerms, RegexpQuery + provider); block tree and controls pass; the fix passes 10/10. |
main refresh a9779ef3c193 | still reproduces | The same 5 of 10 study tests fail. |
| re-verification | real | Main and 10.5.2 with assertions on and off, 10.0.0, a 9.12.3 probe; controls 3/3; the fix shadow passes. |
| cross-model review, refute lens (OpenAI GPT-5.6) | real, high | Own independent test fails on main with assertions on and off; the cleaned-NFA control passes; the fix passes its test and three upstream suites. |
| this doc | fails on stock main; fixes A and B validated | Gradle runs of the repro, fix A with 22 core test classes, the memory module and the static checks, fix B with the same classes; path probes per codec. |
| fact check | real; four errors in trigger scope and wording, fixed | Re-ran the repro (assertions on and off) and the gradle command, re-ran fixes A and B, and probed which dead-state shapes crash: a dead end the walk reaches throws; a dead state with a self-loop counts correctly. |
| cross-model fact check (OpenAI GPT-5.6) | real; no high-severity error; wording fixed | A review of the brief against the code: precision of the NFA branch condition, the Operations claim, sampled releases, scoped claims and caveats on the fixes. |
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 498 KB
Finding record, Lucene and upstream sources, repro files and diagram notes782 words
- Registry title
- AutomatonTermsEnum throws ArrayIndexOutOfBoundsException for an NFA with a reachable dead state
- Area
- util / automaton
- Priority
- Low, rank 1142: AutomatonTermsEnum throws ArrayIndexOutOfBoundsException for hand-built NFAs with a reachable dead state through non-block-tree consumers, and Lucene's own builders did not produce one in the study's checks.
- Impact class
- crash
- Impact note
- Only applications that build an NFA themselves (or return one from an AutomatonProvider) with a reachable dead end (a dead state with no transitions) that the term walk reaches, run through a consumer that enumerates with AutomatonTermsEnum (doc-values rewrite or intersect, MemoryIndex, FilterTerms wrappers, SimpleText); the default codec with the default rewrite answers correctly; the query throws ArrayIndexOutOfBoundsException, with no wrong results; released since 10.0.0 (10.0.0 to 10.6.0 and main) [exec].
- Summary
- You compile an NFA that has a reachable dead end (a dead state with no transitions) on the term walk into a CompiledAutomaton and intersect it through a non-block-tree consumer such as DOC_VALUES_REWRITE, SortedSetDocValues.intersect or MemoryIndex. You expect the matching terms, which the block-tree path returns. Instead AutomatonTermsEnum throws ArrayIndexOutOfBoundsException.
- Introduced
- c972c6a7754e (LUCENE-10010 'Introduce NFARunAutomaton to run NFA directly', apache/lucene#225, by zhaih, merged 2021-12-20, first released in 10.0.0): it removed CompiledAutomaton's 'Automaton must be deterministic' check, scoped the no-dead-states rule to DFAs and wrapped NFAs as they are [exec: single-commit builds; its parent e79012550444 rejects the NFA with IllegalArgumentException when the query is built, c972c6a7754e throws the ArrayIndexOutOfBoundsException; read: git tag --contains lists 10.0.0 to 10.6.0 and no 9.x tag]; the same input also threw in the sampled releases 4.0.0 to 9.12.3, where the javadoc forbade dead states (since LUCENE-3030, b7846ffaa1f, 2011) (2021-12-20)
- First affected version
- 10.0.0 [exec] (the minimal repro fails on the 10.0.0 release jars; before 10.0 the javadoc forbade dead states)
- Affected versions
- 10.0.0 to 10.6.0 and main [exec: the minimal repro fails on the 10.0.0, 10.5.2 and 10.6.0 release jars and on main 645d4fe14a20 (-ea and -da on main); read: git tag --contains c972c6a7754e lists 10.0.0 to 10.6.0]; not fixed upstream: upstream main f815e679bf6c has the same code [read, 2026-11-01]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-11-01, read-only: a scripted prior-report search with 60 GitHub issue/PR, 6 commit, 5 downstream, 32 mailing-list (dev@, java-user@, issues@, commits@), 6 JIRA-archive and 6 web queries, 63 GitHub GraphQL phrasings and the changed files of all 409 open apache/lucene PRs; earlier study searches 2026-10-22); closest items, none a report of this bug: #11049 (the LUCENE-10010 design discussion), #225 (the introducing PR), #14218 and #14211/#14212 (dead states created by Operations, not user NFAs); not fixed upstream
- Lucene commit tested
- main
645d4fe14a20(the minimal repro through gradle with-Ptests.asserts=trueand=false, stock and with fix A and fix B; fix A with 22 related core test classes, the memory module and:lucene:core:check -x test; fix B with the same 22 classes; path probes for the default, test-framework, asserting and SimpleText codecs; a fact-check probe of dead-state shapes with assertions on and off), the 10.0.0, 10.5.2 and 10.6.0 release jars (minimal repro), single-commit builds ofc972c6a7754e(#225) and its parent, the releases 4.0.0 to 9.12.3 (probes), and, by the study and the main refresh, main7cecf79523,b83101cdd2anda9779ef3c193. The files involved are byte-identical on upstream mainf815e679bf6c(2026-11-01, read).
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-40, :159-160, :253-257, :348-357 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/automaton/NFARunAutomaton.java:199-206, :209-221, :399-403, :464 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/AutomatonTermsEnum.java:129-146, :190-191, :262-310 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/Terms.java:64-86; index/SortedSetDocValues.java:109-119; search/DocValuesRewriteMethod.java:98-104; search/AutomatonQuery.java:99 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/IntersectTermsEnumFrame.java:126-129; IntersectTermsEnum.java:415-429 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:253, :258-261; codecs/lucene103/blocktree/IntersectTermsEnumFrame.java:123-137 (at 645d4fe14a20)
- Who is affected
lucene/CHANGES.txt:94-97 (GITHUB#15939, 11.0.0); lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:143, 264, 322, 356, 436, 501; index/FilterLeafReader.java:90-96 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:147-193 (optional), :1305 (reverse); RegExp.java:711-726 (a `<name>` leaf is returned as is) (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-40; search/AutomatonQuery.java:29-42, :59-65; index/AutomatonTermsEnum.java:190-191, :297-299; util/automaton/Operations.java:1007-1012 (at 645d4fe14a20)lucene/MIGRATE.md:922-926 (at 645d4fe14a20)
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:1007-1012 (removeDeadStates returns the input when every state is live) (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:455 (hasDeadStatesFromInitial), :926-1000 (getLiveStates, getLiveStatesToAccept); UTF32ToUTF8.java:291-336; search/AutomatonQuery.java:99-104; lucene/CHANGES.txt:94-100 (GITHUB#15939, GITHUB#15961) (at 645d4fe14a20)
- History
lucene/CHANGES.txt:2304 (at 645d4fe14a20)
- TestAutomatonQueryNfaDeadState.java (JUnit, one test):
repro/LFV-055/src/org/apache/lucene/search/TestAutomatonQueryNfaDeadState.java(62 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-055/bug.png(498 KB)
