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

cryptoe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new e421a067931 fix: handle interrupt during partial mmap to not explode 
(#20053)
e421a067931 is described below

commit e421a067931d3959071c94fed881a887a1210a1e
Author: Clint Wylie <[email protected]>
AuthorDate: Tue Aug 18 14:32:31 2026 -0700

    fix: handle interrupt during partial mmap to not explode (#20053)
---
 .../segment/file/PartialSegmentFileMapperV10.java  | 171 ++++++++++++++-------
 .../file/PartialSegmentFileMapperV10Test.java      | 101 ++++++++++++
 2 files changed, 220 insertions(+), 52 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
 
b/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
index df571ff2647..1c25f089871 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
@@ -44,6 +44,7 @@ import java.io.RandomAccessFile;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 import java.nio.MappedByteBuffer;
+import java.nio.channels.ClosedByInterruptException;
 import java.nio.channels.FileChannel;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -195,6 +196,11 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
         result = parseHeaderFile(headerFile, jsonMapper);
         bitmapBuffer = mmapBitmap(headerFile, result);
       }
+      catch (ClosedByInterruptException e) {
+        // The header is fine, an interrupt aborted the mapping (see 
mapUninterruptibly). Treating this as corruption
+        // would delete a valid local header and force a needless re-download, 
so leave the file alone and unwind.
+        throw e;
+      }
       catch (Exception e) {
         // corrupted file (partial write, truncated bitmap, bad JSON, etc.), 
delete and re-fetch
         result = null;
@@ -228,47 +234,54 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
         maxFetchRunBytes
     );
 
