This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 00eafa5ed9 [MINOR] feat(policy): Persist snapshot-expiration fields
and validate Iceberg policy content (#14229)
00eafa5ed9 is described below
commit 00eafa5ed9a4dd9ef1e6843d14f96ea2a653b47c
Author: MaSai <[email protected]>
AuthorDate: Mon Oct 26 19:38:23 2026 +0800
[MINOR] feat(policy): Persist snapshot-expiration fields and validate
Iceberg policy content (#14229)
### What changes were proposed in this pull request?
1. **Snapshot expiration fields** — add `olderThanDays` (default **5**),
`retainLast` (1), `streamResults` (false) so empty placeholder content
can be persisted without changing `JsonUtils`.
2. **Optimizer wiring (orphan-style)** —
`SnapshotExpirationStrategyHandler` (`trigger-expr: true`),
`SnapshotExpirationJobContext`, `GravitinoSnapshotExpirationJobAdapter`,
registered in `GravitinoJobSubmitter`.
3. **Save-time validation for Iceberg maintenance policies:**
- **compaction** — allowlist `rewriteOptions` names/types/ranges
- **orphan** — reject unsafe `location` syntax (`..`, `\`, `%`,
query/fragment)
- **snapshot expiration** — range-check `olderThanDays` / `retainLast`
- **rewrite manifests** — existing threshold checks (unchanged)
### Why are the changes needed?
- Empty snapshot-expiration content fails Jackson `FAIL_ON_EMPTY_BEANS`
on insert (HTTP 500).
- Compaction accepts unknown/invalid `rewriteOptions` on save; jobs fail
later.
- Snapshot expiration had no recommender path; wire it like orphan
(schedule-driven, always-eligible trigger) rather than a snapshot-count
gate.
### Does this PR introduce _any_ user-facing change?
Yes. Snapshot-expiration content may include the three optional fields;
invalid compaction `rewriteOptions` and unsafe orphan `location` syntax
return **400** on create/update. Operators can register
`gravitino.optimizer.strategyHandler.iceberg-snapshot-expiration.className`
to enable policy-driven expire.
### How was this patch tested?
`./gradlew :api:test --tests
org.apache.gravitino.policy.TestPolicyContents --tests
org.apache.gravitino.policy.TestIcebergOrphanFileRemovalContent --tests
org.apache.gravitino.policy.TestIcebergRewriteManifestsContent --tests
org.apache.gravitino.policy.TestIcebergSnapshotExpirationContent
:maintenance:optimizer:test --tests
org.apache.gravitino.maintenance.optimizer.recommender.job.TestGravitinoSnapshotExpirationJobAdapter
-PskipITs`
---------
Co-authored-by: Cursor <[email protected]>
---
.../policy/IcebergDataCompactionContent.java | 138 ++++-
.../policy/IcebergOrphanFileRemovalContent.java | 13 +
.../policy/IcebergSnapshotExpirationContent.java | 125 ++++-
.../apache/gravitino/policy/PolicyContents.java | 17 +-
.../TestIcebergOrphanFileRemovalContent.java | 18 +
.../TestIcebergSnapshotExpirationContent.java | 70 +++
.../gravitino/policy/TestPolicyContents.java | 28 +
.../gravitino/dto/policy/PolicyContentDTO.java | 74 ++-
.../dto/requests/PolicyUpdateRequest.java | 7 +-
.../apache/gravitino/dto/util/DTOConverters.java | 12 +-
.../policy/TestSnapshotExpirationPolicyDTO.java | 196 +++++++
conf/gravitino-optimizer.conf.template | 6 +
.../org/apache/gravitino/meta/PolicyEntity.java | 38 +-
.../org/apache/gravitino/policy/PolicyManager.java | 12 +-
.../storage/relational/utils/POConverters.java | 4 +-
.../apache/gravitino/policy/TestPolicyManager.java | 38 ++
.../storage/relational/utils/TestPOConverters.java | 41 ++
.../iceberg-expire-snapshots-maintenance-job.md | 578 +++++++--------------
docs/open-api/policies.yaml | 24 +-
.../optimizer-configuration.md | 47 +-
.../optimizer/recommender/Recommender.java | 22 +-
.../snapshot/SnapshotExpirationJobContext.java | 59 +++
.../SnapshotExpirationStrategyHandler.java | 63 +++
.../recommender/job/GravitinoJobSubmitter.java | 5 +-
.../job/GravitinoSnapshotExpirationJobAdapter.java | 120 +++++
.../TestGravitinoSnapshotExpirationJobAdapter.java | 149 ++++++
26 files changed, 1450 insertions(+), 454 deletions(-)
diff --git
a/api/src/main/java/org/apache/gravitino/policy/IcebergDataCompactionContent.java
b/api/src/main/java/org/apache/gravitino/policy/IcebergDataCompactionContent.java
index 08c8478d66..011aec1ba1 100644
---
a/api/src/main/java/org/apache/gravitino/policy/IcebergDataCompactionContent.java
+++
b/api/src/main/java/org/apache/gravitino/policy/IcebergDataCompactionContent.java
@@ -27,6 +27,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
+import java.util.function.Consumer;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.MetadataObject;
@@ -96,9 +97,40 @@ public class IcebergDataCompactionContent implements
PolicyContent {
/** Default Iceberg sort order (empty means unset). */
public static final String DEFAULT_SORT_ORDER = "";
+ private enum RewriteOptionValueKind {
+ POSITIVE_LONG,
+ POSITIVE_INT,
+ NON_NEGATIVE_INT,
+ BOOLEAN,
+ REWRITE_JOB_ORDER,
+ RATIO
+ }
+
private static final Set<String> SUPPORTED_REWRITE_STRATEGIES =
ImmutableSet.of(REWRITE_STRATEGY_BINPACK, REWRITE_STRATEGY_SORT);
private static final Pattern OPTION_KEY_PATTERN =
Pattern.compile("[A-Za-z0-9._-]+");
+ private static final Set<String> REWRITE_JOB_ORDERS =
+ ImmutableSet.of("bytes-asc", "bytes-desc", "files-asc", "files-desc",
"none");
+ // Names mirror Iceberg RewriteDataFiles / SizeBasedFileRewriter /
SizeBasedDataRewriter.
+ private static final Map<String, RewriteOptionValueKind>
SUPPORTED_REWRITE_OPTIONS =
+ ImmutableMap.<String, RewriteOptionValueKind>builder()
+ .put("target-file-size-bytes", RewriteOptionValueKind.POSITIVE_LONG)
+ .put("min-file-size-bytes", RewriteOptionValueKind.POSITIVE_LONG)
+ .put("max-file-size-bytes", RewriteOptionValueKind.POSITIVE_LONG)
+ .put("max-file-group-size-bytes",
RewriteOptionValueKind.POSITIVE_LONG)
+ .put("min-input-files", RewriteOptionValueKind.POSITIVE_INT)
+ .put("max-concurrent-file-group-rewrites",
RewriteOptionValueKind.POSITIVE_INT)
+ .put("partial-progress.max-commits",
RewriteOptionValueKind.POSITIVE_INT)
+ .put("partial-progress.max-failed-commits",
RewriteOptionValueKind.NON_NEGATIVE_INT)
+ .put("output-spec-id", RewriteOptionValueKind.NON_NEGATIVE_INT)
+ .put("delete-file-threshold",
RewriteOptionValueKind.NON_NEGATIVE_INT)
+ .put("rewrite-all", RewriteOptionValueKind.BOOLEAN)
+ .put("partial-progress.enabled", RewriteOptionValueKind.BOOLEAN)
+ .put("use-starting-sequence-number", RewriteOptionValueKind.BOOLEAN)
+ .put("remove-dangling-deletes", RewriteOptionValueKind.BOOLEAN)
+ .put("rewrite-job-order", RewriteOptionValueKind.REWRITE_JOB_ORDER)
+ .put("delete-ratio-threshold", RewriteOptionValueKind.RATIO)
+ .build();
private static final Set<MetadataObject.Type> SUPPORTED_OBJECT_TYPES =
ImmutableSet.of(
MetadataObject.Type.CATALOG, MetadataObject.Type.SCHEMA,
MetadataObject.Type.TABLE);
@@ -284,6 +316,7 @@ public class IcebergDataCompactionContent implements
PolicyContent {
StringUtils.isNotBlank(sortOrder),
"sortOrder must be set when rewriteStrategy is '%s'",
REWRITE_STRATEGY_SORT);
+ // Full Iceberg parse needs Spark ExtendedParser + table Schema; that
runs in the job.
} else {
Preconditions.checkArgument(
StringUtils.isBlank(sortOrder),
@@ -291,21 +324,7 @@ public class IcebergDataCompactionContent implements
PolicyContent {
rewriteStrategy);
}
- rewriteOptions.forEach(
- (key, value) -> {
- Preconditions.checkArgument(StringUtils.isNotBlank(key), "rewrite
option key is blank");
- Preconditions.checkArgument(
- OPTION_KEY_PATTERN.matcher(key).matches(),
- "rewrite option key '%s' contains illegal characters",
- key);
- Preconditions.checkArgument(
- !key.startsWith(JOB_OPTIONS_PREFIX),
- "rewrite option key '%s' must not start with '%s'",
- key,
- JOB_OPTIONS_PREFIX);
- Preconditions.checkArgument(
- StringUtils.isNotBlank(value), "rewrite option '%s' must have
non-empty value", key);
- });
+
rewriteOptions.forEach(IcebergDataCompactionContent::validateRewriteOption);
}
@Override
@@ -360,4 +379,93 @@ public class IcebergDataCompactionContent implements
PolicyContent {
+ rewriteOptions
+ '}';
}
+
+ private static void validateRewriteOption(String key, String value) {
+ Preconditions.checkArgument(StringUtils.isNotBlank(key), "rewrite option
key is blank");
+ Preconditions.checkArgument(
+ OPTION_KEY_PATTERN.matcher(key).matches(),
+ "rewrite option key '%s' contains illegal characters",
+ key);
+ Preconditions.checkArgument(
+ !key.startsWith(JOB_OPTIONS_PREFIX),
+ "rewrite option key '%s' must not start with '%s'",
+ key,
+ JOB_OPTIONS_PREFIX);
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(value), "rewrite option '%s' must have
non-empty value", key);
+ RewriteOptionValueKind kind = SUPPORTED_REWRITE_OPTIONS.get(key);
+ Preconditions.checkArgument(
+ kind != null,
+ "rewrite option '%s' is not supported by rewrite_data_files; supported
options are %s",
+ key,
+ SUPPORTED_REWRITE_OPTIONS.keySet());
+ switch (kind) {
+ case POSITIVE_LONG:
+ parseRewriteOptionLong(
+ key,
+ value,
+ v -> Preconditions.checkArgument(v > 0, rewriteOptionMessage(key,
"a positive long")));
+ break;
+ case POSITIVE_INT:
+ parseRewriteOptionInt(
+ key,
+ value,
+ v -> Preconditions.checkArgument(v > 0, rewriteOptionMessage(key,
"a positive int")));
+ break;
+ case NON_NEGATIVE_INT:
+ parseRewriteOptionInt(
+ key,
+ value,
+ v ->
+ Preconditions.checkArgument(
+ v >= 0, rewriteOptionMessage(key, "a non-negative int")));
+ break;
+ case BOOLEAN:
+ Preconditions.checkArgument(
+ "true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value),
+ rewriteOptionMessage(key, "true or false"));
+ break;
+ case REWRITE_JOB_ORDER:
+ Preconditions.checkArgument(
+ REWRITE_JOB_ORDERS.contains(value.toLowerCase(Locale.ROOT)),
+ "rewrite option '%s' must be one of %s, but got '%s'",
+ key,
+ REWRITE_JOB_ORDERS,
+ value);
+ break;
+ case RATIO:
+ try {
+ double ratio = Double.parseDouble(value.trim());
+ Preconditions.checkArgument(
+ ratio >= 0.0D && ratio <= 1.0D,
+ rewriteOptionMessage(key, "a number between 0 and 1"));
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(
+ rewriteOptionMessage(key, "a number between 0 and 1"), e);
+ }
+ break;
+ default:
+ throw new IllegalStateException("Unhandled rewrite option kind: " +
kind);
+ }
+ }
+
+ private static void parseRewriteOptionLong(String key, String value,
Consumer<Long> check) {
+ try {
+ check.accept(Long.parseLong(value.trim()));
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(rewriteOptionMessage(key, "a long"),
e);
+ }
+ }
+
+ private static void parseRewriteOptionInt(String key, String value,
Consumer<Integer> check) {
+ try {
+ check.accept(Integer.parseInt(value.trim()));
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(rewriteOptionMessage(key, "an int"),
e);
+ }
+ }
+
+ private static String rewriteOptionMessage(String key, String expected) {
+ return String.format("rewrite option '%s' must be %s", key, expected);
+ }
}
diff --git
a/api/src/main/java/org/apache/gravitino/policy/IcebergOrphanFileRemovalContent.java
b/api/src/main/java/org/apache/gravitino/policy/IcebergOrphanFileRemovalContent.java
index 2823901b7b..d10f6e2071 100644
---
a/api/src/main/java/org/apache/gravitino/policy/IcebergOrphanFileRemovalContent.java
+++
b/api/src/main/java/org/apache/gravitino/policy/IcebergOrphanFileRemovalContent.java
@@ -143,6 +143,19 @@ public class IcebergOrphanFileRemovalContent implements
PolicyContent {
Preconditions.checkArgument(
location == null ||
location.equals(StringUtils.strip(location.trim())),
"location must not have leading or trailing whitespace");
+ if (location != null) {
+ // Mirror OrphanFileLocationUtils.normalizeLocation safety without
needing a table root.
+ Preconditions.checkArgument(
+ !location.contains(".."), "location must not contain '..' path
segments: %s", location);
+ Preconditions.checkArgument(
+ !location.contains("\\"), "location must not contain '\\': %s",
location);
+ Preconditions.checkArgument(
+ !location.contains("%"), "location must not contain
percent-encoding: %s", location);
+ Preconditions.checkArgument(
+ !location.contains("?") && !location.contains("#"),
+ "location must not contain query or fragment: %s",
+ location);
+ }
}
@Override
diff --git
a/api/src/main/java/org/apache/gravitino/policy/IcebergSnapshotExpirationContent.java
b/api/src/main/java/org/apache/gravitino/policy/IcebergSnapshotExpirationContent.java
index d752d8f905..6c6a62f2df 100644
---
a/api/src/main/java/org/apache/gravitino/policy/IcebergSnapshotExpirationContent.java
+++
b/api/src/main/java/org/apache/gravitino/policy/IcebergSnapshotExpirationContent.java
@@ -18,19 +18,23 @@
*/
package org.apache.gravitino.policy;
+import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
+import javax.annotation.Nullable;
import org.apache.gravitino.MetadataObject;
/**
- * Placeholder built-in policy content for Iceberg snapshot expiration.
+ * Configuration for Iceberg snapshot expiration on an optimizer invocation.
*
- * <p>Configurable thresholds will be added when the expire-snapshots policy
lands; this class
- * registers the built-in type for TMS task mapping.
+ * <p>Maps to {@code builtin-iceberg-expire-snapshots} job options ({@code
older_than} is derived
+ * from {@link #olderThanDays()} by the job adapter; {@code retain_last} and
{@code stream_results}
+ * are forwarded).
*/
public class IcebergSnapshotExpirationContent implements PolicyContent {
/** Property key for strategy type. */
@@ -41,21 +45,68 @@ public class IcebergSnapshotExpirationContent implements
PolicyContent {
public static final String TRIGGER_EXPR_KEY = "trigger-expr";
/** Rule key for the score expression. */
public static final String SCORE_EXPR_KEY = "score-expr";
+ /** Prefix for policy rules forwarded as job options. */
+ public static final String JOB_OPTIONS_PREFIX = "job.options.";
+ /** Retention age option key shared with the job adapter. */
+ public static final String OLDER_THAN_DAYS_KEY = "olderThanDays";
+ /** Retain-last option key shared with the job adapter. */
+ public static final String RETAIN_LAST_KEY = "retainLast";
+ /** Stream-results option key shared with the job adapter. */
+ public static final String STREAM_RESULTS_KEY = "streamResults";
+ /** Maximum retention, approximately 100 years. */
+ public static final long MAX_OLDER_THAN_DAYS = 36500;
/** Strategy type for snapshot expiration. */
public static final String STRATEGY_TYPE_VALUE =
"iceberg-snapshot-expiration";
/** Built-in job template name for expire snapshots. */
public static final String JOB_TEMPLATE_NAME_VALUE =
"builtin-iceberg-expire-snapshots";
+ /** Default retention in days (Iceberg expire_snapshots default: five days
ago). */
+ public static final long DEFAULT_OLDER_THAN_DAYS = 5;
+ /** Default number of recent snapshots to keep. */
+ public static final long DEFAULT_RETAIN_LAST = 1;
+ /** Default stream-results flag. */
+ public static final boolean DEFAULT_STREAM_RESULTS = false;
- private static final Set<MetadataObject.Type> SUPPORTED_OBJECT_TYPES =
- ImmutableSet.of(
- MetadataObject.Type.CATALOG, MetadataObject.Type.SCHEMA,
MetadataObject.Type.TABLE);
+ private final long olderThanDays;
+ private final long retainLast;
+ private final boolean streamResults;
- /** Default constructor for Jackson deserialization only. */
- public IcebergSnapshotExpirationContent() {}
+ /** Creates content with built-in defaults. */
+ public IcebergSnapshotExpirationContent() {
+ this(DEFAULT_OLDER_THAN_DAYS, DEFAULT_RETAIN_LAST, DEFAULT_STREAM_RESULTS);
+ }
+
+ IcebergSnapshotExpirationContent(
+ @Nullable Long olderThanDays, @Nullable Long retainLast, @Nullable
Boolean streamResults) {
+ this.olderThanDays = olderThanDays == null ? DEFAULT_OLDER_THAN_DAYS :
olderThanDays;
+ this.retainLast = retainLast == null ? DEFAULT_RETAIN_LAST : retainLast;
+ this.streamResults = streamResults == null ? DEFAULT_STREAM_RESULTS :
streamResults;
+ }
+
+ /**
+ * @return minimum age of snapshots eligible for expiration, in days
+ */
+ public long olderThanDays() {
+ return olderThanDays;
+ }
+
+ /**
+ * @return minimum number of recent snapshots to keep regardless of age
+ */
+ public long retainLast() {
+ return retainLast;
+ }
+
+ /**
+ * @return whether the expire job should stream intermediate delete results
+ */
+ public boolean streamResults() {
+ return streamResults;
+ }
@Override
public Set<MetadataObject.Type> supportedObjectTypes() {
- return SUPPORTED_OBJECT_TYPES;
+ return ImmutableSet.of(
+ MetadataObject.Type.CATALOG, MetadataObject.Type.SCHEMA,
MetadataObject.Type.TABLE);
}
@Override
@@ -67,24 +118,70 @@ public class IcebergSnapshotExpirationContent implements
PolicyContent {
@Override
public Map<String, Object> rules() {
Map<String, Object> rules = new LinkedHashMap<>();
- // Placeholders must not auto-trigger until real thresholds land in a
follow-up.
- rules.put(TRIGGER_EXPR_KEY, "false");
+ // Eligibility is evaluated when the optimizer is invoked. Scheduling is
owned by the caller.
+ rules.put(TRIGGER_EXPR_KEY, "true");
rules.put(SCORE_EXPR_KEY, "1");
+ rules.put(JOB_OPTIONS_PREFIX + OLDER_THAN_DAYS_KEY, olderThanDays);
+ rules.put(JOB_OPTIONS_PREFIX + RETAIN_LAST_KEY, retainLast);
+ rules.put(JOB_OPTIONS_PREFIX + STREAM_RESULTS_KEY, streamResults);
return Collections.unmodifiableMap(rules);
}
+ /**
+ * Validates retention age before storing a policy or submitting a job.
+ *
+ * @param days minimum snapshot age in days
+ * @throws IllegalArgumentException if days is outside the supported range
+ */
+ public static void validateOlderThanDays(long days) {
+ Preconditions.checkArgument(
+ days >= 1 && days <= MAX_OLDER_THAN_DAYS,
+ "olderThanDays must be between 1 and %s",
+ MAX_OLDER_THAN_DAYS);
+ }
+
+ /**
+ * Validates retain-last before storing a policy or submitting a job.
+ *
+ * @param retainLast number of recent snapshots to keep
+ * @throws IllegalArgumentException if retainLast is not a positive long
+ */
+ public static void validateRetainLast(long retainLast) {
+ Preconditions.checkArgument(retainLast >= 1, "retainLast must be a
positive long");
+ }
+
+ @Override
+ public void validate() {
+ PolicyContent.super.validate();
+ validateOlderThanDays(olderThanDays);
+ validateRetainLast(retainLast);
+ }
+
@Override
public boolean equals(Object other) {
- return other instanceof IcebergSnapshotExpirationContent;
+ if (!(other instanceof IcebergSnapshotExpirationContent)) {
+ return false;
+ }
+ IcebergSnapshotExpirationContent that = (IcebergSnapshotExpirationContent)
other;
+ return olderThanDays == that.olderThanDays
+ && retainLast == that.retainLast
+ && streamResults == that.streamResults;
}
@Override
public int hashCode() {
- return IcebergSnapshotExpirationContent.class.hashCode();
+ return Objects.hash(olderThanDays, retainLast, streamResults);
}
@Override
public String toString() {
- return "IcebergSnapshotExpirationContent{}";
+ return "IcebergSnapshotExpirationContent{"
+ + "olderThanDays="
+ + olderThanDays
+ + ", retainLast="
+ + retainLast
+ + ", streamResults="
+ + streamResults
+ + '}';
}
}
diff --git a/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
b/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
index d1028a29fb..640424a64b 100644
--- a/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
+++ b/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
@@ -206,12 +206,25 @@ public class PolicyContents {
}
/**
- * Creates placeholder Iceberg snapshot-expiration policy content.
+ * Creates Iceberg snapshot-expiration policy content with built-in defaults.
*
* @return snapshot-expiration policy content
*/
public static PolicyContent icebergSnapshotExpiration() {
- return new IcebergSnapshotExpirationContent();
+ return icebergSnapshotExpiration(null, null, null);
+ }
+
+ /**
+ * Creates Iceberg snapshot-expiration policy content. Null arguments use
built-in defaults.
+ *
+ * @param olderThanDays minimum snapshot age in days, or null for the default
+ * @param retainLast minimum recent snapshots to keep, or null for the
default
+ * @param streamResults whether to stream intermediate delete results, or
null for the default
+ * @return snapshot-expiration policy content
+ */
+ public static IcebergSnapshotExpirationContent icebergSnapshotExpiration(
+ @Nullable Long olderThanDays, @Nullable Long retainLast, @Nullable
Boolean streamResults) {
+ return new IcebergSnapshotExpirationContent(olderThanDays, retainLast,
streamResults);
}
/**
diff --git
a/api/src/test/java/org/apache/gravitino/policy/TestIcebergOrphanFileRemovalContent.java
b/api/src/test/java/org/apache/gravitino/policy/TestIcebergOrphanFileRemovalContent.java
index f082ecaad9..87cea9eeda 100644
---
a/api/src/test/java/org/apache/gravitino/policy/TestIcebergOrphanFileRemovalContent.java
+++
b/api/src/test/java/org/apache/gravitino/policy/TestIcebergOrphanFileRemovalContent.java
@@ -94,4 +94,22 @@ class TestIcebergOrphanFileRemovalContent {
Assertions.assertTrue(error.getMessage().contains("olderThanDays"));
}
}
+
+ @Test
+ void rejectsUnsafeLocationSyntax() {
+ for (String location :
+ new String[] {
+ "s3://bucket/table/../other",
+ "s3://bucket/table/%2e%2e/other",
+ "s3://bucket/table\\data",
+ "s3://bucket/table/data?query",
+ "s3://bucket/table/data#frag"
+ }) {
+ IllegalArgumentException error =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> PolicyContents.icebergOrphanFileRemoval(3, location,
true).validate());
+ Assertions.assertTrue(error.getMessage().contains("location"));
+ }
+ }
}
diff --git
a/api/src/test/java/org/apache/gravitino/policy/TestIcebergSnapshotExpirationContent.java
b/api/src/test/java/org/apache/gravitino/policy/TestIcebergSnapshotExpirationContent.java
new file mode 100644
index 0000000000..96ce1c6b4c
--- /dev/null
+++
b/api/src/test/java/org/apache/gravitino/policy/TestIcebergSnapshotExpirationContent.java
@@ -0,0 +1,70 @@
+/*
+ * 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.gravitino.policy;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link IcebergSnapshotExpirationContent}. */
+public class TestIcebergSnapshotExpirationContent {
+
+ @Test
+ public void testDefaultsAndRules() {
+ IcebergSnapshotExpirationContent content =
+ (IcebergSnapshotExpirationContent)
PolicyContents.icebergSnapshotExpiration();
+ assertEquals(IcebergSnapshotExpirationContent.DEFAULT_OLDER_THAN_DAYS,
content.olderThanDays());
+ assertEquals(IcebergSnapshotExpirationContent.DEFAULT_RETAIN_LAST,
content.retainLast());
+ assertEquals(IcebergSnapshotExpirationContent.DEFAULT_STREAM_RESULTS,
content.streamResults());
+ assertEquals("true",
content.rules().get(IcebergSnapshotExpirationContent.TRIGGER_EXPR_KEY));
+ assertEquals(
+ content.olderThanDays(),
+ content
+ .rules()
+ .get(
+ IcebergSnapshotExpirationContent.JOB_OPTIONS_PREFIX
+ + IcebergSnapshotExpirationContent.OLDER_THAN_DAYS_KEY));
+ }
+
+ @Test
+ public void testValidateRejectsInvalidBounds() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> PolicyContents.icebergSnapshotExpiration(0L, 1L,
false).validate());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> PolicyContents.icebergSnapshotExpiration(1L, 0L,
false).validate());
+ IllegalArgumentException tooOld =
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ PolicyContents.icebergSnapshotExpiration(
+ IcebergSnapshotExpirationContent.MAX_OLDER_THAN_DAYS +
1, 1L, false)
+ .validate());
+ assertTrue(tooOld.getMessage().contains("olderThanDays"));
+ }
+
+ @Test
+ public void testValidateAcceptsDefaults() {
+ PolicyContents.icebergSnapshotExpiration().validate();
+ PolicyContents.icebergSnapshotExpiration(5L, 1L, true).validate();
+ }
+}
diff --git
a/api/src/test/java/org/apache/gravitino/policy/TestPolicyContents.java
b/api/src/test/java/org/apache/gravitino/policy/TestPolicyContents.java
index ca456c2c05..e378f4c7fd 100644
--- a/api/src/test/java/org/apache/gravitino/policy/TestPolicyContents.java
+++ b/api/src/test/java/org/apache/gravitino/policy/TestPolicyContents.java
@@ -179,6 +179,34 @@ public class TestPolicyContents {
Assertions.assertTrue(exception.getMessage().contains("maxPartitionNum"));
}
+ @Test
+ void testIcebergCompactionContentRejectsUnknownOrInvalidRewriteOptions() {
+ IllegalArgumentException unknown =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ PolicyContents.icebergDataCompaction(1000L, 1L,
mapOf("no-such-option", "x"))
+ .validate());
+ Assertions.assertTrue(unknown.getMessage().contains("no-such-option"));
+
+ IllegalArgumentException negative =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ PolicyContents.icebergDataCompaction(
+ 1000L, 1L, mapOf("target-file-size-bytes", "-1"))
+ .validate());
+
Assertions.assertTrue(negative.getMessage().contains("target-file-size-bytes"));
+
+ IllegalArgumentException notBool =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ PolicyContents.icebergDataCompaction(1000L, 1L,
mapOf("rewrite-all", "yes"))
+ .validate());
+ Assertions.assertTrue(notBool.getMessage().contains("rewrite-all"));
+ }
+
@Test
void testAccessControlContent() {
AccessControlContent content =
diff --git
a/common/src/main/java/org/apache/gravitino/dto/policy/PolicyContentDTO.java
b/common/src/main/java/org/apache/gravitino/dto/policy/PolicyContentDTO.java
index 0692a92a24..323d1d3efa 100644
--- a/common/src/main/java/org/apache/gravitino/dto/policy/PolicyContentDTO.java
+++ b/common/src/main/java/org/apache/gravitino/dto/policy/PolicyContentDTO.java
@@ -43,6 +43,7 @@ import org.apache.gravitino.policy.ColumnMaskContent;
import org.apache.gravitino.policy.IcebergDataCompactionContent;
import org.apache.gravitino.policy.IcebergOrphanFileRemovalContent;
import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
+import org.apache.gravitino.policy.IcebergSnapshotExpirationContent;
import org.apache.gravitino.policy.PolicyContent;
import org.apache.gravitino.policy.PolicyContents;
import org.apache.gravitino.policy.RowFilterContent;
@@ -473,17 +474,54 @@ public interface PolicyContentDTO extends PolicyContent {
}
}
- /** Placeholder DTO for Iceberg snapshot-expiration policy content. */
+ /**
+ * Typed snapshot-expiration policy content for REST requests and responses.
+ *
+ * <p>{@code updateContent} replaces the whole content object: omitted
fields fall back to
+ * defaults rather than merging with the previous content.
+ */
@EqualsAndHashCode
@ToString
+ @Builder(setterPrefix = "with")
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
class IcebergSnapshotExpirationContentDTO implements PolicyContentDTO {
+ @JsonProperty("olderThanDays")
+ @JsonDeserialize(using = SnapshotLongDeserializer.class)
+ private Long olderThanDays;
+
+ @JsonProperty("retainLast")
+ @JsonDeserialize(using = SnapshotLongDeserializer.class)
+ private Long retainLast;
+
+ @JsonProperty("streamResults")
+ @JsonDeserialize(using = SnapshotBooleanDeserializer.class)
+ private Boolean streamResults;
+
private IcebergSnapshotExpirationContentDTO() {}
/**
- * @return empty snapshot-expiration content DTO
+ * @return minimum snapshot age in days, defaulting to five
*/
- public static IcebergSnapshotExpirationContentDTO instance() {
- return new IcebergSnapshotExpirationContentDTO();
+ public long olderThanDays() {
+ return olderThanDays == null
+ ? IcebergSnapshotExpirationContent.DEFAULT_OLDER_THAN_DAYS
+ : olderThanDays;
+ }
+
+ /**
+ * @return minimum recent snapshots to keep, defaulting to one
+ */
+ public long retainLast() {
+ return retainLast == null ?
IcebergSnapshotExpirationContent.DEFAULT_RETAIN_LAST : retainLast;
+ }
+
+ /**
+ * @return whether to stream intermediate delete results
+ */
+ public boolean streamResults() {
+ return streamResults == null
+ ? IcebergSnapshotExpirationContent.DEFAULT_STREAM_RESULTS
+ : streamResults;
}
@Override
@@ -507,8 +545,34 @@ public interface PolicyContentDTO extends PolicyContent {
toDomainContent().validate();
}
+ /** Reads a long option without silently coercing fractional values or
strings. */
+ public static class SnapshotLongDeserializer extends
JsonDeserializer<Long> {
+ @Override
+ public Long deserialize(JsonParser parser, DeserializationContext
context)
+ throws IOException {
+ if (!parser.hasToken(JsonToken.VALUE_NUMBER_INT)) {
+ return context.reportInputMismatch(
+ Long.class, "olderThanDays and retainLast must be 64-bit
integers");
+ }
+ return parser.getLongValue();
+ }
+ }
+
+ /** Reads streamResults without silently coercing numbers or strings to
booleans. */
+ public static class SnapshotBooleanDeserializer extends
JsonDeserializer<Boolean> {
+ @Override
+ public Boolean deserialize(JsonParser parser, DeserializationContext
context)
+ throws IOException {
+ if (!parser.hasToken(JsonToken.VALUE_TRUE) &&
!parser.hasToken(JsonToken.VALUE_FALSE)) {
+ return context.reportInputMismatch(Boolean.class, "streamResults
must be a boolean");
+ }
+ return parser.getBooleanValue();
+ }
+ }
+
private PolicyContent toDomainContent() {
- return PolicyContents.icebergSnapshotExpiration();
+ return PolicyContents.icebergSnapshotExpiration(
+ olderThanDays(), retainLast(), streamResults());
}
}
diff --git
a/common/src/main/java/org/apache/gravitino/dto/requests/PolicyUpdateRequest.java
b/common/src/main/java/org/apache/gravitino/dto/requests/PolicyUpdateRequest.java
index 49d4a3b547..0473426043 100644
---
a/common/src/main/java/org/apache/gravitino/dto/requests/PolicyUpdateRequest.java
+++
b/common/src/main/java/org/apache/gravitino/dto/requests/PolicyUpdateRequest.java
@@ -121,7 +121,12 @@ public interface PolicyUpdateRequest extends RESTRequest {
public void validate() throws IllegalArgumentException {}
}
- /** The policy update request for updating a policy content. */
+ /**
+ * The policy update request for updating a policy content.
+ *
+ * <p>{@code newContent} fully replaces the previous content. Omitted typed
fields use that
+ * content type's defaults; they are not merged with the previously stored
values.
+ */
@EqualsAndHashCode
@Getter
@ToString
diff --git
a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
index 69f7922280..e6996d9f66 100644
--- a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
+++ b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
@@ -700,7 +700,12 @@ public class DTOConverters {
.build();
}
if (policyContent instanceof IcebergSnapshotExpirationContent) {
- return PolicyContentDTO.IcebergSnapshotExpirationContentDTO.instance();
+ IcebergSnapshotExpirationContent content =
(IcebergSnapshotExpirationContent) policyContent;
+ return PolicyContentDTO.IcebergSnapshotExpirationContentDTO.builder()
+ .withOlderThanDays(content.olderThanDays())
+ .withRetainLast(content.retainLast())
+ .withStreamResults(content.streamResults())
+ .build();
}
if (policyContent instanceof IcebergDataCompactionContent) {
IcebergDataCompactionContent icebergCompactionContent =
@@ -1600,7 +1605,10 @@ public class DTOConverters {
content.useCaching());
}
if (policyContentDTO instanceof
PolicyContentDTO.IcebergSnapshotExpirationContentDTO) {
- return PolicyContents.icebergSnapshotExpiration();
+ PolicyContentDTO.IcebergSnapshotExpirationContentDTO content =
+ (PolicyContentDTO.IcebergSnapshotExpirationContentDTO)
policyContentDTO;
+ return PolicyContents.icebergSnapshotExpiration(
+ content.olderThanDays(), content.retainLast(),
content.streamResults());
}
if (policyContentDTO instanceof
PolicyContentDTO.IcebergCompactionContentDTO) {
PolicyContentDTO.IcebergCompactionContentDTO icebergCompactionContentDTO
=
diff --git
a/common/src/test/java/org/apache/gravitino/dto/policy/TestSnapshotExpirationPolicyDTO.java
b/common/src/test/java/org/apache/gravitino/dto/policy/TestSnapshotExpirationPolicyDTO.java
new file mode 100644
index 0000000000..7046c74c23
--- /dev/null
+++
b/common/src/test/java/org/apache/gravitino/dto/policy/TestSnapshotExpirationPolicyDTO.java
@@ -0,0 +1,196 @@
+/*
+ * 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.gravitino.dto.policy;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import java.time.Instant;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.dto.requests.PolicyCreateRequest;
+import org.apache.gravitino.dto.requests.PolicyUpdateRequest;
+import org.apache.gravitino.dto.util.DTOConverters;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.policy.IcebergSnapshotExpirationContent;
+import org.apache.gravitino.policy.PolicyContent;
+import org.apache.gravitino.policy.PolicyContents;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class TestSnapshotExpirationPolicyDTO {
+
+ @Test
+ void createRequestDefaultsAndValidation() throws Exception {
+ String json =
+
"{\"name\":\"expire\",\"policyType\":\"system_iceberg_snapshot_expiration\",\"enabled\":true,\"content\":{}}";
+ PolicyCreateRequest request =
+ JsonUtils.objectMapper().readValue(json, PolicyCreateRequest.class);
+ request.validate();
+ Assertions.assertEquals(
+ PolicyContents.icebergSnapshotExpiration(),
+ DTOConverters.fromDTO(request.getPolicyContent()));
+ String invalid = json.replace("\"content\":{}",
"\"content\":{\"olderThanDays\":0}");
+ PolicyCreateRequest bad =
+ JsonUtils.objectMapper().readValue(invalid, PolicyCreateRequest.class);
+ Assertions.assertThrows(IllegalArgumentException.class, bad::validate);
+ }
+
+ @Test
+ void updateContentRoundTrip() throws Exception {
+ String json =
+
"{\"@type\":\"updateContent\",\"policyType\":\"system_iceberg_snapshot_expiration\","
+ +
"\"newContent\":{\"olderThanDays\":14,\"retainLast\":3,\"streamResults\":true}}";
+ PolicyUpdateRequest request =
+ JsonUtils.objectMapper().readValue(json, PolicyUpdateRequest.class);
+ request.validate();
+ PolicyUpdateRequest.UpdatePolicyContentRequest update =
+ (PolicyUpdateRequest.UpdatePolicyContentRequest) request;
+ Assertions.assertEquals(
+ PolicyContents.icebergSnapshotExpiration(14L, 3L, true),
+ DTOConverters.fromDTO(update.getNewContent()));
+ Assertions.assertEquals(
+ request,
+ JsonUtils.objectMapper()
+ .readValue(
+ JsonUtils.objectMapper().writeValueAsString(request),
PolicyUpdateRequest.class));
+ }
+
+ @Test
+ void updateContentOmitsResetToDefaults() throws Exception {
+ // updateContent fully replaces content; omitted fields use defaults, not
prior values.
+ String json =
+
"{\"@type\":\"updateContent\",\"policyType\":\"system_iceberg_snapshot_expiration\","
+ + "\"newContent\":{\"olderThanDays\":30}}";
+ PolicyUpdateRequest.UpdatePolicyContentRequest update =
+ JsonUtils.objectMapper()
+ .readValue(json,
PolicyUpdateRequest.UpdatePolicyContentRequest.class);
+ update.validate();
+ Assertions.assertEquals(
+ PolicyContents.icebergSnapshotExpiration(
+ 30L,
+ IcebergSnapshotExpirationContent.DEFAULT_RETAIN_LAST,
+ IcebergSnapshotExpirationContent.DEFAULT_STREAM_RESULTS),
+ DTOConverters.fromDTO(update.getNewContent()));
+ }
+
+ @Test
+ void responseRoundTripRetainsTypedContent() throws Exception {
+ PolicyContent domain = PolicyContents.icebergSnapshotExpiration(14L, 5L,
true);
+ PolicyDTO dto =
+ PolicyDTO.builder()
+ .withName("expire")
+ .withPolicyType("system_iceberg_snapshot_expiration")
+ .withEnabled(true)
+ .withContent(DTOConverters.toDTO(domain))
+
.withAudit(AuditDTO.builder().withCreator("test").withCreateTime(Instant.EPOCH).build())
+ .build();
+ PolicyDTO restored =
+ JsonUtils.objectMapper()
+ .readValue(JsonUtils.objectMapper().writeValueAsString(dto),
PolicyDTO.class);
+ Assertions.assertEquals(dto, restored);
+ Assertions.assertEquals(domain, DTOConverters.fromDTO(restored.content()));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "olderThanDays,14.5",
+ "olderThanDays,\"14\"",
+ "olderThanDays,true",
+ "retainLast,1.0",
+ "retainLast,\"1\"",
+ "retainLast,false",
+ "streamResults,0",
+ "streamResults,1",
+ "streamResults,\"true\"",
+ "streamResults,\"false\"",
+ "streamResults,[]",
+ "streamResults,{}"
+ })
+ void rejectInvalidValuesInCreateAndUpdate(String field, String value) {
+ String content = "{\"" + field + "\":" + value + "}";
+ String create =
+
"{\"name\":\"expire\",\"policyType\":\"system_iceberg_snapshot_expiration\","
+ + "\"enabled\":true,\"content\":"
+ + content
+ + "}";
+ String update =
+
"{\"@type\":\"updateContent\",\"policyType\":\"system_iceberg_snapshot_expiration\","
+ + "\"newContent\":"
+ + content
+ + "}";
+ Assertions.assertThrows(
+ JsonProcessingException.class,
+ () -> JsonUtils.objectMapper().readValue(create,
PolicyCreateRequest.class));
+ Assertions.assertThrows(
+ JsonProcessingException.class,
+ () ->
+ JsonUtils.objectMapper()
+ .readValue(update,
PolicyUpdateRequest.UpdatePolicyContentRequest.class));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void booleanStreamResultsValues(boolean streamResults) throws Exception {
+ PolicyContentDTO.IcebergSnapshotExpirationContentDTO dto =
+ JsonUtils.objectMapper()
+ .readValue(
+ "{\"streamResults\":" + streamResults + "}",
+ PolicyContentDTO.IcebergSnapshotExpirationContentDTO.class);
+ dto.validate();
+ Assertions.assertEquals(
+ PolicyContents.icebergSnapshotExpiration(null, null, streamResults),
+ DTOConverters.fromDTO(dto));
+ }
+
+ @Test
+ void createAndUpdateValidateRetentionUpperBoundary() throws Exception {
+ long maximum = IcebergSnapshotExpirationContent.MAX_OLDER_THAN_DAYS;
+ for (long days : new long[] {maximum, maximum + 1, Long.MAX_VALUE}) {
+ String content = "{\"olderThanDays\":" + days + "}";
+ PolicyCreateRequest create =
+ JsonUtils.objectMapper()
+ .readValue(
+
"{\"name\":\"expire\",\"policyType\":\"system_iceberg_snapshot_expiration\",\"content\":"
+ + content
+ + "}",
+ PolicyCreateRequest.class);
+ PolicyUpdateRequest update =
+ JsonUtils.objectMapper()
+ .readValue(
+
"{\"@type\":\"updateContent\",\"policyType\":\"system_iceberg_snapshot_expiration\",\"newContent\":"
+ + content
+ + "}",
+ PolicyUpdateRequest.class);
+ if (days == maximum) {
+ Assertions.assertDoesNotThrow(create::validate);
+ Assertions.assertDoesNotThrow(update::validate);
+ } else {
+ Assertions.assertTrue(
+ Assertions.assertThrows(IllegalArgumentException.class,
create::validate)
+ .getMessage()
+ .contains("olderThanDays"));
+ Assertions.assertTrue(
+ Assertions.assertThrows(IllegalArgumentException.class,
update::validate)
+ .getMessage()
+ .contains("olderThanDays"));
+ }
+ }
+ }
+}
diff --git a/conf/gravitino-optimizer.conf.template
b/conf/gravitino-optimizer.conf.template
index 6c01b37378..1693c32c52 100644
--- a/conf/gravitino-optimizer.conf.template
+++ b/conf/gravitino-optimizer.conf.template
@@ -33,6 +33,12 @@ gravitino.optimizer.recommender.tableMetaProvider =
gravitino-table-metadata-pro
# The job submitter for the recommender (noop-job-submitter keeps
recommendations local).
gravitino.optimizer.recommender.jobSubmitter = noop-job-submitter
+# Built-in strategy handlers (optional overrides; Recommender has the same
defaults in code).
+# gravitino.optimizer.strategyHandler.iceberg-data-compaction.className =
org.apache.gravitino.maintenance.optimizer.recommender.handler.compaction.CompactionStrategyHandler
+# gravitino.optimizer.strategyHandler.iceberg-rewrite-manifests.className =
org.apache.gravitino.maintenance.optimizer.recommender.handler.ManifestRewriteStrategyHandler
+# gravitino.optimizer.strategyHandler.iceberg-orphan-file-removal.className =
org.apache.gravitino.maintenance.optimizer.recommender.handler.orphan.OrphanFileRemovalStrategyHandler
+# gravitino.optimizer.strategyHandler.iceberg-snapshot-expiration.className =
org.apache.gravitino.maintenance.optimizer.recommender.handler.snapshot.SnapshotExpirationStrategyHandler
+
# Updater plugins (push statistics/metrics back into Gravitino)
# The statistics updater for the recommender/update-statistics tool.
gravitino.optimizer.updater.statisticsUpdater = gravitino-statistics-updater
diff --git a/core/src/main/java/org/apache/gravitino/meta/PolicyEntity.java
b/core/src/main/java/org/apache/gravitino/meta/PolicyEntity.java
index ad9bcf2af3..0fae994c48 100644
--- a/core/src/main/java/org/apache/gravitino/meta/PolicyEntity.java
+++ b/core/src/main/java/org/apache/gravitino/meta/PolicyEntity.java
@@ -160,7 +160,17 @@ public class PolicyEntity implements Entity, Auditable,
HasIdentifier {
@Override
public void validate() throws IllegalArgumentException {
Entity.super.validate();
- validatePolicy();
+ validatePolicy(true /* validateContent */);
+ }
+
+ /**
+ * Validates policy metadata and type/content class matching without calling
{@link
+ * PolicyContent#validate()}. Used when renaming, commenting, or enabling a
policy whose stored
+ * content may predate stricter content checks.
+ */
+ public void validateExceptContent() throws IllegalArgumentException {
+ Entity.super.validate();
+ validatePolicy(false /* validateContent */);
}
@Override
@@ -182,7 +192,7 @@ public class PolicyEntity implements Entity, Auditable,
HasIdentifier {
return Objects.hash(id, name, namespace, policyType, comment, enabled,
content, auditInfo);
}
- private void validatePolicy() {
+ private void validatePolicy(boolean validateContent) {
Preconditions.checkArgument(StringUtils.isNotBlank(name()), "Policy name
cannot be blank");
Preconditions.checkArgument(content() != null, "Policy content cannot be
null");
@@ -198,7 +208,9 @@ public class PolicyEntity implements Entity, Auditable,
HasIdentifier {
policyType.contentClass().getSimpleName(),
policyType.name(),
content().getClass().getName());
- content().validate();
+ if (validateContent) {
+ content().validate();
+ }
}
public static class Builder {
@@ -252,6 +264,24 @@ public class PolicyEntity implements Entity, Auditable,
HasIdentifier {
}
public PolicyEntity build() {
+ PolicyEntity policyEntity = newEntity();
+ policyEntity.validate();
+ return policyEntity;
+ }
+
+ /**
+ * Builds a policy entity without invoking {@link
PolicyContent#validate()}. Prefer {@link
+ * #build()} for create and content-update paths.
+ *
+ * @return policy entity with metadata validated only
+ */
+ public PolicyEntity buildSkippingContentValidation() {
+ PolicyEntity policyEntity = newEntity();
+ policyEntity.validateExceptContent();
+ return policyEntity;
+ }
+
+ private PolicyEntity newEntity() {
PolicyEntity policyEntity = new PolicyEntity();
policyEntity.id = id;
policyEntity.name = name;
@@ -261,8 +291,6 @@ public class PolicyEntity implements Entity, Auditable,
HasIdentifier {
policyEntity.enabled = enabled;
policyEntity.content = content;
policyEntity.auditInfo = auditInfo;
- policyEntity.validate();
-
return policyEntity;
}
}
diff --git a/core/src/main/java/org/apache/gravitino/policy/PolicyManager.java
b/core/src/main/java/org/apache/gravitino/policy/PolicyManager.java
index eb3c8b5848..52d8d91c5c 100644
--- a/core/src/main/java/org/apache/gravitino/policy/PolicyManager.java
+++ b/core/src/main/java/org/apache/gravitino/policy/PolicyManager.java
@@ -263,7 +263,8 @@ public class PolicyManager implements PolicyDispatcher {
policyEntity -> {
PolicyEntity.Builder builder = newPolicyBuilder(policyEntity);
builder.withEnabled(expectedEnabledState);
- return builder.build();
+ // Enable/disable must not re-validate legacy stored content.
+ return builder.buildSkippingContentValidation();
});
} catch (NoSuchEntityException e) {
// The policy was dropped between the state check above and this update.
@@ -314,6 +315,7 @@ public class PolicyManager implements PolicyDispatcher {
String newName = policyEntity.name();
String newComment = policyEntity.comment();
PolicyContent newContent = policyEntity.content();
+ boolean contentUpdated = false;
for (PolicyChange change : changes) {
if (change instanceof PolicyChange.RenamePolicy) {
@@ -343,20 +345,24 @@ public class PolicyManager implements PolicyDispatcher {
}
newContent = updateContent.getContent();
+ contentUpdated = true;
validateReadRestrictionContent(policyType, newContent);
} else {
throw new IllegalArgumentException("Unsupported policy change: " +
change);
}
}
- newContent.validate();
+ // Rename/comment must not re-validate legacy stored content that would
fail new checks.
+ if (contentUpdated) {
+ newContent.validate();
+ }
PolicyEntity.Builder builder = newPolicyBuilder(policyEntity);
builder.withName(newName);
builder.withComment(newComment);
builder.withContent(newContent);
- return builder.build();
+ return contentUpdated ? builder.build() :
builder.buildSkippingContentValidation();
}
private static void validateReadRestrictionContent(
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
b/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
index 9dfe048e2c..b56d38825e 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
@@ -1558,6 +1558,8 @@ public class POConverters {
public static PolicyEntity fromPolicyPO(PolicyPO policyPO, Namespace
namespace) {
try {
Policy.BuiltInType policyType =
Policy.BuiltInType.fromPolicyType(policyPO.getPolicyType());
+ // Skip PolicyContent.validate() so stricter checks added after write do
not break load,
+ // rename, comment, or enable of already-stored policies.
Create/UpdateContent still validate.
return PolicyEntity.builder()
.withId(policyPO.getPolicyId())
.withName(policyPO.getPolicyName())
@@ -1570,7 +1572,7 @@ public class POConverters {
.readValue(policyPO.getPolicyVersionPO().getContent(),
policyType.contentClass()))
.withAuditInfo(
JsonUtils.anyFieldMapper().readValue(policyPO.getAuditInfo(),
AuditInfo.class))
- .build();
+ .buildSkippingContentValidation();
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to deserialize json object:", e);
}
diff --git
a/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
b/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
index 72c9a52d29..51887d8b05 100644
--- a/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
+++ b/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
@@ -588,6 +588,44 @@ public class TestPolicyManager {
Assertions.assertEquals(legacyContent, commented.content());
}
+ @Test
+ public void testRenameDoesNotRevalidateLegacyCompactionRewriteOptions()
throws IOException {
+ String policyName = "legacy_compaction_" +
UUID.randomUUID().toString().replace("-", "");
+ // Constructed without content validate(); would fail rewriteOptions
allowlist on create.
+ PolicyContent legacyContent =
+ PolicyContents.icebergDataCompaction(1000L, 1L,
ImmutableMap.of("no-such-option", "x"));
+ PolicyEntity legacyPolicy =
+ PolicyEntity.builder()
+ .withId(new RandomIdGenerator().nextId())
+ .withName(policyName)
+ .withNamespace(NamespaceUtil.ofPolicy(METALAKE))
+ .withPolicyType(Policy.BuiltInType.ICEBERG_COMPACTION)
+ .withEnabled(true)
+ .withContent(legacyContent)
+ .withAuditInfo(
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .buildSkippingContentValidation();
+ entityStore.put(legacyPolicy, false /* overwritten */);
+
+ String newName = "renamed_" + policyName;
+ PolicyEntity renamed =
+ Assertions.assertDoesNotThrow(
+ () -> policyManager.alterPolicy(METALAKE, policyName,
PolicyChange.rename(newName)));
+ Assertions.assertEquals(newName, renamed.name());
+ Assertions.assertEquals(legacyContent, renamed.content());
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ policyManager.alterPolicy(
+ METALAKE,
+ newName,
+ PolicyChange.updateContent(
+ "system_iceberg_compaction",
+ PolicyContents.icebergDataCompaction(
+ 1000L, 1L, ImmutableMap.of("no-such-option", "y")))));
+ }
+
@Test
public void testCreateUpdateAndGetColumnMaskPolicy() {
String policyName = "column_mask_" +
UUID.randomUUID().toString().replace("-", "");
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
index dae145b560..759bbdc059 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.storage.relational.utils;
import static org.apache.gravitino.file.Fileset.LOCATION_NAME_UNKNOWN;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -1032,6 +1033,46 @@ public class TestPOConverters {
assertEquals(expectedPolicy.content(), convertedPolicy.content());
}
+ @Test
+ public void testFromPolicyPOIcebergSnapshotExpiration() throws
JsonProcessingException {
+ PolicyContent content = PolicyContents.icebergSnapshotExpiration(14L, 5L,
true);
+ String stored = JsonUtils.anyFieldMapper().writeValueAsString(content);
+ assertTrue(stored.contains("\"olderThanDays\":14"));
+ assertTrue(stored.contains("\"retainLast\":5"));
+ assertTrue(stored.contains("\"streamResults\":true"));
+
+ PolicyEntity converted =
+ POConverters.fromPolicyPO(
+ createPolicyPO(
+ 2L,
+ "snapshot-expiration",
+ "system_iceberg_snapshot_expiration",
+ 1L,
+ createPolicyVersionPO(2L, 1L, 1L, "expire", true, content)),
+ NamespaceUtil.ofPolicy("test_metalake"));
+ assertEquals(Policy.BuiltInType.ICEBERG_SNAPSHOT_EXPIRATION,
converted.policyType());
+ assertEquals(content, converted.content());
+ }
+
+ @Test
+ public void testFromPolicyPOIcebergRewriteManifestsPersists() throws
JsonProcessingException {
+ PolicyContent content = PolicyContents.icebergRewriteManifests();
+ String stored = JsonUtils.anyFieldMapper().writeValueAsString(content);
+ assertFalse(stored.isEmpty());
+
+ PolicyEntity converted =
+ POConverters.fromPolicyPO(
+ createPolicyPO(
+ 3L,
+ "rewrite-manifests",
+ "system_iceberg_rewrite_manifests",
+ 1L,
+ createPolicyVersionPO(3L, 1L, 1L, "rewrite", true, content)),
+ NamespaceUtil.ofPolicy("test_metalake"));
+ assertEquals(Policy.BuiltInType.ICEBERG_REWRITE_MANIFESTS,
converted.policyType());
+ assertEquals(content, converted.content());
+ }
+
@Test
public void testFromTagPO() throws JsonProcessingException {
TagPO tagPO = createTagPO(1L, "test", 1L, "this is test");
diff --git a/design-docs/iceberg-expire-snapshots-maintenance-job.md
b/design-docs/iceberg-expire-snapshots-maintenance-job.md
index 483ab3c1c7..b856b4c653 100644
--- a/design-docs/iceberg-expire-snapshots-maintenance-job.md
+++ b/design-docs/iceberg-expire-snapshots-maintenance-job.md
@@ -21,9 +21,10 @@
| Field | Value
|
| ------- |
-------------------------------------------------------------------- |
-| Status | Draft
|
+| Status | Implemented (aligned with orphan cleanup)
|
| Authors | @laserninja
|
| Created | 2026-05-26
|
+| Updated | 2026-10-26
|
| Issue | [#11194](https://github.com/apache/gravitino/issues/11194)
|
| Module | `api`, `maintenance/jobs`, `maintenance/optimizer`
|
@@ -43,24 +44,26 @@ Trino-side delegation for `expire_snapshots` as a
procedure, but there is
no server-side built-in job that can be triggered automatically via the
Table Maintenance Service (Optimizer) policies.
-This design proposes adding full end-to-end support for Iceberg snapshot
-expiration: from policy definition through strategy evaluation to Spark
-job execution.
+This design adds end-to-end support for Iceberg snapshot expiration: from
+policy definition through strategy evaluation to Spark job execution,
+following the same pattern as orphan-file removal.
---
## 2. Goals
-1. Add a new built-in policy type `system_iceberg_snapshot_expiration` for
- declarative snapshot expiration configuration.
-2. Add a strategy handler that evaluates when snapshot expiration should
- run, based on the snapshot count read from Iceberg table metadata.
-3. Expose that snapshot count as a reserved Iceberg table property so the
- handler needs no statistics collection.
-4. Add a job adapter that converts strategy evaluation results into job
- configurations.
-5. Add the Spark job that executes Iceberg's `expire_snapshots` procedure.
-6. Ensure the full flow works end-to-end: policy → strategy → job
+1. Add a built-in policy type `system_iceberg_snapshot_expiration` for
+ declarative retention configuration (`olderThanDays`, `retainLast`,
+ `streamResults`).
+2. Add a strategy handler that follows orphan cleanup: `trigger-expr` is
+ `true`, so each optimizer invocation may submit expire when the policy
+ is attached. Scheduling remains external (optimizer CLI / cron / TMS).
+3. Add a job adapter that maps policy options to
+ `builtin-iceberg-expire-snapshots` jobConf (`older_than`, `retain_last`,
+ `stream_results`).
+4. Reuse the Spark job that executes Iceberg's `expire_snapshots`
+ procedure.
+5. Ensure the full flow works end-to-end: policy → strategy → job
submission → Spark execution.
---
@@ -71,44 +74,47 @@ job execution.
- Automatic policy creation - users must explicitly create and attach
policies.
- Changes to the Optimizer scheduling framework itself.
+- Triggering expire from a `snapshot-count` metadata gate (see
+ [§6](#6-trigger-model)). Industry practice is schedule-driven retention,
+ not count thresholds.
---
## 4. Existing Architecture Overview
The Gravitino maintenance module follows a layered architecture for
-automated table maintenance. The existing Iceberg compaction flow
-establishes the pattern:
+automated table maintenance. Orphan-file removal is the closest precedent
+for whole-table, always-eligible cleanup:
```
Policy Creation (REST API)
↓
GravitinoStrategyProvider (loads policies as strategies)
↓
-CompactionStrategyHandler (evaluates trigger / score expressions)
+OrphanFileRemovalStrategyHandler (BaseExpressionStrategyHandler, trigger-expr
true)
↓
-CompactionJobContext → GravitinoCompactionJobAdapter (converts to job config)
+OrphanFileRemovalJobContext → GravitinoOrphanFileRemovalJobAdapter
↓
GravitinoJobSubmitter (submits job via REST)
↓
-IcebergRewriteDataFilesJob (Spark execution)
+IcebergRemoveOrphanFilesJob (Spark execution)
```
### 4.1 Layer Summary
-| Layer | Compaction Components
| Purpose |
+| Layer | Orphan Components
| Purpose |
| ------------ |
----------------------------------------------------------------------------------
| -------------------------------------------------- |
-| **Policy** | `Policy.BuiltInType.ICEBERG_COMPACTION`,
`IcebergDataCompactionContent` | Define configuration, thresholds,
expressions |
-| **Strategy** | `CompactionStrategyHandler` extends
`BaseExpressionStrategyHandler` | Evaluate trigger conditions,
score partitions |
-| **Adapter** | `GravitinoCompactionJobAdapter`, `CompactionJobContext`
| Convert evaluation result to job configuration |
-| **Job** | `IcebergRewriteDataFilesJob`, registered in
`BuiltInJobTemplateProvider` | Execute Spark procedure
|
+| **Policy** | `ICEBERG_ORPHAN_FILE_REMOVAL`,
`IcebergOrphanFileRemovalContent` | Retention and job options
|
+| **Strategy** | `OrphanFileRemovalStrategyHandler` extends
`BaseExpressionStrategyHandler` | `trigger-expr: true`; table-level
eligibility |
+| **Adapter** | `GravitinoOrphanFileRemovalJobAdapter`,
`OrphanFileRemovalJobContext` | Convert options to jobConf
|
+| **Job** | `IcebergRemoveOrphanFilesJob`
| Execute Spark procedure |
---
## 5. Proposed Design
-We add the same four layers for snapshot expiration, following the
-compaction pattern.
+We add the same four layers for snapshot expiration, mirroring orphan
+cleanup rather than compaction's statistics-driven expressions.
### 5.1 Architecture Diagram
@@ -118,6 +124,7 @@ compaction pattern.
│ type: "system_iceberg_snapshot_expiration" │
│ content: IcebergSnapshotExpirationContent │
│ { olderThanDays, retainLast, streamResults } │
+│ rules: trigger-expr=true, job.options.* │
└──────────────────────────┬───────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
@@ -129,17 +136,17 @@ compaction pattern.
↓
┌──────────────────────────────────────────────────────────────┐
│ SnapshotExpirationStrategyHandler │
-│ implements StrategyHandler │
+│ extends BaseExpressionStrategyHandler │
│ dataRequirements: {TABLE_METADATA} │
-│ Reads `snapshot-count` from table metadata properties │
-│ Evaluates: snapshot count ≥ minSnapshotCount │
+│ trigger-expr true → shouldTrigger() when optimizer runs │
│ Returns: StrategyEvaluation with score + context │
└──────────────────────────┬───────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ SnapshotExpirationJobContext → JobAdapter │
-│ Extracts: older_than, retain_last, stream_results │
-│ Builds: job config map for template substitution │
+│ olderThanDays → older_than (UTC timestamp) │
+│ retainLast → retain_last │
+│ streamResults → stream_results │
└──────────────────────────┬───────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
@@ -160,126 +167,57 @@ compaction pattern.
### 5.2 Layer 1 - Policy Definition (`api/`)
-#### 5.2.1 New Policy Type
+#### 5.2.1 Policy Type
-Add `ICEBERG_SNAPSHOT_EXPIRATION` to `Policy.BuiltInType`:
+`ICEBERG_SNAPSHOT_EXPIRATION` / `system_iceberg_snapshot_expiration` maps to
+`IcebergSnapshotExpirationContent`.
-```java
-// api/src/main/java/org/apache/gravitino/policy/Policy.java
-enum BuiltInType {
- ICEBERG_COMPACTION("system_iceberg_compaction",
- IcebergDataCompactionContent.class),
- ICEBERG_SNAPSHOT_EXPIRATION("system_iceberg_snapshot_expiration",
- IcebergSnapshotExpirationContent.class), // NEW
- CUSTOM("custom", CustomContent.class);
-}
-```
-
-#### 5.2.2 New Policy Content Class
-
-Create `IcebergSnapshotExpirationContent` following the
-`IcebergDataCompactionContent` pattern:
+#### 5.2.2 Policy Content
```java
-// NEW: api/src/main/java/org/apache/gravitino/policy/
-// IcebergSnapshotExpirationContent.java
public class IcebergSnapshotExpirationContent implements PolicyContent {
+ public static final String STRATEGY_TYPE_VALUE =
"iceberg-snapshot-expiration";
+ public static final String JOB_TEMPLATE_NAME_VALUE =
"builtin-iceberg-expire-snapshots";
- // Strategy metadata
- public static final String STRATEGY_TYPE_VALUE =
- "iceberg-snapshot-expiration";
- public static final String JOB_TEMPLATE_NAME_VALUE =
- "builtin-iceberg-expire-snapshots";
-
- // Configurable fields - all independently optional
- @Nullable private final Long olderThanDays; // no Gravitino default
- @Nullable private final Long retainLast; // no Gravitino default
- @Nullable private final Boolean streamResults;
- private final long minSnapshotCount; // trigger threshold, default: 10
+ public static final long DEFAULT_OLDER_THAN_DAYS = 5; // Iceberg expire
default
+ public static final long DEFAULT_RETAIN_LAST = 1;
+ public static final boolean DEFAULT_STREAM_RESULTS = false;
- // Rule keys
- public static final String MIN_SNAPSHOT_COUNT_KEY = "minSnapshotCount";
+ private final long olderThanDays;
+ private final long retainLast;
+ private final boolean streamResults;
- // Defaults
- public static final boolean DEFAULT_STREAM_RESULTS = true;
- public static final long DEFAULT_MIN_SNAPSHOT_COUNT = 10;
+ // rules(): trigger-expr=true, score-expr=1,
+ // job.options.olderThanDays / retainLast / streamResults
}
```
-`olderThanDays` and `retainLast` deliberately have **no Gravitino-side
-default**. See [§5.2.3](#523-expiration-parameters-and-defaults).
+Null JSON fields use the defaults above (same pattern as orphan's
+`olderThanDays` / `dryRun`).
#### 5.2.3 Expiration Parameters and Defaults
-Both expiration conditions are **independently optional**. A user may
-specify only `olderThanDays`, only `retainLast`, or both - the job adapter
-omits an unset condition from the procedure call rather than filling it in
-with a Gravitino default. (`streamResults` is different: it is a transport
-knob rather than an expiration condition, so it is always passed - see
-[§5.2.4](#524-why-streamresults-defaults-to-true).)
-
-**Why no Gravitino default for `olderThanDays` / `retainLast`:** Iceberg
-already defines defaults for both, sourced from table properties:
-
-| Procedure argument | Iceberg fallback (table property) | Iceberg
default |
-| ------------------ | ----------------------------------------- |
--------------- |
-| `older_than` | `history.expire.max-snapshot-age-ms` | 5 days
|
-| `retain_last` | `history.expire.min-snapshots-to-keep` | 1
|
-
-If Gravitino injected its own defaults, a policy that only sets
-`retainLast` would silently also apply age-based expiration, and a
-per-table `history.expire.*` property set by the table owner would be
-overridden by the policy. Omitting unset parameters keeps a single
-condition meaningful on its own and keeps table-level Iceberg
-configuration authoritative.
-
-| Field | Type | Required | Default
| Description |
-| ----------------- | --------- | -------- |
--------------------------------------------- |
------------------------------------------------------------ |
-| `olderThanDays` | `Long` | No | unset - falls back to Iceberg
table property | Expire snapshots older than this many days
|
-| `retainLast` | `Long` | No | unset - falls back to Iceberg
table property | Minimum number of snapshots to always retain
|
-| `streamResults` | `Boolean` | No | `true`
| Stream deleted-file rows to the driver by RDD partition |
-| `minSnapshotCount`| `long` | No | 10
| Trigger threshold - only run once the snapshot count reaches this
|
-
-**Usage scenarios:**
-
-- **Only `olderThanDays`:** Expire snapshots older than N days; the number
- retained follows `history.expire.min-snapshots-to-keep` (Iceberg
- default 1).
-- **Only `retainLast`:** Keep at least N snapshots; the age cutoff follows
- `history.expire.max-snapshot-age-ms` (Iceberg default 5 days).
-- **Both:** Expire snapshots older than N days, but always keep at least M
- snapshots.
-- **Neither:** Pure Iceberg behavior - expiration is governed entirely by
- the table's `history.expire.*` properties.
-
-#### 5.2.4 Why `streamResults` Defaults to `true`
-
-Iceberg's `stream_results` controls how the list of deleted files reaches
-the Spark driver: when `false` (the procedure's own default) the full file
-list is collected to the driver in one result set; when `true` the rows are
-streamed by RDD partition, which is what the Iceberg documentation
-recommends to avoid driver OOM on large deletions.
-
-Setting it to `false` buys very little here: this job consumes only the
-summary counts (`deleted_data_files_count` and friends), never the
-individual file rows, so materializing the whole list on the driver has no
-downstream benefit. The only cost of streaming is slightly more per-
-partition overhead on small tables, which is negligible relative to the
-snapshot expiration itself.
-
-Because the job runs unattended and on tables of unknown size, we default
-`streamResults` to `true` for robustness - deliberately differing from the
-procedure default - while keeping the field configurable for parity with
-the procedure and for operators who want the collected form.
-
-#### 5.2.5 Example Policy Creation
+| Field | Type | Required | Default | Description |
+| --------------- | ------- | -------- | ------- | ----------- |
+| `olderThanDays` | `long` | No | `5` | Age cutoff in days; adapter
emits UTC `older_than` |
+| `retainLast` | `long` | No | `1` | Minimum recent snapshots to
keep |
+| `streamResults` | `boolean`| No | `false` | Forwarded as
`stream_results` |
+
+`olderThanDays` must be in `[1, 36500]`. `retainLast` must be `>= 1`.
+
+Unlike an earlier draft that omitted Gravitino defaults so Iceberg table
+properties alone would apply, the shipped content always materializes
+defaults (aligned with orphan cleanup and with Iceberg's common 5-day / 1
+retain defaults). The adapter always submits explicit `older_than`,
+`retain_last`, and `stream_results`.
+
+#### 5.2.4 Example Policy Creation
```json
POST /metalakes/default/policies
{
"name": "expire_old_snapshots",
- "type": "system_iceberg_snapshot_expiration",
- "comment": "Expire snapshots older than 7 days, keep at least 3",
+ "policyType": "system_iceberg_snapshot_expiration",
"enabled": true,
"content": {
"olderThanDays": 7,
@@ -288,19 +226,7 @@ POST /metalakes/default/policies
}
```
-A user may also specify only one parameter:
-
-```json
-{
- "name": "retain_only_policy",
- "type": "system_iceberg_snapshot_expiration",
- "comment": "Always keep at least 5 snapshots",
- "enabled": true,
- "content": {
- "retainLast": 5
- }
-}
-```
+Empty `content: {}` applies defaults (`5`, `1`, `false`).
---
@@ -309,322 +235,172 @@ A user may also specify only one parameter:
#### 5.3.1 Strategy Handler
```java
-// NEW: maintenance/optimizer/src/main/java/…/handler/snapshot/
-// SnapshotExpirationStrategyHandler.java
-public class SnapshotExpirationStrategyHandler implements StrategyHandler {
-
- public static final String NAME = "iceberg-snapshot-expiration";
-
- private NameIdentifier nameIdentifier;
- private Strategy strategy;
- private Table tableMetadata;
-
- @Override
- public String strategyType() {
- return NAME;
- }
+public class SnapshotExpirationStrategyHandler extends
BaseExpressionStrategyHandler {
+ public static final String NAME =
IcebergSnapshotExpirationContent.STRATEGY_TYPE_VALUE;
@Override
public Set<DataRequirement> dataRequirements() {
- // Table metadata alone is enough: snapshot count comes from the
- // table's reserved properties. No statistics are needed.
- return EnumSet.of(DataRequirement.TABLE_METADATA);
- }
-
- @Override
- public void initialize(StrategyHandlerContext context) {
- Preconditions.checkArgument(
- context.tableMetadata().isPresent(), "Table metadata is null");
- this.nameIdentifier = context.nameIdentifier();
- this.strategy = context.strategy();
- this.tableMetadata = context.tableMetadata().get();
+ return Set.of(DataRequirement.TABLE_METADATA);
}
@Override
- public boolean shouldTrigger() {
- OptionalLong snapshotCount = snapshotCount();
- return snapshotCount.isPresent()
- && snapshotCount.getAsLong() >= minSnapshotCount(strategy);
+ public String strategyType() {
+ return NAME;
}
@Override
- public StrategyEvaluation evaluate() {
- // Score is the snapshot count: tables with more snapshots come first.
- return new StrategyEvaluationImpl(
- snapshotCount().getAsLong(),
- new SnapshotExpirationJobContext(
- nameIdentifier, strategy.jobOptions(),
strategy.jobTemplateName()));
+ protected JobExecutionContext buildJobExecutionContext(...) {
+ return new SnapshotExpirationJobContext(
+ nameIdentifier, jobOptions, strategy.jobTemplateName());
}
-
- // Reads the `snapshot-count` reserved property from table metadata.
- private OptionalLong snapshotCount() { … }
}
```
-**Key design decision 1 - table level, not partition level.** Unlike
-compaction, which scores and selects individual partitions,
-`expire_snapshots` always processes the entire table's snapshot history.
-Therefore no partition scoring or selection logic is needed, and
-`dataRequirements()` excludes `PARTITION_STATISTICS`.
-
-**Key design decision 2 - trigger on table metadata, not statistics.**
-Snapshot count is already part of Iceberg table metadata and needs no scan,
-so the handler reads it from the metadata the Recommender already loads (see
-[§6](#6-snapshot-count-from-table-metadata)) instead of depending on a
-collected statistic. Two consequences:
-
-- The handler implements `StrategyHandler` directly rather than extending
- `BaseExpressionStrategyHandler`. The base class builds its expression
- context from table/partition statistics plus numeric strategy rules only,
- so metadata-derived values are not addressable from `trigger-expr` /
- `score-expr`. With a single table-level condition, the expression
- machinery buys nothing, so the handler compares snapshot count against
- `minSnapshotCount` in `shouldTrigger()` and returns it as the score.
-- Expiration no longer depends on `builtin-iceberg-update-stats` having run
- recently, and it cannot act on a stale snapshot count.
-
-If the `snapshot-count` property is absent (for example, a non-Iceberg table
-or an older catalog), `shouldTrigger()` logs and returns `false` rather than
-guessing.
+**Key design decisions:**
+
+1. **Whole table, not partition-level.** `expire_snapshots` always processes
+ table snapshot history; `dataRequirements` is only `TABLE_METADATA`
+ (same as orphan).
+2. **Orphan-style eligibility.** `trigger-expr` is `true`. Each optimizer
+ invocation may submit when the policy is attached. Retention is enforced
+ by job options (`olderThanDays` / `retainLast`), not by a
+ `snapshot-count ≥ N` gate.
+3. **Register via config:**
+ `gravitino.optimizer.strategyHandler.iceberg-snapshot-expiration.className`.
#### 5.3.2 Job Execution Context
```java
-// NEW: maintenance/optimizer/src/main/java/…/handler/snapshot/
-// SnapshotExpirationJobContext.java
public class SnapshotExpirationJobContext implements JobExecutionContext {
- private final NameIdentifier nameIdentifier;
- private final Map<String, String> jobOptions;
- private final String jobTemplateName;
-
- // jobOptions keys (all optional except catalog/table):
- // older_than_days, retain_last, stream_results,
- // catalog_name, table_identifier
+ // nameIdentifier, jobOptions (olderThanDays, retainLast, streamResults),
+ // jobTemplateName
}
```
-#### 5.3.3 Handler Registration
-
-The strategy handler type `"iceberg-snapshot-expiration"` must be
-registered so that the `Recommender` can instantiate it when it encounters
-a policy with that strategy type. This follows the existing pattern where
-handler classes are looked up by strategy type name.
-
---
### 5.4 Layer 3 - Job Adapter (`maintenance/optimizer/`)
-#### 5.4.1 Job Adapter
-
-The adapter passes only the expiration conditions that were explicitly set
-in the policy content, so each works independently, and always passes
-`stream_results`:
-
```java
-// NEW: maintenance/optimizer/src/main/java/…/job/
-// GravitinoSnapshotExpirationJobAdapter.java
-public class GravitinoSnapshotExpirationJobAdapter
- implements GravitinoJobAdapter {
-
- @Override
- public Map<String, String> jobConfig(JobExecutionContext context) {
- SnapshotExpirationJobContext ctx =
- (SnapshotExpirationJobContext) context;
- Map<String, String> config = new HashMap<>();
- config.put("catalog_name",
- ctx.nameIdentifier().namespace()[0]);
- config.put("table_identifier",
- ctx.nameIdentifier().namespace()[1]
- + "." + ctx.nameIdentifier().name());
-
- Map<String, String> opts = ctx.jobOptions();
-
- // Only pass older_than if explicitly configured
- if (opts.containsKey("olderThanDays")) {
- long days = Long.parseLong(opts.get("olderThanDays"));
- String ts = Instant.now()
- .minus(Duration.ofDays(days))
- .toString()
- .replace("T", " ")
- .substring(0, 19); // "yyyy-MM-dd HH:mm:ss"
- config.put("older_than", ts);
- }
-
- // Only pass retain_last if explicitly configured
- if (opts.containsKey("retainLast")) {
- config.put("retain_last", opts.get("retainLast"));
- }
-
- // stream_results always has a value: policy setting, or `true`
- config.put(
- "stream_results",
- opts.getOrDefault(
- "streamResults",
- String.valueOf(
- IcebergSnapshotExpirationContent.DEFAULT_STREAM_RESULTS)));
-
- return config;
- }
+public class GravitinoSnapshotExpirationJobAdapter implements
GravitinoJobAdapter {
+ // olderThanDays == 5 → omit older_than (Iceberg / table history.expire.*)
+ // olderThanDays != 5 → older_than as UTC timestamp with Z
+ // retainLast == 1 → omit retain_last; else emit retain_last
+ // streamResults → stream_results
+ // plus table_identifier (catalog_name from shared submitter config)
}
```
-#### 5.4.2 Register Adapter
-
-```java
-// UPDATE: GravitinoJobSubmitter.java
-private static final Map<String, Class<? extends GravitinoJobAdapter>>
- jobAdapters = ImmutableMap.of(
- "builtin-iceberg-rewrite-data-files",
- GravitinoCompactionJobAdapter.class,
- "builtin-iceberg-expire-snapshots",
- GravitinoSnapshotExpirationJobAdapter.class // NEW
- );
-```
+Register in `GravitinoJobSubmitter.jobAdapters` under
+`builtin-iceberg-expire-snapshots`.
---
### 5.5 Layer 4 - Spark Job (`maintenance/jobs/`) - Already Implemented
-`IcebergExpireSnapshotsJob` is implemented in PR
-[#11206](https://github.com/apache/gravitino/pull/11206):
-
-- **Template name:** `builtin-iceberg-expire-snapshots`
-- **Version:** `v1`
-- **Parameters:** `--catalog`, `--table`, `--older-than`, `--retain-last`,
- `--stream-results`, `--spark-conf` (all optional except `--catalog` and
- `--table`)
-- **Procedure:**
- `CALL catalog.system.expire_snapshots(table => '…', older_than =>
- TIMESTAMP '…', retain_last => N, stream_results => bool)`
-- **Output:** `deleted_data_files_count`,
- `deleted_manifest_files_count`, `deleted_manifest_lists_count`
-- **Security:** SQL injection prevention via `escapeSqlString()` /
- `escapeSqlIdentifier()`
-- **Tests:** 40 unit tests covering template metadata, argument parsing,
- procedure call building, escaping, and input validation
-
-The job already handles partial parameters gracefully - only parameters
-that are non-empty are included in the procedure call.
+`IcebergExpireSnapshotsJob` / template `builtin-iceberg-expire-snapshots`:
----
+- Parameters: `--catalog`, `--table`, `--older-than`, `--retain-last`,
+ `--stream-results`, `--spark-conf`
+- Procedure: `CALL catalog.system.expire_snapshots(...)`
-## 6. Snapshot Count from Table Metadata
+---
-The trigger input is the table's **snapshot count**. It is taken from table
-metadata rather than from a collected statistic.
+## 6. Trigger Model
-**Why not a statistic.** The alternative would be a `custom-snapshot-count`
-statistic produced by `builtin-iceberg-update-stats` (which today derives
-its statistics from the Iceberg `.files` metadata table). That would make
-expiration depend on the stats job having run recently, and the trigger
-would evaluate a snapshot count that is stale by up to one stats interval -
-for a metric that is free to read from metadata.
+### 6.1 Chosen approach (orphan-aligned)
-**What is available today.** The Recommender loads table metadata through
-`GravitinoTableMetadataProvider`, which returns a Gravitino `Table` over
-REST. For Iceberg tables, `IcebergTable` merges in the reserved properties
-built by `IcebergTablePropertiesUtil.buildReservedProperties(TableMetadata)`
-- currently `format`, `provider`, `current-snapshot-id`, `location`,
-`format-version`, `sort-order`, `identifier-fields`. Snapshot count is not
-among them, so the handler cannot read it yet.
+| Concern | Behavior |
+| ------- | -------- |
+| When to run | Optimizer / external schedule invokes recommender |
+| Policy eligibility | `trigger-expr: true` (always eligible when attached) |
+| What to delete | `older_than` + `retain_last` (+ `stream_results`) |
-**Proposed change.** Expose snapshot count as one more reserved Iceberg
-table property. `TableMetadata.snapshots()` is already in memory when the
-properties are built, so this costs no I/O and no scan:
+This matches common Iceberg / Amoro practice: **periodic expire with a
+retention policy**, not "fire only after snapshot count crosses a
+threshold."
-| Property | Type | Source | Description
|
-| ---------------- | ------ | ----------------------------- |
-------------------------------- |
-| `snapshot-count` | `long` | `TableMetadata.snapshots()` | Number of
snapshots in the table |
+### 6.2 Rejected: `snapshot-count ≥ minSnapshotCount`
-This requires:
+An earlier draft proposed reading a reserved `snapshot-count` table
+property and triggering only when count ≥ `minSnapshotCount` (default 10),
+with a custom `StrategyHandler` instead of `BaseExpressionStrategyHandler`.
-1. A `SNAPSHOT_COUNT` constant in `IcebergConstants` and a reserved entry in
- `IcebergTablePropertiesMetadata` (reserved, like `current-snapshot-id`, so
- users cannot set it).
-2. Populating it in `IcebergTablePropertiesUtil.buildReservedProperties`.
+Rejected because:
-Being reserved and read-only, the property is a safe addition: it shows up
-in `loadTable` responses and is rejected on create/alter.
+- Industry tools (Iceberg maintenance docs, Amoro table-expire) schedule
+ expire and apply age / retain-last; count is not the primary trigger.
+- A count gate adds catalog reserved-property work without matching how
+ operators expect expire to run.
+- Orphan cleanup already established the always-eligible + schedule model
+ for whole-table cleanup.
-An unset or non-numeric `snapshot-count` is treated as "unknown" - the
-handler does not trigger, so a non-Iceberg table attached to the policy is
-skipped rather than mis-evaluated.
+A future optional "skip if fewer than N snapshots" optimization can be
+added without redesigning the policy or handler base class.
---
## 7. File Changes Summary
-### 7.1 New Files
-
-| File
| Layer | Description |
-|
---------------------------------------------------------------------------------
| -------- | ---------------------------------- |
-| `api/…/policy/IcebergSnapshotExpirationContent.java`
| Policy | Policy content with expiration cfg |
-|
`maintenance/optimizer/…/handler/snapshot/SnapshotExpirationStrategyHandler.java`
| Strategy | Trigger / score evaluation |
-| `maintenance/optimizer/…/handler/snapshot/SnapshotExpirationJobContext.java`
| Strategy | Job execution context |
-| `maintenance/optimizer/…/job/GravitinoSnapshotExpirationJobAdapter.java`
| Adapter | Context → job config conversion |
-| `maintenance/jobs/…/iceberg/IcebergExpireSnapshotsJob.java`
| Job | Spark job (already in PR #11206) |
-
-### 7.2 Modified Files
-
-| File | Change
|
-| ------------------------------------------------------- |
-------------------------------------------------------- |
-| `api/…/policy/Policy.java` | Add
`ICEBERG_SNAPSHOT_EXPIRATION` to `BuiltInType` enum |
-| `catalog-common/…/iceberg/IcebergConstants.java` | Add
`SNAPSHOT_COUNT` constant |
-| `catalogs/catalog-lakehouse-iceberg/…/IcebergTablePropertiesMetadata.java` |
Add reserved `snapshot-count` property entry |
-| `catalogs/catalog-lakehouse-iceberg/…/utils/IcebergTablePropertiesUtil.java`
| Populate `snapshot-count` from `TableMetadata.snapshots()` |
-| `maintenance/optimizer/…/job/GravitinoJobSubmitter.java` | Register
expire-snapshots adapter in `jobAdapters` map |
-| `maintenance/jobs/…/BuiltInJobTemplateProvider.java` | Register
`IcebergExpireSnapshotsJob` (in PR #11206) |
-| Handler registry | Register
`SnapshotExpirationStrategyHandler` |
-
-### 7.3 Test Files
-
-| File | Description
|
-| ------------------------------------------------ |
----------------------------------- |
-| `TestIcebergSnapshotExpirationContent.java` | Policy content unit tests
|
-| `TestSnapshotExpirationStrategyHandler.java` | Strategy handler unit
tests, including missing / non-numeric `snapshot-count` |
-| `TestGravitinoSnapshotExpirationJobAdapter.java` | Job adapter unit tests
|
-| `TestIcebergTable.java` (existing) | Assert `snapshot-count`
is exposed and reserved |
-| `TestIcebergExpireSnapshotsJob.java` | Spark job unit tests (in
PR #11206) |
+### 7.1 New / primary files
+
+| File | Layer | Description |
+| ---- | ----- | ----------- |
+| `api/…/policy/IcebergSnapshotExpirationContent.java` | Policy | Content +
`trigger-expr` / `job.options.*` |
+|
`maintenance/optimizer/…/handler/snapshot/SnapshotExpirationStrategyHandler.java`
| Strategy | Orphan-style handler |
+| `maintenance/optimizer/…/handler/snapshot/SnapshotExpirationJobContext.java`
| Strategy | Job context |
+| `maintenance/optimizer/…/job/GravitinoSnapshotExpirationJobAdapter.java` |
Adapter | Options → jobConf |
+| `maintenance/jobs/…/iceberg/IcebergExpireSnapshotsJob.java` | Job | Already
shipped |
+
+### 7.2 Modified files
+
+| File | Change |
+| ---- | ------ |
+| `api/…/policy/Policy.java` / DTO / OpenAPI | Built-in type and REST fields |
+| `maintenance/optimizer/…/job/GravitinoJobSubmitter.java` | Register expire
adapter |
+| Optimizer docs | Handler `className` registration |
+
+### 7.3 Tests
+
+| File | Description |
+| ---- | ----------- |
+| `TestIcebergSnapshotExpirationContent.java` | Policy content |
+| `TestGravitinoSnapshotExpirationJobAdapter.java` | Handler + adapter
(orphan-style) |
+| `TestIcebergExpireSnapshotsJob.java` | Spark job |
---
-## 8. Proposed PR Plan
+## 8. Configuration
-| PR | Scope
| Dependencies |
-| ----------------- |
------------------------------------------------------------------------------------
| ------------ |
-| **PR 1** (this) | Design document
| None |
-| **PR 2** (ready) | Job layer: `IcebergExpireSnapshotsJob` +
`BuiltInJobTemplateProvider` + tests | PR 1 |
-| **PR 3** | Metadata layer: reserved `snapshot-count` Iceberg table
property + tests | PR 1 |
-| **PR 4** | Policy layer: `IcebergSnapshotExpirationContent` +
`Policy.BuiltInType` + tests | PR 2 |
-| **PR 5** | Strategy + Adapter: handler, context, adapter, submitter
registration + tests | PR 3, PR 4 |
+```properties
+gravitino.optimizer.strategyHandler.iceberg-snapshot-expiration.className = \
+
org.apache.gravitino.maintenance.optimizer.recommender.handler.snapshot.SnapshotExpirationStrategyHandler
+```
-PR 3 is independent of PR 2 and can land in parallel. PRs 4 and 5 can be
-combined into a single PR if preferred.
+The job adapter is registered in code. Shared submitter settings still
+supply `catalog_name` and `spark_conf`.
---
## 9. Open Questions
-1. **Trigger conditions** - Should we support conditions beyond snapshot
- count (e.g., oldest snapshot age, total metadata size)? Oldest snapshot
- age would need a second reserved property
- (`oldest-snapshot-timestamp-ms`); it is equally free to compute from
- `TableMetadata`, so it can be added later without redesign.
-2. **PR granularity** - Single PR for all remaining layers or split
- per-layer?
+1. **Optional skip-if-few-snapshots** - Worth a later knob, or leave to
+ Iceberg no-op when nothing is expired?
+2. **Default `streamResults`** - Shipped as `false` (procedure default).
+ Operators may prefer `true` on large tables; field remains configurable.
---
-## 10. Comparison with Compaction Flow
-
-| Aspect | Compaction
| Snapshot Expiration |
-| ----------------- |
-------------------------------------------------------------- |
--------------------------------------- |
-| Policy type | `system_iceberg_compaction`
| `system_iceberg_snapshot_expiration` |
-| Strategy type | `iceberg-data-compaction`
| `iceberg-snapshot-expiration` |
-| Job template | `builtin-iceberg-rewrite-data-files`
| `builtin-iceberg-expire-snapshots` |
-| Scope | Per-partition (scored, top-N selected)
| Whole table |
-| Handler base | `BaseExpressionStrategyHandler` (expression-driven)
| `StrategyHandler` (single metadata check) |
-| Data requirements | `TABLE_METADATA` + `TABLE_STATISTICS` +
`PARTITION_STATISTICS` | `TABLE_METADATA` |
-| Trigger input | `custom-data-file-mse`, `custom-delete-file-number`
(statistics) | `snapshot-count` (table metadata property) |
-| Iceberg procedure | `rewrite_data_files`
| `expire_snapshots` |
-| Key parameters | strategy, sort-order, where, options (all required)
| older_than, retain_last (independently optional, fall back to Iceberg
table properties), stream_results (defaults to `false`) |
+## 10. Comparison with Orphan Cleanup
+
+| Aspect | Orphan cleanup |
Snapshot expiration |
+| ----------------- | --------------------------------------------------- |
-------------------------------------------- |
+| Policy type | `system_iceberg_orphan_file_removal` |
`system_iceberg_snapshot_expiration` |
+| Strategy type | `iceberg-orphan-file-removal` |
`iceberg-snapshot-expiration` |
+| Job template | `builtin-iceberg-remove-orphan-files` |
`builtin-iceberg-expire-snapshots` |
+| Scope | Whole table |
Whole table |
+| Handler base | `BaseExpressionStrategyHandler` |
`BaseExpressionStrategyHandler` |
+| Data requirements | `TABLE_METADATA` |
`TABLE_METADATA` |
+| Trigger | `trigger-expr: true` |
`trigger-expr: true` |
+| Iceberg procedure | `remove_orphan_files` |
`expire_snapshots` |
+| Key parameters | `olderThanDays`, `location`, `dryRun` |
`olderThanDays`, `retainLast`, `streamResults` |
diff --git a/docs/open-api/policies.yaml b/docs/open-api/policies.yaml
index 781080ff47..924eba053b 100644
--- a/docs/open-api/policies.yaml
+++ b/docs/open-api/policies.yaml
@@ -517,8 +517,30 @@ components:
IcebergSnapshotExpirationContent:
type: object
- description: Placeholder content for system_iceberg_snapshot_expiration.
Configurable thresholds land in a follow-up.
+ description: >
+ Snapshot expiration options for system_iceberg_snapshot_expiration.
Scheduling is
+ configured separately. On updateContent, newContent fully replaces the
previous content;
+ omitted fields use these defaults rather than merging with stored
values. Integer fields
+ must be JSON integers (not strings or floats); streamResults must be a
JSON boolean.
additionalProperties: false
+ properties:
+ olderThanDays:
+ type: integer
+ format: int64
+ minimum: 1
+ maximum: 36500
+ default: 5
+ description: Minimum age of snapshots eligible for expiration, in
days (Iceberg default).
+ retainLast:
+ type: integer
+ format: int64
+ minimum: 1
+ default: 1
+ description: Minimum number of recent snapshots to keep regardless
of age.
+ streamResults:
+ type: boolean
+ default: false
+ description: Whether the expire job should stream intermediate
delete results.
IcebergSnapshotExpirationPolicy:
allOf:
diff --git a/docs/table-maintenance-service/optimizer-configuration.md
b/docs/table-maintenance-service/optimizer-configuration.md
index ffa9334358..bd9bd65b46 100644
--- a/docs/table-maintenance-service/optimizer-configuration.md
+++ b/docs/table-maintenance-service/optimizer-configuration.md
@@ -198,9 +198,54 @@ See [Remove Orphan
Files](./optimizer-cli-reference.md#remove-orphan-files) for
complete submission example. For policy-driven submission, configure the
handler
and policy below. Periodic scheduling remains separate.
+### Snapshot expiration policy integration
+
+Register the handler alongside the existing optimizer providers (optional; the
+Recommender also defaults this class when the property is unset):
+
+```properties
+gravitino.optimizer.strategyHandler.iceberg-snapshot-expiration.className =
org.apache.gravitino.maintenance.optimizer.recommender.handler.snapshot.SnapshotExpirationStrategyHandler
+```
+
+The built-in job adapter is registered automatically. Keep the same Spark and
+catalog submission configuration as for direct expire-snapshots runs, including
+`catalog_name` and `spark_conf`. The adapter supplies `table_identifier` and
+`stream_results`. For `older_than` / `retain_last`, values equal to Iceberg's
+procedure defaults (`olderThanDays=5`, `retainLast=1`) are omitted so the job
+template placeholders stay empty and Iceberg table `history.expire.*`
+properties remain authoritative; non-default values are emitted explicitly.
+
+Create a policy through `POST /api/metalakes/{metalake}/policies`:
+
+```json
+{
+ "name": "expire_snapshots",
+ "policyType": "system_iceberg_snapshot_expiration",
+ "enabled": true,
+ "content": {
+ "olderThanDays": 5,
+ "retainLast": 1,
+ "streamResults": false
+ }
+}
+```
+
+Associate the policy with a tag and attach that tag to the target table,
schema,
+or catalog, following the [policy setup walkthrough](./optimizer.md).
+`olderThanDays` defaults to 5 (Iceberg's expire default) and must be between 1
and 36500.
+`retainLast` defaults to 1. `streamResults` defaults to false.
+
+When `olderThanDays` is not the Iceberg default of 5, the adapter subtracts it
+from the current instant and emits `older_than` as an explicit UTC timestamp
+ending in `Z`. Each optimizer invocation may submit expire when the policy is
+attached (`trigger-expr` is `true`). This does not install a periodic
+scheduler, cooldown, or last-run tracking—same as orphan cleanup. Operators
+must control how often `submit-strategy-jobs` (or an external cron) runs.
+
### Orphan cleanup policy integration
-Register the handler alongside the existing optimizer providers:
+Register the handler alongside the existing optimizer providers (optional; the
+Recommender also defaults this class when the property is unset):
```properties
gravitino.optimizer.strategyHandler.iceberg-orphan-file-removal.className =
org.apache.gravitino.maintenance.optimizer.recommender.handler.orphan.OrphanFileRemovalStrategyHandler
diff --git
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/Recommender.java
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/Recommender.java
index d73ceb4aa0..254836d40a 100644
---
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/Recommender.java
+++
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/Recommender.java
@@ -50,7 +50,13 @@ import
org.apache.gravitino.maintenance.optimizer.common.OptimizerEnv;
import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
import org.apache.gravitino.maintenance.optimizer.common.util.ProviderUtils;
import
org.apache.gravitino.maintenance.optimizer.recommender.handler.ManifestRewriteStrategyHandler;
+import
org.apache.gravitino.maintenance.optimizer.recommender.handler.compaction.CompactionStrategyHandler;
+import
org.apache.gravitino.maintenance.optimizer.recommender.handler.orphan.OrphanFileRemovalStrategyHandler;
+import
org.apache.gravitino.maintenance.optimizer.recommender.handler.snapshot.SnapshotExpirationStrategyHandler;
+import org.apache.gravitino.policy.IcebergDataCompactionContent;
+import org.apache.gravitino.policy.IcebergOrphanFileRemovalContent;
import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
+import org.apache.gravitino.policy.IcebergSnapshotExpirationContent;
import org.apache.gravitino.rel.Table;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -380,10 +386,22 @@ public class Recommender implements AutoCloseable {
*/
private String getStrategyHandlerClassName(String strategyType) {
String configured =
optimizerEnv.config().getStrategyHandlerClassName(strategyType);
- if (StringUtils.isBlank(configured)
- &&
IcebergRewriteManifestsContent.STRATEGY_TYPE_VALUE.equals(strategyType)) {
+ if (StringUtils.isNotBlank(configured)) {
+ return configured;
+ }
+ // Built-in defaults so policy-driven jobs work without copying docs into
conf.
+ if (IcebergDataCompactionContent.STRATEGY_TYPE_VALUE.equals(strategyType))
{
+ return CompactionStrategyHandler.class.getName();
+ }
+ if
(IcebergRewriteManifestsContent.STRATEGY_TYPE_VALUE.equals(strategyType)) {
return ManifestRewriteStrategyHandler.class.getName();
}
+ if
(IcebergOrphanFileRemovalContent.STRATEGY_TYPE_VALUE.equals(strategyType)) {
+ return OrphanFileRemovalStrategyHandler.class.getName();
+ }
+ if
(IcebergSnapshotExpirationContent.STRATEGY_TYPE_VALUE.equals(strategyType)) {
+ return SnapshotExpirationStrategyHandler.class.getName();
+ }
return configured;
}
diff --git
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/snapshot/SnapshotExpirationJobContext.java
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/snapshot/SnapshotExpirationJobContext.java
new file mode 100644
index 0000000000..718ee74dd5
--- /dev/null
+++
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/snapshot/SnapshotExpirationJobContext.java
@@ -0,0 +1,59 @@
+/*
+ * 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.gravitino.maintenance.optimizer.recommender.handler.snapshot;
+
+import java.util.Map;
+import org.apache.gravitino.NameIdentifier;
+import
org.apache.gravitino.maintenance.optimizer.api.recommender.JobExecutionContext;
+
+/** Immutable target and options for table-level snapshot expiration. */
+public class SnapshotExpirationJobContext implements JobExecutionContext {
+ private final NameIdentifier name;
+ private final Map<String, String> jobOptions;
+ private final String jobTemplateName;
+
+ /**
+ * Creates the context from table metadata and policy options.
+ *
+ * @param name normalized catalog.schema.table identifier
+ * @param jobOptions policy options
+ * @param jobTemplateName template to submit
+ */
+ public SnapshotExpirationJobContext(
+ NameIdentifier name, Map<String, String> jobOptions, String
jobTemplateName) {
+ this.name = name;
+ this.jobOptions = Map.copyOf(jobOptions);
+ this.jobTemplateName = jobTemplateName;
+ }
+
+ @Override
+ public NameIdentifier nameIdentifier() {
+ return name;
+ }
+
+ @Override
+ public Map<String, String> jobOptions() {
+ return jobOptions;
+ }
+
+ @Override
+ public String jobTemplateName() {
+ return jobTemplateName;
+ }
+}
diff --git
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/snapshot/SnapshotExpirationStrategyHandler.java
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/snapshot/SnapshotExpirationStrategyHandler.java
new file mode 100644
index 0000000000..43c371aa3e
--- /dev/null
+++
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/snapshot/SnapshotExpirationStrategyHandler.java
@@ -0,0 +1,63 @@
+/*
+ * 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.gravitino.maintenance.optimizer.recommender.handler.snapshot;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.maintenance.optimizer.api.common.PartitionPath;
+import org.apache.gravitino.maintenance.optimizer.api.common.Strategy;
+import
org.apache.gravitino.maintenance.optimizer.api.recommender.JobExecutionContext;
+import
org.apache.gravitino.maintenance.optimizer.recommender.handler.BaseExpressionStrategyHandler;
+import org.apache.gravitino.policy.IcebergSnapshotExpirationContent;
+import org.apache.gravitino.rel.Table;
+
+/**
+ * Evaluates snapshot expiration at table level, including for partitioned
tables.
+ *
+ * <p>Follows orphan cleanup: {@code trigger-expr} defaults to {@code true},
so each optimizer
+ * invocation may submit expire when the policy is attached. Retention is
enforced by job options
+ * ({@code olderThanDays} / {@code retainLast}), not by a snapshot-count gate.
+ */
+public class SnapshotExpirationStrategyHandler extends
BaseExpressionStrategyHandler {
+
+ /** Registered strategy type. */
+ public static final String NAME =
IcebergSnapshotExpirationContent.STRATEGY_TYPE_VALUE;
+
+ @Override
+ public Set<DataRequirement> dataRequirements() {
+ return Set.of(DataRequirement.TABLE_METADATA);
+ }
+
+ @Override
+ public String strategyType() {
+ return NAME;
+ }
+
+ @Override
+ protected JobExecutionContext buildJobExecutionContext(
+ NameIdentifier nameIdentifier,
+ Strategy strategy,
+ Table tableMetadata,
+ List<PartitionPath> partitions,
+ Map<String, String> jobOptions) {
+ return new SnapshotExpirationJobContext(nameIdentifier, jobOptions,
strategy.jobTemplateName());
+ }
+}
diff --git
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoJobSubmitter.java
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoJobSubmitter.java
index 69fb475611..1a6f62b377 100644
---
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoJobSubmitter.java
+++
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoJobSubmitter.java
@@ -33,6 +33,7 @@ import
org.apache.gravitino.maintenance.optimizer.common.util.GravitinoClientUti
import org.apache.gravitino.policy.IcebergDataCompactionContent;
import org.apache.gravitino.policy.IcebergOrphanFileRemovalContent;
import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
+import org.apache.gravitino.policy.IcebergSnapshotExpirationContent;
/** Submits optimizer jobs to Gravitino using job template adapters. */
public class GravitinoJobSubmitter implements JobSubmitter {
@@ -55,7 +56,9 @@ public class GravitinoJobSubmitter implements JobSubmitter {
IcebergRewriteManifestsContent.JOB_TEMPLATE_NAME_VALUE,
GravitinoManifestRewriteJobAdapter.class,
IcebergOrphanFileRemovalContent.JOB_TEMPLATE_NAME_VALUE,
- GravitinoOrphanFileRemovalJobAdapter.class);
+ GravitinoOrphanFileRemovalJobAdapter.class,
+ IcebergSnapshotExpirationContent.JOB_TEMPLATE_NAME_VALUE,
+ GravitinoSnapshotExpirationJobAdapter.class);
@Override
public String name() {
diff --git
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoSnapshotExpirationJobAdapter.java
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoSnapshotExpirationJobAdapter.java
new file mode 100644
index 0000000000..30d000bf3c
--- /dev/null
+++
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoSnapshotExpirationJobAdapter.java
@@ -0,0 +1,120 @@
+/*
+ * 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.gravitino.maintenance.optimizer.recommender.job;
+
+import com.google.common.base.Preconditions;
+import java.time.Clock;
+import java.time.DateTimeException;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import
org.apache.gravitino.maintenance.optimizer.api.recommender.JobExecutionContext;
+import org.apache.gravitino.maintenance.optimizer.common.util.IdentifierUtils;
+import
org.apache.gravitino.maintenance.optimizer.recommender.handler.snapshot.SnapshotExpirationJobContext;
+import org.apache.gravitino.policy.IcebergSnapshotExpirationContent;
+
+/** Converts snapshot-expiration options to the built-in Spark template
configuration. */
+public class GravitinoSnapshotExpirationJobAdapter implements
GravitinoJobAdapter {
+ private static final String TABLE_IDENTIFIER_KEY = "table_identifier";
+ private static final String OLDER_THAN_KEY = "older_than";
+ private static final String RETAIN_LAST_KEY = "retain_last";
+ private static final String STREAM_RESULTS_KEY = "stream_results";
+ private static final DateTimeFormatter TIMESTAMP =
+ DateTimeFormatter.ofPattern("uuuu-MM-dd
HH:mm:ssXXX").withZone(ZoneOffset.UTC);
+ private final Clock clock;
+
+ /** Creates an adapter using the UTC system clock. */
+ public GravitinoSnapshotExpirationJobAdapter() {
+ this(Clock.systemUTC());
+ }
+
+ GravitinoSnapshotExpirationJobAdapter(Clock clock) {
+ this.clock = clock;
+ }
+
+ @Override
+ public Map<String, String> jobConfig(JobExecutionContext context) {
+ Preconditions.checkArgument(
+ context instanceof SnapshotExpirationJobContext,
+ "jobExecutionContext must be SnapshotExpirationJobContext");
+ SnapshotExpirationJobContext expire = (SnapshotExpirationJobContext)
context;
+ Map<String, String> options = expire.jobOptions();
+ long days;
+ try {
+ days =
+ Long.parseLong(
+ options.getOrDefault(
+ IcebergSnapshotExpirationContent.OLDER_THAN_DAYS_KEY,
+
String.valueOf(IcebergSnapshotExpirationContent.DEFAULT_OLDER_THAN_DAYS)));
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("olderThanDays must be an integer",
e);
+ }
+ IcebergSnapshotExpirationContent.validateOlderThanDays(days);
+
+ long retainLast;
+ try {
+ retainLast =
+ Long.parseLong(
+ options.getOrDefault(
+ IcebergSnapshotExpirationContent.RETAIN_LAST_KEY,
+
String.valueOf(IcebergSnapshotExpirationContent.DEFAULT_RETAIN_LAST)));
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("retainLast must be an integer", e);
+ }
+ IcebergSnapshotExpirationContent.validateRetainLast(retainLast);
+
+ String streamResults =
+ options.getOrDefault(
+ IcebergSnapshotExpirationContent.STREAM_RESULTS_KEY,
+
String.valueOf(IcebergSnapshotExpirationContent.DEFAULT_STREAM_RESULTS));
+ Preconditions.checkArgument(
+ "true".equalsIgnoreCase(streamResults) ||
"false".equalsIgnoreCase(streamResults),
+ "streamResults must be true or false");
+
+ Map<String, String> config = new LinkedHashMap<>();
+ config.put(
+ TABLE_IDENTIFIER_KEY,
+
IdentifierUtils.removeCatalogFromIdentifier(expire.nameIdentifier()).toString());
+ // Match template placeholders {{older_than:-}} / {{retain_last:-}}: omit
when using the same
+ // defaults as Iceberg's procedure so table history.expire.* properties
remain authoritative.
+ if (days == IcebergSnapshotExpirationContent.DEFAULT_OLDER_THAN_DAYS) {
+ config.put(OLDER_THAN_KEY, "");
+ } else {
+ config.put(OLDER_THAN_KEY, cutoff(days));
+ }
+ if (retainLast == IcebergSnapshotExpirationContent.DEFAULT_RETAIN_LAST) {
+ config.put(RETAIN_LAST_KEY, "");
+ } else {
+ config.put(RETAIN_LAST_KEY, Long.toString(retainLast));
+ }
+ config.put(STREAM_RESULTS_KEY,
Boolean.toString(Boolean.parseBoolean(streamResults)));
+ return Map.copyOf(config);
+ }
+
+ private String cutoff(long days) {
+ try {
+ return TIMESTAMP.format(clock.instant().minus(days, ChronoUnit.DAYS));
+ } catch (DateTimeException | ArithmeticException e) {
+ throw new IllegalArgumentException(
+ "olderThanDays cannot be represented as an expiration timestamp", e);
+ }
+ }
+}
diff --git
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/job/TestGravitinoSnapshotExpirationJobAdapter.java
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/job/TestGravitinoSnapshotExpirationJobAdapter.java
new file mode 100644
index 0000000000..3f8a6f896f
--- /dev/null
+++
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/job/TestGravitinoSnapshotExpirationJobAdapter.java
@@ -0,0 +1,149 @@
+/*
+ * 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.gravitino.maintenance.optimizer.recommender.job;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Map;
+import java.util.Set;
+import org.apache.gravitino.NameIdentifier;
+import
org.apache.gravitino.maintenance.optimizer.api.recommender.StrategyHandler.DataRequirement;
+import
org.apache.gravitino.maintenance.optimizer.api.recommender.StrategyHandlerContext;
+import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
+import
org.apache.gravitino.maintenance.optimizer.recommender.handler.snapshot.SnapshotExpirationJobContext;
+import
org.apache.gravitino.maintenance.optimizer.recommender.handler.snapshot.SnapshotExpirationStrategyHandler;
+import
org.apache.gravitino.maintenance.optimizer.recommender.strategy.GravitinoStrategy;
+import org.apache.gravitino.policy.IcebergSnapshotExpirationContent;
+import org.apache.gravitino.policy.Policy;
+import org.apache.gravitino.policy.PolicyContents;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.expressions.transforms.Transforms;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+class TestGravitinoSnapshotExpirationJobAdapter {
+ private static final String TEMPLATE =
IcebergSnapshotExpirationContent.JOB_TEMPLATE_NAME_VALUE;
+ private static final Clock CLOCK =
+ Clock.fixed(Instant.parse("2026-09-27T12:30:00Z"), ZoneOffset.UTC);
+ private final GravitinoSnapshotExpirationJobAdapter adapter =
+ new GravitinoSnapshotExpirationJobAdapter(CLOCK);
+
+ @Test
+ void policyToSubmissionWorksWithoutPartitionStatistics() {
+ for (boolean partitioned : new boolean[] {false, true}) {
+ Policy policy = Mockito.mock(Policy.class);
+ Mockito.when(policy.content())
+ .thenReturn(PolicyContents.icebergSnapshotExpiration(5L, 3L, true));
+ GravitinoStrategy strategy = new GravitinoStrategy(policy);
+ Table table = Mockito.mock(Table.class);
+ Mockito.when(table.properties()).thenReturn(Map.of());
+ Mockito.when(table.partitioning())
+ .thenReturn(partitioned ? new Transform[]
{Transforms.identity("id")} : new Transform[0]);
+ SnapshotExpirationStrategyHandler handler = new
SnapshotExpirationStrategyHandler();
+ handler.initialize(
+ StrategyHandlerContext.builder(NameIdentifier.of("catalog", "db",
"table"), strategy)
+ .withTableMetadata(table)
+ .build());
+ Assertions.assertEquals(Set.of(DataRequirement.TABLE_METADATA),
handler.dataRequirements());
+ Assertions.assertEquals(strategy.strategyType(), handler.strategyType());
+ Assertions.assertTrue(handler.shouldTrigger());
+ Assertions.assertEquals(1, handler.evaluate().score());
+ SnapshotExpirationJobContext context =
+ (SnapshotExpirationJobContext)
handler.evaluate().jobExecutionContext().orElseThrow();
+ Assertions.assertInstanceOf(
+ GravitinoSnapshotExpirationJobAdapter.class,
+ new
GravitinoJobSubmitter().loadJobAdapter(context.jobTemplateName()));
+ OptimizerConfig config =
+ new OptimizerConfig(
+ Map.of(
+ OptimizerConfig.JOB_SUBMITTER_CONFIG_PREFIX +
"catalog_name", "spark_catalog"));
+ Map<String, String> job = GravitinoJobSubmitter.buildJobConfig(config,
context, adapter);
+ Assertions.assertEquals("spark_catalog", job.get("catalog_name"));
+ Assertions.assertEquals("db.table", job.get("table_identifier"));
+ // olderThanDays=5 matches Iceberg default → omit so table properties
can apply
+ Assertions.assertEquals("", job.get("older_than"));
+ Assertions.assertEquals("3", job.get("retain_last"));
+ Assertions.assertEquals("true", job.get("stream_results"));
+ }
+ }
+
+ @Test
+ void defaultsFillOptionalPlaceholders() {
+ Map<String, String> job = adapter.jobConfig(context(Map.of()));
+ Assertions.assertEquals("", job.get("older_than"));
+ Assertions.assertEquals("", job.get("retain_last"));
+ Assertions.assertEquals("false", job.get("stream_results"));
+ Assertions.assertEquals(
+ "2026-09-26 12:30:00Z",
+ adapter.jobConfig(context(Map.of("olderThanDays",
"1"))).get("older_than"));
+ }
+
+ @Test
+ void rejectsUnsafeOptionsBeforeSubmission() {
+ for (String days : new String[] {"0", "-1", "no", "9223372036854775807"}) {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> adapter.jobConfig(context(Map.of("olderThanDays", days))));
+ }
+ for (String retain : new String[] {"0", "-1", "no"}) {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> adapter.jobConfig(context(Map.of("retainLast", retain))));
+ }
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> adapter.jobConfig(context(Map.of("streamResults", "yes"))));
+ }
+
+ @Test
+ void validatesRetentionRangeAndReportsTimestampErrors() {
+ long maximum = IcebergSnapshotExpirationContent.MAX_OLDER_THAN_DAYS;
+ Assertions.assertEquals(
+ "1926-10-22 12:30:00Z",
+ adapter
+ .jobConfig(context(Map.of("olderThanDays",
String.valueOf(maximum))))
+ .get("older_than"));
+ for (String days :
+ new String[] {
+ "0", "-1", "no", String.valueOf(maximum + 1),
String.valueOf(Long.MAX_VALUE)
+ }) {
+ IllegalArgumentException error =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> adapter.jobConfig(context(Map.of("olderThanDays", days))));
+ Assertions.assertTrue(error.getMessage().contains("olderThanDays"));
+ }
+ GravitinoSnapshotExpirationJobAdapter ancient =
+ new GravitinoSnapshotExpirationJobAdapter(Clock.fixed(Instant.MIN,
ZoneOffset.UTC));
+ IllegalArgumentException error =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> ancient.jobConfig(context(Map.of("olderThanDays", "2"))));
+ Assertions.assertTrue(error.getMessage().contains("olderThanDays"));
+ Assertions.assertNotNull(error.getCause());
+ }
+
+ private SnapshotExpirationJobContext context(Map<String, String> options) {
+ return new SnapshotExpirationJobContext(
+ NameIdentifier.of("catalog", "db", "table"), options, TEMPLATE);
+ }
+}