All findings · Lucene finding · LFV-126 · mmap-slice-readadvice-ignores-multisegment-offset

MMapDirectory puts a slice's read advice on the wrong bytes when the slice starts inside an mmap chunk and reaches or crosses that chunk's end (10.0.0+)

With read advice on, an MMapDirectory slice that starts inside an mmap chunk and reaches or crosses its end is advised from the chunk start: with 1 MiB chunks, slice [512 KiB, 1.5 MiB) gets MADV_RANDOM on [0, 1 MiB). Reads stay correct; only readahead changes.

Summary

Priority
Low, rank 1033MMapDirectory applies a slice's read advice to the wrong bytes when the slice crosses an mmap chunk boundary, performance only and needing non-default advice plus a non-default chunk size or a compound file over 16 GiB.
Impact
minor
Since
10.0.0 (#13467, 2024-06-21); compound files since 10.1.0introduced 2024-06-21 · about 2 years
Affected
10.0.0-10.6.0 and main; opt-in on 10.3.0+
Status
documented
Found by
semi-formal: noticed while modelling the chunk arithmetic in Z3, then an executed test

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

Lucene can tell the operating system how a part of a file will be read, so the OS tunes readahead for those pages only. Here the hint is computed from the wrong starting byte.

Background: chunks, slices and read advice

  1. MMapDirectory maps each file in chunks of maxChunkSize bytes: 16 GiB by default on 64-bit JVMs, or a smaller chunk size passed to its constructor (rounded down to a power of two).
  2. A slice is a window [offset, offset + length) of an open file. A compound file (.cfs) packs a segment's small files into one, and Lucene reads each inner file through a slice.
  3. With setReadAdvice (for example ADVISE_BY_CONTEXT), a slice opened with an IOContext gets its own madvise hint: RANDOM turns readahead off for its pages, SEQUENTIAL makes it more aggressive. Lucene advises only the part of the slice that lies in its first chunk.
Diagram of the bug: a 2 MiB file mapped as chunk 0 [0, 1 MiB) and chunk 1 [1 MiB, 2 MiB); a slice of [512 KiB, 1.5 MiB) opened with a RANDOM hint keeps all of chunk 0 and remembers that it starts 512 KiB into it. The hint should cover [512 KiB, 1 MiB), the slice's part of its first chunk; because advise() ignores that 512 KiB offset it covers [0, 1 MiB), half of it before the slice. Reads stay correct.
A 2 MiB file in two 1 MiB chunks and the slice [512 KiB, 1.5 MiB): the RANDOM hint should cover [512 KiB, 1 MiB) but starts at the chunk start and covers [0, 1 MiB). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Open an MMapDirectory with 1 MiB chunks and setReadAdvice(ADVISE_BY_CONTEXT), write a 2 MiB file and open it normally. The file is mapped as two chunks, [0, 1 MiB) and [1 MiB, 2 MiB).
  2. Take slice("inner", 512 KiB, 1 MiB, context with DataAccessHint.RANDOM), the call the compound-file reader makes for each inner file. The slice is bytes [524,288, 1,572,864): it starts halfway into chunk 0 and ends halfway into chunk 1.
  3. You expect MADV_RANDOM only on the slice's own pages. Lucene advises only the part of a slice in its first chunk, by design, so that is [524,288, 1,048,576).
  4. Instead the random-read flag lands on [0, 1,048,576): the 512 KiB before the slice get it. The slice remembers that it starts 512 KiB into chunk 0, and prefetch() adds that offset (fixed in #14109), but the advice call does not.
  5. In a compound file those bytes belong to other inner files: an .fdt hint covered the compound header and 11 other inner files. A short slice that straddles a chunk boundary can get none of its own pages advised. Every byte read stays correct.

Who is affected

MMapDirectory users on Linux or macOS with a 64-bit JVM who set a read-advice function (opt-in since 10.3.0; on 10.1-10.2.x the defaults already advise compound files). An inner file of at least a page must start inside a chunk and reach or cross its end: a smaller maxChunkSize, or a compound file over 16 GiB. Performance only; slowdown not measured.

Mechanism, terms and numbers890 words

MMapDirectory maps a file in chunks (16 GiB by default on 64-bit JVMs, smaller when the application passes a maxChunkSize). With a read-advice function set, IndexInput#slice(name, offset, length, IOContext) gives the slice its own madvise hint, and the compound-file reader uses it so that each inner file advises its own pages. A slice that starts inside a chunk and reaches or crosses that chunk's end keeps the whole first chunk and remembers where it starts inside it, in MultiSegmentImpl.offset. slice(..., IOContext) then advises the slice from its offset 0, and advise() never adds that in-chunk start, so the hint starts at the chunk start instead of the slice start. #14109 fixed the same omission for prefetch() only. In the repro (1 MiB chunks) a slice of [512 KiB, 1.5 MiB) with a RANDOM hint puts MADV_RANDOM on [0, 1 MiB). In a compound file those bytes belong to other inner files, and a short slice straddling a chunk boundary can get none of its own pages advised. Every byte read and every query result stays correct: it is a performance defect, needing a non-NORMAL read advice (opt-in on 10.3.0+ and main, the default on 10.1-10.2.x) and a slice of at least one page that starts inside a chunk and reaches or crosses its end. Affected: every 10.x release and main. No slowdown was measured.

Where it happens (main 645d4fe14a20, store/MemorySegmentIndexInput.java)

  1. The slice keeps the whole first chunk. buildSlice (:653-700) copies the chunks the slice touches (:663-667), truncates the last one (:670) and keeps only the in-chunk start, offset = offset & chunkSizeMask (:672). A single-chunk slice cuts its segment to exactly the slice (slices[0].asSlice(offset, length), :680); a multi-chunk slice passes offset to new MultiSegmentImpl(...) (:688-698), which stores it (:848, :871).
  2. The slice is advised from its offset 0. slice(String, long, long, IOContext) (:623-650) returns early for NORMAL advice (:627-630) and for slices shorter than a page (:632-640), then calls:
slice.advise(                                   // :642
    0,
    slice.length,
    segment -> {
      nativeAccess.madvise(segment, advice);
      return true;
    });
  1. advise() does not know about the slice offset. It takes segments[(int) (offset >> chunkSizePower)] (:405) and offset &= chunkSizeMask (:406), clamps the length to that segment (:408-413), rounds to pages (:414-427) and advises segment.asSlice(offset, length) (:429-430).
  2. MultiSegmentImpl translates every other data position. It adds offset in seek (:886-889), the positional read* methods (:896-919), segmentSliceOrNull (:921-934), buildSlice (:936-940) and prefetch (:942-946, the #14109 fix), and subtracts it in getFilePointer() (:891-894) and in the positions it reports in errors (:880-883). It has no advise override. A whole multi-chunk file and its clones have offset 0 (:93-105), so only slices that start inside a chunk are affected, together with their clones (which keep the offset, :578) and slices of them (buildSlice adds the parent's offset, :936-940). A slice that ends exactly on a chunk boundary also gets a 0-byte second segment (:665-670), so it is a MultiSegmentImpl too.

The numbers (the minimal repro)

value
chunk size C1,048,576 (1 MiB)
file2 MiB: chunk 0 = [0, 1048576), chunk 1 = [1048576, 2097152)
sliceoffset 524,288, length 1,048,576: file bytes [524288, 1572864)
slice's segments[0], MultiSegmentImpl.offsetall of chunk 0; 524,288
advise(0, 1048576) todaysegments[0] from 0, length 1 MiB: file bytes [0, 1048576)
advise(524288 + 0, 1048576) with the fixfrom 524,288, length clamped to 524,288: [524288, 1048576)

In general: slices shorter than a page are never advised (:632-640). A longer slice that starts at in-chunk offset o > 0 of chunk k and ends at or after (k+1)*C is advised on [k*C, k*C + min(length, C)), page-rounded, instead of its part of the first chunk, [k*C + o, (k+1)*C) (with the fix, a start that is not page-aligned is rounded down to its page). Single-chunk slices and slices that start on a chunk boundary (o = 0) are correct; they passed as controls wherever a verifier included them (the first independent reproducer both; the cross-model reviews a single-chunk control).

Other shapes executed

casechunkslice (file bytes)hint landed oncorrect range
starts 768 KiB into a chunk2 MiB[2883584, 4980736)[2097152, 4194304)[2883584, 4194304)
short slice straddling a boundary2 MiB[6029312, 6553600)[4194304, 4718592): none of the slice[6029312, 6291456)
IndexWriter, default codec, compound file: the .fdt inner file (400 docs)1 MiB[268352, 1915452) of the .cfs[0, 1048576): the compound header and 11 other inner files[268352, 1048576)
the same, one flushed segment (2,000 docs)1 MiB[900032, 3919682) of the .cfs[0, 1048576): the 900,032 bytes (about 879 KiB) before .fdt, including whole pages of .tip, .pos, .tim[900032, 1048576)
ends exactly on the chunk boundary (resolution probe)1 MiB[524288, 1048576)[0, 524288): none of the slice[524288, 1048576)
a slice of a slice: parent [262144, 2097152) without advice (resolution probe)1 MiB[524288, 1572864)[0, 1048576)[524288, 1048576)

On main those other inner files ask for no RANDOM hint; in the default codec only stored fields (.fdt), term vectors and the kNN vector files do.

Exposure and likelihood494 words
  • Effect. MADV_RANDOM turns readahead off for the pages it covers, so sequential reads of the neighbouring inner files (for example a merge reading .tim or .pos) take more, smaller page faults. ADVISE_BY_CONTEXT returns SEQUENTIAL for MERGE and FLUSH contexts (MMapDirectory.java:159-161), which makes readahead more aggressive on pages the slice does not own. A short slice straddling a chunk boundary can get no hint on its own pages while its readAdvice field still says RANDOM, which isEfficientFor() trusts (MemorySegmentIndexInput.java:381-392) when the compound reader decides whether to reuse a mapping (Lucene90CompoundReader.java:196). None of this was measured.
  • Correctness is unaffected. The hint is NORMAL, RANDOM or SEQUENTIAL on a read-only mapping of the same file. Reads go through the slice's own segments and offset, which the bug does not touch.
  • Platform. madvise is used only on Linux and macOS (NativeAccess.java:41-46) and only on 64-bit JVMs (PosixNativeAccess.java:66-67).
  • Default vs opt-in. On main and 10.3.0-10.6.0 the default read-advice function returns Optional.empty() (MMapDirectory.java:172-173), which maps to Constants.DEFAULT_READADVICE, NORMAL (MMapDirectory.java:326-328, Constants.java:186-189), and NORMAL advises nothing. It takes an explicit setting: in practice setReadAdvice, for example with ADVISE_BY_CONTEXT (MMapDirectory.java:157-170). The other setting, the system property org.apache.lucene.store.defaultReadAdvice (Constants.java:181-189), gives every file and context the same advice, so the whole compound file already carries it and the misplaced hint changes nothing visible (by reading). On 10.1.0-10.2.2 the default settings already advise compound inner files. On 10.0.0 only direct slice(..., ReadAdvice) calls are affected.
  • Callers. The in-tree callers of slice(..., IOContext) are the compound readers: Lucene90CompoundReader (:195, :207, :210) and SimpleText's compound format (SimpleTextCompoundFormat.java:138-139, a debugging codec); applications and custom codecs may call it too.
  • Layout. The slice must start inside a chunk and reach or cross that chunk's end. With the default 16 GiB chunk (MMapDirectory.java:185-186) that needs a compound file above 16 GiB, which the defaults make unusual: by default a merged segment is a compound file only up to 64 MB with byte-size merge policies such as the default TieredMergePolicy, or up to 65,536 documents with LogDocMergePolicy (CompoundFormat.java:38-42, :163-180). Realistic triggers are a smaller maxChunkSize (public constructor MMapDirectory(Path, long); the class javadoc recommends reducing the chunk size when mapping fails, MMapDirectory.java:49-50, though on 64-bit it says the chunk "should always be large (like 16 GiBytes)", :241-242) or raised compound-file thresholds together with segments above 16 GiB (the default TieredMergePolicy keeps merged segments below 5 GB, TieredMergePolicy.java:77, so that also takes a larger maximum segment size or a forceMerge). With 1 MiB chunks the end-to-end tests needed only 400 and 2,000 documents.
  • Size of the misplaced range. At most one chunk (min(length, C)) of the same file. In the compound-reader path it happens once per inner-file open (segment open, merge), not per query; other callers of the public API choose their own frequency.

Minimal repro

One JUnit test on the public API: an MMapDirectory with 1 MiB chunks and ADVISE_BY_CONTEXT, a 2 MiB file, one slice [512 KiB, 1.5 MiB) with a RANDOM hint; then it reads which file bytes carry the random-read flag (rr) in /proc/self/smaps. Linux only (it skips elsewhere); deterministic; fails with assertions on and off.

TestMultiChunkSliceReadAdvice.java (one test)64 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.store;

import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.Constants;

public class TestMultiChunkSliceReadAdvice extends LuceneTestCase {

  public void testRandomAdviceStaysInsideTheSlice() throws Exception {
    assumeTrue("needs Linux madvise", Constants.LINUX && MMapDirectory.supportsMadvise());
    int chunk = 1 << 20;
    Path path = createTempDir();
    try (MMapDirectory dir = new MMapDirectory(path, chunk)) {
      dir.setReadAdvice(MMapDirectory.ADVISE_BY_CONTEXT);
      try (IndexOutput out = dir.createOutput("data", IOContext.DEFAULT)) {
        out.writeBytes(new byte[2 * chunk], 2 * chunk);
      }
      try (IndexInput in = dir.openInput("data", IOContext.DEFAULT)) {
        long start = chunk / 2, end = start + chunk;
        in.slice("inner", start, chunk, IOContext.DEFAULT.withHints(DataAccessHint.RANDOM));
        String file = path.resolve("data").toRealPath().toString();
        long mapStart = -1, mapEnd = -1;
        for (String line : Files.readAllLines(Path.of("/proc/self/smaps"))) {
          String[] parts = line.split("\\s+");
          if (line.endsWith(" " + file)) {
            String[] addr = parts[0].split("-");
            mapStart = Long.parseLong(parts[2], 16);
            mapEnd = mapStart + Long.parseLong(addr[1], 16) - Long.parseLong(addr[0], 16);
          } else if (line.startsWith("VmFlags:") && mapStart >= 0) {
            assertTrue(
                "expected RANDOM advice only inside the slice ["
                    + start
                    + ", "
                    + end
                    + "), but file bytes ["
                    + mapStart
                    + ", "
                    + mapEnd
                    + ") got it",
                (line + " ").contains(" rr ") == false || (mapStart >= start && mapEnd <= end));
            mapStart = -1;
          }
        }
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestMultiChunkSliceReadAdvice.java lucene/core/src/test/org/apache/lucene/store/
./gradlew :lucene:core:test --tests org.apache.lucene.store.TestMultiChunkSliceReadAdvice -Ptests.seed=DEADBEEF
# assertions off (production): add -Ptests.asserts=false; the result is the same
# without -Ptests.asserts Gradle picks the assertion mode from the seed (on for ~75% of seeds); DEADBEEF picks on
Expected
MADV_RANDOM only on bytes inside the slice [524288, 1572864); with the fix it lands on [524288, 1048576).
Observed
File bytes [0, 1048576) carry the random-read flag: the 512 KiB before the slice got the hint.

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

More files, environment, full output314 words

The test observes madvise through the per-mapping VmFlags in /proc/self/smaps (rr = random read), a practical way to see it from Java without changing Lucene, so it assumes Linux and MMapDirectory.supportsMadvise(); it ran (0 skipped) in every recorded run. The file is opened with IOContext.DEFAULT, which ADVISE_BY_CONTEXT maps to NORMAL, so nothing is advised on open and the only RANDOM hint is the slice's. The test asserts only that the hint does not land outside the slice, because read advice is best effort; a probe shows the fixed code still advises the slice's own part of chunk 0 ([524288, 1048576)), so the pass is not vacuous on the tested host. The test logic draws no random values (LuceneTestCase still randomizes locale, codec and other settings per seed); seeds DEADBEEF and 5EED1234 give the same failure. The same test is the suggested regression test (for example as a method of TestMMapDirectory). One gap as a regression test: it would also pass if smaps listed no matching mapping or no rr flag at all, and an unreadable /proc/self/smaps fails it instead of skipping it; a committer may want an extra check that the slice's own part carries rr (true with the fix, per the probe).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on the 10.6.0 and 10.5.2 release jars
jdkOpenJDK 25.0.3
osLinux aarch64, 4 KiB pages

Observed output

# Gradle, main 645d4fe14a20, stock (the same with -Ptests.asserts=false)
TestMultiChunkSliceReadAdvice > testRandomAdviceStaysInsideTheSlice FAILED
    java.lang.AssertionError: expected RANDOM advice only inside the slice [524288, 1572864), but file bytes [0, 1048576) got it
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.store.TestMultiChunkSliceReadAdvice.testRandomAdviceStaysInsideTheSlice(TestMultiChunkSliceReadAdvice.java:47)
1 test completed, 1 failed

# with the fix: 1 test completed, 0 failed (assertions on and off, 0 skipped)
# probe of the rr-flagged file ranges: main [0, 1048576); with the fix [524288, 1048576)
# 10.6.0 and 10.5.2 release jars, -ea and -da: the same AssertionError

Why it looks unintended

  • The compound reader slices each inner file "so inner files advise their own pages"; slice() even skips sub-page files to spare neighbours' advice.
  • #14109 fixed the same missing offset in prefetch() as a bug (10.2.0); advise() was missed.
  • "Advice is best effort" covers advice that stops at the first chunk or is page-rounded, not advice moved before the slice.
The full argument224 words
  • The API exists to advise one inner file. The IndexInput#slice(String, long, long, IOContext) javadoc: "This is typically used by CompoundFormat implementations to modify the IOContext for specific files within the compound file" (IndexInput.java:126-139).
  • The code says neighbours must not be advised. slice(..., IOContext) skips slices shorter than a page because advising them means "potentially overriding the advice of other files that share the same pages" (MemorySegmentIndexInput.java:633-638). The compound reader opens its extra mapping "without advice, like handle, so inner files advise their own pages" (Lucene90CompoundReader.java:202). In the #16883 review, jimczi wrote that "each inner file advises its own pages through its slice".
  • The same omission was fixed as a bug. #14109, "prefetch may select the wrong memory segment for multi-segment slices", is under Bug Fixes for 10.2.0 (lucene/CHANGES.txt:1998-1999): "When sliced, a multi-segment index input uses an offset into the initial memory segment. This offset should be added to the prefetch offset to determine the absolute offset." uschindler's review there: "it's way too easy to add new methods but forget to look at code of methods ... prefetch just works". That fix overrode prefetch() only.
  • No test pins the current range. TestMMapDirectory#testPrefetchWithMultiSegment "does not verify that the actual segment is prefetched" (TestMMapDirectory.java:468). TestMMapDirectory, TestMultiMMap and TestLucene90CompoundFormat pass on the stock jar and with the fix.
The devil's-advocate check204 words

The strongest case for "intended": read advice is a best-effort hint. The javadoc says "only certain IOContext may be usable here" (IndexInput.java:130-131); advise() deliberately advises only the first chunk ("this case is rare enough that we don't try to optimize it and keep things simple", MemorySegmentIndexInput.java:409-411); the sub-page comment accepts that an inner file can override a neighbour's advice on shared pages; and the #16883 review deferred rounding the advised range inwards (jimczi: "We could, by rounding the advised range inwards so only pages fully inside the inner file get advised ... Could we do it in a follow up?").

Why it fails: each accepted imprecision is advice that does not reach far enough (later chunks) or one shared page at each end of an inner file. This defect moves the window up to a whole chunk before the slice, onto other files, and can miss the slice entirely; rounding inwards would act inside the same wrong window. The same missing offset in advise()'s other caller was treated as a bug. The works-as-designed reviewer (OpenAI GPT-5.6) rated the intended argument "moderate" and still judged the bug real; the devil's-advocate verifier found no argument that survives. Verdict: a defect, low severity.

Suggested fix

Recommended Override advise() in MultiSegmentImpl; let prefetch() rely on it

Give MultiSegmentImpl an advise() override that adds the slice's in-chunk offset, as its seek, read* and buildSlice already do, and drop the same addition from its prefetch() override, which goes through advise(). Validated: the repro passes with assertions on and off, and 180 tests of the closest suites and the core lint checks pass.

   @Override
   public boolean prefetch(long offset, long length) throws IOException {
     Objects.checkFromIndexSize(offset, length, this.length);
-    return super.prefetch(this.offset + offset, length);
+    return super.prefetch(offset, length);
+  }
+
+  @Override
+  boolean advise(long offset, long length, IOFunction<MemorySegment, Boolean> advice)
+      throws IOException {
+    // offsets are relative to this slice; segments[0] starts this.offset bytes before it
+    return super.advise(this.offset + offset, length, advice);
   }
All options and trade-offs3 options ยท 535 words

One option was validated through Gradle in a scratch worktree of 645d4fe14a20; the study had validated the same diff by shadowing the class.

Override advise() in MultiSegmentImpl; let prefetch() rely on it (validated by the checks) (recommended)

In MemorySegmentIndexInput.MultiSegmentImpl: advise() adds this.offset, and the prefetch() override keeps its bounds check but passes the slice-relative offset, because the base prefetch() calls advise() (:366), which now adds the offset once. Both callers of advise() then pass slice-relative offsets, the convention of every other MultiSegmentImpl method. +8/-1 lines in one file.

   @Override
   public boolean prefetch(long offset, long length) throws IOException {
     Objects.checkFromIndexSize(offset, length, this.length);
-    return super.prefetch(this.offset + offset, length);
+    return super.prefetch(offset, length);
+  }
+
+  @Override
+  boolean advise(long offset, long length, IOFunction<MemorySegment, Boolean> advice)
+      throws IOException {
+    // offsets are relative to this slice; segments[0] starts this.offset bytes before it
+    return super.advise(this.offset + offset, length, advice);
   }

Trade-offs

  • prefetch() advises the same absolute range as before.
  • Nuance: a multi-chunk slice whose start is not page-aligned now rounds its first page down (with chunks of at least a page, its segments[0] starts on a page boundary), so up to pageSize-1 bytes of the preceding inner file can still get the hint, while a single-chunk slice skips the shared first page (MemorySegmentIndexInput.java:414-427). That is the one-shared-page limitation the #16883 review accepted; the third option would make both consistent.
  • Main only: on 10.x advise() is void advise(long, long, IOConsumer<MemorySegment>) and prefetch() returns void (10.6.0 MemorySegmentIndexInput.java:404, :962), so a backport needs the same change with those signatures.
  • No benchmark was run.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20, seed DEADBEEF: without the fix the repro fails with assertions on and off; with it the repro passes with assertions on and off (-Ptests.asserts=false), 0 skipped.
  • With the fix, the closest suites pass: TestMMapDirectory (70), TestMultiMMap (73), TestLucene90CompoundFormat (36) and the repro: 180 tests, 0 failures, 1 skipped (testLargeCFS, @Nightly).
  • :lucene:core:checkGoogleJavaFormat, ecjLintMain and ecjLintTest pass with the repro test in place.
  • In the study, the same diff alone passed the verifiers' own tests (including an end-to-end compound-file test) and 178 upstream tests with -ea (the refute reviewer's own tests also with -da; the study's -da run of the 178 tests combined it with another fix of the same unit). A probe shows it advises [524288, 1048576) in the repro.

Advise through the parent at the call site

In slice(..., IOContext), call advise(offset, length, ...) on the input being sliced instead of slice.advise(0, slice.length, ...).

Trade-offs

One line, but correct only when the parent's offset is 0 (a whole file, as in the compound reader); a slice of a multi-chunk slice would still be advised wrongly, and it leaves the trap the #14109 review described. Not validated.

Follow-up: round a slice's advised range inwards

Advise only pages fully inside the inner file, as agreed as a follow-up in the #16883 review.

Trade-offs

The helper is shared with prefetch(), which needs outward rounding (jimczi, #16883), so it needs a parameter or a second helper. It does not fix this bug on its own. Not validated.

How it was found

Semi-formal: while writing a Z3 bit-vector model of the MemorySegmentIndexInput chunk arithmetic, the hunting AI tool noticed that MultiSegmentImpl overrides prefetch() but not advise(), and the solver witnessed it (the advised range starts before the slice). A JUnit reading /proc/self/smaps then showed it on the stock jars.

The method in full155 words

Method: semi-formal (a hypothesis formed while modelling, then witnessed by the solver). A hunt unit modelled the chunk arithmetic of MemorySegmentIndexInput (seek, slice, prefetch and advise ranges) in Z3 bit-vector arithmetic, 44 queries.

  • While writing the model the AI tool noted that MultiSegmentImpl overrides prefetch() (the #14109 fix) but not advise(). One query, declared expect="sat" with that reasoning in its comment, was SAT: the range slice(..., IOContext) advises starts at the chunk start, before the slice. All 44 queries matched their declared expectation; it is the only one about advise(), and the prefetch range proofs hold (the other counterexample queries concern different findings of the same unit).
  • Because the hypothesis came before the solver's answer, the finding is classed as semi-formal, not formal.
  • The defect was then executed on the stock jars by reading the per-mapping random-read flag (rr) from /proc/self/smaps, and reproduced by every verifier with its own test.

Prior reports, history and checks

Prior reports

No matching public report found in GitHub issues and PRs, every open PR's changed files, commits, the mailing lists, JIRA and three downstream projects, searched 2026-10-25/26 and again 2026-11-01. The sibling #14109 fixed the same missing offset in prefetch() only.

Searches6 searches

Closest items, none the same bug: #14109 (the sibling); #13467 (introduced the code); #13907 and #13917 (compound slices take advice; the sub-page guard); #16883 (the one-shared-page limitation and the deferred "round inwards" follow-up, by design and not this bug); #14281 (open, an unexplained merge regression after #13907, nothing about chunk offsets); #14463 and #16213 (which advice is applied, not where). No maintainer has said this bug was known. Limits: GitHub search does not index diffs; two broad queries were cut at 50 results.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-25/26, 2026-11-01the scripted prior-report search (40 GitHub queries in the latest run, comments of the top 20 hits read) and 46 direct searches, for example MultiSegmentImpl, "slice.advise", sliceWithAdvice, ADVISE_BY_CONTEXT, in:title advise / madvise / ReadAdvice / multi-segmentno report of this defect
Open PRs2026-11-01changed-file lists of all 410 open PRs and of every PR in the 127 issues and PRs updated since 2026-10-25several touch MemorySegmentIndexInput.java; none changes the advise() offset
Commits and branches2026-11-01commit-message queries; git log -S'super.advise(' over the full history of MemorySegmentIndexInput.java (and over all refs under lucene/core/src in the cross-model fact check); main, branch_11x and branch_10_6 contentsno advise() override has ever existed; not fixed upstream (main, branch_11x and branch_10_6; branch_10x decommissioned)
Mailing lists (lists.apache.org)2026-10-26, 2026-11-01dev@, java-user@, issues@ and commits@ (32 queries in the latest run), and the hunt's own dev@ threadno report
JIRA archive2026-11-016 queriesnone; the madvise code postdates the migration
Downstream (Elasticsearch, OpenSearch, Solr)2026-10-26, 2026-11-01repository searchesno report

History

Introduced in 10.0.0 by #13467 (cfbf8d93df81, merged 2024-06-21), which added the slice-with-advice API and the slice.advise(0, ...) call; compound files reach it since 10.1.0 (#13907). Every 10.x release and main are affected; not fixed upstream.

Versions tested6 rows

Introduced by apache/lucene#13467, "Honor read advice on compound files." (cfbf8d93df81, jpountz, merged 2024-06-21 before the 10.0 branch was cut; earliest tag 10.0.0). It added IndexInput#slice(String, long, long, ReadAdvice), turned the prefetch() body into the shared advise() helper and added the slice.advise(0, slice.length, ...) call. The defect was born with the API, so the before/after pair is the releases 9.12.0 (no API) and 10.0.0 (fails).

Related: #13337 / #13363 (2024-05, prefetch() and the segment-selection lines advise() reuses); #13907 (e2636c648df, 2024-10-15, "Only call madvise when necessary.": the compound reader slices each inner file with its own advice, from 10.1.0); #14109 (d4f0a3244e5, 2025-01-08, the sibling fix in prefetch() only, 10.2.0); #14482 (bb76f0a2d98, 2025-04-30, slice(..., IOContext) replaces the ReadAdvice overload and keeps the call); #15040 (cefc6c7cfbf, 2025-08-08, default advice NORMAL, so 10.3.0+ needs opt-in). The releases not executed (10.2.0, 10.2.1, 10.3.1-10.5.1) have the same code by source identity.

Versions and branches executed, with results
Version or commitVerdictResult
9.12.0, 9.12.3 not affectedno slice-with-advice API: the probes do not compile; the compound test passes
10.0.0 direct calls onlya direct slice(..., ReadAdvice) test fails (-ea and -da); the compound reader sliced without advice
10.1.0, 10.2.2 default settingsdirect tests fail; a default-settings compound test fails (-ea and -da) and passes for the same index with 64 MiB chunks
10.3.0, 10.5.2, 10.6.0 opt-infails with setReadAdvice(ADVISE_BY_CONTEXT); 10.5.2 and 10.6.0 with -ea and -da
main 645d4fe14a20 affectedthe repro fails with -ea and -da, standalone and through Gradle
main + fix (scratch worktree) not affectedthe repro passes with -ea and -da; 180 tests of the closest suites pass

Independent checks

Confirmed with high confidence: an independent reproducer, an intended-behaviour devil's advocate, reviews by another model family (OpenAI GPT-5.6) and an adjudication found it real, each with its own or a re-run test. Re-run on 645d4fe14a20; the fix validated through Gradle.

Checks and verdicts262 words

No executed check dissented; all agreed it is performance only. A first attempt at the refute review could not run code and returned a procedural "not real", low confidence; a second attempt ran its own tests and found it real.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateA Z3 query is SAT (the advised range starts at the chunk start); its JUnit fails on main with -ea and -da and on 10.3.0 and 10.5.2; passes with the fix.
review (OpenAI GPT-5.6, refute lens) real, highIts own test fails with -ea and -da and on 10.3.0 and 10.5.2, its single-chunk control passes; the fix passes its tests and 178 upstream tests.
independent reproducer realOwn 5-test class from the claim only: 3 fail (mid-chunk start, short straddling slice, end-to-end compound file), 2 controls pass; on main, 10.5.2, 10.3.0 and, ported, 10.2.2 and 10.1.0 (10.0.0: the two direct cases); 5 of 5 pass with the fix.
works-as-designed review (OpenAI GPT-5.6) real, highRated the best-effort-hint argument moderate and rejected it: it explains missing or page-rounded advice, not advice moved before the slice.
intended-behaviour devil's advocate real, low severityNo intended-behaviour argument survives; its end-to-end compound test fails on main and 10.5.2 and passes with the fix.
adjudication confirmed, highRe-ran the independent reproduction's test with seed 5EED1234 (3 of 5 fail with -ea and -da, 0 with the fix) and the finder's repro with -da.
re-verification still reproducesMain a9779ef3c193 (-ea); main 645d4fe14a20, 10.5.2 and 10.6.0 (-ea and -da).

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

Finding record

LFV-126, snapshot of 2026-11-06

Registry title
MMapDirectory applies a slice's read advice to the wrong bytes when the slice starts inside an mmap chunk and crosses into the next one (the #14109 prefetch fix missed this caller of advise())
Area
store / MMapDirectory read advice (MemorySegmentIndexInput slices, compound files)
Priority
Low, rank 1033: MMapDirectory applies a slice's read advice to the wrong bytes when the slice crosses an mmap chunk boundary, performance only and needing non-default advice plus a non-default chunk size or a compound file over 16 GiB.
Impact class
minor
Impact note
Applications that use MMapDirectory where madvise is available (Linux or macOS, 64-bit JVM, native access) with a non-NORMAL read advice for some files (opt-in on 10.3+ and main through setReadAdvice, for example ADVISE_BY_CONTEXT; the default on 10.1-10.2.x) and that have a compound-file inner file, or a direct IndexInput#slice(..., IOContext) call, that starts inside an mmap chunk and crosses into the next one (a non-default maxChunkSize, or a compound file larger than the 16 GiB default chunk); performance only, deterministic with -ea and -da: up to one chunk of neighbouring bytes of the same file gets the hint and a short slice can get none of its own pages advised, while every byte read and every query result stays correct; the real-world slowdown was not measured; released since 10.0.0.
Summary
You take a slice of an MMapDirectory file with IndexInput#slice(name, offset, length, IOContext) and a context that asks for RANDOM or SEQUENTIAL read advice, as the compound-file reader does for each inner file, and you expect the madvise hint to land on the slice's own pages. When the slice starts inside an mmap chunk and crosses into the next chunk, the hint is applied from the start of that chunk instead: in the repro, a slice of [512 KiB, 1.5 MiB) with 1 MiB chunks puts the RANDOM hint on [0, 1 MiB), so other inner files of a compound file get it, and a short slice that straddles a chunk boundary can get none of its own pages advised. Reads and query results stay correct; only the operating system's readahead and caching behaviour on those pages changes.
Introduced
cfbf8d93df81 (apache/lucene#13467 'Honor read advice on compound files.', author jpountz, merged on main before the 10.0 branch was cut, no separate backport) added IndexInput#slice(String, long, long, ReadAdvice), the shared advise() helper and the call slice.advise(0, slice.length, ...) that advises from the start of segments[0]; the helper's segment selection was moved out of prefetch() (#13337 3003731aa942, #13363 46f1f95ceb03), which had the same missing MultiSegmentImpl offset until #14109 (d4f0a3244e5 on main, cfeba19994e on branch_10x, first released in 10.2.0) fixed it by overriding prefetch() only [read: git log -S 'slice.advise(' and -S 'madvise(segment, advice)' over both file paths return only cfbf8d93df8; git blame -w -M -C; the defect was born with the API, so no before/after execution at commit level is possible]; the compound-file path became reachable with #13907 (e2636c648df on main, b8bfffa3681 on branch_10x, 10.1.0); #14482 (bb76f0a2d98) replaced the ReadAdvice overload by slice(..., IOContext) and kept the same call (2024-06-21 (#13467 merged); compound-file reach 2024-10-15 (#13907); the sibling prefetch() fix #14109 on 2025-01-08 left this caller unchanged)
First affected version
10.0.0 [exec] for direct IndexInput#slice(..., ReadAdvice) calls; 10.1.0 [exec] for the compound-file path (#13907)
Affected versions
every 10.x release: 10.0.0 through 10.6.0 (13 releases on Maven Central) and main ea57360912ed; executed failing with direct-slice repros (each with a passing single-chunk control): 10.0.0, 10.1.0, 10.2.2 (-ea and -da), 10.3.0, 10.5.2, 10.6.0 (-ea and -da, 2026-11-01) and main (-ea and -da) [exec]; a default-settings compound-file test (no setReadAdvice) fails on 10.1.0 and 10.2.2 (-ea and -da) and passes for the same index with 64 MiB chunks, while on 10.3.0, 10.5.2 and main it passes because nothing is advised without opt-in [exec]; on 10.0.0 the compound reader sliced without advice, so only direct calls are affected there [exec]; the other 10.x releases by source identity (every 10.x tag has the slice.advise(0, ...) call and no advise() override in MultiSegmentImpl) [read]; unaffected: 9.12.3 and every earlier release, which have no slice-with-advice API (the repro does not compile on 9.12.3; the compound test passes) [exec]; branch_10x and branch_10_6 have the same code [read, upstream contents, 2026-10-26], and 10.6.0 (released 2026-10-29) is affected [exec 2026-11-01: the minimal test fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da]; not fixed upstream [read]
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: Z3, code reading
Upstream and prior reports
none found (2026-10-25 and 2026-10-26: scripted prior-report searches by the finder (33 queries: 16 GitHub issue/PR, 3 commit, 10 mailing-list, 4 JIRA) and in a later round (45 queries: 16 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list, 4 JIRA, comments of the top 15 hits read; plus a 24-query lists-and-JIRA re-run); about 20 direct GitHub searches (among them MultiSegmentImpl, "slice.advise", madvise maxChunkSize, in:title madvise, in:title ReadAdvice, in:title multi-segment) and 5 downstream-repo searches (Elasticsearch, OpenSearch, Solr); the independent reproduction's and the intended-behaviour check's own GitHub searches and the intended-behaviour check's 15 mailing-list queries; the changed-file lists of all 413 open apache/lucene PRs (#16804, #16145, #15556, #14371, #14156, #13981 and #13133 touch MemorySegmentIndexInput.java but not the advise() offset); every apache/lucene issue and PR updated since 2026-09-01; direct lists.apache.org searches of dev@, java-user@, issues@ and commits@; git log -S 'super.advise(' over the full history is empty; main, branch_10x and branch_10_6 have no MultiSegmentImpl.advise override); related prior items: #14109 (sibling: the same missing MultiSegmentImpl offset, fixed in prefetch() only, 10.2.0), #13467 (introduced the code), #13907 (made the compound-file path reachable), #16883 (review deferred rounding the advised range inwards: a by-design one-page-per-side limitation, a different issue), #14281 (open, unexplained merge regression after #13907, no link to chunk offsets), #14463 and #16213 (which advice is applied, not where)
Lucene commit tested
main 645d4fe14a20: the one-test repro fails with assertions on and off, as a standalone JUnit run and through Gradle in a scratch worktree; with the fix it passes, and so do TestMMapDirectory, TestMultiMMap and TestLucene90CompoundFormat (180 tests) and the core format and ecj lint checks. Release jars: 10.6.0 and 10.5.2 fail with assertions on and off; the study and the history check also ran 10.0.0, 10.1.0, 10.2.2 and 10.3.0 (fail) and 9.12.0 / 9.12.3 (no API). The hunt itself ran on main ea57360912ed, where MemorySegmentIndexInput.java and Lucene90CompoundReader.java are byte-identical. Linux aarch64 with 4 KiB pages, OpenJDK 25.0.3.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/store/MemorySegmentIndexInput.java:394-432 (advise(): segments[(int) (offset >> chunkSizePower)] and offset &= chunkSizeMask at :405-406, no MultiSegmentImpl offset; first-segment-only and page-rounding comments), :623-650 (slice(String, long, long, IOContext): NORMAL and sub-page early returns, 'potentially overriding the advice of other files that share the same pages', slice.advise(0, slice.length, ...) at :642-648), :653-688 (buildSlice keeps the whole first chunk as segments[0] and stores the in-chunk start, :672), :847 (MultiSegmentImpl), :936-946 (MultiSegmentImpl overrides buildSlice and prefetch, the latter adding this.offset at :945; no advise override)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/store/IndexInput.java:126-140 (slice(String, long, long, IOContext) javadoc: 'typically used by CompoundFormat implementations to modify the IOContext for specific files within the compound file'; 'only certain IOContext may be usable here'; default implementation ignores the context)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90CompoundReader.java:187-210 (inner files sliced with their own context, :195, :207, :210; comment 'so inner files advise their own pages', :202)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/store/MMapDirectory.java:44-50 (reduce the chunk size if mapping fails), :157-173 (ADVISE_BY_CONTEXT, SEQUENTIAL for MERGE and FLUSH contexts; the default read-advice function returns Optional.empty()), :175-186 (DEFAULT_MAX_CHUNK_SIZE 16 GiB on 64-bit JVMs), :235-246 (MMapDirectory(Path, LockFactory, long) javadoc: on 64-bit the chunk 'should always be large (like 16 GiBytes)'); PosixNativeAccess.java:66-67 (madvise bound on 64-bit JVMs only); NativeAccess.java:37-46 (Linux and macOS only)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs (grep DataAccessHint.RANDOM: only Lucene90CompressingStoredFieldsReader (.fdt), Lucene90CompressingTermVectorsReader and the kNN vector formats ask for RANDOM; FieldsIndexReader opens .fdx with FileTypeHint.INDEX only)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/store/MemorySegmentIndexInput.java:623-650, :653-700, :394-436, :846-947, :93-105, :556-592, :665-670
Who is affected
  • lucene/core/src/java/org/apache/lucene/store/MMapDirectory.java:49-50, :157-173, :185-186, :230, :241-242, :326-328
  • lucene/core/src/java/org/apache/lucene/store/NativeAccess.java:41-46; lucene/core/src/java/org/apache/lucene/store/PosixNativeAccess.java:66-67
  • lucene/core/src/java/org/apache/lucene/util/Constants.java:181-189; lucene/core/src/java/org/apache/lucene/codecs/CompoundFormat.java:38-42, :163-180; lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:77
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90CompoundReader.java:187-211
  • lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextCompoundFormat.java:138-139
  • lucene/core/src/java/org/apache/lucene/store/MemorySegmentIndexInput.java:381-392
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/store/IndexInput.java:126-139
  • lucene/core/src/java/org/apache/lucene/store/MemorySegmentIndexInput.java:409-411, :632-640
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90CompoundReader.java:202
  • lucene/CHANGES.txt:1998-1999 (GITHUB#14109 under Lucene 10.2.0 Bug Fixes)
  • lucene/core/src/test/org/apache/lucene/store/TestMMapDirectory.java:462-469
Suggested fix
  • lucene/core/src/java/org/apache/lucene/store/MemorySegmentIndexInput.java:366, :414-427, :623-650, :942-946
  • a Lucene clone: git show releases/lucene/10.6.0:lucene/core/src/java21/org/apache/lucene/store/MemorySegmentIndexInput.java (void advise :404, void prefetch :962)
History
  • a Lucene clone: git show -s cfbf8d93df81, d4f0a3244e5, e2636c648df, bb76f0a2d98, cefc6c7cfbf; git tag --contains cfbf8d93df81 (earliest releases/lucene/10.0.0)

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 minimal repro's numbers at 645d4fe14a20.

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.