All findings · Lucene finding · LFV-1184 · multilevelskiplistwriter-window-fast-path-ignores-level-cap

MultiLevelSkipListWriter.bufferSkip throws ArrayIndexOutOfBoundsException for a one-level skip writer (maxSkipLevels = 1), a regression since 9.9.0

Since 9.9.0, a skip writer built on MultiLevelSkipListWriter with one level (maxSkipLevels = 1) throws ArrayIndexOutOfBoundsException once a term reaches skipInterval × skipMultiplier documents: a speed-up in bufferSkip (#12653) skips the level cap. Lucene's own postings formats use 4 or 10 levels.

Summary

Priority
Low, rank 695A regression since 9.9.0 makes a single-level skip writer built on the experimental MultiLevelSkipListWriter throw ArrayIndexOutOfBoundsException at the first term with skipInterval times skipMultiplier documents. Only custom codecs with maxSkipLevels 1 reach it, no stock format does, and it fails loudly at once with no wrong data.
Impact
minor
Since
9.9.0 (2023, #12653)introduced 2023-10-21 · about 3 years
Affected
every release from 9.9.0 to 10.6.0 and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of MultiLevelSkipListWriter's level-cap 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

bufferSkip decides how many levels each skip entry goes to, and must never go past numberOfSkipLevels. The 9.9.0 shortcut skips that check for its first step up, so a one-level writer is sent to a second level it never allocated.

Background: multi-level skip lists

  1. A skip list lets a postings reader jump ahead in a term's long list of documents. Level 0 has one entry every skipInterval documents, for example every 4.
  2. Every skipMultiplier-th level-0 entry (with 2: the entries at documents 8, 16, 24, ...) also goes to level 1, and so on up. A codec sets the most levels it wants with maxSkipLevels; with 1 there is only level 0.
Diagram of the bug: a skip writer with skipInterval 4, skipMultiplier 2 and maxSkipLevels 1 has one level buffer. bufferSkip(4) writes level 0. bufferSkip(8) should write level 0 only, as on 9.8.0; on main the shortcut sees 8 % (4 x 2) == 0, sets the level count to 2 without checking the cap, writes level 0 and then reads skipBuffer[1], which does not exist: ArrayIndexOutOfBoundsException, Index 1 out of bounds for length 1. A two-level writer with the same input writes levels 0 and 1.
A one-level skip list with skipInterval 4 and skipMultiplier 2: the entry at document 8 belongs on level 0 only, but the 9.9.0 shortcut counts a second level, and the writer's one-slot buffer array has no slot 1. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You write a postings format whose skip writer extends MultiLevelSkipListWriter with skipInterval 4, skipMultiplier 2 and maxSkipLevels 1. The writer then has one level (numberOfSkipLevels is 1) and allocates one level buffer.
  2. While writing a term you call bufferSkip every 4 documents. bufferSkip(4) writes an entry on level 0.
  3. You expect bufferSkip(8) to write on level 0 too, because there is no other level. 9.8.0 does exactly that.
  4. Instead the 9.9.0 shortcut sees that 8 is a multiple of 4 × 2, raises the level count to 2 without checking the cap, and reads the missing second buffer: ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1, with assertions on or off.
  5. With settings like 128 and 8 the first failing call is bufferSkip(1024), in the first term with about 1,024 documents. Writing that term fails loudly; nothing is silently wrong.

Who is affected

In practice only custom codecs whose skip writer extends this experimental class with maxSkipLevels = 1, at their first bufferSkip call with a df of skipInterval × skipMultiplier. Lucene's own formats use 4 (SimpleText) or 10 (backward codecs) levels, and the default codec on main does not use the class. The failure is loud: an exception, no wrong data.

Mechanism, terms and numbers552 words

MultiLevelSkipListWriter is the public abstract base class (@lucene.experimental) for codec skip writers. Its constructor caps the number of levels at maxSkipLevels (when df > skipInterval; otherwise it uses 1), and init() allocates exactly that many level buffers. Since 9.9.0, bufferSkip(df) first checks df % windowLength == 0, where windowLength = skipInterval * skipMultiplier, and then raises the level count from 1 to 2 without comparing it with the cap; only the later increments are capped. So a writer built with maxSkipLevels = 1 throws ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1 at the first df that is a multiple of skipInterval * skipMultiplier, with assertions on and off. 9.8.0 wrote that entry to level 0, as a one-level skip list should.

The change was #12653, presented in its title and CHANGES entry as a speed-up. In practice only custom codecs with a single skip level are hit: Lucene's own skip writers use 4 levels (SimpleText) or 10 (backward codecs), and the default codec on main no longer uses this class. The failure is a loud exception while writing that term, not corruption.

The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/codecs/MultiLevelSkipListWriter.java)

  1. The constructor caps the level count. :70-84: if df > skipInterval, numberOfSkipLevels = min(1 + log(df / skipInterval, skipMultiplier), maxSkipLevels), with the comment "also make sure it does not exceed maxSkipLevels" (:77), otherwise 1 (:80-82); and windowLength = skipInterval * skipMultiplier (:83). With maxSkipLevels = 1 the result is 1.
  2. init() allocates exactly that many buffers. :95-99: skipBuffer = new ByteBuffersDataOutput[numberOfSkipLevels], here one.
  3. The shortcut's first step is uncapped. bufferSkip(df) (:128-159) starts with numLevels = 1, then if (df % windowLength == 0) { numLevels++; df /= windowLength; while ((df % skipMultiplier) == 0 && numLevels < numberOfSkipLevels) { ... } } (:134-141). The first numLevels++ (:135) is the only increment with no cap. This is the fix site.
  4. The level loop then reads past the array. for (int level = 0; level < numLevels; level++) { writeSkipData(level, skipBuffer[level]); ... } (:146-147) writes level 0 and then reads skipBuffer[1]: ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1.
  5. Before 9.9.0 every increment was capped. 9.8.0: df /= skipInterval; while ((df % skipMultiplier) == 0 && numLevels < numberOfSkipLevels) { numLevels++; df /= skipMultiplier; }.

The exact condition is a writer with one level (numberOfSkipLevels == 1) called with a df that is a multiple of windowLength. The normal way to get one level is maxSkipLevels = 1. The other way, a constructor df below windowLength, reaches the line only if bufferSkip is later called with a df above the constructor's (for example 4, 2, 10 levels, constructor df 4, then bufferSkip(8)). Neither method checks that bound, but in-tree callers pass maxDoc as the constructor's df (SimpleTextSkipWriter.java:62), so a term's df cannot exceed it.

Not counted as a trigger: the backward-codecs Lucene99SkipWriter built with maxSkipLevels = 1 throws the same way (the refute-lens cross-model review drove it), but Lucene99PostingsReader always reads with 10 levels, so a one-level writer there would itself be a misuse. Not claimed: the constructor accepts maxSkipLevels <= 0 (a nonsense value that fails loudly).

Exposure and likelihood185 words
  • Who. Authors of a custom postings format whose skip writer extends MultiLevelSkipListWriter with one skip level. The class is public, abstract and @lucene.experimental, and invites subclassing ("subclasses must define the actual format of the skip data", MultiLevelSkipListWriter.java:47-48).
  • Default vs opt-in. Opt-in only. SimpleText passes 4 levels (SimpleTextSkipWriter.java:40, :62), the backward-codecs Lucene90 and Lucene99 writers 10 (MAX_SKIP_LEVELS), and no core postings format on main subclasses the class.
  • Effect. Deterministic, with assertions on and off: writing that term fails with an exception. In a postings writer it would surface while flushing the segment (by reading; not run through IndexWriter). No wrong results, no silent damage.
  • Scale. None needed: the first bufferSkip call with a df of skipInterval * skipMultiplier (8 in the repro; 1,024 for 128 and 8, made at a term's 1,024th document by a writer that buffers skip entries as each block completes, like SimpleText, or at the 1,025th by one that buffers them when the next block starts, like the backward-codecs Lucene90 writer).
  • Likelihood. Rare: no public report was found in the documented searches since 9.9.0 (2023).

Minimal repro

One JUnit test method: an anonymous one-level MultiLevelSkipListWriter subclass (skipInterval 4, skipMultiplier 2, maxSkipLevels 1, df 64) that records each entry's level, then bufferSkip(4) and bufferSkip(8). It doubles as the regression test. Deterministic and single-threaded; the test itself takes about 1 second in a standalone JUnit runner.

TestSingleLevelSkipWriter.java (lucene/core, package org.apache.lucene.codecs, 1 test)42 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;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.store.DataOutput;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSingleLevelSkipWriter extends LuceneTestCase {

  public void testSingleLevelSkipList() throws IOException {
    List<Integer> levels = new ArrayList<>();
    MultiLevelSkipListWriter w =
        new MultiLevelSkipListWriter(4, 2, 1, 64) {
          @Override
          protected void writeSkipData(int level, DataOutput out) {
            levels.add(level);
          }
        };
    w.resetSkip();
    w.bufferSkip(4);
    w.bufferSkip(8);
    // expected: both entries on level 0; actual: ArrayIndexOutOfBoundsException at bufferSkip(8)
    assertEquals(List.of(0, 0), levels);
  }
}
Command
cp $REPRO/TestSingleLevelSkipWriter.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.TestSingleLevelSkipWriter -Ptests.seed=DEADBEEF
Expected
Both entries go to level 0: writeSkipData sees levels [0, 0], as on 9.8.0.
Observed
bufferSkip(8) throws ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1 at MultiLevelSkipListWriter.java:147, assertions on and off.

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

More files, environment, full output204 words

TestSingleLevelSkipWriter (one method, extends LuceneTestCase, package org.apache.lucene.codecs of the lucene/core tests because resetSkip() is protected; Apache license header). It is the hunt's minimal repro, unchanged. The test draws no random values, so the outcome does not depend on the seed.

Without a fix it fails through gradle with assertions on and off; with fix A it passes with both. The same test fails on the main jars through a standalone JUnit runner. The release runs used the hunt's equivalent one-level tests (the reproducer's has the same input): 9.9.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 fail with the same exception, and 9.8.0 passes.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.9.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0: fail, 9.8.0: passes (release runs with the hunt's equivalent one-level tests)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (only this platform was run)
Command (environment)
cp $REPRO/TestSingleLevelSkipWriter.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.TestSingleLevelSkipWriter -Ptests.seed=DEADBEEF   # add -Ptests.asserts=false: same failure

Observed output

# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF (assertions on; -Ptests.asserts=false gives the same)
TestSingleLevelSkipWriter > testSingleLevelSkipList FAILED
    java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E9607C52B90717C8]:0)
        at org.apache.lucene.codecs.MultiLevelSkipListWriter.bufferSkip(MultiLevelSkipListWriter.java:147)
        at org.apache.lucene.codecs.TestSingleLevelSkipWriter.testSingleLevelSkipList(TestSingleLevelSkipWriter.java:38)

