All findings · Lucene finding · LFV-346 · japanese-completion-romaji-exponential-expansion

JapaneseCompletionFilter builds every romaji spelling of a reading with no bound: 32 kana throw OutOfMemoryError

JapaneseCompletionAnalyzer adds a token for every way to type a reading, and each ン (n or nn) or し (si or shi) doubles the count: 32 x ン (indexing) or 32 x し (QUERY mode) throw OutOfMemoryError. No downstream Lucene limit prevents it; all releases since 9.0.0 are affected.

Summary

Priority
Medium, rank 259In the opt-in kuromoji completion filter, a long all-kana reading (24 to 32 or more ambiguous kana), which can come from query text, causes an OutOfMemoryError that can take down the whole process, but nobody has reported it and the input is rare, so it is the new bottom of Medium.
Impact
crash
Since
9.0.0 (2021), when the filter was addedintroduced 2021-09-17 · about 5 years
Affected
every release from 9.0.0 to 10.6.0 (10.6.0 by reading), and main
Status
documented
Found by
bounded-exhaustive translation validation of the romanizer

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The filter writes out every way to type a reading on a romaji keyboard. Each kana with two spellings doubles the number of ways, and the whole list is built in memory before the filter returns anything.

Summary of the bug: JapaneseCompletionFilter builds every romaji spelling of a reading at once, so the tokens at one position grow as 2^n + 1 with the number n of ン in the input until 32 kana exhaust the heap.
How the list of spellings doubles with each ambiguous kana inside romanize, and how the token count at one position grows until the heap runs out. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You build Japanese auto-complete with JapaneseCompletionAnalyzer. For each reading it adds every way to type it on a romaji keyboard, at the same position: ハシ gives hasi and hashi.
  2. Eight single kana have two spellings (ン is n or nn, し is si or shi) and ヂ has three, and so do many two- and three-kana keystrokes such as シャ (sya or sha). Each of them doubles (or triples) the list: 8 x ン gives 257 tokens (256 spellings plus the surface), 16 give 65,537, 20 give 1,048,577. For ン almost all are duplicates: 20 x ン has only 21 different spellings.
  3. You expect a 32-character string to be analyzed. Instead 32 x ン (INDEX mode, the default) or 32 x し (QUERY mode) throw OutOfMemoryError with a 2 GB heap: the filter tries to build all 2^32 spellings in one list before it returns its first token.
  4. Ordinary text gets there too: in QUERY mode a 32-character hiragana sentence gives 16,385 tokens at one position, and the same sentence twice (64 characters) runs out of heap in both modes.
  5. No downstream Lucene limit prevents it: LimitTokenCountFilter after the filter, QueryBuilder's clause limit and AnalyzingSuggester's determinize limit act only after the whole list is built (the last two do reject some smaller expansions). An IndexWriter that analyzes such a field closes and drops its uncommitted documents.

Who is affected

Applications that use the opt-in kuromoji completion analyzer or filter on text they do not fully control: auto-complete queries (QUERY mode joins kana into one reading) or indexed suggestions. It takes 24-32 ambiguous keystrokes and default settings. The OutOfMemoryError hits the whole JVM: other threads can fail, and an IndexWriter closes.

Mechanism, terms and numbers675 words

JapaneseCompletionFilter (kuromoji, used by JapaneseCompletionAnalyzer for Japanese auto-completion) adds the romanized spellings of each reading as extra tokens at the same position. Nine single kana have more than one spelling in romaji_map.txt: シ (si/shi), ジ (zi/ji), チ (ti/chi), ツ (tu/tsu), ヅ (du/zu), フ (hu/fu), ヲ (wo/o), ン (n/nn), and ヂ (di/zi/ji); so do 114 two- and three-kana keystrokes, such as シャ (sya/sha), チャ (tya/cha/cya) and ッファ (4 spellings). KatakanaRomanizer.romanize builds the full product of those choices, eagerly, with no limit and no de-duplication, and the filter queues every result before it returns the first token.

A reading with n such keystrokes therefore gets 2^n (or 3^n, or more) spellings, and the filter emits one token per spelling plus the surface token: 16 x ン gives 65,537 tokens at one position, 20 x ン gives 1,048,577, and 32 x ン (INDEX mode, the default) or 32 x し (QUERY mode) throw OutOfMemoryError with a 2 GB heap. For ン most are duplicates: 2^n spellings, n+1 distinct strings. Because the whole product is built inside one incrementToken() call, LimitTokenCountFilter after the filter cannot help; QueryBuilder's clause limit and AnalyzingSuggester's determinize limit act only after analysis, so they reject some smaller expansions but cannot prevent the OutOfMemoryError at 32 characters; and an IndexWriter that analyzes such a field closes on the Error and drops its uncommitted documents. 64 characters of ordinary hiragana fail in both modes. Present since the filter was added in 9.0.0; every release from 9.0.0 to 10.6.0 (10.6.0 by reading) and main are affected.

Terms

  • Reading: the katakana pronunciation of a token (from the dictionary, or the kana surface itself). In QUERY mode, consecutive all-kana tokens are joined into one reading.
  • Romanized tokens: JapaneseCompletionFilter keeps the surface token and adds one token per romaji spelling of the reading, all at the same position (position increment 0), so a prefix typed in romaji can match.
  • Ambiguous kana: romaji_map.txt gives two spellings for シ si/shi (:29), ジ zi/ji (:38), チ ti/chi (:47), ツ tu/tsu (:48), ヅ du/zu (:56), フ hu/fu (:72), ヲ wo/o (:118), ン n/nn (:119), and three for ヂ di/zi/ji (:55). 114 two- and three-kana keystrokes also have 2-4 spellings, for example シャ sya/sha (:33), チャ tya/cha/cya (:51) and ッファ (4, :292).

