This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch branch-tms-scheduler
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-tms-scheduler by this
push:
new d926cddeb8 [#13800] feat(policy): Maintenance execution peers on
Iceberg contents (#13885)
d926cddeb8 is described below
commit d926cddeb81e276c5404f6a662df2f482f3f7b27
Author: MaSai <[email protected]>
AuthorDate: Fri Oct 16 10:01:18 2026 +0800
[#13800] feat(policy): Maintenance execution peers on Iceberg contents
(#13885)
### What changes were proposed in this pull request?
Split from #13801 (1/2). **Domain** execution peers for Epic #13764 /
#13800.
- `PolicyExecutionSpec` / `ExecutablePolicyContent` /
`PolicyExecutionFields`
- Four Iceberg maintenance contents carry peers at construction
- TMS design notes on peers / `BuiltInType` identity
**Follow-up (stack):** #13888 / #13887 — wire/DTO/SerDe +
`minIntervalMs` registry.
### Why are the changes needed?
TMS needs shared trigger / cooldown / option-bag concepts without
freezing them onto every `PolicyContent`.
Fix: #13800
### Does this PR introduce _any_ user-facing change?
API/domain surface for optional peers on Iceberg maintenance contents
(REST wire lands in #13887).
### How was this patch tested?
```bash
./gradlew :api:test --tests 'org.apache.gravitino.policy.*' -PskipITs
```
---------
Co-authored-by: Cursor <[email protected]>
---
.../policy/AbstractExecutablePolicyContent.java | 112 +++++++
.../gravitino/policy/ExecutablePolicyContent.java | 35 ++
.../policy/IcebergDataCompactionContent.java | 78 +++--
.../policy/IcebergOrphanFileRemovalContent.java | 23 +-
.../policy/IcebergRewriteManifestsContent.java | 23 +-
.../policy/IcebergSnapshotExpirationContent.java | 23 +-
.../org/apache/gravitino/policy/PolicyContent.java | 11 +-
.../apache/gravitino/policy/PolicyContents.java | 131 +++++++-
.../gravitino/policy/PolicyExecutionSpec.java | 283 ++++++++++++++++
.../gravitino/policy/TestPolicyContents.java | 65 ++++
.../gravitino/policy/TestPolicyExecutionSpec.java | 103 ++++++
.../json/ExecutablePolicyContentSerde.java | 370 +++++++++++++++++++++
.../java/org/apache/gravitino/json/JsonUtils.java | 49 ++-
.../storage/relational/utils/TestPOConverters.java | 139 ++++++++
design-docs/table-maintenance-service-design.md | 92 +++--
15 files changed, 1450 insertions(+), 87 deletions(-)
diff --git
a/api/src/main/java/org/apache/gravitino/policy/AbstractExecutablePolicyContent.java
b/api/src/main/java/org/apache/gravitino/policy/AbstractExecutablePolicyContent.java
new file mode 100644
index 0000000000..c24a827699
--- /dev/null
+++
b/api/src/main/java/org/apache/gravitino/policy/AbstractExecutablePolicyContent.java
@@ -0,0 +1,112 @@
+/*
+ * 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 java.util.Objects;
+import javax.annotation.Nullable;
+
+/**
+ * Base for built-in Iceberg maintenance policy contents that carry optional
execution peers.
+ *
+ * <p>Peers are supplied at construction as an immutable {@link
PolicyExecutionSpec}. Subclasses
+ * keep strategy fields separate and pass the spec through {@link
+ * #AbstractExecutablePolicyContent(PolicyExecutionSpec)}. Wire and
persistence JSON keep peer keys
+ * flat; mapping to and from those keys is handled in {@code common} (custom
Jackson serdes), not by
+ * flattening the five fields onto this type.
+ */
+public abstract class AbstractExecutablePolicyContent implements
ExecutablePolicyContent {
+
+ private final PolicyExecutionSpec executionSpec;
+
+ /**
+ * Creates content with the given execution peers.
+ *
+ * @param executionSpec immutable peers; {@code null} is treated as {@link
+ * PolicyExecutionSpec#empty()}
+ */
+ protected AbstractExecutablePolicyContent(@Nullable PolicyExecutionSpec
executionSpec) {
+ this.executionSpec = executionSpec == null ? PolicyExecutionSpec.empty() :
executionSpec;
+ }
+
+ /** Creates content with unset execution peers. */
+ protected AbstractExecutablePolicyContent() {
+ this(PolicyExecutionSpec.empty());
+ }
+
+ @Override
+ public PolicyExecutionSpec executionSpec() {
+ return executionSpec;
+ }
+
+ /**
+ * Validates universal {@link PolicyContent} rules plus shared
execution-peer checks.
+ *
+ * <p>Does not enforce type-specific maintenance contracts (for example
orphan cleanup forbidding
+ * {@code commitTrigger}, or cron parsing). Those belong on concrete
subclasses or the server
+ * create/alter path with the real {@link Policy.BuiltInType}.
+ */
+ @Override
+ public void validate() throws IllegalArgumentException {
+ ExecutablePolicyContent.super.validate();
+ executionSpec().validate();
+ }
+
+ /**
+ * Whether execution fields match.
+ *
+ * @param that other content
+ * @return true when execution fields are equal
+ */
+ protected boolean executionFieldsEqual(AbstractExecutablePolicyContent that)
{
+ return Objects.equals(executionSpec(), that.executionSpec());
+ }
+
+ /**
+ * @return hash of execution fields
+ */
+ protected int executionFieldsHashCode() {
+ return Objects.hashCode(executionSpec());
+ }
+
+ /**
+ * Appends execution fields to a subclass {@code toString} body.
+ *
+ * <p>Option map <em>values</em> are never rendered (keys only) so
accidental credentials are not
+ * exposed through logs or exception messages.
+ *
+ * <p>Callers that already wrote subclass fields should append {@code ", "}
before invoking this
+ * method.
+ *
+ * @param sb builder already containing an opening brace and any subclass
fields
+ */
+ protected void appendExecutionFields(StringBuilder sb) {
+ PolicyExecutionSpec spec = executionSpec();
+ sb.append("timeTrigger='")
+ .append(spec.timeTrigger())
+ .append('\'')
+ .append(", commitTrigger=")
+ .append(spec.commitTrigger())
+ .append(", minIntervalMs=")
+ .append(spec.minIntervalMs())
+ .append(", jobOptionsKeys=")
+ .append(spec.jobOptions().keySet())
+ .append(", updaterOptionsKeys=")
+ .append(spec.updaterOptions().keySet());
+ }
+}
diff --git
a/api/src/main/java/org/apache/gravitino/policy/ExecutablePolicyContent.java
b/api/src/main/java/org/apache/gravitino/policy/ExecutablePolicyContent.java
new file mode 100644
index 0000000000..7a6fa84296
--- /dev/null
+++ b/api/src/main/java/org/apache/gravitino/policy/ExecutablePolicyContent.java
@@ -0,0 +1,35 @@
+/*
+ * 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;
+
+/**
+ * Policy content that carries immutable execution peers for automated
maintenance.
+ *
+ * <p>Row-filter, column-mask, and custom contents are not executable in this
sense. Callers that
+ * need peers should use {@code instanceof ExecutablePolicyContent} (or a
concrete maintenance
+ * content type) and read {@link #executionSpec()} rather than assuming every
{@link PolicyContent}
+ * exposes triggers or option bags.
+ */
+public interface ExecutablePolicyContent extends PolicyContent {
+
+ /**
+ * @return immutable execution peers; never {@code null}
+ */
+ PolicyExecutionSpec executionSpec();
+}
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..00aec6a0ec 100644
---
a/api/src/main/java/org/apache/gravitino/policy/IcebergDataCompactionContent.java
+++
b/api/src/main/java/org/apache/gravitino/policy/IcebergDataCompactionContent.java
@@ -32,7 +32,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.MetadataObject;
/** Built-in policy content for Iceberg compaction strategy. */
-public class IcebergDataCompactionContent implements PolicyContent {
+public class IcebergDataCompactionContent extends
AbstractExecutablePolicyContent {
/** Property key for strategy type. */
public static final String STRATEGY_TYPE_KEY = "strategy.type";
/** Strategy type value for iceberg data compaction. */
@@ -130,7 +130,7 @@ public class IcebergDataCompactionContent implements
PolicyContent {
/** Default constructor for Jackson deserialization only. */
private IcebergDataCompactionContent() {
- this(null, null, null, null, null, null, null, null);
+ this(null, null, null, null, null, null, null, null,
PolicyExecutionSpec.empty());
}
IcebergDataCompactionContent(
@@ -142,6 +142,29 @@ public class IcebergDataCompactionContent implements
PolicyContent {
String rewriteStrategy,
String sortOrder,
Map<String, String> rewriteOptions) {
+ this(
+ minDataFileMse,
+ minDeleteFileNumber,
+ dataFileMseWeight,
+ deleteFileNumberWeight,
+ maxPartitionNum,
+ rewriteStrategy,
+ sortOrder,
+ rewriteOptions,
+ PolicyExecutionSpec.empty());
+ }
+
+ IcebergDataCompactionContent(
+ Long minDataFileMse,
+ Long minDeleteFileNumber,
+ Long dataFileMseWeight,
+ Long deleteFileNumberWeight,
+ Long maxPartitionNum,
+ String rewriteStrategy,
+ String sortOrder,
+ Map<String, String> rewriteOptions,
+ PolicyExecutionSpec executionSpec) {
+ super(executionSpec);
// Nullable inputs are treated as "use default" to simplify policy
creation.
this.minDataFileMse = minDataFileMse == null ? DEFAULT_MIN_DATA_FILE_MSE :
minDataFileMse;
this.minDeleteFileNumber =
@@ -267,7 +290,7 @@ public class IcebergDataCompactionContent implements
PolicyContent {
@Override
public void validate() throws IllegalArgumentException {
- PolicyContent.super.validate();
+ super.validate();
// All fields are defaulted in the constructor, so only range checks are
needed here.
Preconditions.checkArgument(minDataFileMse >= 0, "minDataFileMse must be
>= 0");
Preconditions.checkArgument(minDeleteFileNumber >= 0, "minDeleteFileNumber
must be >= 0");
@@ -314,7 +337,8 @@ public class IcebergDataCompactionContent implements
PolicyContent {
return false;
}
IcebergDataCompactionContent that = (IcebergDataCompactionContent) o;
- return Objects.equals(minDataFileMse, that.minDataFileMse)
+ return executionFieldsEqual(that)
+ && Objects.equals(minDataFileMse, that.minDataFileMse)
&& Objects.equals(minDeleteFileNumber, that.minDeleteFileNumber)
&& Objects.equals(dataFileMseWeight, that.dataFileMseWeight)
&& Objects.equals(deleteFileNumberWeight, that.deleteFileNumberWeight)
@@ -327,6 +351,7 @@ public class IcebergDataCompactionContent implements
PolicyContent {
@Override
public int hashCode() {
return Objects.hash(
+ executionFieldsHashCode(),
minDataFileMse,
minDeleteFileNumber,
dataFileMseWeight,
@@ -339,25 +364,30 @@ public class IcebergDataCompactionContent implements
PolicyContent {
@Override
public String toString() {
- return "IcebergDataCompactionContent{"
- + "minDataFileMse="
- + minDataFileMse
- + ", minDeleteFileNumber="
- + minDeleteFileNumber
- + ", dataFileMseWeight="
- + dataFileMseWeight
- + ", deleteFileNumberWeight="
- + deleteFileNumberWeight
- + ", maxPartitionNum="
- + maxPartitionNum
- + ", rewriteStrategy='"
- + rewriteStrategy
- + '\''
- + ", sortOrder='"
- + sortOrder
- + '\''
- + ", rewriteOptions="
- + rewriteOptions
- + '}';
+ StringBuilder sb =
+ new StringBuilder("IcebergDataCompactionContent{")
+ .append("minDataFileMse=")
+ .append(minDataFileMse)
+ .append(", minDeleteFileNumber=")
+ .append(minDeleteFileNumber)
+ .append(", dataFileMseWeight=")
+ .append(dataFileMseWeight)
+ .append(", deleteFileNumberWeight=")
+ .append(deleteFileNumberWeight)
+ .append(", maxPartitionNum=")
+ .append(maxPartitionNum)
+ .append(", rewriteStrategy='")
+ .append(rewriteStrategy)
+ .append('\'')
+ .append(", sortOrder='")
+ .append(sortOrder)
+ .append('\'')
+ // Never render rewriteOptions values; same redaction as
jobOptions / updaterOptions.
+ .append(", rewriteOptionsKeys=")
+ .append(rewriteOptions.keySet())
+ .append(", ");
+ appendExecutionFields(sb);
+ sb.append('}');
+ return sb.toString();
}
}
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..441fe83516 100644
---
a/api/src/main/java/org/apache/gravitino/policy/IcebergOrphanFileRemovalContent.java
+++
b/api/src/main/java/org/apache/gravitino/policy/IcebergOrphanFileRemovalContent.java
@@ -31,7 +31,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.MetadataObject;
/** Configuration for table-level Iceberg orphan cleanup on an optimizer
invocation. */
-public class IcebergOrphanFileRemovalContent implements PolicyContent {
+public class IcebergOrphanFileRemovalContent extends
AbstractExecutablePolicyContent {
/** Property key for strategy type. */
public static final String STRATEGY_TYPE_KEY = "strategy.type";
/** Property key for the job template. */
@@ -64,10 +64,19 @@ public class IcebergOrphanFileRemovalContent implements
PolicyContent {
private final boolean dryRun;
private IcebergOrphanFileRemovalContent() {
- this(DEFAULT_OLDER_THAN_DAYS, null, DEFAULT_DRY_RUN);
+ this(DEFAULT_OLDER_THAN_DAYS, null, DEFAULT_DRY_RUN,
PolicyExecutionSpec.empty());
}
IcebergOrphanFileRemovalContent(long olderThanDays, @Nullable String
location, boolean dryRun) {
+ this(olderThanDays, location, dryRun, PolicyExecutionSpec.empty());
+ }
+
+ IcebergOrphanFileRemovalContent(
+ long olderThanDays,
+ @Nullable String location,
+ boolean dryRun,
+ @Nullable PolicyExecutionSpec executionSpec) {
+ super(executionSpec);
this.olderThanDays = olderThanDays;
this.location = location;
this.dryRun = dryRun;
@@ -136,7 +145,10 @@ public class IcebergOrphanFileRemovalContent implements
PolicyContent {
@Override
public void validate() {
- PolicyContent.super.validate();
+ super.validate();
+ Preconditions.checkArgument(
+ !Boolean.TRUE.equals(executionSpec().commitTrigger()),
+ "commitTrigger is not allowed for orphan file removal");
validateOlderThanDays(olderThanDays);
Preconditions.checkArgument(
location == null || StringUtils.isNotBlank(location), "location must
not be blank");
@@ -151,13 +163,14 @@ public class IcebergOrphanFileRemovalContent implements
PolicyContent {
return false;
}
IcebergOrphanFileRemovalContent that = (IcebergOrphanFileRemovalContent)
other;
- return olderThanDays == that.olderThanDays
+ return executionFieldsEqual(that)
+ && olderThanDays == that.olderThanDays
&& dryRun == that.dryRun
&& Objects.equals(location, that.location);
}
@Override
public int hashCode() {
- return Objects.hash(olderThanDays, location, dryRun);
+ return Objects.hash(executionFieldsHashCode(), olderThanDays, location,
dryRun);
}
}
diff --git
a/api/src/main/java/org/apache/gravitino/policy/IcebergRewriteManifestsContent.java
b/api/src/main/java/org/apache/gravitino/policy/IcebergRewriteManifestsContent.java
index 53d59202cb..0c1fa54d41 100644
---
a/api/src/main/java/org/apache/gravitino/policy/IcebergRewriteManifestsContent.java
+++
b/api/src/main/java/org/apache/gravitino/policy/IcebergRewriteManifestsContent.java
@@ -24,15 +24,16 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
+import javax.annotation.Nullable;
import org.apache.gravitino.MetadataObject;
/**
* Placeholder built-in policy content for Iceberg manifest rewrite.
*
* <p>Configurable thresholds will be added when the rewrite-manifests policy
lands; this class
- * registers the built-in type for TMS task mapping.
+ * registers {@link Policy.BuiltInType#ICEBERG_REWRITE_MANIFESTS}.
*/
-public class IcebergRewriteManifestsContent implements PolicyContent {
+public class IcebergRewriteManifestsContent extends
AbstractExecutablePolicyContent {
/** Property key for strategy type. */
public static final String STRATEGY_TYPE_KEY = "strategy.type";
/** Property key for the job template. */
@@ -51,7 +52,13 @@ public class IcebergRewriteManifestsContent implements
PolicyContent {
MetadataObject.Type.CATALOG, MetadataObject.Type.SCHEMA,
MetadataObject.Type.TABLE);
/** Default constructor for Jackson deserialization only. */
- public IcebergRewriteManifestsContent() {}
+ public IcebergRewriteManifestsContent() {
+ this(PolicyExecutionSpec.empty());
+ }
+
+ IcebergRewriteManifestsContent(@Nullable PolicyExecutionSpec executionSpec) {
+ super(executionSpec);
+ }
@Override
public Set<MetadataObject.Type> supportedObjectTypes() {
@@ -75,16 +82,20 @@ public class IcebergRewriteManifestsContent implements
PolicyContent {
@Override
public boolean equals(Object other) {
- return other instanceof IcebergRewriteManifestsContent;
+ return other instanceof IcebergRewriteManifestsContent
+ && executionFieldsEqual((IcebergRewriteManifestsContent) other);
}
@Override
public int hashCode() {
- return IcebergRewriteManifestsContent.class.hashCode();
+ return executionFieldsHashCode();
}
@Override
public String toString() {
- return "IcebergRewriteManifestsContent{}";
+ StringBuilder sb = new StringBuilder("IcebergRewriteManifestsContent{");
+ appendExecutionFields(sb);
+ sb.append('}');
+ return sb.toString();
}
}
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..0d4071f25e 100644
---
a/api/src/main/java/org/apache/gravitino/policy/IcebergSnapshotExpirationContent.java
+++
b/api/src/main/java/org/apache/gravitino/policy/IcebergSnapshotExpirationContent.java
@@ -24,15 +24,16 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
+import javax.annotation.Nullable;
import org.apache.gravitino.MetadataObject;
/**
* Placeholder built-in policy content for Iceberg snapshot expiration.
*
* <p>Configurable thresholds will be added when the expire-snapshots policy
lands; this class
- * registers the built-in type for TMS task mapping.
+ * registers {@link Policy.BuiltInType#ICEBERG_SNAPSHOT_EXPIRATION}.
*/
-public class IcebergSnapshotExpirationContent implements PolicyContent {
+public class IcebergSnapshotExpirationContent extends
AbstractExecutablePolicyContent {
/** Property key for strategy type. */
public static final String STRATEGY_TYPE_KEY = "strategy.type";
/** Property key for the job template. */
@@ -51,7 +52,13 @@ public class IcebergSnapshotExpirationContent implements
PolicyContent {
MetadataObject.Type.CATALOG, MetadataObject.Type.SCHEMA,
MetadataObject.Type.TABLE);
/** Default constructor for Jackson deserialization only. */
- public IcebergSnapshotExpirationContent() {}
+ public IcebergSnapshotExpirationContent() {
+ this(PolicyExecutionSpec.empty());
+ }
+
+ IcebergSnapshotExpirationContent(@Nullable PolicyExecutionSpec
executionSpec) {
+ super(executionSpec);
+ }
@Override
public Set<MetadataObject.Type> supportedObjectTypes() {
@@ -75,16 +82,20 @@ public class IcebergSnapshotExpirationContent implements
PolicyContent {
@Override
public boolean equals(Object other) {
- return other instanceof IcebergSnapshotExpirationContent;
+ return other instanceof IcebergSnapshotExpirationContent
+ && executionFieldsEqual((IcebergSnapshotExpirationContent) other);
}
@Override
public int hashCode() {
- return IcebergSnapshotExpirationContent.class.hashCode();
+ return executionFieldsHashCode();
}
@Override
public String toString() {
- return "IcebergSnapshotExpirationContent{}";
+ StringBuilder sb = new StringBuilder("IcebergSnapshotExpirationContent{");
+ appendExecutionFields(sb);
+ sb.append('}');
+ return sb.toString();
}
}
diff --git a/api/src/main/java/org/apache/gravitino/policy/PolicyContent.java
b/api/src/main/java/org/apache/gravitino/policy/PolicyContent.java
index 03f6168679..dfe48d9e50 100644
--- a/api/src/main/java/org/apache/gravitino/policy/PolicyContent.java
+++ b/api/src/main/java/org/apache/gravitino/policy/PolicyContent.java
@@ -24,7 +24,13 @@ import java.util.Set;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.gravitino.MetadataObject;
-/** The interface of the content of the policy. */
+/**
+ * The interface of the content of the policy.
+ *
+ * <p>Execution peers ({@code timeTrigger}, {@code commitTrigger}, {@code
minIntervalMs}, {@code
+ * jobOptions}, {@code updaterOptions}) are not part of this universal
interface. Built-in Iceberg
+ * maintenance contents carry them via {@link ExecutablePolicyContent}/{@link
PolicyExecutionSpec}.
+ */
public interface PolicyContent {
/**
@@ -50,6 +56,9 @@ public interface PolicyContent {
/**
* Validates the policy content.
*
+ * <p>Checks universal fields such as {@link #supportedObjectTypes()}.
Maintenance execution-field
+ * validation lives on {@link ExecutablePolicyContent} implementations.
+ *
* @throws IllegalArgumentException if the content is invalid.
*/
default void validate() throws IllegalArgumentException {
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 b7e92193f6..4eba5b102a 100644
--- a/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
+++ b/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
@@ -134,6 +134,55 @@ public class PolicyContents {
String rewriteStrategy,
String sortOrder,
Map<String, String> rewriteOptions) {
+ return icebergDataCompaction(
+ minDataFileMse,
+ minDeleteFileNumber,
+ dataFileMseWeight,
+ deleteFileNumberWeight,
+ maxPartitionNum,
+ rewriteStrategy,
+ sortOrder,
+ rewriteOptions,
+ null,
+ null,
+ null,
+ null,
+ null);
+ }
+
+ /**
+ * Creates an iceberg compaction policy content with rewrite strategy, sort
order, and execution
+ * peers.
+ *
+ * @param minDataFileMse minimum threshold for custom-data-file-mse
+ * @param minDeleteFileNumber minimum threshold for custom-delete-file-number
+ * @param dataFileMseWeight weight used for custom-data-file-mse score
contribution
+ * @param deleteFileNumberWeight weight used for custom-delete-file-number
score contribution
+ * @param maxPartitionNum maximum partition number selected for compaction
+ * @param rewriteStrategy Iceberg rewrite_data_files top-level strategy
+ * @param sortOrder Iceberg rewrite_data_files top-level sort_order
+ * @param rewriteOptions rewrite options forwarded as job.options.*
+ * @param timeTrigger optional cron expression
+ * @param commitTrigger optional commit-trigger flag
+ * @param minIntervalMs optional cooldown ms
+ * @param jobOptions optional job options
+ * @param updaterOptions optional updater options
+ * @return iceberg compaction policy content
+ */
+ public static PolicyContent icebergDataCompaction(
+ long minDataFileMse,
+ long minDeleteFileNumber,
+ long dataFileMseWeight,
+ long deleteFileNumberWeight,
+ long maxPartitionNum,
+ String rewriteStrategy,
+ String sortOrder,
+ Map<String, String> rewriteOptions,
+ @Nullable String timeTrigger,
+ @Nullable Boolean commitTrigger,
+ @Nullable Long minIntervalMs,
+ @Nullable Map<String, String> jobOptions,
+ @Nullable Map<String, String> updaterOptions) {
return new IcebergDataCompactionContent(
minDataFileMse,
minDeleteFileNumber,
@@ -142,7 +191,9 @@ public class PolicyContents {
maxPartitionNum,
rewriteStrategy,
sortOrder,
- rewriteOptions);
+ rewriteOptions,
+ PolicyExecutionSpec.of(
+ timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions));
}
/**
@@ -165,7 +216,37 @@ public class PolicyContents {
*/
public static PolicyContent icebergOrphanFileRemoval(
long olderThanDays, @Nullable String location, boolean dryRun) {
- return new IcebergOrphanFileRemovalContent(olderThanDays, location,
dryRun);
+ return icebergOrphanFileRemoval(olderThanDays, location, dryRun, null,
null, null, null, null);
+ }
+
+ /**
+ * Creates orphan cleanup policy content with execution peers.
+ *
+ * @param olderThanDays minimum file age in days, from 1 to 36500 inclusive
+ * @param location optional scan location within the table
+ * @param dryRun whether to list candidates without deleting them
+ * @param timeTrigger optional cron expression
+ * @param commitTrigger optional commit-trigger flag (must not be {@code
true})
+ * @param minIntervalMs optional cooldown ms
+ * @param jobOptions optional job options
+ * @param updaterOptions optional updater options
+ * @return orphan cleanup policy content
+ */
+ public static PolicyContent icebergOrphanFileRemoval(
+ long olderThanDays,
+ @Nullable String location,
+ boolean dryRun,
+ @Nullable String timeTrigger,
+ @Nullable Boolean commitTrigger,
+ @Nullable Long minIntervalMs,
+ @Nullable Map<String, String> jobOptions,
+ @Nullable Map<String, String> updaterOptions) {
+ return new IcebergOrphanFileRemovalContent(
+ olderThanDays,
+ location,
+ dryRun,
+ PolicyExecutionSpec.of(
+ timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions));
}
/**
@@ -174,7 +255,28 @@ public class PolicyContents {
* @return rewrite-manifests policy content
*/
public static PolicyContent icebergRewriteManifests() {
- return new IcebergRewriteManifestsContent();
+ return icebergRewriteManifests(null, null, null, null, null);
+ }
+
+ /**
+ * Creates placeholder Iceberg rewrite-manifests policy content with
execution peers.
+ *
+ * @param timeTrigger optional cron expression
+ * @param commitTrigger optional commit-trigger flag
+ * @param minIntervalMs optional cooldown ms
+ * @param jobOptions optional job options
+ * @param updaterOptions optional updater options
+ * @return rewrite-manifests policy content
+ */
+ public static PolicyContent icebergRewriteManifests(
+ @Nullable String timeTrigger,
+ @Nullable Boolean commitTrigger,
+ @Nullable Long minIntervalMs,
+ @Nullable Map<String, String> jobOptions,
+ @Nullable Map<String, String> updaterOptions) {
+ return new IcebergRewriteManifestsContent(
+ PolicyExecutionSpec.of(
+ timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions));
}
/**
@@ -183,7 +285,28 @@ public class PolicyContents {
* @return snapshot-expiration policy content
*/
public static PolicyContent icebergSnapshotExpiration() {
- return new IcebergSnapshotExpirationContent();
+ return icebergSnapshotExpiration(null, null, null, null, null);
+ }
+
+ /**
+ * Creates placeholder Iceberg snapshot-expiration policy content with
execution peers.
+ *
+ * @param timeTrigger optional cron expression
+ * @param commitTrigger optional commit-trigger flag
+ * @param minIntervalMs optional cooldown ms
+ * @param jobOptions optional job options
+ * @param updaterOptions optional updater options
+ * @return snapshot-expiration policy content
+ */
+ public static PolicyContent icebergSnapshotExpiration(
+ @Nullable String timeTrigger,
+ @Nullable Boolean commitTrigger,
+ @Nullable Long minIntervalMs,
+ @Nullable Map<String, String> jobOptions,
+ @Nullable Map<String, String> updaterOptions) {
+ return new IcebergSnapshotExpirationContent(
+ PolicyExecutionSpec.of(
+ timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions));
}
/**
diff --git
a/api/src/main/java/org/apache/gravitino/policy/PolicyExecutionSpec.java
b/api/src/main/java/org/apache/gravitino/policy/PolicyExecutionSpec.java
new file mode 100644
index 0000000000..24ab7ba572
--- /dev/null
+++ b/api/src/main/java/org/apache/gravitino/policy/PolicyExecutionSpec.java
@@ -0,0 +1,283 @@
+/*
+ * 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 com.google.common.base.Preconditions;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Immutable execution peer fields for executable policy contents: triggers,
cooldown, and option
+ * bags.
+ *
+ * <p>This type is Jackson-independent in-memory composition for {@link
ExecutablePolicyContent}.
+ * Wire and persistence JSON keep the flat peer keys ({@code timeTrigger},
{@code commitTrigger},
+ * {@code minIntervalMs}, {@code jobOptions}, {@code updaterOptions}); {@code
common} maps those
+ * keys to and from this value object via custom Jackson serdes (same idea as
{@code
+ * DistributionSerializer}).
+ *
+ * <p>Invoked from {@link AbstractExecutablePolicyContent}, not from universal
{@link
+ * PolicyContent#validate()}. Type-specific rules (for example rejecting
{@code commitTrigger} for
+ * orphan cleanup) belong on the concrete content or the server create/alter
path.
+ *
+ * <p>{@code timeTrigger} cron syntax is <strong>not</strong> validated here.
The scheduler
+ * registration path (db-scheduler / runtime cron parser) is the source of
truth; validating with a
+ * regex or a second library in {@code api} would drift from that runtime and
pull scheduler
+ * dependencies into the public API module.
+ */
+public final class PolicyExecutionSpec {
+
+ /** Spec with every peer unset. */
+ public static final PolicyExecutionSpec EMPTY =
+ new PolicyExecutionSpec(null, null, null, null, null);
+
+ @Nullable private final String timeTrigger;
+ @Nullable private final Boolean commitTrigger;
+ @Nullable private final Long minIntervalMs;
+ private final Map<String, String> jobOptions;
+ private final Map<String, String> updaterOptions;
+
+ private PolicyExecutionSpec(
+ @Nullable String timeTrigger,
+ @Nullable Boolean commitTrigger,
+ @Nullable Long minIntervalMs,
+ @Nullable Map<String, String> jobOptions,
+ @Nullable Map<String, String> updaterOptions) {
+ this.timeTrigger = StringUtils.isBlank(timeTrigger) ? null : timeTrigger;
+ this.commitTrigger = commitTrigger;
+ this.minIntervalMs = minIntervalMs;
+ this.jobOptions = copyOptions(jobOptions);
+ this.updaterOptions = copyOptions(updaterOptions);
+ }
+
+ /**
+ * Creates an immutable execution spec.
+ *
+ * @param timeTrigger optional cron expression
+ * @param commitTrigger optional commit-trigger flag; any non-null value
(including {@code false})
+ * is retained
+ * @param minIntervalMs optional cooldown ms
+ * @param jobOptions optional job options
+ * @param updaterOptions optional updater options
+ * @return immutable spec, or {@link #EMPTY} when every peer is unset
+ */
+ public static PolicyExecutionSpec of(
+ @Nullable String timeTrigger,
+ @Nullable Boolean commitTrigger,
+ @Nullable Long minIntervalMs,
+ @Nullable Map<String, String> jobOptions,
+ @Nullable Map<String, String> updaterOptions) {
+ if (!hasAny(timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions)) {
+ return EMPTY;
+ }
+ return new PolicyExecutionSpec(
+ timeTrigger, commitTrigger, minIntervalMs, jobOptions, updaterOptions);
+ }
+
+ /**
+ * @return empty spec
+ */
+ public static PolicyExecutionSpec empty() {
+ return EMPTY;
+ }
+
+ /**
+ * @return cron expression, or {@code null} when unset
+ */
+ @Nullable
+ public String timeTrigger() {
+ return timeTrigger;
+ }
+
+ /**
+ * @return commit-trigger flag when explicitly supplied (including {@code
false}), or {@code null}
+ * when unset
+ */
+ @Nullable
+ public Boolean commitTrigger() {
+ return commitTrigger;
+ }
+
+ /**
+ * @return minimum interval in milliseconds, or {@code null} when unset
+ */
+ @Nullable
+ public Long minIntervalMs() {
+ return minIntervalMs;
+ }
+
+ /**
+ * @return job options; never {@code null}
+ */
+ public Map<String, String> jobOptions() {
+ return jobOptions;
+ }
+
+ /**
+ * @return updater options; never {@code null}
+ */
+ public Map<String, String> updaterOptions() {
+ return updaterOptions;
+ }
+
+ /**
+ * @return {@code true} when any peer is set
+ */
+ public boolean hasAny() {
+ return hasAny(timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions);
+ }
+
+ /**
+ * @param timeTrigger replacement cron expression
+ * @return new spec with updated {@code timeTrigger}
+ */
+ public PolicyExecutionSpec withTimeTrigger(@Nullable String timeTrigger) {
+ return of(timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions);
+ }
+
+ /**
+ * @param commitTrigger replacement commit-trigger flag
+ * @return new spec with updated {@code commitTrigger}
+ */
+ public PolicyExecutionSpec withCommitTrigger(@Nullable Boolean
commitTrigger) {
+ return of(timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions);
+ }
+
+ /**
+ * @param minIntervalMs replacement cooldown
+ * @return new spec with updated {@code minIntervalMs}
+ */
+ public PolicyExecutionSpec withMinIntervalMs(@Nullable Long minIntervalMs) {
+ return of(timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions);
+ }
+
+ /**
+ * @param jobOptions replacement job options
+ * @return new spec with updated {@code jobOptions}
+ */
+ public PolicyExecutionSpec withJobOptions(@Nullable Map<String, String>
jobOptions) {
+ return of(timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions);
+ }
+
+ /**
+ * @param updaterOptions replacement updater options
+ * @return new spec with updated {@code updaterOptions}
+ */
+ public PolicyExecutionSpec withUpdaterOptions(@Nullable Map<String, String>
updaterOptions) {
+ return of(timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions);
+ }
+
+ /**
+ * Validates shared peer constraints (cooldown and option maps).
+ *
+ * <p>Does not parse {@code timeTrigger}; cron validity is checked when the
expression is
+ * registered with the scheduler.
+ */
+ public void validate() {
+ if (!hasAny()) {
+ return;
+ }
+ if (minIntervalMs != null) {
+ Preconditions.checkArgument(minIntervalMs >= 0, "minIntervalMs must be
>= 0");
+ }
+ validateOptionMap(jobOptions, "jobOptions");
+ validateOptionMap(updaterOptions, "updaterOptions");
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof PolicyExecutionSpec)) {
+ return false;
+ }
+ PolicyExecutionSpec that = (PolicyExecutionSpec) other;
+ return Objects.equals(timeTrigger, that.timeTrigger)
+ && Objects.equals(commitTrigger, that.commitTrigger)
+ && Objects.equals(minIntervalMs, that.minIntervalMs)
+ && Objects.equals(jobOptions, that.jobOptions)
+ && Objects.equals(updaterOptions, that.updaterOptions);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(timeTrigger, commitTrigger, minIntervalMs, jobOptions,
updaterOptions);
+ }
+
+ @Override
+ public String toString() {
+ // Never render option map values; credentials may appear before
sensitive-key rejection.
+ return "PolicyExecutionSpec{"
+ + "timeTrigger='"
+ + timeTrigger
+ + '\''
+ + ", commitTrigger="
+ + commitTrigger
+ + ", minIntervalMs="
+ + minIntervalMs
+ + ", jobOptionsKeys="
+ + jobOptions.keySet()
+ + ", updaterOptionsKeys="
+ + updaterOptions.keySet()
+ + '}';
+ }
+
+ /**
+ * Whether any execution peer field is set.
+ *
+ * <p>Every non-null {@code commitTrigger} (including {@code false}) counts
as explicitly
+ * supplied, so JSON round-trips and {@code equals}/{@code hashCode} do not
depend on other peers.
+ */
+ private static boolean hasAny(
+ @Nullable String timeTrigger,
+ @Nullable Boolean commitTrigger,
+ @Nullable Long minIntervalMs,
+ @Nullable Map<String, String> jobOptions,
+ @Nullable Map<String, String> updaterOptions) {
+ return StringUtils.isNotBlank(timeTrigger)
+ || commitTrigger != null
+ || minIntervalMs != null
+ || (jobOptions != null && !jobOptions.isEmpty())
+ || (updaterOptions != null && !updaterOptions.isEmpty());
+ }
+
+ private static Map<String, String> copyOptions(@Nullable Map<String, String>
options) {
+ if (options == null || options.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ return Collections.unmodifiableMap(new LinkedHashMap<>(options));
+ }
+
+ private static void validateOptionMap(Map<String, String> options, String
mapName) {
+ if (options == null || options.isEmpty()) {
+ return;
+ }
+ options.forEach(
+ (key, value) -> {
+ Preconditions.checkArgument(StringUtils.isNotBlank(key), "%s key is
blank", mapName);
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(value), "%s '%s' must have non-empty
value", mapName, key);
+ });
+ }
+}
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 4bac0a9d0f..5d12087b5d 100644
--- a/api/src/test/java/org/apache/gravitino/policy/TestPolicyContents.java
+++ b/api/src/test/java/org/apache/gravitino/policy/TestPolicyContents.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.policy;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.util.HashMap;
import java.util.Map;
@@ -172,6 +173,70 @@ public class TestPolicyContents {
Assertions.assertTrue(exception.getMessage().contains("maxPartitionNum"));
}
+ @Test
+ void testValidateRejectsNegativeMinIntervalMsOnExecutableContent() {
+ PolicyContent content =
+ PolicyContents.icebergDataCompaction(
+ IcebergDataCompactionContent.DEFAULT_MIN_DATA_FILE_MSE,
+ IcebergDataCompactionContent.DEFAULT_MIN_DELETE_FILE_NUMBER,
+ IcebergDataCompactionContent.DEFAULT_DATA_FILE_MSE_WEIGHT,
+ IcebergDataCompactionContent.DEFAULT_DELETE_FILE_NUMBER_WEIGHT,
+ IcebergDataCompactionContent.DEFAULT_MAX_PARTITION_NUM,
+ IcebergDataCompactionContent.DEFAULT_REWRITE_STRATEGY,
+ IcebergDataCompactionContent.DEFAULT_SORT_ORDER,
+ null,
+ null,
+ null,
+ -1L,
+ null,
+ null);
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(IllegalArgumentException.class,
content::validate);
+ Assertions.assertTrue(exception.getMessage().contains("minIntervalMs"));
+ }
+
+ @Test
+ void testExecutionPeersOnlyOnExecutablePolicyContent() {
+ Assertions.assertInstanceOf(
+ ExecutablePolicyContent.class, PolicyContents.icebergDataCompaction());
+ Assertions.assertInstanceOf(
+ ExecutablePolicyContent.class,
PolicyContents.icebergOrphanFileRemoval());
+ Assertions.assertFalse(
+ PolicyContents.custom(
+ ImmutableMap.of("rule", "v"),
ImmutableSet.of(MetadataObject.Type.TABLE), null)
+ instanceof ExecutablePolicyContent);
+ }
+
+ @Test
+ void testValidateAcceptsBuiltInContentsWithoutExecutionPeers() {
+ Assertions.assertDoesNotThrow(() ->
PolicyContents.icebergDataCompaction().validate());
+ Assertions.assertDoesNotThrow(() ->
PolicyContents.icebergOrphanFileRemoval().validate());
+ Assertions.assertDoesNotThrow(
+ () ->
+ PolicyContents.custom(
+ ImmutableMap.of("rule", "v"),
ImmutableSet.of(MetadataObject.Type.TABLE), null)
+ .validate());
+ }
+
+ @Test
+ void testOrphanFileRemovalRejectsCommitTrigger() {
+ PolicyContent content =
+ PolicyContents.icebergOrphanFileRemoval(
+ IcebergOrphanFileRemovalContent.DEFAULT_OLDER_THAN_DAYS,
+ null,
+ IcebergOrphanFileRemovalContent.DEFAULT_DRY_RUN,
+ null,
+ true,
+ null,
+ null,
+ null);
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(IllegalArgumentException.class,
content::validate);
+ Assertions.assertTrue(exception.getMessage().contains("commitTrigger"));
+ }
+
private static Map<String, String> mapOf(String key, String value) {
Map<String, String> map = new HashMap<>();
map.put(key, value);
diff --git
a/api/src/test/java/org/apache/gravitino/policy/TestPolicyExecutionSpec.java
b/api/src/test/java/org/apache/gravitino/policy/TestPolicyExecutionSpec.java
new file mode 100644
index 0000000000..cf36a06d66
--- /dev/null
+++ b/api/src/test/java/org/apache/gravitino/policy/TestPolicyExecutionSpec.java
@@ -0,0 +1,103 @@
+/*
+ * 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 com.google.common.collect.ImmutableMap;
+import java.util.Collections;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestPolicyExecutionSpec {
+
+ @Test
+ void emptyPeersReuseSingleton() {
+ Assertions.assertSame(
+ PolicyExecutionSpec.EMPTY, PolicyExecutionSpec.of(null, null, null,
null, null));
+ Assertions.assertSame(PolicyExecutionSpec.EMPTY,
PolicyExecutionSpec.empty());
+ }
+
+ @Test
+ void commitTriggerFalseIsRetainedWhenAlone() {
+ PolicyExecutionSpec spec = PolicyExecutionSpec.of(null, false, null, null,
null);
+ Assertions.assertNotSame(PolicyExecutionSpec.EMPTY, spec);
+ Assertions.assertEquals(Boolean.FALSE, spec.commitTrigger());
+ Assertions.assertTrue(spec.hasAny());
+ }
+
+ @Test
+ void toStringOmitsOptionValues() {
+ PolicyExecutionSpec spec =
+ PolicyExecutionSpec.of(
+ "0 0 2 * * ?",
+ true,
+ 1000L,
+ ImmutableMap.of("password", "secret"),
+ ImmutableMap.of("oauth_credential", "id:secret"));
+
+ String text = spec.toString();
+ Assertions.assertTrue(text.contains("jobOptionsKeys=[password]"));
+
Assertions.assertTrue(text.contains("updaterOptionsKeys=[oauth_credential]"));
+ Assertions.assertFalse(text.contains("secret"));
+ Assertions.assertFalse(text.contains("id:secret"));
+ }
+
+ @Test
+ void validateAcceptsScalarTriggers() {
+ PolicyExecutionSpec spec =
+ PolicyExecutionSpec.of(
+ "0 0 2 * * ?",
+ true,
+ 100L,
+ ImmutableMap.of("uri", "http://irc"),
+ ImmutableMap.of("metalake", "ml"));
+ Assertions.assertDoesNotThrow(spec::validate);
+ }
+
+ @Test
+ void validateAcceptsCommitTriggerTrueAlone() {
+ PolicyExecutionSpec spec =
+ PolicyExecutionSpec.of(null, true, null, Collections.emptyMap(),
Collections.emptyMap());
+ Assertions.assertDoesNotThrow(spec::validate);
+ }
+
+ @Test
+ void doesNotParseTimeTriggerCron() {
+ // Cron syntax is owned by the scheduler registration path, not this API
type.
+ PolicyExecutionSpec spec =
+ PolicyExecutionSpec.of(
+ "0 2 * *", null, null, Collections.emptyMap(),
Collections.emptyMap());
+ Assertions.assertDoesNotThrow(spec::validate);
+ }
+
+ @Test
+ void blankTimeTriggerTreatedAsUnset() {
+ Assertions.assertSame(
+ PolicyExecutionSpec.EMPTY,
+ PolicyExecutionSpec.of(" ", null, null, Collections.emptyMap(),
Collections.emptyMap()));
+ Assertions.assertDoesNotThrow(PolicyExecutionSpec.EMPTY::validate);
+ }
+
+ @Test
+ void validateRejectsNegativeMinInterval() {
+ PolicyExecutionSpec spec = PolicyExecutionSpec.of(null, null, -1L, null,
null);
+ IllegalArgumentException ex =
+ Assertions.assertThrows(IllegalArgumentException.class,
spec::validate);
+ Assertions.assertTrue(ex.getMessage().contains("minIntervalMs"));
+ }
+}
diff --git
a/common/src/main/java/org/apache/gravitino/json/ExecutablePolicyContentSerde.java
b/common/src/main/java/org/apache/gravitino/json/ExecutablePolicyContentSerde.java
new file mode 100644
index 0000000000..a033614e57
--- /dev/null
+++
b/common/src/main/java/org/apache/gravitino/json/ExecutablePolicyContentSerde.java
@@ -0,0 +1,370 @@
+/*
+ * 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.json;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import javax.annotation.Nullable;
+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.PolicyContents;
+import org.apache.gravitino.policy.PolicyExecutionSpec;
+
+/**
+ * Jackson serdes for executable policy contents and {@link
PolicyExecutionSpec}.
+ *
+ * <p>Follows the same idea as {@link JsonUtils.DistributionSerializer}: keep
an immutable composed
+ * value in memory, and map it to or from the flat wire/persistence keys
({@code timeTrigger},
+ * {@code commitTrigger}, {@code minIntervalMs}, {@code jobOptions}, {@code
updaterOptions}) without
+ * nesting an {@code executionSpec} object.
+ */
+public final class ExecutablePolicyContentSerde {
+
+ private ExecutablePolicyContentSerde() {}
+
+ /**
+ * Registers serdes used when persisting domain {@link
org.apache.gravitino.policy.PolicyContent}
+ * via {@link JsonUtils#anyFieldMapper()}.
+ *
+ * @param module module to register on
+ */
+ public static void registerDomainSerdes(SimpleModule module) {
+ module.addSerializer(
+ IcebergDataCompactionContent.class, new
IcebergDataCompactionContentSerializer());
+ module.addDeserializer(
+ IcebergDataCompactionContent.class, new
IcebergDataCompactionContentDeserializer());
+ module.addSerializer(
+ IcebergOrphanFileRemovalContent.class, new
IcebergOrphanFileRemovalContentSerializer());
+ module.addDeserializer(
+ IcebergOrphanFileRemovalContent.class, new
IcebergOrphanFileRemovalContentDeserializer());
+ module.addSerializer(
+ IcebergRewriteManifestsContent.class, new
IcebergRewriteManifestsContentSerializer());
+ module.addDeserializer(
+ IcebergRewriteManifestsContent.class, new
IcebergRewriteManifestsContentDeserializer());
+ module.addSerializer(
+ IcebergSnapshotExpirationContent.class, new
IcebergSnapshotExpirationContentSerializer());
+ module.addDeserializer(
+ IcebergSnapshotExpirationContent.class, new
IcebergSnapshotExpirationContentDeserializer());
+ }
+
+ /**
+ * Writes execution peer fields as siblings of other content properties (no
object wrapper).
+ *
+ * @param spec execution peers; {@code null} or empty writes nothing
+ * @param gen JSON generator already inside a content object
+ * @throws IOException if writing JSON fails
+ */
+ public static void writeExecutionPeerFields(@Nullable PolicyExecutionSpec
spec, JsonGenerator gen)
+ throws IOException {
+ if (spec == null || !spec.hasAny()) {
+ return;
+ }
+ if (spec.timeTrigger() != null) {
+ gen.writeStringField("timeTrigger", spec.timeTrigger());
+ }
+ if (spec.commitTrigger() != null) {
+ gen.writeBooleanField("commitTrigger", spec.commitTrigger());
+ }
+ if (spec.minIntervalMs() != null) {
+ gen.writeNumberField("minIntervalMs", spec.minIntervalMs());
+ }
+ if (!spec.jobOptions().isEmpty()) {
+ gen.writeObjectField("jobOptions", spec.jobOptions());
+ }
+ if (!spec.updaterOptions().isEmpty()) {
+ gen.writeObjectField("updaterOptions", spec.updaterOptions());
+ }
+ }
+
+ /**
+ * Reads flat execution peer keys from a content JSON object.
+ *
+ * @param node content object node
+ * @return immutable spec (possibly {@link PolicyExecutionSpec#empty()})
+ */
+ public static PolicyExecutionSpec readExecutionSpec(JsonNode node) {
+ Preconditions.checkArgument(
+ node != null && node.isObject(),
+ "Cannot parse execution peers from invalid JSON: %s",
+ node);
+ String timeTrigger = textOrNull(node, "timeTrigger");
+ Boolean commitTrigger = booleanOrNull(node, "commitTrigger");
+ Long minIntervalMs = longOrNull(node, "minIntervalMs");
+ Map<String, String> jobOptions = stringMapOrNull(node, "jobOptions");
+ Map<String, String> updaterOptions = stringMapOrNull(node,
"updaterOptions");
+ return PolicyExecutionSpec.of(
+ timeTrigger, commitTrigger, minIntervalMs, jobOptions, updaterOptions);
+ }
+
+ /**
+ * Serializer for {@link PolicyExecutionSpec} as a JSON object with the flat
peer property names.
+ *
+ * <p>Same role as {@link JsonUtils.DistributionSerializer}: owns the wire
shape of the composed
+ * value. Domain content serdes call {@link #writeExecutionPeerFields} so
peers stay un-nested on
+ * the content object.
+ */
+ public static class PolicyExecutionSpecSerializer extends
JsonSerializer<PolicyExecutionSpec> {
+ @Override
+ public void serialize(
+ PolicyExecutionSpec value, JsonGenerator gen, SerializerProvider
serializers)
+ throws IOException {
+ gen.writeStartObject();
+ writeExecutionPeerFields(value, gen);
+ gen.writeEndObject();
+ }
+ }
+
+ /**
+ * Deserializer for {@link PolicyExecutionSpec} from a JSON object with the
flat peer property
+ * names.
+ */
+ public static class PolicyExecutionSpecDeserializer
+ extends JsonDeserializer<PolicyExecutionSpec> {
+ @Override
+ public PolicyExecutionSpec deserialize(JsonParser p,
DeserializationContext ctxt)
+ throws IOException {
+ JsonNode node = p.getCodec().readTree(p);
+ return readExecutionSpec(node);
+ }
+ }
+
+ /** Domain persistence serde for Iceberg compaction content. */
+ public static class IcebergDataCompactionContentSerializer
+ extends JsonSerializer<IcebergDataCompactionContent> {
+ @Override
+ public void serialize(
+ IcebergDataCompactionContent value, JsonGenerator gen,
SerializerProvider serializers)
+ throws IOException {
+ gen.writeStartObject();
+ gen.writeNumberField("minDataFileMse", value.minDataFileMse());
+ gen.writeNumberField("minDeleteFileNumber", value.minDeleteFileNumber());
+ gen.writeNumberField("dataFileMseWeight", value.dataFileMseWeight());
+ gen.writeNumberField("deleteFileNumberWeight",
value.deleteFileNumberWeight());
+ gen.writeNumberField("maxPartitionNum", value.maxPartitionNum());
+ gen.writeStringField("rewriteStrategy", value.rewriteStrategy());
+ gen.writeStringField("sortOrder", value.sortOrder());
+ if (value.rewriteOptions() != null && !value.rewriteOptions().isEmpty())
{
+ gen.writeObjectField("rewriteOptions", value.rewriteOptions());
+ }
+ writeExecutionPeerFields(value.executionSpec(), gen);
+ gen.writeEndObject();
+ }
+ }
+
+ /** Domain persistence serde for Iceberg compaction content. */
+ public static class IcebergDataCompactionContentDeserializer
+ extends JsonDeserializer<IcebergDataCompactionContent> {
+ @Override
+ public IcebergDataCompactionContent deserialize(JsonParser p,
DeserializationContext ctxt)
+ throws IOException {
+ JsonNode node = p.getCodec().readTree(p);
+ Preconditions.checkArgument(
+ node != null && node.isObject(),
+ "Cannot parse iceberg compaction content from invalid JSON: %s",
+ node);
+ return (IcebergDataCompactionContent)
+ PolicyContents.icebergDataCompaction(
+ longOrNull(node, "minDataFileMse"),
+ longOrNull(node, "minDeleteFileNumber"),
+ longOrNull(node, "dataFileMseWeight"),
+ longOrNull(node, "deleteFileNumberWeight"),
+ longOrNull(node, "maxPartitionNum"),
+ textOrNull(node, "rewriteStrategy"),
+ textOrNull(node, "sortOrder"),
+ stringMapOrNull(node, "rewriteOptions"),
+ textOrNull(node, "timeTrigger"),
+ booleanOrNull(node, "commitTrigger"),
+ longOrNull(node, "minIntervalMs"),
+ stringMapOrNull(node, "jobOptions"),
+ stringMapOrNull(node, "updaterOptions"));
+ }
+ }
+
+ /** Domain persistence serde for orphan-file removal content. */
+ public static class IcebergOrphanFileRemovalContentSerializer
+ extends JsonSerializer<IcebergOrphanFileRemovalContent> {
+ @Override
+ public void serialize(
+ IcebergOrphanFileRemovalContent value, JsonGenerator gen,
SerializerProvider serializers)
+ throws IOException {
+ gen.writeStartObject();
+ gen.writeNumberField("olderThanDays", value.olderThanDays());
+ if (value.location() != null) {
+ gen.writeStringField("location", value.location());
+ }
+ gen.writeBooleanField("dryRun", value.dryRun());
+ writeExecutionPeerFields(value.executionSpec(), gen);
+ gen.writeEndObject();
+ }
+ }
+
+ /** Domain persistence serde for orphan-file removal content. */
+ public static class IcebergOrphanFileRemovalContentDeserializer
+ extends JsonDeserializer<IcebergOrphanFileRemovalContent> {
+ @Override
+ public IcebergOrphanFileRemovalContent deserialize(JsonParser p,
DeserializationContext ctxt)
+ throws IOException {
+ JsonNode node = p.getCodec().readTree(p);
+ Preconditions.checkArgument(
+ node != null && node.isObject(),
+ "Cannot parse orphan file removal content from invalid JSON: %s",
+ node);
+ Long olderThanDaysNode = longOrNull(node, "olderThanDays");
+ long olderThanDays =
+ olderThanDaysNode == null
+ ? IcebergOrphanFileRemovalContent.DEFAULT_OLDER_THAN_DAYS
+ : olderThanDaysNode;
+ Boolean dryRunNode = booleanOrNull(node, "dryRun");
+ boolean dryRun =
+ dryRunNode == null ? IcebergOrphanFileRemovalContent.DEFAULT_DRY_RUN
: dryRunNode;
+ return (IcebergOrphanFileRemovalContent)
+ PolicyContents.icebergOrphanFileRemoval(
+ olderThanDays,
+ textOrNull(node, "location"),
+ dryRun,
+ textOrNull(node, "timeTrigger"),
+ booleanOrNull(node, "commitTrigger"),
+ longOrNull(node, "minIntervalMs"),
+ stringMapOrNull(node, "jobOptions"),
+ stringMapOrNull(node, "updaterOptions"));
+ }
+ }
+
+ /** Domain persistence serde for rewrite-manifests content. */
+ public static class IcebergRewriteManifestsContentSerializer
+ extends JsonSerializer<IcebergRewriteManifestsContent> {
+ @Override
+ public void serialize(
+ IcebergRewriteManifestsContent value, JsonGenerator gen,
SerializerProvider serializers)
+ throws IOException {
+ gen.writeStartObject();
+ writeExecutionPeerFields(value.executionSpec(), gen);
+ gen.writeEndObject();
+ }
+ }
+
+ /** Domain persistence serde for rewrite-manifests content. */
+ public static class IcebergRewriteManifestsContentDeserializer
+ extends JsonDeserializer<IcebergRewriteManifestsContent> {
+ @Override
+ public IcebergRewriteManifestsContent deserialize(JsonParser p,
DeserializationContext ctxt)
+ throws IOException {
+ JsonNode node = p.getCodec().readTree(p);
+ Preconditions.checkArgument(
+ node != null && node.isObject(),
+ "Cannot parse rewrite-manifests content from invalid JSON: %s",
+ node);
+ return (IcebergRewriteManifestsContent)
+ PolicyContents.icebergRewriteManifests(
+ textOrNull(node, "timeTrigger"),
+ booleanOrNull(node, "commitTrigger"),
+ longOrNull(node, "minIntervalMs"),
+ stringMapOrNull(node, "jobOptions"),
+ stringMapOrNull(node, "updaterOptions"));
+ }
+ }
+
+ /** Domain persistence serde for snapshot-expiration content. */
+ public static class IcebergSnapshotExpirationContentSerializer
+ extends JsonSerializer<IcebergSnapshotExpirationContent> {
+ @Override
+ public void serialize(
+ IcebergSnapshotExpirationContent value, JsonGenerator gen,
SerializerProvider serializers)
+ throws IOException {
+ gen.writeStartObject();
+ writeExecutionPeerFields(value.executionSpec(), gen);
+ gen.writeEndObject();
+ }
+ }
+
+ /** Domain persistence serde for snapshot-expiration content. */
+ public static class IcebergSnapshotExpirationContentDeserializer
+ extends JsonDeserializer<IcebergSnapshotExpirationContent> {
+ @Override
+ public IcebergSnapshotExpirationContent deserialize(JsonParser p,
DeserializationContext ctxt)
+ throws IOException {
+ JsonNode node = p.getCodec().readTree(p);
+ Preconditions.checkArgument(
+ node != null && node.isObject(),
+ "Cannot parse snapshot-expiration content from invalid JSON: %s",
+ node);
+ return (IcebergSnapshotExpirationContent)
+ PolicyContents.icebergSnapshotExpiration(
+ textOrNull(node, "timeTrigger"),
+ booleanOrNull(node, "commitTrigger"),
+ longOrNull(node, "minIntervalMs"),
+ stringMapOrNull(node, "jobOptions"),
+ stringMapOrNull(node, "updaterOptions"));
+ }
+ }
+
+ @Nullable
+ private static String textOrNull(JsonNode node, String property) {
+ if (!node.has(property) || node.get(property).isNull()) {
+ return null;
+ }
+ return node.get(property).asText();
+ }
+
+ @Nullable
+ private static Boolean booleanOrNull(JsonNode node, String property) {
+ if (!node.has(property) || node.get(property).isNull()) {
+ return null;
+ }
+ return node.get(property).asBoolean();
+ }
+
+ @Nullable
+ private static Long longOrNull(JsonNode node, String property) {
+ if (!node.has(property) || node.get(property).isNull()) {
+ return null;
+ }
+ return node.get(property).asLong();
+ }
+
+ @Nullable
+ private static Map<String, String> stringMapOrNull(JsonNode node, String
property) {
+ if (!node.has(property) || node.get(property).isNull()) {
+ return null;
+ }
+ JsonNode mapNode = node.get(property);
+ Preconditions.checkArgument(
+ mapNode.isObject(), "Cannot parse string map property %s from: %s",
property, mapNode);
+ Map<String, String> map = new LinkedHashMap<>();
+ Iterator<Map.Entry<String, JsonNode>> fields = mapNode.fields();
+ while (fields.hasNext()) {
+ Map.Entry<String, JsonNode> entry = fields.next();
+ map.put(entry.getKey(), entry.getValue().isNull() ? null :
entry.getValue().asText());
+ }
+ return map;
+ }
+}
diff --git a/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
b/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
index e98bc7e387..4757df5e14 100644
--- a/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
+++ b/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
@@ -82,6 +82,7 @@ import
org.apache.gravitino.dto.rel.partitions.IdentityPartitionDTO;
import org.apache.gravitino.dto.rel.partitions.ListPartitionDTO;
import org.apache.gravitino.dto.rel.partitions.PartitionDTO;
import org.apache.gravitino.dto.rel.partitions.RangePartitionDTO;
+import org.apache.gravitino.policy.PolicyExecutionSpec;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.TableChange;
import org.apache.gravitino.rel.expressions.Expression;
@@ -312,16 +313,28 @@ public class JsonUtils {
.setVisibility(PropertyAccessor.FIELD,
JsonAutoDetect.Visibility.ANY)
.registerModule(new JavaTimeModule())
.registerModule(new Jdk8Module())
- .registerModule(
- new SimpleModule()
- .addDeserializer(Type.class, new TypeDeserializer())
- .addSerializer(Type.class, new TypeSerializer())
- .addDeserializer(Partitioning.class, new
PartitioningDeserializer())
- .addSerializer(Partitioning.class, new
PartitioningSerializer())
- .addDeserializer(Expression.class, new
ColumnDefaultValueDeserializer())
- .addSerializer(Expression.class, new
ColumnDefaultValueSerializer())
- .addDeserializer(StatisticValue.class, new
StatisticValueDeserializer())
- .addSerializer(StatisticValue.class, new
StatisticValueSerializer()));
+ .registerModule(anyFieldModule());
+ }
+
+ private static SimpleModule anyFieldModule() {
+ SimpleModule module =
+ new SimpleModule()
+ .addDeserializer(Type.class, new TypeDeserializer())
+ .addSerializer(Type.class, new TypeSerializer())
+ .addDeserializer(Partitioning.class, new
PartitioningDeserializer())
+ .addSerializer(Partitioning.class, new PartitioningSerializer())
+ .addDeserializer(Expression.class, new
ColumnDefaultValueDeserializer())
+ .addSerializer(Expression.class, new
ColumnDefaultValueSerializer())
+ .addDeserializer(StatisticValue.class, new
StatisticValueDeserializer())
+ .addSerializer(StatisticValue.class, new
StatisticValueSerializer())
+ .addSerializer(
+ PolicyExecutionSpec.class,
+ new
ExecutablePolicyContentSerde.PolicyExecutionSpecSerializer())
+ .addDeserializer(
+ PolicyExecutionSpec.class,
+ new
ExecutablePolicyContentSerde.PolicyExecutionSpecDeserializer());
+ ExecutablePolicyContentSerde.registerDomainSerdes(module);
+ return module;
}
/**
@@ -1309,6 +1322,22 @@ public class JsonUtils {
}
}
+ /**
+ * Custom JSON serializer for {@link PolicyExecutionSpec}.
+ *
+ * @see ExecutablePolicyContentSerde.PolicyExecutionSpecSerializer
+ */
+ public static class PolicyExecutionSpecSerializer
+ extends ExecutablePolicyContentSerde.PolicyExecutionSpecSerializer {}
+
+ /**
+ * Custom JSON deserializer for {@link PolicyExecutionSpec}.
+ *
+ * @see ExecutablePolicyContentSerde.PolicyExecutionSpecDeserializer
+ */
+ public static class PolicyExecutionSpecDeserializer
+ extends ExecutablePolicyContentSerde.PolicyExecutionSpecDeserializer {}
+
/** Custom JSON serializer for DistributionDTO objects. */
public static class DistributionSerializer extends
JsonSerializer<DistributionDTO> {
@Override
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..e5fda37d73 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;
@@ -67,9 +68,11 @@ import org.apache.gravitino.meta.TableStatisticEntity;
import org.apache.gravitino.meta.TagEntity;
import org.apache.gravitino.meta.TopicEntity;
import org.apache.gravitino.meta.UserEntity;
+import org.apache.gravitino.policy.IcebergDataCompactionContent;
import org.apache.gravitino.policy.Policy;
import org.apache.gravitino.policy.PolicyContent;
import org.apache.gravitino.policy.PolicyContents;
+import org.apache.gravitino.policy.PolicyExecutionSpec;
import org.apache.gravitino.rel.expressions.Expression;
import org.apache.gravitino.rel.expressions.NamedReference;
import org.apache.gravitino.rel.expressions.distributions.Distributions;
@@ -1032,6 +1035,142 @@ public class TestPOConverters {
assertEquals(expectedPolicy.content(), convertedPolicy.content());
}
+ @Test
+ public void testFromPolicyPOIcebergCompactionWithExecutionPeers() throws
JsonProcessingException {
+ PolicyContent content =
+ PolicyContents.icebergDataCompaction(
+ IcebergDataCompactionContent.DEFAULT_MIN_DATA_FILE_MSE,
+ IcebergDataCompactionContent.DEFAULT_MIN_DELETE_FILE_NUMBER,
+ IcebergDataCompactionContent.DEFAULT_DATA_FILE_MSE_WEIGHT,
+ IcebergDataCompactionContent.DEFAULT_DELETE_FILE_NUMBER_WEIGHT,
+ IcebergDataCompactionContent.DEFAULT_MAX_PARTITION_NUM,
+ IcebergDataCompactionContent.DEFAULT_REWRITE_STRATEGY,
+ IcebergDataCompactionContent.DEFAULT_SORT_ORDER,
+ ImmutableMap.of("target-file-size-bytes", "134217728"),
+ "0 0 2 * * ?",
+ true,
+ 3_600_000L,
+ ImmutableMap.of("spark.executor.memory", "4g"),
+ ImmutableMap.of("metalake", "ml"));
+
+ // Persistence must keep peer keys flat (not nested under executionSpec).
+ String storedJson = JsonUtils.anyFieldMapper().writeValueAsString(content);
+ assertTrue(storedJson.contains("\"timeTrigger\""));
+ assertTrue(storedJson.contains("\"commitTrigger\""));
+ assertTrue(storedJson.contains("\"minIntervalMs\""));
+ assertTrue(storedJson.contains("\"jobOptions\""));
+ assertTrue(storedJson.contains("\"updaterOptions\""));
+ assertFalse(storedJson.contains("\"executionSpec\""));
+
+ PolicyVersionPO policyVersionPO =
+ createPolicyVersionPO(1L, 1L, 1L, "compaction with peers", true,
content);
+ PolicyPO policyPO =
+ createPolicyPO(1L, "iceberg-compaction", "system_iceberg_compaction",
1L, policyVersionPO);
+
+ PolicyEntity convertedPolicy =
+ POConverters.fromPolicyPO(policyPO,
NamespaceUtil.ofPolicy("test_metalake"));
+
+ assertEquals(Policy.BuiltInType.ICEBERG_COMPACTION,
convertedPolicy.policyType());
+ assertEquals(content, convertedPolicy.content());
+
+ IcebergDataCompactionContent loaded = (IcebergDataCompactionContent)
convertedPolicy.content();
+ PolicyExecutionSpec peers = loaded.executionSpec();
+ assertEquals("0 0 2 * * ?", peers.timeTrigger());
+ assertEquals(Boolean.TRUE, peers.commitTrigger());
+ assertEquals(3_600_000L, peers.minIntervalMs());
+ assertEquals("4g", peers.jobOptions().get("spark.executor.memory"));
+ assertEquals("ml", peers.updaterOptions().get("metalake"));
+ assertEquals("134217728",
loaded.rewriteOptions().get("target-file-size-bytes"));
+ }
+
+ @Test
+ public void testFromPolicyPOIcebergOrphanFileRemovalWithExecutionPeers()
+ throws JsonProcessingException {
+ PolicyContent content =
+ PolicyContents.icebergOrphanFileRemoval(
+ 7L,
+ "s3://bucket/table/data",
+ true,
+ "0 0 3 * * ?",
+ null,
+ 1_800_000L,
+ ImmutableMap.of("spark.executor.cores", "2"),
+ ImmutableMap.of("metalake", "ml"));
+ assertFlatExecutionPeersPersisted(content);
+
+ PolicyEntity converted =
+ POConverters.fromPolicyPO(
+ createPolicyPO(
+ 2L,
+ "orphan-cleanup",
+ "system_iceberg_orphan_file_removal",
+ 1L,
+ createPolicyVersionPO(2L, 1L, 1L, "orphan peers", true,
content)),
+ NamespaceUtil.ofPolicy("test_metalake"));
+ assertEquals(Policy.BuiltInType.ICEBERG_ORPHAN_FILE_REMOVAL,
converted.policyType());
+ assertEquals(content, converted.content());
+ }
+
+ @Test
+ public void testFromPolicyPOIcebergRewriteManifestsWithExecutionPeers()
+ throws JsonProcessingException {
+ PolicyContent content =
+ PolicyContents.icebergRewriteManifests(
+ "0 30 1 * * ?",
+ true,
+ 900_000L,
+ ImmutableMap.of("spark.executor.memory", "2g"),
+ ImmutableMap.of("queue", "tms"));
+ assertFlatExecutionPeersPersisted(content);
+
+ PolicyEntity converted =
+ POConverters.fromPolicyPO(
+ createPolicyPO(
+ 3L,
+ "rewrite-manifests",
+ "system_iceberg_rewrite_manifests",
+ 1L,
+ createPolicyVersionPO(3L, 1L, 1L, "rewrite peers", true,
content)),
+ NamespaceUtil.ofPolicy("test_metalake"));
+ assertEquals(Policy.BuiltInType.ICEBERG_REWRITE_MANIFESTS,
converted.policyType());
+ assertEquals(content, converted.content());
+ }
+
+ @Test
+ public void testFromPolicyPOIcebergSnapshotExpirationWithExecutionPeers()
+ throws JsonProcessingException {
+ PolicyContent content =
+ PolicyContents.icebergSnapshotExpiration(
+ "0 0 4 * * ?",
+ false,
+ 2_700_000L,
+ ImmutableMap.of("spark.driver.memory", "1g"),
+ ImmutableMap.of("metalake", "ml"));
+ assertFlatExecutionPeersPersisted(content);
+
+ PolicyEntity converted =
+ POConverters.fromPolicyPO(
+ createPolicyPO(
+ 4L,
+ "snapshot-expiration",
+ "system_iceberg_snapshot_expiration",
+ 1L,
+ createPolicyVersionPO(4L, 1L, 1L, "snapshot peers", true,
content)),
+ NamespaceUtil.ofPolicy("test_metalake"));
+ assertEquals(Policy.BuiltInType.ICEBERG_SNAPSHOT_EXPIRATION,
converted.policyType());
+ assertEquals(content, converted.content());
+ }
+
+ private static void assertFlatExecutionPeersPersisted(PolicyContent content)
+ throws JsonProcessingException {
+ String storedJson = JsonUtils.anyFieldMapper().writeValueAsString(content);
+ assertTrue(storedJson.contains("\"timeTrigger\""));
+ assertTrue(storedJson.contains("\"minIntervalMs\""));
+ assertTrue(storedJson.contains("\"jobOptions\""));
+ assertTrue(storedJson.contains("\"updaterOptions\""));
+ assertFalse(storedJson.contains("\"executionSpec\""));
+ }
+
@Test
public void testFromTagPO() throws JsonProcessingException {
TagPO tagPO = createTagPO(1L, "test", 1L, "this is test");
diff --git a/design-docs/table-maintenance-service-design.md
b/design-docs/table-maintenance-service-design.md
index d4487e8e12..5ec6be113f 100644
--- a/design-docs/table-maintenance-service-design.md
+++ b/design-docs/table-maintenance-service-design.md
@@ -125,6 +125,31 @@ parts:
1. **Policy** — what to maintain, when to trigger, and non-sensitive job
settings.
2. **Scheduling and commit** — how work is enqueued, claimed, and recorded.
+### 5.0 Terminology: policy identity vs scheduler task kind
+
+Do **not** overload “task type”. TMS uses two different identities:
+
+| Concept | Meaning | Identity today |
+| ------- | ------- | -------------- |
+| **Policy / activity type** | Which maintenance the policy describes
(compaction, manifest-rewrite, snapshot-expiry, orphan-cleanup) |
`Policy.BuiltInType` (e.g. `ICEBERG_COMPACTION`) |
+| **Scheduler task kind** | Which db-scheduler row / pool owns the work |
`policy-expand`, `cron-job`, `commit-job` |
+
+Rules:
+
+- Prefer **`Policy.BuiltInType`** as the single policy/activity identity.
Server defaults
+ (for example per-type `minIntervalMs`) and content registries key off it. Do
**not**
+ introduce a parallel enum that maps 1:1 to `BuiltInType` (the former
+ `TableMaintenanceTaskType` pattern).
+- Keep **scheduler task kinds** separate. They are control-plane scheduling
units, not
+ policy types. One policy may produce expand / cron / commit work without
changing its
+ `BuiltInType`.
+- If a future runtime “activity” ever diverges from policy identity (non-1:1),
introduce
+ that concept then with an explicit contract. Until then, do not invent a
second
+ activity layer.
+
+In prose, prefer “maintenance type” / `BuiltInType` for policy activity, and
“scheduler
+task kind” for expand / cron-job / commit-job.
+
TMS has two trigger paths that stay separate:
- **crontab** — expand a policy into one-shot Spark submits (one type per
submit).
@@ -147,31 +172,37 @@ policy first, then scheduling.
TMS reuses the existing metalake Policy APIs and does **not** add
`/api/maintenance/table/policies`. Each policy covers **one** maintenance
type. The same
-policy may enable both `onCommit` and `crontab`.
+policy may enable both commit and time triggers (`commitTrigger` and
`timeTrigger`).
-#### 5.1.1 Evaluate triggers (`onCommit` + `crontab`)
+#### 5.1.1 Evaluate triggers (`commitTrigger` + `timeTrigger`)
-Triggers live in **`policy_version_info.content.schedule`**, next to
`jobOptions`,
-`rewriteOptions`, and `minIntervalMs`. Operators edit that versioned content
through Policy
-APIs. `policy_meta` only holds identity and attachment. The
`scheduled_*_tasks` tables are runtime
+Triggers are **flat** peers on policy content (same JSON object as
`jobOptions` /
+`updaterOptions` / `rewriteOptions` / `minIntervalMs`): `commitTrigger`
(boolean) and
+`timeTrigger` (cron string). Operators edit that versioned content through
Policy APIs.
+`policy_meta` only holds identity and attachment. The `scheduled_*_tasks`
tables are runtime
state for db-scheduler — not where operators set triggers.
TMS has two trigger modes. They never feed each other's queues:
-| Trigger | Path | Which types
|
-| ---------- | ---------------------------------------- |
----------------------------------------------- |
-| `onCommit` | IRC → **commit-job** only (skips expand) | Compaction /
manifest-rewrite / snapshot-expiry |
-| `crontab` | **policy-expand** → cron-job | All four types
|
+| Wire field | Runtime path | Which types
|
+| ---------------- | ----------------------------------------- |
----------------------------------------------- |
+| `commitTrigger` | IRC → **commit-job** only (skips expand) | Compaction /
manifest-rewrite / snapshot-expiry |
+| `timeTrigger` | **policy-expand** → cron-job | All four
types |
-**`onCommit`** runs after a successful Iceberg commit through IRC. That fits
types that
+**`commitTrigger`** runs after a successful Iceberg commit through IRC. That
fits types that
rewrite or expire data and metadata from the commit stream. **`orphan-cleanup`
does not:**
it scans storage for unreferenced files on a schedule. Running it on every
commit would be
-too heavy. Create/alter rejects `schedule.onCommit = true` for orphan-cleanup;
that type is
-**crontab-only**.
+too heavy. Create/alter rejects `commitTrigger = true` for orphan-cleanup;
that type is
+**timeTrigger-only** when automation is enabled.
-**`minIntervalMs`** sits in policy content **beside** `schedule` (not inside
it). It is the
-cooldown for **that policy's type** after `MAX(finished_at)` on
`table_maintenance_job` for
-`(table, policy)`. Server conf and code defaults are lower-priority fallbacks.
+**`minIntervalMs`** is the cooldown for **that policy's type** after
`MAX(finished_at)` on
+`table_maintenance_job` for `(table, policy)`. Server conf and code defaults
are
+lower-priority fallbacks.
+
+**`timeTrigger` cron:** the string is stored as-is on the policy API. Syntax
and time-zone
+rules are owned by the TMS / db-scheduler registration path (not re-validated
with a second
+parser in `api`). Examples below use a typical six-field Quartz-style form;
align examples
+with the scheduler contract when registration validation is wired.
Example content:
@@ -183,28 +214,27 @@ Example content:
"uri": "http://host:9001/iceberg",
"type": "rest"
},
- "schedule": { "onCommit": true, "crontab": "0 2 * * *" },
+ "commitTrigger": true,
+ "timeTrigger": "0 0 2 * * ?",
"minIntervalMs": 3600000
}
```
-| Field | Runtime effect
|
-| ------------------- |
----------------------------------------------------------------- |
-| `schedule.onCommit` | IRC upserts commit-job; commit pool submits one
chained Job |
-| `schedule.crontab` | Sets / refreshes next `policy-expand` due time
|
-| `minIntervalMs` | Cooldown for this policy's type; highest when set
|
-| `jobOptions` | Non-sensitive Spark / IRC client props (`uri`, `type`,
memory, …) |
-
-Automated maintenance should set at least one of `onCommit` or `crontab`
(orphan-cleanup:
-**`crontab` only**).
+| Field | Runtime effect
|
+| ---------------- |
----------------------------------------------------------------- |
+| `commitTrigger` | IRC upserts commit-job; commit pool submits one chained
Job |
+| `timeTrigger` | Sets / refreshes next `policy-expand` due time
|
+| `minIntervalMs` | Cooldown for this policy's type; highest when set
|
+| `jobOptions` | Non-sensitive Spark / IRC client props (`uri`, `type`,
memory, …) |
+| `updaterOptions` | Non-sensitive Gravitino updater props (`gravitino_uri`,
`metalake`, …) |
#### 5.1.2 Sensitive credentials
Policy content is versioned, edited through Policy APIs, and readable with
`VIEW_POLICY`.
**Secrets must not live there.** Put credential-shaped keys in `jobOptions` /
-`rewriteOptions` and create/alter **rejects** them. Non-sensitive Spark / IRC
client props
-(`uri`, `type`, memory, …) stay on the nearest policy; auth is cluster-static
in
-`gravitino.conf` (values may be SecretManager references). Conf keys: §7.4.
+`rewriteOptions` / `updaterOptions` and create/alter **rejects** them.
Non-sensitive Spark /
+IRC client props (`uri`, `type`, memory, …) stay on the nearest policy; auth is
+cluster-static in `gravitino.conf` (values may be SecretManager references).
Conf keys: §7.4.
TMS talks to **two** backends with **two** identities, so there are two
prefixes:
@@ -220,7 +250,7 @@ each — it does not write secrets back into policy content:
| Runtime bag | Non-sensitive (examples)
| Auth overlay |
| ------------------- |
------------------------------------------------------------- |
--------------------------------------- |
-| **`updateOptions`** | `gravitino_uri`, `metalake`, updater impl names, …
| `gravitino.maintenance.gravitinoAuth.*` |
+| **`updaterOptions`** | `gravitino_uri`, `metalake`, updater impl names, …
| `gravitino.maintenance.gravitinoAuth.*` |
| **`jobOptions`** | nearest policy `jobOptions` (`uri`, `type`, Spark
resources…) | `gravitino.maintenance.ircAuth.*` |
### 5.2 Scheduling and commit
@@ -325,7 +355,7 @@ flowchart TD
B --> C[Keep expand row; set next crontab]
```
-Example with crontab `0 2 * * *`: create inserts expand due next 02:00; at
02:00 expand inserts
+Example with `timeTrigger` `0 0 2 * * ?`: create inserts expand due next
02:00; at 02:00 expand inserts
many cron-jobs due now, then schedules itself for the next 02:00. Expand's due
time is when
**enqueue** happens — not when Spark runs.
@@ -500,7 +530,7 @@ Cluster-static — **not** in policy content and **not** a DB
table.
| Prefix | Role
| Merged into `runJob` bag |
| -------------------------------------- |
------------------------------------------------------------ |
----------------------------------------------- |
-| `gravitino.maintenance.gravitinoAuth.` | Expand / submit / RBAC client; also
jobs that call Gravitino | `updateOptions` (+ non-sensitive updater props) |
+| `gravitino.maintenance.gravitinoAuth.` | Expand / submit / RBAC client; also
jobs that call Gravitino | `updaterOptions` (+ non-sensitive updater props) |
| `gravitino.maintenance.ircAuth.` | Iceberg REST client auth for Spark
Jobs → IRC | `jobOptions` (+ policy non-sensitive props) |
Illustrative auth keys (same shape under each prefix; values may be
SecretManager references where