All findings · Lucene finding · LFV-561 · checkindex-ignores-hnsw-graph-errors

CheckIndex reports an index clean after its HNSW graph test fails, unless failFast is set

On Lucene main, CheckIndex at level 2 or 3 prints test: hnsw graphs.........ERROR for a broken HNSW graph and then reports the index clean ('No problems were detected', exit code 0). Unless failFast is on, the sub-test's error never reaches the verdict; only main and branch_11x have the check.

Summary

Priority
Low, rank 752Unreleased CheckIndex at a non-default level reports clean after an HNSW graph error, hiding writer bugs without touching data and audit-downgraded, so it follows the other CheckIndex rows LFV-089 and LFV-543.
Impact
wrong error
Since
#13984 (11eb2c86979e, main, 2025-01-08)introduced 2025-01-08 · about 1 year
Affected
main and branch_11x (11.0.0-SNAPSHOT); no release has the HNSW sub-test
Status
documented
Found by
non-formal: release-jar differential probe, then code reading

AI-assisted page: this finding was first signalled by code reading or testing 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

The HNSW sub-test catches its own exception, prints ERROR and stores it. Every other sub-test's stored error is rethrown by testSegment, which marks the segment FAILED; the HNSW one is not, so the final verdict never sees it.

Background

  1. CheckIndex levels. Level 1, the command-line default, checks file checksums. Level 2 adds logical integrity checks, one sub-test per part of each segment (postings, doc values, vectors, ...); level 3 adds slow checks. A failed sub-test makes its segment FAILED, the index not clean, and the exit code 1; -exorcise can then drop that segment.
  2. failFast. Off by default: CheckIndex keeps checking the other segments and reports at the end. On: it throws at the first problem. Its javadoc describes only that choice.
  3. The HNSW graph sub-test (main only, #13984). For each vector field it walks the graph and throws on four errors: a node out of range, a neighbour not on the node's level, neighbours out of order, a repeated neighbour. Connectedness is only printed.
Diagram of the bug: CheckIndex at level 2 on the 4-vector index. The per-segment sub-tests' stored errors feed a rethrow chain that marks a segment FAILED; the HNSW graph sub-test prints 'ERROR: Field v has repeated neighbors of node 0 with value 1' and stores the error, but the chain has no clause for it, so the run ends with 'No problems were detected with this index.', clean = true and exit code 0.
A CheckIndex run on the 4-vector index: every sub-test's stored error goes through the rethrow chain that marks a segment FAILED, but the HNSW graph sub-test's ERROR bypasses it, so the run still ends 'No problems were detected', clean, exit code 0. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. On main, index 4 documents with 4-dimensional vectors and the default HNSW format, its tiny-segment threshold set to 0 so 4 documents get a graph. On level 0, node 0 links to nodes 1, 2 and 3. Change one byte so its list reads 1, 1, 2 (a repeated neighbour, as writer bugs #14127 and #15467 produced) and rewrite the checksum.
  2. Run CheckIndex at level 2 (-level 2 on the command line, or setLevel(2)), with failFast off as by default. It prints test: hnsw graphs.........ERROR: ... has repeated neighbors of node 0 with value 1.
  3. You expect the segment to count as bad, as for every other sub-test: Status.clean false, exit code 1, and -exorcise able to act. With setFailFast(true) such an index does throw CheckIndexException.
  4. Instead the run ends with 'No problems were detected with this index.': clean true, 0 bad segments, exit code 0. The sub-test only stores its exception, and the per-segment rethrow chain that fails a segment for every other sub-test has no clause for it.

Who is affected

Anyone on main (and branch_11x) who runs CheckIndex at level 2 or 3 without failFast: the command-line tool with -level 2 or 3 (it has no failFast option), setLevel(2|3) callers, tests using TestUtil.checkIndex(dir). It matters only once a graph is already broken, for example by a writer bug; data and search are unaffected.

Mechanism, terms and numbers862 words

On main, CheckIndex at level 2 or 3 includes an HNSW graph sub-test (#13984) that rejects four kinds of broken graph: a node out of range, a neighbour that is not on the node's level, neighbours out of order, and repeated neighbours. When it finds one and failFast is off (the default, and the only mode of the command-line tool), it prints test: hnsw graphs.........ERROR: ... and a stack trace, stores the exception in hnswGraphsStatus.error and returns. testSegment turns a stored sub-test error into a failed segment through its "Rethrow the first exception we encountered" chain, which has a clause for every other sub-test but none for the HNSW one, and nothing else reads hnswGraphsStatus.error. So the segment is never counted as bad: the run ends with "No problems were detected with this index.", Status.clean true, numBadSegments 0, exit code 0, and -exorcise does nothing. With failFast on, the same index throws CheckIndexException, so the setting meant to choose between stopping early and checking more segments decides whether the damage counts at all. No release has the sub-test; main and branch_11x do, so the bug would first ship in 11.0.0. Search, reads, merges and writes are unaffected; the wrong output is CheckIndex's verdict.

The code (main 645d4fe14a20, CheckIndex.java)

  1. Level gate. :1114: if (level >= Level.MIN_LEVEL_FOR_INTEGRITY_CHECKS) (= 2, :4754); inside it :1143-1144: segInfoStat.hnswGraphsStatus = testHnswGraphs(reader, infoStream, failFast);.
  2. The sub-test stores its failure. testHnswGraphs (:2926-2966) calls testHnswGraph for every HnswGraphProvider field, which throws CheckIndexException for the four conditions (:3016-3024 node range, :3031-3042 neighbour not on its level, :3046-3057 out of order, :3058-3065 repeated). Its catch (:2956-2964): if failFast, rethrow; else print ERROR: + the exception, set status.error, print the stack trace, return.
  3. The verdict never sees it. :1158-1197, under "Rethrow the first exception we encountered / This will cause stats for failed segments to be incremented properly", rethrows the stored error of live docs, field infos, norms, postings, stored fields, term vectors, doc values, points, vectors (:1185-1187), index sort and soft deletes. There is no hnswGraphsStatus clause, and nothing in lucene/ reads hnswGraphsStatus.error.
  4. No error, no bad segment. Only the outer catch (Throwable t) (:1200-1211) sets segInfoStat.error, prints FAILED and "exorciseIndex() would remove reference to this segment"; it is never reached. processSegmentInfoStatusResult (:975-985) counts a segment as bad only when segmentInfoStatus.error != null, otherwise keeps it for -exorcise's new commit.
  5. Clean verdict. :933-934 sets clean = true when no segment is bad; :957-958 prints "No problems were detected with this index."; doCheck exorcises only when clean == false and returns clean ? 0 : 1 (:4902-4927). In the repro the HNSW error is the only problem; another bad segment, or an invalid segment-name counter (:945-955), still makes clean false.
  6. Who runs with failFast off. The field defaults to false (:515); the command-line tool has no failFast option (:4787-4819); TestUtil.checkIndex(dir) and checkIndex(dir, level) pass false (TestUtil.java:302-308), and BaseDirectoryWrapper.close uses them (:44-45). Only MockDirectoryWrapper.close checks with failFast=true (:917); the stack traces quoted in #14127 and #15467 both run through it.
  7. Nuance (code reading, not run). The sub-test catches Exception only, so any Exception raised during the walk is stored and masked: the four CheckIndexExceptions, and also, for example, an IOException from the graph reader. An Error still reaches the outer catch (Throwable t) and fails the segment. That splits the node-range check: testHnswGraph first puts every node of the level into a FixedBitSet (:3006-3010), whose set asserts the index is in range (FixedBitSet.java:296). With assertions on, an out-of-range node throws an AssertionError before the range check at :3016 and fails the segment (not masked); with assertions off it reaches that check, or an ArrayIndexOutOfBoundsException, and is masked. A neighbour id outside the graph meets the same assertion in FixedBitSet.get (:286). The three conditions that were run (repeated, out-of-order and off-level neighbours, all with in-range ids) are masked with assertions on and off. Only fields whose vectors reader is an HnswGraphProvider are checked (:2941-2943).

The other outputs (the study's verifier, not the repro)

1,000 vectors of dimension 8 written by the stock default codec, one neighbour edited in .vex at the same byte length, CRC32 footer recomputed:

check of the edited indexresult
level 2, failFast off: "repeated neighbors"ERROR printed, then clean=true, numBadSegments=0
level 2, failFast off: "node: 4 with a neighbor 60 which is not on its level (1)"ERROR printed, then clean=true, numBadSegments=0
command line, -level 2 (doCheck)ERROR printed, "No problems were detected", returns 0
level 2, one thread, setFailFast(true)throws the graph error itself: CheckIndexException: Field "vector" has repeated neighbors of node 0 with value 1

The study's finder showed the same for "neighbors out of order for node 0: 85<170", through a codec-SPI KnnVectorsFormat whose graph view lists neighbours out of order. With several threads a failFast check gets the graph error wrapped, as CheckIndexException: Segment _0 check failed. (:919-924; the diagram probe, 4 threads).

Exposure and likelihood298 words
  • Default vs opt-in. The level is opt-in: the programmatic default is 0 (the level field has no initialiser, CheckIndex.java:499) and the command-line default is 1 (:4748); the usage text says "Use -level with value of '2' or higher if you also want to check segment file contents". failFast off is the default. Two downstream engines' shard checks (Elasticsearch and OpenSearch Store.checkIndex) do not call setLevel, so the HNSW sub-test does not run there.
  • What happens. A broken HNSW graph passes a deep health check: clean verdict, exit code 0, nothing for -exorcise to remove. The ERROR line and the stack trace are printed, so a person reading the whole log can see it; a script or caller that reads Status.clean or the exit code cannot.
  • When a graph fails the check. Only after something else went wrong: a writer bug (two reached main in 2025, #14127 and #15467, both fixed in the writer; the stack traces in both issues run through the test framework's failFast check in MockDirectoryWrapper.close), a checksum-consistent edit of index files, or a valid 9.12.0-10.1.0 graph that repeats a neighbour (the companion bug LFV-543, where the masked "clean" happens to be right). Main's stock writer sorts each neighbour list and drops repeats before writing it (Lucene99HnswVectorsWriter.java:575, :585-587); that says nothing about other codecs or external writers.
  • Scale. None needed: 4 vectors reproduce it, and every segment whose graph check fails with an Exception is masked the same way.
  • What it does not do. No hit, score, document or file changes; with failFast off the graph error does not escape checkIndex(), it is only printed and stored.
  • How likely: rare. It takes a deep check without failFast on main and a graph that is already broken.

Minimal repro

One JUnit test on the public API: write 4 vectors with the default HNSW format (tiny-segment threshold 0), change one byte of .vex so node 0 repeats a neighbour, rewrite the checksum, run CheckIndex at level 2 with one thread and failFast off, and assert clean == false. Deterministic; a precondition checks that the HNSW sub-test really failed.

TestCheckIndexHnswGraphError.java (one test)82 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.index;

import static java.nio.charset.StandardCharsets.UTF_8;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.util.Arrays;
import org.apache.lucene.codecs.CodecUtil;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestCheckIndexHnswGraphError extends LuceneTestCase {

  public void testBrokenHnswGraphIsNotReportedClean() throws IOException {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc =
          new IndexWriterConfig()
              .setCodec(TestUtil.alwaysKnnVectorsFormat(TestUtil.getDefaultKnnVectorsFormat()))
              .setUseCompoundFile(false);
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (int i = 0; i < 4; i++) {
          float[] v = new float[4];
          v[i] = 1;
          Document doc = new Document();
          doc.add(new KnnFloatVectorField("v", v));
          w.addDocument(doc);
        }
      }
      String vex = Arrays.stream(dir.listAll()).filter(f -> f.endsWith(".vex")).findFirst().get();
      byte[] bytes;
      int node0SecondDelta;
      try (IndexInput in = dir.openInput(vex, IOContext.DEFAULT)) {
        node0SecondDelta = CodecUtil.readIndexHeader(in).length + 2;
        bytes = new byte[(int) in.length()];
        in.seek(0);
        in.readBytes(bytes, 0, bytes.length);
      }
      bytes[node0SecondDelta] = 0;
      dir.deleteFile(vex);
      try (IndexOutput out = dir.createOutput(vex, IOContext.DEFAULT)) {
        out.writeBytes(bytes, bytes.length - CodecUtil.footerLength());
        CodecUtil.writeFooter(out);
      }
      ByteArrayOutputStream log = new ByteArrayOutputStream();
      try (CheckIndex checker = new CheckIndex(dir)) {
        checker.setLevel(CheckIndex.Level.MIN_LEVEL_FOR_INTEGRITY_CHECKS);
        checker.setThreadCount(1);
        checker.setInfoStream(new PrintStream(log, true, UTF_8));
        CheckIndex.Status status = checker.checkIndex();
        assertNotNull(log.toString(UTF_8), status.segmentInfos.get(0).hnswGraphsStatus.error);
        assertFalse(
            "expected clean=false after the HNSW graph test failed, got clean=true:\n"
                + log.toString(UTF_8),
            status.clean);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestCheckIndexHnswGraphError -Ptests.seed=DEADBEEF
Expected
status.clean == false, since the HNSW sub-test reported "repeated neighbors of node 0 with value 1".
Observed
AssertionError: expected clean=false after the HNSW graph test failed, got clean=true: the log shows the ERROR, then "No problems were detected with this index." (assertions on and off).

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

More files, environment, full output304 words

TestUtil.getDefaultKnnVectorsFormat() is Lucene99HnswVectorsFormat(DEFAULT_MAX_CONN, DEFAULT_BEAM_WIDTH, 0) (TestUtil.java:1426-1428), so even a 4-document segment gets a graph. Node 0's level-0 list is the first entry after the .vex index header, stored as vInts 3, 1, 1, 1 (count, then deltas: neighbours 1, 2, 3); setting the second delta to 0 gives 1, 1, 2. CodecUtil.writeFooter writes a fresh checksum, so test: check integrity passes. The edit stands in for a writer bug; main's writer de-duplicates neighbours, so this is not the old-index case of the companion bug.

The assertNotNull on hnswGraphsStatus.error makes sure the HNSW sub-test really failed, so the test cannot fail for another reason. 20 of 20 random seeds fail the same way with assertions on.

Regression test: this same test; it passes with the fix and fits as one more method in TestCheckIndex.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. No release has the HNSW sub-test (the test does not compile there)
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/TestCheckIndexHnswGraphError.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestCheckIndexHnswGraphError -Ptests.seed=DEADBEEF
# the same result with -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestCheckIndexHnswGraphError > testBrokenHnswGraphIsNotReportedClean FAILED
    java.lang.AssertionError: expected clean=false after the HNSW graph test failed, got clean=true:
    Checking index with threadCount: 1
    0.00% total deletions; 4 documents; 0 deletions
    ...
        test: vectors.............OK [1 fields, 4 vectors] [took 0.024 sec]
        test: hnsw graphs.........ERROR: org.apache.lucene.index.CheckIndex$CheckIndexException: Field "v" has repeated neighbors of node 0 with value 1
    org.apache.lucene.index.CheckIndex$CheckIndexException: Field "v" has repeated neighbors of node 0 with value 1
    	at org.apache.lucene.index.CheckIndex.testHnswGraph(CheckIndex.java:3059)
    	...
    No problems were detected with this index.

    Took 0.096 sec total.
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:3E199C6F7C78FE7D]:0)
        ...
        at org.apache.lucene.index.TestCheckIndexHnswGraphError.testBrokenHnswGraphIsNotReportedClean(TestCheckIndexHnswGraphError.java:75)

# with either fix: 1 test, 0 failures (assertions on and off)

Why it looks unintended

  • Status.clean means "no problems were found"; the tool exits 1 when the index "has any corruption". Here it prints ERROR and exits 0.
  • setFailFast only chooses between throwing at once and checking more segments; here it flips the verdict.
  • #13984 lists the four conditions under "exceptions are thrown if"; #128 fixed the same missing clause for other sub-tests in 2021.
The full argument262 words
  • The contract. Status.clean: "True if no problems were found with the index." (CheckIndex.java:130). doCheck: "@return 0 iff the index is clean, 1 otherwise" (:4884). The tool: "This tool exits with exit code 1 if the index cannot be opened or has any corruption, else 0." (:4661-4662, usage text :4867-4868).
  • failFast means "stop early". setFailFast: "If true, just throw the original exception immediately when corruption is detected, rather than continuing to iterate to other segments looking for more corruption." (:517-520).
  • The chain says what it is for. "Rethrow the first exception we encountered / This will cause stats for failed segments to be incremented properly" (:1158-1159).
  • The PR that added the check called these errors. In #13984 the author (Ben Chaplin) wrote "I chose not to throw an exception for disconnectedness" and then "So overall, exceptions are thrown if:" followed by the four conditions. A reviewer (Michael McCandless) called the missing coverage "a blind spot for CheckIndex today, which is scary". Nobody discussed the final verdict or the rethrow chain.
  • The same omission was fixed before. Before LUCENE-9662 (#128, 424192e1704, 2021), testVectors and testSort stored their errors but the chain stopped at points; #128 added the Vectors, Index Sort and Soft Deletes clauses (a reviewer asked "were we failing to CheckIndex soft deletes before this?"). The HNSW sub-test arrived in 2025 with the same catch-and-store pattern and no clause.
  • Tests. No upstream test references hnswGraphsStatus or testHnswGraphs, and none asserts a verdict after a failed HNSW check.
The devil's-advocate check280 words

The strongest case: the HNSW block is a diagnostic ("Add HNSW graph checks and stats to CheckIndex" in CHANGES); the ERROR and stack trace are printed, so the problem is reported; level 2 is opt-in and CheckIndex is @lucene.experimental; and the masking is what keeps valid 9.12.0-10.1.0 indexes, whose repeated neighbours upstream calls "benign" (#14127), from being declared broken and exorcised.

Why it fails: the author separated the informational statistic (connectedness) from the four conditions that throw, and those throw the same CheckIndexException as every other integrity sub-test. The documented outputs (clean, the exit code, -exorcise) are what scripts and callers read, and the run contradicts itself ("ERROR", then "No problems"). failFast is documented only as stopping early, and the default mode is no misuse. The old-index point is an interaction with the companion bug, not intent: the remedy proposed upstream for old graphs is an index version gate (msokolov on #14127: "re-enable the check, with an index version gate"), not ignoring the sub-test, and the masking also hides real writer bugs. The study's reviewers that weighed intent, including cross-model reviews, found the intended-behaviour case weak. The cross-model fact check of this doc rated it moderate overall, because it differs by condition: for repeated neighbours the backward-compatibility argument has real weight (valid 9.12.0-10.1.0 graphs can hold them, and the rethrow alone made -exorcise drop 994 of 1,000 documents), which is why the recommended fix pairs the rethrow with a version gate; for out-of-order and off-level neighbours, and out-of-range nodes, no such argument exists. A fair upstream framing: count real structural failures as bad segments, and decide separately how to treat old graphs that repeat a neighbour.

Suggested fix

Recommended Rethrow the HNSW error, with a version gate for repeated neighbours (validated)

Add the missing clause to testSegment's rethrow chain (3 lines) with a version gate tolerating repeated neighbours in segments written before 10.2.0, the companion bug's fix. Without the gate, -exorcise deletes 994 of 1,000 documents of a valid 10.1.0 index. With both, the repro passes; 357 closest-suite and 816 back-compat tests run with 0 failures (12 + 2 skipped).

// testSegment, after the vectors clause
} else if (segInfoStat.hnswGraphsStatus.error != null) {
  throw new CheckIndexException(
      "HNSW graph test failed", segInfoStat.hnswGraphsStatus.error);
}

// testHnswGraphs: pre-10.2 segments may repeat a neighbour
boolean allowRepeated =
    reader instanceof SegmentReader sr
        && sr.getSegmentInfo().info.getVersion().onOrAfter(Version.LUCENE_10_2_0) == false;
testHnswGraph(hnswGraph, fieldInfo.name, status, allowRepeated);
// testHnswGraph: } else if (nbr == lastNeighbor && allowRepeated) { continue; }
All options and trade-offs3 options · 666 words

The recommended option is the rethrow clause plus the companion bug's version gate; it and the rethrow alone were both run through gradle. The rethrow alone is not enough: it turns a valid old index into a broken one for CheckIndex, and -exorcise then deletes its documents. The warning variant was not run. Doc nit for the same PR: the HnswGraphsStatus.error javadoc says "Exception thrown during term index test" (CheckIndex.java:449).

Rethrow the HNSW error, with a version gate for repeated neighbours (validated) (recommended)

Add the missing clause after the vectors clause of testSegment's rethrow chain, and in testHnswGraphs tolerate repeated neighbours in segments whose writer version is before 10.2.0 (graph builders before 10.2 could link a neighbour twice, #14127; de-duplicated at write time since #14157). The gate is the companion bug's fix: credit msokolov, who proposed "re-enable the check, with an index version gate" on #14127, and iverase's reduced test there. One file, CheckIndex.java.

// testSegment, after the vectors clause
} else if (segInfoStat.hnswGraphsStatus.error != null) {
  throw new CheckIndexException(
      "HNSW graph test failed", segInfoStat.hnswGraphsStatus.error);
}

// testHnswGraphs: pre-10.2 segments may repeat a neighbour
boolean allowRepeated =
    reader instanceof SegmentReader sr
        && sr.getSegmentInfo().info.getVersion().onOrAfter(Version.LUCENE_10_2_0) == false;
testHnswGraph(hnswGraph, fieldInfo.name, status, allowRepeated);
// testHnswGraph: } else if (nbr == lastNeighbor && allowRepeated) { continue; }

Trade-offs

  • The gate keys on the writer version, so a repeated neighbour in a pre-10.2 segment caused by some other bug passes too; the other three conditions stay strict for every segment.
  • Conservative, not exact. The known affected writers are 9.12.0, 9.12.1, 10.0.0 and 10.1.0 (9.11.1, 9.12.2, 9.12.3 and 10.2.2 wrote no repeated neighbour in 60 seeds each; registry LFV-543), but the gate tolerates repeats in every segment written before 10.2.0.
  • Only for a SegmentReader. The public static testHnswGraphs(CodecReader, ...) applies the gate only when it gets a SegmentReader; CheckIndex itself always passes one.
  • Maintainers may prefer a warning over a gate (option C); the split of the four conditions in #13984 is theirs to revisit.

What the checks validated

Applied to a scratch worktree of 645d4fe14a20 and run through gradle with OpenJDK 25.0.3, seed DEADBEEF (2026-10-31; the worktree was removed afterwards):

  • stock: the minimal test fails with assertions on and off;
  • with the fix: the minimal test passes with assertions on and off;
  • the closest core suites, TestCheckIndex, TestAllFilesCheckIndexHeader, TestKnnGraph, org.apache.lucene.util.hnsw.* and org.apache.lucene.codecs.lucene99.*: 19 classes, 357 tests, 0 failures, 12 skipped;
  • backward-codecs TestBasicBackwardsCompatibility (deep CheckIndex on the shipped 9.x and 10.x indexes): 816 tests, 0 failures, 2 skipped;
  • :lucene:core:checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass;
  • the companion's old-index tests on the stock jars with CheckIndex shadowed by the fix: valid indexes written by 9.12.0, 10.0.0 and 10.1.0 whose graphs repeat a neighbour pass, and CheckIndex without failFast reports the 10.1.0 one (1,000 documents) clean, so -exorcise drops nothing (4 of 4 pass).

Not run: x86.

Rethrow the HNSW error only (validated; not enough alone)

The first part of the recommended option: three lines after the vectors clause of the rethrow chain.

Trade-offs

  • Unsafe alone. On a valid 10.1.0-written index (1,000 documents, a graph that repeats a neighbour) CheckIndex without failFast then reports clean=false badSegments=1 lostDocCount=994, and exorciseIndex leaves 6 of the 1,000 documents; on the stock jars the same test keeps all 1,000.
  • Upstream tests would not notice: the shipped 9.12.0, 10.0.0 and 10.1.0 back-compat indexes hold no repeated neighbours, and TestBasicBackwardsCompatibility passes 814 of 814 with this option alone.

What the checks validated

The minimal test passes with assertions on and off (gradle, same scratch worktree; the first run without assertions died before any test when another process stopped the shared Gradle daemons, and was re-run).

Make repeated neighbours a warning everywhere, and rethrow the rest

Count the error for node range, neighbour level and order, and only print repeated neighbours, which upstream calls benign (#14127, #15467).

Trade-offs

No version gate is needed, but CheckIndex would no longer catch new duplicate-writing bugs like #14127 and #15467 on current segments. Not run.

Downstream note

Tools that run CheckIndex at level 2 or 3 on main and act on its exit code or Status.clean should not read a pass as proof that the HNSW graphs are intact until a fix ships.

How it was found

A hunt unit on how main reads, merges and checks vector segments written by older releases ran CheckIndex at level 2 on a 10.1.0-written index: it printed an HNSW graph ERROR and then 'No problems were detected'. Reading testSegment's rethrow chain gave the cause. Testing plus code reading, not a formal-model finding.

The method in full132 words

Method: non-formal (the finder tagged it semi-formal). A main-side differential probe indexed vectors with release jars, then read, merged and checked the indexes on main. On a 10.1.0-written index whose graph repeats a neighbour (the companion bug), CheckIndex at level 2 printed test: hnsw graphs.........ERROR: ... has repeated neighbors followed by "No problems were detected with this index." with clean=true.

  • Root cause by reading testSegment's rethrow chain and testHnswGraphs' catch block.
  • Confirmation. A 2-test JUnit (an out-of-order graph through a codec-SPI vectors format, and the 10.1.0 index) failed on the stock main jars with assertions on and off; an independent reproducer, cross-model reviews from another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication confirmed it, and a 2026-10-29 minimization cut the repro to the one test above.

Prior reports, history and checks

Prior reports

No prior report found: 219 queries on 2026-10-26 (GitHub issues, PRs and commits, the JIRA archive, four mailing lists, downstream repositories, the web, the files of all 408 open PRs), re-checked 2026-10-31. Closest: #13984, which added the check, and #128 (LUCENE-9662), the same omission fixed for other sub-tests.

Searches2 searches
  • #13984 (merged): introduced the sub-test; its body, 13 comments and every review comment were read, and none mentions the verdict or the rethrow chain. #14120: its CHANGES entry, under 10.2.0. #13724 (open): the wish list behind it.
  • #14127/#14157 and #15467/#15478: writer bugs that the check caught (the stack traces in both issues run through the test framework's failFast check), fixed in the writer (#15470 was closed unmerged in favour of #15478). The version-gate discussion on #14127 belongs to the companion bug LFV-543.
  • #128 (LUCENE-9662): a precedent, not a report. #15540, #16099: other CheckIndex requests.
  • Not fixed upstream: upstream main 2f424fd428da and branch_11x d0c0decc6606 (2026-10-30) have the same CheckIndex.java; none of the 10 open PRs that modify it (2026-10-26) touches the HNSW sub-test or the rethrow chain.
  • Limits: GitHub search is keyword-based and does not index PR diffs; private reports are invisible; JIRA is a static archive.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, OpenSearch k-NN, Solr), dev@, java-user@, issues@ and commits@ lists, JIRA, web2026-10-26219 queries: four runs of a scripted prior-report search, the finder's run, 21 manual queries; the changed files of all 408 open apache/lucene PRs24 hits read, none the same bug
upstream main, branch_11x, open PRs2026-10-31read-only GitHub API: CheckIndex.java blob on main and branch_11x; open PRs matching 'CheckIndex'same blob, no fix; no open PR touches the HNSW sub-test

History

Born with the check in #13984 (11eb2c86979e, merged 2025-01-08, main only), which added the sub-test without a clause in the rethrow chain. No release has the HNSW sub-test (9.12.3 to 10.6.0); upstream main and branch_11x still have the bug, so it would first ship in 11.0.0.

Versions tested7 rows

Introduced by 11eb2c86979e, apache/lucene#13984 "Add some basic HNSW graph checks to CheckIndex" (Ben Chaplin; squash-merged by Michael Sokolov on 2025-01-08; only CheckIndex.java, +229). The rethrow chain it missed comes from LUCENE-9662 (424192e1704, #128, 2021-08-31). No revision of #13984 ever had a clause. Built and run: the commit fails 3 of 4 with assertions on and off; its parent d4f0a3244e51 has no such API.

Never backported: main's CHANGES lists GITHUB#13984 under 10.2.0 (lucene/CHANGES.txt:1910), but no 10.x tag or branch contains the code.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off (gradle); passes with the fix
main ea57360912ed affectedthe study's tests fail (3 of 4, 2 of 2), assertions on and off
main a9779ef3c193 affectedthe study's larger test fails 3 of 4 (assertions on, one run)
upstream main 2f424fd428da, branch_11x d0c0decc6606 readsame CheckIndex.java blob as 645d4fe14a20 (2026-10-30)
11eb2c86979e (#13984) vs parent d4f0a3244e51 boundarycommit: 3 of 4 fail; parent: HNSW sub-test absent
9.12.3, 10.1.0, 10.2.0, 10.5.2 not affectedno HNSW sub-test (release jars run)
10.6.0 tag, branch_10x, branch_10_6 readno HNSW sub-test

Independent checks

Confirmed by an independent reproducer, a devil's-advocate verifier and an adjudication; cross-model reviews (OpenAI GPT-5.6) agreed on the root cause, one of them with a procedural 'not real' because it could not run code. The version-history runs, a credibility audit and a minimization re-ran it; this doc's gradle run reproduced it and validated the fix.

Checks and verdicts560 words

Every executed check agrees. The credibility audit lowered the impact class from 'silent wrong results' to 'wrong error', because the only wrong output is CheckIndex's verdict.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (release-jar differential probe, then code reading) candidateA 2-test JUnit fails 2 of 2 on main with assertions on and off; the rethrow fix passes 2 of 2.
refute review (OpenAI GPT-5.6, read-only) not real (procedural, low)Could not run code; agreed on the root cause and rated the intended case weak. Its two points (the synthetic graph models a writer bug; the rethrow needs the version gate) were adopted.
Independent reproducer realOwn 4-test class (stock codec, CRC-consistent .vex edit) fails 3 of 4 on main with assertions on and off, 30 of 40 over 10 iterations; the failFast control throws; the rethrow passes 4 of 4.
Intended lens (OpenAI GPT-5.6, read-only) real (medium)Intended argument weak, root cause agrees; a separate re-run reproduced 2 of 2 and its fix check passed.
Intended-behaviour devil's advocate realRe-ran 2 of 2 with assertions on and off; a contrast test shows failFast throwing while the command line at -level 2 exits 0; rebutted eight intent arguments.
Adjudication confirmed (high)Re-ran the independent reproduction (3 of 4 fail, on and off), the finder's test (2 of 2) and the rethrow fix (4 of 4 pass).
History follow-up confirmedThe rebuilt introducing commit fails 3 of 4; its parent and four releases have no sub-test. Corrected an earlier claim about the PR's first revision.
Credibility audit downgrade (wrong error)Re-ran the independent reproduction (3 of 4) and the finder's test (2 of 2) with fresh seeds; real, but the only wrong output is the verdict.
Minimal repro verifiedThe one-test class fails on 645d4fe14a20 with assertions on and off, and 20 of 20 random seeds with assertions on; the rethrow passes.
This doc real; fix validatedGradle in a scratch worktree of 645d4fe14a20: stock fails with assertions on and off; both fixes pass the repro; with the recommended fix 357 closest-suite tests (12 skipped) and 816 back-compat tests (2 skipped) run with 0 failures, lint passes, and keeps valid old indexes clean.
Fact check of this doc (independent) holds; 3 text errors, fixedRe-ran the doc's gradle command on a fresh worktree of 645d4fe14a20 (fails with assertions on and off, output as quoted), the repro through the harness (on, off), and fix A and fix B (each passes, on and off). Errors: the independent reproduction table mixed in the intended-behaviour check's details, one line number, and an overclaim for a9779ef3c193; all fixed, with five minor items.
Fact check of this doc (OpenAI GPT-5.6, read-only) core bug real; wording correctedA reading-only review: 2 factual errors (1 medium: with assertions on, the node-range check meets a FixedBitSet assertion first and is not masked; 1 low), 3 overclaims and 3 missing caveats. It rated the intended-behaviour case moderate overall: weak for broken structure, meaningful for old graphs that repeat a neighbour. Every item was fixed or answered.
Diagram check passEvery on-screen value, API name and line matched the code and the probe. Two summary-card wording items were fixed in a re-render, together with the card's trigger line (the node-range point above).

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-561, snapshot of 2026-11-06

Registry title
With failFast off (the default), CheckIndex reports an index clean ('No problems were detected', Status.clean true, exit code 0) although its HNSW graph test printed ERROR, because testSegment never rethrows hnswGraphsStatus.error
Area
index / CheckIndex HNSW graph check
Priority
Low, rank 752: Unreleased CheckIndex at a non-default level reports clean after an HNSW graph error, hiding writer bugs without touching data and audit-downgraded, so it follows the other CheckIndex rows LFV-089 and LFV-543.
Impact class
wrong error
Impact note
Only an operator or application that runs CheckIndex at level 2 or 3 (opt-in: the programmatic default is 0 and the CLI default 1) without failFast (the default) on unreleased main (11.0.0-SNAPSHOT), over an index whose HNSW graph fails one of the sub-test's structural checks with an Exception (executed for repeated, out-of-order and off-level neighbours; by code reading, with assertions on an out-of-range node meets a FixedBitSet assertion first and does fail the segment), gets a clean verdict right after an ERROR line: Status.clean true, numBadSegments 0, 'No problems were detected with this index.', doCheck exit code 0, and -exorcise does nothing; no index data, search or merge is affected and no scale is needed, but such a graph comes only from a writer bug (two reached main in 2025, #14127 and #15467; the stack traces in both issues run through the test framework's failFast check), a checksum-consistent file edit, or a 9.12.0-10.1.0 graph with repeated neighbours (companion LFV-543, where the masked verdict happens to be the right one).
Summary
You run CheckIndex at level 2 or 3 (the command line with -level 2, or setLevel) without failFast on an index whose HNSW graph is broken, for example a neighbour that is not on its level or a neighbour list out of order. You expect the segment to count as bad, as it does for every other sub-test: Status.clean false, exit code 1, and -exorcise able to act; with failFast on, the same index does throw CheckIndexException. Instead the HNSW sub-test prints 'test: hnsw graphs.........ERROR: ...' and only stores the exception, because testSegment's rethrow chain has no clause for hnswGraphsStatus.error, so the run ends with 'No problems were detected with this index.', Status.clean true, numBadSegments 0 and exit code 0.
Introduced
11eb2c86979e79b34cf5420ba2d9e3c4e322d938 (#13984 'Add some basic HNSW graph checks to CheckIndex', by benchaplin, squash-merged by msokolov; only CheckIndex.java changed, +229) [read: git log -S testHnswGraphs / -S hnswGraphsStatus and blame; exec: rebuilt at the commit, the follow-up history test fails 3 of 4 with -ea and -da, and at its parent d4f0a3244e51 the API is absent]. Born with the feature: the commit added the sub-test, its status field and the call in testSegment, but no clause in the rethrow chain that LUCENE-9662 (424192e1704, #128, 2021-08-31) had written for every other per-segment sub-test; no revision of the PR ever had one (2025-01-08)
First affected version
unreleased (main only, 11.0.0-SNAPSHOT; git tag --contains 11eb2c86979 is empty; would first ship in 11.0.0 unless fixed)
Affected versions
main from 11eb2c86979 (2025-01-08) [exec at the rebuilt commit, -ea and -da] through ea57360912ed [exec: -ea and -da] and a9779ef3c193 [exec, registry re-run]; upstream main ffa9d816eeb4 (2026-10-26) still lacks the clause [read]. No release has the HNSW sub-test: 9.12.3, 10.1.0, 10.2.0 and 10.5.2 [exec: API absent, the history test skips], tags 10.1.0-10.5.2 and the branch_10x (657014b3c579) and branch_10_6 (c7788c5c48be) heads [read], although main's CHANGES lists GITHUB#13984 under 10.2.0; the parent d4f0a3244e51 [exec]: API absent
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by code reading
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record verdict new): 219 queries, four scripted prior-report searches (GitHub issues, PRs and commits of apache/lucene, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, 10 web queries) plus the finder's run and 21 manual queries (GitHub search and API, JIRA REST, list statistics); 24 hits read, all same_bug false. Nearest: #13984 (introduced the check; body, 13 comments and every review comment read, none about the verdict or the rethrow chain), #14120 (CHANGES entry under 10.2.0), #13724 (the wish list behind it), #14127/#14157 and #15467/#15478 (writer bugs caught on the test framework's failFast=true path), #15540, #16099, and #128 (LUCENE-9662, a precedent: the same missing-clause pattern fixed in 2021 for vectors, index sort and soft deletes). Not fixed upstream: main ffa9d816eeb still lacks the clause, and none of the 10 open PRs (of 408) that modify CheckIndex.java touches the HNSW sub-test or the rethrow chain
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off through gradle in a scratch worktree and through a standalone runner (20 of 20 random seeds with assertions on); with the rethrow fix alone and with the rethrow plus the version gate it passes, and the closest suites pass with the combined fix. Earlier main ea57360912ed: the study's two larger tests fail (3 of 4 and 2 of 2), assertions on and off; a9779ef3c193: the larger one fails 3 of 4 (assertions on, one run). The introducing commit 11eb2c86979e was rebuilt and fails 3 of 4; its parent and the release jars 9.12.3, 10.1.0, 10.2.0 and 10.5.2 have no HNSW sub-test. Upstream main 2f424fd428da and branch_11x d0c0decc6606 (2026-10-30) have the same CheckIndex.java blob (read, 2026-10-31).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:130 (at ea57360912ed) (Status.clean javadoc 'True if no problems were found with the index.'), :268 (SegmentInfoStatus.hnswGraphsStatus), :440-451 (HnswGraphsStatus.error, javadoc copied as 'Exception thrown during term index test'), :499 (level field, default 0), :517-520 (setFailFast javadoc: throw 'immediately when corruption is detected' versus keep iterating), :934 and :947 (clean set from the bad-segment count), :958 ('No problems were detected with this index.'), :975-985 (processSegmentInfoStatusResult counts a segment bad only if segmentInfoStatus.error != null), :1114 (level >= MIN_LEVEL_FOR_INTEGRITY_CHECKS), :1143-1144 (testHnswGraphs call), :1158-1197 (rethrow chain 'Rethrow the first exception we encountered / This will cause stats for failed segments to be incremented properly', no hnswGraphsStatus clause), :2926-2965 (testHnswGraphs: catch Exception, rethrow only if failFast, else print 'ERROR' and set status.error), :2996-3071 (the four structural checks), :4661 and :4867 (usage text 'exits with exit code 1 if the index cannot be opened or has any corruption'), :4748-4754 (Level.DEFAULT_VALUE = 1, MIN_LEVEL_FOR_INTEGRITY_CHECKS = 2), :4884 (doCheck '@return 0 iff the index is clean'); grep over lucene/: hnswGraphsStatus.error is read nowhere
  • https://github.com/apache/lucene/pull/13984 (author: 'exceptions are thrown if:' the four conditions; 'I chose not to throw an exception for disconnectedness'), https://github.com/apache/lucene/pull/128 (LUCENE-9662 precedent), https://github.com/apache/lucene/issues/14127 and https://github.com/apache/lucene/issues/15467 (writer bugs caught by the check through failFast)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:515, (at 645d4fe14a20) :933-934, :957-958, :975-985, :1114, :1143-1144, :1158-1197, :1200-1211, :2926-2966, :3016-3065, :4754, :4787-4819, :4902-4927
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/TestUtil.java:302-308; (at 645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/store/BaseDirectoryWrapper.java:44-45; MockDirectoryWrapper.java:917 (read)
  • grep over lucene for hnswGraphsStatus and testHnswGraphs: CheckIndex.java only; hnswGraphsStatus.error nowhere (read, 2026-10-31)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:499, (at 645d4fe14a20) :4748, :4854-4855 (usage text)
Minimal repro
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/TestUtil.java:1426-1428 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:110 (at 645d4fe14a20) (@lucene.experimental), :130, :517-520, :1158-1159, :4661-4662, :4867-4868, :4884
  • github apache/lucene#13984 issue comments (benchaplin 2025-01-01: 'I chose not to throw an exception for disconnectedness', 'So overall, exceptions are thrown if:'; mikemccand review: 'a blind spot for `CheckIndex` today, which is scary') (read, 2026-10-31)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:449 (at 645d4fe14a20) (javadoc nit)
Prior reports
  • github apache/lucene contents API (CheckIndex.java sha 8c09a098181d on main 2f424fd428da and branch_11x d0c0decc6606) and gh pr list --search CheckIndex (read-only, 2026-10-31)
History
  • a Lucene clone: git show releases/lucene/10.6.0:lucene/core/src/java/org/apache/lucene/index/CheckIndex.java (no testHnswGraphs); origin/branch_11x (forked from main at c5ab3e57b9ba, 2026-10-28) (read, 2026-10-31)
  • github apache/lucene contents API, main and branch_11x (read-only, 2026-10-31)
  • lucene/CHANGES.txt:1840, (at 645d4fe14a20) :1910

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, the minimal repro and the recorded verification runs.

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.