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

jerryshao 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 3563d9ee6e [#11220] docs(spec): Add design documents for async hard 
deletion (#11152)
3563d9ee6e is described below

commit 3563d9ee6eba68f25ce270b47798b25d8485e39c
Author: roryqi <[email protected]>
AuthorDate: Thu May 28 16:35:55 2026 +0800

    [#11220] docs(spec): Add design documents for async hard deletion (#11152)
    
    ### What changes were proposed in this pull request?
    
    Adds the design document
    `design-docs/async-iceberg-rest-hard-deletion.md` for **asynchronous
    hard deletion** in the Gravitino Iceberg REST server.
    
    The design makes `DELETE …?purgeRequested=true` return quickly by
    dropping the catalog entry on the request thread and deleting
    data/metadata files in the background via a JDBC-backed job table
    (`iceberg_purge_job`) and a multi-replica worker pool (lease via `FOR
    UPDATE SKIP LOCKED`, CAS fallback elsewhere), with retry/backoff and a
    `DEAD_LETTER` state. The synchronous path is retained behind
    `async-purge.enabled=false` purely as a rollback.
    
    Resolved review/open questions in this revision:
    - **Name reuse during purge (§5.13, closes §8.1 / Jerry's Q1):** the
    active job row acts as a tombstone — `createTable`/`register` at the
    same identifier returns **409** until the job is terminal, closing the
    PRD §2.2 same-name/same-location recreate attack.
    Re-register-as-recovery atomically cancels the matching job
    (`CANCELLED`), removing the worker-deletes-recovered-files race.
    - **Purge visibility (§5.14, Jerry's follow-up):** how to tell a table
    is mid-purge / done / dead-lettered — a wire-compatible informative
    `409` message, a read-only `purge-jobs` management endpoint, metrics,
    and events, with `DEAD_LETTER` discoverable across all three.
    - Marked §8.1 (Q1) and §8.4 (Q4) resolved; updated schema (`CANCELLED`
    state, `idx_object`), failure model, wire-compat notes, and task
    breakdown.
    
    ### Why are the changes needed?
    
    Synchronous purge on the Jetty request thread exceeds HTTP timeouts,
    saturates the thread pool, and leaks files with no retry/audit on large
    tables. This document captures the async design before implementation.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No (design document only). The design it describes would change drop
    semantics (recreate blocked with 409 until cleanup completes) — called
    out in the doc's release-notes section.
    
    ### How was this patch tested?
    
    N/A — documentation only.
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 <[email protected]>
---
 design-docs/async-iceberg-rest-hard-deletion.md | 686 ++++++++++++++++++++++++
 1 file changed, 686 insertions(+)

diff --git a/design-docs/async-iceberg-rest-hard-deletion.md 
b/design-docs/async-iceberg-rest-hard-deletion.md
new file mode 100644
index 0000000000..34315ae98e
--- /dev/null
+++ b/design-docs/async-iceberg-rest-hard-deletion.md
@@ -0,0 +1,686 @@
+<!--
+  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: Asynchronous Hard Deletion for the Gravitino Iceberg REST Server
+
+| Field    | Value                                                   |
+| -------- | ------------------------------------------------------- |
+| Status   | Complete                                                |
+| Authors  | @roryqi                                                 |
+| Created  | 2026-05-19                                              |
+| Module   | `iceberg/iceberg-rest-server`, `iceberg/iceberg-common` |
+
+---
+
+## 1. Background
+
+When a client issues:
+
+```
+DELETE /v1/{prefix}/namespaces/{namespace}/tables/{table}?purgeRequested=true
+```
+
+today's path is fully synchronous: `IcebergTableOperations.dropTable` →
+`IcebergTableOperationExecutor.dropTable` → `IcebergCatalogWrapper.purgeTable`
+→ `CatalogHandlers.purgeTable`, which walks every snapshot / manifest /
+data file and deletes each one through `FileIO` on the Jetty request
+thread.
+
+For production tables this fails in three ways:
+
+- Multi-minute purges exceed HTTP timeouts.
+- Concurrent purges saturate the Jetty pool.
+- Mid-purge failures leak files with no retry or audit trail.
+
+We want an opt-in path where the drop returns quickly, finishes file
+deletion reliably in the background, survives restarts, and runs safely
+across multiple server replicas — while synchronous deletion remains the
+default, selected automatically for any client that does not opt in.
+
+*Not in scope:* `RelationalGarbageCollector`, which deletes tombstoned
+**rows** from Gravitino's relational backend. Different IO surface,
+different failure model — kept separate.
+
+---
+
+## 2. Goals
+
+1. **Fast response**: `DELETE … ?purgeRequested=true` returns at typical
+   request latency (target p99 < 500 ms, < 5 s even for the largest
+   tables) regardless of table size.
+2. **Operational simplicity**: Ship one async deletion path as an opt-in;
+   synchronous deletion stays the default so existing behavior is unchanged
+   unless a client explicitly asks for async.
+3. **Reliable deletion**: The async path deletes every file the
+   synchronous purge would have, retries transient failures, and survives
+   restarts and replica failover.
+4. **Wire compatibility**: No change to the Iceberg REST wire protocol.
+5. **Request-thread authorization**: Authorization runs on the request
+   thread, never deferred.
+
+---
+
+## 3. Non-Goals
+
+1. **Native soft-delete / undrop semantics**: This design delivers async
+   *hard* deletion only — once a drop is accepted the files are destroyed
+   with no undrop or recovery path. Full soft-delete / undrop is a separate
+   follow-up.
+2. **Purge cancellation**: User-initiated cancellation of in-flight purges
+   is out of scope; it needs a control API and lifecycle states we can add
+   later without breaking this design.
+3. **Third-party deletion plugins**: We ship one async implementation. A
+   pluggable extension point is explicitly *not* built now — see §4.
+
+---
+
+## 4. Solution Investigations
+
+An earlier draft proposed a pluggable `IcebergPurger` SPI. Review feedback
+pushed back: no second implementation is in flight, and the goal is a
+simpler design with a smaller bug surface. We chose a single async
+implementation gated behind a per-request opt-in, with synchronous deletion
+as the default.
+
+| Approach                                                    | Pros           
                                                                          | 
Cons                                                                            
                 | Decision                                                     
           |
+|-------------------------------------------------------------|------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
+| Synchronous only (status quo)                               | Simplest; 
strongest "deleted means gone" guarantee                                       
| Exceeds HTTP timeouts, saturates Jetty, no retry/audit on large tables        
                   | **Rejected** — the problem we are solving                  
             |
+| Pluggable `IcebergPurger` SPI                               | Extensible 
without code changes                                                          | 
Real added surface (SPI, discovery factory, context) with **no** second 
implementation in flight | **Rejected** — revisit when a second implementation 
has a real customer |
+| Reuse `RelationalGarbageCollector`                          | Proven 
worker/scheduling pattern                                                       
  | Different IO surface (object store vs. JDBC) and failure model              
                     | **Rejected** — share patterns, not code                  
               |
+| External job system (Quartz / Temporal)                     | Mature 
scheduling, retries, observability                                              
  | Heavy operational burden on every operator                                  
                     | **Rejected** — disproportionate for one workload         
               |
+| Enumerate files at enqueue time                             | Worker needs 
no metadata re-read                                                         | 
Slow on large tables (defeats the latency goal), bloats job rows                
                 | **Rejected** — store `metadata_location`, re-read at run 
time           |
+| Object-store job markers (no DB table)                      | No schema / 
migration                                                                    | 
Hand-rolled lease + renewal, no indexed scheduling, fragments per-bucket        
                 | **Rejected** — higher net complexity (see below)             
           |
+| **JDBC job table + worker pool, async opt-in (synchronous default)** | 
Smallest bug surface; restart-safe and cluster-safe via CAS claim; one code 
path to test | No built-in extension point                                      
                                | **Chosen**                                    
                          |
+
+**Why not an S3-only control plane?** Conditional writes (`If-None-Match`)
+let workers claim jobs without a relational backend, but it *raises* net
+complexity for Gravitino: a DB gives lease acquisition and an indexed
+candidate scan for free, whereas S3 forces hand-rolled leases discovered by
+slow, per-request `LIST`. One server fronts many
+catalogs across S3 / GCS / ADLS, so a single job table is a uniform control
+plane; object markers fragment per-bucket. Gravitino already runs a
+relational metastore, so one extra table is low marginal cost — and it
+doubles as the name-reuse tombstone (§5.7).
+
+---
+
+## 5. Proposal
+
+### 5.1 Overview
+
+```
+  DELETE …?purgeRequested=true
+            │
+            ▼
+  IcebergTableOperationExecutor.dropTable
+            │  X-Gravitino-Async-Purge header (absent ⇒ synchronous)
+      ┌─────┴───────────────────────────┐
+      │ header absent                   │ X-Gravitino-Async-Purge: true
+      ▼                                 ▼
+ CatalogUtil.dropTableData         persist iceberg_cleanup_job
+ (synchronous, on request thread)  row, return 204
+                                        │
+                                        ▼
+                                  worker pool (any replica)
+                                    claims job via CAS UPDATE (heartbeat 
ownership)
+                                    → rebuild snapshot graph from 
metadata_location
+                                    → stream + delete every reachable file, 
retry on later polls
+                                    → SUCCEEDED | FAILED
+```
+
+The header is optional and defaults to synchronous: when it is **absent** —
+which is the case for any standard Iceberg client — the drop is synchronous,
+preserving today's "deleted means gone" behavior. A client opts into async
+deletion by sending `X-Gravitino-Async-Purge: true` (the header name uses
+canonical HTTP casing, each word capitalized).
+
+### 5.2 User flow
+
+1. A client issues
+   `DELETE /v1/{prefix}/namespaces/{ns}/tables/{t}?purgeRequested=true`
+   with `X-Gravitino-Async-Purge: true` to opt into async deletion. Absent
+   the header — the case for any standard Iceberg client — the drop is
+   synchronous (today's behavior). Steps 2–5 describe the async path.
+2. The server runs authorization on the request thread, loads the table
+   metadata location, drops the catalog entry, and persists an
+   `iceberg_cleanup_job` row.
+3. The server responds `204 No Content` within typical request latency. The
+   table is immediately absent from `LIST` and `HEAD` returns `404`.
+4. A worker on any replica claims the job, rebuilds the snapshot graph from
+   `metadata_location`, and streams through every reachable file deleting
+   it, retrying transient failures on later polls.
+5. On success the job is `SUCCEEDED`; on terminal failure it lands in
+   `FAILED` for operator inspection (queryable in the job table, §5.9).
+
+### 5.3 Request-path interaction
+
+```java
+public void dropTable(IcebergRequestContext ctx, TableIdentifier id,
+                      boolean purgeRequested) {
+  IcebergCatalogWrapper w = 
catalogWrapperManager.getCatalogWrapper(ctx.catalogName());
+  if (!purgeRequested) { w.dropTable(id); return; }
+
+  if (!asyncPurgeEnabled) {                 // default when header is absent
+    w.purgeTable(id);                       // synchronous, today's behavior
+    return;
+  }
+
+  TableMetadata metadata = w.loadTableMetadata(id);
+  w.dropTable(id);                          // metadata-only drop in the 
catalog
+  purgeJobStore.enqueue(
+      IcebergPurgeJob.builder()
+          .catalogName(ctx.catalogName())
+          .tableIdentifier(id)
+          .metadataLocation(metadata.metadataFileLocation())
+          .fileIoImpl(w.fileIoImpl())
+          .fileIoProperties(w.fileIoProperties())
+          .createdBy(ctx.userPrincipal())
+          .build());
+}
+```
+
+Order matters on the async path: load metadata location → drop catalog
+entry → enqueue the job. A cleanup job exists only for a table already gone
+from the catalog. `fileIoProperties` is captured at enqueue time so the
+worker can reconstruct `FileIO` even if the catalog is later reconfigured.
+The enqueued row also serves as the **tombstone** that blocks name reuse
+while the files still exist (§5.7).
+
+**Repeated drop is idempotent.** Because the catalog entry is removed before
+the first `DELETE` returns, a second `DELETE …?purgeRequested=true` for the
+same identifier reaches `w.loadTableMetadata(id)` (or `w.dropTable(id)`),
+finds nothing, and returns `404 NoSuchTableException` — the standard Iceberg
+REST response for dropping a missing table. We do **not** enqueue a second
+cleanup job: the first `loadTableMetadata` throws `NoSuchTableException`
+before the enqueue line is reached, so a client retrying a drop (or a
+duplicate request) never produces a duplicate job or a duplicate purge. This
+holds regardless of whether the in-flight job is still `PENDING`/`RUNNING` or
+already terminal, so it is consistent with the §5.7 tombstone table.
+
+### 5.4 Schema — `iceberg_cleanup_job`
+
+```sql
+CREATE TABLE IF NOT EXISTS `iceberg_cleanup_job` (
+  `id`                BIGINT(20)    UNSIGNED NOT NULL AUTO_INCREMENT,
+  `metalake_name`     VARCHAR(128)  NOT NULL,
+  `catalog_name`      VARCHAR(128)  NOT NULL,
+  `namespace`         VARCHAR(512)  NOT NULL,
+  `table_name`        VARCHAR(256)  NOT NULL,
+  `metadata_location` VARCHAR(1024) NOT NULL,
+  `file_io_impl`      VARCHAR(256)  NOT NULL,
+  `file_io_props`     MEDIUMTEXT    NOT NULL COMMENT 'JSON',
+  `state`             VARCHAR(16)   NOT NULL COMMENT 
'PENDING|RUNNING|SUCCEEDED|FAILED',
+  `attempts`          INT(10)       NOT NULL DEFAULT 0,
+  `last_error`        VARCHAR(2048) NULL COMMENT 'truncated reason for the 
most recent failure; NULL until a job fails',
+  `heartbeat_at`      BIGINT(20)    NULL COMMENT 'last heartbeat from the 
worker; NULL when unclaimed',
+  `created_by`        VARCHAR(128)  NOT NULL COMMENT 'principal that requested 
the drop (audit)',
+  `updated_at`        BIGINT(20)    NOT NULL COMMENT 'last state change; 
drives poll ordering and terminal-row pruning',
+  PRIMARY KEY (`id`),
+  KEY `idx_state_updated` (`state`, `updated_at`),
+  KEY `idx_object` (`catalog_name`, `namespace`, `table_name`, `state`)
+) ENGINE=InnoDB;
+```
+
+We store only `metadata_location`, not the file list — enumeration is slow
+on large tables, and `TableMetadataParser.read(io, location)` rebuilds the
+snapshot graph deterministically when the worker runs.
+
+A single column drives retries: `attempts` counts failures so the worker
+gives up at the retry ceiling. A failed job returns to `PENDING` and is
+re-claimed on a later poll, so the poll cadence (`poll-interval-ms`,
+§5.10) is the retry interval — no separate scheduling column is needed. The
+ceiling is a config (`max-attempts`, §5.10), not a per-row `max_attempts`
+column, since it is the same for every job.
+
+`last_error` records the reason for the most recent failure — the metadata
+exception that sent a job back to `PENDING`, or the one that drove it to
+`FAILED` — truncated to fit the column. It is overwritten on each attempt,
+not appended, so it always reflects the latest failure. This puts the
+failure reason in the job row itself, so an operator triaging a `FAILED`
+cleanup (§5.6, §5.9) reads it from a single indexed query without correlating
+worker logs by timestamp. The full stack trace still goes to the logs.
+
+Migration: `upgrade-1.2.0-to-1.3.0-mysql.sql` (and H2 / PostgreSQL).
+
+### 5.5 Worker pool and multi-replica ownership
+
+The worker pool has `worker-threads` threads, modeled on the
+`RelationalGarbageCollector` scheduler. **A thread polls for work only when
+it is free**: a free thread claims a single job, runs it to completion or
+failure, then loops back to poll for the next; a thread busy on a job never
+polls. Capacity-gating is therefore by construction — no central scheduler
+claims jobs ahead of the threads, the pool never holds a claimed-but-unstarted
+job, and a `RUNNING` row always maps to a thread actively deleting files (its
+heartbeat reflects real progress, not a job idling in a queue). A poll that
+finds nothing claimable waits `poll-interval-ms` and retries. `worker-threads`
+thus bounds the concurrent jobs per server; there is no separate claim-batch
+size.
+
+Ownership is tracked by a **heartbeat** alone: the worker that claims a job
+keeps refreshing `heartbeat_at` while it runs and remembers in memory, per
+owned id, the `heartbeat_at` value it last wrote (no owner column — a stale
+row is reclaimable by anyone, which is exactly the failover behavior we want).
+A job is abandoned, and reclaimable, once its heartbeat is older than
+`heartbeatTimeout`.
+
+Claiming is an optimistic compare-and-swap `UPDATE` that works on **every**
+SQL backend. A free thread reads a small candidate window and claims the
+first row it wins:
+
+```sql
+-- a free thread reads a small candidate window
+SELECT id FROM iceberg_cleanup_job
+ WHERE state = 'PENDING'
+    OR (state = 'RUNNING'
+        AND (heartbeat_at IS NULL OR heartbeat_at < :now - :heartbeatTimeout))
+ ORDER BY updated_at LIMIT :window;
+
+-- claim one candidate; the row is ours only if affected_rows = 1
+UPDATE iceberg_cleanup_job
+   SET state='RUNNING', heartbeat_at=:now
+ WHERE id=:id
+   AND ( state='PENDING'
+      OR (state='RUNNING'
+          AND (heartbeat_at IS NULL OR heartbeat_at < :now - 
:heartbeatTimeout)) );
+```
+
+A loser sees `affected_rows = 0` and tries the next id in its window. The
+window is a small constant that only gives a CAS loser alternatives — not a
+capacity control, since a free thread still claims exactly one job. Where
+`SELECT … FOR UPDATE SKIP LOCKED` is available (MySQL 8+, PostgreSQL) the
+worker uses it to cut contention up front; the CAS form is the portable
+fallback (H2, older MySQL). `SKIP LOCKED` is purely an optimization — the
+claiming `UPDATE` is the serialization point, so two replicas can never claim
+the same job. We do not use DB-specific advisory locks.
+
+**Worker loop (one free thread):**
+
+1. **Poll & claim.** Read the candidate window above and CAS the first
+   winnable row to `RUNNING` with a fresh `heartbeat_at`. If nothing is
+   claimable, wait `poll-interval-ms` and repeat.
+2. **Load metadata.** `TableMetadataParser.read(io, metadata_location)`. A
+   transient failure releases the job for retry; a terminal one fails it
+   (§5.6).
+3. **Stream & delete.** Walk the snapshot graph lazily, group reachable files
+   into batches of `delete-batch-size`, and hand each batch to the shared
+   `deleteExecutor` for a **bulk** delete; `NotFoundException` counts as
+   deleted. The worker thread itself never issues the storage call directly,
+   and a *separate* background task keeps `heartbeat_at` fresh throughout
+   (see "Heartbeat is decoupled from deletion" below).
+4. **Finish.** Once every reachable file is gone the job is `SUCCEEDED`
+   (§5.6). The thread then loops back to step 1.
+
+Execution mirrors `CatalogHandlers.purgeTable`, but **streams** the
+reachable files instead of materializing them. A large table can reference
+millions of data files, so the worker walks the snapshot graph lazily and
+deletes them **in bulk batches**, never one file at a time:
+
+```java
+TableMetadata meta = TableMetadataParser.read(io, job.metadataLocation());
+try (CloseableIterable<String> files = reachableFiles(meta)) {  // lazy, not 
materialized
+  Iterators.partition(files.iterator(), deleteBatchSize)        // bounded 
batches
+      .forEachRemaining(batch ->
+          deleteExecutor.execute(() ->
+              // SupportsBulkOperations.deleteFiles when the FileIO supports it
+              // (e.g. S3FileIO's batch-delete API), else concurrent per-file
+              CatalogUtil.deleteFiles(io, batch, "cleanup", /* bulk */ true)));
+}
+```
+
+We delete in bulk rather than per file because a single `DeleteObjects`
+round trip removes up to ~1000 objects, cutting both request count and
+S3 cost by three orders of magnitude on a large table.
+`CatalogUtil.deleteFiles(io, batch, msg, true)` routes to
+`SupportsBulkOperations.deleteFiles` when the `FileIO` implements it
+(S3FileIO, GCSFileIO, ADLSFileIO all do) and falls back to a concurrent
+per-file delete otherwise; partial-batch failures are logged and suppressed,
+matching the synchronous purge's best-effort stance (§5.6).
+
+`deleteExecutor` is a single server-wide pool sized by `delete-threads`
+(§5.10), shared by all concurrent jobs rather than allocated per job. This
+bounds total file-delete concurrency on the server regardless of how many
+jobs run at once; raise it to drain large tables faster when purge
+throughput matters.
+
+**When the delete queue is full.** The `deleteExecutor` is a
+`ThreadPoolExecutor` with `delete-threads` threads and a *bounded* work queue,
+and a worker that walks a million-file table can enqueue batches faster than
+the pool drains them. We do **not** use an unbounded queue (it would let one
+huge table OOM the server) and we do **not** drop work on rejection (that
+would leak files). Instead the pool uses `CallerRunsPolicy`: once both the
+threads and the bounded queue are saturated, `execute(...)` runs the batch
+**inline on the calling worker thread**. That worker stops pulling new batches
+from its lazy iterator until the inline delete returns, which is exactly the
+back-pressure we want — the snapshot walk naturally slows to the rate storage
+can absorb, queue depth stays bounded, and no batch is ever discarded. Because
+the heartbeat lives on a different thread (next paragraph), a worker blocked in
+an inline bulk delete still keeps its lease alive.
+
+**Heartbeat is decoupled from deletion.** Deletion is a long, blocking,
+I/O-bound activity: a worker thread can sit inside a bulk `deleteFiles` call —
+or inside a `CallerRunsPolicy` inline batch — for seconds at a time, and across
+a whole table for many minutes. If that same thread were responsible for
+writing `heartbeat_at`, any slow storage round trip would stall the heartbeat,
+the row would age past `heartbeatTimeout`, and a peer would wrongly reclaim a
+job that is in fact making progress — causing duplicate work and lease
+thrashing. So the heartbeat is **never** issued from the deleting thread. A
+single background task per process (next paragraph) refreshes `heartbeat_at`
+for every owned id on a fixed schedule, independent of where the worker threads
+are blocked. This is why `delete-threads` work can be fully synchronous from
+the worker's point of view without endangering the lease.
+
+**Updating the heartbeat.** One background task per worker process — not one
+per job — runs every `heartbeatTimeout / 3`, so two updates can be missed
+(a GC pause, a brief DB hiccup) before the row looks abandoned. For each id
+the process owns it issues a guarded `UPDATE`:
+
+```sql
+UPDATE iceberg_cleanup_job
+   SET heartbeat_at = :now
+ WHERE id = :id
+   AND state = 'RUNNING'
+   AND heartbeat_at = :lastValueIWrote;   -- CAS on the value I last wrote
+```
+
+The `heartbeat_at` value the worker last wrote is the lease: the `UPDATE`
+is a compare-and-swap on it, so it succeeds (`affected_rows = 1`, and the
+worker records the new `:now` as its next `:lastValueIWrote`) **only while
+this process is still the owner**. If the process stalled past
+`heartbeatTimeout` and a peer reclaimed the job, the peer's claim overwrote
+`heartbeat_at`; this process's next CAS then matches no row
+(`affected_rows = 0`), and it reads that as "I no longer own this job" —
+it stops deleting and drops the id from its in-memory set. That is how
+ownership transfers cleanly with no owner column. The claim itself
+(`SET heartbeat_at=:now` above) seeds the first `:lastValueIWrote`; finishing
+or releasing a job removes the id from the set so the task stops touching it.
+A worker owns at most `worker-threads` ids, so this is a handful of cheap
+point updates per cycle. If the host dies the task stops entirely and the
+rows age out for reclaim.
+
+### 5.6 Failure model, restart, and crash recovery
+
+Per-file failures are logged but do not fail the whole job — the
+synchronous purge has the same "best effort" stance. A job fails only if the
+**metadata phase** fails. `NotFoundException` from `deleteFile` counts as
+success. A transient failure goes back to `PENDING` and is retried when a
+free thread next polls (§5.5), incrementing `attempts` each time; when 
`attempts`
+reaches `max-attempts` the job goes to `FAILED` instead of `PENDING`. A
+clearly terminal failure skips the retries and goes straight to `FAILED`.
+
+| Outcome                                              | Action                
                                                                |
+|------------------------------------------------------|---------------------------------------------------------------------------------------|
+| All files deleted (or already gone)                  | `state='SUCCEEDED'`   
                                                                 |
+| Transient failure, `attempts < max-attempts`         | `attempts++`, set 
`last_error`, back to `PENDING`, `heartbeat_at=NULL`; re-claimed later |
+| Transient failure, `attempts` reaches `max-attempts` | `attempts++`, set 
`last_error` → `FAILED` (gave up retrying)                            |
+| Terminal failure (e.g. metadata gone/corrupt)        | set `last_error` → 
`FAILED` immediately; retrying cannot help                          |
+| Worker killed mid-job                                | `RUNNING` row's 
heartbeat goes stale; another worker reclaims; deletes are idempotent  |
+
+**After `SUCCEEDED`.** The files are gone, so the tombstone lifts at once:
+`createTable` / `register` at the identifier succeed again (§5.7). The
+`purge.completed` counter increments and the row is pruned
+`retention-hours` later (§5.10).
+
+**After `FAILED`.** `FAILED` is terminal — the poll never re-selects it, so
+the worker stops touching the job. The tombstone also lifts (§5.7), so a
+failed cleanup never permanently wedges the name; the cost is that any files
+the job did not delete are now an orphaned leak. The failure is therefore
+made loud rather than silent: it increments `purge.failed`, the row stays
+queryable (§5.9) with the reason in its `last_error` column (and the full
+stack trace in the worker logs). An operator reclaims
+the leaked files out of band — a manual delete or a storage lifecycle rule on
+the table's prefix — and then drops the row, or lets it prune after
+`retention-hours`. There is no automatic re-drive in 1.3;
+re-enqueueing a `FAILED` cleanup is a manual operator action.
+
+Restart handling falls out of the durable job table plus the heartbeat
+model — no separate mechanism:
+
+1. **Nothing in-flight is lost.** The job is committed before the `DELETE`
+   returns; there is no in-memory queue. `PENDING` jobs are picked up as soon
+   as a thread polls after boot.
+2. **Orphaned in-flight jobs are reclaimed.** A crash leaves a `RUNNING` row
+   with a stale heartbeat, which the poll already selects; the restarted node
+   or any peer reclaims it via the CAS.
+3. **Re-running a half-finished job is safe.** The worker rebuilds the file
+   set and re-deletes; already-gone files raise `NotFoundException`, counted
+   as success. A crash costs only duplicated work, never corruption.
+
+In multi-replica deployments this is seamless. The one rough edge is a
+single-replica restart: the crashed node's jobs sit idle until their
+heartbeat ages past `heartbeatTimeout` before the rebooted process reclaims
+them. We keep `heartbeatTimeout` modest so the resume delay is small; with no
+owner column there is nothing extra to sweep on boot.
+
+### 5.7 Name reuse during purge
+
+While a purge job for an identifier is non-terminal (`PENDING` or
+`RUNNING`), that identifier is treated as **still occupied** even though its
+catalog entry is already gone. The active `iceberg_cleanup_job` row *is* the
+tombstone — no second table is needed. The REST server consults the store on
+the request thread (one indexed lookup via `idx_object`):
+
+| Operation                                   | Active job exists              
   | No active job (`SUCCEEDED`/`FAILED`/none) |
+|---------------------------------------------|-----------------------------------|-------------------------------------------|
+| `loadTable` / `HEAD`, `alterTable` / commit | `404 NoSuchTableException`     
   | `404`                                     |
+| `dropTable` (same identifier, repeated)     | `404 NoSuchTableException`     
   | `404`                                     |
+| `createTable` (same identifier)             | **`409 Conflict`** — being 
purged | succeeds                                  |
+| `registerTable` (same identifier)           | **`409 Conflict`**             
   | succeeds                                  |
+
+A `SUCCEEDED` job (even before pruning) no longer blocks reuse — the files
+are gone. A `FAILED` job also stops blocking, so a failed cleanup never
+permanently wedges the namespace; operators see the failed row and reclaim
+leaked files out of band. This closes the same-name / same-location recreate
+attack: a recreate cannot expose the dropped table's data, because it is
+refused until the files are deleted. A **namespace** carries no data, so it
+can be recreated freely; tables inside it are guarded individually.
+
+**Why the tombstone is necessary — does a recreate reuse the old path?**
+Yes. When a `createTable` omits an explicit `location` — the common case —
+Iceberg derives the table root from the identifier via
+`BaseMetastoreCatalog.defaultWarehouseLocation(identifier)`, i.e.
+`<warehouse>/<namespace>/<table>`. A recreate at the same identifier
+therefore lands on the **same base prefix** as the table being purged. The
+*file names* underneath do not collide — data files get UUID names from
+`OutputFileFactory` and each metadata commit writes 
`<version>-<uuid>.metadata.json`
+— and the worker deletes strictly by reachability from the *old*
+`metadata_location`, so it can never delete the new table's files. The real
+hazard is two live tables transiently sharing one prefix while old files are
+still being deleted: confusing for operators, and an opening for a recreate
+to read leftover data under the shared prefix. Blocking `createTable` /
+`registerTable` with `409` until the cleanup is terminal removes that window
+entirely. (A client that *does* pass an explicit, distinct `location` on
+recreate would not share the prefix, but we still block on identifier to keep
+one simple, predictable rule.)
+
+Once a drop is accepted the table is gone for good: this is *hard* delete,
+so there is deliberately no undrop or re-register recovery path. A client
+that wants a grace period before data is destroyed should use a future
+soft-delete feature (a non-goal here, §3), not this path.
+
+**Concurrency.** The create/register conflict check and the worker's claim
+both serialize on the row's `state`. A `createTable` / `registerTable` that
+observes a non-terminal job (`PENDING` or `RUNNING`) returns `409`; one that
+observes a terminal job (`SUCCEEDED` / `FAILED`) or no job proceeds. The
+worker's claim CAS moves `PENDING → RUNNING`, so the conflict check and the
+claim never disagree about whether the identifier is still occupied.
+
+### 5.8 Object coverage: tables now, views later
+
+The initial scope is **tables only**.
+
+- **Namespace (schema) drops** are *not* a cascade: the Iceberg REST spec
+  requires a namespace to be empty before it can be dropped, so the client
+  drops each table first. Every such `DELETE …?purgeRequested=true` enqueues
+  its own purge job, and the namespace drop itself touches no data files.
+  There is no namespace-level purge job.
+- **Views** are a planned follow-up. A view carries no data files — cleanup
+  only removes its `metadata.json` — so it slots into the same job table
+  (adding an `object_type` column) with the same tombstone rule when added.
+
+### 5.9 Events and observability
+
+Existing `IcebergDropTableEvent` continues to fire on the REST thread with
+the *requested* purge flag, preserving today's listener contract.
+
+Dedicated purge events (`IcebergPurgeStartedEvent`,
+`IcebergPurgeCompletedEvent`, `IcebergPurgeFailedEvent` — work begins, files
+deleted, retries exhausted) are **deferred to Phase 2**, not part of 1.3. In
+1.3, observability comes entirely from the durable job row plus metrics,
+which ship for free with the worker; streaming events are an additive
+follow-up that does not change the job model.
+
+The source of truth for "is this table still being purged, done, or failed?"
+is the `iceberg_cleanup_job` row, exposed without touching the wire contract:
+
+- **Iceberg REST clients** observe only that the table is dropped
+  (`404` / omitted from `LIST`). The single purge-specific signal is the
+  §5.7 `409` on a same-identifier `createTable` / `register`, whose
+  `ErrorResponse.message` names the blocking job.
+- **Operators** query `iceberg_cleanup_job` directly (the `idx_object` index
+  makes the per-identifier lookup cheap), plus the metrics below; the failure
+  reason for a `FAILED` job is in the row's `last_error` column (§5.4), with
+  the full stack trace in the worker's logs. This is enough to answer
+  "still purging / failed, and why?" without new HTTP surface. A read-only
+  management endpoint is deferred to Phase 2.
+- **Metrics**: gauges `purge.jobs.{pending,running,failed}` and
+  `purge.oldest_pending_age_ms`; counters `purge.{completed,failed}`. These
+  let operators alert on a growing backlog or any `FAILED` job — the two
+  conditions that would otherwise go unnoticed.
+
+`FAILED` is the operationally critical state: in 1.3 it is queryable (DB)
+and counted (metric), so a cleanup that exhausts retries is always
+discoverable rather than a silent file leak — even before the Phase 2 events
+land.
+
+### 5.10 Configuration
+
+Whether a given drop runs asynchronously is **not** a server config — it is a
+per-request **client** choice via the `X-Gravitino-Async-Purge` header
+(absent ⇒ synchronous; `true` opts into async deletion). The server-side keys
+only tune the worker pool and retries:
+
+| Key                                                           | Default  | 
Description                                                                  |
+|---------------------------------------------------------------|----------|------------------------------------------------------------------------------|
+| `gravitino.iceberg-rest.async-purge.worker-threads`           | `2`      | 
Worker pool size per server (concurrent jobs).                               |
+| `gravitino.iceberg-rest.async-purge.delete-threads`           | `4`      | 
Server-wide file-delete pool size, shared across all jobs.                   |
+| `gravitino.iceberg-rest.async-purge.delete-batch-size`        | `1000`   | 
Files per bulk-delete batch handed to `deleteExecutor` (§5.5).               |
+| `gravitino.iceberg-rest.async-purge.poll-interval-ms`         | `5000`   | 
Worker poll interval.                                                        |
+| `gravitino.iceberg-rest.async-purge.heartbeat-timeout-ms`     | `300000` | 
Age after which a job with no heartbeat is reclaimable.                      |
+| `gravitino.iceberg-rest.async-purge.max-attempts`             | `5`      | 
Attempts before `FAILED`.                                                    |
+| `gravitino.iceberg-rest.async-purge.retention-hours`          | `720`    | 
How long terminal (`SUCCEEDED` / `FAILED`) rows are retained before pruning (30 
days). |
+
+The thread defaults are deliberately modest. Each `delete-threads` thread now
+issues a *bulk* delete of up to `delete-batch-size` files per call (§5.5), so
+4 threads already sustain high delete throughput — far more than the 16
+single-file threads an earlier draft assumed. `worker-threads` is 2 because a
+single server rarely needs many tables purging at once and each worker can
+saturate the delete pool on its own; both knobs are raised only when a backlog
+metric (§5.9) shows the pool is the bottleneck.
+
+There is no separate claim-batch-size key: a free thread claims one job at a
+time and only when idle (§5.5), so `worker-threads` already bounds concurrent
+jobs.
+
+### 5.11 Security
+
+- `@AuthorizationExpression` on `dropTable` runs on the request thread,
+  unchanged.
+- The worker uses FileIO credentials snapshotted at enqueue time.
+  Credentials that expire before the worker runs (STS tokens, …) are
+  refreshed from the catalog on each heartbeat, so long-running purges keep
+  valid credentials.
+- `iceberg_cleanup_job` may contain credentials in `file_io_props`; the
+  existing Gravitino DB encryption / access controls apply.
+
+### 5.12 Backward compatibility (wire)
+
+The Iceberg REST spec does not require file deletion to be complete before
+the response. With this design:
+
+- The catalog entry is removed before `204`, so `HEAD` returns `404` and
+  `LIST` no longer includes the table. `CREATE` / `register` at the same
+  identifier is rejected with `409 Conflict` until the purge job completes
+  (§5.7).
+- Object-store files may linger until the worker drains. Documented in
+  release notes.
+
+The async-vs-synchronous choice rides on the optional `X-Gravitino-Async-Purge`
+header — a Gravitino extension, not part of the Iceberg REST spec. A standard
+client never sends it and gets the synchronous default, so the protocol is
+unchanged.
+
+---
+
+## 6. Task Breakdown
+
+Ordered so dependencies come first. Each item maps to one GitHub issue/PR.
+Synchronous purge remains **the default in 1.3**; async purge ships as an
+opt-in path selected per request via the `X-Gravitino-Async-Purge` header.
+
+### Phase 1 (1.3): Async purge core
+- [ ] Add the `iceberg_cleanup_job` schema and migrations (MySQL, H2, 
PostgreSQL)
+- [ ] Implement `IcebergPurgeJobStore` enqueue path (persist job, return)
+- [ ] Implement the worker pool: CAS claiming with heartbeat ownership (`FOR 
UPDATE SKIP LOCKED` where available), heartbeat renewal on a thread decoupled 
from deletion, streaming **bulk** file deletion (`CatalogUtil.deleteFiles(..., 
true)`) through a bounded `deleteExecutor` with `CallerRunsPolicy` 
back-pressure, retry state machine that records `last_error` (re-claim failed 
jobs on later polls, give up at `max-attempts`)
+- [ ] Honor the `X-Gravitino-Async-Purge` request header and wire both paths 
into `IcebergTableOperationExecutor.dropTable`
+- [ ] Add observability (§5.9): metrics and the informative `ErrorResponse` 
message on the §5.7 `409`; operator read path in 1.3 is direct DB query
+- [ ] Enforce tombstone semantics (§5.7): on `createTable`/`registerTable`, 
reject with `409` when an active job exists (`idx_object` lookup on the request 
thread)
+
+### Phase 1 (1.3): Testing
+- [ ] Unit tests: enqueue, state machine, worker claiming/heartbeat/contention 
(H2)
+- [ ] Docker integration tests: latency, restart-resume (`kill -9` mid-purge), 
two-replica no-duplicate-deletes
+- [ ] Scale & concurrency tests (§7)
+
+### Phase 1 (1.3): Documentation
+- [ ] Update user-facing docs in `docs/` (async semantics, the 
`X-Gravitino-Async-Purge` header, config, release notes)
+
+### Phase 2 (post-1.3)
+- [ ] Add purge events (§5.9): `IcebergPurgeStartedEvent`, 
`IcebergPurgeCompletedEvent`, `IcebergPurgeFailedEvent`
+- [ ] View support (§5.8): `object_type='VIEW'` cleanup (metadata-only) and 
the view tombstone rule
+- [ ] Read-only `metalakes/{metalake}/catalogs/{catalog}/cleanups` management 
endpoint (§5.9): REST resource, DTOs, authorization, client support, docs, 
tests. Filtered list keyed by object identifier; no by-id fetch
+
+---
+
+## 7. Testing
+
+- Unit (`./gradlew :iceberg:iceberg-rest-server:test -PskipITs`):
+  - `TestIcebergPurgeJobStore` — enqueue and row contents.
+  - `TestIcebergPurgeStateMachine` — PENDING → RUNNING → SUCCEEDED;
+    failure → retry (back to PENDING) → FAILED; `last_error` populated on
+    each failure.
+  - `TestIcebergPurgeWorker` — claiming, heartbeat renewal on a thread that
+    stays fresh while delete batches block, contention (H2), bulk-delete
+    batching, and `CallerRunsPolicy` back-pressure when the delete queue fills.
+  - `TestIcebergTableOperationExecutorAsyncPurge` — `X-Gravitino-Async-Purge:
+    true` enqueues exactly one job; a repeated drop returns `404` and enqueues
+    no second job; an absent header runs the synchronous purge (the default);
+    thrown errors surface as 5xx.
+  - `TestIcebergPurgeTombstone` (§5.7) — `createTable`/`register` at an
+    identifier with an active job returns `409`, succeeds once
+    `SUCCEEDED`/`FAILED`; `loadTable`/`alterTable`/repeated `dropTable`
+    return `404`.
+- Integration (`gravitino-docker-test`):
+  - Drop a table; REST returns < 500 ms; worker eventually clears every file.
+  - Restart REST mid-purge (`kill -9`); purge resumes.
+  - Two replicas on one DB; no duplicate deletions or orphans.
+  - Synchronous default: existing `IcebergRESTServiceIT` reruns unchanged
+    with no `X-Gravitino-Async-Purge` header.
+- Scale & concurrency:
+  - High-file-count fixtures: medium (~50K files), large (~500K files).
+  - Concurrent-drop tier; drop returns within 5 s regardless of table size.
+  - Direct GCS/S3 listing asserts the storage prefix is empty after cleanup.


Reply via email to