All findings · Lucene finding · LFV-1053 · icu-collation-key-analyzer-normalize-skips-collation

ICUCollationKeyAnalyzer.normalize ignores the collator, so QueryParser range queries on ICU collation-key fields match the wrong documents

ICUCollationKeyAnalyzer indexes values as ICU collation keys, but its normalize() returns plain UTF-8, and the classic QueryParser builds range bounds with normalize(): [a TO c] misses a document b (0 hits instead of 1). Every release since 7.0.0 has it (6.2.0 with an opt-in); the fix is one method.

Summary

Priority
Medium, rank 123Since 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
silent wrong results
Since
6.2.0 (LUCENE-7355, 2016); in the default configuration since 7.0.0introduced 2016-06-28 · over 10 years
Affected
6.2.0 to 10.6.0 and main (6.x only with setAnalyzeRangeTerms(true))
Status
documented
Found by
bounded-exhaustive metamorphic scan: normalize() vs the indexed token

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

ICUCollationKeyAnalyzer stores ICU collation keys, but normalize(), and with it every range bound the classic QueryParser builds, produces plain UTF-8 for it.

Background

  1. Collation keys. A collator turns text into bytes whose byte order is the language's sort order. ICUCollationKeyAnalyzer indexes each whole value as such a key, so a TermRangeQuery over keys is a range in that order.
  2. 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".
  3. The term's encoding comes from an AttributeFactory. normalize uses attributeFactory(fieldName), which an analyzer overrides when its terms are not UTF-8; the java.text twin CollationKeyAnalyzer does.
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.
The value 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.

The finding, step by step

  1. Index one document whose field holds b, analyzed with new 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.
  2. Parse [a TO c] with the classic QueryParser on the same analyzer and count. You expect 1 hit: b sorts between a and c, and the java.text twin CollationKeyAnalyzer handles such ranges correctly.
  3. 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.
  4. Why: normalize takes its term encoding from attributeFactory(fieldName). ICUCollationKeyAnalyzer gives its collation factory only to its tokenizer and never overrides that method, so normalize falls back to UTF-8.
  5. 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 query b still matches.

Who is affected

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.

The code (main 645d4fe14a20)

  1. Indexing. ICUCollationKeyAnalyzer.createComponents builds new KeywordTokenizer(factory, KeywordTokenizer.DEFAULT_BUFFER_SIZE), one token for the whole value (ICUCollationKeyAnalyzer.java:64-69). factory is an ICUCollationAttributeFactory (:53, :60-62) whose term attribute writes the collator's raw key as the term bytes (ICUCollatedTermAttributeImpl.java:46-54).
  2. Normalization. Analyzer.normalize (public final, Analyzer.java:213) applies the normalization char filters, then builds a one-term StringTokenStream with attributeFactory(fieldName) (:233-236) and reads its bytes (:237).
  3. The missing override. The default attributeFactory(String) returns TokenStream.DEFAULT_TOKEN_ATTRIBUTE_FACTORY (:293-295). ICUCollationKeyAnalyzer is final and has only a constructor and createComponents (ICUCollationKeyAnalyzer.java:52-70), so normalize returns UTF-8.
  4. The parser. QueryParserBase.newRangeQuery sets start = getAnalyzer().normalize(field, part1) and end = getAnalyzer().normalize(field, part2) (QueryParserBase.java:550, :556) and returns a TermRangeQuery over them (:559-560); an open end stays null.
  5. Result. TermRangeQuery compares unsigned bytes, so UTF-8 bounds against ICU keys select an arbitrary slice of the key space.

The bytes in the repro

valueindexed termnormalize, stocknormalize, 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.

The twin

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 ICUCollationKeyAnalyzer and a range parsed by the classic QueryParser; every bound other than * goes through normalize. Direct callers of Analyzer.normalize on this analyzer get UTF-8 too. Since 7.0.0 no parser setting avoids it (setAnalyzeRangeTerms was 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 normalize checks differed from the indexed term. At primary strength [a TO b] misses a, ab, b, B and ä and returns Greek Β and β, whose keys fall between the bytes of a and b. 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; TermRangeQuery built by hand from collation-key bytes (what the ICU tests do); ICUCollationDocValuesField; a downstream server's ICU collation field type that builds its bounds through tokenStream.
  • Not claimed: prefix, wildcard and fuzzy terms over opaque keys, the flexible StandardQueryParser and SimpleQueryParser, 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 and SimpleQueryParser results 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 with utf8ToString() (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).

Minimal repro

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.

TestICUCollationKeyAnalyzerRange.java (one test, public API)49 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.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]")));
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:analysis:icu:test --tests TestICUCollationKeyAnalyzerRange -Ptests.seed=DEADBEEF
Expected
[a TO c] matches the document b: 1 hit.
Observed
0 hits: AssertionError: expected:<1> but was:<0>, with assertions on and off.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0 release
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
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)

