This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new dd2c3cdfac9 Add size-based table retention (#19770)
dd2c3cdfac9 is described below
commit dd2c3cdfac918e18a20ef20cfd2a55eb868d2054
Author: Xiang Fu <[email protected]>
AuthorDate: Sun Oct 11 01:52:49 2026 -0700
Add size-based table retention (#19770)
Add optional segmentsConfig.retentionSize (for example, "100G") for
retention by compressed live-segment bytes, enforced independently for OFFLINE
and REALTIME tables.
Evict oldest eligible segments while preserving the newest OFFLINE segment,
REALTIME recovery segments, and the stopping boundary around all retained
lineage entries. Publish a table-scoped blocked gauge when the configured cap
cannot be met.
Validation: 78 targeted local tests and scoped quality checks passed. All
CI checks passed before merge.
---
.../pinot/common/metrics/ControllerGauge.java | 5 +
.../helix/core/retention/RetentionManager.java | 245 +++++-
.../helix/core/retention/RetentionManagerTest.java | 943 +++++++++++++++++++++
.../segment/local/utils/TableConfigUtils.java | 12 +
.../segment/local/utils/TableConfigUtilsTest.java | 45 +
.../SegmentsValidationAndRetentionConfig.java | 69 +-
.../spi/utils/builder/TableConfigBuilder.java | 9 +
.../spi/utils/builder/TableConfigBuilderTest.java | 41 +
8 files changed, 1340 insertions(+), 29 deletions(-)
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/ControllerGauge.java
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/ControllerGauge.java
index ae1df54e8fc..75d629dd3d7 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/ControllerGauge.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/ControllerGauge.java
@@ -208,6 +208,11 @@ public enum ControllerGauge implements
AbstractMetrics.Gauge {
// Metric used to track errors during the periodic table retention management
RETENTION_MANAGER_ERROR("retentionManagerError", false),
+ /// Status of the latest completed table size retention pass: 1 when a
configured policy cannot run safely, deletion
+ /// fails, or protected data keeps the table above its cap; 0 when the
configured policy succeeds within the cap.
+ /// Published once when each configured pass completes; absent when size
retention is disabled or unsupported.
+ SIZE_RETENTION_BLOCKED("sizeRetentionBlocked", false),
+
// Gauge to reflect whether pauseless is enabled or not
PAUSELESS_CONSUMPTION_ENABLED("pauselessConsumptionEnabled", false),
diff --git
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/retention/RetentionManager.java
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/retention/RetentionManager.java
index b5ba82d3133..e2a65a92098 100644
---
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/retention/RetentionManager.java
+++
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/retention/RetentionManager.java
@@ -23,6 +23,8 @@ import com.google.common.base.Preconditions;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -35,6 +37,7 @@ import org.apache.helix.model.IdealState;
import org.apache.helix.store.zk.ZkHelixPropertyStore;
import org.apache.helix.zookeeper.datamodel.ZNRecord;
import org.apache.logging.log4j.util.Strings;
+import org.apache.pinot.common.lineage.LineageEntry;
import org.apache.pinot.common.lineage.SegmentLineage;
import org.apache.pinot.common.lineage.SegmentLineageAccessHelper;
import org.apache.pinot.common.lineage.SegmentLineageUtils;
@@ -47,6 +50,7 @@ import org.apache.pinot.common.utils.URIUtils;
import org.apache.pinot.controller.ControllerConf;
import org.apache.pinot.controller.LeadControllerManager;
import org.apache.pinot.controller.helix.core.PinotHelixResourceManager;
+import org.apache.pinot.controller.helix.core.PinotResourceManagerResponse;
import
org.apache.pinot.controller.helix.core.periodictask.ControllerPeriodicTask;
import
org.apache.pinot.controller.helix.core.retention.strategy.RetentionStrategy;
import
org.apache.pinot.controller.helix.core.retention.strategy.TimeRetentionStrategy;
@@ -63,6 +67,7 @@ import org.apache.pinot.spi.filesystem.PinotFS;
import org.apache.pinot.spi.filesystem.PinotFSFactory;
import org.apache.pinot.spi.utils.CommonConstants;
import org.apache.pinot.spi.utils.CommonConstants.Segment.Realtime.Status;
+import org.apache.pinot.spi.utils.DataSizeUtils;
import org.apache.pinot.spi.utils.IngestionConfigUtils;
import org.apache.pinot.spi.utils.builder.TableNameBuilder;
import org.apache.pinot.spi.utils.retry.RetryPolicies;
@@ -122,6 +127,198 @@ public class RetentionManager extends
ControllerPeriodicTask<Void> {
// Delete segments based on segment lineage and clean up segment lineage
metadata.
manageSegmentLineageCleanupForTable(tableConfig);
+
+ // Re-read active segments after time and lineage cleanup so
already-deleted bytes do not cause extra eviction.
+ manageSizeBasedRetention(tableConfig);
+ }
+
+ /// Enforces the optional compressed-segment size limit on live data
independently of time retention. Replaced
+ /// segments are excluded from accounting. Eviction proceeds oldest first
and stops before the first active segment
+ /// listed on either side of any lineage entry, including replacement copies
excluded from accounting. The newest
+ /// OFFLINE segment and realtime recovery segments are preserved. Each typed
table uses its own size limit and
+ /// eviction order without consulting the OFFLINE time boundary.
+ /// Unknown sizes or missing metadata disable this pass; consuming segments
are excluded from accounting and eviction.
+ @VisibleForTesting
+ protected void manageSizeBasedRetention(TableConfig tableConfig) {
+ String tableNameWithType = tableConfig.getTableName();
+ String retentionSize =
tableConfig.getValidationConfig().getRetentionSize();
+ if (retentionSize == null || (tableConfig.getTableType() ==
TableType.OFFLINE
+ &&
!"APPEND".equalsIgnoreCase(IngestionConfigUtils.getBatchSegmentIngestionType(tableConfig))))
{
+ _controllerMetrics.removeTableGauge(tableNameWithType,
ControllerGauge.SIZE_RETENTION_BLOCKED);
+ return;
+ }
+ boolean blocked = true;
+ try {
+ blocked = !enforceSizeBasedRetention(tableConfig, retentionSize);
+ } finally {
+ // Publish only the completed pass's result, including a blocked result
if an exception interrupted it.
+ _controllerMetrics.setOrUpdateTableGauge(tableNameWithType,
ControllerGauge.SIZE_RETENTION_BLOCKED,
+ blocked ? 1 : 0);
+ }
+ }
+
+ /// Returns whether the table is within its size cap after this pass; unsafe
skips and failed evictions return false.
+ private boolean enforceSizeBasedRetention(TableConfig tableConfig, String
retentionSize) {
+ String tableNameWithType = tableConfig.getTableName();
+ long retentionSizeBytes;
+ try {
+ retentionSizeBytes = DataSizeUtils.toBytes(retentionSize);
+ Preconditions.checkArgument(retentionSizeBytes > 0, "Retention size must
be positive");
+ } catch (Exception e) {
+ LOGGER.warn("Invalid retention size: {} for table: {}, skip size
retention", retentionSize, tableNameWithType);
+ return false;
+ }
+
+ // Read IdealState before lineage, matching the existing live-segment
filtering order. A replacement starting
+ // after this snapshot cannot add both its source and destination to the
bytes counted by this pass.
+ Set<String> activeSegments = new
HashSet<>(_pinotHelixResourceManager.getSegmentsFor(tableNameWithType, false));
+ SegmentLineage segmentLineage =
+
SegmentLineageAccessHelper.getSegmentLineage(_pinotHelixResourceManager.getPropertyStore(),
tableNameWithType);
+ Map<String, LineageEntry> lineageEntries = segmentLineage == null ?
Map.of() : segmentLineage.getLineageEntries();
+ Set<String> lineageOwnedSegments = new HashSet<>();
+ // Size retention protects both sides of every retained entry, regardless
of state, age, or the deletion flag.
+ lineageEntries.values().forEach(entry -> {
+ lineageOwnedSegments.addAll(entry.getSegmentsFrom());
+ lineageOwnedSegments.addAll(entry.getSegmentsTo());
+ });
+ lineageOwnedSegments.retainAll(activeSegments);
+ // Replaced segments awaiting lineage cleanup are no longer queryable and
must not trigger live-data eviction.
+ SegmentLineageUtils.filterSegmentsBasedOnLineageInPlace(activeSegments,
segmentLineage);
+ if (activeSegments.isEmpty()) {
+ return true;
+ }
+ List<SegmentZKMetadata> metadataList =
_pinotHelixResourceManager.getSegmentsZKMetadata(tableNameWithType);
+ Map<String, SegmentZKMetadata> lineageOwnedMetadata = new HashMap<>();
+ Set<String> segmentsWithMetadata = new HashSet<>();
+ List<SegmentZKMetadata> completedSegments = new ArrayList<>();
+ Map<String, Long> completedSegmentSizes = new HashMap<>();
+ boolean realtime = tableConfig.getTableType() == TableType.REALTIME;
+ long retainedBytes = 0L;
+ for (SegmentZKMetadata metadata : metadataList) {
+ String segmentName = metadata.getSegmentName();
+ if (lineageOwnedSegments.contains(segmentName)) {
+ lineageOwnedMetadata.put(segmentName, metadata);
+ }
+ if (!activeSegments.contains(segmentName)) {
+ continue;
+ }
+ segmentsWithMetadata.add(segmentName);
+ if (realtime && !metadata.getStatus().isCompleted()) {
+ continue;
+ }
+ long size = getSizeRetentionSegmentSize(tableNameWithType, metadata);
+ if (size < 0) {
+ LOGGER.warn("Unknown size for segment: {} in table: {}, skip size
retention", segmentName, tableNameWithType);
+ return false;
+ }
+ // Plain addition assumes realistic table sizes; corrupt totals above
Long.MAX_VALUE may wrap and report healthy.
+ retainedBytes += size;
+ completedSegments.add(metadata);
+ completedSegmentSizes.put(segmentName, size);
+ }
+ if (!segmentsWithMetadata.containsAll(activeSegments)) {
+ LOGGER.warn("Missing active segment metadata for table: {}, skip size
retention", tableNameWithType);
+ return false;
+ }
+ if (retainedBytes <= retentionSizeBytes) {
+ return true;
+ }
+
+ Comparator<SegmentZKMetadata> evictionOrder =
Comparator.comparingLong(RetentionManager::getSizeRetentionTimestamp)
+ .thenComparing(SegmentZKMetadata::getSegmentName);
+ // Shadow copies do not count toward the cap, but still stop eviction at
their position in the ordering.
+ // Without metadata or a timestamp for any active lineage-owned segment,
its position cannot be determined safely.
+ if (lineageOwnedMetadata.size() != lineageOwnedSegments.size() ||
lineageOwnedMetadata.values().stream()
+ .anyMatch(metadata -> getSizeRetentionTimestamp(metadata) < 0)) {
+ LOGGER.warn("Cannot order lineage-owned segments for table: {}, skip
size retention", tableNameWithType);
+ return false;
+ }
+ SegmentZKMetadata lineageBarrier =
lineageOwnedMetadata.values().stream().min(evictionOrder).orElse(null);
+ completedSegments.removeIf(metadata -> getSizeRetentionTimestamp(metadata)
< 0);
+ if (realtime) {
+ Set<String> lastCompletedSegments =
+ new
HashSet<>(_pinotHelixResourceManager.getLastLLCCompletedSegments(metadataList));
+ completedSegments.removeIf(metadata ->
lastCompletedSegments.contains(metadata.getSegmentName()));
+ } else {
+ // A size typo must not remove the entire OFFLINE table, even if its
newest segment exceeds the cap alone.
+ completedSegments.stream().max(evictionOrder)
+ .ifPresent(newest -> completedSegments.removeIf(metadata -> metadata
== newest));
+ }
+ completedSegments.sort(evictionOrder);
+
+ List<String> segmentsToDelete = new ArrayList<>();
+ long projectedRetainedBytes = retainedBytes;
+ for (SegmentZKMetadata metadata : completedSegments) {
+ if (projectedRetainedBytes <= retentionSizeBytes) {
+ break;
+ }
+ // Stop at the lineage boundary even when that segment is excluded by
another protection or from accounting.
+ // This snapshot rule applies regardless of the lineage-exclusive
deletion flag.
+ if (lineageBarrier != null && evictionOrder.compare(metadata,
lineageBarrier) >= 0) {
+ LOGGER.info("Stop size retention for table: {} at lineage-owned
segment: {}", tableNameWithType,
+ lineageBarrier.getSegmentName());
+ break;
+ }
+ long size = completedSegmentSizes.get(metadata.getSegmentName());
+ if (size == 0) {
+ continue;
+ }
+ segmentsToDelete.add(metadata.getSegmentName());
+ projectedRetainedBytes -= size;
+ }
+ if (!segmentsToDelete.isEmpty()) {
+ PinotResourceManagerResponse response;
+ // Coordinate with local lineage writers and reject stale accounting or
a changed stopping point, even when
+ // lineage-exclusive deletion is disabled. Other controllers can still
update lineage after this fresh read.
+ synchronized
(_pinotHelixResourceManager.getLineageUpdaterLock(tableNameWithType)) {
+ SegmentLineage currentLineage =
+
SegmentLineageAccessHelper.getSegmentLineage(_pinotHelixResourceManager.getPropertyStore(),
+ tableNameWithType);
+ Map<String, LineageEntry> currentEntries =
+ currentLineage == null ? Map.of() :
currentLineage.getLineageEntries();
+ if (!lineageEntries.equals(currentEntries)) {
+ LOGGER.warn("Segment lineage changed for table: {}, skip size
retention deletion", tableNameWithType);
+ return false;
+ }
+ response =
_pinotHelixResourceManager.deleteSegments(tableNameWithType, segmentsToDelete);
+ }
+ if (!response.isSuccessful()) {
+ LOGGER.warn("Size retention deletion failed for table: {}, bytes
retained: {}, cap: {}, response: {}",
+ tableNameWithType, retainedBytes, retentionSize, response);
+ return false;
+ }
+ retainedBytes = projectedRetainedBytes;
+ LOGGER.info("Deleted {} oldest segments from table: {} for retention
size: {}", segmentsToDelete.size(),
+ tableNameWithType, retentionSize);
+ }
+ if (retainedBytes > retentionSizeBytes) {
+ LOGGER.warn("Table: {} remains above retention size: {} due to
protected, undated, or lineage-owned segments",
+ tableNameWithType, retentionSize);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ protected void nonLeaderCleanup(List<String> tableNamesWithType) {
+ for (String tableNameWithType : tableNamesWithType) {
+ _controllerMetrics.removeTableGauge(tableNameWithType,
ControllerGauge.SIZE_RETENTION_BLOCKED);
+ }
+ }
+
+ /// Returns the compressed bytes used by size retention, or a negative value
when the size is unknown.
+ /// Subclasses can supply authoritative sizes for logical segments that
aggregate multiple physical segments.
+ protected long getSizeRetentionSegmentSize(String tableNameWithType,
SegmentZKMetadata metadata) {
+ return metadata.getSizeInBytes();
+ }
+
+ private static long getSizeRetentionTimestamp(SegmentZKMetadata metadata) {
+ long endTime = metadata.getEndTimeMs();
+ if (endTime >= 0) {
+ return endTime;
+ }
+ long creationTime = metadata.getCreationTime();
+ return creationTime >= 0 ? creationTime : metadata.getPushTime();
}
@Override
@@ -239,29 +436,8 @@ public class RetentionManager extends
ControllerPeriodicTask<Void> {
LOGGER.info("Managing retention for hybrid table: {}",
realtimeTableConfig.getTableName());
List<String> segmentsToDelete = new ArrayList<>();
String realtimeTableName = realtimeTableConfig.getTableName();
- String rawTableName =
TableNameBuilder.extractRawTableName(realtimeTableName);
- String offlineTableName =
TableNameBuilder.OFFLINE.tableNameWithType(rawTableName);
try {
- ZkHelixPropertyStore<ZNRecord> propertyStore =
_pinotHelixResourceManager.getPropertyStore();
- Schema schema = ZKMetadataProvider.getTableSchema(propertyStore,
offlineTableName);
- Preconditions.checkState(schema != null, "Failed to get schema for
table: " + offlineTableName);
- String timeColumn = null;
- SegmentsValidationAndRetentionConfig validationConfig =
offlineTableConfig.getValidationConfig();
- if (validationConfig != null) {
- timeColumn = validationConfig.getTimeColumnName();
- }
- Preconditions.checkState(StringUtils.isNotEmpty(timeColumn),
- "TimeColumn is null or empty for table: " + offlineTableName);
- DateTimeFieldSpec dateTimeSpec = schema.getSpecForTimeColumn(timeColumn);
- Preconditions.checkState(dateTimeSpec != null, String.format(
- "Failed to get DateTimeFieldSpec for time column: %s of table: %s",
timeColumn, offlineTableName));
- DateTimeFormatSpec timeFormatSpec = dateTimeSpec.getFormatSpec();
- TimeBoundaryInfo timeBoundaryInfo =
_brokerServiceHelper.getTimeBoundaryInfo(offlineTableConfig);
- Preconditions.checkState(timeBoundaryInfo != null,
- "Failed to get time boundary info for table: " + offlineTableName);
- long timeBoundaryMs =
timeFormatSpec.fromFormatToMillis(timeBoundaryInfo.getTimeValue());
- Preconditions.checkState(timeBoundaryMs > 0,
- "Failed to determine a valid time boundary for table: " +
offlineTableName);
+ long timeBoundaryMs = getHybridTimeBoundaryMs(offlineTableConfig);
// Iterate over all COMPLETED segments of the REALTIME table and check
if they are eligible for deletion.
for (SegmentZKMetadata segmentZKMetadata :
_pinotHelixResourceManager.getSegmentsZKMetadata(realtimeTableName)) {
@@ -287,6 +463,31 @@ public class RetentionManager extends
ControllerPeriodicTask<Void> {
}
}
+ private long getHybridTimeBoundaryMs(TableConfig offlineTableConfig) {
+ String offlineTableName = offlineTableConfig.getTableName();
+ ZkHelixPropertyStore<ZNRecord> propertyStore =
_pinotHelixResourceManager.getPropertyStore();
+ Schema schema = ZKMetadataProvider.getTableSchema(propertyStore,
offlineTableName);
+ Preconditions.checkState(schema != null, "Failed to get schema for table:
" + offlineTableName);
+ String timeColumn = null;
+ SegmentsValidationAndRetentionConfig validationConfig =
offlineTableConfig.getValidationConfig();
+ if (validationConfig != null) {
+ timeColumn = validationConfig.getTimeColumnName();
+ }
+ Preconditions.checkState(StringUtils.isNotEmpty(timeColumn),
+ "TimeColumn is null or empty for table: " + offlineTableName);
+ DateTimeFieldSpec dateTimeSpec = schema.getSpecForTimeColumn(timeColumn);
+ Preconditions.checkState(dateTimeSpec != null, String.format(
+ "Failed to get DateTimeFieldSpec for time column: %s of table: %s",
timeColumn, offlineTableName));
+ DateTimeFormatSpec timeFormatSpec = dateTimeSpec.getFormatSpec();
+ TimeBoundaryInfo timeBoundaryInfo =
_brokerServiceHelper.getTimeBoundaryInfo(offlineTableConfig);
+ Preconditions.checkState(timeBoundaryInfo != null,
+ "Failed to get time boundary info for table: " + offlineTableName);
+ long timeBoundaryMs =
timeFormatSpec.fromFormatToMillis(timeBoundaryInfo.getTimeValue());
+ Preconditions.checkState(timeBoundaryMs > 0,
+ "Failed to determine a valid time boundary for table: " +
offlineTableName);
+ return timeBoundaryMs;
+ }
+
private boolean shouldDeleteInProgressLLCSegment(String segmentName,
IdealState idealState,
SegmentZKMetadata segmentZKMetadata) {
if (idealState == null) {
diff --git
a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/retention/RetentionManagerTest.java
b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/retention/RetentionManagerTest.java
index c0023bda878..a442d598587 100644
---
a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/retention/RetentionManagerTest.java
+++
b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/retention/RetentionManagerTest.java
@@ -30,6 +30,8 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import org.apache.commons.io.FileUtils;
import org.apache.helix.AccessOption;
import org.apache.helix.HelixAdmin;
@@ -37,17 +39,24 @@ import org.apache.helix.model.IdealState;
import org.apache.helix.model.InstanceConfig;
import org.apache.helix.store.zk.ZkHelixPropertyStore;
import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.common.lineage.LineageEntry;
+import org.apache.pinot.common.lineage.LineageEntryState;
+import org.apache.pinot.common.lineage.SegmentLineage;
import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.common.metrics.ControllerGauge;
import org.apache.pinot.common.metrics.ControllerMetrics;
+import org.apache.pinot.common.metrics.MetricValueUtils;
import org.apache.pinot.common.utils.LLCSegmentName;
import org.apache.pinot.controller.ControllerConf;
import org.apache.pinot.controller.LeadControllerManager;
import org.apache.pinot.controller.helix.core.PinotHelixResourceManager;
+import org.apache.pinot.controller.helix.core.PinotResourceManagerResponse;
import org.apache.pinot.controller.helix.core.PinotTableIdealStateBuilder;
import org.apache.pinot.controller.helix.core.SegmentDeletionManager;
import org.apache.pinot.controller.util.BrokerServiceHelper;
import org.apache.pinot.controller.util.CompletionServiceHelper;
import org.apache.pinot.core.realtime.impl.fakestream.FakeStreamConfigUtils;
+import org.apache.pinot.core.routing.timeboundary.TimeBoundaryInfo;
import org.apache.pinot.spi.config.table.SegmentsValidationAndRetentionConfig;
import org.apache.pinot.spi.config.table.TableConfig;
import org.apache.pinot.spi.config.table.TableType;
@@ -59,8 +68,10 @@ import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.metrics.PinotMetricUtils;
import org.apache.pinot.spi.stream.LongMsgOffset;
import org.apache.pinot.spi.utils.CommonConstants;
+import org.apache.pinot.spi.utils.CommonConstants.Segment.Realtime.Status;
import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+import org.apache.zookeeper.data.Stat;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
@@ -71,6 +82,7 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertThrows;
import static org.testng.Assert.assertTrue;
@@ -915,4 +927,935 @@ public class RetentionManagerTest {
// already asserts the correct segments via TestNG assertions
verify(pinotHelixResourceManager,
times(1)).deleteSegments(eq(OFFLINE_TABLE_NAME), anyList());
}
+
+ @Test
+ public void testSizeOnlyRetentionDeletesOldestSegmentsUntilWithinLimit() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "100B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("newest", 60, now),
+ createSizeRetentionSegment("oldest", 60, now - 2),
+ createSizeRetentionSegment("middle", 60, now - 1));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+
+ // Exercise the complete table pass: a time-retention configuration is
deliberately absent.
+ retentionManager.processTable(OFFLINE_TABLE_NAME);
+
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest", "middle"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 0);
+ }
+
+ @Test
+ public void testSizeRetentionKeepsSegmentsAtOrBelowLimit() {
+ for (String limit : List.of("100B", "101B")) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, limit);
+ tableConfig.getValidationConfig().setReplication("3");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 40, now - 1),
+ createSizeRetentionSegment("newest", 60, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ // Stored archive sizes count once, regardless of table replication.
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 0);
+ }
+ }
+
+ @Test
+ public void
testSizeRetentionKeepsNewestOfflineSegmentWhenItAloneExceedsLimit() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "100B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("newest", 120, now),
+ createSizeRetentionSegment("oldest", 40, now - 1));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+
+ @Test
+ public void testSizeRetentionNeverDeletesOnlyOfflineSegment() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "1B");
+ List<SegmentZKMetadata> segments =
+ List.of(createSizeRetentionSegment("only", 100,
System.currentTimeMillis()));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+
+ @Test
+ public void testSizeRetentionOrderingWithCreationAndPushTimeFallbacks() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "20B");
+ long now = System.currentTimeMillis();
+ SegmentZKMetadata creationFallback =
createSizeRetentionSegment("creationFallback", 10, -1);
+ creationFallback.setCreationTime(now - 1);
+ SegmentZKMetadata pushFallback =
createSizeRetentionSegment("pushFallback", 10, -1);
+ pushFallback.setCreationTime(-1);
+ pushFallback.setPushTime(now - 4);
+ List<SegmentZKMetadata> segments = List.of(creationFallback,
+ createSizeRetentionSegment("b", 10, now - 3), pushFallback,
+ createSizeRetentionSegment("oldest", 10, now - 5),
createSizeRetentionSegment("a", 10, now - 3));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest", "pushFallback", "a"));
+ }
+
+ @Test
+ public void
testSizeRetentionProtectsIncompleteAndLastCompletedRealtimeSegments() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.REALTIME, "50B");
+ long now = System.currentTimeMillis();
+ SegmentZKMetadata oldest = createSizeRetentionSegment(
+ new LLCSegmentName(TEST_TABLE_NAME, 0, 4, now - 3).getSegmentName(),
20, now - 3);
+ oldest.setStatus(Status.DONE);
+ SegmentZKMetadata lastCompleted = createSizeRetentionSegment(
+ new LLCSegmentName(TEST_TABLE_NAME, 0, 5, now - 1).getSegmentName(),
40, now - 1);
+ lastCompleted.setStatus(Status.DONE);
+ SegmentZKMetadata consuming = createSizeRetentionSegment("consuming", -1,
now - 5);
+ consuming.setStatus(Status.IN_PROGRESS);
+ SegmentZKMetadata committing = createSizeRetentionSegment("committing",
999, now - 4);
+ committing.setStatus(Status.COMMITTING);
+ SegmentZKMetadata uploaded = createSizeRetentionSegment("uploaded", 60,
now - 2);
+ List<SegmentZKMetadata> segments = List.of(lastCompleted, committing,
uploaded, consuming, oldest);
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(REALTIME_TABLE_NAME,
List.of(oldest.getSegmentName(), "uploaded"));
+ }
+
+ @Test
+ public void testSizeRetentionSkipsTableWhenCompletedSegmentSizeIsUnknown() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "50B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 60, now - 2),
+ createSizeRetentionSegment("unknown", -1, now - 1),
createSizeRetentionSegment("newest", 60, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+
+ @Test
+ public void testSizeRetentionIgnoresMetadataOutsideIdealState() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "100B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 40, now - 2),
+ createSizeRetentionSegment("orphan", -1, now - 1),
createSizeRetentionSegment("newest", 40, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager);
+ when(resourceManager.getSegmentsFor(OFFLINE_TABLE_NAME,
false)).thenReturn(List.of("oldest", "newest"));
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ }
+
+ @Test
+ public void
testSizeRetentionStopsAtInProgressLineageEvenWhenExclusiveDeleteIsDisabled() {
+ for (boolean exclusiveDelete : List.of(true, false)) {
+ for (String retentionSize : List.of("50B", "160B")) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, retentionSize);
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("olderLive", 60, now - 3),
+ createSizeRetentionSegment("source", 80, now - 2),
+ createSizeRetentionSegment("destination", -1, now - 1),
+ createSizeRetentionSegment("newerLive", 20, now - 1),
+ createSizeRetentionSegment("newestLive", 60, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ ControllerConf conf = new ControllerConf();
+ conf.setProperty(ControllerConf.LINEAGE_EXCLUSIVE_DELETE_ENABLED,
exclusiveDelete);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics,
+ conf, mock(BrokerServiceHelper.class));
+ setupSizeRetentionLineage(resourceManager, OFFLINE_TABLE_NAME,
"source", "destination",
+ LineageEntryState.IN_PROGRESS);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ // Evict the older prefix, then stop at the source instead of jumping
over it to newer live data.
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("olderLive"));
+ verify(resourceManager,
never()).deleteSegmentsForLineageCleanup(anyString(), anyList());
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME,
retentionSize.equals("160B") ? 0 : 1);
+ }
+ }
+ }
+
+ @Test
+ public void
testSizeRetentionExcludesCompletedReplacedSegmentsFromAccounting() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "100B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("olderLive", 60, now - 3),
+ createSizeRetentionSegment("source", -1, now - 2),
+ createSizeRetentionSegment("destination", 40, now - 1),
createSizeRetentionSegment("newest", 40, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ SegmentLineage lineage = new SegmentLineage(OFFLINE_TABLE_NAME);
+ lineage.addLineageEntry("replacement",
+ new LineageEntry(List.of("source"), List.of("destination"),
LineageEntryState.COMPLETED, now));
+ when(resourceManager.getPropertyStore().get(anyString(), any(Stat.class),
eq(AccessOption.PERSISTENT)))
+ .thenReturn(lineage.toZNRecord());
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).getSegmentsFor(OFFLINE_TABLE_NAME, false);
+ verify(resourceManager, never()).getSegmentsFor(OFFLINE_TABLE_NAME, true);
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("olderLive"));
+ verify(resourceManager.getPropertyStore(), times(2))
+ .get(anyString(), any(Stat.class), eq(AccessOption.PERSISTENT));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 0);
+ }
+
+ @Test
+ public void testSizeRetentionSeesLineageStartedWhileReadingIdealState() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "50B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("olderLive", 60, now - 3),
+ createSizeRetentionSegment("source", 80, now - 2),
+ createSizeRetentionSegment("destination", -1, now - 1),
createSizeRetentionSegment("newerLive", 20, now - 1),
+ createSizeRetentionSegment("newestLive", 60, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ SegmentLineage lineage = new SegmentLineage(OFFLINE_TABLE_NAME);
+ lineage.addLineageEntry("replacement",
+ new LineageEntry(List.of("source"), List.of("destination"),
LineageEntryState.IN_PROGRESS, now));
+ AtomicReference<ZNRecord> lineageRecord = new AtomicReference<>();
+ ZkHelixPropertyStore<ZNRecord> propertyStore =
resourceManager.getPropertyStore();
+ when(propertyStore.get(anyString(), any(Stat.class),
eq(AccessOption.PERSISTENT)))
+ .thenAnswer(invocation -> lineageRecord.get());
+ when(resourceManager.getSegmentsFor(OFFLINE_TABLE_NAME,
false)).thenAnswer(invocation -> {
+ lineageRecord.set(lineage.toZNRecord());
+ return segments.stream().map(SegmentZKMetadata::getSegmentName).toList();
+ });
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ // The newer lineage snapshot sees replacement bytes exposed by the
just-read ideal state.
+ var readOrder = inOrder(resourceManager, propertyStore);
+ readOrder.verify(resourceManager).getSegmentsFor(OFFLINE_TABLE_NAME,
false);
+ readOrder.verify(propertyStore).get(anyString(), any(Stat.class),
eq(AccessOption.PERSISTENT));
+
readOrder.verify(resourceManager).getSegmentsZKMetadata(OFFLINE_TABLE_NAME);
+ readOrder.verify(propertyStore).get(anyString(), any(Stat.class),
eq(AccessOption.PERSISTENT));
+ readOrder.verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("olderLive"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+
+ @Test
+ public void testSizeRetentionStopsBeforeExcludedLineageShadowSegment() {
+ for (LineageEntryState state : LineageEntryState.values()) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "1B");
+ long now = System.currentTimeMillis();
+ String shadow = state == LineageEntryState.COMPLETED ? "source" :
"destination";
+ String live = state == LineageEntryState.COMPLETED ? "destination" :
"source";
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 40, now - 4),
+ createSizeRetentionSegment(shadow, -1, now - 3),
createSizeRetentionSegment("newerLive", 40, now - 2),
+ createSizeRetentionSegment(live, 80, now - 1),
createSizeRetentionSegment("newest", 40, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ setupSizeRetentionLineage(resourceManager, OFFLINE_TABLE_NAME, "source",
"destination", state);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ // Shadow bytes do not count, but their position still prevents deleting
newer queryable data.
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+ }
+
+ @Test
+ public void testSizeRetentionDoesNotEvictWhenOldestSegmentIsLineageOwned() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "1B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("source", 80, now - 2),
+ createSizeRetentionSegment("destination", -1, now - 1),
createSizeRetentionSegment("newerLive", 40, now - 1),
+ createSizeRetentionSegment("newest", 40, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ setupSizeRetentionLineage(resourceManager, OFFLINE_TABLE_NAME, "source",
"destination",
+ LineageEntryState.IN_PROGRESS);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+
+ @Test
+ public void testSizeRetentionCannotCrossUnlocatableLineageShadow() {
+ for (boolean missingMetadata : List.of(true, false)) {
+ for (String retentionSize : List.of("50B", "200B")) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, retentionSize);
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments = new
ArrayList<>(List.of(createSizeRetentionSegment("oldest", 40, now - 3),
+ createSizeRetentionSegment("newerLive", 40, now - 2),
+ createSizeRetentionSegment("destination", 40, now - 1),
createSizeRetentionSegment("newest", 40, now)));
+ if (!missingMetadata) {
+ SegmentZKMetadata undatedSource =
createSizeRetentionSegment("source", -1, -1);
+ undatedSource.setCreationTime(-1);
+ undatedSource.setPushTime(-1);
+ segments.add(undatedSource);
+ }
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ when(resourceManager.getSegmentsFor(OFFLINE_TABLE_NAME, false))
+ .thenReturn(List.of("oldest", "newerLive", "source",
"destination", "newest"));
+ setupSizeRetentionLineage(resourceManager, OFFLINE_TABLE_NAME,
"source", "destination",
+ LineageEntryState.COMPLETED);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ // Without the active shadow's timestamp the prefix cannot be located
safely.
+ // An already satisfied cap is healthy.
+ verify(resourceManager, never()).deleteSegments(anyString(),
anyList());
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME,
retentionSize.equals("200B") ? 0 : 1);
+ }
+ }
+ }
+
+ @Test
+ public void
testSizeRetentionLineageBarrierUsesTimestampFallbacksAndNameTieBreak() {
+ for (boolean usePushTime : List.of(true, false)) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "1B");
+ long now = System.currentTimeMillis();
+ SegmentZKMetadata shadow = createSizeRetentionSegment("b", -1, -1);
+ shadow.setCreationTime(usePushTime ? -1 : now);
+ shadow.setPushTime(now);
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("a", 20, now), shadow,
+ createSizeRetentionSegment("z", 20, now),
createSizeRetentionSegment("newest", 20, now + 1));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ setupSizeRetentionLineage(resourceManager, OFFLINE_TABLE_NAME, "b",
"absentDestination",
+ LineageEntryState.COMPLETED);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME, List.of("a"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+ }
+
+ @Test
+ public void testSizeRetentionIgnoresLineageWithoutActiveBlockedSegments() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "40B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 40, now - 2),
+ createSizeRetentionSegment("middle", 40, now - 1),
createSizeRetentionSegment("newest", 40, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ setupSizeRetentionLineage(resourceManager, OFFLINE_TABLE_NAME,
"absentSource", "absentDestination",
+ LineageEntryState.IN_PROGRESS);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest", "middle"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 0);
+ }
+
+ @Test
+ public void testSizeRetentionRevertedSourceCreatesBarrier() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "40B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 40, now - 3),
+ createSizeRetentionSegment("source", 40, now - 2),
createSizeRetentionSegment("destination", -1, now - 1),
+ createSizeRetentionSegment("newest", 40, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ setupSizeRetentionLineage(resourceManager, OFFLINE_TABLE_NAME, "source",
"destination", LineageEntryState.REVERTED);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+
+ @Test
+ public void
testSizeRetentionCompletedDestinationCreatesBarrierWithSourceAbsentOrNewer() {
+ for (boolean sourceAbsent : List.of(true, false)) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "1B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments = new
ArrayList<>(List.of(createSizeRetentionSegment("oldest", 40, now - 3),
+ createSizeRetentionSegment("destination", 40, now - 2),
createSizeRetentionSegment("newerLive", 40, now - 1),
+ createSizeRetentionSegment("newest", 40, now)));
+ if (!sourceAbsent) {
+ segments.add(createSizeRetentionSegment("source", -1, now + 1));
+ }
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ ControllerConf conf = new ControllerConf();
+ conf.setProperty(ControllerConf.LINEAGE_EXCLUSIVE_DELETE_ENABLED, false);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics,
+ conf, mock(BrokerServiceHelper.class));
+ setupSizeRetentionLineage(resourceManager, OFFLINE_TABLE_NAME, "source",
"destination",
+ LineageEntryState.COMPLETED, now - TimeUnit.DAYS.toMillis(400));
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+ }
+
+ @Test
+ public void
testSizeRetentionRejectsPrefixWhenLineageMembershipChangesWithExclusiveDeleteDisabled()
{
+ for (LineageEntryState state : LineageEntryState.values()) {
+ for (boolean selectedTarget : List.of(true, false)) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "60B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("lateMarker", 0, now - 3),
+ createSizeRetentionSegment("oldest", 60, now - 2),
createSizeRetentionSegment("middle", 60, now - 1),
+ createSizeRetentionSegment("newest", 60, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ ControllerConf conf = new ControllerConf();
+ conf.setProperty(ControllerConf.LINEAGE_EXCLUSIVE_DELETE_ENABLED,
false);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics,
+ conf, mock(BrokerServiceHelper.class));
+ SegmentLineage lineage = new SegmentLineage(OFFLINE_TABLE_NAME);
+ lineage.addLineageEntry("replacement", new
LineageEntry(List.of("absentSource"),
+ List.of(selectedTarget ? "oldest" : "lateMarker"), state, now));
+ when(resourceManager.getPropertyStore().get(anyString(),
any(Stat.class), eq(AccessOption.PERSISTENT)))
+ .thenReturn(null, lineage.toZNRecord());
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ // An earlier boundary change invalidates the batch even when no
selected target is a new lineage member.
+ verify(resourceManager, never()).deleteSegments(anyString(),
anyList());
+ verify(resourceManager.getPropertyStore(), times(2))
+ .get(anyString(), any(Stat.class), eq(AccessOption.PERSISTENT));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+ }
+ }
+
+ @Test
+ public void testSizeRetentionReadsFreshLineageAndDeletesUnderUpdaterLock() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "60B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 60, now - 1),
+ createSizeRetentionSegment("newest", 60, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ Object updaterLock =
resourceManager.getLineageUpdaterLock(OFFLINE_TABLE_NAME);
+ AtomicInteger lineageReads = new AtomicInteger();
+ when(resourceManager.getPropertyStore().get(anyString(), any(Stat.class),
eq(AccessOption.PERSISTENT)))
+ .thenAnswer(invocation -> {
+ if (lineageReads.incrementAndGet() == 2) {
+ assertTrue(Thread.holdsLock(updaterLock));
+ }
+ return null;
+ });
+ when(resourceManager.deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest"))).thenAnswer(invocation -> {
+ assertTrue(Thread.holdsLock(updaterLock));
+ return PinotResourceManagerResponse.SUCCESS;
+ });
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ assertEquals(lineageReads.get(), 2);
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 0);
+ }
+
+ @Test
+ public void
testSizeRetentionRejectsPrefixWhenLineageStateOrTimestampChanges() {
+ for (boolean stateChanges : List.of(true, false)) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "160B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 60, now - 3),
+ createSizeRetentionSegment("source", 80, now - 2),
createSizeRetentionSegment("destination", 40, now - 1),
+ createSizeRetentionSegment("newest", 60, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ SegmentLineage planningLineage = new SegmentLineage(OFFLINE_TABLE_NAME);
+ planningLineage.addLineageEntry("replacement",
+ new LineageEntry(List.of("source"), List.of("destination"),
LineageEntryState.IN_PROGRESS, now));
+ SegmentLineage latestLineage = new SegmentLineage(OFFLINE_TABLE_NAME);
+ latestLineage.addLineageEntry("replacement", new
LineageEntry(List.of("source"), List.of("destination"),
+ stateChanges ? LineageEntryState.COMPLETED :
LineageEntryState.IN_PROGRESS, stateChanges ? now : now + 1));
+ when(resourceManager.getPropertyStore().get(anyString(),
any(Stat.class), eq(AccessOption.PERSISTENT)))
+ .thenReturn(planningLineage.toZNRecord(),
latestLineage.toZNRecord());
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ // The members are unchanged, but a state transition changes which side
is queryable and how bytes are counted.
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+ }
+
+ @Test
+ public void
testSizeRetentionPublishesBlockedGaugeWhenFreshLineageReadThrows() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "60B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 60, now - 1),
+ createSizeRetentionSegment("newest", 60, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ when(resourceManager.getPropertyStore().get(anyString(), any(Stat.class),
eq(AccessOption.PERSISTENT)))
+ .thenReturn(null).thenThrow(new IllegalStateException("Fresh lineage
read failed"));
+
+ assertThrows(IllegalStateException.class, () ->
retentionManager.manageSizeBasedRetention(tableConfig));
+
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+
+ @Test
+ public void testSizeRetentionRecoveryProtectionCannotHideLineageBarrier() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.REALTIME, "1B");
+ long now = System.currentTimeMillis();
+ String sourceName = new LLCSegmentName(TEST_TABLE_NAME, 0, 5, now -
3).getSegmentName();
+ SegmentZKMetadata source = createSizeRetentionSegment(sourceName, 80, now
- 3);
+ source.setStatus(Status.DONE);
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 40, now - 4), source,
+ createSizeRetentionSegment("destination", -1, now - 2),
createSizeRetentionSegment("newerLive", 40, now - 1),
+ createSizeRetentionSegment("newest", 40, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ setupSizeRetentionLineage(resourceManager, REALTIME_TABLE_NAME,
sourceName, "destination",
+ LineageEntryState.IN_PROGRESS);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(REALTIME_TABLE_NAME,
List.of("oldest"));
+ verifySizeRetentionGauge(metrics, REALTIME_TABLE_NAME, 1);
+ }
+
+ @Test
+ public void testSizeRetentionForHybridStopsAtLineageBarrier() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.REALTIME, "1B");
+ long boundary = System.currentTimeMillis();
+ SegmentZKMetadata source = createSizeRetentionSegment("source", 80, -1);
+ source.setCreationTime(boundary - 2);
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 20, boundary - 3), source,
+ createSizeRetentionSegment("coveredNewer", 20, boundary - 1),
+ createSizeRetentionSegment("newest", 20, boundary + 1),
+ createSizeRetentionSegment("destination", -1, boundary + 2));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ BrokerServiceHelper brokerServiceHelper = mock(BrokerServiceHelper.class);
+ ControllerConf conf = new ControllerConf();
+ conf.setProperty(ControllerConf.ENABLE_HYBRID_TABLE_RETENTION_STRATEGY,
true);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics,
+ conf, brokerServiceHelper);
+ setupSizeRetentionOfflineCounterpart(resourceManager);
+ setupSizeRetentionLineage(resourceManager, REALTIME_TABLE_NAME, "source",
"destination",
+ LineageEntryState.IN_PROGRESS);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(REALTIME_TABLE_NAME,
List.of("oldest"));
+ verify(resourceManager, never()).getOfflineTableConfig(anyString());
+ verify(brokerServiceHelper,
never()).getTimeBoundaryInfo(any(TableConfig.class));
+ verifySizeRetentionGauge(metrics, REALTIME_TABLE_NAME, 1);
+ }
+
+ @Test
+ public void
testSizeRetentionKeepsNewestOfflineSegmentUsingFallbackAndNameTieBreak() {
+ for (boolean usePushTime : List.of(true, false)) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "1B");
+ long now = System.currentTimeMillis();
+ SegmentZKMetadata newest = createSizeRetentionSegment("z", 20, -1);
+ newest.setCreationTime(usePushTime ? -1 : now);
+ newest.setPushTime(now);
+ SegmentZKMetadata sameTimestamp = createSizeRetentionSegment("a", 20,
now);
+ List<SegmentZKMetadata> segments = List.of(newest, sameTimestamp,
+ createSizeRetentionSegment("oldest", 20, now - 1));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest", "a"));
+ }
+ }
+
+ @Test
+ public void
testSizeRetentionGaugeClearsForEmptyTableAndWhenLeadershipIsLost() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "100B");
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, List.of(), resourceManager, metrics);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 0);
+
+ retentionManager.nonLeaderCleanup(List.of(OFFLINE_TABLE_NAME));
+
+ verify(metrics).removeTableGauge(OFFLINE_TABLE_NAME,
ControllerGauge.SIZE_RETENTION_BLOCKED);
+ }
+
+ @Test
+ public void testSizeRetentionGaugeRecoversWhenUnknownSizeBecomesAvailable() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "100B");
+ long now = System.currentTimeMillis();
+ SegmentZKMetadata unknown = createSizeRetentionSegment("unknown", -1, now
- 1);
+ List<SegmentZKMetadata> segments = List.of(unknown,
createSizeRetentionSegment("newest", 40, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ unknown.setSizeInBytes(60);
+ clearInvocations(metrics);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 0);
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ }
+
+ @Test
+ public void testSizeRetentionGaugeRegistryLifecycle() {
+ for (boolean refreshTable : List.of(true, false)) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "100B");
+ IngestionConfig refreshConfig = new IngestionConfig();
+ refreshConfig.setBatchIngestionConfig(new BatchIngestionConfig(null,
"REFRESH", null));
+ tableConfig.setIngestionConfig(refreshTable ? refreshConfig : null);
+ tableConfig.getValidationConfig().setRetentionSize(refreshTable ? "100B"
: null);
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 60, now - 1),
+ createSizeRetentionSegment("newest", 60, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = spy(new
ControllerMetrics("sizeRetentionLifecycle.",
+ PinotMetricUtils.getPinotMetricsRegistry()));
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ try {
+ assertFalse(
+ MetricValueUtils.tableGaugeExists(metrics, OFFLINE_TABLE_NAME,
ControllerGauge.SIZE_RETENTION_BLOCKED));
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verifySizeRetentionGaugeRemoved(metrics);
+ verify(resourceManager, never()).deleteSegments(anyString(),
anyList());
+ clearInvocations(metrics);
+ tableConfig.setIngestionConfig(null);
+ tableConfig.getValidationConfig().setRetentionSize("200B");
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 0);
+ assertSizeRetentionGaugeValue(metrics, 0);
+ clearInvocations(metrics);
+ tableConfig.getValidationConfig().setRetentionSize("100B");
+ when(resourceManager.deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest")))
+ .thenReturn(PinotResourceManagerResponse.failure("Targets became
lineage-locked"),
+ PinotResourceManagerResponse.SUCCESS);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ assertSizeRetentionGaugeValue(metrics, 1);
+ clearInvocations(metrics);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager, times(2)).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest"));
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 0);
+ assertSizeRetentionGaugeValue(metrics, 0);
+ clearInvocations(metrics, resourceManager);
+ tableConfig.getValidationConfig().setRetentionSize("invalid");
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ assertSizeRetentionGaugeValue(metrics, 1);
+ clearInvocations(metrics);
+ tableConfig.setIngestionConfig(refreshTable ? refreshConfig : null);
+ tableConfig.getValidationConfig().setRetentionSize(refreshTable ?
"100B" : null);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verifySizeRetentionGaugeRemoved(metrics);
+ verify(resourceManager, never()).deleteSegments(anyString(),
anyList());
+ clearInvocations(metrics);
+ tableConfig.setIngestionConfig(null);
+ tableConfig.getValidationConfig().setRetentionSize("200B");
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 0);
+ assertSizeRetentionGaugeValue(metrics, 0);
+ clearInvocations(metrics);
+
+ retentionManager.nonLeaderCleanup(List.of(OFFLINE_TABLE_NAME));
+
+ verifySizeRetentionGaugeRemoved(metrics);
+ } finally {
+ metrics.removeTableGauge(OFFLINE_TABLE_NAME,
ControllerGauge.SIZE_RETENTION_BLOCKED);
+ }
+ }
+ }
+
+ @Test
+ public void
testSizeRetentionPublishesBlockedGaugeOnceWhenMetadataReadThrows() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "100B");
+ List<SegmentZKMetadata> segments =
+ List.of(createSizeRetentionSegment("only", 60,
System.currentTimeMillis()));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+
when(resourceManager.getSegmentsZKMetadata(OFFLINE_TABLE_NAME)).thenThrow(new
IllegalStateException("Read failed"));
+
+ assertThrows(IllegalStateException.class, () ->
retentionManager.manageSizeBasedRetention(tableConfig));
+
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ }
+
+ @Test
+ public void testSizeRetentionForHybridEvictsOldestWithoutOfflineCoverage() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.REALTIME, "20B");
+ long boundary = System.currentTimeMillis();
+ SegmentZKMetadata invalidEndTime =
createSizeRetentionSegment("invalidEndTime", 20, -1);
+ invalidEndTime.setCreationTime(boundary - 2);
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("before", 20, boundary - 1),
+ createSizeRetentionSegment("at", 20, boundary),
createSizeRetentionSegment("after", 20, boundary + 1),
+ createSizeRetentionSegment("newest", 20, boundary + 2),
invalidEndTime);
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ BrokerServiceHelper brokerServiceHelper = mock(BrokerServiceHelper.class);
+ when(brokerServiceHelper.getTimeBoundaryInfo(any(TableConfig.class)))
+ .thenReturn(new TimeBoundaryInfo("ms", Long.toString(boundary)));
+ ControllerConf conf = new ControllerConf();
+ conf.setProperty(ControllerConf.ENABLE_HYBRID_TABLE_RETENTION_STRATEGY,
true);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics,
+ conf, brokerServiceHelper);
+ setupSizeRetentionOfflineCounterpart(resourceManager);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(REALTIME_TABLE_NAME,
List.of("invalidEndTime", "before", "at", "after"));
+ verify(resourceManager, never()).getOfflineTableConfig(anyString());
+ verify(brokerServiceHelper,
never()).getTimeBoundaryInfo(any(TableConfig.class));
+ verifySizeRetentionGauge(metrics, REALTIME_TABLE_NAME, 0);
+ }
+
+ @Test
+ public void testSizeRetentionDoesNotConsultOfflineTimeBoundary() {
+ for (boolean hybridStrategy : List.of(true, false)) {
+ for (boolean offlineCounterpart : List.of(true, false)) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.REALTIME, "20B");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("oldest", 20, now - 1),
+ createSizeRetentionSegment("newest", 20, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ BrokerServiceHelper brokerServiceHelper =
mock(BrokerServiceHelper.class);
+ when(brokerServiceHelper.getTimeBoundaryInfo(any(TableConfig.class)))
+ .thenThrow(new IllegalStateException("Time boundary unavailable"));
+ ControllerConf conf = new ControllerConf();
+
conf.setProperty(ControllerConf.ENABLE_HYBRID_TABLE_RETENTION_STRATEGY,
hybridStrategy);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics,
+ conf, brokerServiceHelper);
+ if (offlineCounterpart) {
+ setupSizeRetentionOfflineCounterpart(resourceManager);
+ }
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(REALTIME_TABLE_NAME,
List.of("oldest"));
+ verify(resourceManager, never()).getOfflineTableConfig(anyString());
+ verify(brokerServiceHelper,
never()).getTimeBoundaryInfo(any(TableConfig.class));
+ verifySizeRetentionGauge(metrics, REALTIME_TABLE_NAME, 0);
+ }
+ }
+ }
+
+ @Test
+ public void testSizeRetentionRechecksMetadataAfterTimeRetention() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "70B");
+ tableConfig.getValidationConfig().setRetentionTimeUnit("DAYS");
+ tableConfig.getValidationConfig().setRetentionTimeValue("365");
+ long now = System.currentTimeMillis();
+ List<SegmentZKMetadata> segments = new ArrayList<>(List.of(
+ createSizeRetentionSegment("expired", 80, now -
TimeUnit.DAYS.toMillis(400)),
+ createSizeRetentionSegment("recent", 60, now - 1),
createSizeRetentionSegment("newest", 30, now)));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager);
+ when(resourceManager.getSegmentsFor(OFFLINE_TABLE_NAME, false)).thenAnswer(
+ invocation ->
segments.stream().map(SegmentZKMetadata::getSegmentName).toList());
+ doAnswer(invocation -> {
+ List<String> deletedSegments = invocation.getArgument(1);
+ segments.removeIf(segment ->
deletedSegments.contains(segment.getSegmentName()));
+ return PinotResourceManagerResponse.SUCCESS;
+ }).when(resourceManager).deleteSegments(eq(OFFLINE_TABLE_NAME), anyList());
+
+ retentionManager.processTable(OFFLINE_TABLE_NAME);
+
+ var deletionOrder = inOrder(resourceManager);
+ deletionOrder.verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("expired"));
+
deletionOrder.verify(resourceManager).getSegmentsZKMetadata(OFFLINE_TABLE_NAME);
+ deletionOrder.verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("recent"));
+
assertEquals(segments.stream().map(SegmentZKMetadata::getSegmentName).toList(),
List.of("newest"));
+ }
+
+ @Test
+ public void testSizeRetentionSkipsTableWhenActiveMetadataIsMissing() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "50B");
+ List<SegmentZKMetadata> segments =
+ List.of(createSizeRetentionSegment("oversized", 100,
System.currentTimeMillis()));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+ when(resourceManager.getSegmentsFor(OFFLINE_TABLE_NAME,
false)).thenReturn(List.of("oversized", "missing"));
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+
+ @Test
+ public void testSizeRetentionKeepsUndatedAndEmptySegments() {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "50B");
+ long now = System.currentTimeMillis();
+ SegmentZKMetadata undated = createSizeRetentionSegment("undated", 80, -1);
+ undated.setCreationTime(-1);
+ List<SegmentZKMetadata> segments =
List.of(createSizeRetentionSegment("empty", 0, now - 1), undated,
+ createSizeRetentionSegment("oldest", 60, now - 2),
createSizeRetentionSegment("newest", 60, now));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager).deleteSegments(OFFLINE_TABLE_NAME,
List.of("oldest"));
+ }
+
+ @Test
+ public void testSizeRetentionSkipsMalformedStoredConfiguration() {
+ for (String retentionSize : List.of("invalid", "0B", "-1B", "")) {
+ TableConfig tableConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, retentionSize);
+ List<SegmentZKMetadata> segments =
+ List.of(createSizeRetentionSegment("oversized", 100,
System.currentTimeMillis()));
+ PinotHelixResourceManager resourceManager =
mock(PinotHelixResourceManager.class);
+ ControllerMetrics metrics = mock(ControllerMetrics.class);
+ RetentionManager retentionManager =
createSizeRetentionManager(tableConfig, segments, resourceManager, metrics);
+
+ retentionManager.manageSizeBasedRetention(tableConfig);
+
+ verify(resourceManager, never()).deleteSegments(anyString(), anyList());
+ verifySizeRetentionGauge(metrics, OFFLINE_TABLE_NAME, 1);
+ }
+ }
+
+ private TableConfig createSizeRetentionTableConfig(TableType tableType,
String retentionSize) {
+ TableConfig tableConfig = new
TableConfigBuilder(tableType).setTableName(TEST_TABLE_NAME).build();
+ tableConfig.getValidationConfig().setRetentionSize(retentionSize);
+ return tableConfig;
+ }
+
+ private void verifySizeRetentionGauge(ControllerMetrics metrics, String
tableName, long value) {
+ verify(metrics).setOrUpdateTableGauge(tableName,
ControllerGauge.SIZE_RETENTION_BLOCKED, value);
+ // Publishing only the final result prevents an otherwise healthy pass
from exposing a transient blocked value.
+ verify(metrics, times(1)).setOrUpdateTableGauge(eq(tableName),
eq(ControllerGauge.SIZE_RETENTION_BLOCKED),
+ anyLong());
+ }
+
+ private void assertSizeRetentionGaugeValue(ControllerMetrics metrics, long
value) {
+ assertTrue(MetricValueUtils.tableGaugeExists(metrics, OFFLINE_TABLE_NAME,
ControllerGauge.SIZE_RETENTION_BLOCKED));
+ assertEquals(MetricValueUtils.getTableGaugeValue(metrics,
OFFLINE_TABLE_NAME,
+ ControllerGauge.SIZE_RETENTION_BLOCKED), value);
+ }
+
+ private void verifySizeRetentionGaugeRemoved(ControllerMetrics metrics) {
+ verify(metrics).removeTableGauge(OFFLINE_TABLE_NAME,
ControllerGauge.SIZE_RETENTION_BLOCKED);
+ verify(metrics, never()).setOrUpdateTableGauge(eq(OFFLINE_TABLE_NAME),
+ eq(ControllerGauge.SIZE_RETENTION_BLOCKED), anyLong());
+ assertFalse(MetricValueUtils.tableGaugeExists(metrics, OFFLINE_TABLE_NAME,
ControllerGauge.SIZE_RETENTION_BLOCKED));
+ }
+
+ private SegmentZKMetadata createSizeRetentionSegment(String segmentName,
long sizeInBytes, long endTimeMs) {
+ SegmentZKMetadata metadata = new SegmentZKMetadata(segmentName);
+ metadata.setSizeInBytes(sizeInBytes);
+ metadata.setTimeUnit(TimeUnit.MILLISECONDS);
+ metadata.setEndTime(endTimeMs);
+ metadata.setCreationTime(System.currentTimeMillis());
+ return metadata;
+ }
+
+ private RetentionManager createSizeRetentionManager(TableConfig tableConfig,
List<SegmentZKMetadata> segments,
+ PinotHelixResourceManager resourceManager) {
+ return createSizeRetentionManager(tableConfig, segments, resourceManager,
mock(ControllerMetrics.class));
+ }
+
+ private RetentionManager createSizeRetentionManager(TableConfig tableConfig,
List<SegmentZKMetadata> segments,
+ PinotHelixResourceManager resourceManager, ControllerMetrics metrics) {
+ return createSizeRetentionManager(tableConfig, segments, resourceManager,
metrics, new ControllerConf(),
+ mock(BrokerServiceHelper.class));
+ }
+
+ private RetentionManager createSizeRetentionManager(TableConfig tableConfig,
List<SegmentZKMetadata> segments,
+ PinotHelixResourceManager resourceManager, ControllerMetrics metrics,
ControllerConf conf,
+ BrokerServiceHelper brokerServiceHelper) {
+ String tableName = tableConfig.getTableName();
+ when(resourceManager.getTableConfig(tableName)).thenReturn(tableConfig);
+
when(resourceManager.getSegmentsZKMetadata(tableName)).thenReturn(segments);
+ when(resourceManager.getSegmentsFor(tableName, false))
+
.thenReturn(segments.stream().map(SegmentZKMetadata::getSegmentName).toList());
+ when(resourceManager.deleteSegments(eq(tableName),
anyList())).thenReturn(PinotResourceManagerResponse.SUCCESS);
+
when(resourceManager.getPropertyStore()).thenReturn(mock(ZkHelixPropertyStore.class));
+ when(resourceManager.getLineageUpdaterLock(tableName)).thenReturn(new
Object());
+
when(resourceManager.getLastLLCCompletedSegments(anyList())).thenCallRealMethod();
+
when(resourceManager.getLastLLCCompletedSegments(tableName)).thenCallRealMethod();
+ conf.setUntrackedSegmentDeletionEnabled(false);
+ return createRetentionManager(resourceManager,
mock(LeadControllerManager.class), conf,
+ metrics, brokerServiceHelper);
+ }
+
+ private void setupSizeRetentionOfflineCounterpart(PinotHelixResourceManager
resourceManager) {
+ TableConfig offlineConfig =
createSizeRetentionTableConfig(TableType.OFFLINE, "1B");
+
when(resourceManager.getOfflineTableConfig(TEST_TABLE_NAME)).thenReturn(offlineConfig);
+ }
+
+ private void setupSizeRetentionLineage(PinotHelixResourceManager
resourceManager, String tableName,
+ String source, String destination, LineageEntryState state) {
+ setupSizeRetentionLineage(resourceManager, tableName, source, destination,
state, System.currentTimeMillis());
+ }
+
+ private void setupSizeRetentionLineage(PinotHelixResourceManager
resourceManager, String tableName,
+ String source, String destination, LineageEntryState state, long
timestamp) {
+ SegmentLineage lineage = new SegmentLineage(tableName);
+ lineage.addLineageEntry("replacement",
+ new LineageEntry(List.of(source), List.of(destination), state,
timestamp));
+ when(resourceManager.getPropertyStore().get(anyString(), any(Stat.class),
eq(AccessOption.PERSISTENT)))
+ .thenReturn(lineage.toZNRecord());
+ }
}
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
index c77b42930a4..4e76120982b 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
@@ -301,6 +301,7 @@ public final class TableConfigUtils {
/// Validates retention config. Checks for following things:
/// - Valid segmentPushType
/// - Valid retentionTimeUnit
+ /// - Positive retentionSize in bytes, when configured
private static void validateRetentionConfig(TableConfig tableConfig) {
SegmentsValidationAndRetentionConfig segmentsConfig =
tableConfig.getValidationConfig();
String tableName = tableConfig.getTableName();
@@ -329,6 +330,17 @@ public final class TableConfigUtils {
}
}
+ String retentionSize = segmentsConfig.getRetentionSize();
+ if (retentionSize != null) {
+ try {
+ Preconditions.checkArgument(DataSizeUtils.toBytes(retentionSize) > 0,
"Retention size must be positive");
+ } catch (IllegalArgumentException e) {
+ throw new IllegalStateException(
+ String.format("Table: %s, invalid retention size: %s; must be a
positive size in bytes", tableName,
+ retentionSize), e);
+ }
+ }
+
// Untracked segments retention may not be specified. Ignore validation in
that case.
String untrackedSegmentsRetentionTimeUnitString =
segmentsConfig.getUntrackedSegmentsRetentionTimeUnit();
String untrackedSegmentsRetentionTimeValueString =
segmentsConfig.getUntrackedSegmentsRetentionTimeValue();
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
index ae5af3891de..371b7431096 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
@@ -2564,6 +2564,51 @@ public class TableConfigUtilsTest {
}
}
+ @DataProvider
+ public Object[][] retentionTableTypes() {
+ return new Object[][]{{TableType.OFFLINE}, {TableType.REALTIME}};
+ }
+
+ @Test(dataProvider = "retentionTableTypes")
+ public void testValidateRetentionSize(TableType tableType) {
+ Schema schema = new Schema.SchemaBuilder().setSchemaName(TABLE_NAME)
+ .addDateTime(TIME_COLUMN, DataType.LONG, "1:MILLISECONDS:EPOCH",
"1:MILLISECONDS")
+ .build();
+ TableConfigBuilder builder = new
TableConfigBuilder(tableType).setTableName(TABLE_NAME)
+ .setTimeColumnName(TIME_COLUMN);
+ if (tableType == TableType.REALTIME) {
+ builder.setStreamConfigs(getStreamConfigs());
+ }
+
+ // Size retention is optional and does not require time retention.
+ TableConfigUtils.validate(builder.build(), schema);
+ for (String retentionSize : List.of("1", "1B", "100G", "1.5gB")) {
+
TableConfigUtils.validate(builder.setRetentionSize(retentionSize).build(),
schema);
+ }
+
+ // The two retention policies can be configured together.
+
TableConfigUtils.validate(builder.setRetentionTimeUnit("DAYS").setRetentionTimeValue("7").build(),
schema);
+ }
+
+ @Test(dataProvider = "retentionTableTypes")
+ public void testRejectInvalidRetentionSize(TableType tableType) {
+ Schema schema = new Schema.SchemaBuilder().setSchemaName(TABLE_NAME)
+ .addDateTime(TIME_COLUMN, DataType.LONG, "1:MILLISECONDS:EPOCH",
"1:MILLISECONDS")
+ .build();
+ TableConfigBuilder builder = new
TableConfigBuilder(tableType).setTableName(TABLE_NAME)
+ .setTimeColumnName(TIME_COLUMN);
+ if (tableType == TableType.REALTIME) {
+ builder.setStreamConfigs(getStreamConfigs());
+ }
+
+ for (String retentionSize : List.of("", " ", "0", "0G", "0.5B", "-1",
"-1G", "100GBB", "abc")) {
+ TableConfig tableConfig =
builder.setRetentionSize(retentionSize).build();
+ IllegalStateException e =
+ expectThrows(IllegalStateException.class, () ->
TableConfigUtils.validate(tableConfig, schema));
+ assertTrue(e.getMessage().contains("invalid retention size: " +
retentionSize), e.getMessage());
+ }
+ }
+
@Test
public void testValidateDedupConfig() {
TableConfig validTableConfig = new
TableConfigBuilder(TableType.REALTIME).setTableName(TABLE_NAME)
diff --git
a/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/SegmentsValidationAndRetentionConfig.java
b/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/SegmentsValidationAndRetentionConfig.java
index 9027fd3ec57..e503c2986f5 100644
---
a/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/SegmentsValidationAndRetentionConfig.java
+++
b/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/SegmentsValidationAndRetentionConfig.java
@@ -27,6 +27,7 @@ import org.apache.pinot.spi.utils.TimeUtils;
public class SegmentsValidationAndRetentionConfig extends BaseJsonConfig {
private String _retentionTimeUnit;
private String _retentionTimeValue;
+ private String _retentionSize;
private String _deletedSegmentsRetentionPeriod;
private String _replacedSegmentsRetentionPeriod;
private String _lineageEntryCleanupRetentionPeriod;
@@ -88,6 +89,59 @@ public class SegmentsValidationAndRetentionConfig extends
BaseJsonConfig {
_retentionTimeValue = retentionTimeValue;
}
+ /// Returns the size retention limit, expressed as a positive size
understood by
+ /// [DataSizeUtils#toBytes(String)], for example `"100G"`.
+ ///
+ /// The limit sums compressed archive bytes for active, live completed
segments, counted once regardless of
+ /// replication. Live-segment lineage filtering excludes sources of
COMPLETED entries and destinations of other
+ /// entries to avoid counting shadow copies. OFFLINE APPEND and REALTIME
limits are enforced independently, including
+ /// hybrid tables. Each typed table expires its own oldest eligible data
without consulting the OFFLINE routing
+ /// time boundary. REALTIME size retention can therefore expire data newer
than that boundary; hybrid time
+ /// retention continues to use its existing boundary rules.
+ /// For hybrid tables, size the REALTIME cap to comfortably retain all data
newer than the routing boundary.
+ /// Otherwise size retention can delete input before RealtimeToOffline
migrates it, and queries for that time
+ /// range can return no rows from either typed table without an error.
+ ///
+ /// Segments are ordered ascending by the first nonnegative end time,
creation time, or push time, in that priority,
+ /// then by segment name. Eviction removes only an old prefix before the
first active segment listed as a source or
+ /// destination in any retained lineage entry. Every listed segment is
protected until its lineage entry is removed,
+ /// regardless of entry state, age, or the lineage-exclusive deletion
setting. Listed shadow copies remain stop
+ /// positions even when their bytes are excluded. Missing live metadata or
unknown live completed-segment sizes cause
+ /// the pass to skip safely. Missing metadata or timestamps for an active
lineage stop prevent eviction when over cap.
+ ///
+ /// With the default lineage manager, COMPLETED sources wait for
[#getReplacedSegmentsRetentionPeriod()] (4h for
+ /// APPEND, 24h for REFRESH) before deletion. The entry is removed on a
subsequent cleanup pass after sources leave
+ /// IdealState. The retention schedule (6h by default) also delays release
of the boundary. Stale IN_PROGRESS entries
+ /// use [#getLineageEntryCleanupRetentionPeriod()] (1d by default); REVERTED
entries are eligible immediately.
+ /// These are cleanup eligibility windows, not deadlines for meeting the
size cap. Continuous merge or rollup
+ /// activity can keep a lineage boundary present and leave an over-cap table
at `sizeRetentionBlocked=1` for hours
+ /// or longer.
+ ///
+ /// The newest OFFLINE segment, highest-sequence DONE LLC segment per
REALTIME partition group, and undated segments
+ /// are preserved. Consuming segments are neither counted nor removed. These
protections can leave the table above
+ /// its cap.
+ ///
+ /// Size retention rechecks the complete lineage entry snapshot under the
local updater lock before deletion,
+ /// regardless of the lineage-exclusive deletion setting. A changed snapshot
aborts the batch for retry next cycle.
+ /// This deliberately includes timestamp-only changes and entries wholly
newer than the boundary, even when the
+ /// selected prefix is unaffected.
+ /// The check and deletion are not a global transaction across controllers.
+ ///
+ /// This is an asynchronous, best-effort retention policy, not a limit on
decompressed server disk usage,
+ /// memory usage, or ingestion. When time retention is also configured, a
segment can be deleted by either criterion.
+ /// The table-scoped `sizeRetentionBlocked` controller gauge is 1 after an
unsafe, failed, or still-over-cap pass,
+ /// 0 after a successful pass, and absent when the policy is disabled or
unsupported.
+ /// A null value disables size retention.
+ public String getRetentionSize() {
+ return _retentionSize;
+ }
+
+ /// Sets the compressed segment archive size retention limit described by
[#getRetentionSize()].
+ /// Set to null to disable size retention.
+ public void setRetentionSize(String retentionSize) {
+ _retentionSize = retentionSize;
+ }
+
public String getDeletedSegmentsRetentionPeriod() {
return _deletedSegmentsRetentionPeriod;
}
@@ -96,12 +150,12 @@ public class SegmentsValidationAndRetentionConfig extends
BaseJsonConfig {
_deletedSegmentsRetentionPeriod = deletedSegmentsRetentionPeriod;
}
- /// Returns the retention period for segments replaced by a REFRESH
ingestion job. Only applies to tables with
- /// REFRESH ingestion type; for APPEND tables this setting is ignored and
replaced segments are deleted immediately.
+ /// Returns the retention period for source segments replaced by a COMPLETED
lineage entry. Applies to all ingestion
+ /// types, including APPEND and REFRESH.
///
/// When a lineage entry transitions to COMPLETED state, source segments are
preserved for this duration before
- /// being scheduled for deletion, providing a rollback window. Consumers of
this config (e.g. the lineage manager)
- /// treat a null or unparseable value as a 1 day default.
+ /// being scheduled for deletion. The default lineage manager uses 4 hours
for APPEND and other ingestion types,
+ /// and 24 hours for REFRESH, when the value is unset, empty, or unparseable.
///
/// Accepts a human-readable period string (e.g. `"7d"`, `"12h"`) as
understood by
/// `TimeUtils.convertPeriodToMillis`. Setting this value too low (e.g.
`"0d"`) eliminates the rollback
@@ -114,9 +168,10 @@ public class SegmentsValidationAndRetentionConfig extends
BaseJsonConfig {
_replacedSegmentsRetentionPeriod = replacedSegmentsRetentionPeriod;
}
- /// Returns the retention period before stale IN_PROGRESS or REVERTED
lineage entries and their destination segments
- /// are cleaned up. Consumers of this config (e.g. the lineage manager)
treat a null or unparseable value as a
- /// 1 day default.
+ /// Returns the retention period before stale IN_PROGRESS lineage entries
and their destination segments become
+ /// eligible for cleanup. The default lineage manager uses 1 day when the
value is unset, empty, or unparseable.
+ /// REVERTED entries are eligible immediately. This period does not apply to
COMPLETED entries: those are removed
+ /// on a cleanup pass after their source segments leave IdealState.
///
/// Accepts a human-readable period string (e.g. `"7d"`, `"12h"`) as
understood by
/// `TimeUtils.convertPeriodToMillis`.
diff --git
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/TableConfigBuilder.java
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/TableConfigBuilder.java
index 8587ea04627..f61f433a4da 100644
---
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/TableConfigBuilder.java
+++
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/TableConfigBuilder.java
@@ -74,6 +74,7 @@ public class TableConfigBuilder {
private String _timeType;
private String _retentionTimeUnit;
private String _retentionTimeValue;
+ private String _retentionSize;
private String _deletedSegmentsRetentionPeriod =
DEFAULT_DELETED_SEGMENTS_RETENTION_PERIOD;
private String _replacedSegmentsRetentionPeriod;
private String _lineageEntryCleanupRetentionPeriod;
@@ -203,6 +204,13 @@ public class TableConfigBuilder {
return this;
}
+ /// Sets the compressed segment archive size retention limit described by
+ /// [SegmentsValidationAndRetentionConfig#getRetentionSize()].
+ public TableConfigBuilder setRetentionSize(String retentionSize) {
+ _retentionSize = retentionSize;
+ return this;
+ }
+
public TableConfigBuilder setDeletedSegmentsRetentionPeriod(String
deletedSegmentsRetentionPeriod) {
_deletedSegmentsRetentionPeriod = deletedSegmentsRetentionPeriod;
return this;
@@ -522,6 +530,7 @@ public class TableConfigBuilder {
validationConfig.setTimeType(_timeType);
validationConfig.setRetentionTimeUnit(_retentionTimeUnit);
validationConfig.setRetentionTimeValue(_retentionTimeValue);
+ validationConfig.setRetentionSize(_retentionSize);
validationConfig.setDeletedSegmentsRetentionPeriod(_deletedSegmentsRetentionPeriod);
validationConfig.setReplacedSegmentsRetentionPeriod(_replacedSegmentsRetentionPeriod);
validationConfig.setLineageEntryCleanupRetentionPeriod(_lineageEntryCleanupRetentionPeriod);
diff --git
a/pinot-spi/src/test/java/org/apache/pinot/spi/utils/builder/TableConfigBuilderTest.java
b/pinot-spi/src/test/java/org/apache/pinot/spi/utils/builder/TableConfigBuilderTest.java
index 2f89899fba5..6a64aa08b7a 100644
---
a/pinot-spi/src/test/java/org/apache/pinot/spi/utils/builder/TableConfigBuilderTest.java
+++
b/pinot-spi/src/test/java/org/apache/pinot/spi/utils/builder/TableConfigBuilderTest.java
@@ -19,11 +19,18 @@
package org.apache.pinot.spi.utils.builder;
+import java.io.IOException;
import org.apache.pinot.spi.config.table.TableConfig;
import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.utils.JsonUtils;
import org.testng.Assert;
+import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
+
/// Tests for the validations in [TableConfigBuilder]
public class TableConfigBuilderTest {
@@ -31,6 +38,40 @@ public class TableConfigBuilderTest {
private static final String TABLE_NAME = "testTable";
private static final String TIME_COLUMN = "timeColumn";
+ @DataProvider
+ public Object[][] tableTypes() {
+ return new Object[][]{{TableType.OFFLINE}, {TableType.REALTIME}};
+ }
+
+ @Test(dataProvider = "tableTypes")
+ public void testRetentionSizeRoundTrip(TableType tableType)
+ throws IOException {
+ TableConfig tableConfig = new
TableConfigBuilder(tableType).setTableName(TABLE_NAME)
+ .setRetentionSize("100G")
+ .setRetentionTimeUnit("DAYS")
+ .setRetentionTimeValue("7")
+ .build();
+ assertEquals(tableConfig.getValidationConfig().getRetentionSize(), "100G");
+
assertEquals(tableConfig.toJsonNode().get("segmentsConfig").get("retentionSize").asText(),
"100G");
+
+ TableConfig roundTrip =
JsonUtils.stringToObject(tableConfig.toJsonString(), TableConfig.class);
+ assertEquals(roundTrip, tableConfig);
+ assertEquals(roundTrip.getValidationConfig().getRetentionSize(), "100G");
+ assertEquals(roundTrip.getValidationConfig().getRetentionTimeUnit(),
"DAYS");
+ assertEquals(roundTrip.getValidationConfig().getRetentionTimeValue(), "7");
+ assertEquals(new
TableConfig(tableConfig).getValidationConfig().getRetentionSize(), "100G");
+ }
+
+ @Test(dataProvider = "tableTypes")
+ public void testRetentionSizeDefaultsToDisabled(TableType tableType)
+ throws IOException {
+ TableConfig tableConfig = new
TableConfigBuilder(tableType).setTableName(TABLE_NAME).build();
+ assertNull(tableConfig.getValidationConfig().getRetentionSize());
+
assertFalse(tableConfig.toJsonNode().get("segmentsConfig").has("retentionSize"));
+ TableConfig roundTrip =
JsonUtils.stringToObject(tableConfig.toJsonString(), TableConfig.class);
+ assertNull(roundTrip.getValidationConfig().getRetentionSize());
+ }
+
@Test
public void testValidateSkipSegmentPreprocessFlag() {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]