All findings · Lucene finding · LFV-1125 · perfield-postings-incomplete-cross-name-suffix-check

PerFieldPostingsFormat's "no suffix for format name" check misses equal formats with different names once another field used the second name: the commit succeeds and a field's postings cannot be read

In PerFieldPostingsFormat, two differently named postings formats that compare equal are rejected at commit, unless another field used the second name: then the commit succeeds and one field's postings cannot be read (silently in the repro; with other group numbers the index fails to open). Needs custom codec code.

Summary

Priority
Low, rank 888One 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
minor
Since
4.0.0 (2012, LUCENE-4090)introduced 2012-05-30 · over 14 years
Affected
every release run from 4.0.0-ALPHA to 10.6.0 (19) and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of PerFieldPostingsFormat's contract, witnessed by an executable repro

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

Background: per-field postings groups

  1. 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, ...
  2. 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>.
  3. 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.
Diagram of the bug: fields a, x and y of one document map to formats EqB(2), EqA(1) and EqB(1). Field a creates file group EqB_0 and field x creates EqA_0. EqB(1) equals EqA(1), so y's postings are written into EqA_0, while y records the attributes (EqB, 0). The check 'was the name EqB used by any group?' passes because of field a, the commit succeeds, and the reader opens EqB_0 for y and finds no terms: docFreq(y:v) = 0, and CheckIndex reports the index clean.
One document, three fields, two file groups: 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.

The finding, step by step

  1. Your codec maps field a to format EqB(2), x to EqA(1) and y to EqB(1). EqA and EqB are your own SPI-registered formats whose equals compares only the number, so EqB(1) equals EqA(1). You index one document with a:v, x:v, y:v and commit.
  2. You expect the commit to throw IllegalStateException: no suffix for format name: EqB, as it does when the document has only x and y; or else docFreq(y:v) to be 1.
  3. Lucene groups fields by equals, so y joins x's file group EqA_0 and its postings are written there. But y records its own format name with that group's number: (EqB, 0), which is a's group.
  4. The check only asks whether any group used the name EqB. Field a did, so it passes and the commit succeeds.
  5. The reader opens EqB_0 for y, which holds only a's terms: docFreq(y:v) is 0 while x:v is found, with assertions on or off, and CheckIndex reports no problems.

Who is affected

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").

  1. Groups are found by equals. formatToGroupBuilders is a HashMap<PostingsFormat, ...> (:202); formatToGroupBuilders.get(format) (:219) uses the format's equals and hashCode.
  2. Numbers are counted per name. On a miss, the next number for format.getName() comes from suffixes (:205, :224-230), and the new group writes files <name>_<number> (:232-237).
  3. 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.
  4. 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).
  5. The reader trusts those attributes: PostingsFormat.forName(formatName) and segment suffix formatName + "_" + 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 PerFieldPostingsFormat subclass or a codec's getPostingsFormatForField hook) that use two of their own SPI-registered PostingsFormat classes with different names whose equals treats 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.experimental extension 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.

Minimal repro

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.

TestPerFieldEqualFormatNames.java (lucene/core, package org.apache.lucene.codecs.perfield, 1 test)124 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.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")));
      }
    }
  }
}
Command
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
commit throws IllegalStateException: no suffix for format name: EqB, as without field a; or docFreq(y:v) is 1.
Observed
commit succeeds and docFreq(y:v) is 0 (AssertionError: expected:<1> but was:<0>), with assertions on and off.

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

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)

META-INF/services/org.apache.lucene.codecs.PostingsFormat (lucene/core/src/test, registers the two test formats)17 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.

org.apache.lucene.codecs.perfield.TestPerFieldEqualFormatNames$EqA
org.apache.lucene.codecs.perfield.TestPerFieldEqualFormatNames$EqB

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (only this platform was run)
Command (environment)
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

Observed output

# 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

Why it looks unintended

  • The always-on check can fail only for equal formats with different names, and rejects this mapping for two colliding fields alone.
  • Format equals overrides 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-243 can fail only when a field joins a group created under another name (see the mechanism), and it does reject the two-field mapping with IllegalStateException.
  • Contract. The group map became equals-keyed on purpose in LUCENE-4090 (#5162); its author (Robert Muir) wrote there that equals on postings formats is "optional and a new optimization". LUCENE-4093 (#5165, still open) asks for such overrides. No javadoc of PostingsFormat, NamedSPI or the per-field hooks says equal formats must have equal names.
  • Tests. TestPerFieldPostingsFormat2.testSameCodecDifferentInstance and testSameCodecDifferentParams (:262, :279) use same-name instances only; no test combines an equals override with two names.
  • History. LUCENE-5969 (#7031, 98e9122375c) turned the original assert 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.

Suggested fix

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.

Check the matched group's own name, not any group's (fix A) (validated by the checks) (recommended)

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, seed DEADBEEF: 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) and TestAddIndexes (39): 290 tests, 0 failures, 16 skipped. :lucene:core:checkGoogleJavaFormat passes 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 throws IllegalStateException: no suffix for format name: VarB, expected: 1.

Key groups by name and format

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.

Document that equal formats must have equal names

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 note

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.

How it was found

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 by equals, 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").

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-02, re-checked the same daya 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 searchno report; #5162, #5165, #7031, #5141, #11264 are background
Open PRs2026-11-02the changed files of all 408 open apache/lucene PRs and of the 31 PRs updated in the last 48 hnone 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, web2026-11-02lists.apache.org phrase searches (dev@, issues@, java-user@, builds@, commits@), JIRA JQL, Elasticsearch, OpenSearch, Solr and Lucene.NET trackers, web queriesthe exception text appears only in commit diffs; the all-of-GitHub query's 22 hits are unrelated repositories; nothing about this bug

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A
10.6.0, 9.12.3 affectedthis 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 versionthe same three fields: commit succeeds, y has a's attributes, docFreq(y:v) 0
5.0.0, main two fields onlyx and y alone: IllegalStateException at commit, the intended rejection
4.0.0, 4.10.4 two fields only4.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 APIno codec API (org.apache.lucene.codecs does not exist)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOwn 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 realOwn 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 realBuilt the strongest not-a-bug case (broken user equals, experimental API, mechanical assert conversion), rated it moderate and rejected it.
adjudication confirmed, LowRe-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; confirmedFive 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 validatedGradle 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.

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

Finding record

LFV-1125, snapshot of 2026-11-06

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.

Lucene and upstream sources

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-318
  • lucene/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, :66
  • lucene/core/src/test/org/apache/lucene/codecs/perfield/TestPerFieldPostingsFormat2.java:262, (at 5adff78bb168) :279
  • https://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

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

Repro files

About the diagrams

The diagram illustrates the repro's one document (main 5adff78bb168, PerFieldPostingsFormat.java:199-260 and :303-318).

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.