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 76fe5d3b91 [#11265] feat(iceberg-rest): add async cleanup persistence
layer (#11266)
76fe5d3b91 is described below
commit 76fe5d3b91b3683415982b49e02a52a92ba95515
Author: roryqi <[email protected]>
AuthorDate: Fri May 29 21:35:12 2026 +0800
[#11265] feat(iceberg-rest): add async cleanup persistence layer (#11266)
### What changes were proposed in this pull request?
First of three stacked PRs for async Iceberg hard deletion - the
**persistence foundation only**:
- New IcebergConfig keys for the async-cleanup worker/retry knobs.
- iceberg_cleanup_job table schema + 1.2.0->1.3.0 migrations for
H2/MySQL/PostgreSQL.
- IcebergCleanupJob value object with a nested State enum.
- IcebergCleanupJobStore - add jobs, take runnable jobs with heartbeat
CAS, mark progress, and delete old finished jobs - with its MyBatis
mapper/PO/SQL provider.
- core test-jar (testArtifacts) wiring so the store tests can reuse the
relational entity-store backend.
Nothing is wired into the REST drop flow yet; that comes in the
follow-up PRs (manager engine, then REST integration).
### Why are the changes needed?
Inline cleanup of a large Iceberg table blocks the drop request and
risks client timeouts, and a crash mid-deletion leaves no record to
resume. A durable, persisted job store is the foundation for an opt-in
async cleanup. Splitting it out keeps each PR reviewable.
Fix: #11265
### Does this PR introduce _any_ user-facing change?
Adds new Iceberg REST config property keys (opt-in, unused until later
PRs) and a new iceberg_cleanup_job table in the 1.3.0 schema/upgrade
scripts. No API or default-behavior change.
### How was this patch tested?
New unit tests TestIcebergCleanupJob and TestIcebergCleanupJobStore (the
latter runs the relational backend matrix via
AbstractIcebergCleanupJobStoreBackendTest). Verified
:iceberg:iceberg-common:testClasses and
:iceberg:iceberg-rest-server:testClasses compile.
---
core/build.gradle.kts | 13 ++
.../gravitino/iceberg/common/IcebergConfig.java | 56 ++++++
.../iceberg/common/TestIcebergConfig.java | 12 ++
iceberg/iceberg-rest-server/build.gradle.kts | 1 +
.../iceberg/service/cleanup/IcebergCleanupJob.java | 100 ++++++++++
.../service/cleanup/IcebergCleanupJobStore.java | 209 +++++++++++++++++++++
.../cleanup/mapper/IcebergCleanupJobMapper.java | 84 +++++++++
.../IcebergCleanupJobSQLProviderFactory.java | 110 +++++++++++
.../IcebergCleanupMapperPackageProvider.java | 38 ++++
.../base/IcebergCleanupJobBaseSQLProvider.java | 185 ++++++++++++++++++
.../service/cleanup/po/IcebergCleanupJobPO.java | 122 ++++++++++++
...elational.mapper.provider.MapperPackageProvider | 19 ++
.../AbstractIcebergCleanupJobStoreBackendTest.java | 146 ++++++++++++++
.../service/cleanup/TestIcebergCleanupJob.java | 53 ++++++
.../integration/test/util/TestDatabaseName.java | 2 +
scripts/h2/schema-1.3.0-h2.sql | 19 ++
scripts/h2/upgrade-1.2.0-to-1.3.0-h2.sql | 19 ++
scripts/mysql/schema-1.3.0-mysql.sql | 19 ++
scripts/mysql/upgrade-1.2.0-to-1.3.0-mysql.sql | 19 ++
scripts/postgresql/schema-1.3.0-postgresql.sql | 32 ++++
.../upgrade-1.2.0-to-1.3.0-postgresql.sql | 32 ++++
21 files changed, 1290 insertions(+)
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index e92a7f52ab..a8511341f0 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -90,6 +90,19 @@ dependencies {
jcstressImplementation(libs.aspectj.aspectjrt)
}
+val testJar by tasks.registering(Jar::class) {
+ archiveClassifier.set("tests")
+ from(sourceSets["test"].output)
+}
+
+configurations {
+ create("testArtifacts")
+}
+
+artifacts {
+ add("testArtifacts", testJar)
+}
+
tasks.test {
val testMode = project.properties["testMode"] as? String ?: "embedded"
if (testMode == "embedded") {
diff --git
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/IcebergConfig.java
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/IcebergConfig.java
index f5ee8dd512..aeba5f01a8 100644
---
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/IcebergConfig.java
+++
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/IcebergConfig.java
@@ -319,6 +319,62 @@ public class IcebergConfig extends Config implements
OverwriteDefaultConfig {
.checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
.createWithDefault(60);
+ public static final ConfigEntry<Integer> ASYNC_CLEANUP_WORKER_THREADS =
+ new ConfigBuilder("async-cleanup.worker-threads")
+ .doc("Worker pool size per server (concurrent async cleanup jobs).")
+ .version(ConfigConstants.VERSION_1_3_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(2);
+
+ public static final ConfigEntry<Integer> ASYNC_CLEANUP_DELETE_THREADS =
+ new ConfigBuilder("async-cleanup.delete-threads")
+ .doc("Server-wide file-delete pool size, shared across all cleanup
jobs.")
+ .version(ConfigConstants.VERSION_1_3_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(4);
+
+ public static final ConfigEntry<Integer> ASYNC_CLEANUP_DELETE_BATCH_SIZE =
+ new ConfigBuilder("async-cleanup.delete-batch-size")
+ .doc("Files per bulk-delete batch handed to the delete executor.")
+ .version(ConfigConstants.VERSION_1_3_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(1000);
+
+ public static final ConfigEntry<Integer> ASYNC_CLEANUP_POLL_INTERVAL_SECS =
+ new ConfigBuilder("async-cleanup.poll-interval-secs")
+ .doc("Worker poll interval in seconds; also the retry interval.")
+ .version(ConfigConstants.VERSION_1_3_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(5);
+
+ public static final ConfigEntry<Integer>
ASYNC_CLEANUP_HEARTBEAT_TIMEOUT_SECS =
+ new ConfigBuilder("async-cleanup.heartbeat-timeout-secs")
+ .doc("Age in seconds after which a stale-heartbeat job can be taken
over by a worker.")
+ .version(ConfigConstants.VERSION_1_3_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(300);
+
+ public static final ConfigEntry<Integer> ASYNC_CLEANUP_MAX_ATTEMPTS =
+ new ConfigBuilder("async-cleanup.max-attempts")
+ .doc("Number of attempts before a cleanup job is marked FAILED.")
+ .version(ConfigConstants.VERSION_1_3_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(5);
+
+ public static final ConfigEntry<Integer> ASYNC_CLEANUP_RETENTION_HOURS =
+ new ConfigBuilder("async-cleanup.retention-hours")
+ .doc("How long finished (SUCCEEDED/FAILED) cleanup rows are retained
before pruning.")
+ .version(ConfigConstants.VERSION_1_3_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(720);
+
public String getJdbcDriver() {
return get(JDBC_DRIVER);
}
diff --git
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestIcebergConfig.java
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestIcebergConfig.java
index 1bd47fcb16..ad8faaa236 100644
---
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestIcebergConfig.java
+++
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestIcebergConfig.java
@@ -87,4 +87,16 @@ public class TestIcebergConfig {
Assertions.assertFalse(
icebergConfigWithNewKey.get(IcebergConfig.ICEBERG_REST_DISABLE_REST_AUTHZ));
}
+
+ @Test
+ public void testAsyncCleanupDefaults() {
+ IcebergConfig config = new IcebergConfig(ImmutableMap.of());
+ Assertions.assertEquals(2,
config.get(IcebergConfig.ASYNC_CLEANUP_WORKER_THREADS));
+ Assertions.assertEquals(4,
config.get(IcebergConfig.ASYNC_CLEANUP_DELETE_THREADS));
+ Assertions.assertEquals(1000,
config.get(IcebergConfig.ASYNC_CLEANUP_DELETE_BATCH_SIZE));
+ Assertions.assertEquals(5,
config.get(IcebergConfig.ASYNC_CLEANUP_POLL_INTERVAL_SECS));
+ Assertions.assertEquals(300,
config.get(IcebergConfig.ASYNC_CLEANUP_HEARTBEAT_TIMEOUT_SECS));
+ Assertions.assertEquals(5,
config.get(IcebergConfig.ASYNC_CLEANUP_MAX_ATTEMPTS));
+ Assertions.assertEquals(720,
config.get(IcebergConfig.ASYNC_CLEANUP_RETENTION_HOURS));
+ }
}
diff --git a/iceberg/iceberg-rest-server/build.gradle.kts
b/iceberg/iceberg-rest-server/build.gradle.kts
index 60b0980987..59485de5ca 100644
--- a/iceberg/iceberg-rest-server/build.gradle.kts
+++ b/iceberg/iceberg-rest-server/build.gradle.kts
@@ -82,6 +82,7 @@ dependencies {
testImplementation(project(":bundles:iceberg-aws-bundle"))
testImplementation(project(":bundles:iceberg-gcp-bundle"))
testImplementation(project(":bundles:iceberg-azure-bundle"))
+ testImplementation(project(":core", "testArtifacts"))
testImplementation(project(":integration-test-common", "testArtifacts"))
testImplementation(project(":server"))
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupJob.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupJob.java
new file mode 100644
index 0000000000..5828d3497b
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupJob.java
@@ -0,0 +1,100 @@
+/*
+ * 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.Map;
+import lombok.Getter;
+import lombok.experimental.Accessors;
+
+/**
+ * Immutable description of a table to cleanup. Carries exactly the fields the
request path supplies
+ * at enqueue and the worker reads back to delete files. Mutable progress
(state, attempts,
+ * heartbeat) lives only in the {@code iceberg_cleanup_job} row, managed by
{@link
+ * IcebergCleanupJobStore}; the {@link State} enum here names those row states.
+ */
+@Getter
+@Accessors(fluent = true)
+public class IcebergCleanupJob {
+
+ /** Lifecycle states of an {@code iceberg_cleanup_job} row. */
+ public enum State {
+ /** Awaiting a worker; also the state a transiently-failed job returns to.
*/
+ PENDING,
+
+ /** Taken by a worker that is actively deleting files. */
+ RUNNING,
+
+ /** Every reachable file was deleted or already gone. */
+ SUCCEEDED,
+
+ /** Retries exhausted or a non-retryable failure; some files may remain
undeleted. */
+ FAILED;
+
+ /**
+ * Whether the job has finished, i.e. reached a final state that a worker
no longer acts on.
+ *
+ * @return {@code true} for {@link #SUCCEEDED} and {@link #FAILED}
+ */
+ public boolean isFinished() {
+ return this == SUCCEEDED || this == FAILED;
+ }
+ }
+
+ private final long id;
+ private final long catalogId;
+ private final String namespace;
+ private final String tableName;
+ private final String metadataLocation;
+ private final String fileIOImpl;
+ private final Map<String, String> fileIOProperties;
+ private final String createdBy;
+
+ /**
+ * Creates a cleanup job description.
+ *
+ * @param id row id, or {@code 0} before persistence
+ * @param catalogId globally unique id of the owning catalog; stable across
catalog rename
+ * @param namespace table namespace (dotted)
+ * @param tableName table name
+ * @param metadataLocation the dropped table's {@code metadata.json} location
+ * @param fileIOImpl FileIO implementation class to reconstruct in the worker
+ * @param fileIOProperties properties to reconstruct the FileIO, snapshotted
at enqueue
+ * @param createdBy principal that requested the drop
+ */
+ public IcebergCleanupJob(
+ long id,
+ long catalogId,
+ String namespace,
+ String tableName,
+ String metadataLocation,
+ String fileIOImpl,
+ Map<String, String> fileIOProperties,
+ String createdBy) {
+ this.id = id;
+ this.catalogId = catalogId;
+ this.namespace = namespace;
+ this.tableName = tableName;
+ this.metadataLocation = metadataLocation;
+ this.fileIOImpl = fileIOImpl;
+ this.fileIOProperties = ImmutableMap.copyOf(fileIOProperties);
+ this.createdBy = createdBy;
+ }
+}
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
new file mode 100644
index 0000000000..2bd3cd10a7
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupJobStore.java
@@ -0,0 +1,209 @@
+/*
+ * 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.annotations.VisibleForTesting;
+import java.util.List;
+import java.util.Optional;
+import
org.apache.gravitino.iceberg.service.cleanup.mapper.IcebergCleanupJobMapper;
+import org.apache.gravitino.iceberg.service.cleanup.po.IcebergCleanupJobPO;
+import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+
+/**
+ * Persistence for {@code iceberg_cleanup_job}, layered on the Gravitino
entity store's shared
+ * relational backend. Async cleanup reuses the entity store's connection
pool, transaction
+ * management, and per-backend SQL dispatch instead of opening its own JDBC
connections. Row ids and
+ * timestamps are supplied by the application, keeping the SQL portable across
H2, MySQL, and
+ * PostgreSQL.
+ */
+public class IcebergCleanupJobStore {
+
+ private static final int MAX_ERROR_LENGTH = 2048;
+
+ private final IdGenerator idGenerator;
+
+ /**
+ * Creates a cleanup job store.
+ *
+ * @param idGenerator generator for new row ids
+ */
+ public IcebergCleanupJobStore(IdGenerator idGenerator) {
+ this.idGenerator = idGenerator;
+ }
+
+ /**
+ * Persists a new PENDING job.
+ *
+ * @param job job to persist
+ * @return generated id
+ */
+ public long addJob(IcebergCleanupJob job) {
+ long id = idGenerator.nextId();
+ long now = System.currentTimeMillis();
+ IcebergCleanupJobPO po = IcebergCleanupJobPO.fromCleanupJob(job, id, now);
+ SessionUtils.doWithCommit(IcebergCleanupJobMapper.class, mapper ->
mapper.insertCleanupJob(po));
+ return id;
+ }
+
+ /**
+ * Scans a small candidate window and takes the first available row via
compare-and-swap.
+ *
+ * @param now current epoch millis, written as the initial heartbeat
+ * @param heartbeatTimeoutMs age past which a RUNNING heartbeat is stale
+ * @param window max candidates to consider
+ * @return the taken job, or {@link Optional#empty()} if nothing was
available
+ */
+ public Optional<IcebergCleanupJob> takePendingJob(long now, long
heartbeatTimeoutMs, int window) {
+ long heartbeatExpiry = now - heartbeatTimeoutMs;
+ List<IcebergCleanupJobPO> candidates =
+ SessionUtils.getWithoutCommit(
+ IcebergCleanupJobMapper.class,
+ mapper -> mapper.selectCandidateJobs(heartbeatExpiry, window));
+ for (IcebergCleanupJobPO po : candidates) {
+ long id = po.getId();
+ int marked =
+ SessionUtils.doWithCommitAndFetchResult(
+ IcebergCleanupJobMapper.class,
+ mapper -> mapper.markRunning(id, now, heartbeatExpiry));
+ if (marked == 1) {
+ // The claim only flips mutable columns (state, heartbeat_at,
updated_at); everything
+ // toCleanupJob reads was fixed at enqueue, so the candidate snapshot
is still accurate.
+ return Optional.of(po.toCleanupJob());
+ }
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Marks a RUNNING job SUCCEEDED.
+ *
+ * @param id job id
+ * @return {@code true} iff the row was still RUNNING and was updated (i.e.
the caller still owned
+ * the job)
+ */
+ public boolean markSucceeded(long id) {
+ long now = System.currentTimeMillis();
+ return SessionUtils.doWithCommitAndFetchResult(
+ IcebergCleanupJobMapper.class,
+ mapper -> mapper.markFinished(id,
IcebergCleanupJob.State.SUCCEEDED.name(), null, now))
+ > 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.
+ *
+ * @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)
+ */
+ public boolean recordFailure(long id, String reason, int maxAttempts) {
+ long now = System.currentTimeMillis();
+ String err = truncate(reason);
+ return SessionUtils.doWithCommitAndFetchResult(
+ IcebergCleanupJobMapper.class,
+ mapper -> mapper.recordFailure(id, err, maxAttempts, now))
+ > 0;
+ }
+
+ /**
+ * Refreshes a heartbeat with compare-and-swap ownership check.
+ *
+ * @param id job id
+ * @param lastHeartbeat previous heartbeat value
+ * @param now new heartbeat value
+ * @return {@code true} iff the row was still owned by the caller
+ */
+ public boolean heartbeat(long id, long lastHeartbeat, long now) {
+ return SessionUtils.doWithCommitAndFetchResult(
+ IcebergCleanupJobMapper.class, mapper -> mapper.heartbeat(id,
lastHeartbeat, now))
+ > 0;
+ }
+
+ /**
+ * Finds the id of an unfinished (PENDING or RUNNING) cleanup job for the
identifier, if any.
+ *
+ * @param catalogId globally unique id of the owning catalog
+ * @param namespace table namespace
+ * @param table table name
+ * @return the unfinished job id, or {@link Optional#empty()} if none exists
+ */
+ public Optional<Long> findUnfinishedJobId(long catalogId, String namespace,
String table) {
+ return Optional.ofNullable(
+ SessionUtils.getWithoutCommit(
+ IcebergCleanupJobMapper.class,
+ mapper -> mapper.selectUnfinishedJobId(catalogId, namespace,
table)));
+ }
+
+ /**
+ * Deletes finished (SUCCEEDED or FAILED) jobs whose last update predates
the timeline.
+ *
+ * @param legacyTimeline cutoff epoch millis; rows updated before this are
removed
+ * @return rows deleted
+ */
+ public int deleteFinishedJobsByLegacyTimeline(long legacyTimeline) {
+ return SessionUtils.doWithCommitAndFetchResult(
+ IcebergCleanupJobMapper.class,
+ mapper -> mapper.deleteFinishedJobsByLegacyTimeline(legacyTimeline));
+ }
+
+ /**
+ * Reads a job state for tests.
+ *
+ * @param id job id
+ * @return its current state
+ * @throws IllegalStateException if the row is gone
+ */
+ @VisibleForTesting
+ IcebergCleanupJob.State stateOf(long id) {
+ String state =
+ SessionUtils.getWithoutCommit(
+ IcebergCleanupJobMapper.class, mapper -> mapper.selectState(id));
+ if (state == null) {
+ throw new IllegalStateException("No cleanup job " + id);
+ }
+ return IcebergCleanupJob.State.valueOf(state);
+ }
+
+ private static String truncate(String value) {
+ return value == null || value.length() <= MAX_ERROR_LENGTH
+ ? value
+ : value.substring(0, MAX_ERROR_LENGTH);
+ }
+}
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
new file mode 100644
index 0000000000..12c981fb2d
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/IcebergCleanupJobMapper.java
@@ -0,0 +1,84 @@
+/*
+ * 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.mapper;
+
+import java.util.List;
+import org.apache.gravitino.iceberg.service.cleanup.po.IcebergCleanupJobPO;
+import org.apache.ibatis.annotations.DeleteProvider;
+import org.apache.ibatis.annotations.InsertProvider;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.SelectProvider;
+import org.apache.ibatis.annotations.UpdateProvider;
+
+/**
+ * MyBatis mapper for the {@code iceberg_cleanup_job} table. SQL is supplied
per backend by {@link
+ * IcebergCleanupJobSQLProviderFactory} and executed through the Gravitino
entity store's shared
+ * {@code SqlSessionFactory}, so async cleanup reuses the relational backend's
connection pool and
+ * multi-backend handling instead of opening its own JDBC connections.
+ */
+public interface IcebergCleanupJobMapper {
+
+ String TABLE_NAME = "iceberg_cleanup_job";
+
+ @InsertProvider(type = IcebergCleanupJobSQLProviderFactory.class, method =
"insertCleanupJob")
+ void insertCleanupJob(@Param("po") IcebergCleanupJobPO po);
+
+ @SelectProvider(type = IcebergCleanupJobSQLProviderFactory.class, method =
"selectCandidateJobs")
+ List<IcebergCleanupJobPO> selectCandidateJobs(
+ @Param("heartbeatExpiry") long heartbeatExpiry, @Param("window") int
window);
+
+ @UpdateProvider(type = IcebergCleanupJobSQLProviderFactory.class, method =
"markRunning")
+ int markRunning(
+ @Param("id") long id, @Param("now") long now, @Param("heartbeatExpiry")
long heartbeatExpiry);
+
+ @UpdateProvider(type = IcebergCleanupJobSQLProviderFactory.class, method =
"markFinished")
+ int markFinished(
+ @Param("id") long id,
+ @Param("state") String state,
+ @Param("reason") String reason,
+ @Param("now") long now);
+
+ @UpdateProvider(type = IcebergCleanupJobSQLProviderFactory.class, method =
"recordFailure")
+ int recordFailure(
+ @Param("id") long id,
+ @Param("reason") String reason,
+ @Param("maxAttempts") int maxAttempts,
+ @Param("now") long now);
+
+ @UpdateProvider(type = IcebergCleanupJobSQLProviderFactory.class, method =
"heartbeat")
+ int heartbeat(
+ @Param("id") long id, @Param("lastHeartbeat") long lastHeartbeat,
@Param("now") long now);
+
+ @SelectProvider(
+ type = IcebergCleanupJobSQLProviderFactory.class,
+ method = "selectUnfinishedJobId")
+ Long selectUnfinishedJobId(
+ @Param("catalogId") long catalogId,
+ @Param("namespace") String namespace,
+ @Param("table") String table);
+
+ @DeleteProvider(
+ type = IcebergCleanupJobSQLProviderFactory.class,
+ method = "deleteFinishedJobsByLegacyTimeline")
+ int deleteFinishedJobsByLegacyTimeline(@Param("legacyTimeline") long
legacyTimeline);
+
+ @SelectProvider(type = IcebergCleanupJobSQLProviderFactory.class, method =
"selectState")
+ String selectState(@Param("id") long id);
+}
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
new file mode 100644
index 0000000000..7c1969fc3f
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/IcebergCleanupJobSQLProviderFactory.java
@@ -0,0 +1,110 @@
+/*
+ * 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.mapper;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import
org.apache.gravitino.iceberg.service.cleanup.mapper.provider.base.IcebergCleanupJobBaseSQLProvider;
+import org.apache.gravitino.iceberg.service.cleanup.po.IcebergCleanupJobPO;
+import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * Supplies the SQL for {@link IcebergCleanupJobMapper}, dispatching by the
entity store's
+ * configured JDBC backend. The statements are written with portable,
parameterized SQL (the row id
+ * and all timestamps are generated by the application, not the database), so
the {@link
+ * IcebergCleanupJobBaseSQLProvider} serves MySQL, H2, and PostgreSQL alike. A
backend that ever
+ * needs a divergent statement can be registered here by mapping its {@link
JDBCBackendType} to an
+ * {@link IcebergCleanupJobBaseSQLProvider} subclass that overrides only the
affected methods.
+ */
+public class IcebergCleanupJobSQLProviderFactory {
+
+ private static final IcebergCleanupJobBaseSQLProvider BASE_PROVIDER =
+ new IcebergCleanupJobBaseSQLProvider();
+
+ private static final Map<JDBCBackendType, IcebergCleanupJobBaseSQLProvider>
PROVIDERS =
+ ImmutableMap.of(
+ JDBCBackendType.MYSQL, BASE_PROVIDER,
+ JDBCBackendType.H2, BASE_PROVIDER,
+ JDBCBackendType.POSTGRESQL, BASE_PROVIDER);
+
+ private static IcebergCleanupJobBaseSQLProvider getProvider() {
+ String databaseId =
+ SqlSessionFactoryHelper.getInstance()
+ .getSqlSessionFactory()
+ .getConfiguration()
+ .getDatabaseId();
+ return PROVIDERS.get(JDBCBackendType.fromString(databaseId));
+ }
+
+ public static String insertCleanupJob(@Param("po") IcebergCleanupJobPO po) {
+ return getProvider().insertCleanupJob(po);
+ }
+
+ public static String selectCandidateJobs(
+ @Param("heartbeatExpiry") long heartbeatExpiry, @Param("window") int
window) {
+ return getProvider().selectCandidateJobs(heartbeatExpiry, window);
+ }
+
+ public static String markRunning(
+ @Param("id") long id,
+ @Param("now") long now,
+ @Param("heartbeatExpiry") long heartbeatExpiry) {
+ return getProvider().markRunning(id, now, heartbeatExpiry);
+ }
+
+ public static String markFinished(
+ @Param("id") long id,
+ @Param("state") String state,
+ @Param("reason") String reason,
+ @Param("now") long now) {
+ return getProvider().markFinished(id, state, reason, now);
+ }
+
+ 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);
+ }
+
+ public static String heartbeat(
+ @Param("id") long id, @Param("lastHeartbeat") long lastHeartbeat,
@Param("now") long now) {
+ return getProvider().heartbeat(id, lastHeartbeat, now);
+ }
+
+ public static String selectUnfinishedJobId(
+ @Param("catalogId") long catalogId,
+ @Param("namespace") String namespace,
+ @Param("table") String table) {
+ return getProvider().selectUnfinishedJobId(catalogId, namespace, table);
+ }
+
+ public static String deleteFinishedJobsByLegacyTimeline(
+ @Param("legacyTimeline") long legacyTimeline) {
+ return getProvider().deleteFinishedJobsByLegacyTimeline(legacyTimeline);
+ }
+
+ public static String selectState(@Param("id") long id) {
+ return getProvider().selectState(id);
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/IcebergCleanupMapperPackageProvider.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/IcebergCleanupMapperPackageProvider.java
new file mode 100644
index 0000000000..cdfd225129
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/IcebergCleanupMapperPackageProvider.java
@@ -0,0 +1,38 @@
+/*
+ * 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.mapper.provider;
+
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import
org.apache.gravitino.iceberg.service.cleanup.mapper.IcebergCleanupJobMapper;
+import
org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider;
+
+/**
+ * Registers the Iceberg async-cleanup mapper into the Gravitino entity
store's MyBatis
+ * configuration via {@link java.util.ServiceLoader}, so the cleanup job store
reuses the shared
+ * relational backend rather than opening its own JDBC connections.
+ */
+public class IcebergCleanupMapperPackageProvider implements
MapperPackageProvider {
+
+ @Override
+ public List<Class<?>> getMapperClasses() {
+ return ImmutableList.of(IcebergCleanupJobMapper.class);
+ }
+}
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
new file mode 100644
index 0000000000..0de925bdf6
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/base/IcebergCleanupJobBaseSQLProvider.java
@@ -0,0 +1,185 @@
+/*
+ * 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.mapper.provider.base;
+
+import static
org.apache.gravitino.iceberg.service.cleanup.mapper.IcebergCleanupJobMapper.TABLE_NAME;
+
+import org.apache.gravitino.iceberg.service.cleanup.po.IcebergCleanupJobPO;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * Portable SQL for {@code iceberg_cleanup_job}, shared by all supported JDBC
backends. The
+ * statements use parameterized SQL with the row id and all timestamps
generated by the application
+ * (not the database), so this base provider serves MySQL, H2, and PostgreSQL
alike. A backend that
+ * ever needs a divergent statement can subclass this and override only the
affected methods.
+ */
+public class IcebergCleanupJobBaseSQLProvider {
+
+ /**
+ * @param po the job row to insert
+ * @return the INSERT statement
+ */
+ public String insertCleanupJob(@Param("po") IcebergCleanupJobPO po) {
+ return "INSERT INTO "
+ + TABLE_NAME
+ + " (id, catalog_id, namespace, table_name, metadata_location,"
+ + " file_io_impl, file_io_props, state, attempts, last_error,
heartbeat_at, created_by,"
+ + " updated_at) VALUES (#{po.id}, #{po.catalogId}, #{po.namespace},"
+ + " #{po.tableName}, #{po.metadataLocation}, #{po.fileIOImpl},
#{po.fileIOProps},"
+ + " #{po.state}, #{po.attempts}, #{po.lastError}, #{po.heartbeatAt},
#{po.createdBy},"
+ + " #{po.updatedAt})";
+ }
+
+ /**
+ * @param heartbeatExpiry heartbeats older than this make a RUNNING row
reclaimable
+ * @param window max candidate rows to return
+ * @return the candidate-window SELECT statement
+ */
+ public String selectCandidateJobs(
+ @Param("heartbeatExpiry") long heartbeatExpiry, @Param("window") int
window) {
+ // A candidate row is PENDING, or RUNNING whose worker has gone silent.
heartbeat_at is
+ // NOT NULL (it defaults to 0 and markRunning always writes the current
time), so the
+ // staleness test is a plain comparison with no NULL branch and no
three-valued-logic
+ // trap, which also keeps the predicate index-friendly. Full rows are
returned so a winning
+ // claimer can build the job directly: the columns the job needs are
immutable after enqueue,
+ // so the pre-claim snapshot stays accurate without a re-read.
+ return "SELECT id, catalog_id AS catalogId, namespace,"
+ + " table_name AS tableName, metadata_location AS metadataLocation,"
+ + " file_io_impl AS fileIOImpl, file_io_props AS fileIOProps, state,
attempts,"
+ + " last_error AS lastError, heartbeat_at AS heartbeatAt, created_by
AS createdBy,"
+ + " updated_at AS updatedAt FROM "
+ + TABLE_NAME
+ + " WHERE state = 'PENDING'"
+ + " OR (state = 'RUNNING' AND heartbeat_at < #{heartbeatExpiry})"
+ + " ORDER BY updated_at LIMIT #{window}";
+ }
+
+ /**
+ * @param id job id
+ * @param now claim timestamp, written as state change and initial heartbeat
+ * @param heartbeatExpiry heartbeats older than this make a RUNNING row
reclaimable
+ * @return the claim UPDATE statement
+ */
+ public String markRunning(
+ @Param("id") long id,
+ @Param("now") long now,
+ @Param("heartbeatExpiry") long heartbeatExpiry) {
+ return "UPDATE "
+ + TABLE_NAME
+ + " SET state = 'RUNNING', heartbeat_at = #{now}, updated_at = #{now}"
+ + " WHERE id = #{id} AND (state = 'PENDING'"
+ + " OR (state = 'RUNNING' AND heartbeat_at < #{heartbeatExpiry}))";
+ }
+
+ /**
+ * @param id job id
+ * @param state terminal state to set (SUCCEEDED or FAILED)
+ * @param reason failure reason, or {@code null} for success
+ * @param now state-change timestamp
+ * @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).
+ return "UPDATE "
+ + TABLE_NAME
+ + " SET state = #{state}, last_error = #{reason}, heartbeat_at = 0,"
+ + " updated_at = #{now} WHERE id = #{id} AND state = 'RUNNING'";
+ }
+
+ /**
+ * @param id job id
+ * @param reason failure reason
+ * @param maxAttempts attempt ceiling past which the job is FAILED
+ * @param now state-change timestamp
+ * @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) {
+ // 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.
+ 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'";
+ }
+
+ /**
+ * @param id job id
+ * @param lastHeartbeat previous heartbeat value the caller wrote
(compare-and-swap key)
+ * @param now new heartbeat value
+ * @return the heartbeat UPDATE statement
+ */
+ public String heartbeat(
+ @Param("id") long id, @Param("lastHeartbeat") long lastHeartbeat,
@Param("now") long now) {
+ return "UPDATE "
+ + TABLE_NAME
+ + " SET heartbeat_at = #{now}, updated_at = #{now}"
+ + " WHERE id = #{id} AND state = 'RUNNING' AND heartbeat_at =
#{lastHeartbeat}";
+ }
+
+ /**
+ * @param catalogId globally unique id of the owning catalog
+ * @param namespace table namespace
+ * @param table table name
+ * @return the unfinished-job lookup SELECT statement
+ */
+ public String selectUnfinishedJobId(
+ @Param("catalogId") long catalogId,
+ @Param("namespace") String namespace,
+ @Param("table") String table) {
+ // catalog_id is globally unique (catalog_meta's primary key), so it
identifies the catalog
+ // without scoping by metalake, and it is stable across catalog rename
unlike a name.
+ return "SELECT id FROM "
+ + TABLE_NAME
+ + " WHERE catalog_id = #{catalogId}"
+ + " AND namespace = #{namespace} AND table_name = #{table}"
+ + " AND state IN ('PENDING', 'RUNNING') LIMIT 1";
+ }
+
+ /**
+ * @param legacyTimeline cutoff; finished rows updated before this are
deleted
+ * @return the cleanup DELETE statement
+ */
+ public String deleteFinishedJobsByLegacyTimeline(@Param("legacyTimeline")
long legacyTimeline) {
+ return "DELETE FROM "
+ + TABLE_NAME
+ + " WHERE state IN ('SUCCEEDED', 'FAILED') AND updated_at <
#{legacyTimeline}";
+ }
+
+ /**
+ * @param id job id
+ * @return the state SELECT statement
+ */
+ public String selectState(@Param("id") long id) {
+ return "SELECT state FROM " + TABLE_NAME + " WHERE id = #{id}";
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/po/IcebergCleanupJobPO.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/po/IcebergCleanupJobPO.java
new file mode 100644
index 0000000000..d9faa298b9
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/po/IcebergCleanupJobPO.java
@@ -0,0 +1,122 @@
+/*
+ * 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.po;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Map;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupJob;
+import
org.apache.gravitino.iceberg.service.cleanup.mapper.IcebergCleanupJobMapper;
+import org.apache.gravitino.json.JsonUtils;
+
+/**
+ * Persistence object for one {@code iceberg_cleanup_job} row. Mirrors the
table columns and is
+ * mapped by {@link IcebergCleanupJobMapper}, and converts to and from the
{@link IcebergCleanupJob}
+ * domain object via {@link #fromCleanupJob} and {@link #toCleanupJob}.
Instances are created
+ * through the generated {@code builder()} (or populated by MyBatis via field
reflection through the
+ * private no-arg constructor) and expose no setters, so they are effectively
immutable once built.
+ */
+@Getter
+@Builder(setterPrefix = "with")
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class IcebergCleanupJobPO {
+
+ private Long id;
+ private Long catalogId;
+ private String namespace;
+ private String tableName;
+ private String metadataLocation;
+ private String fileIOImpl;
+ private String fileIOProps;
+ private String state;
+ private Integer attempts;
+ private String lastError;
+ private Long heartbeatAt;
+ private String createdBy;
+ private Long updatedAt;
+
+ /**
+ * Builds a new PENDING row from a domain job.
+ *
+ * @param job the domain job to persist
+ * @param id the generated row id
+ * @param now the enqueue timestamp (initial {@code updated_at})
+ * @return the persistence object
+ */
+ public static IcebergCleanupJobPO fromCleanupJob(IcebergCleanupJob job, long
id, long now) {
+ return builder()
+ .withId(id)
+ .withCatalogId(job.catalogId())
+ .withNamespace(job.namespace())
+ .withTableName(job.tableName())
+ .withMetadataLocation(job.metadataLocation())
+ .withFileIOImpl(job.fileIOImpl())
+ .withFileIOProps(propertiesToJson(job.fileIOProperties()))
+ .withState(IcebergCleanupJob.State.PENDING.name())
+ .withAttempts(0)
+ .withLastError(null)
+ .withHeartbeatAt(0L)
+ .withCreatedBy(job.createdBy())
+ .withUpdatedAt(now)
+ .build();
+ }
+
+ /**
+ * Converts this row to the domain job. Only the columns that are immutable
after enqueue are
+ * carried over; mutable bookkeeping columns (state, attempts, heartbeat,
etc.) live only in the
+ * row.
+ *
+ * @return the domain job
+ */
+ public IcebergCleanupJob toCleanupJob() {
+ return new IcebergCleanupJob(
+ id,
+ catalogId,
+ namespace,
+ tableName,
+ metadataLocation,
+ fileIOImpl,
+ jsonToProperties(fileIOProps),
+ createdBy);
+ }
+
+ private static String propertiesToJson(Map<String, String> props) {
+ try {
+ return JsonUtils.objectMapper().writeValueAsString(props);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to serialize fileIOProperties",
e);
+ }
+ }
+
+ private static Map<String, String> jsonToProperties(String json) {
+ try {
+ return JsonUtils.objectMapper().readValue(json, new
TypeReference<Map<String, String>>() {});
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to deserialize fileIOProperties",
e);
+ }
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/resources/META-INF/services/org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider
b/iceberg/iceberg-rest-server/src/main/resources/META-INF/services/org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider
new file mode 100644
index 0000000000..7ed1ef96c3
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/resources/META-INF/services/org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+org.apache.gravitino.iceberg.service.cleanup.mapper.provider.IcebergCleanupMapperPackageProvider
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
new file mode 100644
index 0000000000..3e58e925df
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/AbstractIcebergCleanupJobStoreBackendTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.Optional;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+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;
+
+/**
+ * Shared cleanup-store test logic exercised against the same relational
backend matrix as core
+ * metadata service tests. {@link TestJDBCBackend} initializes H2 by default,
adds MySQL and
+ * PostgreSQL when {@code dockerTest=true}, and truncates all backend tables
before each invocation.
+ */
+abstract class AbstractIcebergCleanupJobStoreBackendTest extends
TestJDBCBackend {
+
+ 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 final long CATALOG_ID = 100L;
+ private static final long OTHER_CATALOG_ID = 200L;
+
+ private static IcebergCleanupJob sampleJob() {
+ return new IcebergCleanupJob(
+ 0L,
+ CATALOG_ID,
+ "db",
+ "t",
+ "s3://b/db/t/metadata/0.json",
+ "org.apache.iceberg.aws.s3.S3FileIO",
+ ImmutableMap.of("k", "v"),
+ "alice");
+ }
+
+ @TestTemplate
+ void testAddTakeSucceedLifecycle() {
+ Assertions.assertFalse(store.findUnfinishedJobId(CATALOG_ID, "db",
"t").isPresent());
+
+ long id = store.addJob(sampleJob());
+ Assertions.assertTrue(id > 0);
+ Assertions.assertTrue(store.findUnfinishedJobId(CATALOG_ID, "db",
"t").isPresent());
+ // The same namespace/table under a different catalog must not match.
+ Assertions.assertFalse(store.findUnfinishedJobId(OTHER_CATALOG_ID, "db",
"t").isPresent());
+
+ long now = System.currentTimeMillis();
+ Optional<IcebergCleanupJob> taken = store.takePendingJob(now, 300_000L,
10);
+ Assertions.assertTrue(taken.isPresent());
+ Assertions.assertEquals(id, taken.get().id());
+ Assertions.assertEquals(ImmutableMap.of("k", "v"),
taken.get().fileIOProperties());
+ Assertions.assertEquals(IcebergCleanupJob.State.RUNNING,
store.stateOf(id));
+ Assertions.assertTrue(store.findUnfinishedJobId(CATALOG_ID, "db",
"t").isPresent());
+ Assertions.assertFalse(store.takePendingJob(now, 300_000L,
10).isPresent());
+
+ Assertions.assertTrue(store.markSucceeded(id));
+ 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.findUnfinishedJobId(CATALOG_ID, "db",
"t").isPresent());
+ Assertions.assertEquals(
+ 1, store.deleteFinishedJobsByLegacyTimeline(System.currentTimeMillis()
+ 1));
+ }
+
+ @TestTemplate
+ void testMarkFailed() {
+ long id = store.addJob(sampleJob());
+ store.takePendingJob(System.currentTimeMillis(), 300_000L, 10);
+ Assertions.assertTrue(store.markFailed(id, "corrupt metadata"));
+ Assertions.assertEquals(IcebergCleanupJob.State.FAILED, store.stateOf(id));
+ }
+
+ @TestTemplate
+ void testTransientFailureRetriesThenFailsAtCeiling() {
+ 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));
+ Assertions.assertEquals(IcebergCleanupJob.State.FAILED, store.stateOf(id));
+ }
+
+ @TestTemplate
+ void testHeartbeatCasAndStaleTakeover() {
+ long id = store.addJob(sampleJob());
+ long t0 = System.currentTimeMillis();
+ store.takePendingJob(t0, 300_000L, 10);
+ Assertions.assertTrue(store.heartbeat(id, t0, t0 + 1000));
+ Assertions.assertFalse(store.heartbeat(id, t0, t0 + 2000));
+ // A stale RUNNING job can be taken again once its heartbeat ages past the
timeout.
+ Assertions.assertEquals(
+ id, store.takePendingJob(t0 + 400_000L, 300_000L,
10).orElseThrow().id());
+ }
+}
+
+class TestIcebergCleanupJobStoreBackend extends
AbstractIcebergCleanupJobStoreBackendTest {}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/TestIcebergCleanupJob.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/TestIcebergCleanupJob.java
new file mode 100644
index 0000000000..260a3711c4
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/TestIcebergCleanupJob.java
@@ -0,0 +1,53 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestIcebergCleanupJob {
+
+ @Test
+ void testConstructorAndAccessors() {
+ IcebergCleanupJob job =
+ new IcebergCleanupJob(
+ 0L,
+ 42L,
+ "db",
+ "t",
+ "s3://b/db/t/metadata/0.json",
+ "org.apache.iceberg.aws.s3.S3FileIO",
+ ImmutableMap.of("k", "v"),
+ "alice");
+ Assertions.assertEquals(42L, job.catalogId());
+ Assertions.assertEquals("db", job.namespace());
+ Assertions.assertEquals(ImmutableMap.of("k", "v"), job.fileIOProperties());
+ Assertions.assertEquals("alice", job.createdBy());
+ }
+
+ @Test
+ void testFinishedStates() {
+ Assertions.assertTrue(IcebergCleanupJob.State.SUCCEEDED.isFinished());
+ Assertions.assertTrue(IcebergCleanupJob.State.FAILED.isFinished());
+ Assertions.assertFalse(IcebergCleanupJob.State.PENDING.isFinished());
+ Assertions.assertFalse(IcebergCleanupJob.State.RUNNING.isFinished());
+ }
+}
diff --git
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/TestDatabaseName.java
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/TestDatabaseName.java
index 1c178036e2..39b644eb65 100644
---
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/TestDatabaseName.java
+++
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/TestDatabaseName.java
@@ -119,6 +119,8 @@ public enum TestDatabaseName {
},
PG_ICEBERG_AUTHZ_IT,
+ PG_ICEBERG_ASYNC_CLEANUP_IT,
+
CLICKHOUSE_CLICKHOUSE_ABSTRACT_IT,
CLICKHOUSE_CATALOG_CLICKHOUSE_IT,
CLICKHOUSE_AUDIT_CATALOG_CLICKHOUSE_IT,
diff --git a/scripts/h2/schema-1.3.0-h2.sql b/scripts/h2/schema-1.3.0-h2.sql
index fc6f026ec3..17de756468 100644
--- a/scripts/h2/schema-1.3.0-h2.sql
+++ b/scripts/h2/schema-1.3.0-h2.sql
@@ -612,3 +612,22 @@ CREATE TABLE IF NOT EXISTS `entity_change_log` (
PRIMARY KEY (`id`),
KEY `idx_ecl_created_at` (`created_at`)
) ENGINE=InnoDB COMMENT='Append-only log of entity structural changes for
targeted metadataIdCache invalidation';
+
+CREATE TABLE IF NOT EXISTS `iceberg_cleanup_job` (
+ `id` BIGINT NOT NULL COMMENT 'globally unique cleanup
job id',
+ `catalog_id` BIGINT 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` CLOB 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` CLOB NOT NULL COMMENT 'JSON-encoded FileIO
properties',
+ `state` VARCHAR(16) NOT NULL COMMENT
'PENDING|RUNNING|SUCCEEDED|FAILED',
+ `attempts` INT 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 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 NOT NULL COMMENT 'last state change,
drives poll ordering and old finished-job cleanup',
+ PRIMARY KEY (`id`)
+) COMMENT='async Iceberg table cleanup jobs';
+CREATE INDEX IF NOT EXISTS `idx_state_updated` ON `iceberg_cleanup_job`
(`state`, `updated_at`);
+CREATE INDEX IF NOT EXISTS `idx_object` ON `iceberg_cleanup_job`
(`catalog_id`, `namespace`, `table_name`, `state`);
diff --git a/scripts/h2/upgrade-1.2.0-to-1.3.0-h2.sql
b/scripts/h2/upgrade-1.2.0-to-1.3.0-h2.sql
index 5366a16436..adc350b6d6 100644
--- a/scripts/h2/upgrade-1.2.0-to-1.3.0-h2.sql
+++ b/scripts/h2/upgrade-1.2.0-to-1.3.0-h2.sql
@@ -100,3 +100,22 @@ CREATE TABLE IF NOT EXISTS `idp_user_group_rel` (
KEY `idx_iuig_uid` (`user_id`),
KEY `idx_iuig_gid` (`group_id`)
) ENGINE=InnoDB;
+
+CREATE TABLE IF NOT EXISTS `iceberg_cleanup_job` (
+ `id` BIGINT NOT NULL COMMENT 'globally unique cleanup
job id',
+ `catalog_id` BIGINT 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` CLOB 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` CLOB NOT NULL COMMENT 'JSON-encoded FileIO
properties',
+ `state` VARCHAR(16) NOT NULL COMMENT
'PENDING|RUNNING|SUCCEEDED|FAILED',
+ `attempts` INT 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 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 NOT NULL COMMENT 'last state change,
drives poll ordering and old finished-job cleanup',
+ PRIMARY KEY (`id`)
+) COMMENT='async Iceberg table cleanup jobs';
+CREATE INDEX IF NOT EXISTS `idx_state_updated` ON `iceberg_cleanup_job`
(`state`, `updated_at`);
+CREATE INDEX IF NOT EXISTS `idx_object` ON `iceberg_cleanup_job`
(`catalog_id`, `namespace`, `table_name`, `state`);
diff --git a/scripts/mysql/schema-1.3.0-mysql.sql
b/scripts/mysql/schema-1.3.0-mysql.sql
index 7ed2d03e17..5959b1886d 100644
--- a/scripts/mysql/schema-1.3.0-mysql.sql
+++ b/scripts/mysql/schema-1.3.0-mysql.sql
@@ -599,3 +599,22 @@ CREATE TABLE IF NOT EXISTS `entity_change_log` (
PRIMARY KEY (`id`),
KEY `idx_ecl_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT
'Append-only log of entity structural changes for targeted metadataIdCache
invalidation';
+
+CREATE TABLE IF NOT EXISTS `iceberg_cleanup_job` (
+ `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 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 old finished-job cleanup',
+ PRIMARY KEY (`id`),
+ KEY `idx_state_updated` (`state`, `updated_at`),
+ 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';
diff --git a/scripts/mysql/upgrade-1.2.0-to-1.3.0-mysql.sql
b/scripts/mysql/upgrade-1.2.0-to-1.3.0-mysql.sql
index d31b6314b0..4c4ef09ce5 100644
--- a/scripts/mysql/upgrade-1.2.0-to-1.3.0-mysql.sql
+++ b/scripts/mysql/upgrade-1.2.0-to-1.3.0-mysql.sql
@@ -118,3 +118,22 @@ CREATE TABLE IF NOT EXISTS `idp_user_group_rel` (
KEY `idx_iuig_uid` (`user_id`),
KEY `idx_iuig_gid` (`group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'local IdP
user group relation';
+
+CREATE TABLE IF NOT EXISTS `iceberg_cleanup_job` (
+ `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 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 old finished-job cleanup',
+ PRIMARY KEY (`id`),
+ KEY `idx_state_updated` (`state`, `updated_at`),
+ 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';
diff --git a/scripts/postgresql/schema-1.3.0-postgresql.sql
b/scripts/postgresql/schema-1.3.0-postgresql.sql
index b922b369da..8efd931d94 100644
--- a/scripts/postgresql/schema-1.3.0-postgresql.sql
+++ b/scripts/postgresql/schema-1.3.0-postgresql.sql
@@ -1054,3 +1054,35 @@ COMMENT ON COLUMN entity_change_log.entity_type IS
'METALAKE | CATALOG | SCHEMA
COMMENT ON COLUMN entity_change_log.entity_full_name IS 'Dot-separated full
name of the affected entity. For ALTER, stores the old name. For DROP, stores
the entity name.';
COMMENT ON COLUMN entity_change_log.operate_type IS 'Operate type code:
1=ALTER, 2=DROP, 3=INSERT. Codes are stable and never re-used.';
COMMENT ON COLUMN entity_change_log.created_at IS 'timestamp of the change in
millis';
+
+CREATE TABLE IF NOT EXISTS iceberg_cleanup_job (
+ id BIGINT NOT NULL PRIMARY KEY,
+ catalog_id BIGINT NOT NULL,
+ namespace VARCHAR(512) NOT NULL,
+ table_name VARCHAR(256) NOT NULL,
+ metadata_location TEXT NOT NULL,
+ file_io_impl VARCHAR(256) NOT NULL,
+ file_io_props TEXT NOT NULL,
+ state VARCHAR(16) NOT NULL,
+ attempts INT NOT NULL DEFAULT 0,
+ last_error VARCHAR(2048),
+ heartbeat_at BIGINT NOT NULL DEFAULT 0,
+ created_by VARCHAR(128) NOT NULL,
+ updated_at BIGINT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_state_updated ON iceberg_cleanup_job (state,
updated_at);
+CREATE INDEX IF NOT EXISTS idx_object ON iceberg_cleanup_job (catalog_id,
namespace, table_name, state);
+COMMENT ON TABLE iceberg_cleanup_job IS 'async Iceberg table cleanup jobs';
+COMMENT ON COLUMN iceberg_cleanup_job.id IS 'globally unique cleanup job id';
+COMMENT ON COLUMN iceberg_cleanup_job.catalog_id IS 'globally unique id of the
owning catalog, stable across catalog rename';
+COMMENT ON COLUMN iceberg_cleanup_job.namespace IS 'namespace of the table to
be cleaned up';
+COMMENT ON COLUMN iceberg_cleanup_job.table_name IS 'name of the table to be
cleaned up';
+COMMENT ON COLUMN iceberg_cleanup_job.metadata_location IS 'location of the
table metadata file to purge';
+COMMENT ON COLUMN iceberg_cleanup_job.file_io_impl IS 'FileIO implementation
class used to access the table files';
+COMMENT ON COLUMN iceberg_cleanup_job.file_io_props IS 'JSON-encoded FileIO
properties';
+COMMENT ON COLUMN iceberg_cleanup_job.state IS 'PENDING | RUNNING | SUCCEEDED
| FAILED';
+COMMENT ON COLUMN iceberg_cleanup_job.attempts IS 'number of processing
attempts made so far';
+COMMENT ON COLUMN iceberg_cleanup_job.last_error IS 'truncated reason for the
most recent failure, NULL until a job fails';
+COMMENT ON COLUMN iceberg_cleanup_job.heartbeat_at IS 'last heartbeat from the
worker, 0 when not running';
+COMMENT ON COLUMN iceberg_cleanup_job.created_by IS 'principal that requested
the drop (audit)';
+COMMENT ON COLUMN iceberg_cleanup_job.updated_at IS 'last state change, drives
poll ordering and old finished-job cleanup';
diff --git a/scripts/postgresql/upgrade-1.2.0-to-1.3.0-postgresql.sql
b/scripts/postgresql/upgrade-1.2.0-to-1.3.0-postgresql.sql
index bf3071567d..03c362cc88 100644
--- a/scripts/postgresql/upgrade-1.2.0-to-1.3.0-postgresql.sql
+++ b/scripts/postgresql/upgrade-1.2.0-to-1.3.0-postgresql.sql
@@ -151,3 +151,35 @@ COMMENT ON COLUMN idp_user_group_rel.group_id IS 'idp
group id';
COMMENT ON COLUMN idp_user_group_rel.current_version IS 'idp relation current
version';
COMMENT ON COLUMN idp_user_group_rel.last_version IS 'idp relation last
version';
COMMENT ON COLUMN idp_user_group_rel.deleted_at IS 'idp relation deleted at';
+
+CREATE TABLE IF NOT EXISTS iceberg_cleanup_job (
+ id BIGINT NOT NULL PRIMARY KEY,
+ catalog_id BIGINT NOT NULL,
+ namespace VARCHAR(512) NOT NULL,
+ table_name VARCHAR(256) NOT NULL,
+ metadata_location TEXT NOT NULL,
+ file_io_impl VARCHAR(256) NOT NULL,
+ file_io_props TEXT NOT NULL,
+ state VARCHAR(16) NOT NULL,
+ attempts INT NOT NULL DEFAULT 0,
+ last_error VARCHAR(2048),
+ heartbeat_at BIGINT NOT NULL DEFAULT 0,
+ created_by VARCHAR(128) NOT NULL,
+ updated_at BIGINT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_state_updated ON iceberg_cleanup_job (state,
updated_at);
+CREATE INDEX IF NOT EXISTS idx_object ON iceberg_cleanup_job (catalog_id,
namespace, table_name, state);
+COMMENT ON TABLE iceberg_cleanup_job IS 'async Iceberg table cleanup jobs';
+COMMENT ON COLUMN iceberg_cleanup_job.id IS 'globally unique cleanup job id';
+COMMENT ON COLUMN iceberg_cleanup_job.catalog_id IS 'globally unique id of the
owning catalog, stable across catalog rename';
+COMMENT ON COLUMN iceberg_cleanup_job.namespace IS 'namespace of the table to
be cleaned up';
+COMMENT ON COLUMN iceberg_cleanup_job.table_name IS 'name of the table to be
cleaned up';
+COMMENT ON COLUMN iceberg_cleanup_job.metadata_location IS 'location of the
table metadata file to purge';
+COMMENT ON COLUMN iceberg_cleanup_job.file_io_impl IS 'FileIO implementation
class used to access the table files';
+COMMENT ON COLUMN iceberg_cleanup_job.file_io_props IS 'JSON-encoded FileIO
properties';
+COMMENT ON COLUMN iceberg_cleanup_job.state IS 'PENDING | RUNNING | SUCCEEDED
| FAILED';
+COMMENT ON COLUMN iceberg_cleanup_job.attempts IS 'number of processing
attempts made so far';
+COMMENT ON COLUMN iceberg_cleanup_job.last_error IS 'truncated reason for the
most recent failure, NULL until a job fails';
+COMMENT ON COLUMN iceberg_cleanup_job.heartbeat_at IS 'last heartbeat from the
worker, 0 when not running';
+COMMENT ON COLUMN iceberg_cleanup_job.created_by IS 'principal that requested
the drop (audit)';
+COMMENT ON COLUMN iceberg_cleanup_job.updated_at IS 'last state change, drives
poll ordering and old finished-job cleanup';