The bug

  1. JapaneseCompletionAnalyzer() is INDEX mode (JapaneseCompletionAnalyzer.java:37-39): CJKWidthCharFilter -> JapaneseTokenizer(userDict, true, true, NORMAL) -> JapaneseCompletionFilter -> LowerCaseFilter (:47-59). A katakana run such as ン x 32 is one token. The tokenizer gives this unknown word no reading, so the filter uses the kana surface, converted to katakana, as the reading (JapaneseCompletionFilter.java:125-128).
  2. In QUERY mode, CompletionTokenGenerator.addToken appends every further all-kana token to the pending one, with no length limit (JapaneseCompletionFilter.java:194-202), so し x 32 or a whole hiragana sentence becomes one reading.
  3. generateOutputs (:217-233) queues the surface token, calls KatakanaRomanizer.getInstance().romanize(reading) (:228) and queues one token per returned spelling (:229-232).
  4. romanize (KatakanaRomanizer.java:103-169) matches keystrokes greedily (:112). For a keystroke with several spellings it builds candidates x pendingOutputs new strings (:138-156; the copy at :148-149), "To produce all possible keystroke patterns" (:143-144). No limit, no de-duplication, one List (:168). So count(x^n) = c(x)^n: 2^n, or 3^n for ヂ. For ン, n+nn and nn+n are the same string: ンン gives nn, nnn, nnn, nnnn.
  5. All of this happens in the first incrementToken() (JapaneseCompletionFilter.java:99-116 -> mayIncrementToken :118-141 -> finish :212-215 -> generateOutputs), before any token leaves the filter. The OutOfMemoryError is thrown in the product loop, at KatakanaRomanizer.romanize:148 or :149, depending on the run.
  6. At 32 two-spelling kana the list would need 2^32 entries, more than a Java array can hold, so a bigger heap only moves the threshold (at 2 GB, 23 x ン works, 24 fails), not the failure at 32.
Exposure and likelihood369 words
  • Who is hit. Applications that chose JapaneseCompletionAnalyzer or JapaneseCompletionFilter (opt-in; no default chain uses it; factory name japaneseCompletion) and analyze text they do not fully control: an auto-complete box (QUERY mode, which targets unconverted kana input) or suggestion entries and document fields (INDEX mode). Downstream servers expose the filter as kuromoji_completion.
  • Trigger size. 24-32 ambiguous keystrokes with the default constructors: a run of ン, し or ヂ, or about 64 characters of ordinary hiragana. Natural inputs are already heavy before that: 9,216 romanized tokens for the 22-char onomatopoeia ジャンジャンバリバリチャンチャンシャンシャン, 16,385 tokens for a 32-char hiragana sentence.
  • Effect: a JVM-wide OutOfMemoryError, not a per-request exception.
    • In 4 of 8 runs a concurrent thread doing ordinary searches also hit an OutOfMemoryError (in one run it killed that thread), and its worst request latency rose from 27-53 ms to 590-1,720 ms while the expansion ran.
    • A JVM started with -XX:+ExitOnOutOfMemoryError terminates (the control without the flag survives).
    • An IndexWriter treats the Error as tragic and closes (IndexWriter.java:1625-1641): uncommitted documents are lost; the last commit stays intact (CheckIndex clean, a new writer works).
  • No downstream Lucene bound prevents it. LimitTokenCountFilter(10) after the filter: still OutOfMemoryError. QueryBuilder (default maxClauseCount 1024): TooManyClauses from 10 x し (1,025 terms) and for the 32-char sentence, OutOfMemoryError at 32 x し, because the clause check runs after analysis. AnalyzingSuggester (index analyzer in INDEX mode, query analyzer in QUERY mode): lookup of 12 or 16 x し and of the 22-char onomatopoeia throws TooComplexToDeterminizeException; 20 x し throws OutOfMemoryError in TokenStreamToAutomaton with a 1 GB heap, and with a 2 GB heap it ran for 10 minutes, until the probe's timeout, in AnalyzingSuggester.replaceSep (Operations.topoSortStates) without a result; 32 x し throws OutOfMemoryError with 2 GB. The determinize limit acts only on the smaller inputs.
  • A small raw length cap is an effective application-level workaround, and it must be small: 12 x ヂ already gives 531,442 tokens, 20 x ン 1,048,577.
  • Releases before 10.6.0 also drain the queue quadratically (INDEX ン x 20: about 51 s on 10.5.2 and 9.12.3 vs 0.5-0.9 s on main); #16416 (10.6.0, released 2026-10-29) fixed only that drain, not the count.

Minimal repro

The test shows that JapaneseCompletionAnalyzer expands every combination of romaji spellings eagerly, so "ン" x 8 gives 2^8 = 256 romanized tokens at one position, nearly all duplicates (the same 2^n growth exhausts the heap at 24-32 kana).

