- An FST (finite state transducer) is Lucene's compact map from keys to outputs, such as weights. Lucene's suggesters store their suggestions in one.
Util.shortestPaths(fst, node, ..., topN, ...)returns the topN keys with the smallest total output reachable from a node. It keeps candidate paths in a sorted queue (TopNSearcher) of at mostmaxQueueDepthentries, andshortestPathssets that limit to topN.- When the queue is full, a new candidate is compared with the worst queued path,
queue.last(), and kept only if it is better.
shortestPaths into a queue limited to 0 paths, why the empty queue counts as full, and where queue.last() throws; then what you expect and what you get. Select the still to open it at full size. Generated with AI, checked against the code.- Build an FST with one key, "a" with output 1, and ask for its cheapest paths from the root:
Util.shortestPaths(fst, root, 0L, naturalOrder, topN, false). With topN = 1 you get one path, a = 1. - Now pass topN = 0, as code does that forwards a caller's count unchanged, for example a request for 0 suggestions sent to
FreeTextSuggester.lookup(key, 0). - You expect an empty result: the top 0 paths are none. Lucene's own search loop already returns an empty, complete result for 0, topN = -1 returns empty, and Lucene 4.0.0 returned empty for 0.
- Instead it throws
java.util.NoSuchElementException.shortestPathslimits the queue to topN paths, here 0. While seeding it,addIfCompetitivechecksqueue.size() == maxQueueDepth: 0 == 0, so the empty queue counts as full, andqueue.last()has nothing to return (Util.java:215-216). - It fails with assertions on and off, on every release tested from 4.1.0 to 10.6.0 and on main.
FreeTextSuggester.lookup("the", 0)throws on the same line, and with assertions off so do someWFSTCompletionLookup,AnalyzingSuggesterandFuzzySuggesterlookups. Nothing is corrupted: only that one call fails.
Code that asks for 0 results: direct users of the experimental Util.shortestPaths or TopNSearcher, and applications that forward a count of 0 to FreeTextSuggester.lookup (no check; fails with assertions on or off for a key that matches its model). With assertions off, some WFSTCompletionLookup, AnalyzingSuggester and FuzzySuggester lookups fail too. One call throws; nothing else breaks. Rare.
Mechanism, terms and numbers938 words
Util.shortestPaths (core, @lucene.experimental) finds "the top N min cost completions to a final node" of an FST, with no stated range for N. Called with topN = 0, it throws java.util.NoSuchElementException instead of returning an empty result as soon as the start node offers one arc to the queue (every labelled arc; the end-of-string arc only with allowEmptyString). It builds a TopNSearcher whose queue may hold maxQueueDepth = topN = 0 paths (Util.java:467); while seeding the queue, addIfCompetitive treats queue.size() == maxQueueDepth as "full, compare with the worst entry", which is true for the empty queue, and queue.last() throws (Util.java:215-216). The rest of the search already handles 0, topN = -1 returns empty, and 4.0.0 returned empty. FreeTextSuggester.lookup(key, 0) has no check on num and reaches the same line, for a key that matches its model, with assertions on and off. With assertions off, so do WFSTCompletionLookup for a prefix that is not itself a key, AnalyzingSuggester without EXACT_FIRST, and AnalyzingSuggester / FuzzySuggester with their default EXACT_FIRST when the key is itself a suggestion. The throw came with LUCENE-4480 (2012) and is in every release executed from 4.1.0 to 10.6.0 and in main; all 121 release tags have the same unguarded check. A four-line early return for maxQueueDepth == 0 fixes every caller; it passes the core FST tests, the suggest module and core's static checks.
- FST: Lucene's finite state transducer, a compact map from keys (byte or char sequences) to outputs such as weights. Suggesters store their suggestions in one.
- Top-N search (
Util.TopNSearcher, used byUtil.shortestPaths): walks the FST from a start node and returns the N completions with the smallest total output. It keeps candidate paths in a sortedTreeSetof at mostmaxQueueDepthentries;shortestPathspasses N as bothtopNandmaxQueueDepth.
Util.shortestPathscreatesnew TopNSearcher<>(fst, topN, topN, comparator)(Util.java:467), callsaddStartPaths(:471), thensearch()(:472). It checks no argument.addStartPathscallsaddIfCompetitive(path)for each arc leaving the start node (:283-286; the end-of-string arc only ifallowEmptyString). So one offered arc is enough: an FST whose only key is the empty string returns empty fortopN = 0withallowEmptyString = false, and throws withtrue(the study verifier's characterization test).addIfCompetitivefirst asks whether the queue is full,if (queue.size() == maxQueueDepth) {(:215), and if so compares the new path with the worst queued one,FSTPath<T> bottom = queue.last();(:216). That assumes a full queue has a last entry. WithmaxQueueDepth == 0the empty queue is already "full" (0 == 0), andTreeSet.last()on an empty set throwsNoSuchElementException.- Everything else already means "nothing" for 0: the non-full branch would add the path and drop it again at size
maxQueueDepth + 1(:247-249), after building it and asking the protectedacceptPartialPathhook (:239-246);search()loopswhile (results.size() < topN)(:310) and reportsisComplete = rejectCount + topN <= maxQueueDepth(:409), true for 0. It is never reached. - Negative values do not throw: the size is never -1, so the queue grows without pruning (it keeps every seed path), and
search()returns empty becauseresults.size() < -1is false. - Suggesters reach the same line.
FreeTextSuggester.lookupnever checksnum(FreeTextSuggester.java:426); for each model whose last tokens have a matching prefix in the FST (others are skipped,:526-561) it buildsTopNSearcher(fst, num, num + seen.size(), ...)(:607-608) withseenempty for the first model; itsaddIfCompetitiveoverride delegates tosuper(:613-617).WFSTCompletionLookup.lookuphas onlyassert num > 0(:156) and callsUtil.shortestPaths(..., num, ...)(:198).AnalyzingSuggesterhas onlyassert num > 0(:670); its main searcher getstopN = num - results.size()and depthnum * maxAnalyzedPathsForOneInput, so depth 0 (:785-786), and is seeded with the prefix paths (:819-821). WithoutEXACT_FIRSTthat happens for any matching prefix. With the defaultEXACT_FIRST, a key that is not itself a suggestion returns early at:777(results.size() == num, 0 == 0); a key that is itself a suggestion is added by the exact-first pass (:769-775), soresults.size()is 1, the early return is skipped, and the searcher (topN = -1, depth 0) throws.FuzzySuggesterextendsAnalyzingSuggester.
One-key FST "a" -> 1: topN = 1 returns a = 1; topN = 0 throws at Util.java:216, called from addStartPaths (:285), with assertions on and off. Suggesters with num = 0 (keys car, cat, dog; the FreeText model built from "the cat sat", "the car", "a dog"):
FreeTextSuggester.lookup("the", 0)throws, with assertions on and off;lookup("the", 1)returns 1 result.WFSTCompletionLookup.lookup("c", false, 0)throws with assertions off (with assertions on,assert num > 0fires first).AnalyzingSuggesterbuilt withoutEXACT_FIRST,lookup("c", false, 0), throws with assertions off (the study fix author's probe on main7cecf79523, and the fact check's probe on645d4fe14a20).AnalyzingSuggester/FuzzySuggesterwith their defaultEXACT_FIRST, assertions off:lookup("cat", false, 0)(the key is itself a suggestion) throws atUtil.java:216;lookup("c", false, 0)returns 0 results. With assertions on,assert num > 0(:670) fires first.FSTCompletionLookupandTSTLookupreturn 0 results; the NRTTopSuggestDocsCollectorrejects 0 withIllegalArgumentException("'num' must be > 0"). (The study's reach test asserts only that FreeText must not throw; the other rows are its printed outcomes, re-checked by the fact check's probe.)
Exposure and likelihood240 words
- Who is hit. Code that passes an explicit 0: direct callers of
Util.shortestPaths/Util.TopNSearcher(public,@lucene.experimental); applications that pass a caller-supplied count toFreeTextSuggester.lookup(key, num), for example a request asking for 0 suggestions for a key that matches the model; and, with assertions off as in production,WFSTCompletionLookup.lookup(prefix, false, 0)for a prefix that is not itself a key,AnalyzingSuggesterbuilt withoutEXACT_FIRSTfor a matching prefix, andAnalyzingSuggester/FuzzySuggesterwith their default options for a key that is itself a suggestion.Lookup.lookupdocumentsnumas the "maximum number of results to return", with no minimum. - What happens. That one call fails at once with an internal
NoSuchElementExceptionfromTreeSet.last(), not with an error about the count. Code that catchesIllegalArgumentExceptionfor bad arguments does not catch it. - What does not happen. No wrong results, no corruption, no hang, no meaningful resource use, no effect on the index or on other requests. Data and scale play no part: one start arc offered to the queue is enough.
- Default vs opt-in. Nothing calls these with 0 by default; it takes an explicit count of 0. Likelihood: rare. The behaviour also differs between suggesters:
FSTCompletionLookupandTSTLookupreturn 0 results fornum = 0,AnalyzingSuggester/FuzzySuggesterwith default options (assertions off) return 0 results for a key that is not itself a suggestion and throw for one that is, and the NRT suggester rejects 0.
One JUnit test on the public API: build a one-key FST and call Util.shortestPaths with topN = 0. It expects an empty result and fails only when the NoSuchElementException escapes. Deterministic, single thread, about 1.4 s; the same with assertions on and off.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.util.fst;
import java.util.Comparator;
import java.util.NoSuchElementException;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.IntsRefBuilder;
public class TestShortestPathsTopNZero extends LuceneTestCase {
public void testTopNZeroReturnsEmptyResult() throws Exception {
PositiveIntOutputs outputs = PositiveIntOutputs.getSingleton();
FSTCompiler<Long> compiler = new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE1, outputs).build();
compiler.add(Util.toIntsRef(new BytesRef("a"), new IntsRefBuilder()), 1L);
FST<Long> fst = FST.fromFSTReader(compiler.compile(), compiler.getFSTReader());
try {
Util.TopResults<Long> top =
Util.shortestPaths(
fst, fst.getFirstArc(new FST.Arc<>()), 0L, Comparator.naturalOrder(), 0, false);
assertEquals(0, top.topN.size());
} catch (NoSuchElementException e) {
throw new AssertionError("expected an empty result for topN=0, got " + e, e);
}
}
}
cp TestShortestPathsTopNZero.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestShortestPathsTopNZero -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
- An empty
TopResults(topN.size() == 0), whichsearch()already computes for topN = 0. - Observed
java.util.NoSuchElementExceptionfromTreeSet.last()atUtil$TopNSearcher.addIfCompetitive(Util.java:216), called fromaddStartPaths(Util.java:285); the same with assertions off.
More files, environment, full output247 words
TestShortestPathsTopNZero builds a one-key FST ("a" -> 1, PositiveIntOutputs, BYTE1) with FSTCompiler, then calls Util.shortestPaths(fst, fst.getFirstArc(new FST.Arc<>()), 0L, Comparator.naturalOrder(), 0, false) and asserts top.topN.size() == 0. A NoSuchElementException is rethrown as an AssertionError that states expected and actual, with the original as its cause. Public API only (it sits in org.apache.lucene.util.fst like TestFSTs, but uses nothing package-private); no randomness of its own, so the seed does not matter (two seeds gave the same result). It compiles on 9.10.0 and later: FST.fromFSTReader, FSTCompiler.getFSTReader and a compile() that returns the FST metadata came together in #12831, first released in 9.10.0.
As the regression test: this same test, for example as testShortestPathsTopNZero in TestFSTs. With fix A it passes ./gradlew :lucene:core:check -x test unchanged. If the maintainer picks option B (reject 0), its assertion becomes expectThrows(IllegalArgumentException.class, () -> Util.shortestPaths(...)).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 9.12.3, 10.0.0, 10.5.2 and 10.6.0; every release tested from 4.1.0 to 10.6.0 is affected |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp TestShortestPathsTopNZero.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestShortestPathsTopNZero -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
TestShortestPathsTopNZero > testTopNZeroReturnsEmptyResult FAILED
java.lang.AssertionError: expected an empty result for topN=0, got java.util.NoSuchElementException
at __randomizedtesting.SeedInfo.seed([DEADBEEF:E5AE15C911A0C3BF]:0)
at org.apache.lucene.util.fst.TestShortestPathsTopNZero.testTopNZeroReturnsEmptyResult(TestShortestPathsTopNZero.java:38)
Caused by:
java.util.NoSuchElementException
at java.base/java.util.TreeMap.key(TreeMap.java:1638)
at java.base/java.util.TreeMap.lastKey(TreeMap.java:310)
at java.base/java.util.TreeSet.last(TreeSet.java:404)
at org.apache.lucene.util.fst.Util$TopNSearcher.addIfCompetitive(Util.java:216)
at org.apache.lucene.util.fst.Util$TopNSearcher.addStartPaths(Util.java:285)
at org.apache.lucene.util.fst.Util$TopNSearcher.addStartPaths(Util.java:257)
at org.apache.lucene.util.fst.Util.shortestPaths(Util.java:471)
at org.apache.lucene.util.fst.TestShortestPathsTopNZero.testTopNZeroReturnsEmptyResult(TestShortestPathsTopNZero.java:34)
# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=false; identical with assertions on- The javadoc promises "the top N min cost completions" with no range for N;
Lookupcallsnuma "maximum number of results". - The code already means empty for 0:
search()returns an empty, complete result, and topN = -1 returns empty. - An apparently accidental 2012 regression: 4.0.0 returned empty for 0; tests only use topN 1 to 10.
The full argument277 words
- Contract.
shortestPaths: "Starting from node, find the top N min cost completions to a final node." (Util.java:455), with no range and no@throws.TopNSearcher: "@param topN the number of top scoring entries to retrieve", "@param maxQueueDepth the maximum size of the queue of possible top entries" (Util.java:185-186), no range.Lookup.lookup: "@param num maximum number of results to return" (Lookup.java:190, 206, 219); nothing there says 0 is invalid. The FST package docs show "N-shortest paths by weight" (package-info.java:82-95) with no range. - The code already means "empty" for 0.
search()returns an empty, complete result fortopN = 0(Util.java:310,:409); the throw happens before it.topN = -1returns empty.AnalyzingSuggesteritself builds aTopNSearcher(0, 0)whenever anEXACT_FIRSTlookup finds no exact match (count * maxSurfaceFormsPerAnalyzedFormwithcount == 0,AnalyzingSuggester.java:731-735), so 0 is a depth Lucene uses; it just never seeds that searcher. - History: by all appearances an accidental regression. Before LUCENE-4480 the searcher pruned only once a separate
bottomfield was set, which never happened fortopN = 0: release 4.0.0 and the parent commit return an empty result. LUCENE-4480 ("handle insert into empty queue correctly", 2012-10-12), which fixed a differentNoSuchElementException(queue exhaustion withtopN = 4), replaced it withif (queue.size() == topN) { bottom = queue.last(); ..., which throws for 0. LUCENE-4481 gave today'squeue.size() == maxQueueDepth. Neither issue (#5546, #5547) mentions 0. - Tests never tried 0.
TestFSTsdrawstopNfromTestUtil.nextInt(random, 1, 10)(TestFSTs.java:1517,:1641), and its fixed shortest-path tests use 3 (:1341-1347,:1442-1448).
The devil's-advocate check172 words
The strongest case for "not a bug" (the cross-model reviewer's and the verifiers'): some suggesters assert num > 0 (WFSTCompletionLookup.java:156, AnalyzingSuggester.java:670) and the NRT collector throws IllegalArgumentException for num <= 0 (TopSuggestDocsCollector.java:77-78), so the project's convention may be that 0 is not a valid count; Util is @lucene.experimental; and the call already fails loudly.
Why it fails: that convention is stated for none of shortestPaths, TopNSearcher, Lookup or FreeTextSuggester. An assert states an internal invariant, not an API precondition, and it is off in production, where WFSTCompletionLookup, AnalyzingSuggester and FuzzySuggester can then hit this exact throw. @lucene.experimental means the API may change, not that inputs are undefined. And even if 0 should be rejected, an internal NoSuchElementException from TreeSet.last() is not how Lucene rejects arguments: the facets module normalised topN <= 0 to IllegalArgumentException (#11503 / PR #751). Residual risk: a maintainer may prefer an IllegalArgumentException to an empty result; that is a choice of fix (option B), not "works as designed".
Recommended A. Return early in addIfCompetitive when maxQueueDepth is 0 (empty result)
Return early from TopNSearcher.addIfCompetitive when maxQueueDepth == 0: a queue that can hold nothing keeps nothing. That one spot fixes shortestPaths and every suggester path that reaches the throw. With it the repro passes with assertions on and off, and the core FST tests, the whole suggest module and core's static checks pass.
--- a/lucene/core/src/java/org/apache/lucene/util/fst/Util.java
+++ b/lucene/core/src/java/org/apache/lucene/util/fst/Util.java
@@ -213,6 +213,10 @@
T output = fst.outputs.add(path.output, path.arc.output());
if (queue.size() == maxQueueDepth) {
+ if (maxQueueDepth == 0) {
+ // Queue can't hold any path, so nothing competes
+ return;
+ }
FSTPath<T> bottom = queue.last();
int comp = pathComparator.compare(path, bottom);
if (comp > 0) {All options and trade-offs3 options ยท 763 words
Fix A was validated through gradle in a scratch worktree of 645d4fe14a20. B and C are alternatives from the study's fix notes and the reviews; neither was run.
A. Return early in addIfCompetitive when maxQueueDepth is 0 (empty result) (validated by the checks) (recommended)
In Util.TopNSearcher.addIfCompetitive, inside the queue-full branch and before queue.last(), return when maxQueueDepth == 0. It leaves the queue as the non-full branch would (that branch adds the path and drops it again at once); it only skips building that path and asking the protected acceptPartialPath hook, which in the tree only NRTSuggester overrides (its collector rejects num <= 0).
--- a/lucene/core/src/java/org/apache/lucene/util/fst/Util.java
+++ b/lucene/core/src/java/org/apache/lucene/util/fst/Util.java
@@ -213,6 +213,10 @@
T output = fst.outputs.add(path.output, path.arc.output());
if (queue.size() == maxQueueDepth) {
+ if (maxQueueDepth == 0) {
+ // Queue can't hold any path, so nothing competes
+ return;
+ }
FSTPath<T> bottom = queue.last();
int comp = pathComparator.compare(path, bottom);
if (comp > 0) {Trade-offs
- Fixes the wrong assumption where it lives, so every caller is covered:
shortestPaths, the publicTopNSearcherconstructor,FreeTextSuggester(its override delegates tosuper),WFSTCompletionLookup, andAnalyzingSuggester/FuzzySuggesterwith and withoutEXACT_FIRST(no throw anywhere in the fact check's probe with the fix). - Restores the 4.0.0 result (empty; 4.0.0 returned a
MinResult[], with noisComplete) and matches whatsearch()computes:topN = 0gives an empty result withisComplete = true;topN >= 1with depth 0 gives empty withisComplete = false("queue too small"), both from the existing formula (Util.java:409). No change for depth 1 or more, or for negative depth. - With assertions off, an exact-key lookup with 0 on
AnalyzingSuggester/FuzzySuggester(default options) then returns its 1 exact match instead of throwing, asWFSTCompletionLookupalready does: more results thannum, from the exact-first step (option C). - Cost: one comparison of a
final int, only inside the already-full branch, which then walks the tree anyway. - It silently accepts 0, which some may see as hiding a caller bug.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seedDEADBEEF, 2026-11-01: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off. ./gradlew :lucene:core:test --tests 'org.apache.lucene.util.fst.*': 9 classes (includingTestFSTsand the repro), 38 tests, 0 failures, 5 skipped (2@Nightly, 3@Ignore)../gradlew :lucene:suggest:test(every otherTopNSearchercaller lives there): 31 classes, 236 tests, 0 failures, 7 skipped../gradlew :lucene:core:check -x testwith the fix and the repro as a new test file: passes (google-java-format, ecj lint, forbidden APIs; error-prone not enabled). The full Lucene test suite was not run.- Earlier: with the fix shadowed into the stock jars the repro passes with assertions on and off; the cross-model reviewer's own tests and the
TestFSTstests it ran pass; FreeText, WFST (prefix) and Analyzing (noEXACT_FIRST) return 0 results. The fact check's probe with the fix: no throw on any core or suggester path;AnalyzingSuggester/FuzzySuggesterdefaultlookup("cat", false, 0)with assertions off returns 1 result;TestFSTspasses.
Check topN < 1 at the top of Util.shortestPaths and num < 1 in FreeTextSuggester.lookup, like TopSuggestDocsCollector does.
if (topN < 1) {
throw new IllegalArgumentException("topN must be >= 1, got " + topN);
}Trade-offs
- Explicit; matches the NRT suggester and the facets precedent (#11503).
- It must not go into the
TopNSearcherconstructor:AnalyzingSuggesterbuildsTopNSearcher(0, 0)whenever anEXACT_FIRSTlookup finds no exact match (AnalyzingSuggester.java:731-735), so a constructor check would break ordinary lookups. - Without that, the public constructor and
AnalyzingSuggester/FuzzySuggester(withoutEXACT_FIRST, or with it for an exact-key lookup; assertions off) still reach the throw, so B needs A as well, or guards in each caller. - It turns
shortestPaths(-1)from empty into an exception;WFSTCompletionLookuppasses -1 today fornum = 0with an exact key.
What the checks validated
Not run.
Today Lookup.lookup(num = 0) asserts, throws IllegalArgumentException, returns empty, or hits this throw, depending on the class. One documented rule (reject or empty) would settle it.
Trade-offs
A policy question across many classes, separate from this bug; it also covers the older WFSTCompletionLookup quirk that returns 1 result for num = 0 on an exact key with assertions off, which fix A does not change, and the same 1-result answer that AnalyzingSuggester / FuzzySuggester give for an exact key with fix A.
Services that pass a user-chosen suggestion count straight to FreeTextSuggester.lookup turn a request for 0 suggestions into an internal NoSuchElementException for that request, so clamping or rejecting counts below 1 before the call avoids it.
Non-formal: code reading. While an AI tool wrote a brute-force checker for the FST top-N search (which only tried topN of 1 or more), it noticed that a queue limited to 0 paths counts as full while empty. An independent verifier then reproduced it through the core API and through the suggesters.
The method in full140 words
- Method: code reading, the study's non-formal tier. In the 2026-10-22 study, the FST verification experiment wrote a brute-force oracle for
Util.shortestPathsto compare with Lucene's top-N search. The oracle's bounded scope usedtopN >= 1, so it did not find this; readingTopNSearcher.addIfCompetitivewhile writing it showed thatqueue.size() == maxQueueDepthholds for an empty queue when the depth is 0. - Verification: an independent verifier (FST) wrote its own core test (three-key FST) and a suggester reach test, reproduced the throw with assertions on and off, found the
FreeTextSuggesterandWFSTCompletionLookuppaths, and called it a new, low-severity bug. - Later: the test was reduced to one test method on a one-key FST (2026-10-29), re-verified on main
645d4fe14a20(2026-11-01), reviewed by a reviewer from another model family that ran its own tests, and its history was bisected by execution.
No matching prior report found in the recorded searches of GitHub issues, PRs and commits, the JIRA archive, the Lucene mailing lists, downstream projects or the web (searched 2026-10-22 and 2026-11-01). The closest item, LUCENE-4480 (#5546), is the 2012 fix that introduced this throw. Not fixed upstream as of 2026-10-30.
Searches4 searches
Closest items, none a report of this bug: #5546 (LUCENE-4480, same method and exception with topN >= 1; its fix introduced this throw), #4919 (LUCENE-3846, the benchmark stack trace that led to LUCENE-4480), #5547 (LUCENE-4481, today's form of the check), #6582 (LUCENE-5519, isComplete), #5633 (LUCENE-4567, a different WFST edge case: an empty FST), #11503 (facets topN <= 0 to IllegalArgumentException: precedent for a fix), #5583 and #10038 (queue sizing in suggesters). Open PRs #12688 and #13390 touch Util.java or FreeTextSuggester.java but not TopNSearcher. Gaps: two downstream port queries were not run, GitHub search reads only the first page of each query, and the open-PR file scan covered selected labels.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits | 2026-11-01 | a scripted prior-report search: 30 issue/PR and 3 commit queries (TopNSearcher, shortestPaths, addIfCompetitive, maxQueueDepth, FreeTextSuggester, WFSTCompletionLookup, topN 0, num 0, NoSuchElementException); manual searches of the same names; changed files of 184 of 410 open PRs | no report; #5546 (LUCENE-4480) introduced the throw |
| Mailing lists, JIRA archive, downstream, web | 2026-11-01 | 24 queries over dev@, java-user@, issues@ and commits@; 6 JIRA queries (LUCENE, SOLR); Elasticsearch, OpenSearch, Solr and Lucene.NET issues; 6 web queries; 290 distinct hits in all, 0 high overlap | no report |
| Earlier study searches | 2026-10-22 to 2026-10-23 | GitHub issue and PR searches for the class and method names and the symptom; all 404 open PRs' changed files; JIRA | no report; no open PR touches TopNSearcher |
| Upstream main | 2026-11-01 | file contents and commits up to 2f424fd428da (2026-10-30); CHANGES.txt | not fixed; no entry |
Introduced in 4.1.0 by LUCENE-4480 (0e77eec4366, 2012-10-12); today's form from LUCENE-4481 (dc49949558d). Release 4.0.0 returned an empty result. Bisected by single-commit builds; 13 releases from 4.1.0 to 10.6.0 and main executed as affected (12 in the history check, 10.5.2 in an earlier run), and all 121 release tags from 4.1.0 to 10.6.0 read.
Versions tested7 rows
Introduced by LUCENE-4480 (apache/lucene#5546, "handle insert into empty queue correctly", trunk 0e77eec4366, 2012-10-12; branch_4x backport a1b3981f322), which replaced a null-safe bottom field with if (queue.size() == topN) { bottom = queue.last(); .... LUCENE-4481 step 1 (7e45db75e3a, 2012-10-20) turned pruning off, which also removed the throw for about 26 hours; step 2 (dc49949558d, 2012-10-21; backport 5726a78f9d4) put it back as queue.size() == maxQueueDepth, today's form. No pull request (Subversion era). git blame at main: Util.java:215 is dc49949558d, :216 is 0e77eec4366. FreeTextSuggester throws from its first release, 4.6.0. A separate, older quirk, not this regression: with assertions off and the default exactFirst, WFSTCompletionLookup.lookup("cat", false, 0) for an exact key returns 1 result (--num makes -1 at WFSTCompletionLookup.java:189), already in 4.0.0; fix A does not change it. With fix A, AnalyzingSuggester / FuzzySuggester with default options give the same 1 exact match for an exact key with assertions off, instead of today's throw.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (standalone and gradle); passes with fix A |
| 10.6.0, 10.5.2, 10.0.0, 9.12.3 | affected | minimal repro fails (10.6.0, 10.5.2 and 9.12.3 with assertions on and off; 10.0.0 with assertions on); Util.java:220 on 9.12.3 |
| 9.0.0, 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4, 4.7.2, 4.1.0 | first affected 4.1.0 | a probe for each FST API generation, same FST and call, throws NoSuchElementException from addStartPaths; the topN = 1 control returns a = 1 |
| 4.6.0 | suggesters | FreeTextSuggester.lookup("the", 0) and WFSTCompletionLookup.lookup("c", false, 0) (assertions off) throw |
| 4.0.0 | last unaffected | returns an empty result for topN = 0, with assertions on and off |
1cadf9c5bb28 / 0e77eec4366 / 7e45db75e3a / dc49949558d | bisect | empty / throws / empty / throws: LUCENE-4480 introduced it, LUCENE-4481 step 2 gave today's form |
121 release tags 4.1.0 to 10.6.0; upstream main 2f424fd428da | read | the same unguarded check (source read, not executed) |
Confirmed by the study's independent verifier, the re-verification on main, a reviewer from another model family (OpenAI GPT-5.6) that ran its own tests and judged it real with high confidence, and this doc's gradle run. The one-hunk fix passed every check it was given.
Checks and verdicts356 words
Seven checks, all agreeing on the root cause; the fact checks led to corrections, now applied in this doc.
| Check | Verdict | What it found |
|---|---|---|
| study verifier | new bug, low severity | Its own core test (three-key FST) and a suggester reach test reproduce the throw with assertions on and off; found the FreeTextSuggester and WFSTCompletionLookup paths. |
| main refresh and re-verification | still reproduces | The verifier's test still fails on main a9779ef3c193; the minimal repro fails on 645d4fe14a20 with assertions on and off, on 10.5.2 and on 9.12.3, and passes with the fix shadowed; the suggester table reproduces. |
cross-model review, refute lens (OpenAI GPT-5.6, ran code on main ea57360912ed) | real, high confidence | Its own two-key FST test and its own FreeTextSuggester test fail for 0 and pass for 1, with assertions on and off; with the fix shadowed they pass, as do the TestFSTs tests it ran. It weighed the positive-count convention of sibling suggesters as the strongest refutation and rejected it. It did not test the older WFST exact-key quirk. |
| history check | introducing commit confirmed by execution | Single-commit builds of 2012 trunk and 13 releases (4.0.0 clean, 12 affected); with 10.5.2 from an earlier run, 13 affected releases were executed in all. |
| brief validation (gradle) | fix A validated | Repro fails on stock main with assertions on and off and passes with fix A; core FST tests, the suggest module and core's static checks pass. |
| independent fact check | core confirmed; one more trigger path | Re-ran the repro standalone and through gradle with assertions on and off, checked every cited line, commit and count, and with its own probe found that AnalyzingSuggester / FuzzySuggester with default options also throw for an exact-key lookup with 0 (assertions off). That path, and small wording fixes, are now in this doc. |
| cross-model fact check (OpenAI GPT-5.6, read-only) | core sound; wording narrowed | Found the diagnosis, history, framing and security call sound; asked for the exact trigger (one start arc offered to the queue), the reach of each suggester path, and softer wording on intent, novelty and releases, now applied. |
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 371 KB
Finding record, Lucene and upstream sources, repro files and diagram notes550 words
- Registry title
- Util.shortestPaths with topN=0 throws NoSuchElementException
- Area
- util / FST
- Priority
- Low, rank 1120: Util.shortestPaths and some suggesters throw NoSuchElementException for a result count of 0, a loud boundary input with a local fix ready.
- Impact class
- crash
- Impact note
- Only callers that ask for 0 results (Util.shortestPaths, FreeTextSuggester.lookup(key, 0), WFSTCompletionLookup.lookup(.., 0)); released since 4.1.
- Summary
- You ask Util.shortestPaths for the top 0 paths, directly or through FreeTextSuggester.lookup(key, 0). You expect an empty result. Instead it throws NoSuchElementException; with assertions off, WFSTCompletionLookup.lookup(prefix, false, 0) throws the same for a prefix that is not itself a key (for an exact key it returns one result, an older, separate quirk).
- Introduced
- LUCENE-4480 (0e77eec4366) introduced the crash; LUCENE-4481 (dc49949558d) gave the current form [exec: single-commit builds: the parent 1cadf9c5bb28 returns an empty result for topN = 0, 0e77eec4366 throws, 7e45db75e3a (LUCENE-4481 step 1) returns an empty result, dc49949558d throws] (2012-10-12 (0e77eec4366))
- First affected version
- 4.1.0 [exec: a probe throws on 4.1.0 and returns an empty result on 4.0.0, the last unaffected release; single-commit builds bisect it to 0e77eec4366, LUCENE-4480]
- Affected versions
- every release from 4.1.0 to 10.6.0, and main [exec: the one-test minimal repro fails on the 9.12.3, 10.0.0, 10.5.2 and 10.6.0 release jars (SHA-1 checked against Maven Central) and on main 645d4fe14a20; probes throw on 4.1.0, 4.6.0, 4.7.2, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.0.0; read: all 121 release tags from 4.1.0 to 10.6.0 have the same unguarded check]; 4.0.0 is not affected [exec]
- 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
- non-formal: code reading
- Upstream and prior reports
- none found (study searches 2026-10-22; public-list check 2026-10-20..23)
- Lucene commit tested
- main
645d4fe14a20: the minimal repro with assertions on and off, standalone and through gradle; the recommended fix in a scratch worktree withorg.apache.lucene.util.fst.*, the whole suggest module and:lucene:core:check -x test. SHA-1-checked release jars: the minimal repro on 9.12.3, 10.0.0, 10.5.2 and 10.6.0; version-specific probes on 4.0.0 (clean), 4.1.0, 4.6.0, 4.7.2, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.0.0; single-commit builds of the 2012 introducing commits and their parent.Util.java,FreeTextSuggester.javaandWFSTCompletionLookup.javaare byte-identical from645d4fe14a20to upstream main2f424fd428da(2026-10-30, read).
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/fst/Util.java:41-43, :181-188, :209-252, :264-292, :294-410, :455-473 (at 645d4fe14a20)lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FreeTextSuggester.java:426, :607-617, :642; fst/WFSTCompletionLookup.java:156, :198; analyzing/FreeTextSuggester.java:526-561; analyzing/AnalyzingSuggester.java:670, :769-777, :785-786, :819-821; analyzing/FuzzySuggester.java:66; document/NRTSuggester.java:159; document/TopSuggestDocsCollector.java:77-78 (at 645d4fe14a20)
- Who is affected
lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:190, :206, :219 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/fst/Util.java:185-186, :310, :409, :455; lucene/core/src/java/org/apache/lucene/util/fst/package-info.java:82-95 (at 645d4fe14a20)lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:190, :206, :219; analyzing/AnalyzingSuggester.java:670, :731-735; fst/WFSTCompletionLookup.java:156; document/TopSuggestDocsCollector.java:77-78 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/util/fst/TestFSTs.java:1341-1347, :1442-1448, :1517, :1641 (at 645d4fe14a20)
- Suggested fix
lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:731-735; fst/WFSTCompletionLookup.java:189; document/TopSuggestDocsCollector.java:77-78 (at 645d4fe14a20)
- TestShortestPathsTopNZero.java (JUnit, one test):
repro/LFV-070/src/org/apache/lucene/util/fst/TestShortestPathsTopNZero.java(41 lines)
Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.
assets/LFV-070/bug.png(371 KB)
![Summary card of the bug. Title: Util.shortestPaths(..., topN = 0, ...) throws NoSuchElementException instead of returning no paths; since 4.1.0 (2013), and main. Four cards in a chain, from the diagram's example FST (keys cat = 5, cow = 2, dog = 7, so the start node has arcs c and d): 1, ask for 0 paths with shortestPaths(fst, start, 0L, cmp, 0, false) or FreeTextSuggester.lookup(key, 0); the javadoc sets no lower bound (Util.java:455). 2, no queue slot: new TopNSearcher<>(fst, topN, topN, cmp) (:467) makes maxQueueDepth = 0, a queue that holds nothing. 3, empty counts as full: addIfCompetitive checks queue.size() == maxQueueDepth (:215), 0 == 0 is true for the first start arc (arc c, cost 2), so it takes the full-queue branch. 4, no worst path: queue.last() (:216) on an empty TreeSet throws NoSuchElementException before search() runs; search() would return [] for topN = 0 (:310, :409). Expected: an empty result, top.topN.size() = 0, isComplete = true. Actual: java.util.NoSuchElementException at TreeSet.last from Util$TopNSearcher.addIfCompetitive(Util.java:216). Also reached by FreeTextSuggester.lookup(key, 0) with assertions on and off, and with assertions off by WFST, Analyzing and Fuzzy suggester lookups. Every release tested from 4.1.0 to 10.6.0, and main; 4.0.0 returned []. A fix: return early in addIfCompetitive when maxQueueDepth == 0.](../assets/LFV-070/bug.png)