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

tarun11Mavani 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 5b53f30d801 Add controller support for rejecting out-of-retention 
segment uploads (#18486)
5b53f30d801 is described below

commit 5b53f30d8016fed5da49488802ae280bb9425036
Author: Prabhat Singh <[email protected]>
AuthorDate: Thu Nov 12 18:54:33 2026 +0530

    Add controller support for rejecting out-of-retention segment uploads 
(#18486)
    
    * Reject out-of-retention segment uploads (optional)
    
    Add optional controller validation to reject offline segment uploads
    outside the table retention window, align RetentionUtils segment end
    time handling with minion raw time fields, emit a controller meter on
    rejection, and extend tests and docs per review feedback.
    
    * Drop trailing period on out-of-retention upload rejection message.
    
    * Address review feedback on out-of-retention upload check
    
    Have TableConfigRetentionUtils.buildRetentionStrategy reuse
    RetentionUtils.parseTableDataRetentionMillis so the upload check and the
    retention manager share one retention parser. Document that minion task
    uploads are subject to the check and how a rejection interacts with the
    RealtimeToOffline watermark, and clarify that the hybrid retention
    strategy uses an independent boundary. Add TableConfigRetentionUtilsTest.
    
    * Add integration test for out-of-retention segment upload rejection
    
    Enable controller.segment.upload.rejectOutOfRetention.enabled in
    SegmentUploadIntegrationTest and cover both outcomes end to end: an
    upload outside a 7-day retention window returns 403, is not registered
    or copied to deep store, and increments the rejection meter; an upload
    inside the window succeeds without touching the meter.
    
    * Address review feedback on out-of-retention upload check
    
    - Rename controller.segment.upload.rejectOutOfRetention.enabled to
      controller.segment.upload.reject.out.of.retention
    - Drop the creation-time fallback option from the SegmentMetadata 
isPurgeable
      overload and restore the SegmentZKMetadata overload to its original body
    - Make getSegmentMetadataEndTimeMillis private and cover it through
      isPurgeable outcomes
    - Rely on TableConfig's non-null validation config
    - Use static Assert/Mockito imports in new test code; add class Javadoc
---
 .../pinot/common/metrics/ControllerMeter.java      |   4 +-
 .../apache/pinot/common/utils/RetentionUtils.java  |  73 ++++++++-
 .../pinot/common/utils/RetentionUtilsTest.java     | 164 +++++++++++++++++++++
 .../apache/pinot/controller/ControllerConf.java    |  23 +++
 .../PinotSegmentUploadDownloadRestletResource.java |   2 +
 .../api/upload/SegmentValidationUtils.java         |  54 +++++++
 .../helix/core/retention/RetentionManager.java     |   8 +-
 .../core/retention/TableConfigRetentionUtils.java  |  19 +--
 .../pinot/controller/ControllerConfTest.java       |  17 +++
 .../api/upload/SegmentValidationUtilsTest.java     | 111 ++++++++++++++
 .../retention/TableConfigRetentionUtilsTest.java   |  94 ++++++++++++
 .../tests/SegmentUploadIntegrationTest.java        |  81 ++++++++++
 12 files changed, 632 insertions(+), 18 deletions(-)

diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/ControllerMeter.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/ControllerMeter.java
index 02c87139355..83e40f4dfb7 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/ControllerMeter.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/ControllerMeter.java
@@ -102,7 +102,9 @@ public enum ControllerMeter implements 
AbstractMetrics.Meter {
   // Number of segment-delete requests rejected because the targets 
participate in a live segment lineage entry.
   LINEAGE_BLOCKED_DELETE_COUNT("LineageBlockedDeleteCount", false),
   // Storage quota breach detected for a table whose enforcement mode is 
ENFORCED or SHADOW (not DISABLED)
-  STORAGE_QUOTA_EXCEEDED("StorageQuotaExceeded", false);
+  STORAGE_QUOTA_EXCEEDED("StorageQuotaExceeded", false),
+  // Segment uploads rejected for being outside the table retention window.
+  
OUT_OF_RETENTION_SEGMENT_UPLOAD_REJECTED("OutOfRetentionSegmentUploadRejected", 
true);
 
   private final String _brokerMeterName;
   private final String _unit;
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/RetentionUtils.java 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/RetentionUtils.java
index df5788b9596..aa2dbde1286 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/RetentionUtils.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/RetentionUtils.java
@@ -18,19 +18,38 @@
  */
 package org.apache.pinot.common.utils;
 
+import java.util.Locale;
+import java.util.OptionalLong;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.segment.spi.SegmentMetadata;
+import org.apache.pinot.spi.config.table.SegmentsValidationAndRetentionConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.utils.IngestionConfigUtils;
 import org.apache.pinot.spi.utils.TimeUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 
-/// Utility methods for evaluating segment retention eligibility.
+/// Utility methods for evaluating segment retention eligibility and parsing 
table data retention from config.
 public class RetentionUtils {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(RetentionUtils.class);
 
   private RetentionUtils() {
   }
 
+  /// Whether time-based data retention should run for this table's segments, 
matching the controller retention manager:
+  /// for {@link TableType#OFFLINE} tables, retention applies only when batch 
segment ingestion type is {@code APPEND}.
+  public static boolean shouldManageTimeBasedDataRetention(TableConfig 
tableConfig) {
+    if (tableConfig.getTableType() == TableType.OFFLINE && !"APPEND"
+        
.equalsIgnoreCase(IngestionConfigUtils.getBatchSegmentIngestionType(tableConfig)))
 {
+      return false;
+    }
+    return true;
+  }
+
   /// Implements the time-comparison and creation-time fallback logic used by 
`TimeRetentionStrategy` for
   /// completed segments. Does NOT check segment completion status — callers 
must guarantee that only completed
   /// segments (DONE or UPLOADED) are passed in.
@@ -69,4 +88,56 @@ public class RetentionUtils {
     }
     return false;
   }
+
+  /// Whether segment file metadata is past the retention window, using the 
end time from
+  /// {@link SegmentMetadata#getEndTime()} converted with {@link 
SegmentMetadata#getTimeUnit()}. Unlike the
+  /// {@link SegmentZKMetadata} overload, there is no creation-time fallback.
+  ///
+  /// - If end time is valid: purgeable when `currentTimeMs - endTimeMs > 
retentionMs`.
+  /// - Otherwise: not purgeable (fail-open).
+  public static boolean isPurgeable(String tableNameWithType, SegmentMetadata 
segmentMetadata, long retentionMs,
+      long currentTimeMs) {
+    long endTimeMs = getSegmentMetadataEndTimeMillis(segmentMetadata);
+    if (TimeUtils.timeValueInValidRange(endTimeMs)) {
+      return currentTimeMs - endTimeMs > retentionMs;
+    }
+    LOGGER.warn("Segment: {} of table: {} has invalid end time in millis: {}. 
Cannot determine retention, skipping",
+        segmentMetadata.getName(), tableNameWithType, endTimeMs);
+    return false;
+  }
+
+  /// Parses {@link 
SegmentsValidationAndRetentionConfig#getRetentionTimeUnit()} and
+  /// {@link SegmentsValidationAndRetentionConfig#getRetentionTimeValue()} 
into table data retention duration in
+  /// milliseconds (same interpretation as controller time-based retention).
+  ///
+  /// @return millis, or empty if unset or not parseable
+  public static OptionalLong 
parseTableDataRetentionMillis(SegmentsValidationAndRetentionConfig 
validationConfig) {
+    String retentionTimeUnit = validationConfig.getRetentionTimeUnit();
+    String retentionTimeValue = validationConfig.getRetentionTimeValue();
+    if (StringUtils.isEmpty(retentionTimeUnit) || 
StringUtils.isEmpty(retentionTimeValue)) {
+      return OptionalLong.empty();
+    }
+    try {
+      return 
OptionalLong.of(TimeUnit.valueOf(retentionTimeUnit.toUpperCase(Locale.ROOT))
+          .toMillis(Long.parseLong(retentionTimeValue)));
+    } catch (Exception e) {
+      LOGGER.debug("Failed to parse table data retention: unit='{}' 
value='{}'", retentionTimeUnit, retentionTimeValue,
+          e);
+      return OptionalLong.empty();
+    }
+  }
+
+  /// Returns segment end time in epoch millis from on-disk {@link 
SegmentMetadata} for retention comparison, using
+  /// {@link SegmentMetadata#getTimeUnit()} and {@link 
SegmentMetadata#getEndTime()} (same raw fields minion task
+  /// executors pass when configuring generation from segment files). When 
`timeUnit` is non-null and raw end time is
+  /// not {@link Long#MIN_VALUE}, returns `timeUnit.toMillis(endTime)`; 
otherwise returns the raw end time (typically
+  /// `Long.MIN_VALUE` when unset).
+  private static long getSegmentMetadataEndTimeMillis(SegmentMetadata 
segmentMetadata) {
+    TimeUnit timeUnit = segmentMetadata.getTimeUnit();
+    long endTime = segmentMetadata.getEndTime();
+    if (timeUnit != null && endTime != Long.MIN_VALUE) {
+      return timeUnit.toMillis(endTime);
+    }
+    return endTime;
+  }
 }
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/RetentionUtilsTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/RetentionUtilsTest.java
index 08ce605f5ff..b0822336067 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/RetentionUtilsTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/RetentionUtilsTest.java
@@ -18,10 +18,20 @@
  */
 package org.apache.pinot.common.utils;
 
+import java.util.Locale;
+import java.util.OptionalLong;
 import java.util.concurrent.TimeUnit;
 import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.segment.spi.SegmentMetadata;
+import org.apache.pinot.spi.config.table.SegmentsValidationAndRetentionConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
 import org.testng.annotations.Test;
 
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertTrue;
 
@@ -47,6 +57,14 @@ public class RetentionUtilsTest {
     return segment;
   }
 
+  private static SegmentMetadata mockSegmentMetadata(TimeUnit timeUnit, long 
endTime) {
+    SegmentMetadata metadata = mock(SegmentMetadata.class);
+    when(metadata.getName()).thenReturn("seg");
+    when(metadata.getTimeUnit()).thenReturn(timeUnit);
+    when(metadata.getEndTime()).thenReturn(endTime);
+    return metadata;
+  }
+
   @Test
   public void testExpiredSegmentIsPurgeable() {
     long now = System.currentTimeMillis();
@@ -127,4 +145,150 @@ public class RetentionUtilsTest {
     SegmentZKMetadata segment = makeSegmentWithCreationTime(now - 2 * 
ONE_DAY_MS, now - 10 * ONE_DAY_MS);
     assertFalse(RetentionUtils.isPurgeable(TABLE_NAME, segment, RETENTION_MS, 
now, true));
   }
+
+  @Test
+  public void testSegmentMetadataExpiredSegmentIsPurgeable() {
+    long now = System.currentTimeMillis();
+    assertTrue(RetentionUtils.isPurgeable(TABLE_NAME,
+        mockSegmentMetadata(TimeUnit.MILLISECONDS, now - 10 * ONE_DAY_MS), 
RETENTION_MS, now));
+  }
+
+  @Test
+  public void testSegmentMetadataExactBoundaryIsNotPurgeable() {
+    long now = System.currentTimeMillis();
+    assertFalse(RetentionUtils.isPurgeable(TABLE_NAME, 
mockSegmentMetadata(TimeUnit.MILLISECONDS, now - RETENTION_MS),
+        RETENTION_MS, now));
+  }
+
+  @Test
+  public void testSegmentMetadataOneMsPastBoundaryIsPurgeable() {
+    long now = System.currentTimeMillis();
+    assertTrue(RetentionUtils.isPurgeable(TABLE_NAME,
+        mockSegmentMetadata(TimeUnit.MILLISECONDS, now - RETENTION_MS - 1), 
RETENTION_MS, now));
+  }
+
+  @Test
+  public void testSegmentMetadataEndTimeConvertedWithTimeUnit() {
+    long nowDays = 20_000;
+    long now = TimeUnit.DAYS.toMillis(nowDays);
+    assertTrue(RetentionUtils.isPurgeable(TABLE_NAME, 
mockSegmentMetadata(TimeUnit.DAYS, nowDays - 8), RETENTION_MS,
+        now));
+    assertFalse(RetentionUtils.isPurgeable(TABLE_NAME, 
mockSegmentMetadata(TimeUnit.DAYS, nowDays - 6), RETENTION_MS,
+        now));
+  }
+
+  @Test
+  public void testSegmentMetadataInvalidEndTimeIsNotPurgeable() {
+    long now = System.currentTimeMillis();
+    assertFalse(RetentionUtils.isPurgeable(TABLE_NAME, 
mockSegmentMetadata(TimeUnit.MILLISECONDS, -1), RETENTION_MS,
+        now));
+  }
+
+  @Test
+  public void testSegmentMetadataWithoutTimeInfoIsNotPurgeable() {
+    // Segments without time metadata have no time unit and an unset end time
+    long now = System.currentTimeMillis();
+    assertFalse(RetentionUtils.isPurgeable(TABLE_NAME, 
mockSegmentMetadata(null, Long.MIN_VALUE), RETENTION_MS, now));
+  }
+
+  @Test
+  public void shouldManageTimeBasedDataRetentionRealtime() {
+    TableConfig cfg = new 
TableConfigBuilder(TableType.REALTIME).setTableName("t").build();
+    assertTrue(RetentionUtils.shouldManageTimeBasedDataRetention(cfg));
+  }
+
+  @Test
+  public void shouldManageTimeBasedDataRetentionOfflineAppendDefault() {
+    TableConfig cfg = new 
TableConfigBuilder(TableType.OFFLINE).setTableName("t_OFFLINE").build();
+    assertTrue(RetentionUtils.shouldManageTimeBasedDataRetention(cfg));
+  }
+
+  @Test
+  public void shouldManageTimeBasedDataRetentionOfflineRefreshSkipped() {
+    TableConfig cfg = new 
TableConfigBuilder(TableType.OFFLINE).setTableName("t_OFFLINE").setSegmentPushType("REFRESH")
+        .build();
+    assertFalse(RetentionUtils.shouldManageTimeBasedDataRetention(cfg));
+  }
+
+  @Test
+  public void testParseTableDataRetentionMillisValidDays() {
+    SegmentsValidationAndRetentionConfig cfg = new 
SegmentsValidationAndRetentionConfig();
+    cfg.setRetentionTimeUnit("DAYS");
+    cfg.setRetentionTimeValue("7");
+    assertTrue(RetentionUtils.parseTableDataRetentionMillis(cfg).isPresent());
+    
assertEquals(RetentionUtils.parseTableDataRetentionMillis(cfg).getAsLong(), 
TimeUnit.DAYS.toMillis(7));
+  }
+
+  @Test
+  public void testParseTableDataRetentionMillisMissingReturnsEmpty() {
+    assertFalse(RetentionUtils.parseTableDataRetentionMillis(new 
SegmentsValidationAndRetentionConfig()).isPresent());
+  }
+
+  @Test
+  public void testParseTableDataRetentionMillisInvalidUnitReturnsEmpty() {
+    SegmentsValidationAndRetentionConfig cfg = new 
SegmentsValidationAndRetentionConfig();
+    cfg.setRetentionTimeUnit("FORTNIGHTS");
+    cfg.setRetentionTimeValue("1");
+    assertFalse(RetentionUtils.parseTableDataRetentionMillis(cfg).isPresent());
+  }
+
+  @Test
+  public void testParseTableDataRetentionMillisEmptyUnitReturnsEmpty() {
+    SegmentsValidationAndRetentionConfig cfg = new 
SegmentsValidationAndRetentionConfig();
+    cfg.setRetentionTimeUnit("");
+    cfg.setRetentionTimeValue("7");
+    assertFalse(RetentionUtils.parseTableDataRetentionMillis(cfg).isPresent());
+  }
+
+  @Test
+  public void testParseTableDataRetentionMillisEmptyValueReturnsEmpty() {
+    SegmentsValidationAndRetentionConfig cfg = new 
SegmentsValidationAndRetentionConfig();
+    cfg.setRetentionTimeUnit("DAYS");
+    cfg.setRetentionTimeValue("");
+    assertFalse(RetentionUtils.parseTableDataRetentionMillis(cfg).isPresent());
+  }
+
+  @Test
+  public void testParseTableDataRetentionMillisInvalidValueReturnsEmpty() {
+    SegmentsValidationAndRetentionConfig cfg = new 
SegmentsValidationAndRetentionConfig();
+    cfg.setRetentionTimeUnit("DAYS");
+    cfg.setRetentionTimeValue("not-a-number");
+    assertFalse(RetentionUtils.parseTableDataRetentionMillis(cfg).isPresent());
+  }
+
+  @Test
+  public void testParseTableDataRetentionMillisLowerCaseUnitParses() {
+    SegmentsValidationAndRetentionConfig cfg = new 
SegmentsValidationAndRetentionConfig();
+    cfg.setRetentionTimeUnit("days");
+    cfg.setRetentionTimeValue("7");
+    OptionalLong parsed = RetentionUtils.parseTableDataRetentionMillis(cfg);
+    assertTrue(parsed.isPresent());
+    assertEquals(parsed.getAsLong(), TimeUnit.DAYS.toMillis(7));
+  }
+
+  @Test
+  public void testParseTableDataRetentionMillisLowerCaseUnitUsesLocaleRoot() {
+    Locale previous = Locale.getDefault();
+    try {
+      Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+      SegmentsValidationAndRetentionConfig cfg = new 
SegmentsValidationAndRetentionConfig();
+      cfg.setRetentionTimeUnit("minutes");
+      cfg.setRetentionTimeValue("5");
+      OptionalLong parsed = RetentionUtils.parseTableDataRetentionMillis(cfg);
+      assertTrue(parsed.isPresent());
+      assertEquals(parsed.getAsLong(), TimeUnit.MINUTES.toMillis(5));
+    } finally {
+      Locale.setDefault(previous);
+    }
+  }
+
+  @Test
+  public void testParseTableDataRetentionMillisValidHours() {
+    SegmentsValidationAndRetentionConfig cfg = new 
SegmentsValidationAndRetentionConfig();
+    cfg.setRetentionTimeUnit("HOURS");
+    cfg.setRetentionTimeValue("12");
+    OptionalLong parsed = RetentionUtils.parseTableDataRetentionMillis(cfg);
+    assertTrue(parsed.isPresent());
+    assertEquals(parsed.getAsLong(), TimeUnit.HOURS.toMillis(12));
+  }
 }
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/ControllerConf.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/ControllerConf.java
index fd6ccdcdea5..cd63977034d 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/ControllerConf.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/ControllerConf.java
@@ -370,6 +370,17 @@ public class ControllerConf extends PinotConfiguration {
   // Amount of the time the segment can take from the beginning of upload to 
the end of upload. Used when parallel push
   // protection is enabled. If the upload does not finish within the timeout, 
next upload can override the previous one.
   public static final String SEGMENT_UPLOAD_TIMEOUT_IN_MILLIS = 
"controller.segment.upload.timeoutInMillis";
+  /// If true, the controller may reject single-segment uploads with HTTP 403 
when segment data end is past the table
+  /// data retention window (see {@link 
org.apache.pinot.controller.api.upload.SegmentValidationUtils}).
+  ///
+  /// This also applies to minion task uploads that push through `POST 
/segments`: always for Purge, UpsertCompaction
+  /// and RefreshSegment, and for RealtimeToOffline, MergeRollup and 
UpsertCompactMerge unless `batchSegmentUpload` is
+  /// on.
+  /// A rejected RealtimeToOffline upload does not advance its watermark, so 
the task retries the same window until the
+  /// source segments age out (indefinitely with the hybrid retention 
strategy, since the time boundary cannot advance).
+  /// Review task schedules and buffer periods against table retention before 
enabling.
+  public static final String SEGMENT_UPLOAD_REJECT_OUT_OF_RETENTION =
+      "controller.segment.upload.reject.out.of.retention";
   public static final String REALTIME_SEGMENT_METADATA_COMMIT_NUMLOCKS =
       "controller.realtime.segment.metadata.commit.numLocks";
   public static final String ENABLE_STORAGE_QUOTA_CHECK = 
"controller.enable.storage.quota.check";
@@ -419,6 +430,8 @@ public class ControllerConf extends PinotConfiguration {
   public static final String DEFAULT_REBALANCE_PRE_CHECKER =
       
"org.apache.pinot.controller.helix.core.rebalance.DefaultRebalancePreChecker";
   public static final long DEFAULT_SEGMENT_UPLOAD_TIMEOUT_IN_MILLIS = 
600_000L; // 10 minutes
+  /// Default for {@link #SEGMENT_UPLOAD_REJECT_OUT_OF_RETENTION}: disabled 
until explicitly enabled.
+  public static final boolean DEFAULT_SEGMENT_UPLOAD_REJECT_OUT_OF_RETENTION = 
false;
   public static final int DEFAULT_MIN_NUM_CHARS_IN_IS_TO_TURN_ON_COMPRESSION = 
-1;
   public static final int DEFAULT_REALTIME_SEGMENT_METADATA_COMMIT_NUMLOCKS = 
64;
   public static final boolean DEFAULT_ENABLE_STORAGE_QUOTA_CHECK = true;
@@ -1254,6 +1267,16 @@ public class ControllerConf extends PinotConfiguration {
     setProperty(SEGMENT_UPLOAD_TIMEOUT_IN_MILLIS, 
segmentUploadTimeoutInMillis);
   }
 
+  /// @return whether out-of-retention single-segment upload rejection is 
enabled
+  public boolean isSegmentUploadRejectOutOfRetention() {
+    return getProperty(SEGMENT_UPLOAD_REJECT_OUT_OF_RETENTION, 
DEFAULT_SEGMENT_UPLOAD_REJECT_OUT_OF_RETENTION);
+  }
+
+  /// @param rejectOutOfRetention value for {@link 
#SEGMENT_UPLOAD_REJECT_OUT_OF_RETENTION}
+  public void setSegmentUploadRejectOutOfRetention(boolean 
rejectOutOfRetention) {
+    setProperty(SEGMENT_UPLOAD_REJECT_OUT_OF_RETENTION, rejectOutOfRetention);
+  }
+
   public int getRealtimeSegmentMetadataCommitNumLocks() {
     return getProperty(REALTIME_SEGMENT_METADATA_COMMIT_NUMLOCKS, 
DEFAULT_REALTIME_SEGMENT_METADATA_COMMIT_NUMLOCKS);
   }
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResource.java
index d90164e46c7..f269cc212cb 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResource.java
@@ -389,6 +389,8 @@ public class PinotSegmentUploadDownloadRestletResource {
         SegmentValidationUtils.validateTimeInterval(segmentMetadata, 
tableConfig);
       }
       
SegmentValidationUtils.validateUpsertSegmentPartitionMetadata(segmentMetadata, 
tableConfig);
+      SegmentValidationUtils.rejectUploadIfOutOfRetention(segmentMetadata, 
tableConfig, System.currentTimeMillis(),
+          _controllerConf.isSegmentUploadRejectOutOfRetention(), 
_controllerMetrics);
       long untarredSegmentSizeInBytes;
       if (uploadType == FileUploadType.METADATA && segmentSizeInBytes > 0) {
         // TODO: Include the untarred segment size when using the METADATA 
push rest API. Currently we can only use the
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/upload/SegmentValidationUtils.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/upload/SegmentValidationUtils.java
index 7c5875d59ff..f0a0c804975 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/upload/SegmentValidationUtils.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/upload/SegmentValidationUtils.java
@@ -19,9 +19,13 @@
 package org.apache.pinot.controller.api.upload;
 
 import java.util.Map;
+import java.util.OptionalLong;
 import java.util.Set;
 import javax.annotation.Nullable;
 import javax.ws.rs.core.Response;
+import org.apache.pinot.common.metrics.ControllerMeter;
+import org.apache.pinot.common.metrics.ControllerMetrics;
+import org.apache.pinot.common.utils.RetentionUtils;
 import 
org.apache.pinot.controller.api.exception.ControllerApplicationException;
 import org.apache.pinot.controller.validation.StorageQuotaChecker;
 import org.apache.pinot.segment.local.utils.TableConfigUtils;
@@ -29,6 +33,7 @@ import org.apache.pinot.segment.spi.ColumnMetadata;
 import org.apache.pinot.segment.spi.SegmentMetadata;
 import org.apache.pinot.spi.config.table.QuotaEnforcementMode;
 import org.apache.pinot.spi.config.table.SegmentPartitionConfig;
+import org.apache.pinot.spi.config.table.SegmentsValidationAndRetentionConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.utils.TimeUtils;
@@ -103,6 +108,55 @@ public class SegmentValidationUtils {
     return null;
   }
 
+  /// When `controller.segment.upload.reject.out.of.retention` is true on the 
controller, rejects the upload if
+  /// the segment's data end time (from segment file metadata) is past the 
table's `retentionTimeValue` /
+  /// `retentionTimeUnit` window. This matches the retention manager's 
time-based boundary. It does not model the
+  /// hybrid retention strategy 
(`controller.enable.hybrid.table.retention.strategy`), under which the REALTIME 
side of
+  /// a hybrid table is purged by the offline time boundary instead of the 
retention window. The two boundaries are
+  /// independent there, so this check can be either stricter or looser than 
what the retention manager purges.
+  /// **Index creation time is not** used as a fallback here: it can reflect 
upstream segment timestamps (for example
+  /// upsert compaction) and is a poor proxy for the data window at upload 
time.
+  ///
+  /// Rejection uses HTTP 403 so callers that enable the controller flag can 
treat the response as a hard failure.
+  /// Clients that do not enable the flag see no behavior change.
+  ///
+  /// Controller wiring: {@link 
org.apache.pinot.controller.api.resources.PinotSegmentUploadDownloadRestletResource}
+  /// invokes this for single-segment upload only. METADATA batch upload 
(`POST /segments/batchUpload`) and
+  /// reingested-segment upload do not call it.
+  ///
+  /// Minion task uploads are not exempt. Purge, UpsertCompaction and 
RefreshSegment always push through this
+  /// single-segment endpoint. RealtimeToOffline, MergeRollup and 
UpsertCompactMerge do too unless `batchSegmentUpload`
+  /// is enabled for the task. A rejected RealtimeToOffline upload does not 
advance the task watermark, so the same
+  /// window is retried on every run until the source segments age out.
+  ///
+  /// For tables where the retention manager does not apply time-based 
retention to completed segments (offline tables
+  /// whose batch ingestion type is not `APPEND`), this method returns without 
evaluating retention.
+  public static void rejectUploadIfOutOfRetention(SegmentMetadata 
segmentMetadata, TableConfig tableConfig,
+      long currentTimeMs, boolean rejectOutOfRetention, @Nullable 
ControllerMetrics controllerMetrics) {
+    if (!rejectOutOfRetention) {
+      return;
+    }
+    if (!RetentionUtils.shouldManageTimeBasedDataRetention(tableConfig)) {
+      return;
+    }
+    SegmentsValidationAndRetentionConfig validationConfig = 
tableConfig.getValidationConfig();
+    OptionalLong retentionMsOpt = 
RetentionUtils.parseTableDataRetentionMillis(validationConfig);
+    if (retentionMsOpt.isEmpty()) {
+      return;
+    }
+    long retentionMs = retentionMsOpt.getAsLong();
+    if (RetentionUtils.isPurgeable(tableConfig.getTableName(), 
segmentMetadata, retentionMs, currentTimeMs)) {
+      if (controllerMetrics != null) {
+        
controllerMetrics.addMeteredGlobalValue(ControllerMeter.OUT_OF_RETENTION_SEGMENT_UPLOAD_REJECTED,
 1L);
+      }
+      throw new ControllerApplicationException(LOGGER, String.format(
+          "Segment %s of table %s is outside the retention window (%s %s); 
upload rejected",
+          segmentMetadata.getName(), tableConfig.getTableName(), 
validationConfig.getRetentionTimeValue(),
+          validationConfig.getRetentionTimeUnit()),
+          Response.Status.FORBIDDEN);
+    }
+  }
+
   public static void checkStorageQuota(String segmentName, long 
tarSegmentSizeInBytes, long untarredSegmentSizeInBytes,
       TableConfig tableConfig,
       StorageQuotaChecker quotaChecker) {
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 5b3f4d4d291..92547929d14 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
@@ -45,6 +45,7 @@ import org.apache.pinot.common.metadata.ZKMetadataProvider;
 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.utils.RetentionUtils;
 import org.apache.pinot.common.utils.TarCompressionUtils;
 import org.apache.pinot.common.utils.URIUtils;
 import org.apache.pinot.controller.ControllerConf;
@@ -333,11 +334,10 @@ public class RetentionManager extends 
ControllerPeriodicTask<Void> {
     String tableNameWithType = tableConfig.getTableName();
     LOGGER.info("Start managing retention for table: {}", tableNameWithType);
 
-    // For offline tables, ensure that the segmentPushType is APPEND.
     SegmentsValidationAndRetentionConfig validationConfig = 
tableConfig.getValidationConfig();
-    String segmentPushType = 
IngestionConfigUtils.getBatchSegmentIngestionType(tableConfig);
-    if (tableConfig.getTableType() == TableType.OFFLINE && 
!"APPEND".equalsIgnoreCase(segmentPushType)) {
-      LOGGER.info("Segment push type is not APPEND for table: {}, skip 
managing retention", tableNameWithType);
+    if (!RetentionUtils.shouldManageTimeBasedDataRetention(tableConfig)) {
+      LOGGER.info("Skipping time-based retention for table: {} (offline tables 
require APPEND batch ingestion)",
+          tableNameWithType);
       return;
     }
     int untrackedSegmentsDeletionBatchSize =
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/retention/TableConfigRetentionUtils.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/retention/TableConfigRetentionUtils.java
index 9f1bf991a9d..439be7fefca 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/retention/TableConfigRetentionUtils.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/retention/TableConfigRetentionUtils.java
@@ -18,9 +18,10 @@
  */
 package org.apache.pinot.controller.helix.core.retention;
 
-import java.util.Locale;
+import java.util.OptionalLong;
 import java.util.concurrent.TimeUnit;
 import javax.annotation.Nullable;
+import org.apache.pinot.common.utils.RetentionUtils;
 import 
org.apache.pinot.controller.helix.core.retention.strategy.RetentionStrategy;
 import 
org.apache.pinot.controller.helix.core.retention.strategy.TimeRetentionStrategy;
 import org.apache.pinot.spi.config.table.SegmentsValidationAndRetentionConfig;
@@ -46,18 +47,12 @@ public class TableConfigRetentionUtils {
   public static RetentionStrategy buildRetentionStrategy(TableConfig 
tableConfig,
       boolean useCreationTimeFallback) {
     SegmentsValidationAndRetentionConfig validationConfig = 
tableConfig.getValidationConfig();
-    String unit = validationConfig.getRetentionTimeUnit();
-    String value = validationConfig.getRetentionTimeValue();
-    if (unit == null || unit.isEmpty() || value == null || value.isEmpty()) {
-      LOGGER.warn("Invalid retention time: {} {} for table: {}, skip", unit, 
value, tableConfig.getTableName());
-      return null;
-    }
-    try {
-      return new 
TimeRetentionStrategy(TimeUnit.valueOf(unit.toUpperCase(Locale.ROOT)), 
Long.parseLong(value),
-          useCreationTimeFallback);
-    } catch (Exception e) {
-      LOGGER.warn("Invalid retention time: {} {} for table: {}, skip", unit, 
value, tableConfig.getTableName());
+    OptionalLong retentionMs = 
RetentionUtils.parseTableDataRetentionMillis(validationConfig);
+    if (retentionMs.isEmpty()) {
+      LOGGER.warn("Invalid retention time: {} {} for table: {}, skip", 
validationConfig.getRetentionTimeUnit(),
+          validationConfig.getRetentionTimeValue(), 
tableConfig.getTableName());
       return null;
     }
+    return new TimeRetentionStrategy(TimeUnit.MILLISECONDS, 
retentionMs.getAsLong(), useCreationTimeFallback);
   }
 }
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/ControllerConfTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/ControllerConfTest.java
index 1fc53dcd1eb..8c4b07fbefc 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/ControllerConfTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/ControllerConfTest.java
@@ -32,6 +32,8 @@ import org.testng.Assert;
 import org.testng.annotations.Test;
 
 import static 
org.apache.pinot.controller.ControllerConf.ControllerPeriodicTasksConf.*;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
 
 
 public class ControllerConfTest {
@@ -296,4 +298,19 @@ public class ControllerConfTest {
     Assert.assertEquals(conf.getSegmentReplaceExternalViewCheckIntervalMs(), 
500L);
     Assert.assertEquals(conf.getSegmentReplaceMaxRetryAttempts(), 2);
   }
+
+  @Test
+  public void testSegmentUploadRejectOutOfRetentionDefault() {
+    ControllerConf conf = new ControllerConf();
+    assertFalse(conf.isSegmentUploadRejectOutOfRetention());
+  }
+
+  @Test
+  public void testSegmentUploadRejectOutOfRetentionOverride() {
+    ControllerConf conf = new ControllerConf();
+    conf.setSegmentUploadRejectOutOfRetention(true);
+    assertTrue(conf.isSegmentUploadRejectOutOfRetention());
+    conf.setSegmentUploadRejectOutOfRetention(false);
+    assertFalse(conf.isSegmentUploadRejectOutOfRetention());
+  }
 }
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/upload/SegmentValidationUtilsTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/upload/SegmentValidationUtilsTest.java
index 6ef9f03d28a..93cc38158de 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/upload/SegmentValidationUtilsTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/upload/SegmentValidationUtilsTest.java
@@ -20,7 +20,10 @@ package org.apache.pinot.controller.api.upload;
 
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 import javax.ws.rs.core.Response;
+import org.apache.pinot.common.metrics.ControllerMeter;
+import org.apache.pinot.common.metrics.ControllerMetrics;
 import 
org.apache.pinot.controller.api.exception.ControllerApplicationException;
 import org.apache.pinot.controller.validation.StorageQuotaChecker;
 import org.apache.pinot.segment.spi.ColumnMetadata;
@@ -40,6 +43,7 @@ import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
 import org.testng.annotations.Test;
 
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertThrows;
@@ -140,6 +144,105 @@ public class SegmentValidationUtilsTest {
     
SegmentValidationUtils.validateUpsertSegmentPartitionMetadata(mockSegmentMetadata(Set.of(1)),
 tableConfig);
   }
 
+  @Test
+  public void testRejectUploadSkippedWhenDisabled() {
+    long now = System.currentTimeMillis();
+    TableConfig tableConfig = getRetentionTableConfig(TableType.OFFLINE);
+    
SegmentValidationUtils.rejectUploadIfOutOfRetention(mockRetentionSegmentMetadata(now
 - TimeUnit.DAYS.toMillis(30)),
+        tableConfig, now, false, null);
+  }
+
+  @Test
+  public void testRejectUploadSkippedForOfflineRefresh() {
+    long now = System.currentTimeMillis();
+    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE)
+        .setTableName(RAW_TABLE_NAME)
+        .setRetentionTimeUnit("DAYS")
+        .setRetentionTimeValue("7")
+        .setSegmentPushType("REFRESH")
+        .build();
+    
SegmentValidationUtils.rejectUploadIfOutOfRetention(mockRetentionSegmentMetadata(now
 - TimeUnit.DAYS.toMillis(30)),
+        tableConfig, now, true, null);
+  }
+
+  @Test
+  public void testRejectUploadSkippedWhenRetentionInvalid() {
+    long now = System.currentTimeMillis();
+    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE)
+        .setTableName(RAW_TABLE_NAME)
+        .setRetentionTimeUnit("BAD")
+        .setRetentionTimeValue("7")
+        .build();
+    
SegmentValidationUtils.rejectUploadIfOutOfRetention(mockRetentionSegmentMetadata(now
 - TimeUnit.DAYS.toMillis(30)),
+        tableConfig, now, true, null);
+  }
+
+  @Test
+  public void testRejectUploadWhenEnabledAndSegmentOutOfRetention() {
+    long now = System.currentTimeMillis();
+    TableConfig tableConfig = getRetentionTableConfig(TableType.OFFLINE);
+    ControllerMetrics controllerMetrics = mock(ControllerMetrics.class);
+    ControllerApplicationException exception = 
expectThrows(ControllerApplicationException.class,
+        () -> SegmentValidationUtils.rejectUploadIfOutOfRetention(
+            mockRetentionSegmentMetadata(now - TimeUnit.DAYS.toMillis(30)), 
tableConfig, now, true,
+            controllerMetrics));
+    assertEquals(exception.getResponse().getStatus(), 
Response.Status.FORBIDDEN.getStatusCode());
+    
verify(controllerMetrics).addMeteredGlobalValue(ControllerMeter.OUT_OF_RETENTION_SEGMENT_UPLOAD_REJECTED,
 1L);
+  }
+
+  @Test
+  public void testRejectUploadAllowsRecentSegment() {
+    long now = System.currentTimeMillis();
+    TableConfig tableConfig = getRetentionTableConfig(TableType.OFFLINE);
+    
SegmentValidationUtils.rejectUploadIfOutOfRetention(mockRetentionSegmentMetadata(now
 - TimeUnit.DAYS.toMillis(1)),
+        tableConfig, now, true, null);
+  }
+
+  @Test
+  public void testRejectUploadDoesNotUseIndexCreationTimeFallback() {
+    long now = System.currentTimeMillis();
+    TableConfig tableConfig = getRetentionTableConfig(TableType.OFFLINE);
+    SegmentMetadata segmentMetadata = mockRetentionSegmentMetadata(-1);
+    when(segmentMetadata.getIndexCreationTime()).thenReturn(now - 
TimeUnit.DAYS.toMillis(30));
+    SegmentValidationUtils.rejectUploadIfOutOfRetention(segmentMetadata, 
tableConfig, now, true, null);
+  }
+
+  @Test
+  public void testRejectUploadSkippedWhenRetentionNotConfigured() {
+    long now = System.currentTimeMillis();
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
+    
SegmentValidationUtils.rejectUploadIfOutOfRetention(mockRetentionSegmentMetadata(now
 - TimeUnit.DAYS.toMillis(30)),
+        tableConfig, now, true, null);
+  }
+
+  @Test
+  public void testRejectUploadThrowsWhenOutOfRetentionAndMetricsNull() {
+    long now = System.currentTimeMillis();
+    TableConfig tableConfig = getRetentionTableConfig(TableType.OFFLINE);
+    ControllerApplicationException exception = 
expectThrows(ControllerApplicationException.class,
+        () -> SegmentValidationUtils.rejectUploadIfOutOfRetention(
+            mockRetentionSegmentMetadata(now - TimeUnit.DAYS.toMillis(30)), 
tableConfig, now, true, null));
+    assertEquals(exception.getResponse().getStatus(), 
Response.Status.FORBIDDEN.getStatusCode());
+  }
+
+  @Test
+  public void testRejectUploadRealtimeOutOfRetentionThrows() {
+    long now = System.currentTimeMillis();
+    TableConfig tableConfig = getRetentionTableConfig(TableType.REALTIME);
+    ControllerApplicationException exception = 
expectThrows(ControllerApplicationException.class,
+        () -> SegmentValidationUtils.rejectUploadIfOutOfRetention(
+            mockRetentionSegmentMetadata(now - TimeUnit.DAYS.toMillis(30)), 
tableConfig, now, true, null));
+    assertEquals(exception.getResponse().getStatus(), 
Response.Status.FORBIDDEN.getStatusCode());
+  }
+
+  private static TableConfig getRetentionTableConfig(TableType tableType) {
+    return new TableConfigBuilder(tableType)
+        .setTableName(RAW_TABLE_NAME)
+        .setRetentionTimeUnit("DAYS")
+        .setRetentionTimeValue("7")
+        .build();
+  }
+
   private static TableConfig getOfflineUpsertTableConfig() {
     return new TableConfigBuilder(TableType.OFFLINE)
         .setTableName(RAW_TABLE_NAME)
@@ -173,6 +276,14 @@ public class SegmentValidationUtilsTest {
     return segmentMetadata;
   }
 
+  private static SegmentMetadata mockRetentionSegmentMetadata(long endTimeMs) {
+    SegmentMetadata segmentMetadata = mock(SegmentMetadata.class);
+    when(segmentMetadata.getTimeUnit()).thenReturn(TimeUnit.MILLISECONDS);
+    when(segmentMetadata.getEndTime()).thenReturn(endTimeMs);
+    when(segmentMetadata.getName()).thenReturn(SEGMENT_NAME);
+    return segmentMetadata;
+  }
+
   private static ColumnMetadata mockColumnMetadata(Set<Integer> partitions) {
     ColumnMetadata columnMetadata = mock(ColumnMetadata.class);
     when(columnMetadata.getPartitions()).thenReturn(partitions);
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/retention/TableConfigRetentionUtilsTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/retention/TableConfigRetentionUtilsTest.java
new file mode 100644
index 00000000000..61c1484d593
--- /dev/null
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/retention/TableConfigRetentionUtilsTest.java
@@ -0,0 +1,94 @@
+/**
+ * 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.pinot.controller.helix.core.retention;
+
+import java.util.concurrent.TimeUnit;
+import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import 
org.apache.pinot.controller.helix.core.retention.strategy.RetentionStrategy;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+
+/// Tests for {@link TableConfigRetentionUtils#buildRetentionStrategy}: the 
strategy purges segments older than the
+/// table retention window, accepts case-insensitive time units, honors the 
creation-time fallback flag, and is not
+/// built when the retention unit or value is missing or invalid.
+public class TableConfigRetentionUtilsTest {
+  private static final String RAW_TABLE_NAME = "testTable";
+  private static final String SEGMENT_NAME = "testSegment";
+
+  @Test
+  public void testBuildRetentionStrategyUsesConfiguredWindow() {
+    RetentionStrategy strategy = 
TableConfigRetentionUtils.buildRetentionStrategy(getTableConfig("DAYS", "7"), 
false);
+    assertNotNull(strategy);
+    long now = System.currentTimeMillis();
+    String tableName = RAW_TABLE_NAME + "_OFFLINE";
+    assertTrue(strategy.isPurgeable(tableName, SEGMENT_NAME, now - 
TimeUnit.DAYS.toMillis(8)));
+    assertFalse(strategy.isPurgeable(tableName, SEGMENT_NAME, now - 
TimeUnit.DAYS.toMillis(6)));
+  }
+
+  @Test
+  public void testBuildRetentionStrategyAcceptsLowercaseUnit() {
+    RetentionStrategy strategy = 
TableConfigRetentionUtils.buildRetentionStrategy(getTableConfig("hours", "2"), 
false);
+    assertNotNull(strategy);
+    long now = System.currentTimeMillis();
+    String tableName = RAW_TABLE_NAME + "_OFFLINE";
+    assertTrue(strategy.isPurgeable(tableName, SEGMENT_NAME, now - 
TimeUnit.HOURS.toMillis(3)));
+    assertFalse(strategy.isPurgeable(tableName, SEGMENT_NAME, now - 
TimeUnit.HOURS.toMillis(1)));
+  }
+
+  @Test
+  public void testBuildRetentionStrategyPropagatesCreationTimeFallback() {
+    SegmentZKMetadata segmentZKMetadata = new SegmentZKMetadata(SEGMENT_NAME);
+    segmentZKMetadata.setCreationTime(System.currentTimeMillis() - 
TimeUnit.DAYS.toMillis(30));
+    String tableName = RAW_TABLE_NAME + "_OFFLINE";
+
+    RetentionStrategy withFallback =
+        
TableConfigRetentionUtils.buildRetentionStrategy(getTableConfig("DAYS", "7"), 
true);
+    assertNotNull(withFallback);
+    assertTrue(withFallback.isPurgeable(tableName, segmentZKMetadata));
+
+    RetentionStrategy withoutFallback =
+        
TableConfigRetentionUtils.buildRetentionStrategy(getTableConfig("DAYS", "7"), 
false);
+    assertNotNull(withoutFallback);
+    assertFalse(withoutFallback.isPurgeable(tableName, segmentZKMetadata));
+  }
+
+  @Test
+  public void 
testBuildRetentionStrategyReturnsNullForInvalidOrMissingRetention() {
+    
assertNull(TableConfigRetentionUtils.buildRetentionStrategy(getTableConfig("BAD",
 "7"), false));
+    
assertNull(TableConfigRetentionUtils.buildRetentionStrategy(getTableConfig("DAYS",
 "abc"), false));
+    assertNull(TableConfigRetentionUtils.buildRetentionStrategy(
+        new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build(), 
false));
+  }
+
+  private static TableConfig getTableConfig(String retentionTimeUnit, String 
retentionTimeValue) {
+    return new TableConfigBuilder(TableType.OFFLINE)
+        .setTableName(RAW_TABLE_NAME)
+        .setRetentionTimeUnit(retentionTimeUnit)
+        .setRetentionTimeValue(retentionTimeValue)
+        .build();
+  }
+}
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/SegmentUploadIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/SegmentUploadIntegrationTest.java
index 1194dd1c6aa..81f3549e96e 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/SegmentUploadIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/SegmentUploadIntegrationTest.java
@@ -22,10 +22,16 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.google.common.collect.Lists;
 import java.io.File;
 import java.io.IOException;
+import java.net.URI;
 import java.util.List;
 import java.util.Map;
 import org.apache.commons.io.FileUtils;
 import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.pinot.common.exception.HttpErrorStatusException;
+import org.apache.pinot.common.metrics.ControllerMeter;
+import org.apache.pinot.common.utils.FileUploadDownloadClient;
+import org.apache.pinot.controller.ControllerConf;
 import org.apache.pinot.plugin.ingestion.batch.common.BaseSegmentPushJobRunner;
 import 
org.apache.pinot.plugin.ingestion.batch.standalone.SegmentMetadataPushJobRunner;
 import 
org.apache.pinot.plugin.ingestion.batch.standalone.SegmentTarPushJobRunner;
@@ -39,6 +45,7 @@ import org.apache.pinot.spi.ingestion.batch.spec.PinotFSSpec;
 import org.apache.pinot.spi.ingestion.batch.spec.PushJobSpec;
 import org.apache.pinot.spi.ingestion.batch.spec.SegmentGenerationJobSpec;
 import org.apache.pinot.spi.ingestion.batch.spec.TableSpec;
+import org.apache.pinot.spi.metrics.PinotMeter;
 import org.apache.pinot.spi.utils.JsonUtils;
 import org.apache.pinot.spi.utils.builder.TableNameBuilder;
 import org.apache.pinot.util.TestUtils;
@@ -49,6 +56,12 @@ import org.testng.annotations.BeforeClass;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+
 
 /// Test for advanced push types.
 /// Currently only tests METADATA push type.
@@ -93,6 +106,13 @@ public class SegmentUploadIntegrationTest extends 
BaseClusterIntegrationTest {
     TestUtils.ensureDirectoriesExistAndEmpty(_tempDir, _segmentDir, _tarDir);
   }
 
+  @Override
+  protected void overrideControllerConf(Map<String, Object> properties) {
+    super.overrideControllerConf(properties);
+    // Applies to every test in this class: any APPEND table configured with a 
retention window is checked on upload.
+    properties.put(ControllerConf.SEGMENT_UPLOAD_REJECT_OUT_OF_RETENTION, 
true);
+  }
+
   @BeforeClass
   public void setUp()
       throws Exception {
@@ -390,6 +410,67 @@ public class SegmentUploadIntegrationTest extends 
BaseClusterIntegrationTest {
     Assert.assertTrue(segmentLineageResponse.contains("\"segmentsTo\":[\"" + 
secondSegmentName + "\"]"));
   }
 
+  @Test
+  public void testUploadRejectedWhenSegmentOutOfRetention()
+      throws Exception {
+    // The test data ends in 2014, so it is far outside a 7-day retention 
window.
+    File segmentTarFile = createTableAndBuildSegment("7");
+    long rejectedBefore = getOutOfRetentionRejectedCount();
+
+    try (FileUploadDownloadClient fileUploadDownloadClient = new 
FileUploadDownloadClient()) {
+      HttpErrorStatusException exception = 
expectThrows(HttpErrorStatusException.class,
+          () -> 
fileUploadDownloadClient.uploadSegment(URI.create(getOrCreateAdminClient().getSegmentUploadUrl()),
+              segmentTarFile.getName(), segmentTarFile, getTableName(), 
TableType.OFFLINE));
+      assertEquals(exception.getStatusCode(), HttpStatus.SC_FORBIDDEN);
+      assertTrue(exception.getMessage().contains("outside the retention 
window"), exception.getMessage());
+    }
+
+    assertEquals(getSegmentsList().size(), 0);
+    assertFalse(new File(_controllerConfig.getDataDir(), 
getTableName()).exists());
+    assertEquals(getOutOfRetentionRejectedCount(), rejectedBefore + 1);
+  }
+
+  @Test
+  public void testUploadAcceptedWhenSegmentWithinRetention()
+      throws Exception {
+    File segmentTarFile = createTableAndBuildSegment("36500");
+    long rejectedBefore = getOutOfRetentionRejectedCount();
+
+    try (FileUploadDownloadClient fileUploadDownloadClient = new 
FileUploadDownloadClient()) {
+      assertEquals(
+          
fileUploadDownloadClient.uploadSegment(URI.create(getOrCreateAdminClient().getSegmentUploadUrl()),
+              segmentTarFile.getName(), segmentTarFile, getTableName(), 
TableType.OFFLINE).getStatusCode(),
+          HttpStatus.SC_OK);
+    }
+
+    assertEquals(getSegmentsList().size(), 1);
+    assertEquals(getOutOfRetentionRejectedCount(), rejectedBefore);
+  }
+
+  private File createTableAndBuildSegment(String retentionDays)
+      throws Exception {
+    Schema schema = createSchema();
+    addSchema(schema);
+    TableConfig offlineTableConfig = createOfflineTableConfig();
+    offlineTableConfig.getValidationConfig().setRetentionTimeUnit("DAYS");
+    
offlineTableConfig.getValidationConfig().setRetentionTimeValue(retentionDays);
+    waitForEVToDisappear(offlineTableConfig.getTableName());
+    addTableConfig(offlineTableConfig);
+
+    ClusterIntegrationTestUtils.buildSegmentFromAvro(getAllAvroFiles().get(0), 
offlineTableConfig, schema,
+        "_retention", _segmentDir, _tarDir);
+    File[] tarFiles = _tarDir.listFiles();
+    assertNotNull(tarFiles);
+    assertEquals(tarFiles.length, 1);
+    return tarFiles[0];
+  }
+
+  private long getOutOfRetentionRejectedCount() {
+    PinotMeter meter = _controllerStarter.getControllerMetrics()
+        
.getMeteredValue(ControllerMeter.OUT_OF_RETENTION_SEGMENT_UPLOAD_REJECTED);
+    return meter != null ? meter.count() : 0L;
+  }
+
   protected TableConfig createOfflineTableConfigWithConsistentPush() {
     TableConfig offlineTableConfig = createOfflineTableConfig();
     IngestionConfig ingestionConfig = new IngestionConfig();


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

Reply via email to