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 1c25f089871a..83e373080e63 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 @@ -36,6 +36,7 @@ import org.apache.druid.utils.CloseableUtils; import javax.annotation.Nullable; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; @@ -84,9 +85,10 @@ * instances. The mmap reflects writes through the shared page cache. *

* State is persisted to disk so that the mapper can be restored after a process restart without re-fetching metadata - * from deep storage. The raw V10 header bytes are written to a local file, and a compact bitmap file is appended to - * the end of it to track which internal files have been downloaded (one bit per file, updated after each download). On - * subsequent calls, the metadata is parsed from the local file instead of range-reading from deep storage. + * from deep storage. The local header file holds the raw V10 header bytes followed by a compact bitmap region that + * tracks which internal files have been downloaded (one bit per file, updated after each download). Both regions are + * written together, so the file's length is fixed for its lifetime at {@code headerSize + ceil(numFiles / 8)} bytes. + * On subsequent calls, the metadata is parsed from the local file instead of range-reading from deep storage. *

* External segment files are supported via child {@link PartialSegmentFileMapperV10} instances, each targeting a * different file in the segment's storage location. @@ -120,6 +122,17 @@ public class PartialSegmentFileMapperV10 implements SegmentFileMapper */ public static final long DEFAULT_MAX_FETCH_RUN_BYTES = 64L * 1024 * 1024; + /** + * Detect if {@code localCacheDir} holds a partial-download header file for {@code targetFilename}. + */ + public static boolean isPartialSegmentLayout(@Nullable File localCacheDir, String targetFilename) + { + if (localCacheDir == null || !localCacheDir.isDirectory()) { + return false; + } + return new File(localCacheDir, targetFilename + METADATA_HEADER_SUFFIX).exists(); + } + /** * Create (or restore) a lazy mapper for the main segment file with attached external file mappers. If persisted state * exists locally from a previous session, metadata is read from disk. Otherwise, metadata is fetched from deep @@ -194,15 +207,16 @@ private static PartialSegmentFileMapperV10 createForFile( if (headerFile.exists()) { try { result = parseHeaderFile(headerFile, jsonMapper); + verifyPersistedHeaderLength(headerFile, result); 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. + // the header is fine, an interrupt aborted the mapping (see mapUninterruptibly), no need to necessarily delete + // let callers determine that throw e; } catch (Exception e) { - // corrupted file (partial write, truncated bitmap, bad JSON, etc.), delete and re-fetch + // corrupted file, delete result = null; if (!headerFile.delete()) { LOG.warn( @@ -216,10 +230,9 @@ private static PartialSegmentFileMapperV10 createForFile( } if (result == null) { - fetchAndPersistHeader(rangeReader, targetFilename, headerFile); - result = parseHeaderFile(headerFile, jsonMapper); + result = fetchAndPersistHeader(rangeReader, jsonMapper, targetFilename, headerFile); bitmapBuffer = mmapBitmap(headerFile, result); - downloadListener.onBytesDownloaded(headerFile.length()); + downloadListener.onBytesDownloaded(headerFileSize(result)); } final PartialSegmentFileMapperV10 mapper = new PartialSegmentFileMapperV10( @@ -235,11 +248,10 @@ private static PartialSegmentFileMapperV10 createForFile( ); 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. + // 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); @@ -308,13 +320,11 @@ private static PartialSegmentFileMapperV10 createForFile( // file names per container index (parallel to metadata.getContainers()) in ascending start-offset order, for // whole-container bulk download and coalesced range planning (files tile back-to-back within a container, so offset - // order is a total order). Built once from the immutable metadata. + // order is a total order). private final List> containerFileNames; - // external file mappers private final Map externalMappers = new HashMap<>(); - // track which internal files have been downloaded private final Set downloadedFiles = ConcurrentHashMap.newKeySet(); private final ConcurrentHashMap fileLocks = new ConcurrentHashMap<>(); private final ReentrantLock bitmapLock; @@ -858,26 +868,18 @@ public void fetchRun(FetchRun run) throws IOException } /** - * Total on-disk size of the header file(s) backing this mapper, summed across the main file and any external file - * mappers. This is the actual reservation size that should be charged against the local cache once the metadata has - * been fetched and persisted; callers can compare it against an up-front pessimistic estimate to decide whether to - * shrink the reservation. + * Computed total on-disk size of the header file(s) backing this mapper, summed across the main file and any + * external file mappers. */ public long getOnDiskHeaderSize() { - long total = headerFileSize(localCacheDir, targetFilename); + long total = headerFileSize(headerSize, metadata); for (PartialSegmentFileMapperV10 ext : externalMappers.values()) { - total += headerFileSize(ext.localCacheDir, ext.targetFilename); + total += headerFileSize(ext.headerSize, ext.metadata); } return total; } - private static long headerFileSize(File dir, String filename) - { - final File header = new File(dir, filename + METADATA_HEADER_SUFFIX); - return header.exists() ? header.length() : 0; - } - /** * Total bytes downloaded so far across all internal files, including external mappers. */ @@ -1204,12 +1206,32 @@ private void markDownloadedInBitmap(String name) } /** - * Fetch the raw V10 header bytes from deep storage and write them to a local file. The bitmap region is not - * included, it is created by {@link #mmapBitmap} after parsing. The file is parseable by - * {@link SegmentFileMetadataReader#read(InputStream, ObjectMapper)}. + * On-disk footprint of a single header file: the raw V10 header followed by one bit per internal file. */ - private static void fetchAndPersistHeader( + private static long headerFileSize(long headerSize, SegmentFileMetadata metadata) + { + return headerSize + numBitmapBytes(metadata); + } + + private static long headerFileSize(SegmentFileMetadataReader.Result result) + { + return headerFileSize(result.getHeaderSize(), result.getMetadata()); + } + + private static int numBitmapBytes(SegmentFileMetadata metadata) + { + return (metadata.getFiles().size() + 7) / 8; + } + + /** + * Fetch the raw V10 header bytes from deep storage and persist them locally at the header file's final length: the + * raw header followed by the zeroed bitmap region. + * + * @return the parsed metadata of the header just persisted + */ + private static SegmentFileMetadataReader.Result fetchAndPersistHeader( SegmentRangeReader rangeReader, + ObjectMapper jsonMapper, String targetFilename, File headerFile ) throws IOException @@ -1243,20 +1265,47 @@ private static void fetchAndPersistHeader( actualHeaderSize = fixedHeader.length; } - // write fixed header + remaining metadata bytes to a local file atomically (write to temp, then rename) - // to avoid leaving a partial file on disk if the process crashes mid-write + // Matches how SegmentFileMetadataReader reports malformed header bytes (bad version, bad lengths): these bytes + // cannot be a V10 header at all, so there is nothing to recover by re-reading them. + if (remainingBytes < 0) { + throw DruidException.defensive( + "Header of [%s] declares [%d] metadata bytes", + targetFilename, + remainingBytes + ); + } + + // Read the header in full: the fixed part already in hand, plus the metadata bytes that follow it. + final byte[] rawHeader = new byte[actualHeaderSize + (int) remainingBytes]; + System.arraycopy(fixedHeader, 0, rawHeader, 0, actualHeaderSize); + try (InputStream remainingStream = rangeReader.readRange(targetFilename, actualHeaderSize, remainingBytes)) { + ByteStreams.readFully(remainingStream, rawHeader, actualHeaderSize, (int) remainingBytes); + } + + final SegmentFileMetadataReader.Result result; + try (InputStream headerBytes = new ByteArrayInputStream(rawHeader)) { + result = SegmentFileMetadataReader.read(headerBytes, jsonMapper); + } + if (rawHeader.length != result.getHeaderSize()) { + throw DruidException.defensive( + "Read [%d] header bytes for [%s] but its metadata describes a [%d] byte header", + rawHeader.length, + targetFilename, + result.getHeaderSize() + ); + } + + // zeroed bitmap region (nothing is downloaded yet). + final byte[] emptyBitmap = new byte[numBitmapBytes(result.getMetadata())]; + + // writeAtomically fsyncs before the rename, so a crash leaves either no header file or a complete one. FileUtils.mkdirp(headerFile.getParentFile()); FileUtils.writeAtomically(headerFile, out -> { - out.write(fixedHeader, 0, actualHeaderSize); - try (InputStream remainingStream = rangeReader.readRange( - targetFilename, - actualHeaderSize, - remainingBytes - )) { - ByteStreams.limit(remainingStream, remainingBytes).transferTo(out); - } + out.write(rawHeader); + out.write(emptyBitmap); return null; }); + return result; } /** @@ -1273,27 +1322,46 @@ private static SegmentFileMetadataReader.Result parseHeaderFile( } /** - * Mmap the bitmap region of the header file as read-write. Extends the file if the bitmap region doesn't exist yet. - * The channel is closed immediately after mapping. + * Mmap the bitmap region of the header file as read-write. The channel is closed immediately after mapping. */ private static MappedByteBuffer mmapBitmap( File headerFile, SegmentFileMetadataReader.Result result ) throws IOException { - final int numBitmapBytes = (result.getMetadata().getFiles().size() + 7) / 8; - final long expectedSize = result.getHeaderSize() + numBitmapBytes; + final int numBitmapBytes = numBitmapBytes(result.getMetadata()); 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); } }); } + /** + * Corruption check for a header file restored from a previous session: its length must be exactly the header plus + * one bit per internal file, since {@link #fetchAndPersistHeader} only ever publishes both regions together. + */ + private static void verifyPersistedHeaderLength(File headerFile, SegmentFileMetadataReader.Result result) + throws IOException + { + final long expectedSize = headerFileSize(result); + final long actualSize = headerFile.length(); + if (actualSize != expectedSize) { + throw new IOException( + StringUtils.format( + "Header file[%s] is [%d] bytes on disk but its metadata describes [%d] bytes (header[%d] plus " + + "bitmap[%d]); treating it as corrupt", + headerFile, + actualSize, + expectedSize, + result.getHeaderSize(), + numBitmapBytes(result.getMetadata()) + ) + ); + } + } + /** * Establish a memory mapping, shielding it from the calling thread's interrupt status. *

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 3925976b88b9..88bb179cbc01 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 @@ -34,6 +34,8 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.io.File; import java.io.FileInputStream; @@ -970,6 +972,141 @@ void testCreateWithExternals() throws IOException } } + @Test + void testIsPartialSegmentLayout() throws IOException + { + final File segmentFile = buildTestSegment(10, CompressionStrategy.NONE); + final File cacheDir = newCacheDir("layout"); + + // an empty (or absent, or non-directory) cache dir is not a partial layout + Assertions.assertFalse(PartialSegmentFileMapperV10.isPartialSegmentLayout(cacheDir, IndexIO.V10_FILE_NAME)); + Assertions.assertFalse(PartialSegmentFileMapperV10.isPartialSegmentLayout(null, IndexIO.V10_FILE_NAME)); + Assertions.assertFalse( + PartialSegmentFileMapperV10.isPartialSegmentLayout(new File(tempDir, "nonexistent"), IndexIO.V10_FILE_NAME) + ); + + // persisting the header is what makes the layout recognizable, container files or not + try (PartialSegmentFileMapperV10 mapper = + createMapper(new DirectoryBackedRangeReader(segmentFile.getParentFile()), cacheDir)) { + Assertions.assertTrue(PartialSegmentFileMapperV10.isPartialSegmentLayout(cacheDir, IndexIO.V10_FILE_NAME)); + mapper.fetchFiles(Set.of("3")); + Assertions.assertTrue(PartialSegmentFileMapperV10.isPartialSegmentLayout(cacheDir, IndexIO.V10_FILE_NAME)); + // ...and only for the file it was persisted for + Assertions.assertFalse(PartialSegmentFileMapperV10.isPartialSegmentLayout(cacheDir, "some-other-file")); + } + } + + @Test + void testHeaderFileLengthMatchesComputedSize() throws IOException + { + final File segmentFile = buildTestSegment(10, CompressionStrategy.NONE); + final File cacheDir = newCacheDir("header_size"); + final File headerFile = new File( + cacheDir, + IndexIO.V10_FILE_NAME + PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX + ); + final DirectoryBackedRangeReader rangeReader = new DirectoryBackedRangeReader(segmentFile.getParentFile()); + + final long freshSize; + // fresh fetch: the header is published at its final length, bitmap region included + try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, cacheDir)) { + freshSize = mapper.getOnDiskHeaderSize(); + Assertions.assertEquals(headerFile.length(), freshSize); + // the header is published in one write, so nothing intermediate is left behind to be cleaned up later + Assertions.assertArrayEquals(new String[]{headerFile.getName()}, cacheDir.list()); + // downloading files must not change the header's footprint, only bits inside its bitmap region + mapper.fetchFiles(Set.of("3")); + Assertions.assertEquals(freshSize, mapper.getOnDiskHeaderSize()); + Assertions.assertEquals(freshSize, headerFile.length()); + } + + // restore from the local header: same size, so a reservation made against one measurement is never grown by the + // other + try (PartialSegmentFileMapperV10 restored = createMapper(rangeReader, cacheDir)) { + Assertions.assertEquals(freshSize, restored.getOnDiskHeaderSize()); + Assertions.assertEquals(freshSize, headerFile.length()); + } + } + + @Test + void testHeaderFileLengthMatchesComputedSizeWithExternals() throws IOException + { + final String externalName = "external.segment"; + final File baseDir = new File(tempDir, "ext_size_base_" + ThreadLocalRandom.current().nextInt()); + FileUtils.mkdirp(baseDir); + + try (SegmentFileBuilderV10 builder = SegmentFileBuilderV10.create(JSON_MAPPER, baseDir)) { + for (int i = 0; i < 5; ++i) { + File tmpFile = new File(tempDir, StringUtils.format("ext-size-main-%s.bin", i)); + Files.write(Ints.toByteArray(i), tmpFile); + builder.add(StringUtils.format("%d", i), tmpFile); + } + SegmentFileBuilder external = builder.getExternalBuilder(externalName); + for (int i = 5; i < 10; ++i) { + File tmpFile = new File(tempDir, StringUtils.format("ext-size-ext-%s.bin", i)); + Files.write(Ints.toByteArray(i), tmpFile); + external.add(StringUtils.format("%d", i), tmpFile); + } + } + + final File cacheDir = newCacheDir("ext_size"); + final DirectoryBackedRangeReader rangeReader = new DirectoryBackedRangeReader(baseDir); + final File mainHeader = new File( + cacheDir, + IndexIO.V10_FILE_NAME + PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX + ); + final File externalHeader = new File( + cacheDir, + externalName + PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX + ); + + // the computed size covers the entry point plus every attached external mapper's header + try (PartialSegmentFileMapperV10 mapper = createMapperWithExternal(rangeReader, cacheDir, externalName)) { + Assertions.assertEquals(mainHeader.length() + externalHeader.length(), mapper.getOnDiskHeaderSize()); + } + } + + @ParameterizedTest(name = "bytesMissing={0}") + @ValueSource(ints = {1, 2}) + void testHeaderShorterThanItsMetadataIsRefetched(int bytesMissing) throws IOException + { + final File segmentFile = buildTestSegment(10, CompressionStrategy.NONE); + final File cacheDir = newCacheDir("short_header_" + bytesMissing); + final File headerFile = new File( + cacheDir, + IndexIO.V10_FILE_NAME + PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX + ); + final DirectoryBackedRangeReader rangeReader = new DirectoryBackedRangeReader(segmentFile.getParentFile()); + + final long expectedSize; + try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, cacheDir)) { + expectedSize = mapper.getOnDiskHeaderSize(); + // 10 files means a 2 byte bitmap region, and file "3" is bit 3 of the first of those bytes: dropping the last + // byte leaves that bit intact on disk, so a mapper that trusted a short file would report it as downloaded + Assertions.assertEquals(2, (mapper.getSegmentFileMetadata().getFiles().size() + 7) / 8); + mapper.fetchFiles(Set.of("3")); + Assertions.assertEquals(4, mapper.getDownloadedBytes()); + } + + // Shorten the header: the state a crash (or an interrupt) between persisting the header bytes and extending the + // file leaves behind, and the shape of a header written before the two were published together. The metadata + // still parses, so only the length tells us the file is incomplete. Growing it back instead would mean the same + // segment measures two different sizes depending on when it is looked at, and would resurrect whatever bits + // happened to survive. + try (RandomAccessFile raf = new RandomAccessFile(headerFile, "rw")) { + raf.setLength(expectedSize - bytesMissing); + } + + try (PartialSegmentFileMapperV10 recovered = createMapper(rangeReader, cacheDir)) { + Assertions.assertEquals(expectedSize, recovered.getOnDiskHeaderSize()); + Assertions.assertEquals(expectedSize, headerFile.length(), "the short header must have been re-fetched"); + // treated as corrupt and re-fetched, so the download bitmap starts over rather than being partly trusted + Assertions.assertEquals(0, recovered.getDownloadedBytes()); + recovered.fetchFiles(Set.of("3")); + Assertions.assertEquals(3, recovered.mapFile("3").getInt()); + } + } + @Test void testCorruptHeaderFileRecovery() throws IOException { diff --git a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrap.java b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrap.java deleted file mode 100644 index 197c3154b6a2..000000000000 --- a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrap.java +++ /dev/null @@ -1,352 +0,0 @@ -/* - * 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.druid.segment.loading; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.druid.error.DruidException; -import org.apache.druid.java.util.common.StringUtils; -import org.apache.druid.java.util.emitter.EmittingLogger; -import org.apache.druid.segment.file.PartialSegmentFileMapperV10; -import org.apache.druid.segment.projections.Projections; -import org.apache.druid.timeline.SegmentId; - -import javax.annotation.Nullable; -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -/** - * Bootstraps partial-segment cache entries from existing on-disk state. Called by the cache manager on historical - * startup for each segment directory that contains the partial-download layout (`{targetFilename}.header` plus one - * or more `{targetFilename}.container.NNNNN` files). - *

- * The bootstrap is read-only with respect to deep storage; it never issues a range read. The on-disk header file is - * parsed in-place by {@link PartialSegmentFileMapperV10#create} (which detects header corruption and, for that one - * case, may delete the local copy; bootstrap callers should treat that as "no restorable state" and fall back to a - * cold start). - *

- * Two-phase contract: - *

    - *
  1. {@link #reserveFromDisk} (called from {@code getCachedSegments}, light): validates the header file is present, - * computes the actual on-disk reservation size, constructs the metadata cache entry, reserves it on the location. - * No mount, no range read.
  2. - *
  3. {@code metadata.mount(location)} (called from {@code bootstrap()} via the bootstrap executor, parallelizable): - * the metadata entry's own mount path builds the file mapper from the on-disk header, then internally invokes - * {@link #restoreBundlesFromDisk} to discover, reserve, and mount any bundles whose container files survived. The - * same call from the fresh acquire path is a no-op (no on-disk containers to restore).
  4. - *
- * Dependency inference is delegated to {@link PartialSegmentMetadataCacheEntry#inferBundleDependencies}. A bundle - * whose inferred dependency isn't itself present on disk is treated as orphaned: its on-disk container files - * are deleted (via {@link PartialSegmentFileMapperV10#evictContainer}, which also clears the relevant bitmap bits) and - * the bundle is not restored. The next access through the cache manager acquire path then triggers a clean cold - * re-fetch, the same fall-back as when the cache manager finds a segment listed in the info directory but missing on - * disk. - */ -public final class PartialSegmentCacheBootstrap -{ - private static final EmittingLogger LOG = new EmittingLogger(PartialSegmentCacheBootstrap.class); - - /** - * Reserve a partial segment's metadata cache entry on the supplied location from the on-disk header. Light-weight: - * no range read, no file mapper, no bundle work. The entry is registered as a weak cache entry (consistent with - * the runtime acquire path), so it is evictable once mounted; bootstrap-restored data is treated as a cache - * optimization, not a permanent fixture. The caller is expected to drive the actual mount through - * {@code metadata.mount(location)} later (typically via {@code SegmentCacheManager#bootstrap}), which builds the - * file mapper (parsing the header from local disk, no fetch) and cascades into {@link #restoreBundlesFromDisk}. - * - * @param segmentId the segment whose entries are being restored - * @param localCacheDir the per-segment directory containing the header + container files - * @param targetFilename the V10 entry-point filename - * @param externalFilenames any external segment file names that were registered as children of the entry-point file - * @param rangeReader the segment's deep-storage range reader, retained for later on-demand fetches - * @param jsonMapper used by the metadata entry's mount path to parse the header - * @param storagePool thread pool the async cursor path submits on-demand column downloads to (which bounds - * load concurrency itself); may be null in tests that never invoke the cursor factory - * @param location the storage location to reserve the metadata entry on - * @param coalesceGapBytes gap tolerance for coalesced range downloads, see - * {@link PartialSegmentFileMapperV10#fetchFiles} - * @param maxFetchRunBytes size cap for parallel-fetch range reads, see - * {@link PartialSegmentFileMapperV10#planParallelFetch} - * @return the reserved {@link PartialSegmentMetadataCacheEntry}; the caller is responsible for mounting it - * @throws DruidException if the expected header file is missing or the location cannot accept the reservation - */ - public static PartialSegmentMetadataCacheEntry reserveFromDisk( - SegmentId segmentId, - File localCacheDir, - String targetFilename, - List externalFilenames, - SegmentRangeReader rangeReader, - ObjectMapper jsonMapper, - @Nullable StorageLoadingThreadPool storagePool, - StorageLocation location, - long coalesceGapBytes, - long maxFetchRunBytes - ) - { - final File headerFile = new File(localCacheDir, targetFilename + PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX); - if (!headerFile.exists()) { - throw DruidException.defensive( - "No on-disk header for partial segment[%s] at [%s]; nothing to restore", - segmentId, - headerFile - ); - } - - // size the metadata reservation to the actual on-disk size so the location accounting is correct from the start - final long actualMetadataSize = computeOnDiskHeaderSize(localCacheDir, targetFilename, externalFilenames); - final PartialSegmentMetadataCacheEntry metadata = new PartialSegmentMetadataCacheEntry( - segmentId, - localCacheDir, - targetFilename, - externalFilenames, - rangeReader, - jsonMapper, - storagePool, - actualMetadataSize, - coalesceGapBytes, - maxFetchRunBytes - ); - - if (!location.reserveWeak(metadata)) { - throw DruidException.defensive( - "Failed to reserve metadata entry for partial segment[%s] at location[%s]", - segmentId, - location.getPath() - ); - } - return metadata; - } - - /** - * Discover, reserve, and mount any bundles whose container files survived on disk for the given partial segment. - * Invoked from {@link PartialSegmentMetadataCacheEntry#mount} after the file mapper has been built; safe to call - * unconditionally (on the fresh-acquire path there are no on-disk containers yet, so the call is a no-op). - *

- * On any failure during bundle reservation or mount, attempts to roll back any partially-mounted bundles before - * propagating the throw. The metadata entry itself is NOT released here, the caller (typically - * {@link PartialSegmentMetadataCacheEntry#doMount}) handles metadata-level rollback on a propagated throw. - */ - static void restoreBundlesFromDisk(PartialSegmentMetadataCacheEntry metadata, StorageLocation location) - throws IOException - { - final PartialSegmentFileMapperV10 fileMapper = metadata.getFileMapper(); - if (fileMapper == null) { - // metadata isn't mounted yet (or has already been unmounted); nothing to restore - return; - } - - final SegmentId segmentId = metadata.getSegmentId(); - final File localCacheDir = metadata.getLocalCacheDir(); - - // Discover bundle names across the main file and every external file, then keep only those whose owned - // container files actually exist on disk. Walks via the file mapper so the external mappers' SegmentFileMetadata - // are visited too; bundles can legitimately span the main file and one or more externals when the writer - // propagates startFileBundle across them. - final Set candidateBundleNames = PartialSegmentBundleCacheEntry.bundleNames(fileMapper); - final List presentBundleNames = filterByContainerPresence( - candidateBundleNames, - fileMapper, - localCacheDir - ); - if (presentBundleNames.isEmpty()) { - // Fresh acquire path or a partial whose containers were all evicted: nothing to do. - return; - } - - // Classify each present bundle as either mountable or orphaned. A bundle is orphaned when its inferred parent - // set includes a bundle that isn't itself present on disk; restoring it would only produce a degenerate state - // where column reads that resolve into the missing parent would fail at query time. Instead, delete the - // orphan's on-disk containers so the next access triggers a clean cold re-fetch from deep storage. - final List mountableBundleNames = new ArrayList<>(); - final Set orphanedBundleNames = new HashSet<>(); - for (String name : presentBundleNames) { - boolean orphaned = false; - for (PartialSegmentBundleCacheEntryIdentifier dep : metadata.inferBundleDependencies(name)) { - if (!presentBundleNames.contains(dep.bundleName())) { - orphaned = true; - break; - } - } - if (orphaned) { - orphanedBundleNames.add(name); - } else { - mountableBundleNames.add(name); - } - } - - for (String orphanName : orphanedBundleNames) { - for (PartialSegmentBundleCacheEntry.BundleContainerRef ref : - PartialSegmentBundleCacheEntry.findContainersForBundle(fileMapper, orphanName)) { - fileMapper.mapperForContainer(ref.externalFilename()).evictContainer(ref.containerIndex()); - } - LOG.debug( - "Deleted on-disk state of orphaned bundle[%s] for segment[%s] (dependency unrestorable); next access " - + "will trigger cold re-fetch", - orphanName, - segmentId - ); - } - - // Mount the base bundle before any dependent bundle so its hold is available when dependents acquire deps. - mountableBundleNames.sort(Comparator.comparing(name -> !Projections.BASE_TABLE_PROJECTION_NAME.equals(name))); - - final List mountedBundles = new ArrayList<>(); - boolean success = false; - try { - for (String bundleName : mountableBundleNames) { - // Mountable bundles have all dependencies present by construction (orphans were filtered out above), so the - // inferred dependency set is exactly what we want, no further filtering needed. - final List parentIds = metadata.inferBundleDependencies(bundleName); - final PartialSegmentBundleCacheEntry bundle = PartialSegmentBundleCacheEntry.forBundle( - metadata, - bundleName, - parentIds - ); - // weak-reserve with a temporary hold so the mount call's own parent-hold acquisition can succeed; release the - // bootstrap hold immediately after, if the entry should remain alive for query-side access, the runtime - // hold chain (transitive parents from aggregates, segment-level holds from acquire APIs) keeps it pinned. - try (StorageLocation.ReservationHold bootstrapHold = - location.addWeakReservationHold(bundle.getId(), () -> bundle)) { - if (bootstrapHold == null) { - throw DruidException.defensive( - "Failed to reserve bundle entry[%s] in location[%s] during bootstrap", - bundle.getId(), - location.getPath() - ); - } - bundle.mount(location); - } - mountedBundles.add(bundle); - } - success = true; - LOG.debug( - "Restored bundles for partial segment[%s] from [%s]: bundles[%s], orphans[%s]", - segmentId, - localCacheDir, - mountableBundleNames, - orphanedBundleNames - ); - } - finally { - if (!success) { - // Reverse-dependency rollback for bundles only; the metadata entry's own rollback is the caller's - // responsibility (it will fire when the propagated throw escapes doMount's try/catch). - for (PartialSegmentBundleCacheEntry bundle : mountedBundles) { - try { - bundle.unmount(); - } - catch (Throwable t) { - LOG.warn(t, "Failed to roll back bundle[%s] during bootstrap failure for [%s]", bundle.getId(), segmentId); - } - } - } - } - } - - /** - * Check whether a directory looks like a partial-segment cache layout for the given target filename. - */ - public static boolean isPartialSegmentLayout(File localCacheDir, String targetFilename) - { - if (localCacheDir == null || !localCacheDir.isDirectory()) { - return false; - } - final File header = new File( - localCacheDir, - targetFilename + PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX - ); - return header.exists(); - } - - private static long computeOnDiskHeaderSize(File localCacheDir, String targetFilename, List externalFilenames) - { - long total = sizeOf(new File( - localCacheDir, - targetFilename + PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX - )); - for (String external : externalFilenames) { - total += sizeOf(new File( - localCacheDir, - external + PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX - )); - } - if (total <= 0) { - // PartialSegmentMetadataCacheEntry requires a positive reservation; if all headers are zero-length the local - // layout is degenerate and should not be restored - throw DruidException.defensive( - "Zero-sized header files in [%s]; refusing to restore", - localCacheDir - ); - } - return total; - } - - private static long sizeOf(File f) - { - return f.exists() ? f.length() : 0; - } - - /** - * Keep only bundles whose every owned container file exists on disk. The on-disk path for a container is - * {@code {mapperTargetFilename}.container.{containerIndex:05d}} where {@code mapperTargetFilename} is the main - * V10 filename for refs in the main mapper, or the external filename for refs in an external mapper. - */ - private static List filterByContainerPresence( - Set candidateBundleNames, - PartialSegmentFileMapperV10 fileMapper, - File localCacheDir - ) - { - final List restorable = new ArrayList<>(); - for (String bundleName : candidateBundleNames) { - final List refs = - PartialSegmentBundleCacheEntry.findContainersForBundle(fileMapper, bundleName); - if (refs.isEmpty()) { - continue; - } - boolean allPresent = true; - for (PartialSegmentBundleCacheEntry.BundleContainerRef ref : refs) { - final String mapperFilename = fileMapper.mapperForContainer(ref.externalFilename()).getTargetFilename(); - final File cf = new File( - localCacheDir, - StringUtils.format("%s.container.%05d", mapperFilename, ref.containerIndex()) - ); - if (!cf.exists()) { - allPresent = false; - break; - } - } - if (allPresent) { - restorable.add(bundleName); - } - } - return restorable; - } - - private PartialSegmentCacheBootstrap() - { - // utility class - } - -} diff --git a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java index ed9bfebc2890..8c4d827fb693 100644 --- a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java +++ b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java @@ -26,6 +26,7 @@ import com.google.errorprone.annotations.concurrent.GuardedBy; import org.apache.druid.common.asyncresource.AsyncResource; import org.apache.druid.error.DruidException; +import org.apache.druid.java.util.common.StringUtils; import org.apache.druid.java.util.common.io.Closer; import org.apache.druid.java.util.emitter.EmittingLogger; import org.apache.druid.segment.PartialBundleAcquirer; @@ -48,7 +49,9 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -229,15 +232,6 @@ public SegmentId getSegmentId() return segmentId; } - /** - * The per-segment cache directory this entry reads from and writes to. Exposed for the bundle-restore helper in - * {@link PartialSegmentCacheBootstrap#restoreBundlesFromDisk} so it can locate the on-disk container files. - */ - File getLocalCacheDir() - { - return localCacheDir; - } - /** * The fingerprint of the partial-load rule currently applied to this entry, or {@code null} if no rule has been * applied. Set by {@link #applyRule(String, Set)} and cleared by {@link #clearRule()}. {@link #doActualUnmount()} @@ -859,7 +853,7 @@ private void doMount(StorageLocation mountLocation) throws IOException // we just installed the gate and no external caller has had a chance to acquire a reference yet. The location // reservation release stays the caller's responsibility (matches mount's overall contract). try { - PartialSegmentCacheBootstrap.restoreBundlesFromDisk(this, mountLocation); + restoreBundlesFromDisk(mountLocation); } catch (Throwable t) { try { @@ -874,15 +868,10 @@ private void doMount(StorageLocation mountLocation) throws IOException } } catch (Throwable t) { - // A failed mount must not leave a lingering, un-re-mountable weak entry in the location. The inner rollbacks - // above close the mapper and delete the on-disk header, so any weak entry left behind is poison: a later - // findExistingPartialWithHold would resurrect it and re-mount would fail again (the header is gone and the - // bootstrap reserve path's entry uses a disk-only range reader). Remove it here so the next acquire rebuilds a - // fresh, deep-storage-capable entry via reservePartial. This is keyed on the entry being unheld: the runtime - // acquire path holds the entry via the AcquireSegmentAction's loadCleanup, so removeUnheldWeakEntry is a no-op - // there and the holder's release runnable performs cleanup instead. The bootstrap reserve path - // (StorageLocation.reserveWeak) places no hold, so this is what cleans it up. Runs outside entryLock (the - // inner blocks released it) so the writeLock -> entryLock order inside removeUnheldWeakEntry is respected. + // Reclaim the reservation of an entry that is still registered here but no longer held, which the rollbacks + // above have just left with a closed mapper and no header on disk. No-op if anything holds this (including + // bundle entries). Runs outside entryLock (the inner blocks released it) so the writeLock -> entryLock order + // inside removeUnheldWeakEntry is respected. try { mountLocation.removeUnheldWeakEntry(id); } @@ -893,6 +882,165 @@ private void doMount(StorageLocation mountLocation) throws IOException } } + /** + * Discover, reserve, and mount any bundles whose container files survived on disk for this segment, so a restart (or + * an acquire that follows an eviction which left container files behind) doesn't re-fetch bytes that are already + * local. Invoked from {@link #doMount} once the file mapper is installed; safe to call unconditionally, on the + * fresh-acquire path there are no container files yet and this is a no-op. + *

+ * A bundle whose inferred dependency set includes a bundle that is not itself present on disk is treated as + * orphaned: restoring it would only produce a degenerate state where column reads that resolve into the + * missing parent fail at query time, so its container files are deleted (via + * {@link PartialSegmentFileMapperV10#evictContainer}, which also clears the matching bitmap bits) and the bundle is + * left unrestored. The next access through the acquire path then triggers a clean cold re-fetch, the same fall-back + * as when the cache manager finds a segment in the info directory but missing on disk. + *

+ * On any failure, bundles mounted so far are rolled back before the throw propagates. This entry's own rollback is + * {@link #doMount}'s responsibility, not this method's. + */ + private void restoreBundlesFromDisk(StorageLocation location) throws IOException + { + final PartialSegmentFileMapperV10 mapper = getFileMapper(); + if (mapper == null) { + // not mounted yet (or already unmounted); nothing to restore + return; + } + + // Discover bundle names across the main file and every external file, then keep only those whose owned container + // files actually exist on disk. Walks via the file mapper so the external mappers' SegmentFileMetadata are visited + // too; bundles can legitimately span the main file and one or more externals when the writer propagates + // startFileBundle across them. + final List presentBundleNames = filterByContainerPresence( + PartialSegmentBundleCacheEntry.bundleNames(mapper), + mapper + ); + if (presentBundleNames.isEmpty()) { + // Fresh acquire path or a partial whose containers were all evicted: nothing to do. + return; + } + + final List mountableBundleNames = new ArrayList<>(); + final Set orphanedBundleNames = new HashSet<>(); + for (String name : presentBundleNames) { + boolean orphaned = false; + for (PartialSegmentBundleCacheEntryIdentifier dep : inferBundleDependencies(name)) { + if (!presentBundleNames.contains(dep.bundleName())) { + orphaned = true; + break; + } + } + if (orphaned) { + orphanedBundleNames.add(name); + } else { + mountableBundleNames.add(name); + } + } + + for (String orphanName : orphanedBundleNames) { + for (PartialSegmentBundleCacheEntry.BundleContainerRef ref : + PartialSegmentBundleCacheEntry.findContainersForBundle(mapper, orphanName)) { + mapper.mapperForContainer(ref.externalFilename()).evictContainer(ref.containerIndex()); + } + LOG.debug( + "Deleted on-disk state of orphaned bundle[%s] for segment[%s] (dependency unrestorable); next access " + + "will trigger cold re-fetch", + orphanName, + segmentId + ); + } + + // Mount the base bundle before any dependent bundle so its hold is available when dependents acquire deps. + mountableBundleNames.sort(Comparator.comparing(name -> !Projections.BASE_TABLE_PROJECTION_NAME.equals(name))); + + final List mountedBundles = new ArrayList<>(); + boolean success = false; + try { + for (String bundleName : mountableBundleNames) { + // Mountable bundles have all dependencies present by construction (orphans were filtered out above), so the + // inferred dependency set is exactly what we want, no further filtering needed. + final PartialSegmentBundleCacheEntry bundle = PartialSegmentBundleCacheEntry.forBundle( + this, + bundleName, + inferBundleDependencies(bundleName) + ); + // weak-reserve with a temporary hold so the mount call's own parent-hold acquisition can succeed; release the + // restore hold immediately after, if the entry should remain alive for query-side access, the runtime hold + // chain (transitive parents from aggregates, segment-level holds from acquire APIs) keeps it pinned. + try (StorageLocation.ReservationHold restoreHold = + location.addWeakReservationHold(bundle.getId(), () -> bundle)) { + if (restoreHold == null) { + throw DruidException.defensive( + "Failed to reserve bundle entry[%s] in location[%s] while restoring from disk", + bundle.getId(), + location.getPath() + ); + } + bundle.mount(location); + } + mountedBundles.add(bundle); + } + success = true; + LOG.debug( + "Restored bundles for partial segment[%s] from [%s]: bundles[%s], orphans[%s]", + segmentId, + localCacheDir, + mountableBundleNames, + orphanedBundleNames + ); + } + finally { + if (!success) { + // Roll back the bundles, dependents before parents, so a parent is unheld (its dependent's holds released) by + // the time we try to remove it. + for (int i = mountedBundles.size() - 1; i >= 0; i--) { + final PartialSegmentBundleCacheEntry bundle = mountedBundles.get(i); + try { + bundle.unmount(); + location.removeUnheldWeakEntry(bundle.getId()); + } + catch (Throwable t) { + LOG.warn(t, "Failed to roll back bundle[%s] during restore failure for [%s]", bundle.getId(), segmentId); + } + } + } + } + } + + /** + * The subset of {@code candidateBundleNames} whose every owned container file is present in the local cache + * directory. A bundle with no containers at all, or with only some of them on disk, is not restorable. + */ + private List filterByContainerPresence( + Set candidateBundleNames, + PartialSegmentFileMapperV10 mapper + ) + { + final List restorable = new ArrayList<>(); + for (String bundleName : candidateBundleNames) { + final List refs = + PartialSegmentBundleCacheEntry.findContainersForBundle(mapper, bundleName); + if (refs.isEmpty()) { + continue; + } + boolean allPresent = true; + for (PartialSegmentBundleCacheEntry.BundleContainerRef ref : refs) { + final String mapperFilename = mapper.mapperForContainer(ref.externalFilename()).getTargetFilename(); + final File containerFile = new File( + localCacheDir, + StringUtils.format("%s.container.%05d", mapperFilename, ref.containerIndex()) + ); + if (!containerFile.exists()) { + allPresent = false; + break; + } + } + if (allPresent) { + restorable.add(bundleName); + } + } + return restorable; + } + private static void awaitMount(SettableFuture future) throws IOException { try { @@ -1335,8 +1483,8 @@ public Closeable acquire(String requestedBundleName) } /** - * Mount the bundle on the supplied location, closing the bootstrap hold and propagating a {@link DruidException} - * if the mount fails. Used by both the fresh-build and re-mount branches. + * Mount the bundle on the supplied location, closing the transient reservation hold and propagating a + * {@link DruidException} if the mount fails. Used by both the fresh-build and re-mount branches. */ private void mountBundleOrClose( PartialSegmentBundleCacheEntry bundle, @@ -1345,13 +1493,10 @@ private void mountBundleOrClose( String bundleName ) { - // Mount this bundle's parents (e.g. __base for a projection bundle) FIRST: the bundle's own mount() takes - // holds + references on each parent and fails with a defensive error if a parent isn't registered+mounted at - // the location. The bootstrap restore path orders base-before-dependents; this is the equivalent ordering for - // the runtime acquire path, which would otherwise reach a projection bundle directly with no __base mounted. - // The bundle keeps its own dependency holds/refs for its lifetime, so we hold these transient ones only across - // the mount and release them immediately after a successful mount (acquire() is acyclic: base/root have no - // dependencies, so the recursion terminates). + // Mount this bundle's parents first: the bundle's own mount() takes holds + references on each parent and + // fails with a defensive error if a parent isn't registered+mounted at the location. The bundle keeps its own + // dependency holds/refs for its lifetime, so we hold these transient ones only across the mount and release + // them immediately after a successful mount. final Closer parentHolds = Closer.create(); try { for (PartialSegmentBundleCacheEntryIdentifier depId : inferBundleDependencies(bundleName)) { diff --git a/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java b/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java index a0948431e507..d8179e3ed45f 100644 --- a/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java +++ b/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java @@ -318,7 +318,7 @@ private void addFilesToCachedSegments( // Partial-segment layout is signaled by a {targetFilename}.header file in the segment dir final File partialDir = cacheEntry.toPotentialLocation(location.getPath()); if (partialDir.exists() - && PartialSegmentCacheBootstrap.isPartialSegmentLayout(partialDir, IndexIO.V10_FILE_NAME)) { + && PartialSegmentFileMapperV10.isPartialSegmentLayout(partialDir, IndexIO.V10_FILE_NAME)) { if (!config.isVirtualStoragePartialDownloadsEnabled()) { // Partial downloads are disabled but a partial-load layout (header + sparse containers) is on disk, e.g. the // operator toggled druid.segmentCache.virtualStoragePartialDownloadsEnabled off. The eager path can't serve @@ -336,61 +336,19 @@ private void addFilesToCachedSegments( atomicMoveAndDeleteCacheEntryDirectory(partialDir); continue; } - SegmentRangeReader rangeReader; - try { - rangeReader = tryOpenRangeReader(segment); - } - catch (Exception e) { - log.warn(e, "Failed to open a range reader for partial segment[%s] during bootstrap", segment.getId()); - rangeReader = null; - } - if (rangeReader == null) { - // Anomalous: a layout on disk means range reads worked when it was written, so this should not happen (the - // loadSpec is now non-range-capable, or no longer converts to a known type). Reclaim it and let the segment - // re-load fresh on next access rather than failing bootstrap or reserving an entry that could never fetch. - // Leave removeInfo true so it's treated as uncached. - log.warn( - "On-disk partial-load layout for segment[%s] in [%s] has no usable range reader (this should not " - + "happen); deleting it so bootstrap can continue.", - segment.getId(), - partialDir - ); - atomicMoveAndDeleteCacheEntryDirectory(partialDir); - continue; - } + // Nothing is reserved here: partial entries are reserved (and immediately mounted, which is what sizes them) + // one at a time in bootstrap(). removeInfo = false; - try { - PartialSegmentCacheBootstrap.reserveFromDisk( - segment.getId(), - partialDir, - IndexIO.V10_FILE_NAME, - List.of(), - rangeReader, - jsonMapper, - virtualStorageLoadingThreadPool, - location, - config.getVirtualStorageCoalesceGapBytes(), - config.getVirtualStorageMaxFetchRunBytes() - ); - cachedSegments.add(segment); - } - catch (Throwable t) { - // Reservation failed (header missing, location full, etc.) - log.warn(t, "Failed to reserve partial segment[%s] from disk; cold fetch on next access", segment.getId()); - } + cachedSegments.add(segment); // do not fall through to 'complete' path since this was a partial continue; } if (cacheEntry.checkExists(location.getPath())) { removeInfo = false; - final boolean reserveResult; - if (config.isVirtualStorage()) { - reserveResult = location.reserveWeak(cacheEntry); - } else { - reserveResult = location.reserve(cacheEntry); - } - if (!reserveResult) { + // Under virtual storage nothing is reserved here: as with the partial layout above, bootstrap() reserves this + // segment and mounts it under the resulting hold. The legacy path reserves it statically, up front. + if (!config.isVirtualStorage() && !location.reserve(cacheEntry)) { log.makeAlert( "storage[%s:%,d] has more segments than it is allowed. Currently loading Segment[%s:%,d]. Please increase druid.segmentCache.locations maxSize param", location.getPath(), @@ -870,72 +828,113 @@ private SegmentRangeReader tryOpenRangeReader(DataSegment dataSegment) */ private ReservedPartial reservePartial(DataSegment dataSegment, SegmentRangeReader rangeReader) { - final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(dataSegment.getId()); final Iterator iterator = strategy.getLocations(); while (iterator.hasNext()) { - final StorageLocation location = iterator.next(); - final File partialDir = new File(location.getPath(), dataSegment.getId().toString()); - try { - FileUtils.mkdirp(partialDir); + final ReservedPartial reserved = tryReservePartialAt(dataSegment, rangeReader, iterator.next(), true); + if (reserved != null) { + return reserved; } - catch (IOException e) { - // Location is unwritable, fall through to next location rather than failing the whole reservation + } + throw DruidException.forPersona(DruidException.Persona.USER) + .ofCategory(DruidException.Category.CAPACITY_EXCEEDED) + .build( + "Unable to reserve partial metadata for segment[%s]; ensure enough disk space has been allocated", + dataSegment.getId() + ); + } + + /** + * Reserve a partial metadata entry for {@code dataSegment} on one specific location, returning {@code null} if that + * location can't take it (unwritable, or no capacity) so a caller walking locations can try the next one. + * + * @param writeInfoFile write the segment info file now. False for bootstrap restores, where it already exists and is + * what told us about this segment in the first place + */ + @Nullable + private ReservedPartial tryReservePartialAt( + DataSegment dataSegment, + SegmentRangeReader rangeReader, + StorageLocation location, + boolean writeInfoFile + ) + { + final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(dataSegment.getId()); + final File partialDir = new File(location.getPath(), dataSegment.getId().toString()); + // A pre-existing directory (e.g. bootstrap) will restore in place + final boolean createdPartialDir = !partialDir.exists(); + try { + FileUtils.mkdirp(partialDir); + } + catch (IOException e) { + // Location is unwritable, let the caller fall through to the next location rather than failing the reservation + log.warn( + e, + "Failed to create partial cache dir on location[%s] for segment[%s]", + location.getPath(), + dataSegment.getId() + ); + return null; + } + final StorageLocation.ReservationHold hold = location.addWeakReservationHold( + id, + () -> new PartialSegmentMetadataCacheEntry( + dataSegment.getId(), + partialDir, + IndexIO.V10_FILE_NAME, + List.of(), + rangeReader, + jsonMapper, + virtualStorageLoadingThreadPool, + config.getVirtualStorageMetadataReservationEstimate(), + config.getVirtualStorageCoalesceGapBytes(), + config.getVirtualStorageMaxFetchRunBytes() + ) + ); + if (hold == null) { + if (createdPartialDir) { + atomicMoveAndDeleteCacheEntryDirectory(partialDir); + } else { + // A layout is already on disk here but the location has no room for its metadata entry and nothing + // reclaimable, which most likely means the location's configured maxSize shrank since the layout was written. + // Worth saying out loud either way: bootstrap fails the segment on this, and an on-demand acquire moves on to + // another location, leaving the files here with no entry behind them. log.warn( - e, - "Failed to create partial cache dir on location[%s] for segment[%s]; trying next location", + "Location[%s] with available bytes[%,d] cannot reserve metadata estimate[%,d] bytes for segment[%s], " + + "which already has partial cache state on disk there; check druid.segmentCache.locations maxSize.", location.getPath(), + location.availableSizeBytes(), + config.getVirtualStorageMetadataReservationEstimate(), dataSegment.getId() ); - continue; } - final StorageLocation.ReservationHold hold = location.addWeakReservationHold( - id, - () -> new PartialSegmentMetadataCacheEntry( - dataSegment.getId(), - partialDir, - IndexIO.V10_FILE_NAME, - List.of(), - rangeReader, - jsonMapper, - virtualStorageLoadingThreadPool, - config.getVirtualStorageMetadataReservationEstimate(), - config.getVirtualStorageCoalesceGapBytes(), - config.getVirtualStorageMaxFetchRunBytes() - ) - ); - if (hold == null) { - atomicMoveAndDeleteCacheEntryDirectory(partialDir); - continue; + return null; + } + try { + if (!(hold.getEntry() instanceof PartialSegmentMetadataCacheEntry partial)) { + throw DruidException.defensive( + "Unexpected non-partial cache entry[%s] at id[%s] on location[%s]", + hold.getEntry().getClass().getSimpleName(), + id, + location.getPath() + ); } - try { - if (!(hold.getEntry() instanceof PartialSegmentMetadataCacheEntry partial)) { - throw DruidException.defensive( - "Unexpected non-partial cache entry[%s] at id[%s] on location[%s]", - hold.getEntry().getClass().getSimpleName(), - id, - location.getPath() - ); - } + if (writeInfoFile) { rewriteInfoFile(dataSegment); - partial.setOnUnmount(() -> deleteSegmentInfoFile(dataSegment)); - return new ReservedPartial(partial, location, hold); } - catch (Throwable t) { - // Close the hold (removing the never-mounted weak entry), then nuke the on-disk dir. - try { - throw CloseableUtils.closeAndWrapInCatch(t, hold); - } - finally { + partial.setOnUnmount(() -> deleteSegmentInfoFile(dataSegment)); + return new ReservedPartial(partial, location, hold); + } + catch (Throwable t) { + // Close the hold (removing the never-mounted weak entry), then nuke the dir if we created it. + try { + throw CloseableUtils.closeAndWrapInCatch(t, hold); + } + finally { + if (createdPartialDir) { atomicMoveAndDeleteCacheEntryDirectory(partialDir); } } } - throw DruidException.forPersona(DruidException.Persona.USER) - .ofCategory(DruidException.Category.CAPACITY_EXCEEDED) - .build( - "Unable to reserve partial metadata for segment[%s]; ensure enough disk space has been allocated", - dataSegment.getId() - ); } /** @@ -1415,14 +1414,23 @@ public DataSegment bootstrap( "bootstrap() should not be called when virtualStorageIsEphemeral is true" ); } - // during bootstrap, check if the segment exists in a location and mount it; getCachedSegments already - // did the reserving for us + // During bootstrap, reserve whatever this segment left on disk and mount it. getCachedSegments only recognizes + // the layout; the reservation is made here, one segment at a time, so that a partial's pessimistic metadata + // estimate is only outstanding until its mount shrinks it, and so that every mount runs under a hold. That hold + // matters: reclaim passes over held entries only, so an unheld entry can be evicted by a parallel bootstrap + // thread's reservation while this one is still mounting it. final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(dataSegment.getId()); // Assemble the loaded-profile inside the segment lock (null = no partial materialized) PartialLoadProfile loadedProfile = null; final ReferenceCountingLock lock = lock(dataSegment); synchronized (lock) { + StorageLocation.ReservationHold bootstrapHold = null; try { + // A segment has either a partial layout or a complete one, so at most one of these reserves anything + bootstrapHold = reservePartialForBootstrap(dataSegment, id); + if (bootstrapHold == null) { + bootstrapHold = reserveCompleteForBootstrap(dataSegment, id); + } for (StorageLocation location : locations) { final CacheEntry entry = location.getCacheEntry(id); if (entry == null) { @@ -1475,6 +1483,12 @@ public DataSegment bootstrap( } } finally { + if (bootstrapHold != null) { + CloseableUtils.closeAndSuppressExceptions( + bootstrapHold, + t -> log.warn(t, "Failed to release bootstrap reservation hold for segment[%s]", dataSegment.getId()) + ); + } unlock(dataSegment, lock); } } @@ -1599,6 +1613,134 @@ private void releaseRuleForFullLoad(DataSegment dataSegment, PartialSegmentMetad ); } + /** + * Whether any location already has a cache entry for {@code id} + */ + private boolean isRegisteredAtAnyLocation(SegmentCacheEntryIdentifier id) + { + for (StorageLocation location : locations) { + if (location.getCacheEntry(id) != null) { + return true; + } + } + return false; + } + + /** + * Reserve a metadata entry for a partial-load layout that survived on disk, so {@link #bootstrap} has something to + * mount, and hand back the hold it was reserved under. Returns {@code null} when this segment has no on-disk partial + * layout, or already has an entry, in which case bootstrap proceeds as it does for complete segments. + *

+ * Restoring reads the local layout rather than deep storage: the mount parses the on-disk header and picks up + * whichever bundles still have all their container files (see + * {@link PartialSegmentMetadataCacheEntry#mount}). The exception is a header that turns out to be damaged, which is + * deleted and re-fetched, leaving the entry mounted with nothing marked as downloaded. + * + * @throws SegmentLoadingException if the layout exists but cannot be restored, so the segment is marked failed and + * the coordinator re-issues a load for it rather than it being announced with + * nothing behind it + */ + @Nullable + private StorageLocation.ReservationHold reservePartialForBootstrap( + DataSegment dataSegment, + SegmentCacheEntryIdentifier id + ) throws SegmentLoadingException + { + if (isRegisteredAtAnyLocation(id)) { + return null; + } + for (StorageLocation location : locations) { + final File partialDir = new File(location.getPath(), dataSegment.getId().toString()); + if (!PartialSegmentFileMapperV10.isPartialSegmentLayout(partialDir, IndexIO.V10_FILE_NAME)) { + continue; + } + SegmentRangeReader rangeReader; + try { + rangeReader = tryOpenRangeReader(dataSegment); + } + catch (Exception e) { + log.warn(e, "Failed to open a range reader for partial segment[%s] during bootstrap", dataSegment.getId()); + rangeReader = null; + } + if (rangeReader == null) { + // Anomalous: a layout on disk means range reads worked when it was written, so this should not happen (the + // loadSpec is now non-range-capable, or no longer converts to a known type). Reclaim the layout so the next + // load fetches it fresh rather than reserving an entry that could never fetch anything. The info file goes + // with it: it asserts that this segment has local cache state, which stops being true here, and nothing else + // will remove it (no entry was reserved, so there is no unmount hook to fire). + log.warn( + "On-disk partial-load layout for segment[%s] in [%s] has no usable range reader (this should not " + + "happen); deleting it so the segment can be re-loaded.", + dataSegment.getId(), + partialDir + ); + atomicMoveAndDeleteCacheEntryDirectory(partialDir); + deleteSegmentInfoFile(dataSegment); + throw new SegmentLoadingException( + "No usable range reader for partial segment[%s]; its local layout has been reclaimed", + dataSegment.getId() + ); + } + final ReservedPartial reserved = tryReservePartialAt(dataSegment, rangeReader, location, false); + if (reserved == null) { + throw new SegmentLoadingException( + "Failed to reserve partial metadata for segment[%s] on location[%s] during bootstrap", + dataSegment.getId(), + location.getPath() + ); + } + return reserved.hold; + } + return null; + } + + /** + * Reserve a {@link CompleteSegmentCacheEntry} for an eagerly-downloaded segment whose files are on disk, so + * {@link #bootstrap} has something to mount, and hand back the hold it was reserved under. Returns {@code null} when + * this segment has no complete layout on disk, or already has an entry. Virtual storage only: the legacy path + * reserves these statically in {@link #getCachedSegments} and never evicts them. + *

+ * Reserving under a hold is what makes the mount safe: reclaim skips held entries only, so an unheld entry can be + * selected as a victim while a parallel bootstrap thread is still mounting it. Releasing the hold once mounted + * leaves the entry as evictable as any other weak entry. + * + * @throws SegmentLoadingException if the location cannot accept the reservation, which used to be an alert followed + * by mounting the segment unreserved, leaving the location under-counting the disk + * it was actually using + */ + @Nullable + private StorageLocation.ReservationHold reserveCompleteForBootstrap( + DataSegment dataSegment, + SegmentCacheEntryIdentifier id + ) throws SegmentLoadingException + { + if (isRegisteredAtAnyLocation(id)) { + return null; + } + for (StorageLocation location : locations) { + final CacheEntry cacheEntry = new CompleteSegmentCacheEntry(dataSegment); + if (!((CompleteSegmentCacheEntry) cacheEntry).checkExists(location.getPath())) { + continue; + } + final StorageLocation.ReservationHold hold = location.addWeakReservationHold( + id, + () -> new CompleteSegmentCacheEntry(dataSegment) + ); + if (hold == null) { + throw new SegmentLoadingException( + "Location[%s] with available bytes[%,d] cannot reserve segment[%s] of size[%,d] during bootstrap; check " + + "druid.segmentCache.locations maxSize", + location.getPath(), + location.availableSizeBytes(), + dataSegment.getId(), + dataSegment.getSize() + ); + } + return hold; + } + return null; + } + /** * Reapply the persisted partial-load rule to a bootstrap-restored metadata entry. Reads the wrapper from the * segment's info-file {@code loadSpec}, resolves the selected bundle names against the just-parsed on-disk @@ -1621,11 +1763,6 @@ private void reapplyRuleFromInfoFile(DataSegment dataSegment, PartialSegmentMeta "Bootstrap-restored partial metadata for segment[%s] has no file mapper", dataSegment.getId() ); } - // Register the info-file cleanup hook BEFORE anything that could throw. reserveFromDisk does NOT install an - // onUnmount hook on bootstrap-restored entries, so without this ordering a throw from getSelectedBundleNames - // or applyRule would leave the entry with no cleanup path. Setting the hook first ensures every teardown path - // deletes the info file consistently. - partial.setOnUnmount(() -> deleteSegmentInfoFile(dataSegment)); final Set selected = Set.copyOf( wrapper.getSelectedBundleNames(dataSegment, mapper.getSegmentFileMetadata()) ); diff --git a/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java b/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java index 6330f20cb4c9..0c3e4cc8d77c 100644 --- a/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java +++ b/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java @@ -50,9 +50,10 @@ * with {@link #reserve(CacheEntry)}, where the space of the entry is accounted for and the storage space will not be * recovered until {@link #release(CacheEntry)} is called. These entries are stored in {@link #staticCacheEntries}. *

- * The second way is to store as a transient cache item with one of {@link #reserveWeak(CacheEntry)}, - * {@link #addWeakReservationHold(CacheEntryIdentifier, Supplier)}, or - * {@link #addWeakReservationHoldIfExists(CacheEntryIdentifier)}. {@link CacheEntry} stored in this manner will exist on + * The second way is to store as a transient cache item with + * {@link #addWeakReservationHold(CacheEntryIdentifier, Supplier)} or + * {@link #addWeakReservationHoldIfExists(CacheEntryIdentifier)}. Both place a hold on the entry, so it cannot be + * reclaimed while the caller is still mounting or reading it. {@link CacheEntry} stored in this manner will exist on * disk in this location until the point that another new reservation needs more space than remains available in the * location, at which point {@link #reclaim(long)} will be called to try to call {@link CacheEntry#unmount()} on any * eligible entries until enough space is available to store the new item. @@ -284,54 +285,6 @@ public boolean reserve(CacheEntry entry) } } - /** - * Reserves space to store a 'weak' reservation for a given {@link CacheEntry}. Returns true if already reserved or - * was able to be successfully reserved, or false if unable to be reserved. This method is intended for use during - * 'bootstrapping'. To use weak cache entries in a query engine use - * {@link #addWeakReservationHold(CacheEntryIdentifier, Supplier)} or - * {@link #addWeakReservationHoldIfExists(CacheEntryIdentifier)}, which places a hold on cache entries to prevent - * eviction until the hold is released. - */ - public boolean reserveWeak(CacheEntry entry) - { - lock.readLock().lock(); - try { - if (staticCacheEntries.containsKey(entry.getId())) { - return true; - } - if (weakCacheEntries.containsKey(entry.getId())) { - weakCacheEntries.get(entry.getId()).visited = true; - return true; - } - } - finally { - lock.readLock().unlock(); - } - - lock.writeLock().lock(); - try { - if (staticCacheEntries.containsKey(entry.getId())) { - return true; - } - if (weakCacheEntries.containsKey(entry.getId())) { - weakCacheEntries.get(entry.getId()).visited = true; - return true; - } - final ReclaimResult reclaimResult = canHandleWeak(entry); - unmountReclaimed(reclaimResult); - if (reclaimResult.isSuccess()) { - final WeakCacheEntry newEntry = new WeakCacheEntry(entry); - linkNewWeakEntry(newEntry); - weakCacheEntries.put(entry.getId(), newEntry); - weakStats.getAndUpdate(s -> s.loadBegin(entry.getSize())); - } - return reclaimResult.isSuccess(); - } - finally { - lock.writeLock().unlock(); - } - } - /** * Returns a {@link ReservationHold} of a {@link CacheEntry} with a 'hold' placed on it, preventing it from being * automatically removed by {@link #reclaim(long)} if the {@link CacheEntry} is one of {@link #weakCacheEntries} until @@ -524,13 +477,12 @@ public void release(CacheEntry entry) * queue and terminating its phaser (which fires the underlying {@link CacheEntry#unmount}). No-op when the entry * is absent, is a {@link #staticCacheEntries} entry, or still has outstanding holds. *

- * This exists for callers that register a weak entry without a {@link ReservationHold} (the bootstrap - * reserve path uses {@link #reserveWeak}) and need to clean it up after a failed mount. The normal runtime path - * registers weak entries via {@link #addWeakReservationHold} and relies on the hold's release runnable to evict a - * never-mounted entry on close; an entry registered without a hold has no such cleanup, so a failed mount would - * otherwise leave it lingering (and un-re-mountable if its on-disk state was deleted by the mount rollback). The - * hold guard makes this safe to call unconditionally on any mount failure: a held entry (runtime path) is left to - * its holder's release runnable. + * This is for reclaiming a reservation whose entry has nothing behind it any more, after a failed mount. It covers + * a hold released mid-mount, once the entry already reported {@link CacheEntry#isMounted()}. Every weak entry is + * created under a {@link ReservationHold} and releasing that hold removes an entry that never mounted, but the + * release runnable leaves a mounted one registered, so if the mount then fails anyway nothing else would remove it. + * The hold guard makes this safe to call unconditionally on any mount failure: an entry someone still holds is left + * to its holder's release runnable. */ public void removeUnheldWeakEntry(CacheEntryIdentifier id) { diff --git a/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntryTest.java b/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntryTest.java index 762230eed4eb..b3bcb026f050 100644 --- a/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntryTest.java +++ b/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntryTest.java @@ -661,7 +661,7 @@ private PartialSegmentMetadataCacheEntry mountedEntryOver(File deepStorageDir) t } /** - * Variant of {@link #mountedEntryOver} that uses {@link StorageLocation#reserveWeak} so the mounted entry is a real + * Variant of {@link #mountedEntryOver} that leaves the entry registered but unheld, so the mounted entry is a real * weak reservation — needed by the rule-holds state machine, which calls * {@link StorageLocation#addWeakReservationHoldIfExists} on itself when {@code applyRule} runs. */ @@ -682,8 +682,13 @@ private PartialSegmentMetadataCacheEntry mountedWeakEntryOver(File deepStorageDi PartialSegmentFileMapperV10.DEFAULT_COALESCE_GAP_BYTES, PartialSegmentFileMapperV10.DEFAULT_MAX_FETCH_RUN_BYTES ); - Assertions.assertTrue(location.reserveWeak(entry)); + // Reserve under a hold, mount, then release: the entry stays registered but unheld, as it would after a + // bootstrap restore. + final StorageLocation.ReservationHold hold = + location.addWeakReservationHold(entry.getId(), () -> entry); + Assertions.assertNotNull(hold); entry.mount(location); + hold.close(); return entry; } } diff --git a/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrapTest.java b/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentRestoreFromDiskTest.java similarity index 76% rename from server/src/test/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrapTest.java rename to server/src/test/java/org/apache/druid/segment/loading/PartialSegmentRestoreFromDiskTest.java index c972f9eee77a..d3e651aff2e4 100644 --- a/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrapTest.java +++ b/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentRestoreFromDiskTest.java @@ -26,7 +26,6 @@ import org.apache.druid.data.input.impl.DimensionsSpec; import org.apache.druid.data.input.impl.LongDimensionSchema; import org.apache.druid.data.input.impl.StringDimensionSchema; -import org.apache.druid.error.DruidException; import org.apache.druid.java.util.common.DateTimes; import org.apache.druid.java.util.common.FileUtils; import org.apache.druid.java.util.common.Intervals; @@ -52,9 +51,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; import java.io.File; import java.io.IOException; +import java.io.RandomAccessFile; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -64,7 +66,7 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; -class PartialSegmentCacheBootstrapTest +class PartialSegmentRestoreFromDiskTest { private static final ObjectMapper JSON_MAPPER = TestHelper.makeJsonMapper(); private static final SegmentId SEGMENT_ID = SegmentId.of("test", Intervals.of("2025/2026"), "v1", 0); @@ -296,13 +298,14 @@ void testRestoreRollsBackOnBundleReservationFailure() throws IOException cacheDir, IndexIO.V10_FILE_NAME + PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX ); - // size location exactly to the header size: metadata reservation fits, but the first bundle's weak reservation - // has 0 bytes of remaining budget and no weak entries to reclaim, so addWeakReservationHold returns null + // Size the location and the reservation estimate exactly to the header size: the metadata reservation fits (and + // needs no shrink at mount), but the first bundle's weak reservation has 0 bytes of remaining budget and no weak + // entries to reclaim, so addWeakReservationHold returns null. final StorageLocation location = new StorageLocation(cacheDir, headerFile.length(), null); Assertions.assertThrows( Throwable.class, - () -> restoreFromDisk(location) + () -> restoreFromDisk(location, new DirectoryBackedRangeReader(deepStorageDir), headerFile.length()) ); // rollback must release the metadata reservation and leave no static/weak entries behind @@ -317,6 +320,40 @@ void testRestoreRollsBackOnBundleReservationFailure() throws IOException Assertions.assertFalse(headerFile.exists(), "bootstrap failure deletes the header via the unmount cleanup path"); } + /** + * The restore mounts {@code __base} before the bundles that depend on it, so a failure on a later bundle has to + * unwind one that already mounted. Unmounting it is not enough: its cache entry would stay registered with its + * reservation, while its containers are gone, and a later acquire reuses a registered entry by id, re-mounting a + * bundle bound to the metadata entry this failure tears down. + */ + @Test + void testRollbackRemovesBundlesItAlreadyMounted() throws IOException + { + primeOnDiskState(); + + final StorageLocation location = Mockito.spy(new StorageLocation(cacheDir, ESTIMATE * 8, null)); + final PartialSegmentBundleCacheEntryIdentifier aggId = + new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, AGG_BUNDLE); + // Refuse only the aggregate bundle's reservation, so the restore fails with __base already mounted. + Mockito.doReturn(null) + .when(location) + .addWeakReservationHold(ArgumentMatchers.eq(aggId), ArgumentMatchers.any()); + + Assertions.assertThrows(Throwable.class, () -> restoreFromDisk(location)); + + // Pins that __base really did mount and then got removed, rather than the rollback loop being empty because the + // restore failed before mounting anything. + Mockito.verify(location).removeUnheldWeakEntry( + new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, Projections.BASE_TABLE_PROJECTION_NAME) + ); + Assertions.assertEquals( + 0, + location.getWeakEntryCount(), + "rollback must remove the bundle entries it registered, not just unmount them" + ); + Assertions.assertEquals(0, location.currentSizeBytes(), "rollback must release every reservation it took"); + } + @Test void testMountFailureRemovesLingeringWeakEntry() throws IOException { @@ -324,24 +361,10 @@ void testMountFailureRemovesLingeringWeakEntry() throws IOException final StorageLocation location = new StorageLocation(cacheDir, ESTIMATE * 8, null); final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(SEGMENT_ID); - // Reserve the metadata entry weakly, exactly as the bootstrap path does (no protecting hold), but with a range - // reader that fails every fetch so we can force a mount failure below. + // A range reader that fails every fetch, so the mount below cannot rebuild the file mapper. final SegmentRangeReader failingReader = (filename, offset, length) -> { throw new IOException("simulated deep-storage fetch failure during mount"); }; - final PartialSegmentMetadataCacheEntry metadata = PartialSegmentCacheBootstrap.reserveFromDisk( - SEGMENT_ID, - cacheDir, - IndexIO.V10_FILE_NAME, - List.of(), - failingReader, - JSON_MAPPER, - null, - location, - PartialSegmentFileMapperV10.DEFAULT_COALESCE_GAP_BYTES, - PartialSegmentFileMapperV10.DEFAULT_MAX_FETCH_RUN_BYTES - ); - Assertions.assertTrue(location.isWeakReserved(id)); // Delete the header so the mount's file-mapper build must re-fetch it from deep storage; the failing reader throws, // failing the mount (the same poison that arises when create() detects header corruption and the deep-storage @@ -352,7 +375,7 @@ void testMountFailureRemovesLingeringWeakEntry() throws IOException ); Assertions.assertTrue(headerFile.delete()); - Assertions.assertThrows(Throwable.class, () -> metadata.mount(location)); + Assertions.assertThrows(Throwable.class, () -> restoreFromDisk(location, failingReader, ESTIMATE)); // The failed mount must not leave the lingering weak entry behind: a later findExistingPartialWithHold would // otherwise resurrect it and re-mount would fail forever (failing reader + deleted header). It must be gone so @@ -365,9 +388,9 @@ void testMountFailureRemovesLingeringWeakEntry() throws IOException @Test void testRestoredEntryCanFetchUndownloadedFile() throws IOException { - // a bootstrap-restored entry must keep the segment's real deep-storage range reader so a later query can - // fetch a bundle/column that wasn't on disk at startup. Previously the entry held a throwing disk-only reader, so - // this fetch failed with "bootstrap should only read from local disk". + // a restored entry keeps the segment's real deep-storage range reader, so a later query can fetch a bundle or + // column that wasn't on disk at startup. The local layout is a head start, never the limit of what the entry can + // read. primeOnDiskState(); final StorageLocation location = new StorageLocation(cacheDir, ESTIMATE * 8, null); final PartialSegmentMetadataCacheEntry metadata = restoreFromDisk(location); @@ -394,38 +417,71 @@ void testRestoredEntryCanFetchUndownloadedFile() throws IOException } @Test - void testReserveFailsWhenHeaderMissing() + void testMountShrinksEstimateToHeaderFootprint() throws IOException { - // no priming: cacheDir is empty + primeOnDiskState(); + final StorageLocation location = new StorageLocation(cacheDir, ESTIMATE * 8, null); - Assertions.assertThrows( - DruidException.class, - () -> PartialSegmentCacheBootstrap.reserveFromDisk( - SEGMENT_ID, - cacheDir, - IndexIO.V10_FILE_NAME, - List.of(), - new DirectoryBackedRangeReader(deepStorageDir), - JSON_MAPPER, - null, - location, - PartialSegmentFileMapperV10.DEFAULT_COALESCE_GAP_BYTES, - PartialSegmentFileMapperV10.DEFAULT_MAX_FETCH_RUN_BYTES - ) - ); + final PartialSegmentMetadataCacheEntry metadata = restoreFromDisk(location); + + // The entry is reserved with the pessimistic estimate and shrunk by mount, which is the only place an entry's + // size is computed. Restoring an existing layout is no exception: it goes through the same reserve-then-shrink, + // so there is no second, pre-mount size that the mount could disagree with. + final long headerFootprint = metadata.getFileMapper().getOnDiskHeaderSize(); + Assertions.assertTrue(headerFootprint < ESTIMATE); + Assertions.assertEquals(headerFootprint, metadata.getSize()); + + // Re-mounting the same entry measures the same footprint, so the shrink is a no-op rather than a grow + metadata.unmount(); + metadata.mount(location); + Assertions.assertEquals(headerFootprint, metadata.getFileMapper().getOnDiskHeaderSize()); + Assertions.assertEquals(headerFootprint, metadata.getSize()); } @Test - void testIsPartialSegmentLayoutDetectsHeader() throws IOException + void testRestoreWithoutLocalHeaderColdFetches() throws IOException + { + // no priming: cacheDir is empty, so the mount fetches the header from deep storage like a fresh acquire + final StorageLocation location = new StorageLocation(cacheDir, ESTIMATE * 8, null); + final PartialSegmentMetadataCacheEntry metadata = restoreFromDisk(location); + + Assertions.assertTrue(metadata.isMounted()); + Assertions.assertEquals(metadata.getFileMapper().getOnDiskHeaderSize(), metadata.getSize()); + } + + /** + * A header file whose bitmap region never made it to disk (a crash, or an interrupt, between persisting the header + * bytes and extending the file) must be treated as corrupt and re-fetched. Before the header and its bitmap region + * were published together, this state was silently repaired by growing the file, which meant the same segment + * measured one size before the mount and a larger one after it, and a reservation sized from the short file was + * asked to grow, which storage locations do not support. + */ + @Test + void testHeaderMissingBitmapRegionDoesNotGrowReservation() throws IOException { - Assertions.assertFalse(PartialSegmentCacheBootstrap.isPartialSegmentLayout(cacheDir, IndexIO.V10_FILE_NAME)); primeOnDiskState(); - Assertions.assertTrue(PartialSegmentCacheBootstrap.isPartialSegmentLayout(cacheDir, IndexIO.V10_FILE_NAME)); - Assertions.assertFalse(PartialSegmentCacheBootstrap.isPartialSegmentLayout(null, IndexIO.V10_FILE_NAME)); - Assertions.assertFalse(PartialSegmentCacheBootstrap.isPartialSegmentLayout( - new File(perTestTempDir, "nonexistent"), - IndexIO.V10_FILE_NAME - )); + + final File headerFile = new File( + cacheDir, + IndexIO.V10_FILE_NAME + PartialSegmentFileMapperV10.METADATA_HEADER_SUFFIX + ); + final int numFiles; + try (PartialSegmentFileMapperV10 introspect = createMapper(deepStorageDir, cacheDir)) { + numFiles = introspect.getSegmentFileMetadata().getFiles().size(); + } + final long fullLength = headerFile.length(); + final int bitmapBytes = (numFiles + 7) / 8; + Assertions.assertTrue(bitmapBytes > 0); + try (RandomAccessFile raf = new RandomAccessFile(headerFile, "rw")) { + raf.setLength(fullLength - bitmapBytes); + } + + final StorageLocation location = new StorageLocation(cacheDir, ESTIMATE * 8, null); + final PartialSegmentMetadataCacheEntry metadata = restoreFromDisk(location); + + Assertions.assertTrue(metadata.isMounted()); + Assertions.assertEquals(fullLength, headerFile.length(), "the truncated header must have been re-fetched"); + Assertions.assertEquals(fullLength, metadata.getSize()); } @Test @@ -529,26 +585,48 @@ private void primeOnDiskState() throws IOException } /** - * Two-step restore helper that mirrors what {@code SegmentLocalCacheManager} does in production: reserve the metadata - * entry via {@link PartialSegmentCacheBootstrap#reserveFromDisk}, then drive {@link PartialSegmentMetadataCacheEntry#mount} - * to trigger the file-mapper build + bundle restore. On a mount failure, {@code mount}'s own rollback removes the - * (unheld) weak entry from the location, so tests can assert on a clean location without any extra cleanup here. + * Two-step restore helper that mirrors what {@code SegmentLocalCacheManager} does in production: reserve the + * metadata entry with the up-front estimate under a hold (exactly as {@code bootstrap()} does when it finds a + * partial layout on disk), drive {@link PartialSegmentMetadataCacheEntry#mount} to trigger the file-mapper build, + * reservation shrink, and bundle restore, then release the hold. On a mount failure, releasing the hold removes the + * never-mounted weak entry, so tests can assert on a clean location without any extra cleanup here. */ private PartialSegmentMetadataCacheEntry restoreFromDisk(StorageLocation location) throws IOException { - final PartialSegmentMetadataCacheEntry metadata = PartialSegmentCacheBootstrap.reserveFromDisk( + return restoreFromDisk(location, new DirectoryBackedRangeReader(deepStorageDir), ESTIMATE); + } + + private PartialSegmentMetadataCacheEntry restoreFromDisk( + StorageLocation location, + SegmentRangeReader rangeReader, + long reservationEstimate + ) throws IOException + { + final PartialSegmentMetadataCacheEntry metadata = new PartialSegmentMetadataCacheEntry( SEGMENT_ID, cacheDir, IndexIO.V10_FILE_NAME, List.of(), - new DirectoryBackedRangeReader(deepStorageDir), + rangeReader, JSON_MAPPER, null, - location, + reservationEstimate, PartialSegmentFileMapperV10.DEFAULT_COALESCE_GAP_BYTES, PartialSegmentFileMapperV10.DEFAULT_MAX_FETCH_RUN_BYTES ); - metadata.mount(location); + final StorageLocation.ReservationHold hold = location.addWeakReservationHold( + metadata.getId(), + () -> metadata + ); + if (hold == null) { + throw new IOException("location has no capacity for the metadata reservation"); + } + try { + metadata.mount(location); + } + finally { + hold.close(); + } return metadata; } diff --git a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerBootstrapReserveTest.java b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerBootstrapReserveTest.java new file mode 100644 index 000000000000..e75f9c3d9137 --- /dev/null +++ b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerBootstrapReserveTest.java @@ -0,0 +1,140 @@ +/* + * 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.druid.segment.loading; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.druid.java.util.common.FileUtils; +import org.apache.druid.java.util.common.Intervals; +import org.apache.druid.segment.SegmentLazyLoadFailCallback; +import org.apache.druid.segment.TestHelper; +import org.apache.druid.segment.column.ColumnConfig; +import org.apache.druid.timeline.DataSegment; +import org.apache.druid.timeline.SegmentId; +import org.apache.druid.timeline.partition.NoneShardSpec; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Covers what a virtual-storage bootstrap reserves, and what happens when it cannot. Both the partial and the complete + * layout are reserved by {@code bootstrap()} rather than by {@code getCachedSegments()}, so that every mount runs under + * a hold: reclaim passes over held entries only, so an unheld entry can be chosen as an eviction victim by a parallel + * bootstrap thread's reservation while this one is still mounting it. + */ +class SegmentLocalCacheManagerBootstrapReserveTest +{ + private static final SegmentId SEGMENT_ID = SegmentId.of("test", Intervals.of("2025/2026"), "v1", 0); + private static final long SEGMENT_SIZE = 4096L; + + @TempDir + File tempDir; + + private File cacheRoot; + private SegmentLocalCacheManager manager; + private DataSegment segment; + + @BeforeEach + void setup() throws IOException + { + cacheRoot = new File(tempDir, "cache"); + FileUtils.mkdirp(cacheRoot); + segment = DataSegment.builder(SEGMENT_ID) + .shardSpec(NoneShardSpec.instance()) + .loadSpec(Map.of("type", "local", "path", new File(tempDir, "deep").getAbsolutePath())) + .size(SEGMENT_SIZE) + .build(); + } + + @AfterEach + void tearDown() + { + if (manager != null) { + manager.shutdown(); + } + } + + /** + * Build a virtual-storage manager over a location of the given size, with the on-disk shape of an eagerly + * downloaded segment: a directory named for the segment, plus its info file. The directory's contents don't matter + * to either test, since neither gets as far as a successful mount. + */ + private void setUpManagerWithLocationSize(long locationSize) throws IOException + { + final ObjectMapper jsonMapper = TestHelper.makeJsonMapper(); + final StorageLocationConfig locationConfig = new StorageLocationConfig(cacheRoot, locationSize, null); + final SegmentLoaderConfig loaderConfig = SegmentLoaderConfig.builder() + .locations(locationConfig) + .virtualStorage(true) + .build(); + final List storageLocations = loaderConfig.toStorageLocations(); + manager = new SegmentLocalCacheManager( + storageLocations, + loaderConfig, + StorageLoadingThreadPool.createFromConfig(loaderConfig), + new LeastBytesUsedStorageLocationSelectorStrategy(storageLocations), + TestHelper.getTestIndexIO(jsonMapper, ColumnConfig.DEFAULT), + jsonMapper + ); + FileUtils.mkdirp(new File(cacheRoot, SEGMENT_ID.toString())); + manager.storeInfoFile(segment); + } + + @Test + void testCompleteLayoutIsReservedByBootstrapNotByGetCachedSegments() throws IOException + { + // Room to spare, so a reservation here would succeed if getCachedSegments still made one. + setUpManagerWithLocationSize(SEGMENT_SIZE * 4); + + Assertions.assertEquals(List.of(segment), manager.getCachedSegments()); + Assertions.assertNull( + manager.getLocations().get(0).getCacheEntry(new SegmentCacheEntryIdentifier(SEGMENT_ID)), + "getCachedSegments must only recognize the layout; reserving is bootstrap's job" + ); + Assertions.assertEquals(0, manager.getLocations().get(0).currentSizeBytes()); + } + + @Test + void testBootstrapFailsSegmentWhenLocationCannotReserveIt() throws IOException + { + // Far too small for the segment, so the bootstrap reservation cannot be satisfied. + setUpManagerWithLocationSize(SEGMENT_SIZE / 4); + manager.getCachedSegments(); + + // Previously the too-small reservation was an alert, after which the segment was mounted anyway with nothing + // reserved for it, leaving the location under-counting the disk it was really using. Now the segment fails to + // bootstrap, so it is not announced and the coordinator re-issues a load for it. + Assertions.assertThrows( + SegmentLoadingException.class, + () -> manager.bootstrap(segment, SegmentLazyLoadFailCallback.NOOP) + ); + Assertions.assertNull( + manager.getLocations().get(0).getCacheEntry(new SegmentCacheEntryIdentifier(SEGMENT_ID)), + "a segment that could not be reserved must not be left registered" + ); + Assertions.assertEquals(0, manager.getLocations().get(0).currentSizeBytes()); + } +} diff --git a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java index 7a823904db63..e251395f4ecc 100644 --- a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java +++ b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java @@ -78,8 +78,10 @@ import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -754,9 +756,9 @@ void testGetCachedSegmentsThenBootstrapMountsPartialEntry() throws IOException, SegmentLoadingException { // Simulate process-restart state: prime the partial on-disk layout (header file + sparse-allocated containers) - // and the segment info file BEFORE getCachedSegments runs, then verify the new two-phase contract: - // - getCachedSegments() reserves the metadata entry on the location but doesn't mount it - // - bootstrap(DataSegment) is what triggers the actual mount + bundle restore via polymorphic dispatch + // and the segment info file BEFORE getCachedSegments runs, then verify the two-phase contract: + // - getCachedSegments() only recognizes the layout as cached; it reserves nothing + // - bootstrap(DataSegment) reserves the metadata entry and mounts it, which is also what sizes it final File partialDir = new File(cacheRoot, SEGMENT_ID.toString()); FileUtils.mkdirp(partialDir); primePartialOnDiskState(partialDir); @@ -767,23 +769,29 @@ void testGetCachedSegmentsThenBootstrapMountsPartialEntry() final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(SEGMENT_ID); final StorageLocation location = manager.getLocations().get(0); + Assertions.assertNull( + location.getCacheEntry(id), + "getCachedSegments must not reserve a partial entry; reservation is deferred to bootstrap()" + ); + Assertions.assertEquals(0, location.currentSizeBytes(), "nothing should be charged to the location yet"); + + manager.bootstrap(partialSegment, SegmentLazyLoadFailCallback.NOOP); + final CacheEntry reserved = location.getCacheEntry(id); Assertions.assertInstanceOf( PartialSegmentMetadataCacheEntry.class, reserved, - "getCachedSegments must reserve a partial metadata entry on the location" + "bootstrap must reserve a partial metadata entry on the location holding the layout" ); final PartialSegmentMetadataCacheEntry partial = (PartialSegmentMetadataCacheEntry) reserved; - Assertions.assertFalse( - partial.isMounted(), - "metadata entry should NOT be mounted after getCachedSegments; mount is deferred to bootstrap()" - ); - - // bootstrap() dispatches polymorphically: partial entry -> metadata.mount(location), which cascades into bundle - // restore via PartialSegmentCacheBootstrap.restoreBundlesFromDisk. - manager.bootstrap(partialSegment, SegmentLazyLoadFailCallback.NOOP); Assertions.assertTrue(partial.isMounted(), "metadata entry must be mounted after bootstrap()"); + // The estimate the entry was reserved with has been shrunk to the mounted footprint, and the bootstrap hold is + // released, so the restored entry is as evictable as any other + Assertions.assertEquals(partial.getFileMapper().getOnDiskHeaderSize(), partial.getSize()); + Assertions.assertTrue( + partial.getSize() < manager.getConfig().getVirtualStorageMetadataReservationEstimate() + ); Assertions.assertFalse( partial.snapshotLinkedBundles().isEmpty(), "bundle restore must have linked at least one bundle to the metadata entry" @@ -795,6 +803,26 @@ void testGetCachedSegmentsThenBootstrapMountsPartialEntry() restoredBundles.contains(Projections.BASE_TABLE_PROJECTION_NAME), "base bundle must be restored by bootstrap; got " + restoredBundles ); + + // A bootstrap-restored entry owns its info file just like an on-demand one: the info file asserts that local + // cache state exists, and unmounting deletes that state (header files included), so the two go together. + // Without the hook, an evicted restored segment would leave the next startup trying to restore a layout that + // isn't there. (Note dropping the segment is not what triggers this: a weak entry survives drop, and reclaim is + // left to eviction, which is what the unmount below stands in for.) + final File infoFile = new File(new File(cacheRoot, "info_dir"), SEGMENT_ID.toString()); + Assertions.assertTrue(infoFile.exists(), "bootstrap must leave the info file it restored from in place"); + + // Unmount dependents before parents so each parent hold is released before its bundle tears down, the order + // eviction's cascade produces. + final List bundles = new ArrayList<>(partial.snapshotLinkedBundles()); + bundles.sort(Comparator.comparing(b -> Projections.BASE_TABLE_PROJECTION_NAME.equals(b.getBundleName()))); + for (PartialSegmentBundleCacheEntry bundle : bundles) { + bundle.unmount(); + } + partial.unmount(); + + Assertions.assertFalse(partial.isMounted(), "the restored entry should be unmounted"); + Assertions.assertFalse(infoFile.exists(), "unmounting the restored entry must delete its info file"); } @Test @@ -805,7 +833,7 @@ void testGetCachedSegmentsDeletesPartialLayoutWhenPartialDisabled() throws IOExc FileUtils.mkdirp(partialDir); primePartialOnDiskState(partialDir); manager.storeInfoFile(partialSegment); - Assertions.assertTrue(PartialSegmentCacheBootstrap.isPartialSegmentLayout(partialDir, IndexIO.V10_FILE_NAME)); + Assertions.assertTrue(PartialSegmentFileMapperV10.isPartialSegmentLayout(partialDir, IndexIO.V10_FILE_NAME)); // A manager with partial downloads disabled (operator toggled the flag off) over the same cache dir must reclaim // the now-unusable partial layout at bootstrap rather than reserving it and failing at query time. @@ -846,7 +874,7 @@ void testGetCachedSegmentsDeletesPartialLayoutWhenPartialDisabled() throws IOExc } @Test - void testGetCachedSegmentsDeletesPartialLayoutWhenRangeReaderUnavailable() throws IOException + void testBootstrapDeletesPartialLayoutWhenRangeReaderUnavailable() throws IOException { // Prime a valid partial on-disk layout (header written from the real deep-storage dir) as a previous run left it. final File partialDir = new File(cacheRoot, SEGMENT_ID.toString()); @@ -855,8 +883,8 @@ void testGetCachedSegmentsDeletesPartialLayoutWhenRangeReaderUnavailable() throw // ...but record the segment with a loadSpec whose storage can't produce a range reader: an existing directory // that holds no V10 file, so LocalLoadSpec.openRangeReader returns null. This is the "shouldn't happen" case — a - // partial layout on disk means range reads worked when it was written — so partial-enabled bootstrap must reclaim - // the layout rather than reserve an entry that could never lazily fetch. + // partial layout on disk means range reads worked when it was written — so bootstrap must reclaim the layout and + // fail the segment rather than reserve an entry that could never lazily fetch. final File noRangeReaderStorage = new File(perTestTempDir, "no_range_reader_storage"); FileUtils.mkdirp(noRangeReaderStorage); final DataSegment unreadableSegment = @@ -866,17 +894,27 @@ void testGetCachedSegmentsDeletesPartialLayoutWhenRangeReaderUnavailable() throw .size(0) .build(); manager.storeInfoFile(unreadableSegment); - Assertions.assertTrue(PartialSegmentCacheBootstrap.isPartialSegmentLayout(partialDir, IndexIO.V10_FILE_NAME)); + Assertions.assertTrue(PartialSegmentFileMapperV10.isPartialSegmentLayout(partialDir, IndexIO.V10_FILE_NAME)); + final File infoFile = new File(new File(cacheRoot, "info_dir"), SEGMENT_ID.toString()); + Assertions.assertTrue(infoFile.exists()); + // The layout is on disk, so it still counts as cached; the reader is only needed once bootstrap tries to restore it final List cached = manager.getCachedSegments(); - Assertions.assertFalse( - cached.contains(unreadableSegment), - "bootstrap must not return a partial segment whose deep storage can't produce a range reader" + Assertions.assertEquals(List.of(unreadableSegment), cached); + + Assertions.assertThrows( + SegmentLoadingException.class, + () -> manager.bootstrap(unreadableSegment, SegmentLazyLoadFailCallback.NOOP), + "bootstrap must fail the segment so the coordinator re-issues a load for it" ); Assertions.assertFalse( partialDir.exists(), "bootstrap must delete the unusable partial layout from disk" ); + Assertions.assertFalse( + infoFile.exists(), + "the info file must go with the layout; it claims local cache state that no longer exists" + ); Assertions.assertNull( manager.getLocations().get(0).getCacheEntry(new SegmentCacheEntryIdentifier(SEGMENT_ID)), "no cache entry should be reserved for the deleted partial layout" @@ -884,7 +922,7 @@ void testGetCachedSegmentsDeletesPartialLayoutWhenRangeReaderUnavailable() throw } @Test - void testGetCachedSegmentsDeletesPartialLayoutWhenLoadSpecUnconvertible() throws IOException + void testBootstrapDeletesPartialLayoutWhenLoadSpecUnconvertible() throws IOException { // Prime a valid partial on-disk layout as a previous run left it... final File partialDir = new File(cacheRoot, SEGMENT_ID.toString()); @@ -892,8 +930,8 @@ void testGetCachedSegmentsDeletesPartialLayoutWhenLoadSpecUnconvertible() throws primePartialOnDiskState(partialDir); // ...but record the segment with a loadSpec whose type is no longer registered, so converting it to a LoadSpec - // throws. Bootstrap must treat that broken segment like a null reader: delete the unusable layout and continue, - // rather than aborting or reserving an entry that could never fetch. + // throws. Bootstrap must treat that broken segment like a null reader: delete the unusable layout and fail this + // segment, rather than aborting the whole bootstrap or reserving an entry that could never fetch. final DataSegment unconvertibleSegment = DataSegment.builder(SEGMENT_ID) .shardSpec(NoneShardSpec.instance()) @@ -901,14 +939,23 @@ void testGetCachedSegmentsDeletesPartialLayoutWhenLoadSpecUnconvertible() throws .size(0) .build(); manager.storeInfoFile(unconvertibleSegment); - Assertions.assertTrue(PartialSegmentCacheBootstrap.isPartialSegmentLayout(partialDir, IndexIO.V10_FILE_NAME)); + Assertions.assertTrue(PartialSegmentFileMapperV10.isPartialSegmentLayout(partialDir, IndexIO.V10_FILE_NAME)); + final File infoFile = new File(new File(cacheRoot, "info_dir"), SEGMENT_ID.toString()); + Assertions.assertTrue(infoFile.exists()); final List cached = manager.getCachedSegments(); - Assertions.assertFalse( - cached.contains(unconvertibleSegment), - "bootstrap must not return a partial segment whose loadSpec can't be converted to a range reader" + Assertions.assertEquals(List.of(unconvertibleSegment), cached); + + Assertions.assertThrows( + SegmentLoadingException.class, + () -> manager.bootstrap(unconvertibleSegment, SegmentLazyLoadFailCallback.NOOP), + "bootstrap must fail the segment so the coordinator re-issues a load for it" ); Assertions.assertFalse(partialDir.exists(), "bootstrap must delete the unusable partial layout from disk"); + Assertions.assertFalse( + infoFile.exists(), + "the info file must go with the layout; it claims local cache state that no longer exists" + ); Assertions.assertNull( manager.getLocations().get(0).getCacheEntry(new SegmentCacheEntryIdentifier(SEGMENT_ID)), "no cache entry should be reserved for the deleted partial layout" diff --git a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java index a0ba9739b9c9..0daded9e8ea9 100644 --- a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java +++ b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java @@ -812,8 +812,8 @@ void testBootstrapReinstallsRuleHoldsFromPersistedInfoFile() throws Exception "bootstrap must reapply the persisted PartialLoadSpec wrapper's fingerprint" ); // Selected bundle + its base dependency are held after bootstrap restore (they were on disk and got - // restored by the metadata mount's PartialSegmentCacheBootstrap.restoreBundlesFromDisk, which register with - // the metadata; applyRule picks up their rule-holds from the linkedBundles state). + // restored by the metadata mount's own bundle restore, which registers them with the metadata; applyRule picks + // up their rule-holds from the linkedBundles state). final StorageLocation loc = restarted.getLocations().get(0); Assertions.assertTrue( loc.isWeakReserved(new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, AGG_BUNDLE)), @@ -844,9 +844,12 @@ void testReserveMkdirpFailureFallsThroughToNextLocation() throws Exception // Bump writable's usage so LeastBytesUsed picks readOnly first. final SegmentId dummy = SegmentId.of("dummy", Intervals.of("2020/2021"), "v", 0); - Assertions.assertTrue( - manager.getLocations().get(0).reserveWeak(stubCacheEntry(new SegmentCacheEntryIdentifier(dummy), 4096L)) - ); + final CacheEntry filler = stubCacheEntry(new SegmentCacheEntryIdentifier(dummy), 4096L); + final StorageLocation.ReservationHold fillerHold = + manager.getLocations().get(0).addWeakReservationHold(filler.getId(), () -> filler); + Assertions.assertNotNull(fillerHold); + filler.mount(manager.getLocations().get(0)); + fillerHold.close(); Assertions.assertTrue(readOnly.setReadOnly(), "test setup must be able to make readOnly location read-only"); try { manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); diff --git a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerTest.java b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerTest.java index 59bd0d30de85..29c2e8f61626 100644 --- a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerTest.java +++ b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerTest.java @@ -1092,10 +1092,20 @@ public void testGetBootstrapSegmentVirtualStorageSegmentAlreadyCached() throws E unzippedSegmentPathInLocation ); - for (DataSegment segment : manager.getCachedSegments()) { + final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(segmentToBootstrap.getId()); + final List cached = manager.getCachedSegments(); + Assertions.assertEquals(ImmutableList.of(segmentToBootstrap), cached); + // under virtual storage getCachedSegments only recognizes the layout; the reservation is bootstrap's job, so that + // the mount happens under a hold and cannot be evicted out from under itself + Assertions.assertNull(manager.getLocations().get(0).getCacheEntry(id)); + + for (DataSegment segment : cached) { manager.bootstrap(segment, SegmentLazyLoadFailCallback.NOOP); } + final CacheEntry entry = manager.getLocations().get(0).getCacheEntry(id); + Assertions.assertNotNull(entry, "bootstrap must reserve the complete entry it mounts"); + Assertions.assertTrue(entry.isMounted()); // if bootstrapping a file that already exists it will be mounted by bootsrap Assertions.assertNotNull(manager.getSegmentFiles(segmentToBootstrap)); } diff --git a/server/src/test/java/org/apache/druid/segment/loading/StorageLocationTest.java b/server/src/test/java/org/apache/druid/segment/loading/StorageLocationTest.java index b3b0f32d9140..52e4c3ee4894 100644 --- a/server/src/test/java/org/apache/druid/segment/loading/StorageLocationTest.java +++ b/server/src/test/java/org/apache/druid/segment/loading/StorageLocationTest.java @@ -72,16 +72,16 @@ public void testWeakReserveAndReclaim() CacheEntry entry4 = new TestCacheEntry("4", 25); CacheEntry entry5 = new TestCacheEntry("5", 25); - location.reserveWeak(entry1); - location.reserveWeak(entry2); - location.reserveWeak(entry3); - location.reserveWeak(entry4); + registerWeak(location, entry1); + registerWeak(location, entry2); + registerWeak(location, entry3); + registerWeak(location, entry4); Assertions.assertEquals(100, location.currentWeakSizeBytes()); Assertions.assertTrue(location.isWeakReserved(entry1.getId())); Assertions.assertTrue(location.isWeakReserved(entry2.getId())); Assertions.assertTrue(location.isWeakReserved(entry3.getId())); Assertions.assertTrue(location.isWeakReserved(entry4.getId())); - location.reserveWeak(entry5); + registerWeak(location, entry5); Assertions.assertEquals(100, location.currentWeakSizeBytes()); Assertions.assertFalse(location.isWeakReserved(entry1.getId())); Assertions.assertTrue(location.isWeakReserved(entry2.getId())); @@ -98,10 +98,10 @@ public void testRemoveFromHead() CacheEntry entry3 = new TestCacheEntry("3", 25); CacheEntry entry4 = new TestCacheEntry("4", 25); - location.reserveWeak(entry1); - location.reserveWeak(entry2); - location.reserveWeak(entry3); - location.reserveWeak(entry4); + registerWeak(location, entry1); + registerWeak(location, entry2); + registerWeak(location, entry3); + registerWeak(location, entry4); Assertions.assertTrue(location.isWeakReserved(entry1.getId())); Assertions.assertTrue(location.isWeakReserved(entry2.getId())); Assertions.assertTrue(location.isWeakReserved(entry3.getId())); @@ -126,10 +126,10 @@ public void testRemoveFromTail() CacheEntry entry3 = new TestCacheEntry("3", 25); CacheEntry entry4 = new TestCacheEntry("4", 25); - location.reserveWeak(entry1); - location.reserveWeak(entry2); - location.reserveWeak(entry3); - location.reserveWeak(entry4); + registerWeak(location, entry1); + registerWeak(location, entry2); + registerWeak(location, entry3); + registerWeak(location, entry4); Assertions.assertTrue(location.isWeakReserved(entry1.getId())); Assertions.assertTrue(location.isWeakReserved(entry2.getId())); Assertions.assertTrue(location.isWeakReserved(entry3.getId())); @@ -159,10 +159,10 @@ public void testRemoveRandom() entries.add(entry3); entries.add(entry4); - location.reserveWeak(entry1); - location.reserveWeak(entry2); - location.reserveWeak(entry3); - location.reserveWeak(entry4); + registerWeak(location, entry1); + registerWeak(location, entry2); + registerWeak(location, entry3); + registerWeak(location, entry4); Assertions.assertTrue(location.isWeakReserved(entry1.getId())); Assertions.assertTrue(location.isWeakReserved(entry2.getId())); Assertions.assertTrue(location.isWeakReserved(entry3.getId())); @@ -200,8 +200,8 @@ public void testBulkReservation() Assertions.assertNotNull(closer.register(location.addWeakReservationHold(entry2.getId(), () -> entry2))); Assertions.assertEquals(2, location.getWeakStats().getHoldCount()); Assertions.assertEquals(50, location.getWeakStats().getHoldBytes()); - Assertions.assertTrue(location.reserveWeak(entry3)); - Assertions.assertTrue(location.reserveWeak(entry4)); + Assertions.assertTrue(registerWeak(location, entry3)); + Assertions.assertTrue(registerWeak(location, entry4)); Assertions.assertEquals(100, location.currentWeakSizeBytes()); Assertions.assertEquals(2, location.getWeakStats().getHoldCount()); @@ -224,7 +224,7 @@ public void testBulkReservation() Assertions.assertTrue(location.isWeakReserved(entry4.getId())); Assertions.assertTrue(location.isWeakReserved(entry5.getId())); - Assertions.assertTrue(location.reserveWeak(entry6)); + Assertions.assertTrue(registerWeak(location, entry6)); Assertions.assertTrue(location.isWeakReserved(entry1.getId())); Assertions.assertTrue(location.isWeakReserved(entry2.getId())); @@ -249,11 +249,11 @@ public void testBulkReservation() Assertions.assertTrue(location.isWeakReserved(entry7.getId())); // all storage is held, cannot reserve - Assertions.assertFalse(location.reserveWeak(entry8)); + Assertions.assertFalse(registerWeak(location, entry8)); // release holds CloseableUtils.closeAndWrapExceptions(closer); - Assertions.assertTrue(location.reserveWeak(entry8)); + Assertions.assertTrue(registerWeak(location, entry8)); Assertions.assertFalse(location.isWeakReserved(entry1.getId())); Assertions.assertTrue(location.isWeakReserved(entry2.getId())); @@ -361,7 +361,7 @@ public void testReserveWeakExistsConcurrency() throws ExecutionException, Interr { StorageLocation loc = new StorageLocation(tempDir, 1000L, null); final TestSegmentCacheEntry entry = makeSegmentEntry("2024/2025", 10); - loc.reserveWeak(entry); + registerWeak(loc, entry); entry.mount(loc); for (int i = 0; i < 1000; i++) { @@ -399,7 +399,7 @@ public void testReclaimRestoreDoesNotCreateZombieEntries() CacheEntry entry2 = new TestCacheEntry("2", 90); CacheEntry entry3 = new TestCacheEntry("3", 20); - location.reserveWeak(entry1); + registerWeak(location, entry1); // hold entry2 so it cannot be evicted by reclaim StorageLocation.ReservationHold hold2 = location.addWeakReservationHold( entry2.getId(), @@ -408,12 +408,12 @@ public void testReclaimRestoreDoesNotCreateZombieEntries() // must free 20 bytes but can only evict entry1 (10). Fails and restores entry1 // where the bug was a mismatch caused by creating a new entry in the list but re-using the old entry for the map. - Assertions.assertFalse(location.reserveWeak(entry3)); + Assertions.assertFalse(registerWeak(location, entry3)); // the hand pointer reaches the new entry1, removes the old entry1 from the map which is a zombie, then wraps around // to the same zombie entry1 again since its head — at which point the map no longer contains the ID and the defensive exception was // thrown. - Assertions.assertFalse(location.reserveWeak(entry3)); + Assertions.assertFalse(registerWeak(location, entry3)); hold2.close(); } @@ -425,7 +425,7 @@ public void testRemoveUnheldWeakEntry() final UnmountTrackingCacheEntry entry = new UnmountTrackingCacheEntry("a", 30); // an unheld weak entry is removed: unlinked from the queue, unmounted, and its size reclaimed - Assertions.assertTrue(location.reserveWeak(entry)); + Assertions.assertTrue(registerWeak(location, entry)); Assertions.assertTrue(location.isWeakReserved(entry.getId())); Assertions.assertEquals(30, location.currentSizeBytes()); @@ -476,7 +476,7 @@ public void testAdjustReservationWeakEntry() { final StorageLocation location = new StorageLocation(tempDir, 100L, null); final TestResizableCacheEntry entry = new TestResizableCacheEntry("a", 80); - Assertions.assertTrue(location.reserveWeak(entry)); + Assertions.assertTrue(registerWeak(location, entry)); Assertions.assertEquals(80, location.currentWeakSizeBytes()); location.adjustReservation(entry.getId(), 30); @@ -541,7 +541,7 @@ public void testAdjustReservationWeakEntryShrinksHeldBytes() throws IOException { final StorageLocation location = new StorageLocation(tempDir, 100L, null); final TestResizableCacheEntry entry = new TestResizableCacheEntry("a", 80); - Assertions.assertTrue(location.reserveWeak(entry)); + Assertions.assertTrue(registerWeak(location, entry)); // Acquire a hold BEFORE shrinking. trackWeakHold records 80 bytes against currHoldBytes. final StorageLocation.ReservationHold hold = location.addWeakReservationHold(entry.getId(), () -> entry); @@ -566,7 +566,7 @@ public void testAdjustReservationWeakEntryShrinksHeldBytesWithMultipleHolds() th { final StorageLocation location = new StorageLocation(tempDir, 100L, null); final TestResizableCacheEntry entry = new TestResizableCacheEntry("a", 50); - Assertions.assertTrue(location.reserveWeak(entry)); + Assertions.assertTrue(registerWeak(location, entry)); // Two concurrent holds: trackWeakHold fires twice, so currHoldBytes = 2 * 50 = 100. final StorageLocation.ReservationHold hold1 = location.addWeakReservationHold(entry.getId(), () -> entry); @@ -629,9 +629,9 @@ public void testRemoveUnheldWeakEntryUnmountCascadeDoesNotThrowConcurrentModific location.addWeakReservationHold(parent.getId(), () -> parent); Assertions.assertNotNull(parentHold); - // Child registered WITHOUT a hold (the bootstrap reserveWeak path that removeUnheldWeakEntry cleans up). + // Child left registered but unheld, the shape removeUnheldWeakEntry cleans up. final CascadingUnmountCacheEntry child = new CascadingUnmountCacheEntry("child", 100L, parentHold); - Assertions.assertTrue(location.reserveWeak(child)); + Assertions.assertTrue(registerWeak(location, child)); child.mount(location); Assertions.assertDoesNotThrow(() -> location.removeUnheldWeakEntry(child.getId())); @@ -658,11 +658,11 @@ public void testReclaimUnmountCascadeDoesNotThrowConcurrentModification() // Child registered unheld and mounted, sole holder of the parent's cache hold; it is the reclaim target. final CascadingUnmountCacheEntry child = new CascadingUnmountCacheEntry("child", 40L, parentHold); - Assertions.assertTrue(location.reserveWeak(child)); + Assertions.assertTrue(registerWeak(location, child)); child.mount(location); final CascadingUnmountCacheEntry filler = new CascadingUnmountCacheEntry("filler", 40L, null); - Assertions.assertDoesNotThrow(() -> location.reserveWeak(filler)); + Assertions.assertDoesNotThrow(() -> registerWeak(location, filler)); Assertions.assertTrue(child.wasUnmounted()); Assertions.assertTrue(parent.wasUnmounted()); @@ -757,6 +757,28 @@ public void unmount() } } + /** + * Register a weak entry the way production does — reserve under a hold, mount, release the hold — leaving it + * registered but unheld, which is the state reclaim and {@link StorageLocation#removeUnheldWeakEntry} act on. + * Returns false when the location could not accept the reservation at all. + */ + private static boolean registerWeak(StorageLocation location, CacheEntry entry) + { + final StorageLocation.ReservationHold hold = + location.addWeakReservationHold(entry.getId(), () -> entry); + if (hold == null) { + return false; + } + try { + entry.mount(location); + } + catch (Exception e) { + throw new RuntimeException(e); + } + hold.close(); + return true; + } + private static final class TestCacheEntry implements CacheEntry { private final StringCacheIdentifier id;