- A codec can give each field its own postings format through
PerFieldPostingsFormat.getPostingsFormatForField. Fields whose formats compare equal (equals) share one set of files, named after the format and a number per name:EqA_0,EqB_0,EqB_1, ... - Each field stores two attributes: the format's name and the group's number. When reading, Lucene loads the format by that name and opens the field from the files
<name>_<number>. - So if two formats with different names compared equal, a field could be written into one group's files while recording the other name. Lucene checks for this at commit and throws
no suffix for format name.
y's postings go into x's group EqA_0 because the formats compare equal, but y's label says (EqB, 0), which is a's group, so the reader finds no terms for y. Select the still to open it at full size. Generated with AI, checked against the code.- Your codec maps field
ato formatEqB(2),xtoEqA(1)andytoEqB(1).EqAandEqBare your own SPI-registered formats whoseequalscompares only the number, soEqB(1)equalsEqA(1). You index one document witha:v,x:v,y:vand commit. - You expect the commit to throw
IllegalStateException: no suffix for format name: EqB, as it does when the document has onlyxandy; or elsedocFreq(y:v)to be 1. - Lucene groups fields by
equals, soyjoinsx's file groupEqA_0and its postings are written there. Butyrecords its own format name with that group's number:(EqB, 0), which isa's group. - The check only asks whether any group used the name
EqB. Fieldadid, so it passes and the commit succeeds. - The reader opens
EqB_0fory, which holds onlya's terms:docFreq(y:v)is 0 whilex:vis found, with assertions on or off, and CheckIndex reports no problems.
Only applications whose own codec has two SPI-registered postings formats with different names that compare equal (say, an equals that compares only settings), plus an earlier-sorting field of the second name. No shipped format overrides equals. One document suffices; the loss is silent in the repro's layout, and other group numbers leave an index that fails to open.
Mechanism, terms and numbers709 words
PerFieldPostingsFormat lets a codec choose a postings format per field. Fields whose formats compare equal (PostingsFormat.equals) share one group of files, named after the format and a per-name number (EqA_0, EqB_0, ...). Each field records two attributes, its own format name (getName()) and the matched group's number, and the reader opens the field from the files <name>_<number>. Two equal formats with different names would therefore write a field into one group while its attributes point to another. Lucene guards this with an always-on check (IllegalStateException: no suffix for format name, PerFieldPostingsFormat.java:240-243 at 5adff78bb168), but the check asks whether any group used the field's name, not whether the matched group was created under it. With fields a -> EqB(2), x -> EqA(1), y -> EqB(1) (equal by setting only), y joins x's group, the check passes because a used EqB, and y records a's group: the commit succeeds, docFreq(y:v) is 0 and CheckIndex is clean. That silence needs the recorded (name, number) to name an existing group (here both numbers are 0); with other group numbers the commit also succeeds but the index then fails to open (CorruptIndexException). With only x and y the same mapping is rejected. It needs two SPI-registered formats with different names whose equals treats them as equal; no format shipped in Lucene overrides equals. Every release from 4.0.0 to 10.6.0 has it.
The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/codecs/perfield/PerFieldPostingsFormat.java)
FieldsWriter.buildFieldsGroupMapping (:199-260) is shared by flush (:143) and merge (:181). It visits fields in name order (flush: index/FreqProxFields.java:38, "fields are already sorted by field name").
- Groups are found by
equals.formatToGroupBuildersis aHashMap<PostingsFormat, ...>(:202);formatToGroupBuilders.get(format)(:219) uses the format'sequalsandhashCode. - Numbers are counted per name. On a miss, the next number for
format.getName()comes fromsuffixes(:205,:224-230), and the new group writes files<name>_<number>(:232-237). - The only check on a hit is
if (!suffixes.containsKey(formatName)) throw new IllegalStateException("no suffix for format name: ...")(:240-243): has any group used this name? This is the fix site. - The field records its own name and the matched group's number:
PER_FIELD_FORMAT_KEY = formatName,PER_FIELD_SUFFIX_KEY = groupBuilder.suffix(:217,:248-253). - The reader trusts those attributes:
PostingsFormat.forName(formatName)and segment suffixformatName + "_" + suffix(:303-318).
In the repro, a creates group EqB_0, x creates EqA_0, and y's EqB(1) equals EqA(1), so y joins EqA_0 and its postings are written there. The check passes because a already used EqB, and y records (EqB, 0), which is a's group. The reader opens EqB_0 for y; it holds only a's terms, so terms(y) is null. The independent reproducer's probe printed exactly this (formats named ProbeA/ProbeB): files _0_ProbeA_0.* and _0_ProbeB_0.*; attributes a {ProbeB, 0}, x {ProbeA, 0}, y {ProbeB, 0}; terms(y) absent; CheckIndex at level 3 clean. Without field a, the probe's commit throws IllegalStateException: no suffix for format name: ProbeB, expected: 0.
On a group hit the matched group was created under some name N, so suffixes always holds N; the check can fail only when the field's name differs from N. So the only mappings it can reject are equal formats with different names, and it misses the case where another group used the field's name.
Other group numbers. The field records its own name with the matched group's number, so what a reader sees depends on whether that group exists. A fact-check variant on main maps a -> VarB(2), w -> VarA(7), x -> VarA(1), y -> VarB(1) (the same equals): y joins x's group VarA_1, the check passes because of a, and y records (VarB, 1), a group that was never written. The commit succeeds; DirectoryReader.open then throws CorruptIndexException: Problem reading index. (resource=_0_VarB_1.psm), caused by NoSuchFileException, and CheckIndex (level 3) reports clean=false, with assertions on and off. With fix A that commit is rejected: IllegalStateException: no suffix for format name: VarB, expected: 1.
Exposure and likelihood201 words
- Who. Applications with their own per-field postings mapping (a
PerFieldPostingsFormatsubclass or a codec'sgetPostingsFormatForFieldhook) that use two of their own SPI-registeredPostingsFormatclasses with different names whoseequalstreats an instance of one as equal to an instance of the other. A third field whose format has the second name, and is not equal, must sort before the colliding field. - Not hit. The default codec and every format shipped in Lucene: none overrides
equals, so distinct instances never share a group. Mappings in which equal formats also have equal names. - Effect. The commit succeeds and one field's postings cannot be read (they are written into another group's files). In the repro's layout this is silent with assertions on and off: queries on the field match nothing and CheckIndex is clean. With other group numbers it is loud but late: the index fails to open after the commit. With only the two colliding fields the same mistake is rejected at commit.
- Default vs opt-in. Opt-in custom codec code on
@lucene.experimentalextension points. - Scale and likelihood. One document with three one-term fields; deterministic. Rated contrived: the recorded searches found no public report of anyone hitting even the existing two-field check.
One JUnit test method on the public API: two SPI-registered formats that delegate to the default one and compare equal by a number, one document with three fields, commit, then docFreq(y:v). An IllegalStateException at commit counts as correct, so it doubles as the regression test. Deterministic, single thread, about 1.4 s.
/*
* 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.codecs.perfield;
import java.io.IOException;
import org.apache.lucene.codecs.Codec;
import org.apache.lucene.codecs.FieldsConsumer;
import org.apache.lucene.codecs.FieldsProducer;
import org.apache.lucene.codecs.FilterCodec;
import org.apache.lucene.codecs.PostingsFormat;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.SegmentReadState;
import org.apache.lucene.index.SegmentWriteState;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestPerFieldEqualFormatNames extends LuceneTestCase {
abstract static class EqualBySetting extends PostingsFormat {
final int setting;
final PostingsFormat delegate = TestUtil.getDefaultPostingsFormat();
EqualBySetting(String name, int setting) {
super(name);
this.setting = setting;
}
@Override
public FieldsConsumer fieldsConsumer(SegmentWriteState state) throws IOException {
return delegate.fieldsConsumer(state);
}
@Override
public FieldsProducer fieldsProducer(SegmentReadState state) throws IOException {
return delegate.fieldsProducer(state);
}
@Override
public boolean equals(Object o) {
return o instanceof EqualBySetting other && other.setting == setting;
}
@Override
public int hashCode() {
return setting;
}
}
public static final class EqA extends EqualBySetting {
public EqA() {
super("EqA", 1);
}
}
public static final class EqB extends EqualBySetting {
public EqB() {
this(1);
}
EqB(int setting) {
super("EqB", setting);
}
}
// Expected: commit rejects y (equal to x's format, other name) as it does without field a, or y
// is readable; actual: docFreq(y) == 0.
public void testEqualFormatsWithDifferentNames() throws Exception {
PostingsFormat a = new EqB(2), x = new EqA(), y = new EqB();
Codec codec =
new FilterCodec(TestUtil.getDefaultCodec().getName(), TestUtil.getDefaultCodec()) {
final PostingsFormat postings =
new PerFieldPostingsFormat() {
@Override
public PostingsFormat getPostingsFormatForField(String field) {
return field.equals("a") ? a : field.equals("x") ? x : y;
}
};
@Override
public PostingsFormat postingsFormat() {
return postings;
}
};
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setCodec(codec))) {
Document doc = new Document();
for (String field : new String[] {"a", "x", "y"}) {
doc.add(new StringField(field, "v", Field.Store.NO));
}
w.addDocument(doc);
try {
w.commit();
} catch (IllegalStateException rejected) {
w.rollback();
return;
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
assertEquals(1, reader.docFreq(new Term("y", "v")));
}
}
}
}
cp $REPRO/src/org/apache/lucene/codecs/perfield/TestPerFieldEqualFormatNames.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/perfield/
cp $REPRO/org.apache.lucene.codecs.PostingsFormat $LUCENE/lucene/core/src/test/META-INF/services/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.perfield.TestPerFieldEqualFormatNames -Ptests.seed=DEADBEEF
# add -Ptests.asserts=false: the same failure
- Expected
committhrowsIllegalStateException: no suffix for format name: EqB, as without fielda; ordocFreq(y:v)is 1.- Observed
commitsucceeds anddocFreq(y:v)is 0 (AssertionError: expected:<1> but was:<0>), with assertions on and off.
More files, environment, full output1 more file (17 lines) · 390 words
TestPerFieldEqualFormatNames (one method, extends LuceneTestCase, package org.apache.lucene.codecs.perfield of the lucene/core tests, Apache license header) plus a two-line SPI file that registers its formats EqA and EqB (the reader looks formats up by name). In the commands, $REPRO is this finding's repro folder, which holds the SPI file at its top level. It is the hunt's one-test repro with the license header added and one comment line wrapped; with them it passes checkGoogleJavaFormat. The test draws no random values; it was run with seed DEADBEEF, and earlier tests of the same mapping also failed with seeds 1234ABCD, 5EED0E01, 5EED0D02, A0D17E01 and A0D17E02. With fix A it passes unchanged, assertions on and off.
META-INF/services/org.apache.lucene.codecs.PostingsFormat (lucene/core/src/test, registers the two test formats)
# 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.
org.apache.lucene.codecs.perfield.TestPerFieldEqualFormatNames$EqA
org.apache.lucene.codecs.perfield.TestPerFieldEqualFormatNames$EqB
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT): fails; 10.6.0 and 9.12.3: fail; 4.0.0-ALPHA to 10.6.0 (19 releases, history version): fail |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (only this platform was run) |
cp $REPRO/src/org/apache/lucene/codecs/perfield/TestPerFieldEqualFormatNames.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/perfield/
cp $REPRO/org.apache.lucene.codecs.PostingsFormat $LUCENE/lucene/core/src/test/META-INF/services/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.perfield.TestPerFieldEqualFormatNames -Ptests.seed=DEADBEEF
# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF (assertions on; -Ptests.asserts=false gives the same)
TestPerFieldEqualFormatNames > testEqualFormatsWithDifferentNames FAILED
java.lang.AssertionError: expected:<1> but was:<0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:EAAB3C0285D31F89]:0)
...
at org.apache.lucene.codecs.perfield.TestPerFieldEqualFormatNames.testEqualFormatsWithDifferentNames(TestPerFieldEqualFormatNames.java:120)
# release jars 10.6.0 and 9.12.3, assertions off: the same AssertionError
# with fix A, assertions on and off
:lucene:core:test (SUCCESS): 1 test
# the commit now throws the existing check; the hunt's earlier test (same mapping, formats FormatA/FormatB) shows the message:
java.lang.IllegalStateException: no suffix for format name: FormatB, expected: 0- The always-on check can fail only for equal formats with different names, and rejects this mapping for two colliding fields alone.
- Format
equalsoverrides are invited (#5162: "optional and a new optimization"; #5165, open); no javadoc requires equal names. - The commit succeeds where two fields are rejected: silently in the repro (CheckIndex clean), or the index fails to open.
The full argument131 words
- The code already treats the mapping as illegal. The check at
PerFieldPostingsFormat.java:240-243can fail only when a field joins a group created under another name (see the mechanism), and it does reject the two-field mapping withIllegalStateException. - Contract. The group map became equals-keyed on purpose in LUCENE-4090 (#5162); its author (Robert Muir) wrote there that
equalson postings formats is "optional and a new optimization". LUCENE-4093 (#5165, still open) asks for such overrides. No javadoc ofPostingsFormat,NamedSPIor the per-field hooks says equal formats must have equal names. - Tests.
TestPerFieldPostingsFormat2.testSameCodecDifferentInstanceandtestSameCodecDifferentParams(:262,:279) use same-name instances only; no test combines anequalsoverride with two names. - History. LUCENE-5969 (#7031,
98e9122375c) turned the originalassert suffixes.containsKey(formatName)into the always-on exception with the same condition.
The devil's-advocate check162 words
The strongest case for "not a bug" (rated moderate by the cross-model reviews and the devil's-advocate verifier): an equals that equates formats whose SPI names differ is broken user code, because the name is what gets written (the private name field is documented as "Unique name that's used to retrieve this format when reading the index", PostingsFormat.java:66); the extension points are @lucene.experimental; no shipped format overrides equals; and LUCENE-5969 turned every assert in the file into a check at once, so the always-on check was not a separate decision about this input.
Why it fails: no contract states the rule; Lucene already rejects this exact mapping, just incompletely; the commit succeeds instead of throwing, and the field is lost (silently with a clean CheckIndex in the repro's layout, or with an index that fails to open for other group numbers); and the fix leaves every valid mapping unchanged. The argument bears on likelihood (hence priority Low), not on correctness.
Recommended Check the matched group's own name, not any group's (fix A)
Remember the format name each group was created with, and on a group hit throw the existing IllegalStateException when the field's name differs (+3/-1 in PerFieldPostingsFormat). Every valid mapping is unchanged. The repro passes with assertions on and off; 290 tests of the perfield suites, TestCodecs and TestAddIndexes pass.
Map<PostingsFormat, String> groupNames = new HashMap<>();
...
formatToGroupBuilders.put(format, groupBuilder);
groupNames.put(format, formatName);
} else {
// we've already seen this format, so just grab its suffix
if (!formatName.equals(groupNames.get(format))) {
throw new IllegalStateException(
"no suffix for format name: " + formatName + ", expected: " + groupBuilder.suffix);
}All options and trade-offs3 options · 618 words
Option A was built and checked through gradle on 5adff78bb168; B and C were not run. A keeps today's behaviour for every mapping Lucene accepts now and turns the silent case into the rejection Lucene already gives for two fields. Side remark, not claimed: PerFieldDocValuesFormat (:191, :229) and PerFieldKnnVectorsFormat (:157, :179) use the same equals-keyed lookup with an assert-only guard; for doc values the two-field mapping fails loudly (an AssertionError at flush with assertions on; with them off, a commit after which DirectoryReader.open throws FileNotFoundException). Vectors were only read.
In buildFieldsGroupMapping, keep a map from each group's format to the name it was created with, and on a hit compare it with format.getName() instead of asking suffixes.containsKey(formatName). Same exception and message as today's two-field rejection.
Map<PostingsFormat, String> groupNames = new HashMap<>();
...
formatToGroupBuilders.put(format, groupBuilder);
groupNames.put(format, formatName);
} else {
// we've already seen this format, so just grab its suffix
if (!formatName.equals(groupNames.get(format))) {
throw new IllegalStateException(
"no suffix for format name: " + formatName + ", expected: " + groupBuilder.suffix);
}Trade-offs
Still a commit-time rejection, as today for two fields, not an error when the codec is configured. The message reads oddly for this case; a final version could name the field and both format names (not run). One extra HashMap per flush or merge, sized by the number of groups. Nothing rejected today becomes accepted: whenever the old check throws (no group used the field's name), the matched group was created under another name, so the new check throws too. Valid mappings are unchanged because the new check throws only on a hit whose group was created under another name. Merges apply the current mapping to the merged field names (PerFieldPostingsFormat.java:171-181), so with the fix, merging segments that an affected version wrote with such a mapping throws the same exception instead of carrying the lost field forward, and new flushes with that mapping are rejected too: the application has to fix its equals (read, not run).
What the checks validated
- Gradle in a scratch worktree at
5adff78bb168, seedDEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (on and off). - With the fix: the repro,
TestPerFieldPostingsFormat(30),TestPerFieldPostingsFormat2(6),TestPerFieldDocValuesFormat(150),TestPerFieldKnnVectorsFormat(61),TestCodecs(3) andTestAddIndexes(39): 290 tests, 0 failures, 16 skipped.:lucene:core:checkGoogleJavaFormatpasses with the fix and the repro in the tree. The whole core module was not run. - Earlier, the hunt's judge ran the same change through a shadowed class: the hunt's first test is then rejected at commit with
IllegalStateException: no suffix for format name: FormatB, expected: 0, and its history version is rejected the same way on main. - A fact-check variant with other group numbers (
a -> VarB(2),w -> VarA(7),x -> VarA(1),y -> VarB(1)), assertions off: with the fix the commit throwsIllegalStateException: no suffix for format name: VarB, expected: 1.
Use a composite key (format name, format) for the groups, so equal formats with different names get separate groups, each written under its own name. The mapping then works instead of being rejected.
Trade-offs
Changes today's two-field behaviour from rejection to success, and touches the Map<PostingsFormat, FieldsGroup> that write and merge iterate (PerFieldPostingsFormat.java:143-195). A bigger change than the bug needs; not built.
State the rule where per-field formats compare formats with equals.
Trade-offs
Does not stop the silent loss; worth adding together with A. Not built.
Downstream, only products that ship their own postings-format classes with an equals across format names could hit it; documents or queries alone cannot trigger it.
A proof-obligation audit of PerFieldPostingsFormat's contract: a per-field mapping the writer accepts must give a segment in which every field's postings can be read back, or be rejected. The group lookup, the attributes and the guard were checked against that obligation, and a one-test public-API JUnit then witnessed a lost field.
The method in full132 words
Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).
- The obligation: a per-field postings mapping that IndexWriter accepts either writes a segment in which every field's postings can be read back, or is rejected. The audit covered
PerFieldPostingsFormat.java:202-205,:217-254(group lookup byequals, numbering per name, the guard, the attributes) and the reader at:300-320. The guard discharges the obligation only when no other group used the field's format name. - A public-API JUnit test then failed on main with assertions on and off and on 10.6.0. The independent reproducer's probe (kept) printed the files, the attributes and a clean CheckIndex.
- An independent reproducer, two executed cross-model reviews, a devil's-advocate verifier and an adjudication reproduced it with their own tests ("Prior reports, history and checks").
No prior report found in apache/lucene issues, PRs and commits (with the JIRA archive), the dev@, java-user@, issues@ and commits@ lists, downstream trackers or the web (searched 2026-11-02). The exception text matched no issue or PR in apache/lucene, Elasticsearch, OpenSearch or Solr. Nearest items: #5162 (introduced the equals keying) and #5165 (open, invites equals overrides).
Searches3 searches
No matching public report was found in the documented searches. Related, not reports: #5162 (LUCENE-4090, the equals keying), #5165 (LUCENE-4093, open), #7031 (LUCENE-5969, made the check always-on; none of its comments covers this case), #5141 (LUCENE-4069, same name with different configurations: the opposite case), #11264 / #432 (LUCENE-10228, a stale vectors suffix on merge, fixed in 9.0). Tracking issue #16937 does not list it. Limits: keyword search; GitHub search returns at most 50 items per query; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-02, re-checked the same day | a scripted prior-report search (20 issue/PR, 3 commit, 2 downstream, 20 mailing-list and 4 JIRA queries; 917 hits, all high and medium overlap read), the finder's earlier run (706 hits), 28 manual GitHub searches, among them an is:pr and an apache/lucenenet search | no report; #5162, #5165, #7031, #5141, #11264 are background |
| Open PRs | 2026-11-02 | the changed files of all 408 open apache/lucene PRs and of the 31 PRs updated in the last 48 h | none touches PerFieldPostingsFormat.java; #16201, #15952, #16092, #16890, #13822 and #13342 touch other perfield files and #15266, #13948, #12314 and #13399 perfield tests; none changes the lookup or the check |
| Mailing lists, JIRA, downstream, web | 2026-11-02 | lists.apache.org phrase searches (dev@, issues@, java-user@, builds@, commits@), JIRA JQL, Elasticsearch, OpenSearch, Solr and Lucene.NET trackers, web queries | the exception text appears only in commit diffs; the all-of-GitHub query's 22 hits are unrelated repositories; nothing about this bug |
Present since per-field groups were keyed by equals (LUCENE-4090, Robert Muir, 2012-05-30; first release 4.0.0). LUCENE-5969 (5.0.0) made the guard always-on with the same condition. Every release run, 4.0.0-ALPHA to 10.6.0, and main fail; not a regression and not fixed upstream.
Versions tested6 rows
Introduced by LUCENE-4090 "PerFieldPostingsFormat cannot use name as suffix" (#5162; trunk 8963cf411bd, branch_4x ad7ddcc600a, author date 2012-05-30; an svn-era patch, no PR). It changed the group map from IdentityHashMap to HashMap, took the number from the matched group while the format attribute stayed format.getName(), and guarded a hit with assert suffixes.containsKey(formatName). First release 4.0.0-ALPHA (released 2012-07-03), first GA 4.0.0 (released 2012-10-12), per dev-tools/doap/lucene.rdf.
LUCENE-5969 (#7031; 98e9122375c, 2014-09-27, on the lucene5969 feature branch, which reached trunk through the merge 35160fd56ac on 2014-10-05; branch_5x 33bdf278cba; first released in 5.0.0) turned the assert into the always-on IllegalStateException with the same condition. Later refactors (LUCENE-5123, 7456, 9045, 9570, #15457) kept the logic. All 124 release tags from 4.0.0-alpha to 10.6.0 contain the same lookup and guard. Not fixed upstream: the last commit on the file is b6ca26f1212 (2026-06-30, unrelated), upstream main checked at 1c2072f0284 (2026-11-02). Release jars ran on JDK 25, not each release's original JDK.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | gradle: fails with assertions on and off; passes with fix A |
| 10.6.0, 9.12.3 | affected | this test (assertions off): docFreq(y:v) 0 |
| 4.0.0-ALPHA, 4.0.0, 4.10.4, 5.0.0, 6.6.6, 7.7.3, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0 to 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.6.0 | history version | the same three fields: commit succeeds, y has a's attributes, docFreq(y:v) 0 |
| 5.0.0, main | two fields only | x and y alone: IllegalStateException at commit, the intended rejection |
| 4.0.0, 4.10.4 | two fields only | 4.0.0 with assertions on: AssertionError at flush; 4.0.0 and 4.10.4 with them off: the commit succeeds and opening the index throws FileNotFoundException |
| 3.6.2 | no API | no codec API (org.apache.lucene.codecs does not exist) |
A finder, an independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6, refute and intended lenses), a devil's-advocate verifier, an adjudication, an audit and a final repro check reproduced it and agree on the root cause. This doc's gradle runs reproduced it on main and validated the fix.
Checks and verdicts329 words
The cross-model reviews ran on an earlier main, ea57360912ed, where the perfield package is byte-identical to 5adff78bb168 (the intended lens also on 10.6.0); a separate re-run of the intended reviewer's own test on 5adff78bb168 fails. The other checks on main ran on 5adff78bb168; release runs used each release's own jars.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Own test fails on main (assertions on and off) and 10.6.0. It also reported a scratch probe (attributes, files, clean CheckIndex) and a non-equal control that passes; that output was not kept, and the kept probe is the independent reproducer's. |
| independent reproducer | real | Own test fails on main, 10.6.0, 10.5.2 and 9.12.3; its probe (kept) shows the files, the attributes, terms(y) absent and a clean level-3 CheckIndex; the two-field control is rejected. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own 3-test class: only the cross-name case fails, assertions on and off; the non-equal control passes and the two-field case is rejected. |
| cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high) | Intended-behaviour argument rated moderate and rejected; own test fails on main and 10.6.0. |
| devil's-advocate verifier | real | Built the strongest not-a-bug case (broken user equals, experimental API, mechanical assert conversion), rated it moderate and rejected it. |
| adjudication | confirmed, Low | Re-ran the finder's test on main with a fresh seed: fail; its shadowed fix (fix A) turns the loss into the existing exception. |
| separate re-runs, audit and final repro check | reproduced; confirmed | Five re-runs on main with five seeds, assertions on and off, all fail; the one-test repro fails on main and passes with the fix; the final check set the impact class to minor, not data corruption. |
| this doc | reproduced; fix A validated | Gradle on main: fails without the fix, passes with it (assertions on and off); 290 tests of the closest suites pass with it; the same test fails on 10.6.0 and 9.12.3. |
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 467 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,369 words
- Registry title
- PerFieldPostingsFormat's 'no suffix for format name' check misses equal formats with different names when another field already used the second name, so the commit succeeds and that field's postings cannot be read
- Area
- codecs / per-field postings format (PerFieldPostingsFormat field-to-group mapping)
- Priority
- Low, rank 888: One field's committed postings are silently unreadable while CheckIndex reports the index clean, in every release since 4.0.0. It needs custom codec code whose equals() treats two differently named postings formats as equal, plus a particular field order, and no shipped format does that, so it is contrived.
- Impact class
- minor
- Impact note
- Only applications whose custom per-field postings codec uses two SPI-registered PostingsFormat classes with different names whose equals() treats instances of the two as equal are exposed (no format shipped in Lucene overrides equals, so the default codec and stock per-field mappings are unaffected), and only when another field with a non-equal format of the second name is visited before the colliding field; then, with one document and with -ea and -da alike, the commit succeeds, that field's postings cannot be read (its terms are null, queries on it match nothing) and CheckIndex reports the index clean, on every release from 4.0.0 through 10.6.0 and on main, with no scale needed.
- Summary
- You give a per-field postings codec two SPI-registered postings formats with different names whose equals() compares only a setting, map field a to the second format with setting 2, field x to the first format with setting 1 and field y to the second format with setting 1, index one document and commit. You expect either the IllegalStateException 'no suffix for format name' that Lucene already throws when only x and y exist, or a segment in which every field's terms can be found. Instead the commit succeeds: y's postings are written into x's group while y's recorded format attributes point to a's group, so a TermQuery on y finds 0 documents and CheckIndex reports the index clean.
- Introduced
- 8963cf411bdfb645e022a19a5a3cae5408a6024d on trunk and its branch_4x copy ad7ddcc600abe16832684bdf5c4521f37f36338a, both 'LUCENE-4090: PerFieldPostingsFormat cannot use name as suffix' (now apache/lucene#5162; an svn-era JIRA patch by Robert Muir, no pull request): the per-field group map changed from an IdentityHashMap to a HashMap keyed by PostingsFormat.equals, the SUFFIX attribute came from the matched group while the FORMAT attribute stayed format.getName(), and a group hit was guarded only by 'assert suffixes.containsKey(formatName)'. LUCENE-5969 (#7031; 98e9122375c, 2014-09-27, on the lucene5969 feature branch, merged to trunk as 35160fd56ac and to branch_5x as 33bdf278cba on 2014-10-05; first released in 5.0.0) turned that assert into the always-on IllegalStateException 'no suffix for format name' with the same condition, which has not changed since [read: git log, pickaxe and blame in the history clone; exec: the history repro fails on 4.0.0-ALPHA and every later executed release]. Not a regression: before LUCENE-4090 the identity-keyed code was never released (2012-05-30 (author dates: trunk 8963cf411bd 20:15:58Z, branch_4x ad7ddcc600a 20:26:15Z))
- First affected version
- 4.0.0-ALPHA (pre-release, tag dated 2012-06-29) [exec, -ea and -da]; first GA release 4.0.0 (2012-10-11) [exec, -ea and -da]; 3.x has no codec API (3.6.2: compile error, package org.apache.lucene.codecs does not exist) [exec]
- Affected versions
- every release from 4.0.0-ALPHA through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT). [exec] The history repro fails (commit succeeds, docFreq(y:vy) = 0) on 4.0.0-ALPHA, 4.0.0, 4.10.4, 5.0.0, 6.6.6, 7.7.3, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0 to 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.6.0 and main with -ea, and also with -da on 4.0.0-ALPHA, 4.0.0, 9.12.3, 10.6.0 and main; the independent reproduction's test fails on main (-ea, -da), 10.6.0, 10.5.2 and 9.12.3 (-ea); the finder's test fails on main (-ea, -da) and 10.6.0 (-ea); the intended-lens cross-model review's own test fails on 10.6.0 (-ea). [read] All 124 release tags 4.0.0-alpha to 10.6.0 in the history clone contain the equals-keyed lookup and the name-only guard. Not new in 10.6. Not fixed upstream: the last commit on PerFieldPostingsFormat.java is b6ca26f1212 (author date 2026-06-30, unrelated), with upstream main at 1c2072f0284 (author date 2026-11-02) [check through the GitHub API, 2026-11-02]. Executed on Linux aarch64 with JDK 25.0.3 only (release jars on JDK 25, not each release's original JDK).
- 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: proof-obligation audit
- Upstream and prior reports
- none found (2026-11-02, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): a scripted prior-report search over 20 GitHub issue and PR queries on apache/lucene, 3 commit queries, 2 downstream-repository queries, 20 mailing-list queries (dev@, java-user@, issues@, commits@), 4 JIRA-archive queries and 6 web queries (917 distinct hits, 1 high and 9 medium overlap, all read), the finder's earlier run (706 hits), 28 manual GitHub searches on apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET and all of GitHub (the exception text 'no suffix for format name' appears in no issue or PR anywhere), the changed-file lists of all 408 open apache/lucene PRs (none touches PerFieldPostingsFormat.java; the open PRs touching other perfield files, #16201, #15952, #16092, #16890, do not change the group lookup or the check) and of the 31 PRs updated in the last 48 h, lists.apache.org phrase searches (the exception text only in commit diffs on commits@), JIRA JQL and 8 web queries: no report of equal postings formats with different names passing the check or of a field's postings being lost this way. Same-day recheck: GitHub search 'no suffix for format name' and 'PerFieldPostingsFormat' updated since 2026-11-01 on apache/lucene: 0 each; the newest PRs (#16956 to #16965) touch no perfield file; no commit on the perfield package since 2026-11-01; tracking issue #16937 does not mention it. Related, not reports: #5162 (LUCENE-4090, introduced the equals keying), #5165 (LUCENE-4093, open, invites equals overrides), #7031 (LUCENE-5969, made the check always-on), #11264/#432 (LUCENE-10228, stale vectors suffix on merge, fixed in 9.0), #5141 (LUCENE-4069, same name with different configurations, the opposite case).
- Lucene commit tested
- main
5adff78bb168(2026-11-02) through gradle on 2026-11-03 in a scratch worktree with assertions on and off, without and with fix A (the repro, the four perfield suites,TestCodecs,TestAddIndexes,checkGoogleJavaFormat); the hunt's copy of the same test on the 10.6.0 and 9.12.3 release jars (assertions off); the hunt's history version of it on 19 releases from 4.0.0-ALPHA to 10.6.0; a fact-check variant with other group numbers (a -> VarB(2),w -> VarA(7),x -> VarA(1),y -> VarB(1)) on main with assertions on and off, and with fix A (assertions off). Only Linux aarch64 with OpenJDK 25.0.3 was run.
- Finding record
lucene/core/src/java/org/apache/lucene/codecs/perfield/PerFieldPostingsFormat.java:199-260 (at 5adff78bb168) (buildFieldsGroupMapping: :202 HashMap keyed by PostingsFormat, :205 suffixes keyed by name, :217 formatName = format.getName(), :219 group lookup by equals, :224-237 suffix bump and new group, :240-243 the only guard on a hit, suffixes.containsKey(formatName), fix site; :248-253 attributes = (formatName, matched group's suffix)), :300-320 (reader resolves each field by forName(formatName) and formatName_suffix), :63 (@lucene.experimental)lucene/core/src/java/org/apache/lucene/codecs/PostingsFormat.java:39 (at 5adff78bb168) (@lucene.experimental); lucene/core/src/java/org/apache/lucene/codecs/perfield/PerFieldDocValuesFormat.java:191 and :229, PerFieldKnnVectorsFormat.java:157 and :179 (siblings: same equals keying, assert-only guard; side remark, not claimed)https://github.com/apache/lucene/issues/5162, https://github.com/apache/lucene/issues/5165, https://github.com/apache/lucene/issues/7031, https://github.com/apache/lucene/issues/5141, https://github.com/apache/lucene/issues/11264, https://github.com/apache/lucene/issues/16937, read 2026-11-02; GitHub API commits on PerFieldPostingsFormat.java and the newest apache/lucene PRs, read 2026-11-02
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/codecs/perfield/PerFieldPostingsFormat.java:143, (at 5adff78bb168) :181, :199-260 (:202, :205, :217, :219, :224-237, :240-243, :248-253), :303-318lucene/core/src/java/org/apache/lucene/index/FreqProxFields.java:38 (at 5adff78bb168)
- Who is affected
lucene/core/src/java/org/apache/lucene/codecs/perfield/PerFieldPostingsFormat.java:63 (at 5adff78bb168) (@lucene.experimental); lucene/core/src/java/org/apache/lucene/codecs/PostingsFormat.java:39 (@lucene.experimental)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/codecs/perfield/PerFieldPostingsFormat.java:240-243; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/codecs/PostingsFormat.java:27-39, :66lucene/core/src/test/org/apache/lucene/codecs/perfield/TestPerFieldPostingsFormat2.java:262, (at 5adff78bb168) :279https://github.com/apache/lucene/issues/5162, https://github.com/apache/lucene/issues/5165, https://github.com/apache/lucene/issues/7031 (read 2026-11-02)
- Suggested fix
lucene/core/src/java/org/apache/lucene/codecs/perfield/PerFieldPostingsFormat.java:143-195, (at 5adff78bb168) :199-260; PerFieldDocValuesFormat.java:191, :229; PerFieldKnnVectorsFormat.java:157, :179
- Diagrams
lucene/core/src/java/org/apache/lucene/codecs/perfield/PerFieldPostingsFormat.java:199-260, (at 5adff78bb168) :303-318
- TestPerFieldEqualFormatNames.java (lucene/core, package org.apache.lucene.codecs.perfield, 1 test):
repro/LFV-1125/TestPerFieldEqualFormatNames.java(124 lines) - META-INF/services/org.apache.lucene.codecs.PostingsFormat (lucene/core/src/test, registers the two test formats):
repro/LFV-1125/org.apache.lucene.codecs.PostingsFormat(17 lines)
The diagram illustrates the repro's one document (main 5adff78bb168, PerFieldPostingsFormat.java:199-260 and :303-318).
assets/LFV-1125/bug.png(467 KB)
