ICUCollationKeyAnalyzer stores ICU collation keys, but normalize(), and with it every range bound the classic QueryParser builds, produces plain UTF-8 for it.
- Collation keys. A collator turns text into bytes whose byte order is the language's sort order.
ICUCollationKeyAnalyzerindexes each whole value as such a key, so aTermRangeQueryover keys is a range in that order. - normalize. A query parser cannot run a range bound through the full analyzer, so it calls
Analyzer.normalize(field, text), documented to return "the representation that it would have in the index". - The term's encoding comes from an
AttributeFactory.normalizeusesattributeFactory(fieldName), which an analyzer overrides when its terms are not UTF-8; the java.text twinCollationKeyAnalyzerdoes.
b is indexed as its ICU collation key, but the parser's bounds for [a TO c] stay plain UTF-8 bytes, so the range compares two different encodings and misses the document. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document whose field holds
b, analyzed withnew ICUCollationKeyAnalyzer(Collator.getInstance(ULocale.ROOT)). The analyzer stores the value's ICU collation key, the bytes[2d 1 5 1 5 0], so that ranges follow the language's sort order. - Parse
[a TO c]with the classicQueryParseron the same analyzer and count. You expect 1 hit:bsorts betweenaandc, and the java.text twinCollationKeyAnalyzerhandles such ranges correctly. - You get 0. The parser builds each bound with
Analyzer.normalize, which for this analyzer returns plain UTF-8,[61]and[63]. The indexed key[2d ...]sorts below[61], so the range cannot contain it. No error, assertions on or off. - Why:
normalizetakes its term encoding fromattributeFactory(fieldName).ICUCollationKeyAnalyzergives its collation factory only to its tokenizer and never overrides that method, sonormalizefalls back to UTF-8. - Not just empty results: over 16 words and 5 collation strengths, 731 of 1,280 parsed ranges were wrong, missing 3,814 documents and adding 750 (at primary strength,
[a TO b]returns Greekβ). A term querybstill matches.
Applications that index a field with ICUCollationKeyAnalyzer (analysis-icu, opt-in) and parse range queries on it with the classic QueryParser: [x TO y], {x TO y}, open-ended bounds. Default parser and index settings; one document is enough. Results are silently wrong in both directions, with assertions on or off. Term queries are unaffected.
Mechanism, terms and numbers461 words
ICUCollationKeyAnalyzer (analysis-icu) indexes each whole value as its ICU collation key, so that a range over the field follows the language's sort order. It does not override Analyzer.attributeFactory(String), the factory that Analyzer.normalize uses to build its term, so normalize returns the plain UTF-8 bytes instead of the key the index holds. The classic QueryParser builds both bounds of every range with normalize, so [a TO c] becomes a byte range from [61] to [63] over a field whose term for b is [2d 1 5 1 5 0]: 0 hits instead of 1. Other ranges miss documents inside the collated range and return documents outside it (731 of 1,280 parsed ranges wrong in a bounded differential). Default IndexWriterConfig and QueryParser, one document, no error. The java.text twin CollationKeyAnalyzer has the one-method override and is correct. Introduced by LUCENE-7355 (6.2.0; default path from 7.0.0), a regression of a scenario LUCENE-4176 had fixed in 2012; still in 10.6.0 and on main.
- Indexing.
ICUCollationKeyAnalyzer.createComponentsbuildsnew KeywordTokenizer(factory, KeywordTokenizer.DEFAULT_BUFFER_SIZE), one token for the whole value (ICUCollationKeyAnalyzer.java:64-69).factoryis anICUCollationAttributeFactory(:53,:60-62) whose term attribute writes the collator's raw key as the term bytes (ICUCollatedTermAttributeImpl.java:46-54). - Normalization.
Analyzer.normalize(public final,Analyzer.java:213) applies the normalization char filters, then builds a one-termStringTokenStreamwithattributeFactory(fieldName)(:233-236) and reads its bytes (:237). - The missing override. The default
attributeFactory(String)returnsTokenStream.DEFAULT_TOKEN_ATTRIBUTE_FACTORY(:293-295).ICUCollationKeyAnalyzerisfinaland has only a constructor andcreateComponents(ICUCollationKeyAnalyzer.java:52-70), sonormalizereturns UTF-8. - The parser.
QueryParserBase.newRangeQuerysetsstart = getAnalyzer().normalize(field, part1)andend = getAnalyzer().normalize(field, part2)(QueryParserBase.java:550,:556) and returns aTermRangeQueryover them (:559-560); an open end stays null. - Result.
TermRangeQuerycompares unsigned bytes, so UTF-8 bounds against ICU keys select an arbitrary slice of the key space.
| value | indexed term | normalize, stock | normalize, with the fix |
|---|---|---|---|
a | [2b 1 5 1 5 0] | [61] | [2b 1 5 1 5 0] |
b | [2d 1 5 1 5 0] | [62] | [2d 1 5 1 5 0] |
c | [2f 1 5 1 5 0] | [63] | [2f 1 5 1 5 0] |
Stock: the range [61]..[63] does not contain [2d 1 5 1 5 0]. With the fix the bounds are the keys of a and c, and b's key lies between them. A term query b on the same field and parser matches, because it goes through tokenStream, which produces the key.
CollationKeyAnalyzer (java.text) builds the same KeywordTokenizer with its own factory and also overrides attributeFactory(String) to return it (CollationKeyAnalyzer.java:70-73), as does the test framework's MockBytesAnalyzer (MockBytesAnalyzer.java:36-39).
Exposure and likelihood344 words
- Trigger. A field analyzed with
ICUCollationKeyAnalyzerand a range parsed by the classicQueryParser; every bound other than*goes throughnormalize. Direct callers ofAnalyzer.normalizeon this analyzer get UTF-8 too. Since 7.0.0 no parser setting avoids it (setAnalyzeRangeTermswas removed). - Effect. Silent wrong results, both directions: in a bounded differential (every ordered pair of 16 words, including supplementary and 3-byte letters, times 5 ICU strengths, against the collator's own order) 731 of 1,280 parsed ranges were wrong, missing 3,814 documents and adding 750, and all 80
normalizechecks differed from the indexed term. At primary strength[a TO b]missesa,ab,b,Bandäand returns GreekΒandβ, whose keys fall between the bytes ofaandb. With the fix: 0 and 0. - The module's advertised use case. The analysis-icu overview lists "Efficient range queries over fields that contain terms in languages that use non-Unicode character orderings" (
overview.html:97-100). - Not affected: the java.text
CollationKeyAnalyzer;TermRangeQuerybuilt by hand from collation-key bytes (what the ICU tests do);ICUCollationDocValuesField; a downstream server's ICU collation field type that builds its bounds throughtokenStream. - Not claimed: prefix, wildcard and fuzzy terms over opaque keys, the flexible
StandardQueryParserandSimpleQueryParser, to keep the report to one clear contract. This bug reaches them too: on stock, the flexible parser's[b TO d]returns 0 hits with the ICU analyzer and 3 with the java.text twin, and the fix makes it 3; the ICU prefix, fuzzy andSimpleQueryParserresults also differ from the twin on stock and match it with the fix. The flexible parser also has a limitation of its own that hits both collation analyzers: it turns each normalized bound back into a string withutf8ToString()(TermRangeQueryNodeProcessor.java:136-139), so key bytes that are not valid UTF-8 do not survive. - Likelihood: plausible. Deterministic for every parsed range on such a field, but a niche feature; many applications build key ranges by hand, and nobody has reported it since 7.0.0 (2017).
One JUnit test, public API only: one document b, ROOT collator, default IndexWriterConfig and QueryParser, the range [a TO c]. It needs one test-only dependency line on the query parser in the ICU module's build.gradle.
/*
* 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.icu;
import com.ibm.icu.text.Collator;
import com.ibm.icu.util.ULocale;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.queryparser.classic.QueryParser;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestICUCollationKeyAnalyzerRange extends LuceneTestCase {
public void testQueryParserRange() throws Exception {
try (Analyzer analyzer = new ICUCollationKeyAnalyzer(Collator.getInstance(ULocale.ROOT));
Directory dir = newDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
Document doc = new Document();
doc.add(new TextField("field", "b", Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
// expected: [a TO c] matches "b"; actual: 0 hits, the bounds are not collation keys
assertEquals(1, new IndexSearcher(reader).count(new QueryParser("field", analyzer).parse("[a TO c]")));
}
}
}
}
cd $LUCENE && ./gradlew :lucene:analysis:icu:test --tests TestICUCollationKeyAnalyzerRange -Ptests.seed=DEADBEEF
- Expected
[a TO c]matches the documentb: 1 hit.- Observed
- 0 hits:
AssertionError: expected:<1> but was:<0>, with assertions on and off.
More files, environment, full output289 words
Single thread. The seed also randomises the test framework's environment, such as the directory, codec, locale and time zone (the DEADBEEF run drew locale mr and time zone Africa/Niamey); the outcome was the same in every recorded run, with assertions on and off, through gradle and the standalone runner. The analysis-icu module has no test dependency on the query parser today, so the run adds moduleTestImplementation project(':lucene:queryparser') to the dependencies block of lucene/analysis/icu/build.gradle.
The hunt's earlier 5-document form (a..e, [b TO d], expected 3, got 0) fails the same way on main and on the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 release jars; it is supplementary and not needed to file.
Regression test: this same test, with the build.gradle line; it passes with the recommended fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0 release |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestICUCollationKeyAnalyzerRange.java $LUCENE/lucene/analysis/icu/src/test/org/apache/lucene/analysis/icu/
# add to the dependencies block of $LUCENE/lucene/analysis/icu/build.gradle:
# moduleTestImplementation project(':lucene:queryparser')
cd $LUCENE && ./gradlew :lucene:analysis:icu:test --tests TestICUCollationKeyAnalyzerRange -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
# main 645d4fe14a20, gradle, -Ptests.seed=DEADBEEF (the same with -Ptests.asserts=false)
TestICUCollationKeyAnalyzerRange > testQueryParserRange FAILED
java.lang.AssertionError: expected:<1> but was:<0>
...
at org.apache.lucene.analysis.icu.TestICUCollationKeyAnalyzerRange.testQueryParserRange(TestICUCollationKeyAnalyzerRange.java:45)
:lucene:analysis:icu:test (FAILURE): 1 test, 1 failure
# standalone runner: main 645d4fe14a20 -ea / -da: fail 1/1, the same message
# the bytes (a main() probe): indexed term of "b" = [2d 1 5 1 5 0]; range bounds normalize("a") = [61], normalize("c") = [63]
# with the fix: the bounds are [2b 1 5 1 5 0] and [2f 1 5 1 5 0]; the test passes; :lucene:analysis:icu:test (SUCCESS): 159 tests, 10 skippednormalize()promises "the representation that it would have in the index";attributeFactoryis the factory "to be used for analysis and normalization".- LUCENE-7355 moved range bounds to
normalize()and added this override to the java.text twin and the test stand-in, not the ICU class. - LUCENE-4176 (2012) fixed this same scenario, a parsed ICU collation range returning 0 hits.
The full argument289 words
- The contracts.
Analyzer.normalize: "Normalize a string down to the representation that it would have in the index. This is typically used by query parsers ..." (Analyzer.java:202-207).Analyzer.attributeFactory(String): "Return the AttributeFactory to be used for analysis and normalization on the given FieldName" (:288-292). The ICU analyzer uses its factory for analysis and the default one for normalization; its javadoc names no exception (ICUCollationKeyAnalyzer.java:24-50). - The twin and the stand-in.
CollationKeyAnalyzeroverridesattributeFactory(CollationKeyAnalyzer.java:70-73); so doesMockBytesAnalyzer(MockBytesAnalyzer.java:36-39), whichTestQueryParser.testByteTerms("// LUCENE-4176",TestQueryParser.java:974-985) uses as the stand-in for the ICU case, because the query parser module does not depend on ICU. No test asserts that the ICU analyzer'snormalizereturns UTF-8; the ICU tests build key bounds by hand (TestICUCollationKeyAnalyzer.java:43-50). - History. The 4.0 MIGRATE note (LUCENE-2514): "To perform collated range queries, use a suitable collating analyzer: CollationKeyAnalyzer or ICUCollationKeyAnalyzer, and set qp.setAnalyzeRangeTerms(true)." LUCENE-4176 (#5248, 2012) was reported against
ICUCollatedTermAttributeImplwith a parsed range returning 0 hits; Robert Muir fixed the parser to "just consume the bytes". LUCENE-7355 (#8409, 2016): Adrien Grand wrote that "some consumers need to get the binary representation of terms, which depends on the AttributeFactory (#5248)" and thatnormalize"uses the same AttributeFactory as the default analysis chain"; the commits added the hook, then without an argument, with overrides inCollationKeyAnalyzerandMockBytesAnalyzer, and touched no file underanalysis/icu. LUCENE-7429 (#8482) added afieldNameparameter to the hook (attributeFactory()becameattributeFactory(String)) and updated the same two classes.ICUCollationKeyAnalyzerhas never had the override. - Consistency. On the same field, parser and analyzer, a term query matches and a range query does not; the java.text twin gives the right answer for both.
The devil's-advocate check193 words
The strongest case: on LUCENE-7429 (#8482, 2016) Uwe Schindler wrote that using the same attribute types for normalization "is sometimes broken for use-cases, where TokenStreams create binary tokens. But those would never be normalized, I think (!?)". The classic parser's package doc says "Untokenized fields are best added directly to queries, and not through the query parser" (package-info.java:86-90), and this analyzer uses a KeywordTokenizer. The ICU tests build key bounds by hand, the module overview's QueryParser range example is stale (it calls removed APIs), and ICUCollationDocValuesField offers another route.
Why it fails: the remark ends in "(!?)"; Adrien Grand replied in the same thread with a possible counter-example (a non-default term attribute such as UTF-16 for CJK text), and the issue's commit kept and updated the override of the binary-token CollationKeyAnalyzer, which points to binary-token analyzers being normalized on purpose, not excluded. The package-doc advice concerns program-generated values ("dates, keywords"), not human text that an analyzer converts. The javadocs make no exception, the MIGRATE note names this analyzer for parsed collated ranges, and LUCENE-4176 fixed exactly this scenario. Verdict: the evidence strongly indicates an omission, not a documented exclusion.
Recommended Override attributeFactory(String) in ICUCollationKeyAnalyzer (validated)
Override attributeFactory(String) in ICUCollationKeyAnalyzer to return its ICUCollationAttributeFactory, the override CollationKeyAnalyzer has (LUCENE-7355 added it without an argument; LUCENE-7429 gave it the current signature) (+6 lines). With it the repro and the whole analysis-icu test module pass, and the bounded differential finds 0 wrong ranges.
import org.apache.lucene.util.AttributeFactory;
@Override
protected AttributeFactory attributeFactory(String fieldName) {
return factory;
}All options and trade-offs3 options · 475 words
Option A was built and run; B and C are alternatives that were not built.
Add the method the java.text twin has (CollationKeyAnalyzer.java:70-73), plus its import. For the PR, add the test of the minimal repro and the one test-dependency line in lucene/analysis/icu/build.gradle.
import org.apache.lucene.util.AttributeFactory;
@Override
protected AttributeFactory attributeFactory(String fieldName) {
return factory;
}Trade-offs
- No index change: the index already holds keys. What changes is the
normalizeresult visible through the API, from UTF-8 to the key the index holds, for direct callers and every parser that uses it. - Parsed prefix, wildcard and fuzzy terms on such a field then also see key bytes, as they already do with the java.text twin; those query types have no clear meaning over collation keys either way (not claimed).
- The flexible
StandardQueryParser's ranges on such a field change too:[b TO d]goes from 0 to 3 hits in the side-parser run, matching the java.text twin. - A consistency repair:
normalizethen agrees with the indexed term. It does not imply that hand-builtTermRangeQuerybounds orICUCollationDocValuesFieldare wrong; both stay as they are. - The regression test adds a test-only dependency of analysis-icu on the query parser; without it, the same one-document check can assert
normalize()against the indexed term.
What the checks validated
- Scratch worktree of
645d4fe14a20, JDK 25, gradle: the minimal test fails on stock and passes with the fix, assertions on and off. - The whole
lucene/analysis/icumodule with the fix and the test: 159 tests in 17 suites, 10 skipped, 0 failures, assertions on and off (the upstreamTestICUCollationKeyAnalyzerandTestICUCollationDocValuesFieldincluded). :lucene:analysis:icu:check -x test(format, ecjLint, forbiddenApis) passes once the test's longassertEqualsline is wrapped as google-java-format asks (./gradlew tidy); the wrapped test passes.- Standalone runner: the minimal test and the hunt's 5-document test pass with the fix, assertions on and off; the bounded differential finds 0 of 1,280 ranges and 0 of 80
normalizechecks wrong. - Not run: the
lucene/queryparsermodule's own tests with the fix, the full Lucene suite, nightly tests.
Make Analyzer.normalize take the factory from the analyzer's own tokenizer, so no analyzer can miss the override.
Trade-offs
A broad core change that reverses a design decision: an earlier LUCENE-7355 patch did borrow the factory from the analysis chain, and Adrien Grand switched to the explicit method because that "makes us close the token stream before it has been consumed, which some analysis chains might not like"; on LUCENE-7429 Uwe Schindler also disliked creating a TokenStream just to get its attribute source.
Say in the javadoc that ranges on this analyzer must be built by hand from collation keys.
Trade-offs
Contradicts the normalize and attributeFactory javadocs, the 4.0 MIGRATE note and the java.text twin.
An application that uses a parsed range on an ICU collation-key field as a restriction would silently include or drop the wrong documents.
An automated bounded-exhaustive scan checked, for 99 configurations of Lucene's stock analyzers, that normalize(w) equals the single token indexing produces for w, and flagged ICUCollationKeyAnalyzer. A differential of both collation analyzers against the collator's own order found the same bug independently; reading the code showed the missing override.
The method in full154 words
Method: bounded-exhaustive metamorphic testing on the stock main jars, by two hunt units at about the same time (2026-10-26).
- Relation N1:
Analyzer.normalize(f, w)equals the single token that indexing produces forw, over about 20,000 short words (every word of 1-2 characters over a 110-character alphabet plus random longer ones) and 99 analyzer configurations; relation N2: the query parsers actually reachnormalize.ICUCollationKeyAnalyzerfailed N1. - A differential of the java.text and ICU collation analyzers against the collator's own order (10 locales x 4 strengths, every string of up to 3 characters over a per-locale alphabet): only the ICU classic-parser range oracle disagreed, in 10,079 of 16,000 checks (0 of 16,000 for the java.text twin).
- An adjudication unit reduced it to one JUnit test, ran a new bounded differential (16 words x 5 strengths: 1,280 ranges, 80
normalizechecks) on stock and with the fix, and swept the releases from 6.1.0 to main.
No report found: no GitHub issue or PR (including the migrated JIRA archive), list thread or downstream report of this defect, and no fix in flight. Closest items are history: LUCENE-7355 introduced it and LUCENE-4176 fixed the same scenario in 2012. Searched 2026-10-30.
Searches4 searches
- History, not reports: #8409 (LUCENE-7355, introduced it), #5248 (LUCENE-4176, the 2012 fix of the same scenario in a since-deleted parser), #8482 (LUCENE-7429, added the hook's
fieldNameparameter; holds the speculative remark quoted above), #8435 (LUCENE-7382, a different class). - Open docs PR #16904 edits only the java.text collation package page; its reviewer notes that "QueryParserBase#newRangeQuery now normalizes the range endpoints".
- Downstream look-alikes (a 2018 Stack Overflow question, a 2015 forum thread) concern a downstream server's own collation filter and range API, which does not analyze the bounds.
- Not fixed upstream: main
716d6f216f5,branch_11x,branch_10_6and the 10.6.0 tag have no override; the file's last commit is a 2021 reformatting. - Gaps: keyword search only; GitHub search does not index PR diffs (open PRs were checked by their changed files); private channels cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), JIRA archive | 2026-10-30 | a scripted prior-report search (55 queries incl. lists and web) and 84 manual queries; ICUCollationKeyAnalyzer x normalize / range / QueryParser / attributeFactory, analyzeRangeTerms, collated range | history only (#8409, #5248, #8482, #8435); no report |
| Open PRs | 2026-10-30 | changed files of all 409 open apache/lucene PRs; 68 PRs and 18 issues updated since 2026-10-27 by title | none touches the fix site; #16904 (docs, java.text page) |
| Mailing lists, downstream repositories, web | 2026-10-30 | dev@, java-user@, issues@, commits@, users lists; downstream code and issue search | no report; look-alikes on another code path |
| GitHub refresh | 2026-10-30 | open PRs naming ICUCollationKeyAnalyzer; issues and PRs updated since 2026-10-29; the file on main, branch_11x, branch_10_6 and the 10.6.0 tag | nothing new; not fixed |
Introduced by LUCENE-7355 (Adrien Grand, 2016): 6.2.0 for users of setAnalyzeRangeTerms(true), every classic-parser range from 7.0.0, which removed that setter. 6.1.0 with the setting is correct; every release run from 6.2.0 to 10.6.0, and main, is wrong.
Versions tested7 rows
Introduced by LUCENE-7355 "Add Analyzer#normalize() and use it in query parsers" (#8409, JIRA era, no pull request), Adrien Grand, author date 2016-06-28, committed 2016-07-12: 7c2e7a0fb80 on branch_6x (first released in 6.2.0) and e92a38af90d on master (7.0.0). It moved the classic parser's analyzed range bounds from tokenStream, which yields the ICU key, to normalize, and added the attributeFactory override, then without an argument, to CollationKeyAnalyzer and MockBytesAnalyzer only (LUCENE-7429 later added the fieldName parameter). The key bytes differ by ICU version; normalize always returns UTF-8.
| Version or commit | Verdict | Result |
|---|---|---|
| 6.1.0, setAnalyzeRangeTerms(true) | not affected | 3 of 3 ([b TO d] over a..e); the bounds are ICU keys |
| 6.2.0, 6.6.6, setAnalyzeRangeTerms(true) | affected | 0 of 3 |
| 7.0.0, 7.7.3, 8.11.4, 9.0.0 | default | 0 of 3 (main() probe) |
| 9.1.0, 9.12.3, 10.5.2, 10.6.0 | default | the 5-document JUnit test: expected:<3> but was:<0> |
main 645d4fe14a20 | affected | the minimal test fails, assertions on and off (runner and gradle) |
| java.text CollationKeyAnalyzer at 6.2.0, 7.0.0, main | control | correct |
fix A on 645d4fe14a20 | not affected | the minimal test and the analysis-icu module pass |
Reproduced by an independent verifier, a devil's-advocate verifier, an adjudication and a cross-model review that ran its own test (OpenAI GPT-5.6); a second cross-model review, reading only, also rated it real. Each java.text control passed and each fix passed.
Checks and verdicts208 words
Each check except the cross-model review ran its own test or re-ran an earlier one on stock main; the controls (the java.text twin, the fix) pass.
| Check | Verdict | What it found |
|---|---|---|
| Finder (judge unit) | candidate | 5-document test fails with -ea and -da; differential 731 of 1,280 ranges wrong, 0 with the fix; release sweep 6.1.0 to main. |
| Cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own test (ENGLISH collator, other values) fails at three seeds with -ea and -da on main ea57360912ed; the java.text control passes; passes with the fix. |
| Independent reproducer | real | Own 2-test class fails on main (-ea, -da), 10.6.0, 10.5.2 and 9.12.3; passes on the java.text twin and with its own fix. |
| Works-as-designed lens (OpenAI GPT-5.6, reading only) | real (medium) | Intended-behaviour argument weak; it could not run, so it ran nothing. |
| Intended-behaviour devil's advocate | real | Re-ran the test on main and the 10.6.0 jars: fails; no intended or misuse argument survives. |
| Adjudication | confirmed (high) | Re-ran the finder's and the independent reproduction's tests on main: fail; no-noise gate passed. |
| Registration check, audit and this doc's runs | keep | Re-runs fail on main and pass with the fix; this doc's gradle runs agree. |
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 502 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,335 words
- Registry title
- ICUCollationKeyAnalyzer does not override attributeFactory(String), so Analyzer.normalize returns the plain UTF-8 bytes instead of the ICU collation key, and classic QueryParser range queries on an ICU collation-key field match the wrong documents
- Area
- analysis-icu / collation (ICUCollationKeyAnalyzer vs Analyzer#normalize; classic QueryParser range bounds)
- Priority
- Medium, rank 123: Since 7.0.0, classic-QueryParser range queries on a field indexed with the stock ICUCollationKeyAnalyzer silently return wrong hits in the default configuration, but ICU collation ranges are a niche opt-in feature, so it is Medium.
- Impact class
- silent wrong results
- Impact note
- Hits every application that indexes a field with the stock ICUCollationKeyAnalyzer (opt-in analysis-icu module) and lets the classic QueryParser build range queries on it ('[x TO y]', '{x TO y}', open-ended bounds): every such range silently matches the wrong documents, missing documents inside the collated range and returning documents outside it (731 of 1,280 ranges wrong in a bounded differential), with the default IndexWriterConfig and QueryParser, no scale and no special input, with -ea and -da; term queries on the field are unaffected; in every release from 6.2.0 (there only with the then-documented setAnalyzeRangeTerms(true)) and from 7.0.0 in the default configuration, through 10.6.0 and main.
- Summary
- You index the values a, b, c, d and e in a TextField analyzed with new ICUCollationKeyAnalyzer(Collator.getInstance(ULocale.ROOT)) and parse "[b TO d]" with the classic QueryParser on the same analyzer; you expect the 3 documents b, c and d, which is what the java.text twin CollationKeyAnalyzer gives. You get 0: the parser builds both range bounds with Analyzer.normalize, which takes its term attribute from attributeFactory(fieldName), and ICUCollationKeyAnalyzer gives its collation attribute factory only to its KeywordTokenizer and does not override attributeFactory, so normalize("b") is the UTF-8 byte [62] while the index holds the ICU collation key [2d 1 5 1 5 0]. With other inputs the same range both misses documents inside it and matches documents outside it.
- Introduced
- 7c2e7a0fb80a5bf733cf710aee6cbf01d02629eb (branch_6x, first released in 6.2.0) and e92a38af90d12e51390b4307ccbe0c24ac7b6b4e (master, first released in 7.0.0), LUCENE-7355 'Add Analyzer#normalize() and use it in query parsers' (JIRA, migrated as apache/lucene#8409; pre-GitHub, no pull request), by Adrien Grand (jpountz): it moved the classic QueryParser's analyzed range bounds from analyzeMultitermTerm (tokenStream, which yields the ICU key) to the new Analyzer#normalize, and in the same commit added the attributeFactory override (then without an argument; LUCENE-7429 later added the fieldName parameter) to CollationKeyAnalyzer and the test MockBytesAnalyzer but not to ICUCollationKeyAnalyzer; the master commit also removed setAnalyzeRangeTerms, so from 7.0.0 every classic-parser range bound goes through normalize [read: full-history clone; exec: 6.1.0 with setAnalyzeRangeTerms(true) returns 3 hits, 6.2.0 returns 0]. ICUCollationKeyAnalyzer has never had the override (git log --all -G attributeFactory over all six of its historical paths: empty). A regression: the same user scenario (an ICU collation analyzer plus a query-parser range) had been fixed at a different site by LUCENE-4176 (apache/lucene#5248, 2012). (2016-06-28 (author date of both commits; committed 2016-07-12))
- First affected version
- 6.2.0 [exec: with setAnalyzeRangeTerms(true), then the documented recipe for collated QueryParser ranges; 6.1.0 with the same setting is not affected]; 7.0.0 in the default configuration [exec]
- Affected versions
- [exec] main 645d4fe14a20 (-ea and -da: finder, the independent reproduction, the intended-behaviour check, adjudication, the cross-model refute review and the re-run); 10.6.0, released (tag releases/lucene/10.6.0 -> 4ae946e5a9b; release jars, -da: finder, the independent reproduction, the intended-behaviour check); 10.5.2 and 9.12.3 (JUnit, -da: finder, the independent reproduction); 9.1.0 (JUnit, -da); 9.0.0, 8.11.4, 7.7.3 and 7.0.0 (main() probe, default configuration); 6.6.6 and 6.2.0 (main() probe with setAnalyzeRangeTerms(true)). Not affected: 6.1.0 with setAnalyzeRangeTerms(true) (3 hits, bounds are the ICU keys). [read] every release in between; upstream main 716d6f216f5, branch_11x 603af870375 and branch_10_6 7e5aec4370e still lack the override. Not new in 10.6. Executed on Linux aarch64.
- 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: exhaustive check; also reached by code reading
- Upstream and prior reports
- none found (2026-10-30): 139 queries in all, a scripted prior-report search (55 queries over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the web; 713 hits, none the same bug), 84 manual searches (apache/lucene issues, PRs and commits; downstream repositories; lists.apache.org; LUCENE and SOLR JIRA), a changed-file scan of all 409 open apache/lucene PRs (none touches ICUCollationKeyAnalyzer.java, Analyzer.java, AnalyzerWrapper.java, QueryParserBase.java or the ICU overview), the 68 PRs and 18 issues updated since 2026-10-27 read by title, and upstream main 716d6f216f5, branch_11x, branch_10_6 and the 10.6.0 tag checked (no override, no commit to the fix site after 645d4fe14a20); a refresh the same day found nothing new. Related, not reports: #8409 (LUCENE-7355, introduced it), #5248 (LUCENE-4176, the same user scenario fixed in 2012 at a different site, AnalyzingQueryParser, since deleted; its test now runs on MockBytesAnalyzer), #8482 (LUCENE-7429, added the hook's fieldName parameter; contains a speculative 2016 design remark that binary-token analyzers 'would never be normalized'), #8435 (LUCENE-7382), #16904 (Deepcodr; docs PR for the java.text collation package page, merged 2026-11-02 by vsop-479 as e1faeba08bd, main only; its review (vsop-479, 2026-10-28) restates that QueryParserBase#newRangeQuery normalizes the endpoints, and the merged page drops setAnalyzeRangeTerms(true); it does not touch the ICU analyzer). Downstream look-alikes (a 2018 Stack Overflow question and a 2015 forum thread about empty ranges over an icu_collation token filter in a downstream search server) use that server's own range API, which does not analyze the bounds, and involve neither ICUCollationKeyAnalyzer nor Analyzer#normalize.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off through gradle (scratch worktree, the test added tolucene/analysis/icuwith a test dependency on the query parser) and through a standalone runner, and passes with the fix; release jars: 10.6.0, 10.5.2, 9.12.3 and 9.1.0 (the finder's 5-document JUnit) and 9.0.0, 8.11.4, 7.7.3, 7.0.0, 6.6.6 and 6.2.0 (main()probes) fail, 6.1.0 withsetAnalyzeRangeTerms(true)is correct. The cross-model reviews ran on mainea57360912ed, whose files involved are unchanged at645d4fe14a20. Upstream main716d6f216f5,branch_11x,branch_10_6and the 10.6.0 tag still lack the override (read 2026-10-30).
- Finding record
lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUCollationKeyAnalyzer.java:52-70 (at 645d4fe14a20) (final class; the ICUCollationAttributeFactory goes only to the KeywordTokenizer in createComponents at :65-67; no attributeFactory(String) override)lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:203 (at 645d4fe14a20) ('Normalize a string down to the representation that it would have in the index'), :213 (public final normalize), :233 (normalize builds its term with attributeFactory(fieldName)), :289-293 (attributeFactory javadoc: 'to be used for analysis and normalization'; default TokenStream.DEFAULT_TOKEN_ATTRIBUTE_FACTORY)lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:542-560 (at 645d4fe14a20) (newRangeQuery; both bounds = getAnalyzer().normalize(field, part) at :550 and :556)lucene/analysis/common/src/java/org/apache/lucene/collation/CollationKeyAnalyzer.java:70-73 (at 645d4fe14a20) (the java.text twin overrides attributeFactory(String) to return its CollationAttributeFactory)lucene/analysis/icu/src/java/overview.html:98-99 (at 645d4fe14a20) ('Efficient range queries over fields that contain terms in languages that use non-Unicode character orderings'), :112-126 (Farsi range example through QueryParser; the page itself is stale, LFV-992)
- The finding in 30 seconds
a Lucene main checkout lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUCollationKeyAnalyzer.java:52-70 (no attributeFactory override; createComponents at :64-69)a Lucene main checkout lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUCollationAttributeFactory.java:50-51, :72-78; lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/tokenattributes/ICUCollatedTermAttributeImpl.java:46-54a Lucene main checkout lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:202-213, :233-237, :288-295a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:542-561a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/collation/CollationKeyAnalyzer.java:70-80; lucene/test-framework/src/java/org/apache/lucene/tests/analysis/MockBytesAnalyzer.java:36-39
- Who is affected
a Lucene main checkout lucene/analysis/icu/src/java/overview.html:97-100
- Why it looks unintended
a Lucene main checkout lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:202-207, :288-292; a Lucene main checkout lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUCollationKeyAnalyzer.java:24-50a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/collation/CollationKeyAnalyzer.java:70-73; lucene/test-framework/src/java/org/apache/lucene/tests/analysis/MockBytesAnalyzer.java:36-39a Lucene main checkout lucene/queryparser/src/test/org/apache/lucene/queryparser/classic/TestQueryParser.java:974-985; lucene/queryparser/src/test/org/apache/lucene/queryparser/util/QueryParserTestBase.java:1282-1288; lucene/analysis/icu/src/test/org/apache/lucene/analysis/icu/TestICUCollationKeyAnalyzer.java:43-50a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/package-info.java:86-90; lucene/analysis/icu/src/java/overview.html:112-136a Lucene main checkout lucene/CHANGES.txt:15301-15303 (LUCENE-4176)
- Suggested fix
a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/collation/CollationKeyAnalyzer.java:70-73
- Diagrams
a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:550, :556; a Lucene main checkout lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:233
- TestICUCollationKeyAnalyzerRange.java (one test, public API):
repro/LFV-1053/TestICUCollationKeyAnalyzerRange.java(49 lines)
Made in parallel with this doc, from the code at 645d4fe14a20 and the executed byte probe of the repro's values.
assets/LFV-1053/bug.png(502 KB)
![Diagram of the repro: at index time ICUCollationKeyAnalyzer turns the value b into its ICU collation key [2d 1 5 1 5 0]; at query time the classic QueryParser builds the bounds of [a TO c] with Analyzer.normalize, which returns the UTF-8 bytes [61] and [63]; the key sorts below [61], so the range returns 0 documents instead of 1. With the fix the bounds are the keys [2b 1 5 1 5 0] and [2f 1 5 1 5 0] and the document matches.](../assets/LFV-1053/bug.png)