# with fix A
BUILD SUCCESSFUL (1 test, 0 failures; assertions on and off)

Why it looks unintended

  • The class caps the level count everywhere else: the constructor, init(), writeSkip and the loop guard inside bufferSkip itself.
  • One level is a designed mode since multi-level skip lists were added (LUCENE-866); the reader still supports it, and 9.8.0 wrote it correctly.
  • #12653 was presented and released as a speed-up; nothing documents a minimum of 2 levels.
The full argument224 words
  • The cap is the class's own contract. The field javadoc calls numberOfSkipLevels the "number of levels in this skip list" (MultiLevelSkipListWriter.java:54-55); the constructor caps it on purpose (:77-79); init() allocates exactly that many buffers (:95-99); writeSkip iterates numberOfSkipLevels - 1 down to 0 (:172-179); the while guard caps every other increment (:138). An ArrayIndexOutOfBoundsException is not a documented outcome.
  • One level is a designed mode. LUCENE-866 (#1941, 2007), which added multi-level skip lists, says the format "does not change for maxSkipLevels=1", and its first commit kept reading older index formats as single-level skip lists (87ba4a12584, SegmentTermEnum: maxSkipLevels = 1; // use single-level skip lists for formats > -3). MultiLevelSkipListReader on main sizes its arrays by maxSkipLevels and caps at it (MultiLevelSkipListReader.java:77-92, :207-209); an independent write-then-read test with one level passes on 9.8.0 and with the fix, and fails on main and 10.6.0.
  • The change was presented as speed only. #12653's title and CHANGES entry (9.9.0, Optimizations: "Optimize computing number of levels in MultiLevelSkipListWriter#bufferSkip") describe only speed, and its 4 comments, 11 reviews and 10 review comments never mention one level. No javadoc, test, CHANGES or MIGRATE entry sets a minimum of 2 for maxSkipLevels, and no test builds a skip writer with fewer than 4 levels.
The devil's-advocate check85 words

The strongest counter-argument (from the verifiers): "The class is experimental and named *Multi*Level; every in-tree writer uses at least 4 levels, so one level is unsupported territory."

Why it does not settle it. @lucene.experimental says the API may change, not that an accepted argument may crash. The constructor accepts 1 without complaint, the reader supports it, the class's own code treats the level count as a hard cap, and 9.8.0 handled it correctly. The argument does bound reach, which is why the priority is Low.

Suggested fix

Recommended Guard the shortcut with numberOfSkipLevels > 1 (fix A)

Guard the shortcut: if (numberOfSkipLevels > 1 && df % windowLength == 0). A one-level writer then keeps every entry on level 0, as before 9.9.0, and writers with 2 or more levels are unchanged. The repro passes, and so do the postings-format suites of every in-tree subclass.

if (numberOfSkipLevels > 1 && df % windowLength == 0) {
All options and trade-offs3 options · 433 words

Fix A was built and checked through gradle on 5adff78bb168 in a scratch worktree, seed DEADBEEF: the repro with assertions on and off, core's TestMultiLevelSkipList (it indexes with the default postings format, which no longer uses this class, so it does not run the changed line), TestSimpleTextPostingsFormat (a 4-level subclass; the class is @Nightly, so it was run with -Ptests.nightly=true), backward-codecs' TestLucene90PostingsFormat and TestLucene99PostingsFormat (they write through the test-only Lucene90RWPostingsFormat and Lucene99RWPostingsFormat with 10-level skip writers; the production formats are read-only), and :lucene:core:checkGoogleJavaFormat. With the same change, the other checks' own tests passed too (the independent reproducer's, the devil's-advocate verifier's write-then-read test and the cross-model reviews' tests), and so did the finder's test and SimpleText's testDocsAndFreqs* (nightly). Option B was run once by the independent fact check: the repro passes through gradle with assertions on.

Guard the shortcut with numberOfSkipLevels > 1 (fix A) (validated by the checks) (recommended)

In MultiLevelSkipListWriter.bufferSkip (:134) add numberOfSkipLevels > 1 && to the shortcut's condition. One line.

if (numberOfSkipLevels > 1 && df % windowLength == 0) {

Trade-offs

Restores the 9.8.0 result for one level and changes nothing for 2 or more levels (the while loop already caps the rest). No file-format change. One extra int compare per call; writing df % windowLength == 0 && numberOfSkipLevels > 1 instead keeps the common non-boundary case at a single modulo, as #12653 intended (equivalent, not run separately).

What the checks validated

Gradle, scratch worktree at 5adff78bb168, seed DEADBEEF: the repro passes with assertions on and off; the repro plus TestMultiLevelSkipList: 2 tests, 0 failures (TestMultiLevelSkipList runs the default postings format, which no longer uses this class); TestSimpleTextPostingsFormat with -Ptests.nightly=true: 30 tests, 0 failures, 1 skipped; TestLucene90PostingsFormat and TestLucene99PostingsFormat (through their test-only RW formats): 65 tests, 0 failures, 4 skipped; :lucene:core:checkGoogleJavaFormat passes with the fix and the test in place.

Cap the first step like the others (option B) (validated by the checks)

Write the shortcut as if (df % windowLength == 0 && numLevels < numberOfSkipLevels), the same guard the while loop uses.

Trade-offs

Equivalent here (numLevels is 1 at that point) and reads like the existing loop guard.

What the checks validated

Run once by the independent fact check: the repro passes through gradle with assertions on (seed DEADBEEF).

Not recommended: reject maxSkipLevels < 2 in the constructor (not run)

Throw IllegalArgumentException for maxSkipLevels < 2.

Trade-offs

It would turn a designed, formerly working mode into an error, while the reader still accepts one level.

What the checks validated

Not run.

Downstream note

Matters only to products that ship their own postings format on this class with a single skip level; Lucene's stock formats are not affected.

How it was found

A proof-obligation audit of MultiLevelSkipListWriter's contract: the level count may never exceed numberOfSkipLevels, which the constructor, init() and the bufferSkip loop all keep. The shortcut in bufferSkip breaks that obligation for one level; a one-test JUnit then witnessed the crash on main.

The method in full125 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder took the invariant the class states and keeps: the level count is capped at numberOfSkipLevels (the field javadoc :54-55, the constructor cap :77-79, the buffer allocation :95-99, the while guard :138).
  • It checked every increment of numLevels in bufferSkip against that cap and found one, the first step of the shortcut (:134-135), that is not guarded, so the obligation fails exactly when numberOfSkipLevels is 1.
  • A one-test JUnit with a one-level subclass witnessed it on main with assertions on and off and on the 10.6.0 and 9.9.0 jars, and showed 9.8.0 passing; the verifiers, the adjudication and a separate re-run all reproduced it.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-11-03): apache/lucene issues, PRs and commits with the JIRA archive, the Lucene mailing lists, Elasticsearch, OpenSearch, Solr and the web. Nearest items: #12653, the change that introduced it, and #13576, a closed PR that relied on the same level cap.

Searches3 searches

No matching public report was found in the documented searches. Related, not reports: #12653 (the introducing change; its discussion never mentions one level), #13576 (closed unmerged 2024; it sized subclass arrays by numberOfSkipLevels, relying on the same cap, without noticing the gap), #13623 (open, skip reader memory), #13585 (moved the default codec off this class), #1941 (LUCENE-866, the original feature). None of the open PRs checked touches a skip-list file. Upstream main 1c2072f0284 (2026-11-02), branch_11x (319bdb587ce) and branch_10_6 (facfa0d52c9) still have the uncapped shortcut. The searches were careful but not exhaustive.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-03, re-checked the same daytwo scripted prior-report searches (the follow-up one alone: 67 queries over identifiers, the exception, symptoms and history), manual searches for MultiLevelSkipListWriter, bufferSkip, maxSkipLevels, skipMultiplier, numLevels and skip-level ArrayIndexOutOfBoundsException (also across all of GitHub), the introducing PR's full discussion and timeline, and git log -S 'df % windowLength' over the full historyno report; #12653, #13576, #13623, #13585, #1941 are history or context
open apache/lucene PRs2026-11-03changed-file lists of the 38 codec-related open PRs and of every open PR updated since 2026-11-01none touches MultiLevelSkipListWriter or any skip-list writer or reader
dev@, java-user@, issues@ and commits@ lists; Elasticsearch, OpenSearch and Solr trackers; the web2026-11-03the scripted searches' list, downstream and web legs; a lists.apache.org search since 2023-10-01no report

History

Introduced by #12653 (de8ae1de7cc, branch_9x 3292aca1f45, 2023-10-21), first release 9.9.0. 9.8.0 passes; 9.9.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main fail, and the 9.8.0 jars with only that commit's file fail. Not fixed upstream.

Versions tested5 rows

Introduced by #12653 "Optimize computing number of levels in MultiLevelSkipListWriter#bufferSkip" (author shubhamvishu, merged by mikemccand): de8ae1de7cc on main and 3292aca1f45 on branch_9x, both dated 2023-10-21. First release 9.9.0 (git tag --contains 3292aca1f45 starts there). It replaced the capped loop, which dated from LUCENE-866 (87ba4a12584, 2007), with the shortcut whose first step is uncapped.

The file is blob f592b7c0737 in every tag from 9.0.0 to 9.8.0 and bbeed679a38 in every tag from 9.9.0 to 10.6.0 and on main; no later commit touches it. Releases before 9.0.0 were checked by reading only (the test does not compile there); they have the same capped loop. Not new in 10.6.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle and the standalone runner: fails with assertions on and off; passes with fix A
10.6.0, 10.5.2, 10.0.0, 9.12.3 affectedthe same exception at bufferSkip:147
9.9.0 affectedfirst release with the bug: fails with assertions on and off
9.8.0 jars + only 3292aca1f45's file commit isolationfails the same way as 9.9.0
9.8.0 not affectedpasses with assertions on and off: both entries go to level 0

Independent checks

A finder, an independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6, intended and refute lenses), a devil's-advocate verifier, an adjudication and a separate re-run reproduced it and agree on the root cause; a final repro check confirmed it at Low.

Checks and verdicts418 words

The cross-model reviews ran on main ea57360912ed, whose MultiLevelSkipListWriter.java is byte-identical; all other main runs used 5adff78bb168, and the release runs used each release's own jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOwn test (8, 3, 1 level, three entries then writeSkip) fails on main (assertions on and off), 10.6.0 and 9.9.0 and passes on 9.8.0; the fix passes it and SimpleText's testDocsAndFreqs* (nightly).
cross-model review, intended lens (OpenAI GPT-5.6, executed) real, highOwn one-level test fails (assertions on and off) while a two-level control passes; passes on 9.8.0, fails on 9.9.0; the fix passes both. Intended-behaviour case judged weak.
independent reproducer realOwn test (4, 2, 1 level, df 64) fails on main (assertions on and off), 10.6.0, 10.5.2 and 9.12.3, passes on 9.8.0 and with the fix.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real, highOwn boundary test fails while a one-level non-boundary control passes; no refutation held; the fix passes its own tests.
devil's-advocate verifier real, highOwn write-then-read test (writer plus matching reader, skipTo(50)) passes on 9.8.0 and with the fix, fails on main and 10.6.0; every intended-behaviour argument fails.
adjudication confirmed, high, LowRe-ran the reproducer's test on clean main (assertions off, fresh seed): fails; it counts only a custom subclass as the trigger.
separate re-run, final repro check fails on main, passes with the fix; confirmed, LowRe-runs fail on main (assertions on and off) and pass with the fix; the final check confirmed the minimal repro at Low and set the impact class to minor.
this doc reproduced; fix A validatedGradle runs on 5adff78bb168: the repro fails with assertions on and off and passes with fix A; with the fix, 97 tests in core, SimpleText (nightly) and the backward codecs, 0 failures (core's TestMultiLevelSkipList runs the default postings format, which no longer uses this class).
independent fact check real as described; 4 small precision fixesRe-ran the brief's gradle command in its own scratch worktree at 5adff78bb168: fails with assertions on and off, passes with fix A and with option B; 9.8.0 passes, 9.9.0 and 10.6.0 fail. The precision fixes are applied in this doc.
cross-model fact check (OpenAI GPT-5.6) substantially sound; wording tightenedNo finding overturns the bug; its reach and wording points (the exact one-level condition, when the first failing call happens, how #12653 was presented) are applied here, and the rest are answered.

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

Finding record

LFV-1184, snapshot of 2026-11-06

Registry title
MultiLevelSkipListWriter.bufferSkip throws ArrayIndexOutOfBoundsException for a one-level skip writer (maxSkipLevels = 1) at the first df that is a multiple of skipInterval * skipMultiplier (regression since 9.9.0, #12653)
Area
core / codecs: MultiLevelSkipListWriter (multi-level skip lists for postings; @lucene.experimental)
Priority
Low, rank 695: A regression since 9.9.0 makes a single-level skip writer built on the experimental MultiLevelSkipListWriter throw ArrayIndexOutOfBoundsException at the first term with skipInterval times skipMultiplier documents. Only custom codecs with maxSkipLevels 1 reach it, no stock format does, and it fails loudly at once with no wrong data.
Impact class
minor
Impact note
Only custom codec code is hit: a postings format whose skip writer extends the public abstract, @lucene.experimental MultiLevelSkipListWriter with maxSkipLevels = 1 fails deterministically, with or without assertions, at the first term whose document count reaches skipInterval * skipMultiplier (for example 1024 for 128 and 8; no scale needed) [exec]; no stock configuration is affected, because the default codec on main no longer uses this class and Lucene's own skip writers use 4 (SimpleText) or 10 (backward-codecs) levels [read]; the failure is a loud exception while writing that term (in a postings writer this would surface during flush, by reading; not executed through IndexWriter), not corruption, and it is in every release from 9.9.0 through 10.6.0.
Summary
You write a postings format whose skip writer extends MultiLevelSkipListWriter with maxSkipLevels = 1 (a single-level skip list) and call bufferSkip every skipInterval documents. You expect every entry to go to level 0, because the writer reports numberOfSkipLevels = 1 and allocates one level buffer, and 9.8.0 does exactly that. Instead, at the first df that is a multiple of skipInterval * skipMultiplier, a shortcut added in 9.9.0 (#12653) raises the level count to 2 without checking that cap, and bufferSkip throws ArrayIndexOutOfBoundsException 'Index 1 out of bounds for length 1'; Lucene's own postings formats use 4 or 10 levels and are not affected.
Introduced
de8ae1de7cc022998f8700ae3d9b59c7bff3cf63 on main and its branch_9x twin 3292aca1f453d6e6b2a2a6ace172f2128e3b397f: apache/lucene#12653 'Optimize computing number of levels in MultiLevelSkipListWriter#bufferSkip' (by shubhamvishu, merged by mikemccand). It replaced the level loop 'df /= skipInterval; while ((df % skipMultiplier) == 0 && numLevels < numberOfSkipLevels)', where every increment was capped, with a 'df % windowLength == 0' fast path whose first increment is not capped [read: git blame and diff of the file; exec: commit isolation, the 9.8.0 jars with only 3292aca1f45's MultiLevelSkipListWriter.java shadowed in fail exactly like 9.9.0]. The capped loop dated from the class's creation (LUCENE-866, 87ba4a12584, 2007). (2023-10-21 (author date 2023-10-21T11:52:28Z for both de8ae1de7cc and 3292aca1f45; PR merged 2023-10-21T11:52:28Z); first release 9.9.0)
First affected version
9.9.0 [exec: the finder's one-level test fails on 9.9.0 with -ea and -da and passes on 9.8.0 with -ea and -da; git tag --contains 3292aca1f45 starts at releases/lucene/9.9.0]
Affected versions
Every release from 9.9.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT); never fixed. [exec, JDK 25.0.3, Linux aarch64] the finder's test fails on main (-ea, -da), 10.6.0 and 9.9.0 (-da) and passes on 9.8.0 (-da); the history step ran it on 9.8.0 (-ea, -da: pass) and on 9.9.0 (-ea, -da), 9.12.3, 10.0.0, 10.6.0 (-da) and main (-ea, -da): fail; the independent reproduction's test fails on main (-ea, -da), 10.6.0, 10.5.2 and 9.12.3 (-da) and passes on 9.8.0 (-da); the intended-behaviour check's write-then-read test fails on main (-ea, -da) and 10.6.0 (-da) and passes on 9.8.0 (-da); the intended-lens cross-model reviewer's own test passes on 9.8.0 and fails on 9.9.0. [read] the file blob is the capped loop (f592b7c0737) in every tag 9.0.0..9.8.0 and the fast path (bbeed679a38) in every tag 9.9.0..10.6.0 and on main. Not new in 10.6 (10.5.2 fails). Not fixed upstream: main 1c2072f0284 (2026-11-02) still has the uncapped 'if (df % windowLength == 0)' at line 134 (contents API, 2026-11-03), so do branch_11x and branch_10_6 (follow-up check), and the newest upstream commit on the file is still de8ae1de7cc.
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-03, on top of the finder's run and the intended-behaviour check's and the adjudication's searches; rechecked the same day): the finder's scripted prior-report search (16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA-archive and 2 web queries) and the follow-up search (67 queries: 30 GitHub issue/PR, 3 commit, 2 downstream over Elasticsearch, OpenSearch and Solr, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA archive, 8 web), manual GitHub searches (MultiLevelSkipListWriter, bufferSkip, numberOfSkipLevels, maxSkipLevels, skipMultiplier, skip-level ArrayIndexOutOfBoundsException, also across all of GitHub), a lists.apache.org query since 2023-10-01, the introducing PR's body, 4 comments, 11 reviews, 10 review comments and timeline, git log -S 'df % windowLength' over the full history (only de8ae1de7cc and 3292aca1f45), and the changed files of the 38 codec-related open PRs and of the open PRs updated since 2026-11-01 (40 in the follow-up check, 28 in the same-day recheck): none touches MultiLevelSkipListWriter.java or any skip-list writer or reader. Related items, not reports: #12653 (the introducing optimization, merged 2023-10-21), #13576 (closed unmerged 2024; it sized subclass arrays by numberOfSkipLevels, relying on the same cap, without noticing the gap), #13623 (open 2024, skip reader memory), #1941 (LUCENE-866, the 2007 feature) and #224 / #11074 (LUCENE-10035, SimpleText skip data).
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle on 2026-11-03 in a scratch worktree: the repro with assertions on and off without a fix (fails) and with fix A (passes); with fix A also core's TestMultiLevelSkipList (it indexes with the default postings format, which no longer uses this class), TestSimpleTextPostingsFormat (nightly), backward-codecs' TestLucene90PostingsFormat and TestLucene99PostingsFormat (written through their test-only RW formats), and the google-java-format check; OpenJDK 25.0.3, Linux aarch64. Release runs from the hunt: 9.8.0 passes; 9.9.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 fail; the 9.8.0 jars with only the introducing commit's file fail.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/codecs/MultiLevelSkipListWriter.java:51 (at 5adff78bb168) (@lucene.experimental), :53 (public abstract class), :54-55 (numberOfSkipLevels javadoc 'number of levels in this skip list'), :66-67 (windowLength), :70-84 (protected constructor: numberOfSkipLevels = min(1 + log(df / skipInterval, skipMultiplier), maxSkipLevels), comment 'also make sure it does not exceed maxSkipLevels', or 1 when df <= skipInterval; windowLength = skipInterval * skipMultiplier), :94-100 (init allocates exactly numberOfSkipLevels buffers), :128-142 (bufferSkip: the uncapped 'if (df % windowLength == 0) { numLevels++;' at :134-135, the capped while loop at :138), :146-147 (level loop indexes skipBuffer[level]: the failure point; the fix site is :134), :172-179 (writeSkip iterates numberOfSkipLevels-1..0)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/codecs/MultiLevelSkipListWriter.java:47-48, (at 5adff78bb168) :51, :53-55, :66-67, :70-84, :95-99, :128-159, :172-179
  • a Lucene clone releases/lucene/9.8.0 lucene/core/src/java/org/apache/lucene/codecs/MultiLevelSkipListWriter.java:129-139 (the capped loop)
  • lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextSkipWriter.java:39-42, (at 5adff78bb168) :62
Who is affected
  • lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextSkipWriter.java:40, (at 5adff78bb168) :62; lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene90/Lucene90PostingsFormat.java:365; lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene99/Lucene99PostingsFormat.java:364
  • Lucene main 5adff78bb168 grep 'extends MultiLevelSkipListWriter' over lucene/: only SimpleTextSkipWriter, Lucene90SkipWriter and Lucene99SkipWriter
  • lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextFieldsWriter.java:214-216; (at 5adff78bb168) lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene90/Lucene90PostingsWriter.java:211-224 (when bufferSkip is called)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/codecs/MultiLevelSkipListWriter.java:54-55, (at 5adff78bb168) :77-79, :95-99, :138, :172-179
  • lucene/core/src/java/org/apache/lucene/codecs/MultiLevelSkipListReader.java:77-92, (at 5adff78bb168) :200-209
  • lucene/CHANGES.txt:3282 (at 5adff78bb168) (GITHUB#12653 under 9.9.0 Optimizations)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/codecs/MultiLevelSkipListWriter.java:132-141 (at 5adff78bb168)
  • lucene/core/src/test/org/apache/lucene/index/TestMultiLevelSkipList.java:74; (at 5adff78bb168) lucene/test-framework/src/java/org/apache/lucene/tests/util/TestUtil.java:1348-1350 (default postings format = Lucene104PostingsFormat)
  • lucene/backward-codecs/src/test/org/apache/lucene/backward_codecs/lucene90/TestLucene90PostingsFormat.java:48, (at 5adff78bb168) lucene99/TestLucene99PostingsFormat.java:49 (RW formats); lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene90/Lucene90PostingsFormat.java:387-389 (read-only)
Prior reports
  • a Lucene clone: git show origin/branch_11x (319bdb587ce, 2026-11-02), origin/branch_10_6 (facfa0d52c9, 2026-11-01) and 1c2072f0284 : MultiLevelSkipListWriter.java (blob bbeed679a38, line 134 'if (df % windowLength == 0) {')
History
  • a Lucene clone: git show de8ae1de7cc, git show 3292aca1f45 --stat, git show releases/lucene/9.8.0:lucene/core/src/java/org/apache/lucene/codecs/MultiLevelSkipListWriter.java

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 input (skipInterval 4, skipMultiplier 2, maxSkipLevels 1, df 64) and its traced outcome on main, on 9.8.0 and with fix A, with a two-level writer as the control.

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.