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 e4af5d615c [#11194] docs: Add design doc for iceberg expire-snapshots 
maintenance job (#11727)
e4af5d615c is described below

commit e4af5d615cbc5c55700a9b2bf16dfe6c4328fe30
Author: Akshay Thorat <[email protected]>
AuthorDate: Mon Sep 14 05:00:03 2026 -0700

    [#11194] docs: Add design doc for iceberg expire-snapshots maintenance job 
(#11727)
    
    ### What changes were proposed in this pull request?
    
    Add a design document for the built-in Iceberg expire-snapshots
    maintenance job
    (`design-docs/iceberg-expire-snapshots-maintenance-job.md`).
    
    The design covers the full end-to-end architecture:
    - **Layer 1 (Policy):** New `IcebergSnapshotExpirationContent` policy
    content class and `ICEBERG_SNAPSHOT_EXPIRATION` built-in type
    - **Layer 2 (Strategy):** `SnapshotExpirationStrategyHandler` operating
    at the table level (no partition scoring)
    - **Layer 3 (Adapter):** `GravitinoSnapshotExpirationJobAdapter`
    converting strategy results to job config
    - **Layer 4 (Job):** `IcebergExpireSnapshotsJob` executing via Spark
    (implementation in PR #11206)
    
    Key design decisions:
    - All expiration parameters (`olderThanDays`, `retainLast`,
    `streamResults`) are **independently optional** — users can specify any
    combination
    - Snapshot expiration operates at the table level (no partition
    statistics needed)
    - Trigger based on `custom-snapshot-count` statistic
    
    ### Why are the changes needed?
    
    As requested by reviewers in PR #11206, the design document should be
    submitted as a dedicated PR before the implementation PRs.
    
    Fix: #11194
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. Documentation only.
    
    ### How was this patch tested?
    
    N/A — documentation only.
---
 .../iceberg-expire-snapshots-maintenance-job.md    | 630 +++++++++++++++++++++
 1 file changed, 630 insertions(+)

diff --git a/design-docs/iceberg-expire-snapshots-maintenance-job.md 
b/design-docs/iceberg-expire-snapshots-maintenance-job.md
new file mode 100644
index 0000000000..30587a9052
--- /dev/null
+++ b/design-docs/iceberg-expire-snapshots-maintenance-job.md
@@ -0,0 +1,630 @@
+<!--
+  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.
+-->
+
+# Design: Built-in Iceberg Expire Snapshots Maintenance Job
+
+| Field   | Value                                                              
  |
+| ------- | 
-------------------------------------------------------------------- |
+| Status  | Draft                                                              
  |
+| Authors | @laserninja                                                        
  |
+| Created | 2026-05-26                                                         
  |
+| Issue   | [#11194](https://github.com/apache/gravitino/issues/11194)         
  |
+| Module  | `api`, `maintenance/jobs`, `maintenance/optimizer`                 
  |
+
+---
+
+## 1. Background
+
+Without periodic snapshot expiration, Iceberg table metadata grows
+indefinitely - accumulating snapshot JSON files and manifest lists that
+slow down table operations and waste storage. The existing built-in
+maintenance jobs (`builtin-iceberg-rewrite-data-files` for data compaction
+and `builtin-iceberg-update-stats` for metrics) address data file
+optimization but do not cover metadata cleanup.
+
+PR [#10500](https://github.com/apache/gravitino/pull/10500) added
+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.
+
+---
+
+## 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
+   submission → Spark execution.
+
+---
+
+## 3. Non-Goals
+
+- Orphan file removal (separate Iceberg procedure, separate issue).
+- Automatic policy creation - users must explicitly create and attach
+  policies.
+- Changes to the Optimizer scheduling framework itself.
+
+---
+
+## 4. Existing Architecture Overview
+
+The Gravitino maintenance module follows a layered architecture for
+automated table maintenance. The existing Iceberg compaction flow
+establishes the pattern:
+
+```
+Policy Creation (REST API)
+    ↓
+GravitinoStrategyProvider  (loads policies as strategies)
+    ↓
+CompactionStrategyHandler  (evaluates trigger / score expressions)
+    ↓
+CompactionJobContext → GravitinoCompactionJobAdapter  (converts to job config)
+    ↓
+GravitinoJobSubmitter  (submits job via REST)
+    ↓
+IcebergRewriteDataFilesJob  (Spark execution)
+```
+
+### 4.1 Layer Summary
+
+| Layer        | Compaction 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                
            |
+
+---
+
+## 5. Proposed Design
+
+We add the same four layers for snapshot expiration, following the
+compaction pattern.
+
+### 5.1 Architecture Diagram
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│  REST API: POST /metalakes/{m}/policies                      │
+│  type: "system_iceberg_snapshot_expiration"                   │
+│  content: IcebergSnapshotExpirationContent                   │
+│    { olderThanDays, retainLast, streamResults }              │
+└──────────────────────────┬───────────────────────────────────┘
+                           ↓
+┌──────────────────────────────────────────────────────────────┐
+│  GravitinoStrategyProvider                                   │
+│  Loads policy → GravitinoStrategy                            │
+│    strategyType:    "iceberg-snapshot-expiration"             │
+│    jobTemplateName: "builtin-iceberg-expire-snapshots"       │
+└──────────────────────────┬───────────────────────────────────┘
+                           ↓
+┌──────────────────────────────────────────────────────────────┐
+│  SnapshotExpirationStrategyHandler                           │
+│    implements StrategyHandler                                │
+│    dataRequirements: {TABLE_METADATA}                        │
+│    Reads `snapshot-count` from table metadata properties      │
+│    Evaluates: snapshot count ≥ minSnapshotCount               │
+│    Returns: StrategyEvaluation with score + context          │
+└──────────────────────────┬───────────────────────────────────┘
+                           ↓
+┌──────────────────────────────────────────────────────────────┐
+│  SnapshotExpirationJobContext → JobAdapter                   │
+│    Extracts: older_than, retain_last, stream_results         │
+│    Builds: job config map for template substitution          │
+└──────────────────────────┬───────────────────────────────────┘
+                           ↓
+┌──────────────────────────────────────────────────────────────┐
+│  GravitinoJobSubmitter                                       │
+│    Template: "builtin-iceberg-expire-snapshots"              │
+│    Submits via REST: POST /metalakes/{m}/jobs                │
+└──────────────────────────┬───────────────────────────────────┘
+                           ↓
+┌──────────────────────────────────────────────────────────────┐
+│  IcebergExpireSnapshotsJob (Spark)                           │
+│  CALL catalog.system.expire_snapshots(                       │
+│    table => '…', older_than => TIMESTAMP '…',                │
+│    retain_last => N, stream_results => bool)                 │
+└──────────────────────────────────────────────────────────────┘
+```
+
+---
+
+### 5.2 Layer 1 - Policy Definition (`api/`)
+
+#### 5.2.1 New Policy Type
+
+Add `ICEBERG_SNAPSHOT_EXPIRATION` to `Policy.BuiltInType`:
+
+```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:
+
+```java
+// NEW: api/src/main/java/org/apache/gravitino/policy/
+//      IcebergSnapshotExpirationContent.java
+public class IcebergSnapshotExpirationContent implements PolicyContent {
+
+  // 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
+
+  // Rule keys
+  public static final String MIN_SNAPSHOT_COUNT_KEY = "minSnapshotCount";
+
+  // Defaults
+  public static final boolean DEFAULT_STREAM_RESULTS   = true;
+  public static final long DEFAULT_MIN_SNAPSHOT_COUNT  = 10;
+}
+```
+
+`olderThanDays` and `retainLast` deliberately have **no Gravitino-side
+default**. See [§5.2.3](#523-expiration-parameters-and-defaults).
+
+#### 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
+
+```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",
+  "enabled": true,
+  "content": {
+    "olderThanDays": 7,
+    "retainLast": 3
+  }
+}
+```
+
+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
+  }
+}
+```
+
+---
+
+### 5.3 Layer 2 - Strategy Handler (`maintenance/optimizer/`)
+
+#### 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;
+  }
+
+  @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();
+  }
+
+  @Override
+  public boolean shouldTrigger() {
+    OptionalLong snapshotCount = snapshotCount();
+    return snapshotCount.isPresent()
+        && snapshotCount.getAsLong() >= minSnapshotCount(strategy);
+  }
+
+  @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()));
+  }
+
+  // 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.
+
+#### 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
+}
+```
+
+#### 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;
+  }
+}
+```
+
+#### 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
+    );
+```
+
+---
+
+### 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.
+
+---
+
+## 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.
+
+**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.
+
+**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.
+
+**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:
+
+| Property         | Type   | Source                        | Description      
                |
+| ---------------- | ------ | ----------------------------- | 
-------------------------------- |
+| `snapshot-count` | `long` | `TableMetadata.snapshots()`   | Number of 
snapshots in the table |
+
+This requires:
+
+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`.
+
+Being reserved and read-only, the property is a safe addition: it shows up
+in `loadTable` responses and is rejected on create/alter.
+
+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.
+
+---
+
+## 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) |
+
+---
+
+## 8. Proposed PR Plan
+
+| 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   |
+
+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.
+
+---
+
+## 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?
+
+---
+
+## 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 `true`) |

Reply via email to