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 df532dbf03 [#11298] feat(iceberg-rest): add async cleanup manager
(#11299)
df532dbf03 is described below
commit df532dbf035fa6eeee48c92276341d61f82630e9
Author: roryqi <[email protected]>
AuthorDate: Tue Jun 2 17:18:41 2026 +0800
[#11298] feat(iceberg-rest): add async cleanup manager (#11299)
### What changes were proposed in this pull request?
- Add `IcebergCleanupManager`: a server-wide worker pool that polls the
`iceberg_cleanup_job` store for `PENDING` jobs, claims them via a
heartbeat lease, deletes the dropped table's reachable files in bulk
through a shared `deleteExecutor` (with `CallerRunsPolicy`
back-pressure), renews heartbeats on a separate scheduler thread, drives
the retry state machine (transient failure → back to `PENDING`, give up
at `max-attempts` → `FAILED`), and prunes finished rows past the
retention window.
- Add `TestIcebergCleanupManager` unit tests, running against the
H2/MySQL/PostgreSQL backend matrix via `TestJDBCBackend`.
- Sync the async hard-deletion design doc with the merged persistence
layer (the `dropTable` enqueue snippet and the testing section).
### Why are the changes needed?
Second of three stacked PRs for async hard deletion in the Iceberg REST
catalog. The persistence layer (#11266) added the durable job store;
this PR adds the worker engine that drains jobs and deletes files. REST
integration follows in PR 3.
Fix: #11298
### Does this PR introduce _any_ user-facing change?
No. The manager is not yet wired into the REST drop path (that lands in
PR 3), and the config keys (`async-cleanup.*`) were introduced with the
persistence layer (#11266).
### How was this patch tested?
`./gradlew :iceberg:iceberg-rest-server:test --tests
"org.apache.gravitino.iceberg.service.cleanup.TestIcebergCleanupManager"
-PskipITs` — 7 tests pass (lifecycle to `SUCCEEDED`, transient-retry to
`FAILED`, bulk-delete batching, already-deleted-file tolerance,
reachable-file cleanup, and tombstone delegation).
---
design-docs/async-iceberg-rest-hard-deletion.md | 253 ++++++++-----
.../service/cleanup/IcebergCleanupJobStore.java | 42 +--
.../service/cleanup/IcebergCleanupManager.java | 389 ++++++++++++++++++++
.../cleanup/mapper/IcebergCleanupJobMapper.java | 6 +-
.../IcebergCleanupJobSQLProviderFactory.java | 10 +-
.../base/IcebergCleanupJobBaseSQLProvider.java | 20 +-
.../AbstractIcebergCleanupJobStoreBackendTest.java | 46 ++-
.../service/cleanup/TestIcebergCleanupManager.java | 406 +++++++++++++++++++++
8 files changed, 1022 insertions(+), 150 deletions(-)
diff --git a/design-docs/async-iceberg-rest-hard-deletion.md
b/design-docs/async-iceberg-rest-hard-deletion.md
index 34315ae98e..69a702d451 100644
--- a/design-docs/async-iceberg-rest-hard-deletion.md
+++ b/design-docs/async-iceberg-rest-hard-deletion.md
@@ -19,12 +19,12 @@
# 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` |
+| Field | Value |
+| ------- | ------------------------------------------------------- |
+| Status | Complete |
+| Authors | @roryqi |
+| Created | 2026-05-19 |
+| Module | `iceberg/iceberg-rest-server`, `iceberg/iceberg-common` |
---
@@ -98,14 +98,14 @@ 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)
|
+| 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`)
@@ -183,21 +183,22 @@ public void dropTable(IcebergRequestContext ctx,
TableIdentifier id,
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());
+ cleanupManager.addJob(
+ new IcebergCleanupJob(
+ 0L, // id assigned by IdGenerator at
enqueue
+ catalogId, // resolved catalog entity id
+ id.namespace().toString(),
+ id.name(),
+ metadata.metadataFileLocation(),
+ w.fileIOImpl(),
+ w.fileIOProperties(),
+ ctx.userPrincipal()));
}
```
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
+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).
@@ -217,33 +218,57 @@ already terminal, so it is consistent with the §5.7
tombstone table.
```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',
+ `id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'globally unique
cleanup job id',
+ `catalog_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'globally unique
id of the owning catalog, stable across catalog rename',
+ `namespace` VARCHAR(512) NOT NULL COMMENT 'namespace of the table
to be cleaned up',
+ `table_name` VARCHAR(256) NOT NULL COMMENT 'name of the table to be
cleaned up',
+ `metadata_location` MEDIUMTEXT NOT NULL COMMENT 'location of the table
metadata file to purge',
+ `file_io_impl` VARCHAR(256) NOT NULL COMMENT 'FileIO implementation
class used to access the table files',
+ `file_io_props` MEDIUMTEXT NOT NULL COMMENT 'JSON-encoded FileIO
properties',
`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',
+ `attempts` INT(10) NOT NULL DEFAULT 0 COMMENT 'number of
processing attempts made so far',
+ `last_error` VARCHAR(2048) NULL COMMENT 'truncated reason for the
most recent failure, NULL until a job fails',
+ `heartbeat_at` BIGINT(20) NOT NULL DEFAULT 0 COMMENT 'last heartbeat
from the worker, 0 when not running',
`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',
+ `updated_at` BIGINT(20) NOT NULL COMMENT 'last state change,
drives poll ordering and old finished-job cleanup',
PRIMARY KEY (`id`),
KEY `idx_state_updated` (`state`, `updated_at`),
- KEY `idx_object` (`catalog_name`, `namespace`, `table_name`, `state`)
-) ENGINE=InnoDB;
+ KEY `idx_object` (`catalog_id`, `namespace`(255), `table_name`(128), `state`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'async
Iceberg table cleanup jobs';
```
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.
+Row `id` is **not** a DB `AUTO_INCREMENT` column: the server allocates it from
+Gravitino's `IdGenerator` at enqueue time, and every timestamp (`heartbeat_at`,
+`updated_at`) is supplied by the application rather than the database. This
keeps
+one parameterized set of `INSERT` / `UPDATE` statements portable across H2,
+MySQL, and PostgreSQL, served by a single shared SQL provider (§5.5).
+The object is keyed by `catalog_id`, not by metalake/catalog *names*. Async
+cleanup only runs when the Iceberg REST server is embedded in Gravitino (the
job
+store reuses the entity store's relational backend), so a real catalog entity —
+and its id — always exists, and the request thread reads it for free:
+`catalogDispatcher.loadCatalog(...)` returns the `BaseCatalog`, whose
+`CatalogEntity` is already in memory, so `catalog.entity().id()` needs no extra
+entity-store round trip. `catalog_id` is globally unique (it is
+`catalog_meta`'s primary key), so it identifies the catalog without scoping by
+metalake, and it is **stable across catalog rename**: a rename mid-cleanup
would
+leave a name-keyed tombstone stranded while the physical warehouse prefix is
+unchanged, but the id-keyed tombstone still matches. The leaf `namespace` and
+`table_name` stay as strings — the table is already dropped at enqueue, so
there
+is no table entity/id to reference. `catalog_id` indexes in full, while
+`namespace` and `table_name` are prefix-indexed on MySQL (`namespace(255)`,
+`table_name(128)` — table names also cap at 128) to keep `idx_object` within
+MySQL's 3072-byte index key-length limit. The job store reuses the entity
+store's relational backend —
+its connection pool, transaction management, and per-backend dispatch — instead
+of opening its own JDBC connections.
+
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`,
+re-claimed on a later poll, so the poll cadence (`poll-interval-secs`,
§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.
@@ -268,7 +293,7 @@ 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`
+finds nothing claimable waits `poll-interval-secs` and retries.
`worker-threads`
thus bounds the concurrent jobs per server; there is no separate claim-batch
size.
@@ -287,59 +312,90 @@ first row it wins:
-- 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))
+ OR (state = 'RUNNING' AND 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
+ SET state='RUNNING', heartbeat_at=:now, updated_at=:now
WHERE id=:id
AND ( state='PENDING'
- OR (state='RUNNING'
- AND (heartbeat_at IS NULL OR heartbeat_at < :now -
:heartbeatTimeout)) );
+ OR (state='RUNNING' AND 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.
+capacity control, since a free thread still claims exactly one job. The same
+portable CAS runs on **every** backend: H2, MySQL, and PostgreSQL share one SQL
+provider, so we do **not** use `SELECT … FOR UPDATE SKIP LOCKED` or DB-specific
+advisory locks. The claiming `UPDATE` is the serialization point, so two
+replicas can never claim the same job; a backend that ever needs a divergent
+statement can register its own provider without touching the rest of the
design.
**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
+ claimable, wait `poll-interval-secs` and repeat.
+2. **Load metadata.** `TableMetadataParser.read(io, metadata_location)`. The
+ root `metadata.json` is the only file whose absence means "the table is
+ already gone": a `NotFoundException` here completes the job (a prior attempt
+ already deleted it). Any other failure (e.g. corrupt/unreadable metadata)
+ releases the job for retry, failing it once `attempts` hits the ceiling
(§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).
+3. **Delete leaves before parents.** Walk the snapshot graph and delete one
+ dependency level at a time — data files → manifests → manifest lists →
+ statistics → ancestor metadata → the root `metadata.json` **last** — only
+ advancing once the current level is fully gone. Within a level, files are
+ independent: group them into batches of `delete-batch-size` and hand each to
+ the shared `deleteExecutor` for a **bulk** delete; `NotFoundException`
counts
+ as deleted. Leaf-first ordering keeps every crash recoverable: the root
+ pointer (and the manifests/manifest lists above any leaf still on disk)
+ survives, so a retry re-enumerates and finishes. Deleting a parent first
+ would strand its children — and a `metadata.json` removed early would be
+ misread as "table already gone", leaking everything beneath it. 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:
+Execution mirrors `CatalogHandlers.purgeTable`, deleting **in bulk batches**
+and never one file at a time. To keep every crash recoverable it deletes one
+dependency level at a time — leaves first, the root `metadata.json` last — and
+each `deleteAll` blocks until its level is fully gone before the next begins.
+Data files are the only unbounded level, so they are streamed and deleted one
+manifest at a time rather than collected up front — a million-file table never
+materializes its whole path set; only the far smaller manifest, manifest-list
+and metadata path lists are held at once:
```java
TableMetadata meta = TableMetadataParser.read(io, job.metadataLocation());
-try (CloseableIterable<String> files = reachableFiles(meta)) { // lazy, not
materialized
- Iterators.partition(files.iterator(), deleteBatchSize) // bounded
batches
+Set<String> manifests = new LinkedHashSet<>();
+for (Snapshot s : meta.snapshots()) {
+ for (ManifestFile m : s.allManifests(io)) {
+ if (!manifests.add(m.path())) continue; // dedup shared
manifests
+ try (CloseableIterable<String> dataFiles = // lazy, one
batch resident at a time
+ ManifestFiles.readPaths(m, io, meta.specsById())) {
+ deleteAll(io, dataFiles); // leaves:
streamed, then deleted
+ }
+ }
+}
+deleteAll(io, manifests); // then their
manifests
+deleteAll(io, ReachableFileUtil.manifestListLocations(table));
+deleteAll(io, ReachableFileUtil.statisticsFilesLocations(table));
+deleteAll(io, ancestorMetadata); // older
metadata.json
+deleteAll(io, List.of(job.metadataLocation())); // root pointer,
deleted last
+
+// deleteAll: bulk-batch the (possibly lazy) iterable, then await it
+void deleteAll(FileIO io, Iterable<String> files) {
+ Iterators.partition(files.iterator(), deleteBatchSize) // bounded batches
.forEachRemaining(batch ->
- deleteExecutor.execute(() ->
+ futures.add(deleteExecutor.submit(() ->
// 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)));
+ CatalogUtil.deleteFiles(io, batch, "cleanup", /* bulk */
true))));
+ awaitAll(futures); //
NotFoundException counts as deleted
}
```
@@ -417,19 +473,20 @@ rows age out for reclaim.
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 |
+**metadata phase** fails. A `NotFoundException` counts as success rather than a
+failure: a missing root `metadata.json` means the table is already gone, and a
+missing data file, manifest, or manifest list (from `deleteFile` or while
+enumerating reachable files) is already deleted. Any other 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`.
+
+| Outcome | Action
|
+| ------------------------------------------ |
-------------------------------------------------------------------------------------
|
+| All files deleted (or already gone) | `state='SUCCEEDED'`
|
+| Failure, `attempts < max-attempts` | `attempts++`, set `last_error`,
back to `PENDING`, `heartbeat_at=0`; re-claimed later |
+| Failure, `attempts` reaches `max-attempts` | `attempts++`, set `last_error`
→ `FAILED` (gave up retrying) |
+| 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
@@ -476,7 +533,7 @@ 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 |
@@ -575,15 +632,15 @@ 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). |
+| Key | Default |
Description
|
+| ------------------------------------------------------------- | ------- |
--------------------------------------------------------------------------------------
|
+| `gravitino.iceberg-rest.async-cleanup.worker-threads` | `2` |
Worker pool size per server (concurrent jobs).
|
+| `gravitino.iceberg-rest.async-cleanup.delete-threads` | `4` |
Server-wide file-delete pool size, shared across all jobs.
|
+| `gravitino.iceberg-rest.async-cleanup.delete-batch-size` | `1000` |
Files per bulk-delete batch handed to `deleteExecutor` (§5.5).
|
+| `gravitino.iceberg-rest.async-cleanup.poll-interval-secs` | `5` |
Worker poll interval in seconds; also the retry interval.
|
+| `gravitino.iceberg-rest.async-cleanup.heartbeat-timeout-secs` | `300` |
Age in seconds after which a job with no heartbeat is reclaimable.
|
+| `gravitino.iceberg-rest.async-cleanup.max-attempts` | `5` |
Attempts before `FAILED`.
|
+| `gravitino.iceberg-rest.async-cleanup.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
@@ -635,8 +692,8 @@ 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`)
+- [ ] Implement `IcebergCleanupJobStore` enqueue path (persist job, return)
+- [ ] Implement the worker pool: portable CAS claiming with heartbeat
ownership (one SQL provider for H2/MySQL/PostgreSQL, no `SKIP LOCKED`),
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)
@@ -659,11 +716,11 @@ opt-in path selected per request via the
`X-Gravitino-Async-Purge` header.
## 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
+ - `TestIcebergCleanupJobStore` — enqueue, row contents, and the state
+ machine: PENDING → RUNNING → SUCCEEDED; failure → retry (back to
+ PENDING) → FAILED; `last_error` populated on each failure. Runs against
+ the H2/MySQL/PostgreSQL backend matrix (`TestJDBCBackend`).
+ - `TestIcebergCleanupManager` — 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:
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupJobStore.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupJobStore.java
index 2bd3cd10a7..6f715ba1d9 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupJobStore.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupJobStore.java
@@ -93,52 +93,40 @@ public class IcebergCleanupJobStore {
}
/**
- * Marks a RUNNING job SUCCEEDED.
+ * Marks a RUNNING job SUCCEEDED, only if the caller still owns it.
*
* @param id job id
- * @return {@code true} iff the row was still RUNNING and was updated (i.e.
the caller still owned
- * the job)
+ * @param heartbeat the caller's heartbeat token; the update applies only if
the row's {@code
+ * heartbeat_at} still matches, so a reclaimed worker cannot flip a job
a peer now owns
+ * @return {@code true} iff the row was updated (still RUNNING and owned by
the caller)
*/
- public boolean markSucceeded(long id) {
+ public boolean markSucceeded(long id, long heartbeat) {
long now = System.currentTimeMillis();
return SessionUtils.doWithCommitAndFetchResult(
IcebergCleanupJobMapper.class,
- mapper -> mapper.markFinished(id,
IcebergCleanupJob.State.SUCCEEDED.name(), null, now))
+ mapper ->
+ mapper.markFinished(
+ id, IcebergCleanupJob.State.SUCCEEDED.name(), null, now,
heartbeat))
> 0;
}
/**
- * Marks a RUNNING job FAILED immediately.
- *
- * @param id job id
- * @param reason failure text
- * @return {@code true} iff the row was still RUNNING and was updated (i.e.
the caller still owned
- * the job)
- */
- public boolean markFailed(long id, String reason) {
- long now = System.currentTimeMillis();
- String err = truncate(reason);
- return SessionUtils.doWithCommitAndFetchResult(
- IcebergCleanupJobMapper.class,
- mapper -> mapper.markFinished(id,
IcebergCleanupJob.State.FAILED.name(), err, now))
- > 0;
- }
-
- /**
- * Records a transient failure: {@code attempts++}, then FAILED at the
ceiling else PENDING.
+ * Records a transient failure, only if the caller still owns the job:
{@code attempts++}, then
+ * FAILED at the ceiling else PENDING.
*
* @param id job id
* @param reason failure text
* @param maxAttempts ceiling from config
- * @return {@code true} iff the row was still RUNNING and was updated (i.e.
the caller still owned
- * the job)
+ * @param heartbeat the caller's heartbeat token; the update applies only if
the row's {@code
+ * heartbeat_at} still matches, so a reclaimed worker cannot disturb a
job a peer now owns
+ * @return {@code true} iff the row was updated (still RUNNING and owned by
the caller)
*/
- public boolean recordFailure(long id, String reason, int maxAttempts) {
+ public boolean recordFailure(long id, String reason, int maxAttempts, long
heartbeat) {
long now = System.currentTimeMillis();
String err = truncate(reason);
return SessionUtils.doWithCommitAndFetchResult(
IcebergCleanupJobMapper.class,
- mapper -> mapper.recordFailure(id, err, maxAttempts, now))
+ mapper -> mapper.recordFailure(id, err, maxAttempts, now,
heartbeat))
> 0;
}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupManager.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupManager.java
new file mode 100644
index 0000000000..f46b3c337d
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupManager.java
@@ -0,0 +1,389 @@
+/*
+ * 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.iceberg.service.cleanup;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.Iterators;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.LongPredicate;
+import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.ManifestFiles;
+import org.apache.iceberg.ReachableFileUtil;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.StaticTableOperations;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
+import org.apache.iceberg.exceptions.NotFoundException;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.io.FileIO;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Server-wide async cleanup engine: claims {@code iceberg_cleanup_job} rows,
deletes the dropped
+ * table's files in bulk, and renews claim heartbeats on a thread decoupled
from deletion.
+ */
+public class IcebergCleanupManager implements AutoCloseable {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(IcebergCleanupManager.class);
+
+ private final IcebergCleanupJobStore store;
+ private final int workerThreads;
+ private final int deleteBatchSize;
+ private final int maxAttempts;
+ private final int candidateWindow;
+ private final long pollIntervalMs;
+ private final long heartbeatTimeoutMs;
+ private final long retentionMs;
+ private final ThreadPoolExecutor deleteExecutor;
+ // Heartbeat token per job this manager currently owns, keyed by id. The
scheduler renews it and a
+ // worker reads it for the terminal CAS; refreshHeartbeats drops the entry
once a peer reclaims.
+ private final Map<Long, Long> ownedHeartbeats = new ConcurrentHashMap<>();
+
+ private final AtomicBoolean running = new AtomicBoolean(false);
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+ private ExecutorService workers;
+ private ScheduledExecutorService scheduler;
+
+ /**
+ * Creates an async cleanup manager.
+ *
+ * @param store the cleanup job store backed by the entity store's
relational backend
+ * @param config Iceberg REST server config
+ */
+ public IcebergCleanupManager(IcebergCleanupJobStore store, IcebergConfig
config) {
+ this.store = store;
+ this.workerThreads =
config.get(IcebergConfig.ASYNC_CLEANUP_WORKER_THREADS);
+ int deleteThreads = config.get(IcebergConfig.ASYNC_CLEANUP_DELETE_THREADS);
+ this.deleteBatchSize =
config.get(IcebergConfig.ASYNC_CLEANUP_DELETE_BATCH_SIZE);
+ this.pollIntervalMs =
config.get(IcebergConfig.ASYNC_CLEANUP_POLL_INTERVAL_SECS) * 1000L;
+ this.heartbeatTimeoutMs =
+ config.get(IcebergConfig.ASYNC_CLEANUP_HEARTBEAT_TIMEOUT_SECS) * 1000L;
+ this.maxAttempts = config.get(IcebergConfig.ASYNC_CLEANUP_MAX_ATTEMPTS);
+ this.retentionMs = config.get(IcebergConfig.ASYNC_CLEANUP_RETENTION_HOURS)
* 3_600_000L;
+ // Scan more candidates than workers so a claim that loses its CAS still
has other rows to try
+ // in the same poll. workerThreads * 4 gives that headroom; the floor of 8
keeps the window
+ // useful when only one or two worker threads are configured.
+ this.candidateWindow = Math.max(8, workerThreads * 4);
+ this.deleteExecutor =
+ new ThreadPoolExecutor(
+ deleteThreads,
+ deleteThreads,
+ 60L,
+ TimeUnit.SECONDS,
+ new ArrayBlockingQueue<>(deleteThreads * 4),
+ daemon("iceberg-cleanup-delete"),
+ new ThreadPoolExecutor.CallerRunsPolicy());
+ }
+
+ /**
+ * Persists a new cleanup job.
+ *
+ * @param job job to persist
+ * @return generated id
+ */
+ public long addJob(IcebergCleanupJob job) {
+ return store.addJob(job);
+ }
+
+ /**
+ * Checks whether an unfinished cleanup job occupies a table identifier.
+ *
+ * @param catalogId globally unique id of the owning catalog
+ * @param namespace table namespace
+ * @param table table name
+ * @return true iff a PENDING or RUNNING job exists for the identifier
+ */
+ public boolean isNameOccupied(long catalogId, String namespace, String
table) {
+ return store.findUnfinishedJobId(catalogId, namespace, table).isPresent();
+ }
+
+ /** Starts worker threads and the heartbeat/prune scheduler. */
+ public void start() {
+ if (closed.get()) {
+ throw new IllegalStateException("Iceberg cleanup manager is already
closed");
+ }
+
+ // compareAndSet keeps concurrent or repeated start() calls from each
allocating a pool.
+ if (!running.compareAndSet(false, true)) {
+ return;
+ }
+
+ // We submit exactly workerThreads loops, so the queue is never used; it
is bounded only to
+ // avoid Executors.newFixedThreadPool's unbounded queue.
+ this.workers =
+ new ThreadPoolExecutor(
+ workerThreads,
+ workerThreads,
+ 0L,
+ TimeUnit.MILLISECONDS,
+ new ArrayBlockingQueue<>(workerThreads),
+ daemon("iceberg-cleanup-worker"));
+ for (int i = 0; i < workerThreads; i++) {
+ workers.submit(this::workerLoop);
+ }
+
+ // One scheduler thread runs both periodic tasks: heartbeat renewal and
row pruning.
+ this.scheduler = Executors.newScheduledThreadPool(1,
daemon("iceberg-cleanup-heartbeat-prune"));
+ long heartbeatIntervalMs = heartbeatTimeoutMs / 3L;
+ scheduler.scheduleAtFixedRate(
+ this::refreshHeartbeats, heartbeatIntervalMs, heartbeatIntervalMs,
TimeUnit.MILLISECONDS);
+ scheduler.scheduleAtFixedRate(this::prune, 1L, 1L, TimeUnit.HOURS);
+ }
+
+ @Override
+ public void close() {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+
+ running.set(false);
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ }
+ if (workers != null) {
+ workers.shutdownNow();
+ awaitTermination(workers);
+ }
+ deleteExecutor.shutdownNow();
+ // Wait for in-flight delete batches to observe the interrupt and stop,
matching the workers
+ // above, so close() does not return while file deletions are still
running on a dying pool.
+ awaitTermination(deleteExecutor);
+ // The job store is backed by the entity store's shared relational
backend, which owns the
+ // connection pool lifecycle, so there is nothing to close here.
+ }
+
+ void cleanupFiles(FileIO io, String metadataLocation) {
+ TableMetadata metadata;
+ try {
+ metadata = TableMetadataParser.read(io, metadataLocation);
+ } catch (NotFoundException metadataAlreadyGone) {
+ // A missing root metadata.json means the table is already gone. Since
we delete it last, its
+ // absence proves every file under it was deleted first, so this is the
one NotFoundException
+ // we treat as success. Return and let runJob mark the job SUCCEEDED.
+ LOG.info("Cleanup metadata {} already absent; treating as done",
metadataLocation);
+ return;
+ }
+
+ Table table = new BaseTable(new StaticTableOperations(metadata, io),
"async-cleanup");
+
+ // Delete children before parents, root metadata.json last. Each deleteAll
blocks until its
+ // level is gone, so a crash always leaves the root (and the manifests
above any surviving file)
+ // readable for a retry to rebuild from. Deleting a parent first would
orphan its children.
+ //
+ // Data files are the only huge level, so they are streamed and deleted
one manifest at a time
+ // rather than all collected first; only the smaller
manifest/list/metadata paths are held.
+ Set<String> manifests = new LinkedHashSet<>();
+ deleteDataFiles(io, metadata, manifests);
+ deleteAll(io, manifests);
+ deleteAll(io, ReachableFileUtil.manifestListLocations(table));
+ deleteAll(io, ReachableFileUtil.statisticsFilesLocations(table));
+
+ // metadataFileLocations includes the current metadata.json; drop it so it
is deleted last.
+ Set<String> ancestorMetadata =
+ new LinkedHashSet<>(ReachableFileUtil.metadataFileLocations(table,
true));
+ ancestorMetadata.remove(metadataLocation);
+ deleteAll(io, ancestorMetadata);
+ deleteAll(io, Collections.singletonList(metadataLocation));
+ }
+
+ void deleteAll(FileIO io, Iterable<String> files) {
+ // Callers pass one manifest's files at a time (or a small fixed list), so
futures stay small;
+ // CallerRunsPolicy on deleteExecutor also throttles submission when the
pool is saturated.
+ List<Future<?>> futures = new ArrayList<>();
+ Iterators.partition(files.iterator(), deleteBatchSize)
+ .forEachRemaining(
+ batch ->
+ futures.add(
+ deleteExecutor.submit(
+ () -> CatalogUtil.deleteFiles(io, batch, "cleanup",
true))));
+
+ for (Future<?> future : futures) {
+ try {
+ future.get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted during bulk delete", e);
+ } catch (ExecutionException e) {
+ if
(Throwables.getCausalChain(e).stream().anyMatch(NotFoundException.class::isInstance))
{
+ LOG.debug("Ignoring already-deleted file during async cleanup", e);
+ continue;
+ }
+ throw new RuntimeException("Bulk delete batch failed", e);
+ }
+ }
+ }
+
+ private void workerLoop() {
+ while (running.get()) {
+ try {
+ long now = System.currentTimeMillis();
+ Optional<IcebergCleanupJob> job =
+ store.takePendingJob(now, heartbeatTimeoutMs, candidateWindow);
+ if (job.isEmpty()) {
+ sleep(pollIntervalMs);
+ continue;
+ }
+
+ ownedHeartbeats.put(job.get().id(), now);
+ runJob(job.get());
+ } catch (Throwable t) {
+ // The loop is submitted once, so if it exits the worker is gone for
good. Catch everything
+ // (including Errors) so a fault only backs off instead of killing the
worker.
+ if (t instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ LOG.warn("Cleanup worker loop hit an unexpected error; backing off",
t);
+ sleep(pollIntervalMs);
+ }
+ }
+ }
+
+ private void runJob(IcebergCleanupJob job) {
+ long id = job.id();
+ // try-with-resources so the per-job FileIO (which may hold an S3 client /
connection pool) is
+ // closed on every path: success, transient failure, and the early return
inside cleanupFiles.
+ try (FileIO io = CatalogUtil.loadFileIO(job.fileIOImpl(),
job.fileIOProperties(), null)) {
+ cleanupFiles(io, job.metadataLocation());
+ finishJob(id, heartbeat -> store.markSucceeded(id, heartbeat));
+ } catch (RuntimeException e) {
+ LOG.warn("Cleanup job {} failed transiently; will retry", id, e);
+ finishJob(id, heartbeat -> store.recordFailure(id, e.getMessage(),
maxAttempts, heartbeat));
+ } finally {
+ ownedHeartbeats.remove(id);
+ }
+ }
+
+ // markSucceeded/recordFailure CAS on the heartbeat token, so a worker whose
lease a peer
+ // reclaimed cannot overwrite the job the peer now owns. A null token means
a refresh already
+ // saw the takeover, so we skip. A failed CAS just leaves the row RUNNING to
be reclaimed and
+ // re-run (which finds the files gone and succeeds); we log it so the
reclaim is observable.
+ void finishJob(long id, LongPredicate terminalUpdate) {
+ Long heartbeat = ownedHeartbeats.get(id);
+ if (heartbeat != null && !terminalUpdate.test(heartbeat)) {
+ LOG.warn("Could not finish cleanup job {}; it will be reclaimed and
re-run", id);
+ }
+ }
+
+ void refreshHeartbeats() {
+ long now = System.currentTimeMillis();
+ for (Map.Entry<Long, Long> entry : new
ArrayList<>(ownedHeartbeats.entrySet())) {
+ long id = entry.getKey();
+ try {
+ long previousHeartbeat = entry.getValue();
+ ownedHeartbeats.put(id, now);
+ if (!store.heartbeat(id, previousHeartbeat, now)) {
+ LOG.warn("Lost ownership of cleanup job {}", id);
+ ownedHeartbeats.remove(id, now);
+ }
+ } catch (Throwable t) {
+ ownedHeartbeats.replace(id, now, entry.getValue());
+ // scheduleAtFixedRate stops a task forever if it throws, so never let
one escape: a bad job
+ // must not stop heartbeat renewal for the whole process.
+ LOG.warn("Heartbeat update failed for job {}", id, t);
+ }
+ }
+ }
+
+ private void prune() {
+ try {
+ store.deleteFinishedJobsByLegacyTimeline(System.currentTimeMillis() -
retentionMs);
+ } catch (Throwable t) {
+ // As above: don't let a throw stop the recurring prune task.
+ LOG.warn("Cleanup-row pruning failed", t);
+ }
+ }
+
+ // Streams each manifest's data files to deleteAll (one manifest's paths in
memory at a time) and
+ // collects the manifest paths into `manifests` for the caller to delete
next.
+ private void deleteDataFiles(FileIO io, TableMetadata metadata, Set<String>
manifests) {
+ for (Snapshot snapshot : metadata.snapshots()) {
+ List<ManifestFile> snapshotManifests;
+ try {
+ snapshotManifests = snapshot.allManifests(io);
+ } catch (NotFoundException manifestListGone) {
+ // Manifest lists are deleted after everything under them, so a
missing one means a prior
+ // attempt already deleted this snapshot's files. Nothing left here;
skip it.
+ LOG.debug("Manifest list for snapshot {} already gone; skipping",
snapshot.snapshotId());
+ continue;
+ }
+ for (ManifestFile manifest : snapshotManifests) {
+ if (!manifests.add(manifest.path())) {
+ continue; // shared by several snapshots; its data files were
already deleted
+ }
+ try (CloseableIterable<String> paths =
+ ManifestFiles.readPaths(manifest, io, metadata.specsById())) {
+ // deleteAll pulls this lazy iterable in batches, so only one batch
is held at a time.
+ deleteAll(io, paths);
+ } catch (NotFoundException manifestGone) {
+ // Manifests are deleted after their data files, so a missing one
has no data files left.
+ LOG.debug("Manifest {} already gone; skipping", manifest.path());
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to read manifest " +
manifest.path(), e);
+ }
+ }
+ }
+ }
+
+ private static void awaitTermination(ExecutorService pool) {
+ try {
+ pool.awaitTermination(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static void sleep(long ms) {
+ try {
+ Thread.sleep(ms);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static ThreadFactory daemon(String name) {
+ return runnable -> {
+ Thread thread = new Thread(runnable, name);
+ thread.setDaemon(true);
+ return thread;
+ };
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/IcebergCleanupJobMapper.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/IcebergCleanupJobMapper.java
index 12c981fb2d..4e85bf2ed0 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/IcebergCleanupJobMapper.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/IcebergCleanupJobMapper.java
@@ -53,14 +53,16 @@ public interface IcebergCleanupJobMapper {
@Param("id") long id,
@Param("state") String state,
@Param("reason") String reason,
- @Param("now") long now);
+ @Param("now") long now,
+ @Param("heartbeat") long heartbeat);
@UpdateProvider(type = IcebergCleanupJobSQLProviderFactory.class, method =
"recordFailure")
int recordFailure(
@Param("id") long id,
@Param("reason") String reason,
@Param("maxAttempts") int maxAttempts,
- @Param("now") long now);
+ @Param("now") long now,
+ @Param("heartbeat") long heartbeat);
@UpdateProvider(type = IcebergCleanupJobSQLProviderFactory.class, method =
"heartbeat")
int heartbeat(
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/IcebergCleanupJobSQLProviderFactory.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/IcebergCleanupJobSQLProviderFactory.java
index 7c1969fc3f..733a01c295 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/IcebergCleanupJobSQLProviderFactory.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/IcebergCleanupJobSQLProviderFactory.java
@@ -75,16 +75,18 @@ public class IcebergCleanupJobSQLProviderFactory {
@Param("id") long id,
@Param("state") String state,
@Param("reason") String reason,
- @Param("now") long now) {
- return getProvider().markFinished(id, state, reason, now);
+ @Param("now") long now,
+ @Param("heartbeat") long heartbeat) {
+ return getProvider().markFinished(id, state, reason, now, heartbeat);
}
public static String recordFailure(
@Param("id") long id,
@Param("reason") String reason,
@Param("maxAttempts") int maxAttempts,
- @Param("now") long now) {
- return getProvider().recordFailure(id, reason, maxAttempts, now);
+ @Param("now") long now,
+ @Param("heartbeat") long heartbeat) {
+ return getProvider().recordFailure(id, reason, maxAttempts, now,
heartbeat);
}
public static String heartbeat(
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/base/IcebergCleanupJobBaseSQLProvider.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/base/IcebergCleanupJobBaseSQLProvider.java
index 0de925bdf6..11b10876c5 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/base/IcebergCleanupJobBaseSQLProvider.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/base/IcebergCleanupJobBaseSQLProvider.java
@@ -93,18 +93,22 @@ public class IcebergCleanupJobBaseSQLProvider {
* @param state terminal state to set (SUCCEEDED or FAILED)
* @param reason failure reason, or {@code null} for success
* @param now state-change timestamp
+ * @param heartbeat the caller's owned heartbeat value (compare-and-swap
ownership key)
* @return the terminal-transition UPDATE statement
*/
public String markFinished(
@Param("id") long id,
@Param("state") String state,
@Param("reason") String reason,
- @Param("now") long now) {
- // Shared transition to a final state: SUCCEEDED (reason null) or FAILED
(reason set).
+ @Param("now") long now,
+ @Param("heartbeat") long heartbeat) {
+ // Shared transition to a final state: SUCCEEDED (reason null) or FAILED
(reason set). The
+ // heartbeat_at predicate checks ownership: a reclaimed worker no longer
matches it.
return "UPDATE "
+ TABLE_NAME
+ " SET state = #{state}, last_error = #{reason}, heartbeat_at = 0,"
- + " updated_at = #{now} WHERE id = #{id} AND state = 'RUNNING'";
+ + " updated_at = #{now}"
+ + " WHERE id = #{id} AND state = 'RUNNING' AND heartbeat_at =
#{heartbeat}";
}
/**
@@ -112,24 +116,28 @@ public class IcebergCleanupJobBaseSQLProvider {
* @param reason failure reason
* @param maxAttempts attempt ceiling past which the job is FAILED
* @param now state-change timestamp
+ * @param heartbeat the caller's owned heartbeat value (compare-and-swap
ownership key)
* @return the transient-failure UPDATE statement
*/
public String recordFailure(
@Param("id") long id,
@Param("reason") String reason,
@Param("maxAttempts") int maxAttempts,
- @Param("now") long now) {
+ @Param("now") long now,
+ @Param("heartbeat") long heartbeat) {
// The state CASE must observe the pre-increment attempts value, so it
MUST precede
// "attempts = attempts + 1" in the SET list. MySQL evaluates SET
assignments left to
// right and later clauses see already-updated columns; if the increment
came first the
// CASE would compare attempts + 2 and fail one attempt early. H2 and
PostgreSQL evaluate
// every right-hand side against the original row, so state-first is
correct there too,
- // keeping this statement portable across all backends.
+ // keeping this statement portable across all backends. The heartbeat_at
predicate checks
+ // ownership (see markFinished).
return "UPDATE "
+ TABLE_NAME
+ " SET state = CASE WHEN attempts + 1 >= #{maxAttempts} THEN 'FAILED'
ELSE 'PENDING' END,"
+ " attempts = attempts + 1, last_error = #{reason},"
- + " heartbeat_at = 0, updated_at = #{now} WHERE id = #{id} AND state =
'RUNNING'";
+ + " heartbeat_at = 0, updated_at = #{now}"
+ + " WHERE id = #{id} AND state = 'RUNNING' AND heartbeat_at =
#{heartbeat}";
}
/**
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/AbstractIcebergCleanupJobStoreBackendTest.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/AbstractIcebergCleanupJobStoreBackendTest.java
index 3e58e925df..005653da64 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/AbstractIcebergCleanupJobStoreBackendTest.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/AbstractIcebergCleanupJobStoreBackendTest.java
@@ -100,36 +100,56 @@ abstract class AbstractIcebergCleanupJobStoreBackendTest
extends TestJDBCBackend
Assertions.assertTrue(store.findUnfinishedJobId(CATALOG_ID, "db",
"t").isPresent());
Assertions.assertFalse(store.takePendingJob(now, 300_000L,
10).isPresent());
- Assertions.assertTrue(store.markSucceeded(id));
+ Assertions.assertTrue(store.markSucceeded(id, now));
Assertions.assertEquals(IcebergCleanupJob.State.SUCCEEDED,
store.stateOf(id));
// A second transition no longer owns the (now terminal) row, so it
reports no update.
- Assertions.assertFalse(store.markSucceeded(id));
+ Assertions.assertFalse(store.markSucceeded(id, now));
Assertions.assertFalse(store.findUnfinishedJobId(CATALOG_ID, "db",
"t").isPresent());
Assertions.assertEquals(
1, store.deleteFinishedJobsByLegacyTimeline(System.currentTimeMillis()
+ 1));
}
@TestTemplate
- void testMarkFailed() {
+ void testTransientFailureRetriesThenFailsAtCeiling() {
long id = store.addJob(sampleJob());
- store.takePendingJob(System.currentTimeMillis(), 300_000L, 10);
- Assertions.assertTrue(store.markFailed(id, "corrupt metadata"));
+ for (int i = 0; i < 2; i++) {
+ long heartbeat = System.currentTimeMillis();
+ store.takePendingJob(heartbeat, 300_000L, 10);
+ Assertions.assertTrue(store.recordFailure(id, "boom " + i, 3,
heartbeat));
+ Assertions.assertEquals(IcebergCleanupJob.State.PENDING,
store.stateOf(id));
+ }
+ long heartbeat = System.currentTimeMillis();
+ store.takePendingJob(heartbeat, 300_000L, 10);
+ Assertions.assertTrue(store.recordFailure(id, "boom final", 3, heartbeat));
Assertions.assertEquals(IcebergCleanupJob.State.FAILED, store.stateOf(id));
}
@TestTemplate
- void testTransientFailureRetriesThenFailsAtCeiling() {
+ void testRecordFailureAtMaxAttemptsMarksFailed() {
long id = store.addJob(sampleJob());
- for (int i = 0; i < 2; i++) {
- store.takePendingJob(System.currentTimeMillis(), 300_000L, 10);
- Assertions.assertTrue(store.recordFailure(id, "boom " + i, 3));
- Assertions.assertEquals(IcebergCleanupJob.State.PENDING,
store.stateOf(id));
- }
- store.takePendingJob(System.currentTimeMillis(), 300_000L, 10);
- Assertions.assertTrue(store.recordFailure(id, "boom final", 3));
+ long heartbeat = System.currentTimeMillis();
+ store.takePendingJob(heartbeat, 300_000L, 10);
+
+ Assertions.assertTrue(store.recordFailure(id, "boom", 1, heartbeat));
Assertions.assertEquals(IcebergCleanupJob.State.FAILED, store.stateOf(id));
}
+ @TestTemplate
+ void testTerminalUpdateNeedsOwnership() {
+ long id = store.addJob(sampleJob());
+ long now = System.currentTimeMillis();
+ store.takePendingJob(now, 300_000L, 10); // writes heartbeat_at = now
+
+ // A stale heartbeat token (a reclaimed worker) cannot finish or fail the
job.
+ Assertions.assertFalse(store.markSucceeded(id, now - 1));
+ Assertions.assertFalse(store.recordFailure(id, "stale", 3, now - 1));
+ Assertions.assertEquals(IcebergCleanupJob.State.RUNNING,
store.stateOf(id));
+
+ // The owner with the current token wins.
+ Assertions.assertTrue(store.markSucceeded(id, now));
+ Assertions.assertEquals(IcebergCleanupJob.State.SUCCEEDED,
store.stateOf(id));
+ }
+
@TestTemplate
void testHeartbeatCasAndStaleTakeover() {
long id = store.addJob(sampleJob());
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/TestIcebergCleanupManager.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/TestIcebergCleanupManager.java
new file mode 100644
index 0000000000..a525dd3fa6
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/TestIcebergCleanupManager.java
@@ -0,0 +1,406 @@
+/*
+ * 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.iceberg.service.cleanup;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NotFoundException;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.inmemory.InMemoryFileIO;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.io.SupportsBulkOperations;
+import org.apache.iceberg.types.Types;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestTemplate;
+
+/**
+ * Exercises {@link IcebergCleanupManager} against the same relational backend
matrix as the cleanup
+ * store: {@link TestJDBCBackend} initializes H2 by default, adds MySQL and
PostgreSQL when {@code
+ * dockerTest=true}, and truncates all backend tables before each invocation.
+ */
+class TestIcebergCleanupManager extends TestJDBCBackend {
+
+ private static final long CATALOG_ID = 100L;
+ private static final String DATA_FILE = "memory://db/t/data/00000-0.parquet";
+
+ private IcebergCleanupJobStore store;
+
+ // TestJDBCBackend's BackendTestExtension overwrites GravitinoEnv's
singleton "config" and
+ // "idGenerator" fields with a backend-only Mockito mock and never restores
them. Because the
+ // whole iceberg-rest-server module runs in one JVM, that mock would leak
into later test classes
+ // (e.g. credential vending), where
MetadataAuthzHelper.enableAuthorization() unboxes the
+ // unstubbed config.get(ENABLE_AUTHORIZATION) -> null and NPEs. Snapshot the
pre-test fields and
+ // restore them after this class so it leaves GravitinoEnv exactly as it
found it.
+ private Object originalConfig;
+ private Object originalIdGenerator;
+
+ @BeforeAll
+ public void snapshotGravitinoEnv() throws IllegalAccessException {
+ originalConfig = FieldUtils.readField(GravitinoEnv.getInstance(),
"config", true);
+ originalIdGenerator = FieldUtils.readField(GravitinoEnv.getInstance(),
"idGenerator", true);
+ }
+
+ @AfterAll
+ public void restoreGravitinoEnv() throws IllegalAccessException {
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "config",
originalConfig, true);
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "idGenerator",
originalIdGenerator, true);
+ }
+
+ @BeforeEach
+ public void prepareCleanupJobStore() {
+ store = new IcebergCleanupJobStore(new RandomIdGenerator());
+ }
+
+ private static IcebergConfig fastPollConfig() {
+ Map<String, String> config = new HashMap<>();
+ config.put("async-cleanup.worker-threads", "1");
+ config.put("async-cleanup.poll-interval-secs", "1");
+ return new IcebergConfig(config);
+ }
+
+ private static IcebergCleanupJob sampleJob() {
+ return new IcebergCleanupJob(
+ 0L,
+ CATALOG_ID,
+ "db",
+ "t",
+ "s3://b/db/t/metadata/0.json",
+ NoopFileIO.class.getName(),
+ ImmutableMap.of(),
+ "alice");
+ }
+
+ // Builds db.t with one appended data file (so it has a manifest list, a
manifest, and a data
+ // file) and materializes the data file in the in-memory FileIO so deletions
are observable.
+ private static BaseTable tableWithDataFile() {
+ InMemoryCatalog catalog = new InMemoryCatalog();
+ catalog.initialize("test", ImmutableMap.of());
+ catalog.createNamespace(Namespace.of("db"));
+ TableIdentifier id = TableIdentifier.of(Namespace.of("db"), "t");
+ Schema schema = new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()));
+ Table table = catalog.createTable(id, schema);
+ DataFile dataFile =
+ DataFiles.builder(PartitionSpec.unpartitioned())
+ .withPath(DATA_FILE)
+ .withFileSizeInBytes(10L)
+ .withRecordCount(1L)
+ .build();
+ table.newAppend().appendFile(dataFile).commit();
+ BaseTable base = (BaseTable) catalog.loadTable(id);
+ ((InMemoryFileIO) base.io()).addFile(DATA_FILE, new byte[] {1});
+ return base;
+ }
+
+ @TestTemplate
+ void testDeleteAllBatches() {
+ CopyOnWriteArrayList<String> deleted = new CopyOnWriteArrayList<>();
+ IcebergCleanupManager svc =
+ new IcebergCleanupManager(store, new IcebergConfig(new HashMap<>()));
+ try {
+ svc.deleteAll(new RecordingFileIO(deleted), Arrays.asList("a", "b", "c",
"d", "e", "f", "g"));
+ Assertions.assertEquals(7, deleted.size());
+ } finally {
+ svc.close();
+ }
+ }
+
+ @TestTemplate
+ void testDeleteAllIgnoresMissing() {
+ IcebergCleanupManager svc =
+ new IcebergCleanupManager(store, new IcebergConfig(new HashMap<>()));
+ try {
+ Assertions.assertDoesNotThrow(
+ () -> svc.deleteAll(new MissingFileIO(),
Arrays.asList("already-gone")));
+ } finally {
+ svc.close();
+ }
+ }
+
+ @TestTemplate
+ void testDeleteAllIgnoresMissingBulk() {
+ IcebergCleanupManager svc =
+ new IcebergCleanupManager(store, new IcebergConfig(new HashMap<>()));
+ try {
+ Assertions.assertDoesNotThrow(
+ () -> svc.deleteAll(new MissingBulkFileIO(),
Arrays.asList("already-gone")));
+ } finally {
+ svc.close();
+ }
+ }
+
+ @TestTemplate
+ void testCleanupDeletesAllFiles() {
+ BaseTable base = tableWithDataFile();
+ FileIO io = base.io();
+ String metadataLocation =
base.operations().current().metadataFileLocation();
+
+ // Capture reachable files before cleanup; the manifests cannot be read
once deleted.
+ List<String> expected = new ArrayList<>();
+ expected.add(metadataLocation);
+ expected.add(DATA_FILE);
+ for (Snapshot snapshot : base.snapshots()) {
+ expected.add(snapshot.manifestListLocation());
+ for (ManifestFile manifest : snapshot.allManifests(io)) {
+ expected.add(manifest.path());
+ }
+ }
+ expected.forEach(file -> Assertions.assertTrue(((InMemoryFileIO)
io).fileExists(file), file));
+
+ IcebergCleanupManager svc =
+ new IcebergCleanupManager(store, new IcebergConfig(new HashMap<>()));
+ try {
+ svc.cleanupFiles(io, metadataLocation);
+ } finally {
+ svc.close();
+ }
+
+ expected.forEach(file -> Assertions.assertFalse(((InMemoryFileIO)
io).fileExists(file), file));
+ }
+
+ @TestTemplate
+ void testCleanupToleratesMissingMetadata() {
+ // A missing root metadata.json means the table is already gone, so
cleanup just returns.
+ IcebergCleanupManager svc =
+ new IcebergCleanupManager(store, new IcebergConfig(new HashMap<>()));
+ try {
+ Assertions.assertDoesNotThrow(
+ () -> svc.cleanupFiles(new InMemoryFileIO(),
"memory://db/t/metadata/missing.json"));
+ } finally {
+ svc.close();
+ }
+ }
+
+ @TestTemplate
+ void testWorkerSucceeds() {
+ AtomicInteger calls = new AtomicInteger();
+ IcebergCleanupManager svc =
+ new IcebergCleanupManager(store, fastPollConfig()) {
+ @Override
+ void cleanupFiles(FileIO io, String metadataLocation) {
+ calls.incrementAndGet();
+ }
+ };
+ long id = store.addJob(sampleJob());
+ svc.start();
+ try {
+ Awaitility.await()
+ .atMost(5, TimeUnit.SECONDS)
+ .until(() -> store.stateOf(id) == IcebergCleanupJob.State.SUCCEEDED);
+ Assertions.assertEquals(1, calls.get());
+ } finally {
+ svc.close();
+ }
+ }
+
+ @TestTemplate
+ void testWorkerRetriesThenFails() {
+ Map<String, String> config = new HashMap<>();
+ config.put("async-cleanup.worker-threads", "1");
+ config.put("async-cleanup.poll-interval-secs", "1");
+ config.put("async-cleanup.max-attempts", "3");
+ IcebergCleanupManager svc =
+ new IcebergCleanupManager(store, new IcebergConfig(config)) {
+ @Override
+ void cleanupFiles(FileIO io, String metadataLocation) {
+ throw new RuntimeException("transient");
+ }
+ };
+ long id = store.addJob(sampleJob());
+ svc.start();
+ try {
+ Awaitility.await()
+ .atMost(15, TimeUnit.SECONDS)
+ .until(() -> store.stateOf(id) == IcebergCleanupJob.State.FAILED);
+ } finally {
+ svc.close();
+ }
+ }
+
+ @TestTemplate
+ void testStartAfterCloseFailsFast() {
+ IcebergCleanupManager svc =
+ new IcebergCleanupManager(store, new IcebergConfig(new HashMap<>()));
+ svc.close();
+
+ Assertions.assertThrows(IllegalStateException.class, svc::start);
+ }
+
+ @TestTemplate
+ @SuppressWarnings("unchecked")
+ void testRefreshHeartbeatsPublishesNewTokenBeforeStoreUpdate() throws
Exception {
+ BlockingHeartbeatStore blockingStore = new BlockingHeartbeatStore();
+ IcebergCleanupManager svc =
+ new IcebergCleanupManager(blockingStore, new IcebergConfig(new
HashMap<>()));
+ try {
+ Map<Long, Long> heartbeats =
+ (Map<Long, Long>) FieldUtils.readField(svc, "ownedHeartbeats", true);
+ heartbeats.put(1L, 100L);
+
+ Thread refresh = new Thread(svc::refreshHeartbeats);
+ refresh.start();
+ Assertions.assertTrue(blockingStore.heartbeatStarted.await(5,
TimeUnit.SECONDS));
+
+ AtomicLong token = new AtomicLong();
+ svc.finishJob(1L, heartbeat -> token.compareAndSet(0L, heartbeat));
+ Assertions.assertTrue(token.get() > 100L);
+
+ blockingStore.releaseHeartbeat.countDown();
+ refresh.join(5_000L);
+ Assertions.assertFalse(refresh.isAlive());
+ } finally {
+ svc.close();
+ }
+ }
+
+ @TestTemplate
+ void testIsNameOccupied() {
+ IcebergCleanupManager svc =
+ new IcebergCleanupManager(store, new IcebergConfig(new HashMap<>()));
+ try {
+ Assertions.assertFalse(svc.isNameOccupied(CATALOG_ID, "db", "t"));
+ svc.addJob(sampleJob());
+ Assertions.assertTrue(svc.isNameOccupied(CATALOG_ID, "db", "t"));
+ } finally {
+ svc.close();
+ }
+ }
+
+ static class RecordingFileIO implements SupportsBulkOperations {
+ private final CopyOnWriteArrayList<String> deleted;
+
+ RecordingFileIO(CopyOnWriteArrayList<String> deleted) {
+ this.deleted = deleted;
+ }
+
+ @Override
+ public InputFile newInputFile(String path) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OutputFile newOutputFile(String path) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void deleteFile(String path) {
+ deleted.add(path);
+ }
+
+ @Override
+ public void deleteFiles(Iterable<String> paths) {
+ for (String path : paths) {
+ deleted.add(path);
+ }
+ }
+ }
+
+ private static class MissingBulkFileIO extends MissingFileIO implements
SupportsBulkOperations {
+ @Override
+ public void deleteFiles(Iterable<String> paths) {
+ throw new NotFoundException("Missing files");
+ }
+ }
+
+ public static class NoopFileIO implements FileIO {
+ @Override
+ public InputFile newInputFile(String path) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OutputFile newOutputFile(String path) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void deleteFile(String path) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ private static class MissingFileIO implements FileIO {
+ @Override
+ public InputFile newInputFile(String path) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OutputFile newOutputFile(String path) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void deleteFile(String path) {
+ throw new NotFoundException("Missing file: %s", path);
+ }
+ }
+
+ private static class BlockingHeartbeatStore extends IcebergCleanupJobStore {
+ private final CountDownLatch heartbeatStarted = new CountDownLatch(1);
+ private final CountDownLatch releaseHeartbeat = new CountDownLatch(1);
+
+ BlockingHeartbeatStore() {
+ super(new RandomIdGenerator());
+ }
+
+ @Override
+ public boolean heartbeat(long id, long lastHeartbeat, long now) {
+ heartbeatStarted.countDown();
+ try {
+ Assertions.assertTrue(releaseHeartbeat.await(5, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ return true;
+ }
+ }
+}