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.
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.- 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:ハシgiveshasiandhashi. - Eight single kana have two spellings (
ンisnornn,しissiorshi) andヂhas three, and so do many two- and three-kana keystrokes such asシャ(syaorsha). 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. - You expect a 32-character string to be analyzed. Instead 32 x
ン(INDEX mode, the default) or 32 xし(QUERY mode) throwOutOfMemoryErrorwith a 2 GB heap: the filter tries to build all 2^32 spellings in one list before it returns its first token. - 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.
- No downstream Lucene limit prevents it:
LimitTokenCountFilterafter the filter,QueryBuilder's clause limit andAnalyzingSuggester's determinize limit act only after the whole list is built (the last two do reject some smaller expansions). AnIndexWriterthat analyzes such a field closes and drops its uncommitted documents.
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.
- 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:
JapaneseCompletionFilterkeeps 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.txtgives 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).
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).- In QUERY mode,
CompletionTokenGenerator.addTokenappends 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. generateOutputs(:217-233) queues the surface token, callsKatakanaRomanizer.getInstance().romanize(reading)(:228) and queues one token per returned spelling (:229-232).romanize(KatakanaRomanizer.java:103-169) matches keystrokes greedily (:112). For a keystroke with several spellings it buildscandidates x pendingOutputsnew strings (:138-156; the copy at:148-149), "To produce all possible keystroke patterns" (:143-144). No limit, no de-duplication, oneList(:168). Socount(x^n) = c(x)^n: 2^n, or 3^n forヂ. Forン,n+nnandnn+nare the same string:ンンgivesnn, nnn, nnn, nnnn.- 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. TheOutOfMemoryErroris thrown in the product loop, atKatakanaRomanizer.romanize:148or:149, depending on the run. - 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
JapaneseCompletionAnalyzerorJapaneseCompletionFilter(opt-in; no default chain uses it; factory namejapaneseCompletion) 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 askuromoji_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:+ExitOnOutOfMemoryErrorterminates (the control without the flag survives). - An
IndexWritertreats theErroras tragic and closes (IndexWriter.java:1625-1641): uncommitted documents are lost; the last commit stays intact (CheckIndex clean, a new writer works).
- In 4 of 8 runs a concurrent thread doing ordinary searches also hit an
- No downstream Lucene bound prevents it.
LimitTokenCountFilter(10)after the filter: stillOutOfMemoryError.QueryBuilder(defaultmaxClauseCount1024):TooManyClausesfrom 10 xし(1,025 terms) and for the 32-char sentence,OutOfMemoryErrorat 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 throwsTooComplexToDeterminizeException; 20 xしthrowsOutOfMemoryErrorinTokenStreamToAutomatonwith a 1 GB heap, and with a 2 GB heap it ran for 10 minutes, until the probe's timeout, inAnalyzingSuggester.replaceSep(Operations.topoSortStates) without a result; 32 xしthrowsOutOfMemoryErrorwith 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.
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).
/*
* 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());
}
}
# 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>).
More files, environment, full output38 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: 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)- 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
BeiderMorseFilterblow-up (LUCENE-3720) was fixed as a bug.
The full argument300 words
- What the code promises.
romanizejavadoc: "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:4632LUCENE-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.
TestKatakanaRomanizerandTestJapaneseCompletionFilter/Analyzeruse 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 andAnalyzingSuggester'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
OutOfMemoryErrorinBeiderMorseFilter, 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.
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
QueryBuilderquery 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
HashSetper branching keystroke; not benchmarked. SECURITY.md (line 46) asks that robustness changes not slow hot paths. - A silent change to public API:
KatakanaRomanizer,JapaneseCompletionFilter,JapaneseCompletionAnalyzerand the factory are public and not marked@lucene.experimentalor@lucene.internal(unlikeAnalyzingSuggesterandTokenStreamToAutomaton), and thecompletionpackage 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, seedDEADBEEF, JDK 25 (tree removed afterwards): the diff applies cleanly; the minimal test,TestKatakanaRomanizer(with the 2 new tests),TestJapaneseCompletionFilter,TestJapaneseCompletionAnalyzerandTestJapaneseCompletionFilterFactory: 17 tests, 0 failures, with-Ptests.asserts=trueand=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,forbiddenApisTestandrenderJavadocpass. - 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
TestKatakanaRomanizer4 of 4 on main (on and off). - Effect probe, stock vs fixed:
QueryBuilderon 32 xしand on the 32-char sentence gives aSynonymQueryof 257 terms (stock:OutOfMemoryError,TooManyClauses); anAnalyzingSuggesterbuilt with the 22-char onomatopoeia finds it for prefixes of 6, 10, 16, 19 and 22 characters (stock: the 22-char lookup throwsTooComplexToDeterminizeException);しん,しんぶ,とうきょう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 includingsix 32 andshi+six 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.
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).
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.
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.
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.
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: 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.
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
KatakanaRomanizeragainst 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
romanizewhile writing the reference model; the harness's duplicate tally (549 inputs, for exampleンン -> [nn, nnn, nnn, nnnn]) and an exact count model built fromromaji_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
OutOfMemoryErrorat 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr | 2026-10-26 | the 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_completion | no 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-26 | 20 tool queries, 27 direct list-archive queries, 12 JQL queries | notification mirrors of the items above; LUCENE-10102, LUCENE-10363 |
| open PRs; the web | 2026-10-26 | the changed files of all 411 open apache/lucene PRs; 13 web searches in English and Japanese | no 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 files | 2026-10-28 | main head 20ced71c1e6e | no change since #13328 / #16416 / #297 / #12047; not fixed |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | minimal repro 2 of 2 and 5-test repro 5 of 5 fail, assertions on and off, also through gradle |
main ea57360912ed | affected | 5 of 5 fail, on and off; every independent check ran here |
| 10.5.2, 9.12.3 | affected | minimal 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) | affected | 5 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) | origin | 5 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 absent | no completion classes; the repro does not compile |
fix, scratch worktree at a9779ef3c193 | fix | both repros pass; kuromoji module with both repros 204 tests, 0 failures (revision 2) |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Translation 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 | real | Own 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) | real | Built 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 | keep | Re-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 reproduces | 5 of 5 fail; none of the 7 new commits touches the files. |
| this doc | reproduced, fix validated | Minimal 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 fixed | Re-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 findings | 9 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 fixed | Every value, line number and list state on screen matched. Fixed and re-rendered; bug.png was already correct. |
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 501 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,371 words
- 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) andea57360912ed(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.
- 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-59lucene/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-1641lucene/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-144lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java:36-59lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionAnalyzer.java:57-59lucene/CHANGES.txt:596-597, 4632lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/completion/TestKatakanaRomanizer.java:24-56SECURITY.md:38, 46
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:392-398SECURITY.md:46lucene/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
- TestJapaneseCompletionDuplicateRomaji.java (JUnit test):
repro/LFV-346/src/org/apache/lucene/analysis/ja/TestJapaneseCompletionDuplicateRomaji.java(43 lines)
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.
assets/LFV-346/bug.png(501 KB)
