You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by ji...@apache.org on 2026/10/01 08:06:32 UTC

(lucene) branch branch_10x updated: Let merges read stored fields sequentially without disturbing searches (#16684)

This is an automated email from the ASF dual-hosted git repository.

jimczi pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/lucene.git


The following commit(s) were added to refs/heads/branch_10x by this push:
     new cd49cbb38d5 Let merges read stored fields sequentially without disturbing searches (#16684)
cd49cbb38d5 is described below

commit cd49cbb38d58dc40489f21b5d24419a432801ae7
Author: Jim Ferenczi <ji...@apache.org>
AuthorDate: Thu Oct 1 09:55:52 2026 +0200

    Let merges read stored fields sequentially without disturbing searches (#16684)
    
    A merge reads stored fields front to back, while searches read the same file at random.
    `getMergeInstance()` clones the mapping the reader already holds, so whether a merge gets sequential
    reads comes down to who opened the segment first. Asking for them in place is not an option, since
    read advice applies to a whole mapping and searches are on it.
    
    So a merge opens the data file for itself with `DataAccessHint.SEQUENTIAL`, and searches keep the
    mapping they have. The original reader owns that second mapping and closes it, since merge instances
    are never closed.
    
    Closes #16213
    Relates #16427
    
    (cherry picked from commit fe67d20e9e96df869d492bfc722a86e71deb42ab)
---
 lucene/CHANGES.txt                                 |   3 +
 .../apache/lucene/codecs/StoredFieldsReader.java   |  10 +-
 .../Lucene90CompressingStoredFieldsReader.java     | 103 ++++++-
 .../org/apache/lucene/index/SegmentMerger.java     |   6 +
 .../index/TestStoredFieldsMergeReadAdvice.java     | 318 +++++++++++++++++++++
 .../asserting/AssertingStoredFieldsFormat.java     |   7 +-
 .../lucene/tests/index/MergingCodecReader.java     |   8 +-
 7 files changed, 443 insertions(+), 12 deletions(-)

diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index ddff87c6242..d6eb6915890 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -114,6 +114,9 @@ Optimizations
   vectors writers now state what file they are writing, and the raw vectors a quantized format keeps
   only to rescore say so as they are written. (Jim Ferenczi)
 
+* GITHUB#16684: Merges now open stored fields for themselves and state that they read the data file
+  sequentially, instead of reusing the mapping searches read at random. (Jim Ferenczi)
+
 * GITHUB#16682: Carry file open hints on merge and flush contexts, which used to drop them. An HNSW
   format now carries the random access hint for the vectors it reads, rather than the flat format
   holding them claiming it whatever reads them. Add NoReuseHint, set by stored fields, term vectors
diff --git a/lucene/core/src/java/org/apache/lucene/codecs/StoredFieldsReader.java b/lucene/core/src/java/org/apache/lucene/codecs/StoredFieldsReader.java
index 0e4d33c5abe..ae33bfc9c22 100644
--- a/lucene/core/src/java/org/apache/lucene/codecs/StoredFieldsReader.java
+++ b/lucene/core/src/java/org/apache/lucene/codecs/StoredFieldsReader.java
@@ -52,7 +52,15 @@ public abstract class StoredFieldsReader extends StoredFields implements Cloneab
    *
    * <p>The default implementation returns {@code this}
    */
-  public StoredFieldsReader getMergeInstance() {
+  public StoredFieldsReader getMergeInstance() throws IOException {
     return this;
   }
+
+  /**
+   * Optional: release what {@link #getMergeInstance()} set up, once the merge is over. Whatever is
+   * not released here is released by {@link #close()}.
+   *
+   * <p>The default implementation is empty
+   */
+  public void finishMerge() throws IOException {}
 }
diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsReader.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsReader.java
index ce47cbbafce..af20a351b4b 100644
--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsReader.java
+++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsReader.java
@@ -39,7 +39,9 @@ import static org.apache.lucene.codecs.lucene90.compressing.Lucene90CompressingS
 import static org.apache.lucene.codecs.lucene90.compressing.Lucene90CompressingStoredFieldsWriter.VERSION_START;
 
 import java.io.EOFException;
+import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.nio.file.NoSuchFileException;
 import java.util.Arrays;
 import org.apache.lucene.codecs.CodecUtil;
 import org.apache.lucene.codecs.StoredFieldsReader;
@@ -98,14 +100,29 @@ public final class Lucene90CompressingStoredFieldsReader extends StoredFieldsRea
   // clustering similar documents together. NOTE: this cache must be small since it's fully scanned.
   private final long[] prefetchedBlockIDCache;
   private int prefetchedBlockIDCacheIndex;
-  private boolean closed;
+  private volatile boolean closed;
+  // what a merge needs to map the data file for itself
+  private final Directory directory;
+  private final String fieldsStreamFN;
+  private final IOContext context;
+  // the reader this one was cloned from, which owns the mapping merges read
+  private final Lucene90CompressingStoredFieldsReader original;
+  private IndexInput mergeFieldsStream;
+  // merge instances handed out and not yet finished
+  private int mergeInstances;
+  // set once this merge instance has released the mapping
+  private boolean mergeFinished;
 
   // used by clone
   private Lucene90CompressingStoredFieldsReader(
-      Lucene90CompressingStoredFieldsReader reader, boolean merging) {
+      Lucene90CompressingStoredFieldsReader reader, boolean merging, IndexInput fieldsStream) {
     this.version = reader.version;
     this.fieldInfos = reader.fieldInfos;
-    this.fieldsStream = reader.fieldsStream.clone();
+    this.fieldsStream = fieldsStream;
+    this.directory = reader.directory;
+    this.fieldsStreamFN = reader.fieldsStreamFN;
+    this.context = reader.context;
+    this.original = reader.original;
     this.indexReader = reader.indexReader.clone();
     this.maxPointer = reader.maxPointer;
     this.chunkSize = reader.chunkSize;
@@ -137,9 +154,11 @@ public final class Lucene90CompressingStoredFieldsReader extends StoredFieldsRea
     boolean success = false;
     fieldInfos = fn;
     numDocs = si.maxDoc();
+    this.directory = d;
+    this.context = context;
+    this.original = this;
 
-    final String fieldsStreamFN =
-        IndexFileNames.segmentFileName(segment, segmentSuffix, FIELDS_EXTENSION);
+    this.fieldsStreamFN = IndexFileNames.segmentFileName(segment, segmentSuffix, FIELDS_EXTENSION);
     ChecksumIndexInput metaIn = null;
     try {
       // Open the data file
@@ -257,11 +276,16 @@ public final class Lucene90CompressingStoredFieldsReader extends StoredFieldsRea
   @Override
   public void close() throws IOException {
     if (!closed) {
-      IOUtils.close(indexReader, fieldsStream);
+      IOUtils.close(indexReader, fieldsStream, mergeFieldsStreamToClose());
       closed = true;
     }
   }
 
+  /** The mapping a merge opened, read under the lock that guards it, closed outside it. */
+  private synchronized IndexInput mergeFieldsStreamToClose() {
+    return original == this && mergeFieldsStream != fieldsStream ? mergeFieldsStream : null;
+  }
+
   private static void readField(DataInput in, StoredFieldVisitor visitor, FieldInfo info, int bits)
       throws IOException {
     switch (bits & TYPE_MASK) {
@@ -699,13 +723,74 @@ public final class Lucene90CompressingStoredFieldsReader extends StoredFieldsRea
   @Override
   public StoredFieldsReader clone() {
     ensureOpen();
-    return new Lucene90CompressingStoredFieldsReader(this, false);
+    return new Lucene90CompressingStoredFieldsReader(this, false, fieldsStream.clone());
   }
 
   @Override
-  public StoredFieldsReader getMergeInstance() {
+  public StoredFieldsReader getMergeInstance() throws IOException {
+    ensureOpen();
+    IndexInput stream = original.mergeFieldsStream();
+    boolean success = false;
+    try {
+      StoredFieldsReader mergeInstance =
+          new Lucene90CompressingStoredFieldsReader(this, true, stream.clone());
+      success = true;
+      return mergeInstance;
+    } finally {
+      if (success == false) {
+        original.releaseMergeFieldsStream();
+      }
+    }
+  }
+
+  /**
+   * The data file as a merge reads it, front to back. Advice applies to a whole mapping, so a merge
+   * maps the file again. Mapped on the first merge, released by {@link #finishMerge()}.
+   */
+  private synchronized IndexInput mergeFieldsStream() throws IOException {
+    assert original == this;
     ensureOpen();
-    return new Lucene90CompressingStoredFieldsReader(this, true);
+    if (mergeFieldsStream == null) {
+      if (context.context() == IOContext.Context.MERGE) {
+        // opened by a merge to begin with, so it already advises sequential reads
+        mergeFieldsStream = fieldsStream;
+      } else {
+        try {
+          mergeFieldsStream =
+              directory.openInput(
+                  fieldsStreamFN,
+                  IOContext.merge()
+                      .withHints(
+                          FileTypeHint.DATA, DataAccessHint.SEQUENTIAL, NoReuseHint.INSTANCE));
+        } catch (@SuppressWarnings("unused") FileNotFoundException | NoSuchFileException e) {
+          // an open reader outlives its files, so fall back to the mapping it already holds
+          mergeFieldsStream = fieldsStream;
+        }
+      }
+    }
+    mergeInstances++;
+    return mergeFieldsStream;
+  }
+
+  /** Releases the mapping once no other merge instance holds it. A later merge maps it again. */
+  @Override
+  public void finishMerge() throws IOException {
+    if (merging == false || mergeFinished) {
+      return;
+    }
+    mergeFinished = true;
+    original.releaseMergeFieldsStream();
+  }
+
+  private synchronized void releaseMergeFieldsStream() throws IOException {
+    assert original == this;
+    if (closed || mergeInstances == 0 || --mergeInstances > 0) {
+      return;
+    }
+    if (mergeFieldsStream != null && mergeFieldsStream != fieldsStream) {
+      mergeFieldsStream.close();
+    }
+    mergeFieldsStream = null;
   }
 
   int getVersion() {
diff --git a/lucene/core/src/java/org/apache/lucene/index/SegmentMerger.java b/lucene/core/src/java/org/apache/lucene/index/SegmentMerger.java
index 321780406ad..deb370228cc 100644
--- a/lucene/core/src/java/org/apache/lucene/index/SegmentMerger.java
+++ b/lucene/core/src/java/org/apache/lucene/index/SegmentMerger.java
@@ -28,6 +28,7 @@ import org.apache.lucene.codecs.KnnVectorsWriter;
 import org.apache.lucene.codecs.NormsConsumer;
 import org.apache.lucene.codecs.NormsProducer;
 import org.apache.lucene.codecs.PointsWriter;
+import org.apache.lucene.codecs.StoredFieldsReader;
 import org.apache.lucene.codecs.StoredFieldsWriter;
 import org.apache.lucene.codecs.TermVectorsWriter;
 import org.apache.lucene.store.Directory;
@@ -334,5 +335,10 @@ final class SegmentMerger {
         reader.finishMerge();
       }
     }
+    for (StoredFieldsReader reader : mergeState.storedFieldsReaders) {
+      if (reader != null) {
+        reader.finishMerge();
+      }
+    }
   }
 }
diff --git a/lucene/core/src/test/org/apache/lucene/index/TestStoredFieldsMergeReadAdvice.java b/lucene/core/src/test/org/apache/lucene/index/TestStoredFieldsMergeReadAdvice.java
new file mode 100644
index 00000000000..44a5e198cb9
--- /dev/null
+++ b/lucene/core/src/test/org/apache/lucene/index/TestStoredFieldsMergeReadAdvice.java
@@ -0,0 +1,318 @@
+/*
+ * 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 java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.lucene.codecs.StoredFieldsReader;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.store.DataAccessHint;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FilterDirectory;
+import org.apache.lucene.store.FilterIndexInput;
+import org.apache.lucene.store.IOContext;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.store.NoReuseHint;
+import org.apache.lucene.store.ReadOnceHint;
+import org.apache.lucene.tests.util.LuceneTestCase;
+
+/**
+ * A merge reads stored fields front to back, while searches read them at random, and read advice
+ * applies to a whole mapping. So a merge opens the data file for itself instead of re-advising the
+ * one searches are reading.
+ */
+public class TestStoredFieldsMergeReadAdvice extends LuceneTestCase {
+
+  public void testMergeOpensItsOwnStoredFields() throws Exception {
+    Opens opens = new Opens();
+    try (Directory dir = new RecordingDirectory(newDirectory(), opens)) {
+      IndexWriterConfig iwc = new IndexWriterConfig();
+      iwc.setUseCompoundFile(false); // so the directory sees the data file by name
+      try (IndexWriter w = new IndexWriter(dir, iwc)) {
+        for (int segment = 0; segment < 2; segment++) {
+          for (int i = 0; i < 64; i++) {
+            Document doc = new Document();
+            doc.add(new StoredField("field", "value " + i));
+            w.addDocument(doc);
+          }
+          w.commit();
+        }
+
+        // searches open the segments first, so the merge finds readers advised for random access
+        try (DirectoryReader reader = DirectoryReader.open(w)) {
+          assertEquals(2, reader.leaves().size());
+          opens.clear();
+          w.forceMerge(1);
+
+          // the merge is over, so what it opened is gone even though the segments are still open
+          List<Open> sequential = sequentialOpens(opens);
+          assertFalse(
+              "the merge never opened stored fields for itself: " + opens, sequential.isEmpty());
+          for (Open open : sequential) {
+            assertTrue("the merge kept its stored fields open: " + open, open.closed());
+          }
+        }
+      }
+
+      assertEquals(
+          "the merge re-advised the stored fields searches are reading: " + opens,
+          List.of(),
+          opens.advised());
+    }
+  }
+
+  /** Nothing depends on the hook: finishing twice, or never, still leaves the mapping sound. */
+  public void testTheHookIsAHint() throws Exception {
+    Opens opens = new Opens();
+    try (Directory dir = new RecordingDirectory(newDirectory(), opens)) {
+      writeDocuments(dir);
+      List<Open> mapped;
+      try (DirectoryReader reader = DirectoryReader.open(dir)) {
+        StoredFieldsReader fields = ((CodecReader) getOnlyLeafReader(reader)).getFieldsReader();
+        opens.clear();
+
+        StoredFieldsReader first = fields.getMergeInstance();
+        StoredFieldsReader second = fields.getMergeInstance();
+        mapped = sequentialOpens(opens);
+        assertEquals("one mapping for both merge instances: " + opens, 1, mapped.size());
+
+        // twice from one instance, and once from a reader that is not a merge instance
+        first.finishMerge();
+        first.finishMerge();
+        fields.finishMerge();
+        assertFalse("still held by the second instance: " + opens, mapped.get(0).closed());
+
+        second.finishMerge();
+        assertTrue("released by the last instance: " + opens, mapped.get(0).closed());
+      }
+    }
+  }
+
+  /** A merge instance nobody finishes, the way an abandoned merge leaves one behind. */
+  public void testTheMappingIsClosedWithTheReader() throws Exception {
+    Opens opens = new Opens();
+    try (Directory dir = new RecordingDirectory(newDirectory(), opens)) {
+      writeDocuments(dir);
+      List<Open> mapped;
+      try (DirectoryReader reader = DirectoryReader.open(dir)) {
+        StoredFieldsReader fields = ((CodecReader) getOnlyLeafReader(reader)).getFieldsReader();
+        opens.clear();
+
+        assertNotNull(fields.getMergeInstance());
+        mapped = sequentialOpens(opens);
+        assertEquals("one mapping for the merge instance: " + opens, 1, mapped.size());
+        assertFalse("still held by the merge instance: " + opens, mapped.get(0).closed());
+      }
+      assertTrue("closed with the reader: " + mapped.get(0), mapped.get(0).closed());
+    }
+  }
+
+  /** A merge open that fails leaves nothing behind, so a later merge still releases the mapping. */
+  public void testAFailedMergeOpenIsNotCountedAsAMergeInstance() throws Exception {
+    Opens opens = new Opens();
+    AtomicBoolean failMergeOpens = new AtomicBoolean();
+    try (Directory dir =
+        new RecordingDirectory(newDirectory(), opens) {
+          @Override
+          public IndexInput openInput(String name, IOContext context) throws IOException {
+            if (failMergeOpens.get()
+                && name.endsWith(".fdt")
+                && context.context() == IOContext.Context.MERGE) {
+              throw new IOException("injected");
+            }
+            return super.openInput(name, context);
+          }
+        }) {
+      writeDocuments(dir);
+      try (DirectoryReader reader = DirectoryReader.open(dir)) {
+        StoredFieldsReader fields = ((CodecReader) getOnlyLeafReader(reader)).getFieldsReader();
+        opens.clear();
+
+        failMergeOpens.set(true);
+        expectThrows(IOException.class, fields::getMergeInstance);
+        assertEquals("a failed open maps nothing: " + opens, List.of(), sequentialOpens(opens));
+
+        failMergeOpens.set(false);
+        StoredFieldsReader merging = fields.getMergeInstance();
+        List<Open> mapped = sequentialOpens(opens);
+        assertEquals("one mapping for the one merge instance: " + opens, 1, mapped.size());
+
+        merging.finishMerge();
+        assertTrue("released by the only merge instance: " + opens, mapped.get(0).closed());
+      }
+    }
+  }
+
+  private static void writeDocuments(Directory dir) throws IOException {
+    IndexWriterConfig iwc = new IndexWriterConfig();
+    iwc.setUseCompoundFile(false); // so the directory sees the data file by name
+    try (IndexWriter w = new IndexWriter(dir, iwc)) {
+      for (int i = 0; i < 64; i++) {
+        Document doc = new Document();
+        doc.add(new StoredField("field", "value " + i));
+        w.addDocument(doc);
+      }
+      w.commit();
+    }
+  }
+
+  /** The opens a merge made for itself: the data file, asked for front to back. */
+  private static List<Open> sequentialOpens(Opens opens) {
+    List<Open> sequential = new ArrayList<>();
+    for (Open open : opens.all()) {
+      // integrity checks read a file once, front to back, and say so with READONCE
+      if (open.name().endsWith(".fdt")
+          && open.hint() == DataAccessHint.SEQUENTIAL
+          && open.hints().contains(ReadOnceHint.INSTANCE) == false) {
+        assertTrue(
+            "a merge reads the data file once and does not come back: " + open,
+            open.hints().contains(NoReuseHint.INSTANCE));
+        assertSame(
+            "the open says a merge is reading, so a directory can route it: " + open,
+            IOContext.Context.MERGE,
+            open.context());
+        sequential.add(open);
+      }
+    }
+    return sequential;
+  }
+
+  /** One {@link Directory#openInput} call. */
+  private static final class Open {
+    private final String name;
+    private final IOContext context;
+    private volatile boolean closed;
+
+    Open(String name, IOContext context) {
+      this.name = name;
+      this.context = context;
+    }
+
+    String name() {
+      return name;
+    }
+
+    Set<IOContext.FileOpenHint> hints() {
+      return context.hints();
+    }
+
+    DataAccessHint hint() {
+      return context.hints(DataAccessHint.class).findFirst().orElse(null);
+    }
+
+    IOContext.Context context() {
+      return context.context();
+    }
+
+    boolean closed() {
+      return closed;
+    }
+
+    @Override
+    public String toString() {
+      return name + " [hint=" + hint() + " closed=" + closed + "]";
+    }
+  }
+
+  private static final class Opens {
+    private final List<Open> opens = new ArrayList<>();
+    private final List<String> advised = new ArrayList<>();
+
+    synchronized Open record(String name, IOContext context) {
+      Open open = new Open(name, context);
+      opens.add(open);
+      return open;
+    }
+
+    /** Files whose advice was changed after they were opened. */
+    synchronized void recordAdvice(String name) {
+      advised.add(name);
+    }
+
+    synchronized List<Open> all() {
+      return List.copyOf(opens);
+    }
+
+    synchronized List<String> advised() {
+      return List.copyOf(advised);
+    }
+
+    synchronized void clear() {
+      opens.clear();
+      advised.clear();
+    }
+
+    @Override
+    public synchronized String toString() {
+      return "opens: " + opens + ", advised: " + advised;
+    }
+  }
+
+  private static class RecordingDirectory extends FilterDirectory {
+    private final Opens opens;
+
+    RecordingDirectory(Directory in, Opens opens) {
+      super(in);
+      this.opens = opens;
+    }
+
+    @Override
+    public IndexInput openInput(String name, IOContext context) throws IOException {
+      Open open = opens.record(name, context);
+      return new RecordingIndexInput(super.openInput(name, context), name, open, opens);
+    }
+  }
+
+  private static final class RecordingIndexInput extends FilterIndexInput {
+    private final String name;
+    private final Open open;
+    private final Opens opens;
+
+    RecordingIndexInput(IndexInput in, String name, Open open, Opens opens) {
+      super("Recording(" + name + ")", in);
+      this.name = name;
+      this.open = open;
+      this.opens = opens;
+    }
+
+    @Override
+    public void updateIOContext(IOContext context) throws IOException {
+      opens.recordAdvice(name);
+      in.updateIOContext(context);
+    }
+
+    @Override
+    public void close() throws IOException {
+      open.closed = true;
+      in.close();
+    }
+
+    @Override
+    public IndexInput clone() {
+      return new RecordingIndexInput(in.clone(), name, open, opens);
+    }
+
+    @Override
+    public IndexInput slice(String sliceDescription, long offset, long length) throws IOException {
+      return new RecordingIndexInput(in.slice(sliceDescription, offset, length), name, open, opens);
+    }
+  }
+}
diff --git a/lucene/test-framework/src/java/org/apache/lucene/tests/codecs/asserting/AssertingStoredFieldsFormat.java b/lucene/test-framework/src/java/org/apache/lucene/tests/codecs/asserting/AssertingStoredFieldsFormat.java
index 2ea955c3c01..db8df27afdc 100644
--- a/lucene/test-framework/src/java/org/apache/lucene/tests/codecs/asserting/AssertingStoredFieldsFormat.java
+++ b/lucene/test-framework/src/java/org/apache/lucene/tests/codecs/asserting/AssertingStoredFieldsFormat.java
@@ -89,10 +89,15 @@ public class AssertingStoredFieldsFormat extends StoredFieldsFormat {
     }
 
     @Override
-    public StoredFieldsReader getMergeInstance() {
+    public StoredFieldsReader getMergeInstance() throws IOException {
       return new AssertingStoredFieldsReader(in.getMergeInstance(), maxDoc, true);
     }
 
+    @Override
+    public void finishMerge() throws IOException {
+      in.finishMerge();
+    }
+
     @Override
     public String toString() {
       return getClass().getSimpleName() + "(" + in.toString() + ")";
diff --git a/lucene/test-framework/src/java/org/apache/lucene/tests/index/MergingCodecReader.java b/lucene/test-framework/src/java/org/apache/lucene/tests/index/MergingCodecReader.java
index f1b076f69c1..a4f60e0c5c1 100644
--- a/lucene/test-framework/src/java/org/apache/lucene/tests/index/MergingCodecReader.java
+++ b/lucene/test-framework/src/java/org/apache/lucene/tests/index/MergingCodecReader.java
@@ -16,6 +16,8 @@
  */
 package org.apache.lucene.tests.index;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import org.apache.lucene.codecs.DocValuesProducer;
 import org.apache.lucene.codecs.NormsProducer;
 import org.apache.lucene.codecs.StoredFieldsReader;
@@ -33,7 +35,11 @@ public class MergingCodecReader extends FilterCodecReader {
       new CloseableThreadLocal<>() {
         @Override
         protected StoredFieldsReader initialValue() {
-          return in.getFieldsReader().getMergeInstance();
+          try {
+            return in.getFieldsReader().getMergeInstance();
+          } catch (IOException e) {
+            throw new UncheckedIOException(e);
+          }
         }
       };
   private final CloseableThreadLocal<NormsProducer> normsReader =