TestJapaneseCompletionDuplicateRomaji.java (JUnit test)43 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.analysis.ja;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestJapaneseCompletionDuplicateRomaji extends LuceneTestCase {

  public void testRomanizedTokensAreDistinct() throws Exception {
    List<String> terms = new ArrayList<>();
    try (Analyzer a = new JapaneseCompletionAnalyzer();
        TokenStream ts = a.tokenStream("f", "ン".repeat(8))) {
      CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
      ts.reset();
      while (ts.incrementToken()) {
        terms.add(term.toString());
      }
      ts.end();
    }
    // expected: the surface form plus each spelling n{8..16} once (10); actual: 1 + 2^8 tokens
    assertEquals("tokens for ン x 8", new HashSet<>(terms).size(), terms.size());
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-346/, 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:$LUCENE/lucene/analysis/kuromoji/build/libs/lucene-analysis-kuromoji-$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.ja.TestJapaneseCompletionDuplicateRomaji
# the module(s) analysis-kuromoji also need their third-party runtime jars
Expected vs actual
Expected: each token once, 10 tokens (the surface form and n x 8 .. n x 16). Actual (-ea and -da): 257 tokens (expected:<10> but was:<257>).

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

More files, environment, full output38 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: tokens for ン x 8 expected:<10> but was:<257>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.analysis.ja.TestJapaneseCompletionDuplicateRomaji.testRomanizedTokensAreDistinct(TestJapaneseCompletionDuplicateRomaji.java:41)

Why it looks unintended

  • The javadoc promises "multiple outputs" per keystroke, not output that doubles per character; no doc, test or discussion mentions a length limit.
  • For ン most outputs are duplicates: 2^n tokens for n+1 distinct strings.
  • Lucene's own limits cannot prevent the out-of-memory, and a similar combinatorial BeiderMorseFilter blow-up (LUCENE-3720) was fixed as a bug.
The full argument300 words
  • What the code promises. romanize javadoc: "Translates a sequence of katakana to romaji. An input can produce multiple outputs because a keystroke can be mapped to multiple romajis." (KatakanaRomanizer.java:99-102). The comment at :143-144: "To produce all possible keystroke patterns, result outputs should be \"shin\", \"shinn\", \"sin\" and \"sinn\"." Several spellings are the design; nothing says the count may grow exponentially with input length.
  • The documented limitations say nothing about length. The class javadoc (JapaneseCompletionFilter.java:36-58) lists circumflex/macron and missing width normalization; the analyzer normalizes width itself (JapaneseCompletionAnalyzer.java:57-59). No javadoc, CHANGES entry (CHANGES.txt:4632 LUCENE-10102, :596-597 #16416) or MIGRATE text mentions an input-length precondition or an output limit.
  • Duplicates are not keystroke patterns. ン x 8 gives 256 romanized tokens, 9 distinct; ン x 16 gives 65,536, 17 distinct.
  • Tests. TestKatakanaRomanizer and TestJapaneseCompletionFilter/Analyzer use readings with at most one ambiguous keystroke, so at most 2 spellings (ハシ -> hasi, hashi); their random-string tests use random Unicode, which practically never has long ambiguous runs, and @IgnoreRandomChains (LUCENE-10363, offsets, :59) keeps the filter out of the random-chains test.
  • History. Feature issue #11140 (LUCENE-10102): "新橋 can be translated into eight romanized forms (in theory)". PR #297 (5 comments, 7 reviews, 8 inline comments) has nothing on size, memory or limits.
  • No downstream bound prevents it. LimitTokenCountFilter, QueryBuilder's clause limit and AnalyzingSuggester's determinize limit all act after the product is built; the last two reject some smaller expansions but cannot prevent the out-of-memory at 32 characters.
  • Precedent. LUCENE-3720 (#4794): a combinatorial OutOfMemoryError in BeiderMorseFilter, called "likely a real bug" and fixed under Bug Fixes.
  • Framing for the issue. A robustness bug (eager, unbounded, duplicate-producing expansion), not wrong output: for シ/し and ヂ every spelling is a valid keystroke pattern.
The devil's-advocate check189 words

The strongest case for "works as designed": the romanizer enumerates every keystroke pattern on purpose, and for シ/し and ヂ every one of the 2^n or 3^n outputs is distinct and valid, so any bound changes documented semantics. SECURITY.md (line 38) makes bounding untrusted analysis input the application's job. The intended consumer, AnalyzingSuggester, already rejects 12 x し, which suggests real inputs are short. Duplicate stacked tokens are legal in the TokenStream API.

Why it is still a defect: nothing documents or accepts memory exponential in a 20-32 character input; the filter has no parameter to bound it; Lucene's own limits cannot prevent the out-of-memory because the whole product exists before the first token; the ン duplicates are not patterns; 64 characters of ordinary hiragana fail; and SECURITY.md itself (line 46) calls out-of-scope issues like this "valid bug reports and hardening suggestions". The design argument does carry weight against a silent hard-coded cap: for シ/し and ヂ any bound drops valid spellings, so how to bound it (a cap, a configurable cap, failing fast, lazy generation plus a limit) is the maintainers' choice.

Suggested fix

Recommended De-duplicate and cap the spellings in romanize (all-or-nothing per keystroke, checked at every keystroke)

In KatakanaRomanizer.romanize, drop duplicate spellings and let a keystroke branch only while pending spellings x its spellings stay at most 256; otherwise it adds just its first spelling, checked again at every keystroke (+22/-5 lines). Validated with gradle at a9779ef3c193: both repros and the kuromoji module pass (204 tests). A configurable cap or failing fast are equally reasonable.

All options and trade-offs7 options · 1,526 words

Which bound to choose is the maintainers' call: option A (a fixed cap) was built and validated in clean scratch trees; a configurable cap (D) or failing fast (E) are equally reasonable, and the design argument weighs against a silent, hard-coded cap. The finder's earlier sketch was validated at ea57360912ed; the other options are untested. Option A does not fix two separate registry bugs in the same code: LFV-341 (romanize ignores the offset of the input CharsRef, KatakanaRomanizer.java:165 and :173) and LFV-335 (the factory upper-cases the default mode). Workaround for users today: cap the raw length of text passed to the analyzer, small enough for the worst case (3^n for ヂ: 12 characters already give 531,442 tokens), or avoid QUERY mode on long all-kana input.

De-duplicate and cap the spellings in romanize (all-or-nothing per keystroke, checked at every keystroke) (validated by the checks) (recommended)

KatakanaRomanizer.java only (+22/-5 lines; the diff also adds 44 lines of tests). Each spelling is produced once (32 x ン: 33 outputs instead of 2^32). The cap is checked again at every keystroke: a keystroke with several spellings branches only if pendingOutputs.size() * candidates.size() is at most MAX_OUTPUTS (256); otherwise it contributes only its first spelling in romaji_map.txt (for example si for シ, n for ン, di for ヂ, sya for シャ). A capped keystroke does not grow the list, so a later keystroke with fewer spellings can still branch: シ x 7 gives 128 outputs, シ x 7 + ヂ also 128 (128 x 3 = 384), シ x 7 + ヂ + シ 256. Each decision depends only on the keystrokes before it, so for a prefix that ends on a keystroke boundary of the whole reading, the prefix's outputs stay prefixes of the whole reading's outputs, as on stock. Without that boundary the property fails on stock too, because of the greedy longest match: romanize(シ) gives si, shi, but romanize(シャ) gives sya, sha. Both modes call the same romanize, but they usually split text into different readings (32 x し: 30 positions in INDEX mode, one reading in QUERY mode). The diff adds two tests to TestKatakanaRomanizer.

/**
 * Maximum number of outputs of {@link #romanize}. ...
 */
static final int MAX_OUTPUTS = 256;
...
} else if (candidates.size() == 1
    || pendingOutputs.size() * candidates.size() > MAX_OUTPUTS) {
  // There are one or more pending output(s) and one matched keystroke, or combining all
  // matched keystrokes would exceed MAX_OUTPUTS.
  // Append the (first) matched keystroke to all pending outputs.
  ...
} else {
  ...
  List<CharsRefBuilder> outputs = new ArrayList<>();
  // different keystroke patterns can give the same output, e.g. "n" + "nn" and "nn" + "n"
  Set<CharsRef> seen = new HashSet<>();
  for (CharsRef cref : candidates) {
    for (CharsRefBuilder pdgOutput : pendingOutputs) {
      CharsRefBuilder buffer = new CharsRefBuilder();
      buffer.copyChars(pdgOutput.chars(), 0, pdgOutput.length());
      buffer.append(cref.chars, cref.offset, cref.length);
      if (seen.add(buffer.toCharsRef())) {
        outputs.add(buffer);
      }
    }
  }
  // update the pending outputs
  pendingOutputs = outputs;
}

Trade-offs

  • Long readings lose spellings past the cap: in QUERY mode a 24-char sentence goes from 1,536 to 192, the 22-char onomatopoeia from 9,216 to 192 and the 32-char sentence from 16,384 to 256. A user who types a non-first spelling (for example the Hepburn shi) as trailing, not yet converted romaji after the cap no longer matches there (reasoned from the code, not measured).
  • Short readings are unchanged: a reading keeps all its spellings when the product of its keystrokes' spelling counts is at most 256 (the 11- and 24-char test sentences keep 64 and 128 in QUERY mode). Thanks to the de-duplication, runs of up to 128 ン also keep all n+1; ン x 129 gives 129, not 130, because the bound counts pending outputs times spellings before de-duplicating.
  • 256 is a judgement call: it keeps the default QueryBuilder query at 257 terms, under the 1024-clause limit (stock fails from 1,024 spellings on). Upstream may prefer another number or a constructor parameter (option D).
  • Cost: one HashSet per branching keystroke; not benchmarked. SECURITY.md (line 46) asks that robustness changes not slow hot paths.
  • A silent change to public API: KatakanaRomanizer, JapaneseCompletionFilter, JapaneseCompletionAnalyzer and the factory are public and not marked @lucene.experimental or @lucene.internal (unlike AnalyzingSuggester and TokenStreamToAutomaton), and the completion package is exported. A fixed cap changes their output for long readings without notice; this weighs for option D or E, and in any case for a CHANGES entry and a javadoc note.
  • Test coverage: the new tests cover repeated シ and ン, ヂヂヂ and one mixed case (シ x 7 + ヂ (+ シ)); none covers a prefix that ends inside a keystroke such as シャ.

What the checks validated

  • Revision 2 of the diff (2026-10-28, after review: the javadoc corrected and 2 assertions added; the code is the same as in revision 1). Clean scratch tree at a9779ef3c193, gradle, seed DEADBEEF, JDK 25 (tree removed afterwards): the diff applies cleanly; the minimal test, TestKatakanaRomanizer (with the 2 new tests), TestJapaneseCompletionFilter, TestJapaneseCompletionAnalyzer and TestJapaneseCompletionFilterFactory: 17 tests, 0 failures, with -Ptests.asserts=true and =false; the whole kuromoji module with the minimal test and the 5-test repro in place: 204 tests, 0 failures, 7 skipped; checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest and renderJavadoc pass.
  • Revision 1 (same code), the same way: stock fails the 5-test repro 5 of 5 and the minimal test 2 of 2; with the fix the 5-test repro and the 4 closest suites, 20 tests, 0 failures, assertions on and off; the module with the 5-test repro, 202 tests, 0 failures, 7 skipped; the minimal test 2 of 2; the same lint tasks (without renderJavadoc) pass.
  • The fixed class on the stock jars (2 GB heap): the 5-test repro 5 of 5 pass with assertions on and off on main; the minimal test 2 of 2 on main (on and off) and on the 10.5.2 and 9.12.3 jars (off); the fixed TestKatakanaRomanizer 4 of 4 on main (on and off).
  • Effect probe, stock vs fixed: QueryBuilder on 32 x し and on the 32-char sentence gives a SynonymQuery of 257 terms (stock: OutOfMemoryError, TooManyClauses); an AnalyzingSuggester built with the 22-char onomatopoeia finds it for prefixes of 6, 10, 16, 19 and 22 characters (stock: the 22-char lookup throws TooComplexToDeterminizeException); しん, しんぶ, とうきょう return the same suggestions as stock; the 64-char hiragana input no longer runs out of memory: 256 spellings at one position in QUERY mode, and 274 tokens at 5 positions, at most 256 spellings at one position, in INDEX mode.
  • New tests: ンン gives exactly nn, nnn, nnnn; 32 x ン gives 33 distinct outputs; 32 x シ gives at most 256 distinct outputs including si x 32 and shi + si x 31; ヂヂヂ still gives all 27; シ x 7 + ヂ gives 128 and シ x 7 + ヂ + シ gives 256 (the cap is checked at every keystroke); every output of シ x 1, 7, 8, 9, 12 and 31 is a prefix of an output of シ x 32.

De-duplicate only

Use a set in the product loop and nothing else.

Trade-offs

Fixes the ン waste (2^n -> n+1) but not シ/し/ヂ, where every spelling is distinct: 32 x し would still need 2^32 outputs (count model). Not enough alone. Untested.

De-duplicate and stop adding outputs mid-loop at 256 (the finder's sketch) (validated by the checks)

Breaks out of the product loop once 256 outputs exist, except for the first candidate.

Trade-offs

Branching at the boundary keystroke is partial and favours the first candidates; the all-or-nothing rule per keystroke of option A is easier to reason about.

What the checks validated

At ea57360912ed with the fixed class on the stock jars: the 5-test repro and the 11 upstream tests then present pass (16 run, 0 failed).

Make the limit configurable

Option A with the cap as a parameter of JapaneseCompletionFilter, JapaneseCompletionAnalyzer and the factory (for example maxRomanizations, default around 256).

Trade-offs

An application that needs every spelling of long readings can raise it, and the limit is visible in the API; adds public API. Untested.

Throw a clear exception above a limit

For example IllegalArgumentException when the count would exceed a limit.

Trade-offs

Exact semantics for accepted inputs, and nothing is dropped silently, but long inputs fail outright. At index time an exception from analysis rejects only that document and keeps the writer open (DocumentsWriterPerThread.java:392-398). Untested.

Generate the spellings lazily (not recommended alone)

Enumerate the product one token at a time instead of building a list.

Trade-offs

Makes LimitTokenCountFilter effective and memory O(n), but without a limit the chain still emits 2^32 tokens (CPU instead of memory), and de-duplication gets harder. Combined with option D or E it is a sound design.

Document a length limit only (not recommended)

A javadoc warning plus "limit the input length", the interim step taken for LUCENE-3720.

Trade-offs

Leaves the trap, and the safe limit is small (12 x ヂ already gives 531,442 tokens).

Downstream note

Downstream: servers that expose this filter as kuromoji_completion pass user text to it; whether their input-length settings stop a 32-character kana string was not checked.

How it was found

Bounded-exhaustive translation validation compared the real KatakanaRomanizer with an independent reference model on all 2,682,144 katakana strings of up to 3 characters. The outputs matched, but 549 inputs had duplicate spellings (ンン -> nn, nnn, nnn, nnnn); an exact count model then showed the 2^n growth, and a JUnit test showed the OutOfMemoryError.

The method in full156 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier), a hunt unit on main ea57360912ed.

  • The harness ran the real KatakanaRomanizer against an independent reference romanizer and an exact output-count formula on every katakana string of length up to 3, each with the suffixes '', 'k' and 'sy' (2,682,144 cases), on main and 10.5.2: 0 output mismatches, 0 count mismatches.
  • The hypothesis came from reading romanize while writing the reference model; the harness's duplicate tally (549 inputs, for example ンン -> [nn, nnn, nnn, nnnn]) and an exact count model built from romaji_map.txt (count(x^n) = c(x)^n, tight at 3^n for ヂ and 2^n for the other 8 ambiguous kana) gave the growth.
  • A public-API JUnit test then witnessed the OutOfMemoryError at 32 characters. No model checker, solver or prover found it; the exhaustive check bounds the input at 3 characters, so the growth itself comes from the count model and the executed tests.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, 114 queries over Lucene's GitHub, JIRA, mailing lists, three downstream projects and the web; upstream commit lists re-checked 2026-10-28). Nearest: #16416, which made draining the output cheaper but left the count unbounded.

Searches4 searches

Verdict new: no report by anyone. #16416 (GitHub luizmlima, merged 2026-08-20) benchmarked シンシンシン inputs "to generate larger output batches" and its linked report notes inputs "that produce a larger number of romanized forms", but it treats the count as given and changed only the queue. Gaps: private reports to security@ cannot be searched; GitHub search is token-based; one web query was rate-limited; on 2026-10-28 GitHub search was rate-limited, so only the commit lists were re-checked.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr2026-10-26the hunt's prior-report tool (21 queries) and 19 manual GitHub searches: JapaneseCompletionFilter, KatakanaRomanizer, JapaneseCompletionAnalyzer, romanize, generateOutputs, romaji, kuromoji completion exponential, OutOfMemoryError, heap space, kuromoji_completionno report; context only: #11140 / #297 (the design), #16416 (drain), #11399 (offsets), #12044 (style), elastic/elasticsearch#81858 and opensearch-project/OpenSearch#12287 (expose the filter)
dev@, java-user@, issues@, commits@; JIRA (LUCENE, SOLR)2026-10-2620 tool queries, 27 direct list-archive queries, 12 JQL queriesnotification mirrors of the items above; LUCENE-10102, LUCENE-10363
open PRs; the web2026-10-26the changed files of all 411 open apache/lucene PRs; 13 web searches in English and Japaneseno open PR touches KatakanaRomanizer.java or romaji_map.txt; web pages describe the variants, none mentions size or memory
upstream commit lists of the four files2026-10-28main head 20ced71c1e6eno change since #13328 / #16416 / #297 / #12047; not fixed

History

Introduced with the filter itself in 4e86df96c038 (#297, LUCENE-10102, merged 2021-09-17); first release 9.0.0. All 33 releases from 9.0.0 to 10.5.2 and main fail the same way; 8.x has no such filter. Not fixed upstream.

Versions tested7 rows

Introduced by 4e86df96c038 "LUCENE-10102: Add JapaneseCompletionFilter for Input Method-aware auto-completion (#297)" (GitHub mocobeta, co-authored by rmuir; issue #11140), merged 2021-09-17. That commit created KatakanaRomanizer, JapaneseCompletionFilter, JapaneseCompletionAnalyzer and romaji_map.txt with the product loop, the eager buffering, the QUERY-mode joining and the ambiguous rows; 65 of the 67 lines of romanize are unchanged since (2 cosmetic, #12978). Not a regression.

#16416 (52bbea2e0bf, 2026-08-20, CHANGES 10.6.0, released 2026-10-29) replaced the output ArrayList with an ArrayDeque; it made draining cheap but does not change what is generated. Upstream main 20ced71c1e6e (2026-10-27) is unchanged.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedminimal repro 2 of 2 and 5-test repro 5 of 5 fail, assertions on and off, also through gradle
main ea57360912ed affected5 of 5 fail, on and off; every independent check ran here
10.5.2, 9.12.3 affectedminimal repro 2 of 2 and 5-test repro 5 of 5 fail; the minimal repro passes with the fixed class; draining is also quadratic there
9.0.0 to 10.5.2 (all 33 releases) affected5 of 5 fail, one by one, assertions off; on as well for 9.0.0, 9.12.3 and 10.5.2
4e86df96c038 (introducing commit) origin5 of 5 fail, on and off: the 9.0.0 jars (whose KatakanaRomanizer.java and romaji_map.txt equal the commit's) with the commit's JapaneseCompletionFilter.java compiled over them, not a build of the tree
8.11.4 and older API absentno completion classes; the repro does not compile
fix, scratch worktree at a9779ef3c193 fixboth repros pass; kuromoji module with both repros 204 tests, 0 failures (revision 2)

Independent checks

The finder, an independent reproducer, an adversarial reviewer from another model family (OpenAI GPT-5.6), a devil's-advocate checker and an adjudication all found it real; a credibility audit kept it. This doc re-ran it on main a9779ef3c193, added a minimal repro and validated a fix.

Checks and verdicts434 words

The checks ran on main ea57360912ed unless stated; the kuromoji files are identical on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTranslation validation and count model; the 5-test JUnit fails 5 of 5 on main (on and off), 10.5.2, 9.12.3 and 9.1.0; its fix sketch passes it and 11 upstream tests.
cross-model review (refute lens, before the candidate) real (high)OpenAI GPT-5.6, reading only: hand-traced ンン -> 4 outputs, 3 distinct; rated the intended-behaviour argument moderate; its points were answered or fixed.
independent reproducer realOwn JUnit fails 5 of 6 on main (on and off), 10.5.2 and 9.12.3; added the natural-text case (64 hiragana characters fail in QUERY mode) and the exact 2^n + 1 growth.
adversarial review (refute lens) real (high)OpenAI GPT-5.6, reading only: root cause agrees, inputs in contract; a separate run re-ran the repro (5 of 5 fail); growth check: ヂ x 12 gives 531,442 tokens, the control stays at 2.
devil's advocate (intended behaviour) realBuilt the "works as designed" case and showed why it fails; found the LUCENE-3720 precedent and the AnalyzingSuggester symptoms.
adjudication confirmed (high)Re-ran both JUnits; corrected the class from hang to crash.
credibility audit keepRe-ran the repro with assertions off (5 of 5 fail); checked the score and the siblings LFV-335 and LFV-341 (different root causes).
main refresh a9779ef3c193 still reproduces5 of 5 fail; none of the 7 new commits touches the files.
this doc reproduced, fix validatedMinimal repro 2 of 2 on main, 10.5.2 and 9.12.3; the 5-test repro on a9779ef3c193; fix validated with gradle (revision 2 after review); effect probe, stock vs fixed.
Fact check of this doc (re-ran everything) bug holds; 3 errors fixedRe-ran the minimal and 5-test repros on main (on and off), 10.5.2 and 9.12.3, the brief's gradle command (asserts on and off) and the fix (17 tests pass). Found 3 errors (the LFV-341 description, the cap rule of fix A, the heap of the 20 x し suggester case) and 7 minor points; all handled.
Cross-model fact check (OpenAI GPT-5.6, reading only) core bug real; 17 accuracy findings9 wording errors (2 high: fix A's cap rule and prefix property), 4 overclaims and 4 missing caveats; rated the intended-behaviour case strong against a silent cap. All handled: the fix's javadoc corrected, a test added, the alternatives given equal weight.
Diagram check 2 wording errors fixedEvery value, line number and list state on screen matched. Fixed and re-rendered; bug.png was already correct.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,371 words

Finding record

LFV-346, snapshot of 2026-11-06

Registry title
JapaneseCompletionFilter expands a reading into every combination of romaji spellings with no bound, so 24-32 kana (for example 32 x "ン", or 32 x "し" in QUERY mode) throw OutOfMemoryError inside one incrementToken() call
Area
analysis / kuromoji (JapaneseCompletionFilter and JapaneseCompletionAnalyzer romanization, KatakanaRomanizer)
Priority
Medium, rank 259: In the opt-in kuromoji completion filter, a long all-kana reading (24 to 32 or more ambiguous kana), which can come from query text, causes an OutOfMemoryError that can take down the whole process, but nobody has reported it and the input is rare, so it is the new bottom of Medium.
Impact class
crash
Impact note
Applications that use the kuromoji JapaneseCompletionAnalyzer or JapaneseCompletionFilter (an opt-in module analyzer for Japanese auto-completion; default constructors, INDEX or QUERY mode) are hit when one reading has roughly 24 or more ambiguous kana: 24-32 characters exhaust a 2 GB heap inside one incrementToken() call (at 32 two-way kana the product has 2^32 entries, more than a Java list can hold, so a bigger heap only moves the threshold), an IndexWriter that analyzes such a field closes through its documented tragic-event handling and drops its uncommitted documents, and in QUERY mode 64 characters of ordinary hiragana are enough; no scale is needed, and every release from 9.0.0 to 10.6.0 (10.6.0 by reading) and main are affected.
Summary
You analyze a short Japanese string with JapaneseCompletionAnalyzer or JapaneseCompletionFilter, for example 32 x "ン" when indexing or 32 x "し" as an auto-complete query, and expect the surface token plus a bounded set of romanized spellings. Instead KatakanaRomanizer.romanize builds every combination of the alternative spellings of each ambiguous kana (2^n for シ ジ チ ツ ヅ フ ヲ ン, 3^n for ヂ; for ン most are duplicates: 2^n tokens but only n+1 distinct strings), eagerly and with no limit, and the filter buffers all of them before it returns the first token. So 20 x "ン" already gives 1,048,577 tokens at one position, 24-32 kana throw OutOfMemoryError, a LimitTokenCountFilter placed after the filter cannot help, and an IndexWriter that analyzes such a field closes.
Introduced
4e86df96c0385a7b552434d7e2a9bee69f18bd77 (apache/lucene#297, LUCENE-10102 / GitHub issue #11140 'Add JapaneseCompletionFilter for Input Method-aware auto-completion', by GitHub mocobeta, co-authored by rmuir) [read: the commit created KatakanaRomanizer.java, JapaneseCompletionFilter.java, JapaneseCompletionAnalyzer.java and romaji_map.txt, and every part of the defect arrives in it: the unbounded candidate product in romanize (else-branch lines 139-157 at the commit), the eager buffering in generateOutputs (lines 221-237), the QUERY-mode joining of consecutive kana tokens (lines 198-206) and the ambiguous romaji rows; git log -S'pendingOutputs = outputs' finds only this commit; git blame on main: 65 of the 67 lines of romanize still come from it, the other 2 are cosmetic (78b4f75a2c6, #12978)] [exec: the commit's code (9.0.0 jars plus the commit's JapaneseCompletionFilter.java via --shadow-src; KatakanaRomanizer.java and romaji_map.txt are identical to 9.0.0) fails 5 of 5 with -ea and -da]. Present since the feature was written, so not a regression; no later commit changes what romanize generates (#16416 changed only the output buffer) (2021-09-17 (merged 2021-09-17T13:37:13Z))
First affected version
9.0.0 [exec] (the first release with JapaneseCompletionFilter; CHANGES.txt lists LUCENE-10102 under 9.0.0 New Features; no 8.x release has the classes: 8.11.4 executed, the repro does not compile; the 8.10.0-8.11.3 kuromoji jars, SHA-1 checked, contain no completion classes)
Affected versions
every release that has the filter, 9.0.0 to 10.6.0 (10.6.0 by reading): all 33 from 9.0.0 to 10.5.2 [exec, -da, one by one: 9.0.0, 9.1.0, 9.2.0, 9.3.0, 9.4.0-9.4.2, 9.5.0, 9.6.0, 9.7.0, 9.8.0, 9.9.0-9.9.2, 9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2, each failing the same 5 of 5 tests; the complete version list of lucene-analysis-kuromoji on Maven Central before 10.6.0], also -ea on 9.0.0, 9.12.3 and 10.5.2, the introducing commit 4e86df96 (-ea and -da) and main ea57360912ed (-ea and -da) [exec]; the independent reproduction's independent JUnit also fails on 10.5.2 and 9.12.3 [exec]; not applicable to 8.x and older, where the API does not exist [exec: 8.11.4; read: never backported]; still unfixed on upstream main e1c4184cb98 (read 2026-10-26). On 10.5.2 and 9.12.3 draining the buffer is also quadratic; #16416 (CHANGES 10.6.0, released 2026-10-29) fixed only that, and the exponential count is identical on every version. All runs: OpenJDK 25.0.3, Linux aarch64, 2 GB heap (the heap moves only the threshold, not the failure at 32 kana)
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new, 114 queries: a scripted prior-report search over GitHub (16), commits (3), downstream (2), the dev@, java-user@, issues@ and commits@ lists (20) and JIRA (4); 10 manual GitHub and 9 downstream (Elasticsearch, OpenSearch, Solr) searches; 27 direct list-archive queries; 8 direct JIRA queries (LUCENE and SOLR); 13 web searches in English and Japanese (one rate-limited and not retried); the changed files of all 411 open apache/lucene PRs (none touches KatakanaRomanizer.java or romaji_map.txt; #13879 touches JapaneseCompletionFilter.java only as an artifact of its 9.10.x base); git log of the touched files on upstream main e1c4184cb98 (last change #16416). The finder's and the intended-behaviour check's earlier searches found nothing either. Context, not reports: #16416 (merged 2026-08-20, GitHub luizmlima: O(n) draining of the output buffer; it benchmarked シンシンシン inputs 'to generate larger output batches' and its linked report notes inputs 'that produce a larger number of romanized forms', but it treats the count as given and does not change romanize), #11140 / #297 (introduced the design, '新橋 can be translated into eight romanized forms (in theory)'; no review comment on size, memory or limits), #11399 / LUCENE-10363 (open, a different defect: offsets), #12044 (style cleanup), elastic/elasticsearch#81858 and opensearch-project/OpenSearch#12287 (expose the filter as kuromoji_completion; nothing on output size), and LUCENE-3720 / #4794 (precedent: a combinatorial OutOfMemoryError in BeiderMorseFilter, fixed as a bug). Not fixed on upstream main e1c4184cb98 (read 2026-10-26). Gaps: private reports to security@lucene.apache.org cannot be searched; GitHub search is token-based; searches cannot prove absence)
Lucene commit tested
main a9779ef3c193 (2026-10-28: the minimal repro and the 5-test repro, assertions on and off; the fix in clean scratch trees with gradle, revisions 1 and 2; the effect probe, stock and fixed) and ea57360912ed (2026-10-26: the hunt and the independent checks); all 33 releases from 9.0.0 to 10.5.2, and the introducing commit's kuromoji code (4e86df96c038, on the 9.0.0 jars), with the 5-test repro; 10.5.2 and 9.12.3 with the minimal repro, stock and with the fixed class. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java:99-169 (romanize javadoc 'An input can produce multiple outputs'; product loop 138-156; comment 'To produce all possible keystroke patterns' 143-144)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java:36-58 (class javadoc), 194-202 (QUERY-mode kana joining), 217-233 (generateOutputs buffers every romanization)
  • lucene/analysis/kuromoji/src/resources/org/apache/lucene/analysis/ja/completion/romaji_map.txt:29 (シ,si,shi), 55 (ヂ,di,zi,ji), 119 (ン,n,nn)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1632-1639 (updateDocuments: an Error is a tragic event, then maybeCloseOnTragicEvent)
  • SECURITY.md:38 (resource exhaustion from unbounded, unfiltered input is not a vulnerability), :46 (out-of-scope issues are valid public bug reports), :50 (in scope only if a documented guarantee for untrusted input is broken)
The finding in 30 seconds
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java:99-169 (romanize; :112 greedy match; :138-156 product; :143-144 comment; :148-149 OOM site; :168 result list)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java:99-141, 176-233 (incrementToken, mayIncrementToken, addToken QUERY joining :194-202, finish, generateOutputs)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionAnalyzer.java:37-59
  • lucene/analysis/kuromoji/src/resources/org/apache/lucene/analysis/ja/completion/romaji_map.txt:29, 33, 38, 47, 48, 51, 55, 56, 72, 118, 119, 292 (9 single kana and 114 multi-kana keystrokes with 2-4 spellings, parsed as KatakanaRomanizer.java:52-65 parses them)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1625-1641
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilterFactory.java:33-40
Why it looks unintended
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java:99-102, 143-144
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java:36-59
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionAnalyzer.java:57-59
  • lucene/CHANGES.txt:596-597, 4632
  • lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/completion/TestKatakanaRomanizer.java:24-56
  • SECURITY.md:38, 46
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:392-398
  • SECURITY.md:46
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java:112, 136, 165, 173; romaji_map.txt:29, 33; module-info.java:24
History
  • lucene/CHANGES.txt:596-597, 4613-4632

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

Repro files

About the diagrams

Made in parallel from the code at a9779ef3c193 and executed probes on that commit (the finder's JUnit, the growth and sentence probes); the PNG is the final summary frame.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.