-    // bitmap-vs-container repair pre-pass: if the bitmap claims a file is 
downloaded but its container file is
-    // missing on disk, the bitmap is lying (e.g. partial-cache eviction that 
cleared containers but couldn't atomically
-    // clear bits, or external file-system damage). Clear those bits before 
the restore loop so we don't spuriously
-    // sparse-allocate empty containers in the restore loop's 
ensureContainerInitialized call and treat their files as
-    // downloaded.
-    for (int i = 0; i < mapper.sortedFileNames.size(); i++) {
-      final int byteIndex = i / 8;
-      final int bitMask = 1 << (i % 8);
-      if ((bitmapBuffer.get(byteIndex) & bitMask) == 0) {
-        continue;
-      }
-      final String name = mapper.sortedFileNames.get(i);
-      final SegmentInternalFileMetadata fileMetadata = 
result.getMetadata().getFiles().get(name);
-      if (fileMetadata == null) {
-        continue;
-      }
-      final File containerFile = new File(
-          localCacheDir,
-          StringUtils.format("%s.container.%05d", targetFilename, 
fileMetadata.getContainer())
-      );
-      if (!containerFile.exists()) {
-        bitmapBuffer.put(byteIndex, (byte) (bitmapBuffer.get(byteIndex) & 
~bitMask));
-      }
-    }
-
-    // restore downloaded files from the (now-repaired) bitmap
-    for (int i = 0; i < mapper.sortedFileNames.size(); i++) {
-      final int byteIndex = i / 8;
-      final int bitIndex = i % 8;
-      if ((bitmapBuffer.get(byteIndex) & (1 << bitIndex)) != 0) {
+    try {
+      // bitmap-vs-container repair pre-pass: if the bitmap claims a file is 
downloaded but its container file is
+      // missing on disk, the bitmap is lying (e.g. partial-cache eviction 
that cleared containers but couldn't
+      // atomically clear bits, or external file-system damage). Clear those 
bits before the restore loop so we don't
+      // spuriously sparse-allocate empty containers in the restore loop's 
ensureContainerInitialized call and treat
+      // their files as downloaded.
+      for (int i = 0; i < mapper.sortedFileNames.size(); i++) {
+        final int byteIndex = i / 8;
+        final int bitMask = 1 << (i % 8);
+        if ((bitmapBuffer.get(byteIndex) & bitMask) == 0) {
+          continue;
+        }
         final String name = mapper.sortedFileNames.get(i);
         final SegmentInternalFileMetadata fileMetadata = 
result.getMetadata().getFiles().get(name);
-        if (fileMetadata != null) {
-          mapper.ensureContainerInitialized(fileMetadata.getContainer());
-          mapper.downloadedFiles.add(name);
-          mapper.downloadedBytes.addAndGet(fileMetadata.getSize());
+        if (fileMetadata == null) {
+          continue;
+        }
+        final File containerFile = new File(
+            localCacheDir,
+            StringUtils.format("%s.container.%05d", targetFilename, 
fileMetadata.getContainer())
+        );
+        if (!containerFile.exists()) {
+          bitmapBuffer.put(byteIndex, (byte) (bitmapBuffer.get(byteIndex) & 
~bitMask));
+        }
+      }
+
+      // restore downloaded files from the (now-repaired) bitmap
+      for (int i = 0; i < mapper.sortedFileNames.size(); i++) {
+        final int byteIndex = i / 8;
+        final int bitIndex = i % 8;
+        if ((bitmapBuffer.get(byteIndex) & (1 << bitIndex)) != 0) {
+          final String name = mapper.sortedFileNames.get(i);
+          final SegmentInternalFileMetadata fileMetadata = 
result.getMetadata().getFiles().get(name);
+          if (fileMetadata != null) {
+            mapper.ensureContainerInitialized(fileMetadata.getContainer());
+            mapper.downloadedFiles.add(name);
+            mapper.downloadedBytes.addAndGet(fileMetadata.getSize());
+          }
         }
       }
-    }
 
-    return mapper;
+      return mapper;
+    }
+    catch (Throwable t) {
+      // close a half-built mapper
+      CloseableUtils.closeAndSuppressExceptions(mapper, t::addSuppressed);
+      throw t;
+    }
   }
 
   private final SegmentFileMetadata metadata;
@@ -1072,8 +1085,9 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
 
   /**
    * Initialize a local container file if not already done. Creates a sparse 
file at the original container size
-   * and memory-maps it. The channel is closed immediately after mapping, the 
mmap persists independently, backed by
-   * the kernel page cache. This avoids the risk of channel closure from 
thread interruption.
+   * and memory-maps it via {@link #mapUninterruptibly}. The channel is closed 
immediately after mapping, the mmap
+   * persists independently, backed by the kernel page cache, so once 
established it is immune to channel closure from
+   * thread interruption.
    */
   private void ensureContainerInitialized(int containerIndex) throws 
IOException
   {
@@ -1095,15 +1109,14 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
       );
 
       // create sparse file at original container size, mmap it, then close 
the channel immediately
-      try (RandomAccessFile raf = new RandomAccessFile(localFile, "rw"); 
FileChannel channel = raf.getChannel()) {
-        raf.setLength(containerMeta.getSize());
-        containerFiles[containerIndex] = localFile;
-        containers[containerIndex] = channel.map(
-            FileChannel.MapMode.READ_ONLY,
-            0,
-            containerMeta.getSize()
-        );
-      }
+      final MappedByteBuffer container = mapUninterruptibly(() -> {
+        try (RandomAccessFile raf = new RandomAccessFile(localFile, "rw"); 
FileChannel channel = raf.getChannel()) {
+          raf.setLength(containerMeta.getSize());
+          return channel.map(FileChannel.MapMode.READ_ONLY, 0, 
containerMeta.getSize());
+        }
+      });
+      containerFiles[containerIndex] = localFile;
+      containers[containerIndex] = container;
     }
     finally {
       containerLocks[containerIndex].unlock();
@@ -1270,15 +1283,69 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
   {
     final int numBitmapBytes = (result.getMetadata().getFiles().size() + 7) / 
8;
     final long expectedSize = result.getHeaderSize() + numBitmapBytes;
-    try (RandomAccessFile raf = new RandomAccessFile(headerFile, "rw");
-         FileChannel channel = raf.getChannel()) {
-      if (raf.length() < expectedSize) {
-        raf.setLength(expectedSize);
+    return mapUninterruptibly(() -> {
+      try (RandomAccessFile raf = new RandomAccessFile(headerFile, "rw");
+           FileChannel channel = raf.getChannel()) {
+        if (raf.length() < expectedSize) {
+          raf.setLength(expectedSize);
+        }
+        return channel.map(FileChannel.MapMode.READ_WRITE, 
result.getHeaderSize(), numBitmapBytes);
+      }
+    });
+  }
+
+  /**
+   * Establish a memory mapping, shielding it from the calling thread's 
interrupt status.
+   * <p>
+   * {@link FileChannel#map} is an interruptible channel operation: an 
interrupt (a canceled query, a stage tearing
+   * down, {@code shutdownNow} on the processing pool) closes the channel 
mid-call and surfaces as
+   * {@link ClosedByInterruptException}. This class already avoids that hazard 
for container data by reading and
+   * writing through plain {@link RandomAccessFile} rather than NIO channels, 
but mapping has no non-NIO equivalent, so
+   * it is handled here instead: the interrupt status is parked for the 
duration of the call and the mapping is retried
+   * once on a fresh channel if an interrupt lands inside the call itself.
+   * <p>
+   * The flag is always restored before returning, so an interrupt is never 
swallowed, the caller still observes it at
+   * its next cancellation checkpoint.
+   */
+  private static MappedByteBuffer mapUninterruptibly(MmapOperation operation) 
throws IOException
+  {
+    boolean interrupted = Thread.interrupted();
+    boolean retried = false;
+
+    try {
+      while (true) {
+        try {
+          return operation.run();
+        }
+        catch (ClosedByInterruptException e) {
+          // An interrupt arrived after the clear above, retry once
+          interrupted = true;
+
+          if (retried) {
+            throw e;
+          }
+
+          retried = true;
+          Thread.interrupted();
+        }
+      }
+    }
+    finally {
+      if (interrupted) {
+        Thread.currentThread().interrupt();
       }
-      return channel.map(FileChannel.MapMode.READ_WRITE, 
result.getHeaderSize(), numBitmapBytes);
     }
   }
 
+  /**
+   * A mapping attempt for {@link #mapUninterruptibly}. Opens its own channel 
so that each attempt is independent.
+   */
+  @FunctionalInterface
+  private interface MmapOperation
+  {
+    MappedByteBuffer run() throws IOException;
+  }
+
   /**
    * One planned coalesced range read: {@code containerIndex} identifies the 
container within the mapper that planned
    * it (runs are only meaningful to their planning mapper), {@code 
startOffset}/{@code length} are container-local,
diff --git 
a/processing/src/test/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10Test.java
 
b/processing/src/test/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10Test.java
index 21e730ca954..3925976b88b 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10Test.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10Test.java
@@ -826,6 +826,107 @@ class PartialSegmentFileMapperV10Test
     }
   }
 
+  @Test
+  void testFetchWithPendingInterruptStillMapsContainer() throws IOException
+  {
+    final File segmentFile = buildTestSegment(10, CompressionStrategy.NONE);
+    final File cacheDir = newCacheDir("interrupted-fetch");
+    final DirectoryBackedRangeReader rangeReader = new 
DirectoryBackedRangeReader(segmentFile.getParentFile());
+
+    try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
+      // A pending interrupt (canceled query, stage teardown, shutdownNow on 
the processing pool) must not abort the
+      // container mmap: FileChannel.map is an interruptible channel 
operation, so without the interrupt parking in
+      // mapUninterruptibly this fails with ClosedByInterruptException.
+      Thread.currentThread().interrupt();
+      try {
+        mapper.fetchFiles(Set.of("5"));
+
+        final ByteBuffer buf = mapper.mapFile("5");
+        Assertions.assertNotNull(buf);
+        Assertions.assertEquals(5, buf.getInt());
+
+        // preserved rather than swallowed, so the caller still unwinds at its 
next cancellation checkpoint
+        Assertions.assertTrue(Thread.currentThread().isInterrupted());
+      }
+      finally {
+        // don't leak the flag into the mapper close below, or into other 
tests on this thread
+        Thread.interrupted();
+      }
+    }
+  }
+
+  @Test
+  void testRestoreWithPendingInterruptKeepsHeader() throws IOException
+  {
+    final File segmentFile = buildTestSegment(10, CompressionStrategy.NONE);
+    final File cacheDir = newCacheDir("interrupted-restore");
+    final File headerFile = new File(
+        cacheDir,
+        IndexIO.V10_FILE_NAME + 
PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX
+    );
+
+    try (PartialSegmentFileMapperV10 mapper =
+             createMapper(new 
DirectoryBackedRangeReader(segmentFile.getParentFile()), cacheDir)) {
+      mapper.fetchFiles(Set.of("5"));
+      Assertions.assertEquals(4, mapper.getDownloadedBytes());
+    }
+
+    final CountingRangeReader freshReader = new 
CountingRangeReader(segmentFile.getParentFile());
+    try {
+      Thread.currentThread().interrupt();
+      try (PartialSegmentFileMapperV10 restored = createMapper(freshReader, 
cacheDir)) {
+        // The bitmap mmap is interrupt-immune too, so a valid local header is 
restored as-is rather than mistaken for
+        // a corrupt file, deleted, and re-fetched from deep storage.
+        Assertions.assertTrue(Thread.currentThread().isInterrupted());
+
+        // Clear here rather than in the finally below, so the mapper close at 
the end of this block runs with the
+        // same clear flag its counterpart in 
testFetchWithPendingInterruptStillMapsContainer does; the finally is
+        // then only a backstop for a failure before this point.
+        Thread.interrupted();
+
+        Assertions.assertTrue(headerFile.exists());
+        Assertions.assertEquals(0, freshReader.getHeaderReadCount(), "header 
must not have been re-downloaded");
+        Assertions.assertEquals(4, restored.getDownloadedBytes());
+
+        final ByteBuffer buf = restored.mapFile("5");
+        Assertions.assertNotNull(buf);
+        Assertions.assertEquals(5, buf.getInt());
+      }
+    }
+    finally {
+      Thread.interrupted();
+    }
+  }
+
+  @Test
+  void testRestoreFailurePropagatesOriginalException() throws IOException
+  {
+    final File segmentFile = buildTestSegment(10, CompressionStrategy.NONE);
+    final File cacheDir = newCacheDir("restore-failure");
+
+    // Persist a header + bitmap with one file resident, so the restore loop 
has a container to initialize.
+    try (PartialSegmentFileMapperV10 mapper =
+             createMapper(new 
DirectoryBackedRangeReader(segmentFile.getParentFile()), cacheDir)) {
+      mapper.fetchFiles(Set.of("5"));
+    }
+
+    // Replace the container file with a directory of the same name: it still 
passes the repair pre-pass's exists()
+    // check, so the bitmap bit survives, but the restore loop's 
RandomAccessFile open fails. Doing it this way rather
+    // than with permissions keeps the test deterministic when it runs as root.
+    final File containerFile = new File(cacheDir, 
StringUtils.format("%s.container.%05d", IndexIO.V10_FILE_NAME, 0));
+    Assertions.assertTrue(containerFile.isFile());
+    Assertions.assertTrue(containerFile.delete());
+    FileUtils.mkdirp(containerFile);
+
+    // createForFile closes the half-built mapper on the way out (releasing 
the bitmap mmap and any containers already
+    // initialized). The unmap itself isn't observable in-process; what this 
pins is that the cleanup neither swallows
+    // the failure nor rewraps it into some other type, which is what would 
hide the real cause from callers.
+    Assertions.assertThrows(
+        IOException.class,
+        () -> createMapper(new 
DirectoryBackedRangeReader(segmentFile.getParentFile()), cacheDir)
+    );
+  }
+
   @Test
   void testCreateWithExternals() throws IOException
   {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to