diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/PartialProjectionLoadRuleQueryTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/PartialProjectionLoadRuleQueryTest.java new file mode 100644 index 000000000000..a3efef184dcc --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/PartialProjectionLoadRuleQueryTest.java @@ -0,0 +1,372 @@ +/* + * 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.testing.embedded.query; + +import org.apache.druid.catalog.guice.CatalogClientModule; +import org.apache.druid.catalog.guice.CatalogCoordinatorModule; +import org.apache.druid.catalog.model.Columns; +import org.apache.druid.catalog.model.TableMetadata; +import org.apache.druid.catalog.model.table.TableBuilder; +import org.apache.druid.common.utils.IdUtils; +import org.apache.druid.data.input.impl.AggregateProjectionSpec; +import org.apache.druid.data.input.impl.ClusteredValueGroupsBaseTableProjectionSpec; +import org.apache.druid.data.input.impl.LongDimensionSchema; +import org.apache.druid.data.input.impl.StringDimensionSchema; +import org.apache.druid.data.input.impl.TimestampSpec; +import org.apache.druid.indexer.granularity.SegmentGranularitySpec; +import org.apache.druid.indexing.common.task.TaskBuilder; +import org.apache.druid.indexing.common.task.batch.parallel.ParallelIndexSupervisorTask; +import org.apache.druid.java.util.common.HumanReadableBytes; +import org.apache.druid.java.util.common.Intervals; +import org.apache.druid.java.util.common.StringUtils; +import org.apache.druid.java.util.common.granularity.Granularities; +import org.apache.druid.query.QueryContexts; +import org.apache.druid.query.aggregation.LongSumAggregatorFactory; +import org.apache.druid.server.coordinator.rules.CannotMatchBehavior; +import org.apache.druid.server.coordinator.rules.ForeverPartialLoadRule; +import org.apache.druid.server.coordinator.rules.WildcardProjectionPartialLoadMatcher; +import org.apache.druid.server.metrics.LatchableEmitter; +import org.apache.druid.server.metrics.StorageMonitor; +import org.apache.druid.testing.embedded.EmbeddedBroker; +import org.apache.druid.testing.embedded.EmbeddedCoordinator; +import org.apache.druid.testing.embedded.EmbeddedDruidCluster; +import org.apache.druid.testing.embedded.EmbeddedHistorical; +import org.apache.druid.testing.embedded.EmbeddedIndexer; +import org.apache.druid.testing.embedded.EmbeddedOverlord; +import org.apache.druid.testing.embedded.EmbeddedRouter; +import org.apache.druid.testing.embedded.catalog.TestCatalogClient; +import org.apache.druid.testing.embedded.junit5.EmbeddedClusterTestBase; +import org.apache.druid.testing.embedded.minio.MinIOStorageResource; +import org.apache.druid.testing.embedded.msq.EmbeddedMSQApis; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * End-to-end coverage for the partial-load-rule wiring: a segment with a clustered base + aggregate projection + * loaded under a {@link ForeverPartialLoadRule} whose matcher selects only the projection. The coordinator emits + * a {@code PartialProjectionLoadSpec} wrapper on the load request; the historical resolves the wrapper into the + * projection's bundle name, eagerly downloads that one bundle, and pins it via a rule-hold. Queries that the + * projection can serve are proven to run entirely off the pre-loaded bundle (zero on-demand loads); queries that + * miss the projection are proven to trigger on-demand loads of the base bundles. + *

+ * Exercises the full happy-path flow end-to-end: coordinator matcher → wire form → historical + * {@code loadPartial} → {@code applyRule} + eager download → announced fingerprint → query with + * projection hit vs miss → on-demand metric emission. + */ +class PartialProjectionLoadRuleQueryTest extends EmbeddedClusterTestBase +{ + private static final String PROJECTION_NAME = "country_delta"; + private static final long CACHE_SIZE = HumanReadableBytes.parse("1MiB"); + private static final long MAX_SIZE = HumanReadableBytes.parse("100MiB"); + private static final long ESTIMATE_SIZE = HumanReadableBytes.parse("2KiB"); + // Quiescence wait for the storage monitor: a few times its PT1s emission period so a single missed tick + // doesn't falsely read as "idle" while still bounding how long we wait once activity has actually stopped. + private static final long MONITOR_QUIESCE_TIMEOUT_MILLIS = 3_000L; + + private final EmbeddedBroker broker = new EmbeddedBroker(); + private final EmbeddedIndexer indexer = new EmbeddedIndexer(); + private final EmbeddedOverlord overlord = new EmbeddedOverlord(); + private final EmbeddedHistorical historical = new EmbeddedHistorical(); + private final EmbeddedCoordinator coordinator = new EmbeddedCoordinator(); + private final EmbeddedRouter router = new EmbeddedRouter(); + private final MinIOStorageResource storageResource = new MinIOStorageResource(); + + private EmbeddedMSQApis msqApis; + + @Override + public EmbeddedDruidCluster createCluster() + { + historical.setServerMemory(500_000_000) + .addProperty("druid.segmentCache.virtualStorage", "true") + .addProperty("druid.segmentCache.virtualStoragePartialDownloadsEnabled", "true") + .addProperty( + "druid.segmentCache.virtualStorageMetadataReservationEstimate", + String.valueOf(ESTIMATE_SIZE) + ) + .addProperty( + "druid.segmentCache.virtualStorageLoadThreads", + String.valueOf(Runtime.getRuntime().availableProcessors()) + ) + .addBeforeStartHook( + (cluster, self) -> self.addProperty( + "druid.segmentCache.locations", + StringUtils.format( + "[{\"path\":\"%s\",\"maxSize\":\"%s\"}]", + cluster.getTestFolder().newFolder().getAbsolutePath(), + CACHE_SIZE + ) + ) + ) + .addProperty("druid.server.maxSize", String.valueOf(MAX_SIZE)); + + broker.setServerMemory(200_000_000) + .addProperty("druid.sql.planner.enableSysQueriesTable", "true") + // Poll the catalog often so the broker's DruidSchema picks up the table definition quickly. + // Under virtualStorage the historical exposes only __time via VirtualPlaceholderSegment, so + // BrokerSegmentMetadataCache alone can't infer the real column schema — the catalog fills that gap. + .addProperty("druid.catalog.client.pollingPeriod", "100") + // Retain Dart reports long enough to fetch them after the query completes. + .addProperty("druid.msq.dart.controller.maxRetainedReportCount", "10"); + + coordinator.addProperty("druid.manager.segments.useIncrementalCache", "always"); + + overlord.addProperty("druid.manager.segments.useIncrementalCache", "always") + .addProperty("druid.manager.segments.pollDuration", "PT0.1s") + .addProperty("druid.catalog.client.maxSyncRetries", "0"); + + indexer.setServerMemory(300_000_000) + .addProperty("druid.worker.capacity", "2") + .addProperty("druid.processing.numThreads", "2") + .addProperty("druid.segment.handoff.pollDuration", "PT0.1s"); + + return EmbeddedDruidCluster + .withEmbeddedDerbyAndZookeeper() + .useLatchableEmitter() + .useDefaultTimeoutForLatchableEmitter(60) + .addResource(storageResource) + // Catalog: table schema must be published explicitly since historical exposes only __time under + // virtualStorage=true (see broker.druid.catalog.client.pollingPeriod above). + .addExtensions(CatalogClientModule.class, CatalogCoordinatorModule.class) + // Run queries via Dart so MSQ leaf processors take the PARTIAL acquire path (async cursor factory); + // native SQL takes the FULL acquire path which pins every bundle for the reference lifetime, defeating + // the memory savings of partial loads. + .addCommonProperty("druid.msq.dart.enabled", "true") + // Clustered base-table + projection segments require the V10 segment format. + .addCommonProperty("druid.indexer.task.buildV10", "true") + // Range reads over MinIO — required for partial loading to be functional. + .addCommonProperty("druid.storage.zip", "false") + .addCommonProperty("druid.monitoring.emissionPeriod", "PT1s") + .addServer(coordinator) + .addServer(overlord) + .addServer(indexer) + .addServer(historical) + .addServer(broker) + .addServer(router); + } + + @BeforeAll + void loadDataAndConfigureRule() throws IOException + { + msqApis = new EmbeddedMSQApis(cluster, overlord); + dataSource = "partial-projection-" + IdUtils.getRandomId(); + + // Under virtualStorage=true the historical announces segments via VirtualPlaceholderSegment, which only exposes + // __time. That means BrokerSegmentMetadataCache alone can't populate a full DruidSchema for SQL validation, so + // the queries below would fail at plan-time with "column not found". Publishing an explicit catalog table + // definition gives DruidSchema the real column list. Future work: derive a RowSignature from the partial-load + // header so this catalog registration becomes unnecessary. + final TestCatalogClient catalog = new TestCatalogClient(cluster); + final TableMetadata table = TableBuilder.datasource(dataSource, "HOUR") + .column(Columns.TIME_COLUMN, Columns.LONG) + .column("channel", Columns.SQL_VARCHAR) + .column("countryName", Columns.SQL_VARCHAR) + .column("delta", Columns.SQL_BIGINT) + .build(); + catalog.createTable(table, true); + + // Configure the partial-load rule BEFORE ingestion so the initial load applies the rule directly, rather than + // needing a rule change afterward. The matcher selects only the country_delta projection; base-table bundles + // (cluster groups) are NOT rule-loaded. + cluster.callApi().onLeaderCoordinator( + c -> c.updateRulesForDatasource( + dataSource, + List.of( + new ForeverPartialLoadRule( + Map.of("_default_tier", 1), + null, + new WildcardProjectionPartialLoadMatcher(List.of(PROJECTION_NAME), null), + CannotMatchBehavior.FALL_THROUGH + ) + ) + ) + ); + + ingestClusteredSegmentWithProjection(); + } + + @Override + protected void refreshDatasourceName() + { + // Fixed datasource across tests — rule + ingest are one-time setup. + } + + @Test + void testProjectionHitLoadsNothingOnDemand() + { + // The country_delta projection can serve group-by-countryName / sum-delta. The projection bundle was + // rule-loaded on ingest, so an MSQ leaf PARTIAL acquire should read it without triggering any downloads. + // MSQ input channel counters don't (yet) observe partial-load on-demand downloads (those happen inside the + // async cursor factory, downstream of the input channel), so we observe the historical's StorageMonitor + // directly — VSF_LOAD_* are incremented as each file is actually pulled from deep storage. + final LatchableEmitter emitter = quiesceAndFlushStorageMonitor(); + + runDartQuery( + "SELECT \"countryName\", SUM(\"delta\") FROM \"" + dataSource + "\" GROUP BY 1 ORDER BY 1", + "CA,3\nFR,7\nUS,17" + ); + + // Wait for post-query storage activity (if any) to settle before we sum the counters. + emitter.awaitMetricQuiescent(StorageMonitor.VSF_LOAD_BEGIN_COUNT, MONITOR_QUIESCE_TIMEOUT_MILLIS); + + Assertions.assertEquals( + 0L, + emitter.getMetricEventLongSum(StorageMonitor.VSF_LOAD_COUNT), + "expected no on-demand file loads for a projection-served Dart query, but the projection bundle was fetched at query time" + ); + Assertions.assertEquals( + 0L, + emitter.getMetricEventLongSum(StorageMonitor.VSF_LOAD_BYTES), + "expected zero on-demand load bytes for a projection-served Dart query" + ); + } + + @Test + void testProjectionMissLoadsBaseBundleOnDemand() + { + // Group by the clustering column — the projection can't serve this, so MSQ must fall back to the clustered + // base table, whose cluster-group bundles were NOT rule-loaded. Under PARTIAL acquire the async cursor + // factory triggers per-file downloads on demand, which show up on StorageMonitor's VSF_LOAD_* counters. + final LatchableEmitter emitter = quiesceAndFlushStorageMonitor(); + + runDartQuery( + "SELECT \"channel\", SUM(\"delta\") FROM \"" + dataSource + "\" GROUP BY 1 ORDER BY 1", + "#en,18\n#fr,9" + ); + + emitter.awaitMetricQuiescent(StorageMonitor.VSF_LOAD_BEGIN_COUNT, MONITOR_QUIESCE_TIMEOUT_MILLIS); + + MatcherAssert.assertThat( + "expected on-demand file loads for a projection-miss Dart query that needs the base-table bundles", + emitter.getMetricEventLongSum(StorageMonitor.VSF_LOAD_COUNT), + Matchers.greaterThan(0L) + ); + MatcherAssert.assertThat( + "expected non-zero on-demand load bytes for a projection-miss Dart query", + emitter.getMetricEventLongSum(StorageMonitor.VSF_LOAD_BYTES), + Matchers.greaterThan(0L) + ); + } + + /** + * Waits for any lingering storage-monitor activity from ingest / rule-application to settle, then flushes the + * emitter's event queue so subsequent {@code getMetricEventLongSum(...)} calls only reflect activity that + * happens after this point. + */ + private LatchableEmitter quiesceAndFlushStorageMonitor() + { + final LatchableEmitter emitter = historical.latchableEmitter(); + emitter.awaitMetricQuiescent(StorageMonitor.VSF_LOAD_BEGIN_COUNT, MONITOR_QUIESCE_TIMEOUT_MILLIS); + emitter.flush(); + return emitter; + } + + /** + * Submits {@code sql} via the Dart engine (MSQ leaves take the PARTIAL acquire path, so partial-load bundles + * only download the files the query actually reads) and asserts the CSV result matches {@code expectedCsv}. + */ + private void runDartQuery(String sql, String expectedCsv) + { + final String sqlQueryId = UUID.randomUUID().toString(); + final String result; + try { + result = msqApis.submitDartSqlAsync( + sql, + Map.of(QueryContexts.CTX_SQL_QUERY_ID, sqlQueryId), + broker + ).get(); + } + catch (Exception e) { + throw new RuntimeException("failed to run Dart query [" + sqlQueryId + "]: " + sql, e); + } + Assertions.assertEquals(expectedCsv, result.trim(), "unexpected result for Dart query [" + sqlQueryId + "]"); + } + + /** + * Ingests a single clustered base-table segment (clustered by {@code channel}) with a {@code country_delta} + * aggregate projection (group by {@code countryName}, sum {@code delta}). Copied from + * {@link ClusteredSegmentProjectionQueryTest#ingestClusteredSegmentWithProjection}; kept local so this test + * doesn't cross-depend on that class. + */ + private void ingestClusteredSegmentWithProjection() throws IOException + { + final File tmpDir = cluster.getTestFolder().newFolder(); + final File inputFile = new File(tmpDir, "clustered-input.json"); + final String inputData = + "{\"time\":\"2024-01-01T00:10:00Z\",\"channel\":\"#en\",\"countryName\":\"US\",\"delta\":10}\n" + + "{\"time\":\"2024-01-01T00:20:00Z\",\"channel\":\"#en\",\"countryName\":\"US\",\"delta\":5}\n" + + "{\"time\":\"2024-01-01T00:30:00Z\",\"channel\":\"#en\",\"countryName\":\"CA\",\"delta\":3}\n" + + "{\"time\":\"2024-01-01T00:40:00Z\",\"channel\":\"#fr\",\"countryName\":\"FR\",\"delta\":7}\n" + + "{\"time\":\"2024-01-01T00:50:00Z\",\"channel\":\"#fr\",\"countryName\":\"US\",\"delta\":2}\n"; + Files.write(inputFile.toPath(), inputData.getBytes(StandardCharsets.UTF_8)); + + final ClusteredValueGroupsBaseTableProjectionSpec clusterSpec = + ClusteredValueGroupsBaseTableProjectionSpec.builder() + .columns( + new StringDimensionSchema("channel"), + new StringDimensionSchema("countryName"), + new LongDimensionSchema("delta"), + new LongDimensionSchema("__time") + ) + .clusteringColumns("channel") + .build(); + + final AggregateProjectionSpec projection = + AggregateProjectionSpec.builder(PROJECTION_NAME) + .groupingColumns(new StringDimensionSchema("countryName")) + .aggregators(new LongSumAggregatorFactory("sumDelta", "delta")) + .build(); + + final SegmentGranularitySpec segmentGranularitySpec = new SegmentGranularitySpec( + Granularities.HOUR, + List.of(Intervals.of("2024-01-01/2024-01-02")) + ); + + final String taskId = IdUtils.getRandomId(); + final ParallelIndexSupervisorTask task = TaskBuilder + .ofTypeIndexParallel() + .jsonInputFormat() + .localInputSourceWithFiles(inputFile) + .dataSchema( + builder -> builder + .withDataSource(dataSource) + .withTimestamp(new TimestampSpec("time", "iso", null)) + .withSegmentGranularity(segmentGranularitySpec) + .withBaseTable(clusterSpec) + .withProjections(List.of(projection)) + ) + .tuningConfig(t -> t.withMaxNumConcurrentSubTasks(1)) + .withId(taskId); + + cluster.callApi().onLeaderOverlord(o -> o.runTask(taskId, task)); + cluster.callApi().waitForTaskToSucceed(taskId, overlord); + cluster.callApi().waitForAllSegmentsToBeAvailable(dataSource, coordinator, broker); + } +} 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 ea4895a7f2e4..78582bd7b6b9 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 @@ -448,7 +448,8 @@ public void ensureAllDownloaded() throws IOException } /** - * Download every container belonging to {@code bundleName} in this mapper, each in a single range read (see + * Download every container belonging to {@code bundleName}, recursing into external mappers to cover bundles that + * span the main file plus one or more externals. Each container is downloaded in a single range read (see * {@link #downloadContainer}). No-op for an unknown bundle. */ public void ensureBundleDownloaded(String bundleName) throws IOException @@ -457,6 +458,32 @@ public void ensureBundleDownloaded(String bundleName) throws IOException for (int containerIndex : getContainerIndicesForBundle(bundleName)) { downloadContainer(containerIndex); } + for (PartialSegmentFileMapperV10 external : externalMappers.values()) { + external.ensureBundleDownloaded(bundleName); + } + } + + /** + * Whether every container belonging to {@code bundleName} has all its files present in {@link #downloadedFiles}, + * across the main mapper AND every attached external mapper. + *

+ * Returns {@code true} for a bundle name unknown to any mapper (no containers to check). + */ + public boolean isBundleFullyDownloaded(String bundleName) + { + checkClosed(); + for (int containerIndex : getContainerIndicesForBundle(bundleName)) { + final List fileNames = containerFileNames.get(containerIndex); + if (!downloadedFiles.containsAll(fileNames)) { + return false; + } + } + for (PartialSegmentFileMapperV10 external : externalMappers.values()) { + if (!external.isBundleFullyDownloaded(bundleName)) { + return false; + } + } + return true; } /** diff --git a/processing/src/main/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpec.java b/processing/src/main/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpec.java index 636de7bc776b..cbc27ad3cf99 100644 --- a/processing/src/main/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpec.java +++ b/processing/src/main/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpec.java @@ -25,7 +25,16 @@ import com.fasterxml.jackson.annotation.JsonTypeName; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Preconditions; +import org.apache.druid.error.DruidException; +import org.apache.druid.segment.file.SegmentFileMetadata; +import org.apache.druid.segment.projections.ClusteredValueGroupsBaseTableSchema; +import org.apache.druid.segment.projections.ProjectionMetadata; +import org.apache.druid.segment.projections.Projections; +import org.apache.druid.segment.projections.TableClusterGroupSpec; +import org.apache.druid.timeline.ClusterGroupTuples; +import org.apache.druid.timeline.DataSegment; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -83,6 +92,64 @@ public List getClusterGroupIndices() return clusterGroupIndices; } + /** + * Resolve each {@code clusterGroupIndex} against the segment's {@link ClusteredValueGroupsBaseTableSchema} to a + * {@code __base$} bundle name via {@link Projections#getClusterGroupBundleName}. The base + * projection carries the authoritative group list; each index picks the group at that position and its + * {@code clusteringValueIds} disambiguate the bundle in the V10 layout. + *

+ * Defensive tripwires fire on any structural inconsistency: empty projections list, non-clustered base projection, + * segment's cluster-group tuple count vs. metadata's group count mismatch, or index out of range. These conditions + * are unreachable under a healthy writer/reader contract; a throw here indicates a coding bug. + *

+ * Returns an empty list when {@code clusterGroupIndices} is empty (the "sibling-empty" case where a matcher + * applied to a clustered segment but no configured pattern matched any group). + */ + @Override + public List getSelectedBundleNames(DataSegment segment, SegmentFileMetadata metadata) + { + if (clusterGroupIndices.isEmpty()) { + return List.of(); + } + final List projections = metadata.getProjections(); + if (projections == null || projections.isEmpty()) { + throw DruidException.defensive( + "Cannot resolve cluster-group bundle names for segment[%s]: metadata has no projections", + segment.getId() + ); + } + if (!(projections.getFirst().getSchema() instanceof ClusteredValueGroupsBaseTableSchema clusteredSummary)) { + throw DruidException.defensive( + "Cannot resolve cluster-group bundle names for segment[%s]: base projection is not clustered", + segment.getId() + ); + } + final List metadataGroups = clusteredSummary.getClusterGroups(); + final ClusterGroupTuples segmentTuples = segment.getClusterGroups(); + final int segmentTupleCount = segmentTuples == null ? 0 : segmentTuples.tuples().size(); + if (segmentTupleCount != metadataGroups.size()) { + throw DruidException.defensive( + "Cluster-group count mismatch for segment[%s]: DataSegment has [%s] tuples, metadata has [%s] groups", + segment.getId(), + segmentTupleCount, + metadataGroups.size() + ); + } + final List bundleNames = new ArrayList<>(clusterGroupIndices.size()); + for (int idx : clusterGroupIndices) { + if (idx < 0 || idx >= metadataGroups.size()) { + throw DruidException.defensive( + "Cluster-group index [%s] is out of range [0, %s) for segment[%s]", + idx, + metadataGroups.size(), + segment.getId() + ); + } + bundleNames.add(Projections.getClusterGroupBundleName(metadataGroups.get(idx).getClusteringValueIds())); + } + return bundleNames; + } + @Override public boolean equals(Object o) { diff --git a/processing/src/main/java/org/apache/druid/segment/loading/PartialLoadSpec.java b/processing/src/main/java/org/apache/druid/segment/loading/PartialLoadSpec.java index f1924dd6c4db..c9b0e82acdd2 100644 --- a/processing/src/main/java/org/apache/druid/segment/loading/PartialLoadSpec.java +++ b/processing/src/main/java/org/apache/druid/segment/loading/PartialLoadSpec.java @@ -24,10 +24,13 @@ import com.google.common.base.Preconditions; import com.google.common.base.Supplier; import com.google.common.base.Suppliers; +import org.apache.druid.segment.file.SegmentFileMetadata; +import org.apache.druid.timeline.DataSegment; import javax.annotation.Nullable; import java.io.File; import java.io.IOException; +import java.util.List; import java.util.Map; /** @@ -136,4 +139,17 @@ public SegmentRangeReader openRangeReader() throws IOException { return materializedDelegate().openRangeReader(); } + + /** + * Returns the cache-layer bundle names that this partial-load spec selects for {@code segment}, resolving the + * scheme-specific identifiers carried in the load spec against the segment's parsed {@code metadata}. The + * historical's cache layer uses the returned names to acquire rule holds on the matching bundles as part of + * {@link SegmentCacheManager#load} for a partial-load rule; bundles not in the returned list are left to the + * ordinary on-demand acquire path. + *

+ * Returns an empty list when the load spec's selection is empty (the "sibling-empty" case — coordinator matcher + * applied but produced no positive selection; the historical still installs a rule hold on the metadata so + * bootstrap re-discovers the segment). + */ + public abstract List getSelectedBundleNames(DataSegment segment, SegmentFileMetadata metadata); } diff --git a/processing/src/main/java/org/apache/druid/segment/loading/PartialProjectionLoadSpec.java b/processing/src/main/java/org/apache/druid/segment/loading/PartialProjectionLoadSpec.java index 737a10655898..c063f33db8e1 100644 --- a/processing/src/main/java/org/apache/druid/segment/loading/PartialProjectionLoadSpec.java +++ b/processing/src/main/java/org/apache/druid/segment/loading/PartialProjectionLoadSpec.java @@ -25,11 +25,17 @@ import com.fasterxml.jackson.annotation.JsonTypeName; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Preconditions; +import org.apache.druid.error.DruidException; +import org.apache.druid.segment.file.SegmentFileMetadata; +import org.apache.druid.segment.projections.ProjectionMetadata; +import org.apache.druid.timeline.DataSegment; import org.apache.druid.utils.CollectionUtils; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; /** * A {@link PartialLoadSpec} that requests partial loading of a segment's projections. The base class carries the @@ -84,6 +90,42 @@ public List getProjections() return projections; } + /** + * Projection names double as bundle names in the V10 partial-segment layout (each projection's containers are + * tagged with its name as the bundle prefix), so the load spec selection maps to bundle names verbatim after + * validating that each requested name refers to a projection actually present on the segment. + *

+ * These are pure defensive tripwires: the coordinator-side matcher derives the wire form from the same segment + * metadata this method reads, so a mismatch here indicates a coding bug (matcher/reader drift, writer/reader + * contract violation, or serialization corruption). + */ + @Override + public List getSelectedBundleNames(DataSegment segment, SegmentFileMetadata metadata) + { + final List segmentProjections = metadata.getProjections(); + if (segmentProjections == null || segmentProjections.isEmpty()) { + throw DruidException.defensive( + "Cannot resolve projection bundles for segment[%s]: metadata has no projections", + segment.getId() + ); + } + final Set known = segmentProjections.stream() + .map(pm -> pm.getSchema().getName()) + .collect(Collectors.toSet()); + for (String projection : projections) { + if (!known.contains(projection)) { + throw DruidException.defensive( + "Segment[%s] does not contain projection[%s]; matcher/reader drift or writer/reader contract violation." + + " Known projections: %s", + segment.getId(), + projection, + known + ); + } + } + return projections; + } + @Override public boolean equals(Object o) { diff --git a/processing/src/test/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpecTest.java b/processing/src/test/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpecTest.java index 28906eba94d9..0c75eb31aed8 100644 --- a/processing/src/test/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpecTest.java +++ b/processing/src/test/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpecTest.java @@ -27,7 +27,26 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.google.common.collect.ImmutableMap; +import org.apache.druid.error.DruidException; import org.apache.druid.jackson.DefaultObjectMapper; +import org.apache.druid.java.util.common.Intervals; +import org.apache.druid.query.OrderBy; +import org.apache.druid.segment.VirtualColumns; +import org.apache.druid.segment.column.ColumnHolder; +import org.apache.druid.segment.column.ColumnType; +import org.apache.druid.segment.column.RowSignature; +import org.apache.druid.segment.file.SegmentFileMetadata; +import org.apache.druid.segment.projections.ClusteredValueGroupsBaseTableSchema; +import org.apache.druid.segment.projections.ClusteringDictionaries; +import org.apache.druid.segment.projections.ProjectionMetadata; +import org.apache.druid.segment.projections.ProjectionSchema; +import org.apache.druid.segment.projections.Projections; +import org.apache.druid.segment.projections.TableClusterGroupSpec; +import org.apache.druid.segment.projections.TableProjectionSchema; +import org.apache.druid.timeline.ClusterGroupTuples; +import org.apache.druid.timeline.DataSegment; +import org.apache.druid.timeline.SegmentId; +import org.apache.druid.timeline.partition.NumberedShardSpec; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -163,6 +182,194 @@ void testRejectsNullFingerprint() ); } + @Test + void testGetSelectedBundleNamesResolvesIndicesToBundleNames() + { + // Three cluster groups in the metadata; pick groups 0 and 2. Each index maps to the group's + // clusteringValueIds → bundle name. + final SegmentFileMetadata metadata = clusteredMetadata( + List.of( + new TableClusterGroupSpec(List.of(0), 10), + new TableClusterGroupSpec(List.of(1), 20), + new TableClusterGroupSpec(List.of(2), 30) + ) + ); + final DataSegment segment = clusteredSegment( + List.of(List.of("acme"), List.of("globex"), List.of("initech")) + ); + final PartialClusterGroupLoadSpec spec = new PartialClusterGroupLoadSpec( + DELEGATE, + List.of(0, 2), + FINGERPRINT, + jsonMapper + ); + Assertions.assertEquals( + List.of( + Projections.getClusterGroupBundleName(List.of(0)), + Projections.getClusterGroupBundleName(List.of(2)) + ), + spec.getSelectedBundleNames(segment, metadata) + ); + } + + @Test + void testGetSelectedBundleNamesEmptyIndicesReturnsEmpty() + { + // Sibling-empty: matcher applied but no positive selection. Caller pins only the metadata. + final SegmentFileMetadata metadata = clusteredMetadata( + List.of(new TableClusterGroupSpec(List.of(0), 1)) + ); + final DataSegment segment = clusteredSegment(List.of(List.of("acme"))); + final PartialClusterGroupLoadSpec spec = new PartialClusterGroupLoadSpec( + DELEGATE, + List.of(), + FINGERPRINT, + jsonMapper + ); + Assertions.assertEquals(List.of(), spec.getSelectedBundleNames(segment, metadata)); + } + + @Test + void testGetSelectedBundleNamesOutOfRangeIndexThrows() + { + final SegmentFileMetadata metadata = clusteredMetadata( + List.of(new TableClusterGroupSpec(List.of(0), 1)) + ); + final DataSegment segment = clusteredSegment(List.of(List.of("acme"))); + final PartialClusterGroupLoadSpec spec = new PartialClusterGroupLoadSpec( + DELEGATE, + List.of(5), + FINGERPRINT, + jsonMapper + ); + final DruidException thrown = Assertions.assertThrows( + DruidException.class, + () -> spec.getSelectedBundleNames(segment, metadata) + ); + Assertions.assertTrue( + thrown.getMessage().contains("Cluster-group index [5] is out of range"), + "unexpected message: " + thrown.getMessage() + ); + } + + @Test + void testGetSelectedBundleNamesSizeMismatchThrows() + { + // Metadata has 2 groups, segment has 1 tuple — writer/reader contract violation; tripwire fires. + final SegmentFileMetadata metadata = clusteredMetadata( + List.of( + new TableClusterGroupSpec(List.of(0), 1), + new TableClusterGroupSpec(List.of(1), 1) + ) + ); + final DataSegment segment = clusteredSegment(List.of(List.of("acme"))); + final PartialClusterGroupLoadSpec spec = new PartialClusterGroupLoadSpec( + DELEGATE, + List.of(0), + FINGERPRINT, + jsonMapper + ); + final DruidException thrown = Assertions.assertThrows( + DruidException.class, + () -> spec.getSelectedBundleNames(segment, metadata) + ); + Assertions.assertTrue( + thrown.getMessage().contains("Cluster-group count mismatch"), + "unexpected message: " + thrown.getMessage() + ); + } + + @Test + void testGetSelectedBundleNamesNonClusteredBaseThrows() + { + // Base projection is a regular (non-clustered) table — partial cluster-group load is nonsensical. + final ProjectionSchema baseSchema = new TableProjectionSchema( + VirtualColumns.EMPTY, + List.of(ColumnHolder.TIME_COLUMN_NAME, "tenant"), + null, + List.of(OrderBy.ascending(ColumnHolder.TIME_COLUMN_NAME)) + ); + final SegmentFileMetadata metadata = new SegmentFileMetadata( + List.of(), + Map.of(), + null, + null, + List.of(new ProjectionMetadata(1, baseSchema)), + null + ); + final DataSegment segment = clusteredSegment(List.of(List.of("acme"))); + final PartialClusterGroupLoadSpec spec = new PartialClusterGroupLoadSpec( + DELEGATE, + List.of(0), + FINGERPRINT, + jsonMapper + ); + final DruidException thrown = Assertions.assertThrows( + DruidException.class, + () -> spec.getSelectedBundleNames(segment, metadata) + ); + Assertions.assertTrue( + thrown.getMessage().contains("base projection is not clustered"), + "unexpected message: " + thrown.getMessage() + ); + } + + @Test + void testGetSelectedBundleNamesNoProjectionsThrows() + { + final SegmentFileMetadata metadata = new SegmentFileMetadata(List.of(), Map.of(), null, null, null, null); + final DataSegment segment = clusteredSegment(List.of(List.of("acme"))); + final PartialClusterGroupLoadSpec spec = new PartialClusterGroupLoadSpec( + DELEGATE, + List.of(0), + FINGERPRINT, + jsonMapper + ); + final DruidException thrown = Assertions.assertThrows( + DruidException.class, + () -> spec.getSelectedBundleNames(segment, metadata) + ); + Assertions.assertTrue( + thrown.getMessage().contains("metadata has no projections"), + "unexpected message: " + thrown.getMessage() + ); + } + + private static final RowSignature CLUSTERING_TENANT = RowSignature.builder() + .add("tenant", ColumnType.STRING) + .build(); + + private static SegmentFileMetadata clusteredMetadata(List groups) + { + final ClusteredValueGroupsBaseTableSchema baseSchema = new ClusteredValueGroupsBaseTableSchema( + VirtualColumns.EMPTY, + List.of(ColumnHolder.TIME_COLUMN_NAME, "tenant", "metric"), + List.of(OrderBy.ascending("tenant"), OrderBy.ascending(ColumnHolder.TIME_COLUMN_NAME)), + CLUSTERING_TENANT, + null, + new ClusteringDictionaries(List.of("acme", "globex", "initech"), null, null, null), + groups + ); + return new SegmentFileMetadata( + List.of(), + Map.of(), + null, + null, + List.of(new ProjectionMetadata(groups.stream().mapToInt(TableClusterGroupSpec::getNumRows).sum(), baseSchema)), + null + ); + } + + private static DataSegment clusteredSegment(List> tuples) + { + return DataSegment.builder( + SegmentId.of("ds", Intervals.ETERNITY, "v1", new NumberedShardSpec(0, 1)) + ) + .size(0) + .clusterGroups(new ClusterGroupTuples(CLUSTERING_TENANT, tuples)) + .build(); + } + /** * Stub LoadSpec used to verify delegation. Uses the same JSON "type"=="stub" key as the test {@link #DELEGATE}. */ diff --git a/processing/src/test/java/org/apache/druid/segment/loading/PartialProjectionLoadSpecTest.java b/processing/src/test/java/org/apache/druid/segment/loading/PartialProjectionLoadSpecTest.java index b64a0d0b7c1a..178f767d70be 100644 --- a/processing/src/test/java/org/apache/druid/segment/loading/PartialProjectionLoadSpecTest.java +++ b/processing/src/test/java/org/apache/druid/segment/loading/PartialProjectionLoadSpecTest.java @@ -27,13 +27,26 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.google.common.collect.ImmutableMap; +import org.apache.druid.error.DruidException; import org.apache.druid.jackson.DefaultObjectMapper; +import org.apache.druid.java.util.common.Intervals; +import org.apache.druid.query.OrderBy; +import org.apache.druid.query.aggregation.AggregatorFactory; +import org.apache.druid.query.aggregation.CountAggregatorFactory; +import org.apache.druid.segment.VirtualColumns; +import org.apache.druid.segment.file.SegmentFileMetadata; +import org.apache.druid.segment.projections.AggregateProjectionSchema; +import org.apache.druid.segment.projections.ProjectionMetadata; +import org.apache.druid.timeline.DataSegment; +import org.apache.druid.timeline.SegmentId; +import org.apache.druid.timeline.partition.NumberedShardSpec; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import javax.annotation.Nullable; import java.io.ByteArrayInputStream; import java.io.File; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -167,6 +180,92 @@ void testRejectsNullFingerprint() ); } + @Test + void testGetSelectedBundleNamesReturnsProjections() + { + // Projection names double as bundle names; each requested name must appear in the segment's projections. + PartialProjectionLoadSpec spec = new PartialProjectionLoadSpec( + DELEGATE, + List.of("user_daily", "user_hourly"), + FINGERPRINT, + jsonMapper + ); + final SegmentFileMetadata metadata = metadataWithProjections("user_daily", "user_hourly", "some_other"); + Assertions.assertEquals(List.of("user_daily", "user_hourly"), spec.getSelectedBundleNames(anySegment(), metadata)); + } + + @Test + void testGetSelectedBundleNamesThrowsWhenProjectionMissing() + { + // Defensive: a wire form referring to a projection this segment doesn't have. Should only happen on + // matcher/reader drift or a coding bug. + PartialProjectionLoadSpec spec = new PartialProjectionLoadSpec( + DELEGATE, + List.of("user_daily", "vanished"), + FINGERPRINT, + jsonMapper + ); + final SegmentFileMetadata metadata = metadataWithProjections("user_daily"); + final DruidException thrown = Assertions.assertThrows( + DruidException.class, + () -> spec.getSelectedBundleNames(anySegment(), metadata) + ); + Assertions.assertTrue( + thrown.getMessage().contains("does not contain projection[vanished]"), + "unexpected message: " + thrown.getMessage() + ); + } + + @Test + void testGetSelectedBundleNamesThrowsWhenMetadataHasNoProjections() + { + // Defensive: a segment with no projections in metadata can't satisfy any projection rule. + PartialProjectionLoadSpec spec = new PartialProjectionLoadSpec( + DELEGATE, + List.of("user_daily"), + FINGERPRINT, + jsonMapper + ); + final SegmentFileMetadata metadata = new SegmentFileMetadata(List.of(), Map.of(), null, null, null, null); + final DruidException thrown = Assertions.assertThrows( + DruidException.class, + () -> spec.getSelectedBundleNames(anySegment(), metadata) + ); + Assertions.assertTrue( + thrown.getMessage().contains("metadata has no projections"), + "unexpected message: " + thrown.getMessage() + ); + } + + private static DataSegment anySegment() + { + return DataSegment.builder(SegmentId.of("ds", Intervals.ETERNITY, "v1", new NumberedShardSpec(0, 1))) + .size(0) + .build(); + } + + private static SegmentFileMetadata metadataWithProjections(String... names) + { + final List projections = new ArrayList<>(names.length); + for (String name : names) { + projections.add(new ProjectionMetadata(1, projectionSchemaNamed(name))); + } + return new SegmentFileMetadata(List.of(), Map.of(), null, null, projections, null); + } + + private static AggregateProjectionSchema projectionSchemaNamed(String name) + { + return new AggregateProjectionSchema( + name, + null, + null, + VirtualColumns.EMPTY, + List.of("dim1"), + new AggregatorFactory[]{new CountAggregatorFactory("cnt")}, + List.of(OrderBy.ascending("dim1")) + ); + } + /** * Stub LoadSpec used to verify delegation. Uses the same JSON "type"=="stub" key as the test {@link #DELEGATE}. */ 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 index 8a53c1b5f139..b4d78ae3cfc3 100644 --- a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrap.java +++ b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrap.java @@ -56,11 +56,12 @@ * {@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). * - * Parent-set inference is delegated to {@link PartialSegmentMetadataCacheEntry#inferParentBundles}. A bundle whose - * inferred parent 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. + * 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 { @@ -173,8 +174,8 @@ static void restoreBundlesFromDisk(PartialSegmentMetadataCacheEntry metadata, St final Set orphanedBundleNames = new HashSet<>(); for (String name : presentBundleNames) { boolean orphaned = false; - for (PartialSegmentBundleCacheEntryIdentifier parent : metadata.inferParentBundles(name)) { - if (!presentBundleNames.contains(parent.bundleName())) { + for (PartialSegmentBundleCacheEntryIdentifier dep : metadata.inferBundleDependencies(name)) { + if (!presentBundleNames.contains(dep.bundleName())) { orphaned = true; break; } @@ -192,23 +193,23 @@ static void restoreBundlesFromDisk(PartialSegmentMetadataCacheEntry metadata, St fileMapper.mapperForContainer(ref.externalFilename()).evictContainer(ref.containerIndex()); } LOG.debug( - "Deleted on-disk state of orphaned bundle[%s] for segment[%s] (parent unrestorable); next access " + "Deleted on-disk state of orphaned bundle[%s] for segment[%s] (dependency unrestorable); next access " + "will trigger cold re-fetch", orphanName, segmentId ); } - // mount base bundle before any dependent bundle so its hold is available when dependents acquire parent holds + // 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 parents present by construction (orphans were filtered out above), so the - // inferred parent set is exactly what we want, no further filtering needed. - final List parentIds = metadata.inferParentBundles(bundleName); + // 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, 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 d597b656da57..94927bfb9efe 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 @@ -48,7 +48,11 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.Callable; @@ -132,8 +136,8 @@ public class PartialSegmentMetadataCacheEntry implements SegmentCacheEntry, Resi // bundle entries that are currently mounted against this segment, registered by PartialSegmentBundleCacheEntry on // successful mount and removed on unmount. Lets the drop path enumerate bundles for cascade-close without scanning - // the StorageLocation's entry maps. - private final Set linkedBundles = ConcurrentHashMap.newKeySet(); + // the StorageLocation's entry maps. Keyed by bundle name (one bundle per name per segment). + private final ConcurrentHashMap linkedBundles = new ConcurrentHashMap<>(); // Reference-counted gate over the actual cleanup work (close file mapper, delete header files). Set on // successful mount; unmount() closes the wrapper which defers running cleanup until all outstanding references @@ -147,6 +151,33 @@ public class PartialSegmentMetadataCacheEntry implements SegmentCacheEntry, Resi // cleared so retries get a fresh attempt; on success the gate stays set until doActualUnmount clears it. private final AtomicReference> mountFuture = new AtomicReference<>(); + /** + * Rule-holds state machine (all guarded by {@link #entryLock}). A partial-load rule is applied via + * {@link #applyRule(String, Set)} after {@link #mount(StorageLocation)}. While a rule is applied + * ({@code ruleFingerprint != null}): + *

+ * {@link #clearRule()} releases the holds and clears the state. {@link #applyRule(String, Set)} with a different + * fingerprint / selection set diffs the current state and closes / acquires only the deltas, so overlapping bundle + * holds stay live across a rule swap. + */ + @GuardedBy("entryLock") + @Nullable + private String ruleFingerprint; + @GuardedBy("entryLock") + private Set ruleSelectedBundleNames = Set.of(); + @GuardedBy("entryLock") + private final Map> ruleBundleHolds = + new HashMap<>(); + @GuardedBy("entryLock") + @Nullable + private StorageLocation.ReservationHold metadataSelfHold; + public PartialSegmentMetadataCacheEntry( SegmentId segmentId, File localCacheDir, @@ -199,6 +230,384 @@ 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()} + * asserts that this field is {@code null} at unmount time, the {@code metadataSelfHold} taken by applyRule pins + * the entry's phaser and prevents eviction reclaim / doActualUnmount from firing while a rule is applied. A fresh + * mount therefore always starts with no rule applied. + */ + @Nullable + public String getRuleFingerprint() + { + entryLock.lock(); + try { + return ruleFingerprint; + } + finally { + entryLock.unlock(); + } + } + + /** + * The set of bundle names currently pinned by an applied partial-load rule, or an empty set if no rule is applied. + * Return value is immutable; safe to inspect without further synchronization. + */ + public Set getRuleSelectedBundleNames() + { + entryLock.lock(); + try { + return ruleSelectedBundleNames; + } + finally { + entryLock.unlock(); + } + } + + /** + * Whether a partial-load rule is currently applied to this entry. Equivalent to {@code getRuleFingerprint() != null}. + * While {@code true}, this entry holds a self-referential {@link StorageLocation.ReservationHold} that prevents cache + * from evicting it, so the metadata's V10 header stays resident until {@link #clearRule()} runs. + */ + public boolean isRuleHeld() + { + entryLock.lock(); + try { + return ruleFingerprint != null; + } + finally { + entryLock.unlock(); + } + } + + /** + * Apply (or replace) a partial-load rule on this entry. Must be called after {@link #mount(StorageLocation)}, the + * entry must be registered with its {@link StorageLocation} so it can acquire holds on itself and on its bundles. + *

+ * Concurrency contract. The caller MUST serialize {@code applyRule} and {@link #clearRule} for a given + * segment id through an external per-segment lock ({@code SegmentLocalCacheManager} uses its + * {@code ReferenceCountingLock segmentLocks}). This method releases {@link #entryLock} between Phase 2 (acquire + * holds outside the lock) and Phase 4 (release holds outside the lock); a concurrent invocation of + * {@code applyRule}/{@code clearRule} on the same entry can observe intermediate state during those windows and + * silently miss rule-hold installations or leak them. {@link #entryLock} alone is insufficient because it is + * released across the acquire/release phases by design (holding it across {@link StorageLocation} lock acquisition + * would invert the writeLock → entryLock ordering the storage layer already uses for eviction). + *

+ * On first application ({@code ruleFingerprint} transitions from {@code null}), a self-referential + * {@link StorageLocation.ReservationHold} is taken to pin the metadata entry in-cache. On repeat calls with the same + * {@code fingerprint} and matching {@code selectedBundleNames}, this is a no-op. On a genuine rule swap, only the + * delta between the previous and new selection is applied: bundle holds for names dropped from the selection are + * closed, and holds for names newly added are acquired for any bundle already registered with this entry (later + * arrivals get their hold via {@link #registerBundle}). + *

+ * Bundles whose {@link StorageLocation.ReservationHold} is currently zero-refcount (never mounted, or evicted but + * unregistered before this call) will not appear in {@link #linkedBundles} yet, so no hold is taken for them here; + * the caller is expected to drive an on-demand acquire per selected bundle name to trigger a fresh mount, which will + * call {@link #registerBundle} and pick up the rule-hold at that point. + * + * @throws DruidException if the entry is not currently mounted + */ + public void applyRule(String fingerprint, Set selectedBundleNames) + { + Objects.requireNonNull(fingerprint, "fingerprint"); + Objects.requireNonNull(selectedBundleNames, "selectedBundleNames"); + final Set newSelection = Set.copyOf(selectedBundleNames); + + // Phase 1: snapshot under entryLock and compute the diff. Do NOT call StorageLocation methods here — that would + // acquire the location's readLock while holding entryLock and could deadlock with a concurrent writeLock holder + // that needs entryLock + final StorageLocation loc; + final boolean needsSelfHold; + final List namesToRelease; + final List namesToAcquire; + entryLock.lock(); + try { + if (location == null) { + throw DruidException.defensive( + "applyRule on partial metadata entry[%s] requires the entry to be mounted", + id + ); + } + if (fingerprint.equals(ruleFingerprint) && newSelection.equals(ruleSelectedBundleNames)) { + return; + } + loc = location; + needsSelfHold = (metadataSelfHold == null); + namesToRelease = new ArrayList<>(); + for (String name : ruleBundleHolds.keySet()) { + if (!newSelection.contains(name)) { + namesToRelease.add(name); + } + } + namesToAcquire = new ArrayList<>(); + for (String name : newSelection) { + if (!ruleBundleHolds.containsKey(name) && findLinkedBundleByName(name) != null) { + namesToAcquire.add(name); + } + } + } + finally { + entryLock.unlock(); + } + + // Phase 2 + 3: acquire outside entryLock, then commit under entryLock. Track every hold this call acquires in + // `uncommittedHolds`; when a hold's ownership transfers into `metadataSelfHold` or `ruleBundleHolds`, it is + // removed from the list. Any throw between acquire and commit (or a race-lose at commit) leaves the hold in + // `uncommittedHolds`, and the finally at Phase 4 releases it. This is what makes applyRule all-or-nothing at + // the ReservationHold level: no leaks on partial failure, no stranded self-hold under a stale fingerprint. + final List> uncommittedHolds = new ArrayList<>(); + final List> displacedHolds = new ArrayList<>(); + try { + final StorageLocation.ReservationHold newSelfHold; + if (needsSelfHold) { + newSelfHold = loc.addWeakReservationHoldIfExists(id); + if (newSelfHold == null) { + throw DruidException.defensive( + "Failed to acquire self-referential rule-hold on partial metadata entry[%s]; entry is not weak-reserved", + id + ); + } + uncommittedHolds.add(newSelfHold); + } else { + newSelfHold = null; + } + final Map> acquired = new HashMap<>(); + for (String name : namesToAcquire) { + final StorageLocation.ReservationHold h = + loc.addWeakReservationHoldIfExists(new PartialSegmentBundleCacheEntryIdentifier(segmentId, name)); + if (h != null) { + acquired.put(name, h); + uncommittedHolds.add(h); + } + } + + // Phase 3: commit under entryLock. Ownership transfers are done by removing from `uncommittedHolds` after + // installing into the field. Anything left in `uncommittedHolds` at the end lost a race and gets released + // in Phase 4 alongside `displacedHolds` (the pre-existing holds diffed out of ruleBundleHolds). + entryLock.lock(); + try { + if (newSelfHold != null) { + if (metadataSelfHold == null) { + metadataSelfHold = newSelfHold; + uncommittedHolds.remove(newSelfHold); + } + } + for (String name : namesToRelease) { + final StorageLocation.ReservationHold h = ruleBundleHolds.remove(name); + if (h != null) { + displacedHolds.add(h); + } + } + for (var e : acquired.entrySet()) { + if (!ruleBundleHolds.containsKey(e.getKey())) { + ruleBundleHolds.put(e.getKey(), e.getValue()); + uncommittedHolds.remove(e.getValue()); + } + } + ruleFingerprint = fingerprint; + ruleSelectedBundleNames = newSelection; + } + finally { + entryLock.unlock(); + } + + // Phase 3b: close the narrow race with concurrent registerBundle + reconcileLinkedBundlesWithSelection(loc, uncommittedHolds); + } + finally { + // Phase 4: release outside entryLock. `uncommittedHolds` contains every hold this call acquired but did not + // successfully transfer into a field (race-lose, mid-acquire failure, or commit failure). `displacedHolds` + // contains pre-existing holds that the diff removed from ruleBundleHolds after successful commit. + releaseHolds(uncommittedHolds); + releaseHolds(displacedHolds); + } + } + + /** + * Post-{@link #applyRule} reconciliation: scan {@link #linkedBundles} against the freshly-committed + * {@link #ruleSelectedBundleNames} and install a rule-hold for any selected bundle that's present in + * {@code linkedBundles} but missing from {@link #ruleBundleHolds}. + *

+ * Closes a race that can occur when bundle mounts triggered by concurrent queries run on the storage-loading pool + * without the per-segment lock, and can call {@link #registerBundle} concurrently with + * {@link #applyRule}. If the bundle lands in {@link #linkedBundles} after {@link #applyRule}'s Phase 1 + * snapshot but its {@code registerBundle} Phase 1 runs before Phase 3 has committed {@code newSelection}, + * {@code registerBundle} observes the OLD selection and bails without installing a hold, and Phase 1's + * {@code namesToAcquire} also missed it (it wasn't in {@code linkedBundles} at snapshot time). Without this + * reconciliation, the entry would incorrectly be evictable despite the rule. + */ + private void reconcileLinkedBundlesWithSelection( + StorageLocation loc, + List> uncommittedHolds + ) + { + final List reconcileNames; + entryLock.lock(); + try { + reconcileNames = new ArrayList<>(); + for (String name : ruleSelectedBundleNames) { + if (!ruleBundleHolds.containsKey(name) && findLinkedBundleByName(name) != null) { + reconcileNames.add(name); + } + } + } + finally { + entryLock.unlock(); + } + if (reconcileNames.isEmpty()) { + return; + } + + final Map> acquired = new HashMap<>(); + for (String name : reconcileNames) { + final StorageLocation.ReservationHold h = + loc.addWeakReservationHoldIfExists(new PartialSegmentBundleCacheEntryIdentifier(segmentId, name)); + if (h != null) { + acquired.put(name, h); + uncommittedHolds.add(h); + } + } + + entryLock.lock(); + try { + for (var e : acquired.entrySet()) { + if (ruleSelectedBundleNames.contains(e.getKey()) && !ruleBundleHolds.containsKey(e.getKey())) { + ruleBundleHolds.put(e.getKey(), e.getValue()); + uncommittedHolds.remove(e.getValue()); + } + } + } + finally { + entryLock.unlock(); + } + } + + /** + * Release the partial-load rule applied to this entry. Closes every {@link StorageLocation.ReservationHold} taken by + * {@link #applyRule(String, Set)} (the metadata self-hold and every bundle hold) and clears the rule state. Bundle + * entries that were kept resident only by the rule become eviction eligible; the metadata entry itself becomes + * eviction eligible as well (unless a concurrent query holds a reference). Safe to call when no rule is applied, no-op. + *

+ * Concurrency contract. Must be serialized against {@link #applyRule} for a given segment id via an external + * per-segment lock (see {@code applyRule}'s Concurrency contract). Hold releases run OUTSIDE {@link #entryLock}, so + * a concurrent {@code applyRule}/{@code clearRule} on the same entry can observe intermediate state. + *

+ * Snapshot-under-lock, release-outside-lock. Closing a {@link StorageLocation.ReservationHold} eventually acquires + * the location's writeLock (via {@code createWeakEntryReleaseRunnable}), so it must not run under {@link #entryLock} + * lest it invert the writeLock → entryLock ordering the storage layer already relies on. + */ + public void clearRule() + { + final List> toClose = new ArrayList<>(); + entryLock.lock(); + try { + toClose.addAll(ruleBundleHolds.values()); + ruleBundleHolds.clear(); + if (metadataSelfHold != null) { + toClose.add(metadataSelfHold); + metadataSelfHold = null; + } + ruleFingerprint = null; + ruleSelectedBundleNames = Set.of(); + } + finally { + entryLock.unlock(); + } + releaseHolds(toClose); + } + + private void releaseHolds(List> holds) + { + for (StorageLocation.ReservationHold h : holds) { + CloseableUtils.closeAndSuppressExceptions( + h, + t -> LOG.warn(t, "Failed releasing rule-hold on partial segment[%s]", segmentId) + ); + } + } + + @Nullable + private PartialSegmentBundleCacheEntry findLinkedBundleByName(String bundleName) + { + return linkedBundles.get(bundleName); + } + + /** + * Whether every container belonging to {@code bundleName} has been downloaded. + *

+ * Returns {@code false} when the entry is not mounted (no file mapper yet). + */ + public boolean isBundleFullyDownloaded(String bundleName) + { + final PartialSegmentFileMapperV10 mapper = getFileMapper(); + return mapper != null && mapper.isBundleFullyDownloaded(bundleName); + } + + /** + * Whether {@code bundleName} is currently held by an applied partial-load rule on this entry (i.e. present in + * {@link #ruleBundleHolds}). + *

+ * NOT the same as "is the bundle linked": a bundle can be linked ({@link #linkedBundles}) without being rule-held, + * because {@link #linkedBundles} is a lock-free {@link ConcurrentHashMap} keyset that + * {@link #registerBundle}/{@link #unregisterBundle} mutate outside {@link #entryLock} (to preserve lock ordering + * against the storage layer's writeLock → entryLock chain). + */ + public boolean isBundleRuleHeld(String bundleName) + { + entryLock.lock(); + try { + return ruleBundleHolds.containsKey(bundleName); + } + finally { + entryLock.unlock(); + } + } + + /** + * Mount and eagerly download the named bundle. Drives an acquire through the internal + * {@link #bundleAcquirer} (which mounts the bundle if needed, causing {@link #registerBundle} to fire), downloads + * every container in the bundle via {@link PartialSegmentFileMapperV10#ensureBundleDownloaded}, then releases the + * transient acquire hold. When a partial-load rule selects this bundle, the metadata's own rule-hold (installed by + * {@link #registerBundle} on the mount that this call drives) keeps the bundle resident after the transient hold + * releases. When no rule selects this bundle, the transient hold's release leaves the bundle eviction eligible; the + * caller should already have driven {@link #applyRule} first to install the rule state before calling this. + */ + public void ensureBundleResidentForRule(String bundleName) throws IOException + { + entryLock.lock(); + try { + if (location == null || fileMapper == null) { + throw DruidException.defensive( + "ensureBundleResidentForRule on unmounted partial metadata entry[%s]", + id + ); + } + } + finally { + entryLock.unlock(); + } + final Closeable acquired = bundleAcquirer.acquire(bundleName); + try { + final PartialSegmentFileMapperV10 mapper = getFileMapper(); + if (mapper == null) { + throw DruidException.defensive( + "Partial metadata entry[%s] lost its file mapper mid-acquire for bundle[%s]", + id, + bundleName + ); + } + mapper.ensureBundleDownloaded(bundleName); + } + finally { + CloseableUtils.closeAndSuppressExceptions(acquired, t -> LOG.warn( + t, + "Failed to release transient bundle-acquire hold for bundle[%s] on segment[%s]", + bundleName, + segmentId + )); + } + } + @Override public long getSize() { @@ -273,9 +682,12 @@ public void mount(StorageLocation mountLocation) throws IOException ours.set(null); } catch (Throwable t) { - // clear the future so the next caller gets a fresh attempt + // Clear the future so the next caller gets a fresh attempt. Signal awaiters with the exception BEFORE firing + // the onUnmount hook so any thread observing the failure via awaitMount(future) sees the exception before + // observing the hook's file-system side effect (info-file deletion). mountFuture.set(null); ours.setException(t); + runOnUnmountHookOnce(); Throwables.propagateIfInstanceOf(t, IOException.class); Throwables.propagateIfPossible(t); throw DruidException.defensive(t, "Failed to mount metadata entry[%s]", id); @@ -463,6 +875,17 @@ private static void awaitMount(SettableFuture future) throws IOException * outstanding, the actual unmap-and-delete work is deferred until the last reference releases; in that case this * method returns immediately and {@link #doActualUnmount} will fire later on the thread that closes the last * reference. With no outstanding references, cleanup runs synchronously on the caller's thread. + *

+ * If this entry was reserved but never mounted, there is no reference-counted gate to close; instead run the + * {@link #setOnUnmount onUnmount} hook directly so external cleanup (e.g. info-file deletion) still fires. + *

+ * Mid-mount race guard. A concurrent reclaim can invoke {@code unmount()} while {@link #doMount} is still + * running its slow deep-storage header fetch and has not yet installed {@link #references}. Firing the hook here + * would race with {@code doMount}. Detect this via {@link #mountFuture} (set by {@link #mount} before + * {@code doMount} runs; cleared by {@code doMount}'s failure path and by {@link #doActualUnmount}) and defer + * hook-firing to the mount path: on success {@code verifyStillReservedOrRollback} calls {@code unmount()} again + * with {@link #references} installed and the hook fires via {@link #doActualUnmount}; on failure {@link #mount}'s + * catch block fires the hook directly (see below). */ @Override public void unmount() @@ -470,6 +893,30 @@ public void unmount() final ReferenceCountingCloseableObject current = references.get(); if (current != null && !current.isClosed()) { current.close(); + return; + } + if (mountFuture.get() != null) { + // doMount is in flight and has not yet installed `references`; hook-firing is deferred to the mount path. + return; + } + // Never-mounted (or already-completed-cleanup) release: doActualUnmount won't run, so fire the hook here. + runOnUnmountHookOnce(); + } + + /** + * Atomically extract and run the {@link #setOnUnmount onUnmount} hook if one is set. Idempotent across concurrent + * callers, the {@code getAndSet(null)} ensures exactly one caller ever observes a non-null hook. + */ + private void runOnUnmountHookOnce() + { + final Runnable hook = onUnmount.getAndSet(null); + if (hook != null) { + try { + hook.run(); + } + catch (Throwable t) { + LOG.warn(t, "onUnmount hook failed for partial segment metadata entry[%s]", segmentId); + } } } @@ -634,12 +1081,21 @@ public Optional acquireFullReference(@Nullable Closeable extraOnClose) */ private void doActualUnmount() { - final Runnable hook; entryLock.lock(); try { if (fileMapper == null) { return; } + // doActualUnmount must never fire while a partial-load rule is applied. + if (ruleFingerprint != null) { + throw DruidException.defensive( + "doActualUnmount fired for partial segment[%s] with rule state still applied (fingerprint=%s, " + + "selectedBundles=%s); the metadata self-hold invariant has been broken", + segmentId, + ruleFingerprint, + ruleSelectedBundleNames + ); + } try { fileMapper.close(); } @@ -654,21 +1110,13 @@ private void doActualUnmount() // Clear the mount-dedup gate so a subsequent mount() on this same instance starts a fresh attempt. mountFuture.set(null); deleteHeaderFiles(); - hook = onUnmount.getAndSet(null); } finally { entryLock.unlock(); } // Run the hook outside entryLock so it can touch the file system / cache manager without contending with // concurrent status reads, and so a slow or buggy hook can't deadlock against acquireReference paths. - if (hook != null) { - try { - hook.run(); - } - catch (Throwable t) { - LOG.warn(t, "onUnmount hook failed for partial segment metadata entry[%s]", segmentId); - } - } + runOnUnmountHookOnce(); } /** @@ -804,7 +1252,7 @@ public Closeable acquire(String requestedBundleName) () -> PartialSegmentBundleCacheEntry.forBundle( PartialSegmentMetadataCacheEntry.this, bundleName, - inferParentBundles(bundleName) + inferBundleDependencies(bundleName) ) ); if (hold == null) { @@ -861,13 +1309,13 @@ private void mountBundleOrClose( // 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 parent 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 - // parents, so the recursion terminates). + // 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). final Closer parentHolds = Closer.create(); try { - for (PartialSegmentBundleCacheEntryIdentifier parentId : inferParentBundles(bundleName)) { - parentHolds.register(acquire(parentId.bundleName())); + for (PartialSegmentBundleCacheEntryIdentifier depId : inferBundleDependencies(bundleName)) { + parentHolds.register(acquire(depId.bundleName())); } bundle.mount(loc); } @@ -885,17 +1333,19 @@ private void mountBundleOrClose( } /** - * Inference of the parent bundles that the given {@code bundleName} depends on within this segment. + * The bundles that {@code bundleName} depends on within this segment. Depending here means: at mount time, this + * bundle's file mapper must be able to acquire a hold on each dependency, and the dependency's containers must + * remain resident for as long as this bundle is mounted. *

* The rule is uniform: the base bundle and the {@link SegmentFileBuilder#ROOT_BUNDLE_NAME root bundle} have no - * parents (the root bundle owns everything written without an explicit {@code startFileBundle} call, for older - * fileGroup-less segments, or any future shared internal metadata and is structurally a peer of the base); + * dependencies (the root bundle owns everything written without an explicit {@code startFileBundle} call, for + * older fileGroup-less segments, or any future shared internal metadata and is structurally a peer of the base); * every other bundle depends on the base bundle, but only if this segment actually carries one. *

* If future writers introduce richer dependency graphs, the rule will need to grow, likely by reading dependency * metadata the writer records explicitly. */ - public List inferParentBundles(String bundleName) + public List inferBundleDependencies(String bundleName) { if (Projections.BASE_TABLE_PROJECTION_NAME.equals(bundleName) || SegmentFileBuilder.ROOT_BUNDLE_NAME.equals(bundleName) @@ -910,6 +1360,34 @@ public List inferParentBundles(String ); } + /** + * Return {@code roots} plus every transitive dependency (as inferred by {@link #inferBundleDependencies}) in + * dependency-first order. + *

+ * No cycle guard: {@link #inferBundleDependencies} only returns {@code []} or {@code [__base]}, and {@code __base} + * itself has no dependencies, so the graph is at most one level deep by construction. A richer future dependency + * graph should revisit this. + */ + public List bundlesInMountOrder(Iterable roots) + { + final LinkedHashSet ordered = new LinkedHashSet<>(); + for (String root : roots) { + visitDependenciesFirst(root, ordered); + } + return List.copyOf(ordered); + } + + private void visitDependenciesFirst(String bundleName, LinkedHashSet ordered) + { + if (ordered.contains(bundleName)) { + return; + } + for (PartialSegmentBundleCacheEntryIdentifier dep : inferBundleDependencies(bundleName)) { + visitDependenciesFirst(dep.bundleName(), ordered); + } + ordered.add(bundleName); + } + /** * Whether this segment carries a {@code __base} bundle (shared base-table column data). Probed from the mounted file * mapper's actual bundle set; returns false when the entry is not mounted. @@ -925,19 +1403,71 @@ private boolean hasBaseBundle() * Register a bundle entry as a current dependent of this metadata entry. Called by * {@link PartialSegmentBundleCacheEntry} after a successful mount; the drop path uses {@link #snapshotLinkedBundles} * to enumerate dependents for cascade-close. + *

+ * If a partial-load rule is currently applied and {@code bundle}'s name is in the rule's selection set, take a + * {@link StorageLocation.ReservationHold} on the bundle so it stays resident until the rule is cleared or swapped + * away. The hold acquisition uses a snapshot-under-lock, acquire-outside-lock, commit-under-lock pattern to avoid + * holding {@link #entryLock} while calling into {@link StorageLocation}'s read/write locks. */ void registerBundle(PartialSegmentBundleCacheEntry bundle) { - linkedBundles.add(bundle); + final String bundleName = bundle.getBundleName(); + linkedBundles.put(bundleName, bundle); + + // Phase 1: snapshot under entryLock. + final StorageLocation loc; + entryLock.lock(); + try { + if (location == null + || !ruleSelectedBundleNames.contains(bundleName) + || ruleBundleHolds.containsKey(bundleName)) { + return; + } + loc = location; + } + finally { + entryLock.unlock(); + } + + // Phase 2: acquire outside entryLock. Caller's mount path (via bundleAcquirer.acquire) already holds a transient + // ReservationHold on this bundle, so the weak entry is guaranteed present and this acquire cannot return null + // for a "just evicted" reason. + final StorageLocation.ReservationHold hold = + loc.addWeakReservationHoldIfExists(new PartialSegmentBundleCacheEntryIdentifier(segmentId, bundleName)); + if (hold == null) { + return; + } + + // Phase 3: commit under entryLock, or discard if the rule state moved under us. + boolean stored = false; + entryLock.lock(); + try { + if (ruleSelectedBundleNames.contains(bundleName) && !ruleBundleHolds.containsKey(bundleName)) { + ruleBundleHolds.put(bundleName, hold); + stored = true; + } + } + finally { + entryLock.unlock(); + } + if (!stored) { + CloseableUtils.closeAndSuppressExceptions( + hold, + t -> LOG.warn(t, "Failed releasing discarded rule-hold for bundle[%s] on segment[%s]", bundleName, segmentId) + ); + } } /** * Reverse of {@link #registerBundle}. Called by {@link PartialSegmentBundleCacheEntry#unmount} so the metadata's - * view stays consistent with which bundles are actually mounted. + * view stays consistent with which bundles are actually mounted. Bookkeeping-only: never touches {@link + * #ruleBundleHolds}. */ void unregisterBundle(PartialSegmentBundleCacheEntry bundle) { - linkedBundles.remove(bundle); + // Remove only if the exact same instance is still registered under this name. A late-arriving unregister for a + // stale bundle instance (e.g. one that was replaced by a fresh remount) must not evict the fresh instance. + linkedBundles.remove(bundle.getBundleName(), bundle); } /** @@ -947,7 +1477,7 @@ void unregisterBundle(PartialSegmentBundleCacheEntry bundle) */ public Collection snapshotLinkedBundles() { - return new ArrayList<>(linkedBundles); + return new ArrayList<>(linkedBundles.values()); } /** 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 2c6e4505a5a8..3518cd7e7414 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 @@ -54,15 +54,21 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CancellationException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.Lock; @@ -454,6 +460,23 @@ private void deleteSegmentInfoFile(DataSegment segment) } } + /** + * Write the info file for a partial-load segment, overwriting any existing content atomically. Distinct from + * {@link #storeInfoFile} which skips the write when the file already exists, for partial segments we must + * unconditionally rewrite so an incoming rule swap (new {@code fingerprint}/{@code delegate} inside the + * wrapped load spec) reaches disk. Otherwise bootstrap after a restart would restore the segment using the + * prior wrapper and re-announce the old rule until the coordinator resyncs. + */ + private void writePartialInfoFile(DataSegment segment) throws IOException + { + final File segmentInfoCacheFile = new File(getEffectiveInfoDir(), segment.getId().toString()); + FileUtils.mkdirp(getEffectiveInfoDir()); + FileUtils.writeAtomically(segmentInfoCacheFile, out -> { + jsonMapper.writeValue(out, segment); + return null; + }); + } + @Override public Optional acquireCachedSegment(final SegmentId segmentId, final AcquireMode acquireMode) { @@ -746,6 +769,48 @@ private ReservedPartial findExistingPartialWithHold(SegmentId segmentId) return null; } + /** + * If any storage location has a non-{@link PartialSegmentMetadataCacheEntry} cache entry at {@code segmentId}, + * attempt to evict it via {@link StorageLocation#removeUnheldWeakEntry}. This handles the config-flip scenario + * where a segment with a {@link PartialLoadSpec} wrapper was previously queried while + * {@code virtualStoragePartialDownloadsEnabled=false} ({@link #acquireSegment} created a + * {@link CompleteSegmentCacheEntry} at this id, and a subsequent partial-load rule cannot reserve here without + * evicting the stale entry first). + *

+ * {@link StorageLocation#removeUnheldWeakEntry} is a no-op when the entry is held (in-flight query), so we + * detect via {@link StorageLocation#getCacheEntry} whether eviction actually happened. If not, throw a retryable + * {@link SegmentLoadingException}: the coordinator's load queue retries on the next sync, and by then the query + * should have released the hold and eviction will succeed. + */ + private void evictStaleNonPartialWeakEntry(SegmentId segmentId) throws SegmentLoadingException + { + final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(segmentId); + for (StorageLocation location : locations) { + final CacheEntry entry = location.getCacheEntry(id); + if (entry == null || entry instanceof PartialSegmentMetadataCacheEntry) { + continue; + } + location.removeUnheldWeakEntry(id); + final CacheEntry stillThere = location.getCacheEntry(id); + if (stillThere != null) { + throw new SegmentLoadingException( + "Stale non-partial cache entry[%s] at id[%s] on location[%s] blocks partial-load reservation and is " + + "currently held; the coordinator's load queue will retry", + stillThere.getClass().getSimpleName(), + id, + location.getPath() + ); + } + log.info( + "Evicted stale non-partial cache entry[%s] at location[%s] to make room for partial-load rule on " + + "segment[%s]", + entry.getClass().getSimpleName(), + location.getPath(), + segmentId + ); + } + } + /** * Try to open a range reader for the given segment's {@link LoadSpec}; returns {@code null} when the backend * doesn't support range reads (e.g. zipped storage), or when partial downloads are disabled via @@ -790,7 +855,14 @@ private ReservedPartial reservePartial(DataSegment dataSegment, SegmentRangeRead FileUtils.mkdirp(partialDir); } catch (IOException e) { - throw DruidException.defensive(e, "Failed to create partial cache dir for segment[%s]", dataSegment.getId()); + // Location is unwritable, fall through to next location rather than failing the whole reservation + log.warn( + e, + "Failed to create partial cache dir on location[%s] for segment[%s]; trying next location", + location.getPath(), + dataSegment.getId() + ); + continue; } final StorageLocation.ReservationHold hold = location.addWeakReservationHold( id, @@ -806,6 +878,7 @@ private ReservedPartial reservePartial(DataSegment dataSegment, SegmentRangeRead ) ); if (hold == null) { + atomicMoveAndDeleteCacheEntryDirectory(partialDir); continue; } try { @@ -817,19 +890,18 @@ private ReservedPartial reservePartial(DataSegment dataSegment, SegmentRangeRead location.getPath() ); } - final File segmentInfoCacheFile = new File(getEffectiveInfoDir(), dataSegment.getId().toString()); - if (!segmentInfoCacheFile.exists()) { - FileUtils.mkdirp(getEffectiveInfoDir()); - FileUtils.writeAtomically(segmentInfoCacheFile, out -> { - jsonMapper.writeValue(out, dataSegment); - return null; - }); - } + writePartialInfoFile(dataSegment); partial.setOnUnmount(() -> deleteSegmentInfoFile(dataSegment)); return new ReservedPartial(partial, location, hold); } catch (Throwable t) { - throw CloseableUtils.closeAndWrapInCatch(t, hold); + // Close the hold (removing the never-mounted weak entry), then nuke the on-disk dir. + try { + throw CloseableUtils.closeAndWrapInCatch(t, hold); + } + finally { + atomicMoveAndDeleteCacheEntryDirectory(partialDir); + } } } throw DruidException.forPersona(DruidException.Persona.USER) @@ -872,6 +944,303 @@ private record ReservedPartial( { } + /** + * Apply (or reconcile) a partial-load rule for a segment coming through {@link #load}. The rule state lives on the + * {@link PartialSegmentMetadataCacheEntry} itself; this method just drives it: + *

    + *
  1. Materialize the wrapper's {@link PartialLoadSpec} and open a range reader over the backend. If the backend + * can't do range reads, clear any prior rule on the segment and return; the segment falls back to the + * weak-full-load path at query time.
  2. + *
  3. Acquire (or reserve) the metadata entry via the shared on-demand path ({@link #findOrReservePartial}), + * take a transient eviction-protective hold across the rest of the flow.
  4. + *
  5. Mount the metadata entry (idempotent).
  6. + *
  7. Compute the rule's selected bundle names from the wrapper + parsed segment metadata, and call + * {@link PartialSegmentMetadataCacheEntry#applyRule} which installs the metadata self-hold and takes + * holds on any already-registered selected bundles.
  8. + *
  9. Kick off async eager downloads on the loading pool for any selected bundle not yet linked. Each async task + * drives {@link PartialSegmentMetadataCacheEntry#ensureBundleResidentForRule}: its bundle mount will call + * {@link PartialSegmentMetadataCacheEntry#registerBundle}, which acquires the rule-hold on that bundle.
  10. + *
  11. Release the transient hold; the metadata's self-hold keeps the entry resident under the applied rule.
  12. + *
+ */ + private void loadPartial(DataSegment dataSegment) throws SegmentLoadingException + { + final PartialLoadSpec wrapper = materializePartialLoadSpec(dataSegment); + final SegmentRangeReader rangeReader = openPartialRangeReader(dataSegment, wrapper); + final ReferenceCountingLock lock = lock(dataSegment); + synchronized (lock) { + try { + // If a stale non-partial cache entry sits at this segment id (a CompleteSegmentCacheEntry created by a prior + // acquireSegment while virtualStoragePartialDownloadsEnabled=false, for example), evict it before any + // partial-entry lookup or reservation; otherwise findExistingPartialWithHold's defensive type-check would + // throw, and reservePartial's addWeakReservationHold would land on the incompatible entry. If the stale + // entry is currently held (in-flight query), this throws a retryable SegmentLoadingException; the + // coordinator's load queue retries on next sync, and by then the query should have released. + evictStaleNonPartialWeakEntry(dataSegment.getId()); + + if (rangeReader == null) { + // Backend doesn't support range reads (e.g. zipped deep storage). The rule can't be honored as a partial + // load; clear any prior rule so the segment falls through to the ordinary weak-full-load path at query + // time. + final ReservedPartial existing = findExistingPartialWithHold(dataSegment.getId()); + if (existing != null) { + try { + existing.metadata().clearRule(); + log.warn( + "Backend for segment[%s] does not support range reads; released rule[fingerprint=%s], segment " + + "will fall back to weak full-load at query time", + dataSegment.getId(), + wrapper.getFingerprint() + ); + } + finally { + CloseableUtils.closeAndSuppressExceptions( + existing.hold(), + t -> log.warn(t, "Failed to release transient hold on partial metadata for segment[%s]", + dataSegment.getId()) + ); + } + } else { + // No prior entry AND range-reader is null on this fresh load: the coordinator asked for a partial rule + // this historical can't honor. Log so operators can debug "why isn't my rule applied on historical X". + log.warn( + "Backend for segment[%s] does not support range reads and no prior partial entry exists; rule" + + "[fingerprint=%s] cannot be applied on this historical", + dataSegment.getId(), + wrapper.getFingerprint() + ); + } + return; + } + + final ReservedPartial reserved = findOrReservePartial(dataSegment, rangeReader); + try { + final PartialSegmentMetadataCacheEntry metadata = reserved.metadata(); + // findOrReservePartial only invokes reservePartial (which writes the info file) on the fresh-reserve + // branch. On the find-existing branch the info file on disk still carries the PRIOR rule's wrapped + // load spec, so a rule swap here would apply in memory only. Rewrite unconditionally before mount. + try { + writePartialInfoFile(dataSegment); + } + catch (IOException e) { + throw new SegmentLoadingException( + e, + "Failed to write partial info file for segment[%s]", + dataSegment.getId() + ); + } + try { + metadata.mount(reserved.location()); + } + catch (IOException e) { + throw new SegmentLoadingException( + e, + "Failed to mount partial metadata for segment[%s]", + dataSegment.getId() + ); + } + final PartialSegmentFileMapperV10 mapper = metadata.getFileMapper(); + if (mapper == null) { + throw DruidException.defensive( + "Partial metadata for segment[%s] mounted without a file mapper", + dataSegment.getId() + ); + } + // Register the info-file cleanup hook BEFORE anything that could throw. Any throw between mount and + // awaitEagerDownloadsOrClearRule leaves the metadata entry weak-reserved with the hook attached; when + // cache eventually reclaims, the info file gets deleted along with the entry. + metadata.setOnUnmount(() -> deleteSegmentInfoFile(dataSegment)); + final Set selected = Set.copyOf( + wrapper.getSelectedBundleNames(dataSegment, mapper.getSegmentFileMetadata()) + ); + final String priorFingerprint = metadata.getRuleFingerprint(); + metadata.applyRule(wrapper.getFingerprint(), selected); + if (priorFingerprint != null && !priorFingerprint.equals(wrapper.getFingerprint())) { + log.info( + "Reconciled partial-load rule for segment[%s]: fingerprint transitioned [%s] → [%s]", + dataSegment.getId(), + priorFingerprint, + wrapper.getFingerprint() + ); + } + // Block until every eager download completes so the announcement fingerprint reflects reality: any failure + // clears the rule state (releasing self-hold + all bundle rule-holds) and propagates as a load failure so + // the coordinator's load queue can retry on its next sync. The announced fingerprint == "rule fully + // realized" contract stays intact. + awaitEagerDownloadsOrClearRule(dataSegment, metadata, selected); + } + finally { + CloseableUtils.closeAndSuppressExceptions( + reserved.hold(), + t -> log.warn(t, "Failed to release transient hold on partial metadata for segment[%s]", + dataSegment.getId()) + ); + } + } + finally { + unlock(dataSegment, lock); + } + } + } + + /** + * Submit eager-download tasks to the loading pool for every rule-selected bundle not yet registered with + * {@code metadata}, then block until every task completes. On any failure the rule state is cleared + * and a {@link SegmentLoadingException} is thrown so the caller treats the load as failed and retries. + */ + private void awaitEagerDownloadsOrClearRule( + DataSegment dataSegment, + PartialSegmentMetadataCacheEntry metadata, + Set selected + ) throws SegmentLoadingException + { + final List> pending = new ArrayList<>(); + Throwable firstFailure = null; + try { + for (String bundleName : metadata.bundlesInMountOrder(selected)) { + // Skip only when the bundle is BOTH rule-held AND fully downloaded (every container's files present on disk). + if (metadata.isBundleRuleHeld(bundleName) && metadata.isBundleFullyDownloaded(bundleName)) { + continue; + } + pending.add(virtualStorageLoadingThreadPool.getExecutorService().submit(() -> { + metadata.ensureBundleResidentForRule(bundleName); + return null; + })); + } + } + catch (RejectedExecutionException e) { + // Pool shutting down or queue saturated. Any already-submitted futures may still run; track the rejection as + // firstFailure and fall through to the await/cancel + clearRule path so state is always cleaned up before we + // throw. + firstFailure = e; + } + + for (Future f : pending) { + if (firstFailure != null) { + // Cancel remaining futures on any failure so we don't wait for tasks whose result we no longer intend to + // commit. cancel(false) — NOT cancel(true) — because a running task is mid-NIO/FileChannel read via + // mapper.ensureBundleDownloaded, and Thread.interrupt() on an in-flight NIO op raises + // ClosedByInterruptException that closes the mapper's shared underlying FD, breaking still-mounted bundles + // for other queries reading the same segment. Not-yet-started tasks are marked CANCELLED; running tasks + // continue on their own timeline, observe ruleSelectedBundleNames == {} after clearRule, and release + // their transient bundleAcquirer holds without acquiring rule-holds. + f.cancel(false); + // Still drain the future so an ExecutionException from a task that completed-with-failure BEFORE we + // canceled is logged instead of silently swallowed. + try { + f.get(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + catch (ExecutionException e) { + final Throwable cause = e.getCause() != null ? e.getCause() : e; + log.warn( + cause, + "Eager download of a rule-selected bundle for segment[%s] failed after the primary failure", + dataSegment.getId() + ); + } + catch (CancellationException e) { + // task never started; expected on the cancel path + } + continue; + } + try { + f.get(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + firstFailure = e; + // Signal cancel WITHOUT interrupt — see cancel(false) comment above; interrupting mid-NIO closes the + // shared mapper FD. + f.cancel(false); + } + catch (ExecutionException e) { + firstFailure = e.getCause() != null ? e.getCause() : e; + } + catch (CancellationException e) { + // Defensive: shouldn't reach here on the first-failure branch (we only cancel after firstFailure is set), + // but if it does, treat it uniformly. + firstFailure = e; + } + } + + if (firstFailure != null) { + // Any late-completing pool task that still succeeds after we've cleared the rule will call registerBundle → + // observing ruleSelectedBundleNames == {} and skipping the rule-hold acquire. + metadata.clearRule(); + throw new SegmentLoadingException( + firstFailure, + "Failed eager download of rule-selected bundles for segment[%s]; cleared partial-load rule", + dataSegment.getId() + ); + } + } + + /** + * Materialize the segment's wrapped load spec to a {@link PartialLoadSpec}. + */ + private PartialLoadSpec materializePartialLoadSpec(DataSegment dataSegment) throws SegmentLoadingException + { + final LoadSpec materializedLoadSpec; + try { + materializedLoadSpec = jsonMapper.convertValue(dataSegment.getLoadSpec(), LoadSpec.class); + } + catch (Exception e) { + throw new SegmentLoadingException( + e, + "Failed to materialize partial load spec for segment[%s]", + dataSegment.getId() + ); + } + if (!(materializedLoadSpec instanceof PartialLoadSpec wrapper)) { + throw DruidException.defensive( + "Segment[%s] load spec was detected as partial but materialized to non-partial type[%s]", + dataSegment.getId(), + materializedLoadSpec.getClass().getSimpleName() + ); + } + return wrapper; + } + + /** + * Open a range reader for the segment's deep storage via the wrapper's {@link PartialLoadSpec#openRangeReader} + * (which delegates to the inner load spec), wrapping any {@link IOException} as a {@link SegmentLoadingException}. + * Returns {@code null} if the backend doesn't support range reads. + */ + @Nullable + private SegmentRangeReader openPartialRangeReader(DataSegment dataSegment, PartialLoadSpec wrapper) + throws SegmentLoadingException + { + try { + return wrapper.openRangeReader(); + } + catch (IOException e) { + throw new SegmentLoadingException(e, "Failed to open range reader for segment[%s]", dataSegment.getId()); + } + } + + /** + * The fingerprint of the currently-applied partial-load rule for {@code segmentId}, or {@code null} if no rule is + * currently applied (or the segment has no partial metadata entry (e.g. it was eager-loaded as a complete cache + * entry). Test-only. Delegates to {@link PartialSegmentMetadataCacheEntry#getRuleFingerprint} on the segment's + * cache entry. + */ + @VisibleForTesting + @Nullable + String getRuleFingerprintForSegment(SegmentId segmentId) + { + final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(segmentId); + for (StorageLocation location : locations) { + final CacheEntry entry = location.getCacheEntry(id); + if (entry instanceof PartialSegmentMetadataCacheEntry partial) { + return partial.getRuleFingerprint(); + } + } + return null; + } + @Nullable private AcquireSegmentAction acquireExistingSegment(SegmentCacheEntryIdentifier identifier) { @@ -927,6 +1296,15 @@ public void load(final DataSegment dataSegment) throws SegmentLoadingException "load() should not be called when virtualStorageIsEphemeral is true" ); } + // Partial-load-rule routing: a wrapped load spec carrying a fingerprint + per-spec selection (cluster groups, + // projections, ...) means the coordinator wants this segment loaded with rule holds pinning the selected + // bundles + metadata. Install the holds via loadPartial; other segments (no wrapper, or partials disabled) + // take the existing weak/no-op path below. + if (config.isVirtualStoragePartialDownloadsEnabled() + && PartialLoadSpec.detectPartialLoadSpec(dataSegment.getLoadSpec())) { + loadPartial(dataSegment); + return; + } // virtual storage doesn't do anything with loading immediately, but check to see if the segment is already cached // and if so, clear out the onUnmount action final ReferenceCountingLock lock = lock(dataSegment); @@ -999,6 +1377,17 @@ public void bootstrap( dataSegment.getId() ); } + // If the persisted DataSegment's loadSpec is a partial-load-rule wrapper, reapply the rule so the + // segment continues to be protected from eviction across the restart. + // + // Strict failure handling: reapplyRuleFromInfoFile throws on eager-download failure (and clears the + // rule state before throwing). The exception propagates up through SegmentManager.loadSegmentOnBootstrap, + // which calls cacheManager.drop and rethrows, so SegmentCacheBootstrapper marks the segment as failed + // and skips its announcement. + if (config.isVirtualStoragePartialDownloadsEnabled() + && PartialLoadSpec.detectPartialLoadSpec(dataSegment.getLoadSpec())) { + reapplyRuleFromInfoFile(dataSegment, partial); + } } else { throw DruidException.defensive( "Unexpected cache entry type[%s] for segment[%s] during bootstrap", @@ -1066,15 +1455,64 @@ public File getSegmentFiles(final DataSegment segment) @Override public void drop(final DataSegment segment) { - final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(segment.getId()); - for (StorageLocation location : locations) { - final CacheEntry entry = location.getCacheEntry(id); - if (entry != null) { - location.release(entry); + final ReferenceCountingLock lock = lock(segment); + synchronized (lock) { + try { + + final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(segment.getId()); + for (StorageLocation location : locations) { + final CacheEntry entry = location.getCacheEntry(id); + if (entry instanceof PartialSegmentMetadataCacheEntry partial) { + // if the segment's cache entry is a partial metadata entry with an applied rule, clear it. Reclaim of + // on-disk state is left to eviction. + partial.clearRule(); + } + if (entry != null) { + // static entries are removed from both cache and disk immediately (no-op for weak held entries) + location.release(entry); + } + } + } + finally { + unlock(segment, lock); } } } + /** + * 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 + * metadata header, calls {@link PartialSegmentMetadataCacheEntry#applyRule}, then drives eager downloads for any + * selected bundle that wasn't restored from disk (via {@link #awaitEagerDownloadsOrClearRule}). On failure the + * exception marks the segment as failed → doesn't announce it → the coordinator's next sync re-issues load. + */ + private void reapplyRuleFromInfoFile(DataSegment dataSegment, PartialSegmentMetadataCacheEntry partial) + throws SegmentLoadingException + { + // evict any stale non-partial cache entry at this segment id before touching the partial state. + // Bootstrap ordering today produces a partial entry for the passed-in `partial`, so this is expected + // to be a no-op except during a config switch. + evictStaleNonPartialWeakEntry(dataSegment.getId()); + + final PartialLoadSpec wrapper = materializePartialLoadSpec(dataSegment); + final PartialSegmentFileMapperV10 mapper = partial.getFileMapper(); + if (mapper == null) { + throw DruidException.defensive( + "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()) + ); + partial.applyRule(wrapper.getFingerprint(), selected); + awaitEagerDownloadsOrClearRule(dataSegment, partial, selected); + } + @Override public void shutdownBootstrap() { 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 460a2fa9d2c6..49fc135a9a8e 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 @@ -540,12 +540,11 @@ public void removeUnheldWeakEntry(CacheEntryIdentifier id) id, (cacheEntryIdentifier, weakCacheEntry) -> { if (weakCacheEntry.isHeld()) { - // a holder is responsible for cleanup when it releases; leave the entry in place return weakCacheEntry; } final boolean isMounted = weakCacheEntry.cacheEntry.isMounted(); unlinkWeakEntry(weakCacheEntry); - weakCacheEntry.unmount(); // terminate the phaser; fires cacheEntry.unmount() (idempotent) + weakCacheEntry.unmount(); if (isMounted) { weakStats.getAndUpdate(s -> s.evict(weakCacheEntry.cacheEntry.getSize())); } 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 f09ca7ba1be1..5beeb750b4e9 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 @@ -46,6 +46,7 @@ import java.io.IOException; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; @@ -219,6 +220,142 @@ void testOnUnmountHookRunsAfterStorageLocationCleanup() throws IOException ); } + @Test + void testOnUnmountHookRunsOnReleaseBeforeMount() + { + // if a caller sets the onUnmount hook before mount() and then releases the reservation on a mount-failure path + // (or without ever calling mount), unmount() must still fire the hook — otherwise external cleanup + // (e.g. info-file deletion) would leak. + final PartialSegmentMetadataCacheEntry entry = newEntry(ESTIMATE); + final AtomicReference hookFired = new AtomicReference<>(false); + entry.setOnUnmount(() -> hookFired.set(true)); + + entry.unmount(); + Assertions.assertTrue(hookFired.get(), "onUnmount hook must run even when the entry was never mounted"); + + // Second unmount is a no-op — the hook must not double-fire. + hookFired.set(false); + entry.unmount(); + Assertions.assertFalse(hookFired.get(), "onUnmount hook must fire exactly once across repeated unmount() calls"); + } + + @Test + void testBundlesInMountOrderReturnsRootsInInputOrderWhenNoBase() + { + // With no __base bundle in the mapped segment (entry not mounted), inferBundleDependencies returns [] for every + // input, so the walker returns roots in their original iteration order. + final PartialSegmentMetadataCacheEntry entry = newEntry(ESTIMATE); + Assertions.assertEquals( + List.of("a", "b", "c"), + entry.bundlesInMountOrder(List.of("a", "b", "c")) + ); + } + + @Test + void testBundlesInMountOrderExpandsWithBaseWhenPresent() throws IOException + { + // A segment carrying __base: dependents' expansion places __base first, then the dependent itself. + final PartialSegmentMetadataCacheEntry entry = mountedEntryOver( + buildSegmentWithBundles(Projections.BASE_TABLE_PROJECTION_NAME, "some_projection") + ); + Assertions.assertEquals( + List.of(Projections.BASE_TABLE_PROJECTION_NAME, "some_projection"), + entry.bundlesInMountOrder(List.of("some_projection")) + ); + } + + @Test + void testBundlesInMountOrderDedupsBaseAcrossMultipleDependents() throws IOException + { + // When multiple dependents share a common dependency, mount order emits it exactly once at the front. + final PartialSegmentMetadataCacheEntry entry = mountedEntryOver( + buildSegmentWithBundles(Projections.BASE_TABLE_PROJECTION_NAME, "projection_a", "projection_b") + ); + Assertions.assertEquals( + List.of(Projections.BASE_TABLE_PROJECTION_NAME, "projection_a", "projection_b"), + entry.bundlesInMountOrder(List.of("projection_a", "projection_b")) + ); + } + + @Test + void testRuleStateEmptyByDefault() + { + final PartialSegmentMetadataCacheEntry entry = newEntry(ESTIMATE); + Assertions.assertFalse(entry.isRuleHeld()); + Assertions.assertNull(entry.getRuleFingerprint()); + Assertions.assertEquals(Set.of(), entry.getRuleSelectedBundleNames()); + } + + @Test + void testApplyRuleBeforeMountThrows() + { + final PartialSegmentMetadataCacheEntry entry = newEntry(ESTIMATE); + Assertions.assertThrows( + DruidException.class, + () -> entry.applyRule("fp", Set.of("bundle")) + ); + } + + @Test + void testApplyRuleSetsFingerprintAndSelection() throws IOException + { + final PartialSegmentMetadataCacheEntry entry = mountedWeakEntryOver( + buildSegmentWithBundles(Projections.BASE_TABLE_PROJECTION_NAME, "projection_a") + ); + entry.applyRule("fp1", Set.of("projection_a")); + Assertions.assertTrue(entry.isRuleHeld()); + Assertions.assertEquals("fp1", entry.getRuleFingerprint()); + Assertions.assertEquals(Set.of("projection_a"), entry.getRuleSelectedBundleNames()); + } + + @Test + void testApplyRuleSameArgsIsIdempotent() throws IOException + { + final PartialSegmentMetadataCacheEntry entry = mountedWeakEntryOver( + buildSegmentWithBundles(Projections.BASE_TABLE_PROJECTION_NAME, "projection_a") + ); + entry.applyRule("fp1", Set.of("projection_a")); + entry.applyRule("fp1", Set.of("projection_a")); + Assertions.assertEquals("fp1", entry.getRuleFingerprint()); + Assertions.assertEquals(Set.of("projection_a"), entry.getRuleSelectedBundleNames()); + } + + @Test + void testApplyRuleSwapUpdatesFingerprintAndSelection() throws IOException + { + final PartialSegmentMetadataCacheEntry entry = mountedWeakEntryOver( + buildSegmentWithBundles(Projections.BASE_TABLE_PROJECTION_NAME, "projection_a", "projection_b") + ); + entry.applyRule("fp1", Set.of("projection_a")); + entry.applyRule("fp2", Set.of("projection_b")); + Assertions.assertEquals("fp2", entry.getRuleFingerprint()); + Assertions.assertEquals(Set.of("projection_b"), entry.getRuleSelectedBundleNames()); + } + + @Test + void testClearRuleReleasesState() throws IOException + { + final PartialSegmentMetadataCacheEntry entry = mountedWeakEntryOver( + buildSegmentWithBundles(Projections.BASE_TABLE_PROJECTION_NAME, "projection_a") + ); + entry.applyRule("fp1", Set.of("projection_a")); + Assertions.assertTrue(entry.isRuleHeld()); + entry.clearRule(); + Assertions.assertFalse(entry.isRuleHeld()); + Assertions.assertNull(entry.getRuleFingerprint()); + Assertions.assertEquals(Set.of(), entry.getRuleSelectedBundleNames()); + } + + @Test + void testClearRuleOnNoRuleIsNoop() throws IOException + { + final PartialSegmentMetadataCacheEntry entry = mountedWeakEntryOver( + buildSegmentWithBundles(Projections.BASE_TABLE_PROJECTION_NAME) + ); + entry.clearRule(); + Assertions.assertFalse(entry.isRuleHeld()); + } + @Test void testConstructorRejectsNonPositiveEstimate() { @@ -385,27 +522,27 @@ void testAcquireReferenceBeforeMountReturnsEmpty() } @Test - void testInferParentBundlesForBaseReturnsEmpty() + void testInferBundleDependenciesForBaseReturnsEmpty() { final PartialSegmentMetadataCacheEntry entry = newEntry(ESTIMATE); Assertions.assertEquals( List.of(), - entry.inferParentBundles(Projections.BASE_TABLE_PROJECTION_NAME) + entry.inferBundleDependencies(Projections.BASE_TABLE_PROJECTION_NAME) ); } @Test - void testInferParentBundlesForRootReturnsEmpty() + void testInferBundleDependenciesForRootReturnsEmpty() { final PartialSegmentMetadataCacheEntry entry = newEntry(ESTIMATE); Assertions.assertEquals( List.of(), - entry.inferParentBundles(SegmentFileBuilder.ROOT_BUNDLE_NAME) + entry.inferBundleDependencies(SegmentFileBuilder.ROOT_BUNDLE_NAME) ); } @Test - void testInferParentBundlesDependsOnBaseWhenBaseBundlePresent() throws IOException + void testInferBundleDependenciesIncludesBaseWhenBaseBundlePresent() throws IOException { // A segment that carries a __base bundle (the non-clustered base+projection shape): every non-base/root bundle // depends on it. Asserted uniformly for an aggregate-projection bundle and for a cluster-group bundle name (the @@ -414,20 +551,20 @@ void testInferParentBundlesDependsOnBaseWhenBaseBundlePresent() throws IOExcepti buildSegmentWithBundles(Projections.BASE_TABLE_PROJECTION_NAME, "some_projection") ); for (String dependent : List.of("some_projection", Projections.getClusterGroupBundleName(List.of(0, 1)))) { - final List parents = entry.inferParentBundles(dependent); - Assertions.assertEquals(1, parents.size(), "expected a __base parent for bundle[" + dependent + "]"); - Assertions.assertEquals(SEGMENT_ID, parents.getFirst().segmentId()); - Assertions.assertEquals(Projections.BASE_TABLE_PROJECTION_NAME, parents.getFirst().bundleName()); + final List deps = entry.inferBundleDependencies(dependent); + Assertions.assertEquals(1, deps.size(), "expected a __base dependency for bundle[" + dependent + "]"); + Assertions.assertEquals(SEGMENT_ID, deps.getFirst().segmentId()); + Assertions.assertEquals(Projections.BASE_TABLE_PROJECTION_NAME, deps.getFirst().bundleName()); } } @Test - void testInferParentBundlesEmptyWhenSegmentHasNoBaseBundle() throws IOException + void testInferBundleDependenciesEmptyWhenSegmentHasNoBaseBundle() throws IOException { // A clustered + aggregate-projection segment with no shared columns has no __base bundle: the base data lives in // per-group __base$ bundles and the aggregate projection is self-contained. So neither a cluster group nor - // the aggregate projection has a parent to depend on. (Pre-shared-columns; the old "aggregate always depends on - // __base" rule would have wrongly tried to mount a nonexistent __base for the projection bundle.) + // the aggregate projection has a dependency. (Pre-shared-columns; the old "aggregate always depends on __base" + // rule would have wrongly tried to mount a nonexistent __base for the projection bundle.) final PartialSegmentMetadataCacheEntry entry = mountedEntryOver( buildSegmentWithBundles( Projections.getClusterGroupBundleName(List.of(0)), @@ -437,9 +574,9 @@ void testInferParentBundlesEmptyWhenSegmentHasNoBaseBundle() throws IOException ); Assertions.assertEquals( List.of(), - entry.inferParentBundles(Projections.getClusterGroupBundleName(List.of(0))) + entry.inferBundleDependencies(Projections.getClusterGroupBundleName(List.of(0))) ); - Assertions.assertEquals(List.of(), entry.inferParentBundles("some_projection")); + Assertions.assertEquals(List.of(), entry.inferBundleDependencies("some_projection")); } private PartialSegmentMetadataCacheEntry newEntry(long estimate) @@ -472,8 +609,8 @@ private File buildTestSegment(int numFiles) throws IOException /** * Build a V10 segment whose containers are tagged with exactly the given bundle names (one column file per bundle), - * so {@link PartialSegmentMetadataCacheEntry#inferParentBundles} can be exercised against a known bundle set without - * a full ingestion. Returns the deep-storage directory containing the V10 file. + * so {@link PartialSegmentMetadataCacheEntry#inferBundleDependencies} can be exercised against a known bundle set + * without a full ingestion. Returns the deep-storage directory containing the V10 file. */ private File buildSegmentWithBundles(String... bundleNames) throws IOException { @@ -493,7 +630,7 @@ private File buildSegmentWithBundles(String... bundleNames) throws IOException /** * Reserve and mount a fresh metadata entry over the segment in {@code deepStorageDir}, into a per-call cache - * directory. The mounted entry's file mapper is what {@code inferParentBundles} probes for the base-bundle existence. + * directory. The mounted entry's file mapper is what {@code inferBundleDependencies} probes for base-bundle presence. */ private PartialSegmentMetadataCacheEntry mountedEntryOver(File deepStorageDir) throws IOException { @@ -515,4 +652,28 @@ private PartialSegmentMetadataCacheEntry mountedEntryOver(File deepStorageDir) t return entry; } + /** + * Variant of {@link #mountedEntryOver} that uses {@link StorageLocation#reserveWeak} 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. + */ + private PartialSegmentMetadataCacheEntry mountedWeakEntryOver(File deepStorageDir) throws IOException + { + final File cache = new File(tempDir, "cache_" + (fixtureSeq++)); + FileUtils.mkdirp(cache); + final StorageLocation location = new StorageLocation(cache, ESTIMATE * 4, null); + final PartialSegmentMetadataCacheEntry entry = new PartialSegmentMetadataCacheEntry( + SEGMENT_ID, + cache, + IndexIO.V10_FILE_NAME, + List.of(), + new DirectoryBackedRangeReader(deepStorageDir), + JSON_MAPPER, + null, + ESTIMATE + ); + Assertions.assertTrue(location.reserveWeak(entry)); + entry.mount(location); + return entry; + } } 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 01862eb2b9a0..7ca9d71cc78b 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 @@ -492,9 +492,9 @@ void testPartialAcquireClusteredWithProjectionMountsProjectionBundleWithoutBase( Assertions.assertEquals(CLUSTERED_SEGMENT_ID, segment.getId()); // group-by tenant + sum(x) matches the aggregate projection. Building this cursor drives the 'proj' bundle to - // mount through the real acquire path. inferParentBundles must return no parent for it (the clustered segment - // has no __base bundle); the old "aggregate always depends on __base" rule would have tried to mount a - // nonexistent __base here and failed. + // mount through the real acquire path. inferBundleDependencies must return no dep for it (the clustered + // segment has no __base bundle); the old "aggregate always depends on __base" rule would have tried to mount + // a nonexistent __base here and failed. final CursorBuildSpec aggSpec = CursorBuildSpec.builder() .setGroupingColumns(List.of("tenant")) .setAggregators(List.of(new LongSumAggregatorFactory("sum_x", "x"))) 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 new file mode 100644 index 000000000000..360fdd4cde9d --- /dev/null +++ b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java @@ -0,0 +1,711 @@ +/* + * 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.InjectableValues; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.NamedType; +import org.apache.druid.data.input.InputRow; +import org.apache.druid.data.input.ListBasedInputRow; +import org.apache.druid.data.input.impl.AggregateProjectionSpec; +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.guice.LocalDataStorageDruidModule; +import org.apache.druid.jackson.SegmentizerModule; +import org.apache.druid.java.util.common.DateTimes; +import org.apache.druid.java.util.common.FileUtils; +import org.apache.druid.java.util.common.Intervals; +import org.apache.druid.java.util.emitter.EmittingLogger; +import org.apache.druid.math.expr.ExprMacroTable; +import org.apache.druid.query.aggregation.CountAggregatorFactory; +import org.apache.druid.query.aggregation.LongSumAggregatorFactory; +import org.apache.druid.query.expression.TestExprMacroTable; +import org.apache.druid.segment.IndexBuilder; +import org.apache.druid.segment.IndexIO; +import org.apache.druid.segment.IndexSpec; +import org.apache.druid.segment.SegmentLazyLoadFailCallback; +import org.apache.druid.segment.TestHelper; +import org.apache.druid.segment.column.ColumnConfig; +import org.apache.druid.segment.column.ColumnType; +import org.apache.druid.segment.column.RowSignature; +import org.apache.druid.segment.data.CompressionStrategy; +import org.apache.druid.segment.file.PartialSegmentFileMapperV10; +import org.apache.druid.segment.incremental.IncrementalIndexSchema; +import org.apache.druid.segment.projections.Projections; +import org.apache.druid.segment.writeout.OffHeapMemorySegmentWriteOutMediumFactory; +import org.apache.druid.server.metrics.NoopServiceEmitter; +import org.apache.druid.timeline.DataSegment; +import org.apache.druid.timeline.SegmentId; +import org.apache.druid.timeline.partition.NoneShardSpec; +import org.joda.time.DateTime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +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.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Manager-level tests for the partial-load-rule {@link SegmentLocalCacheManager#load} path: a segment whose + * {@code loadSpec} is a {@link PartialLoadSpec} wrapper drives + * {@link PartialSegmentMetadataCacheEntry#applyRule} on the metadata entry, which installs a self-referential + * {@link StorageLocation.ReservationHold} to keep the metadata resident and holds on each selected bundle already + * registered. Selected bundles not yet registered get eager-downloads submitted to the loading pool; {@code load} + * blocks until every eager download completes so the announced fingerprint reflects "rule fully realized". On any + * eager-download failure the rule state is cleared and a {@link SegmentLoadingException} propagates so the + * coordinator's load queue retries. The cache entries themselves live in {@code weakCacheEntries} exactly as they + * would from an on-demand acquire; the rule-holds keep them from being SIEVE-evicted for as long as the coordinator + * considers the segment loaded. + *

+ * {@code drop} calls {@link PartialSegmentMetadataCacheEntry#clearRule} — entries become unheld weak (subject to any + * remaining query holds) and SIEVE reclaims them naturally. + */ +class SegmentLocalCacheManagerPartialRuleLoadTest +{ + private static final SegmentId SEGMENT_ID = SegmentId.of("test", Intervals.of("2025/2026"), "v1", 0); + private static final DateTime TIME = DateTimes.of("2025-01-01"); + private static final String AGG_BUNDLE = "dim1_metric1_sum"; + private static final String OTHER_AGG_BUNDLE = "dim1_count"; + private static final String FINGERPRINT = "v1:rule-bundle-test"; + + private static final RowSignature ROW_SIGNATURE = RowSignature.builder() + .add("dim1", ColumnType.STRING) + .add("metric1", ColumnType.LONG) + .build(); + + private static final List PROJECTIONS = Arrays.asList( + AggregateProjectionSpec.builder(AGG_BUNDLE) + .groupingColumns(new StringDimensionSchema("dim1")) + .aggregators( + new LongSumAggregatorFactory("_metric1_sum", "metric1"), + new CountAggregatorFactory("_count") + ) + .build(), + // A second aggregate projection so tests can verify that non-selected bundles are NOT pre-mounted while + // selected + parent bundles ARE. + AggregateProjectionSpec.builder(OTHER_AGG_BUNDLE) + .groupingColumns(new StringDimensionSchema("dim1")) + .aggregators(new CountAggregatorFactory("_count")) + .build() + ); + + private static final List ROWS = Arrays.asList( + new ListBasedInputRow(ROW_SIGNATURE, TIME, ROW_SIGNATURE.getColumnNames(), Arrays.asList("a", 1L)), + new ListBasedInputRow(ROW_SIGNATURE, TIME.plusMinutes(1), ROW_SIGNATURE.getColumnNames(), Arrays.asList("a", 2L)), + new ListBasedInputRow(ROW_SIGNATURE, TIME.plusMinutes(2), ROW_SIGNATURE.getColumnNames(), Arrays.asList("b", 3L)), + new ListBasedInputRow(ROW_SIGNATURE, TIME.plusMinutes(3), ROW_SIGNATURE.getColumnNames(), Arrays.asList("b", 4L)) + ); + + @TempDir + static File SHARED_TEMP_DIR; + + private static File DEEP_STORAGE_DIR; + + @TempDir + File perTestTempDir; + + private ObjectMapper jsonMapper; + private File cacheRoot; + private SegmentLocalCacheManager manager; + + @BeforeAll + static void buildSegment() + { + final File tmp = new File(SHARED_TEMP_DIR, "build_" + ThreadLocalRandom.current().nextInt()); + DEEP_STORAGE_DIR = IndexBuilder.create() + .useV10() + .tmpDir(tmp) + .segmentWriteOutMediumFactory(OffHeapMemorySegmentWriteOutMediumFactory.instance()) + .schema( + IncrementalIndexSchema.builder() + .withDimensionsSpec( + DimensionsSpec.builder() + .setDimensions( + List.of( + new StringDimensionSchema("dim1"), + new LongDimensionSchema("metric1") + ) + ) + .build() + ) + .withRollup(false) + .withMinTimestamp(TIME.getMillis()) + .withProjections(PROJECTIONS) + .build() + ) + .indexSpec(IndexSpec.builder() + .withMetadataCompression(CompressionStrategy.NONE) + .build()) + .rows(ROWS) + .buildMMappedIndexFile(); + EmittingLogger.registerEmitter(new NoopServiceEmitter()); + } + + @BeforeEach + void setup() throws IOException + { + jsonMapper = TestHelper.makeJsonMapper(); + jsonMapper.registerSubtypes(new NamedType(LocalLoadSpec.class, "local")); + jsonMapper.registerSubtypes(new NamedType(PartialProjectionLoadSpec.class, PartialProjectionLoadSpec.TYPE)); + jsonMapper.registerModule(new SegmentizerModule()); + jsonMapper.registerModules(new LocalDataStorageDruidModule().getJacksonModules()); + jsonMapper.setInjectableValues( + new InjectableValues.Std() + .addValue(LocalDataSegmentPuller.class, new LocalDataSegmentPuller()) + .addValue(IndexIO.class, TestHelper.getTestIndexIO(jsonMapper, ColumnConfig.DEFAULT)) + .addValue(ObjectMapper.class, jsonMapper) + .addValue(DataSegment.PruneSpecsHolder.class, DataSegment.PruneSpecsHolder.DEFAULT) + .addValue(ExprMacroTable.class, TestExprMacroTable.INSTANCE) + ); + + cacheRoot = new File(perTestTempDir, "cache_" + ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE)); + FileUtils.mkdirp(cacheRoot); + } + + @AfterEach + void tearDown() + { + if (manager != null) { + // Drop the segment to release rule-holds and unmount mmap'd bundle files before @TempDir tries to clean up + // the cache directory. Without this, on some platforms the temp-dir cleanup fails on still-mapped files. + try { + manager.drop(partialWrapperSegment(List.of(AGG_BUNDLE))); + } + catch (Throwable ignored) { + // best-effort — some tests never installed a rule on this segment + } + manager.shutdown(); + } + } + + @Test + void testLoadInstallsRuleHoldsOnMetadataAndSelectedBundle() throws Exception + { + manager = makeManager(true, true); + final StorageLocation location = manager.getLocations().get(0); + + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); + + // The metadata entry now carries the rule state. + final PartialSegmentMetadataCacheEntry metadata = weakReservedMetadata(location, SEGMENT_ID); + Assertions.assertTrue(metadata.isRuleHeld(), "rule must be applied to the metadata entry"); + Assertions.assertEquals(FINGERPRINT, metadata.getRuleFingerprint(), "fingerprint must be stored on the entry"); + // Metadata + selected bundle + its base parent are weak-reserved on the location; NOT static. + final SegmentCacheEntryIdentifier metaId = new SegmentCacheEntryIdentifier(SEGMENT_ID); + final PartialSegmentBundleCacheEntryIdentifier aggId = + new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, AGG_BUNDLE); + final PartialSegmentBundleCacheEntryIdentifier baseId = + new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, Projections.BASE_TABLE_PROJECTION_NAME); + Assertions.assertTrue(location.isWeakReserved(metaId), "metadata entry should be weak-reserved"); + Assertions.assertTrue(location.isWeakReserved(aggId), "selected bundle should be weak-reserved"); + Assertions.assertTrue(location.isWeakReserved(baseId), "base dependency should be weak-reserved"); + Assertions.assertFalse(location.isReserved(metaId), "metadata entry should NOT be in staticCacheEntries"); + Assertions.assertFalse(location.isReserved(aggId), "selected bundle should NOT be in staticCacheEntries"); + } + + @Test + void testLoadDoesNotReserveNonSelectedBundles() throws Exception + { + manager = makeManager(true, true); + final StorageLocation location = manager.getLocations().get(0); + + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); + + final PartialSegmentBundleCacheEntryIdentifier otherAggId = + new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, OTHER_AGG_BUNDLE); + Assertions.assertFalse( + location.isWeakReserved(otherAggId), + "non-selected bundle must not be reserved by loadPartial" + ); + Assertions.assertNull( + location.getCacheEntry(otherAggId), + "non-selected bundle must not be in the cache at all after load" + ); + } + + @Test + void testLoadEagerlyDownloadsSelectedBundleContainers() throws Exception + { + // ensureBundleDownloaded should be a no-op after load — the eager-download pool task already downloaded the + // bundle. + manager = makeManager(true, true); + final StorageLocation location = manager.getLocations().get(0); + + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); + + final PartialSegmentMetadataCacheEntry metadata = weakReservedMetadata(location, SEGMENT_ID); + final PartialSegmentFileMapperV10 mapper = metadata.getFileMapper(); + Assertions.assertNotNull(mapper, "metadata mount should produce a file mapper"); + final long downloadedBefore = mapper.getDownloadedBytes(); + mapper.ensureBundleDownloaded(AGG_BUNDLE); + Assertions.assertEquals( + downloadedBefore, + mapper.getDownloadedBytes(), + "ensureBundleDownloaded must be a no-op — the eager task already downloaded the bundle" + ); + } + + @Test + void testLoadEagerlyDownloadsBaseDependencyOfSelectedProjection() throws Exception + { + // when the rule selects a projection bundle, __base is an implicit mount-time dependency (parent hold + sparse + // allocation) but its container bytes are NOT downloaded by ensureBundleDownloaded(projection). + manager = makeManager(true, true); + final StorageLocation location = manager.getLocations().get(0); + + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); + + final PartialSegmentMetadataCacheEntry metadata = weakReservedMetadata(location, SEGMENT_ID); + final PartialSegmentFileMapperV10 mapper = metadata.getFileMapper(); + Assertions.assertNotNull(mapper, "metadata mount should produce a file mapper"); + Assertions.assertTrue( + mapper.isBundleFullyDownloaded(Projections.BASE_TABLE_PROJECTION_NAME), + "base dependency of the selected projection must be fully downloaded eagerly, not just sparse-mounted" + ); + } + + @Test + void testLoadWithoutVirtualStorageDoesNotInstallRuleHolds() throws Exception + { + // virtualStorage=false: partial-load rules aren't a concept here. load() takes the eager full-load path (via + // PartialLoadSpec.loadSegment → the materialized inner LocalLoadSpec), and no rule state is created. + manager = makeManager(false, false); + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); + Assertions.assertNull( + manager.getRuleFingerprintForSegment(SEGMENT_ID), + "no rule state on the eager path" + ); + } + + @Test + void testLoadWithPartialsDisabledIsNoop() throws Exception + { + manager = makeManager(true, false); + final StorageLocation location = manager.getLocations().get(0); + + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); + + Assertions.assertNull( + manager.getRuleFingerprintForSegment(SEGMENT_ID), + "with partial downloads disabled, loadPartial must not run" + ); + Assertions.assertFalse( + location.isWeakReserved(new SegmentCacheEntryIdentifier(SEGMENT_ID)), + "with partial downloads disabled, no reservation should be installed at load time" + ); + } + + @Test + void testSameFingerprintReIssueIsIdempotent() throws Exception + { + manager = makeManager(true, true); + final StorageLocation location = manager.getLocations().get(0); + + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); + final PartialSegmentMetadataCacheEntry firstMeta = weakReservedMetadata(location, SEGMENT_ID); + + // Second load with the same fingerprint must be idempotent — same metadata entry, unchanged rule state. + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); + final PartialSegmentMetadataCacheEntry secondMeta = weakReservedMetadata(location, SEGMENT_ID); + Assertions.assertSame(firstMeta, secondMeta, "same-fingerprint re-issue must not create a new metadata entry"); + Assertions.assertEquals(FINGERPRINT, secondMeta.getRuleFingerprint(), "fingerprint unchanged after re-issue"); + } + + @Test + void testRuleChangeSwapsHoldsWithoutDroppingSharedMetadata() throws Exception + { + // Same segment, different fingerprint: the rule's bundle selection changes but the underlying metadata entry is + // the same weak entry (its self-hold is never released during the swap; only bundle rule-holds diff). + manager = makeManager(true, true); + final StorageLocation location = manager.getLocations().get(0); + + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE), "v1:rule-original")); + final PartialSegmentMetadataCacheEntry meta = weakReservedMetadata(location, SEGMENT_ID); + Assertions.assertEquals("v1:rule-original", meta.getRuleFingerprint()); + + manager.load(partialWrapperSegment(List.of(OTHER_AGG_BUNDLE), "v2:rule-updated")); + + // Metadata entry is the SAME instance across the swap. + final PartialSegmentMetadataCacheEntry metaAfter = weakReservedMetadata(location, SEGMENT_ID); + Assertions.assertSame(meta, metaAfter, "rule change must reuse the underlying metadata entry"); + Assertions.assertEquals("v2:rule-updated", metaAfter.getRuleFingerprint(), "fingerprint must swap"); + // New rule's bundle is rule-held. + Assertions.assertTrue( + location.isWeakReserved(new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, OTHER_AGG_BUNDLE)), + "new rule's bundle should be reserved" + ); + Assertions.assertFalse(metaAfter.isBundleRuleHeld(AGG_BUNDLE)); + } + + @Test + void testRuleSwapRewritesInfoFileOnDisk() throws Exception + { + // findOrReservePartial returns the existing entry on a rule swap, make sure we rewrite the + // info file. + manager = makeManager(true, true); + + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE), "v1:rule-original")); + manager.load(partialWrapperSegment(List.of(OTHER_AGG_BUNDLE), "v2:rule-updated")); + + final File infoDir = new File(cacheRoot, "info_dir"); + final File infoFile = new File(infoDir, SEGMENT_ID.toString()); + Assertions.assertTrue(infoFile.exists(), "info file must exist after loadPartial"); + final DataSegment onDisk = jsonMapper.readValue(infoFile, DataSegment.class); + Assertions.assertEquals( + "v2:rule-updated", + onDisk.getLoadSpec().get("fingerprint"), + "info file on disk must carry the latest rule's fingerprint after a swap, not the prior rule's" + ); + Assertions.assertEquals( + List.of(OTHER_AGG_BUNDLE), + onDisk.getLoadSpec().get("projections"), + "info file on disk must carry the latest rule's projection selection" + ); + } + + @Test + void testDropClearsRule() throws Exception + { + manager = makeManager(true, true); + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); + Assertions.assertNotNull(manager.getRuleFingerprintForSegment(SEGMENT_ID)); + + manager.drop(partialWrapperSegment(List.of(AGG_BUNDLE))); + + Assertions.assertNull( + manager.getRuleFingerprintForSegment(SEGMENT_ID), + "drop must clear the rule state on the metadata entry" + ); + } + + @Test + void testRangeReaderNullDropsPriorRule() throws Exception + { + // Prior rule installed. Second load with a wrapper whose delegate can't produce a range reader releases the rule + // via clearRule so subsequent queries fall through to weak-full-load semantics. + manager = makeManager(true, true); + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE), "v1:rule-original")); + Assertions.assertNotNull(manager.getRuleFingerprintForSegment(SEGMENT_ID)); + + manager.load(partialWrapperSegmentWithNullRangeReader(List.of(AGG_BUNDLE), "v2:rule-updated")); + + Assertions.assertNull( + manager.getRuleFingerprintForSegment(SEGMENT_ID), + "null range reader must clear the prior rule" + ); + } + + @Test + void testRangeReaderNullNoopWhenNoPriorRule() throws Exception + { + // No prior rule, no range reader → no rule installed, but also no failure. Segment simply doesn't get a rule; + // a later query would drive weak-full-load via the on-demand path. + manager = makeManager(true, true); + manager.load(partialWrapperSegmentWithNullRangeReader(List.of(AGG_BUNDLE), "v1:rule-noop")); + Assertions.assertNull(manager.getRuleFingerprintForSegment(SEGMENT_ID)); + } + + @Test + void testLoadFailureLeavesNoRuleApplied() throws Exception + { + // Wrapper referring to a non-existent projection — wrapper.getSelectedBundleNames throws before applyRule fires. + // In the v2 design there is no rollback-nuke of the partial dir on failure; the transient hold releases and the + // now-unheld weak metadata entry becomes SIEVE-eligible naturally (info-file cleanup fires via the entry's + // onUnmount hook when SIEVE eventually reclaims it). The important correctness invariant is: no stranded rule + // state, no partial rule fingerprint applied. + manager = makeManager(true, true); + final DataSegment segment = partialWrapperSegment(List.of("does_not_exist")); + + final Throwable thrown = Assertions.assertThrows( + Throwable.class, + () -> manager.load(segment) + ); + Assertions.assertTrue( + thrown.getMessage() != null && thrown.getMessage().contains("does not contain projection[does_not_exist]") + || thrown.getCause() != null + && thrown.getCause().getMessage().contains("does not contain projection[does_not_exist]"), + "unexpected throwable: " + thrown + ); + Assertions.assertNull( + manager.getRuleFingerprintForSegment(SEGMENT_ID), + "failed load must not leave a rule applied on the metadata entry" + ); + } + + @Test + void testBootstrapReinstallsRuleHoldsFromPersistedInfoFile() throws Exception + { + // on historical restart, getCachedSegments+bootstrap must reapply the rule using the persisted DataSegment's + // loadSpec (the wire-form PartialLoadSpec is stored in the info file), so a rule-pinned segment is protected from + // eviction as soon as bootstrap completes + manager = makeManager(true, true); + final DataSegment segment = partialWrapperSegment(List.of(AGG_BUNDLE)); + + manager.load(segment); + Assertions.assertEquals(FINGERPRINT, manager.getRuleFingerprintForSegment(SEGMENT_ID)); + manager.shutdown(); + manager = null; + + // Fresh manager over the same cacheRoot — simulates a restart. + final SegmentLocalCacheManager restarted = makeManager(true, true); + try { + final List cached = restarted.getCachedSegments(); + Assertions.assertTrue( + cached.stream().anyMatch(s -> s.getId().equals(SEGMENT_ID)), + "restarted historical must rediscover the segment via its info file" + ); + + restarted.bootstrap(segment, SegmentLazyLoadFailCallback.NOOP); + + // Fingerprint round-trips through the info file → wrapper → applyRule. + Assertions.assertEquals( + FINGERPRINT, + restarted.getRuleFingerprintForSegment(SEGMENT_ID), + "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). + final StorageLocation loc = restarted.getLocations().get(0); + Assertions.assertTrue( + loc.isWeakReserved(new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, AGG_BUNDLE)), + "selected bundle must be reserved after bootstrap reapply" + ); + Assertions.assertTrue( + loc.isWeakReserved( + new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, Projections.BASE_TABLE_PROJECTION_NAME) + ), + "base dependency must be reserved after bootstrap reapply" + ); + } + finally { + restarted.shutdown(); + } + } + + @Test + void testReserveMkdirpFailureFallsThroughToNextLocation() throws Exception + { + // Setup: writable first (so info_dir defaults there), read-only second, dummy reservation on writable so + // LeastBytesUsed picks the read-only location first. mkdirp on read-only fails → release + continue to writable. + final File writable = new File(perTestTempDir, "loc_rw_" + ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE)); + final File readOnly = new File(perTestTempDir, "loc_ro_" + ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE)); + FileUtils.mkdirp(writable); + FileUtils.mkdirp(readOnly); + manager = makeManagerAtLocations(true, true, List.of(writable, readOnly)); + + // 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)) + ); + Assertions.assertTrue(readOnly.setReadOnly(), "test setup must be able to make readOnly location read-only"); + try { + manager.load(partialWrapperSegment(List.of(AGG_BUNDLE))); + + Assertions.assertFalse( + new File(readOnly, SEGMENT_ID.toString()).exists(), + "read-only location must NOT receive the segment" + ); + Assertions.assertTrue( + new File(writable, SEGMENT_ID.toString()).isDirectory(), + "load must fall through to the writable location" + ); + Assertions.assertNotNull(manager.getRuleFingerprintForSegment(SEGMENT_ID)); + } + finally { + Assertions.assertTrue(readOnly.setWritable(true), "test teardown must restore write permission"); + } + } + + @Test + void testLoadReusesExistingWeakEntryHeldByConcurrentQuery() throws Exception + { + // A concurrent query is holding a weak metadata entry from an on-demand acquire when the coordinator issues a + // partial-load rule for the same segment. loadPartial must reuse that entry — addWeakReservationHoldIfExists + // adds another party to the same phaser rather than installing a fresh entry alongside. + manager = makeManager(true, true); + final StorageLocation location = manager.getLocations().get(0); + final SegmentCacheEntryIdentifier id = new SegmentCacheEntryIdentifier(SEGMENT_ID); + + final DataSegment segment = partialWrapperSegment(List.of(AGG_BUNDLE)); + final File partialDir = new File(cacheRoot, SEGMENT_ID.toString()); + FileUtils.mkdirp(partialDir); + final PartialSegmentMetadataCacheEntry preExisting = new PartialSegmentMetadataCacheEntry( + SEGMENT_ID, + partialDir, + IndexIO.V10_FILE_NAME, + List.of(), + new DirectoryBackedRangeReader(DEEP_STORAGE_DIR), + jsonMapper, + null, + 16L * 1024L * 1024L + ); + final StorageLocation.ReservationHold queryHold = + location.addWeakReservationHold(id, () -> preExisting); + Assertions.assertNotNull(queryHold, "precondition: on-demand-style weak reserve must succeed"); + try { + Assertions.assertTrue(location.isWeakReserved(id)); + + manager.load(segment); + + // The rule hold reuses the pre-existing entry — same instance across the load. + final PartialSegmentMetadataCacheEntry ruleHeld = weakReservedMetadata(location, SEGMENT_ID); + Assertions.assertSame( + preExisting, + ruleHeld, + "loadPartial must add its hold to the pre-existing weak entry, not install a fresh one" + ); + Assertions.assertNotNull(manager.getRuleFingerprintForSegment(SEGMENT_ID)); + } + finally { + queryHold.close(); + } + } + + private SegmentLocalCacheManager makeManager(boolean virtualStorage, boolean partialDownloadsEnabled) + { + return makeManagerAtLocations(virtualStorage, partialDownloadsEnabled, List.of(cacheRoot)); + } + + private SegmentLocalCacheManager makeManagerAtLocations( + boolean virtualStorage, + boolean partialDownloadsEnabled, + List locationRoots + ) + { + final List locConfigs = locationRoots.stream() + .map(root -> new StorageLocationConfig(root, 1024L * 1024L * 1024L, null)) + .toList(); + final SegmentLoaderConfig loaderConfig = new SegmentLoaderConfig() + .setLocations(locConfigs) + .setVirtualStorage(virtualStorage) + .setVirtualStoragePartialDownloadsEnabled(partialDownloadsEnabled); + final List storageLocations = loaderConfig.toStorageLocations(); + return new SegmentLocalCacheManager( + storageLocations, + loaderConfig, + StorageLoadingThreadPool.createFromConfig(loaderConfig), + new LeastBytesUsedStorageLocationSelectorStrategy(storageLocations), + TestHelper.getTestIndexIO(jsonMapper, ColumnConfig.DEFAULT), + jsonMapper + ); + } + + private DataSegment partialWrapperSegment(List selectedProjections) + { + return partialWrapperSegment(selectedProjections, FINGERPRINT); + } + + private DataSegment partialWrapperSegment(List selectedProjections, String fingerprint) + { + final Map delegate = Map.of( + "type", "local", + "path", DEEP_STORAGE_DIR.getAbsolutePath() + ); + final Map wrapperWire = + PartialProjectionLoadSpec.wireForm(delegate, selectedProjections, fingerprint); + return DataSegment.builder(SEGMENT_ID) + .shardSpec(NoneShardSpec.instance()) + .loadSpec(wrapperWire) + .size(0) + .build(); + } + + /** + * A wrapper whose inner LoadSpec resolves via {@code LocalLoadSpec} against a directory that holds no V10 file, so + * {@code openRangeReader()} returns {@code null}. Simulates the "backend doesn't support range reads" case. + */ + private DataSegment partialWrapperSegmentWithNullRangeReader(List selectedProjections, String fingerprint) + throws IOException + { + final File noRangeReaderDir = new File( + perTestTempDir, + "no_range_reader_" + fingerprint.replace(':', '_').replace('.', '_') + ); + FileUtils.mkdirp(noRangeReaderDir); + final Map delegate = Map.of( + "type", "local", + "path", noRangeReaderDir.getAbsolutePath() + ); + final Map wrapperWire = + PartialProjectionLoadSpec.wireForm(delegate, selectedProjections, fingerprint); + return DataSegment.builder(SEGMENT_ID) + .shardSpec(NoneShardSpec.instance()) + .loadSpec(wrapperWire) + .size(0) + .build(); + } + + /** + * Post-load lookup of the mounted partial metadata entry, verifying it's in weak — not static. loadPartial's + * install is synchronous by the time load() returns; the entry is either fully mounted or the call threw. + */ + private static PartialSegmentMetadataCacheEntry weakReservedMetadata(StorageLocation location, SegmentId segmentId) + { + final CacheEntry entry = location.getCacheEntry(new SegmentCacheEntryIdentifier(segmentId)); + Assertions.assertInstanceOf(PartialSegmentMetadataCacheEntry.class, entry); + final PartialSegmentMetadataCacheEntry partial = (PartialSegmentMetadataCacheEntry) entry; + Assertions.assertTrue(partial.isMounted(), "metadata entry for " + segmentId + " should be mounted after load()"); + return partial; + } + + private static CacheEntry stubCacheEntry(CacheEntryIdentifier id, long size) + { + return new CacheEntry() + { + @Override + public CacheEntryIdentifier getId() + { + return id; + } + + @Override + public long getSize() + { + return size; + } + + @Override + public boolean isMounted() + { + return false; + } + + @Override + public void mount(StorageLocation location) + { + } + + @Override + public void unmount() + { + } + }; + } +}