Observed output

# 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 skipped

Why it looks unintended

  • normalize() promises "the representation that it would have in the index"; attributeFactory is 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. CollationKeyAnalyzer overrides attributeFactory (CollationKeyAnalyzer.java:70-73); so does MockBytesAnalyzer (MockBytesAnalyzer.java:36-39), which TestQueryParser.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's normalize returns 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 ICUCollatedTermAttributeImpl with 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 that normalize "uses the same AttributeFactory as the default analysis chain"; the commits added the hook, then without an argument, with overrides in CollationKeyAnalyzer and MockBytesAnalyzer, and touched no file under analysis/icu. LUCENE-7429 (#8482) added a fieldName parameter to the hook (attributeFactory() became attributeFactory(String)) and updated the same two classes. ICUCollationKeyAnalyzer has 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.

Suggested fix

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.

Override attributeFactory(String) in ICUCollationKeyAnalyzer (validated) (recommended)

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 normalize result 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: normalize then agrees with the indexed term. It does not imply that hand-built TermRangeQuery bounds or ICUCollationDocValuesField are 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/icu module with the fix and the test: 159 tests in 17 suites, 10 skipped, 0 failures, assertions on and off (the upstream TestICUCollationKeyAnalyzer and TestICUCollationDocValuesField included).
  • :lucene:analysis:icu:check -x test (format, ecjLint, forbiddenApis) passes once the test's long assertEquals line 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 normalize checks wrong.
  • Not run: the lucene/queryparser module's own tests with the fix, the full Lucene suite, nightly tests.

Derive the normalization factory from createComponents (not built)

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.

Document the limitation (not recommended)

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.

Downstream note

An application that uses a parsed range on an ICU collation-key field as a restriction would silently include or drop the wrong documents.

How it was found

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 for w, 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 reach normalize. ICUCollationKeyAnalyzer failed 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 normalize checks) on stock and with the fix, and swept the releases from 6.1.0 to main.

Prior reports, history and checks

Prior reports

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 fieldName parameter; 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_6 and 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.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), JIRA archive2026-10-30a scripted prior-report search (55 queries incl. lists and web) and 84 manual queries; ICUCollationKeyAnalyzer x normalize / range / QueryParser / attributeFactory, analyzeRangeTerms, collated rangehistory only (#8409, #5248, #8482, #8435); no report
Open PRs2026-10-30changed files of all 409 open apache/lucene PRs; 68 PRs and 18 issues updated since 2026-10-27 by titlenone touches the fix site; #16904 (docs, java.text page)
Mailing lists, downstream repositories, web2026-10-30dev@, java-user@, issues@, commits@, users lists; downstream code and issue searchno report; look-alikes on another code path
GitHub refresh2026-10-30open 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 tagnothing new; not fixed

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
6.1.0, setAnalyzeRangeTerms(true) not affected3 of 3 ([b TO d] over a..e); the bounds are ICU keys
6.2.0, 6.6.6, setAnalyzeRangeTerms(true) affected0 of 3
7.0.0, 7.7.3, 8.11.4, 9.0.0 default0 of 3 (main() probe)
9.1.0, 9.12.3, 10.5.2, 10.6.0 defaultthe 5-document JUnit test: expected:<3> but was:<0>
main 645d4fe14a20 affectedthe minimal test fails, assertions on and off (runner and gradle)
java.text CollationKeyAnalyzer at 6.2.0, 7.0.0, main controlcorrect
fix A on 645d4fe14a20 not affectedthe minimal test and the analysis-icu module pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (judge unit) candidate5-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 realOwn 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 realRe-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 keepRe-runs fail on main and pass with the fix; this doc's gradle runs agree.

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,335 words

Finding record

LFV-1053, snapshot of 2026-11-06

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 to lucene/analysis/icu with 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 with setAnalyzeRangeTerms(true) is correct. The cross-model reviews ran on main ea57360912ed, whose files involved are unchanged at 645d4fe14a20. Upstream main 716d6f216f5, branch_11x, branch_10_6 and the 10.6.0 tag still lack the override (read 2026-10-30).

Lucene and upstream sources

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-54
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:202-213, :233-237, :288-295
  • a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:542-561
  • a 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-50
  • a 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-39
  • a 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-50
  • a 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-136
  • a 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

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 with this doc, from the code at 645d4fe14a20 and the executed byte probe of the repro's values.

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.