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 2adfe2321b [#11363] feat(iceberg-rest): wire async cleanup into the
REST drop flow (#11364)
2adfe2321b is described below
commit 2adfe2321b388a4c110cfb71ea6f799265425480
Author: roryqi <[email protected]>
AuthorDate: Wed Jun 3 15:08:15 2026 +0800
[#11363] feat(iceberg-rest): wire async cleanup into the REST drop flow
(#11364)
### What changes were proposed in this pull request?
Final PR (3 of 3) of async hard deletion — wires the cleanup engine
(#11298) into the REST path:
- `IcebergTableOperationExecutor.dropTable` branches on the
`X-Gravitino-Async-Purge` header: synchronous purge by default; when
`true` in auxiliary mode it snapshots the table metadata location, drops
the catalog entry, and enqueues an `IcebergCleanupJob` (keyed by catalog
id) instead of deleting files inline.
- Name-reuse tombstone: `createTable`/`registerTable` return `409` while
an active cleanup job occupies the identifier (best-effort — skipped
when no Gravitino catalog entity backs the name).
- Adds
`IcebergCatalogWrapper.loadTableMetadata/fileIOImpl/fileIOProperties`,
`IcebergRequestContext.asyncPurge()`, and `IcebergCleanupManager`
lifecycle wiring in `RESTService`.
- Documents the async semantics, the `X-Gravitino-Async-Purge` header,
and the `async-cleanup.*` config.
### Why are the changes needed?
The async cleanup engine (#11298) is unused until the REST drop path
opts requests into it.
Fix: #11363
### Does this PR introduce _any_ user-facing change?
- New opt-in request header `X-Gravitino-Async-Purge: true` on `DELETE
...?purgeRequested=true`.
- New `gravitino.iceberg-rest.async-cleanup.*` configuration keys
(documented).
- `createTable`/`registerTable` return `409 Conflict` while a cleanup
job is active for the identifier.
### How was this patch tested?
- Unit: `TestIcebergTableOperationExecutorAsyncPurge`,
`TestIcebergPurgeTombstone`, `TestIcebergRequestContext`; full
`:iceberg:iceberg-rest-server` unit suite green.
- Integration: `IcebergRESTAsyncPurgeIT` (Gravitino server + Iceberg
REST aux service, PostgreSQL-backed catalog via the dynamic provider) —
verifies the async drop enqueues a cleanup job, the `409` tombstone
blocks recreate while in flight, and a background worker deletes the
table's files.
---
docs/iceberg-rest-service.md | 20 ++
.../iceberg/common/ops/IcebergCatalogWrapper.java | 32 +++
.../org/apache/gravitino/iceberg/RESTService.java | 31 ++-
.../service/cleanup/IcebergCleanupJobStore.java | 27 +++
.../IcebergCleanupMapperPackageProvider.java | 12 +-
.../service/dispatcher/IcebergCleanupHelper.java | 79 +++++++
.../IcebergNamespaceOperationExecutor.java | 12 +-
.../dispatcher/IcebergTableOperationExecutor.java | 57 ++++-
.../listener/api/event/IcebergRequestContext.java | 21 ++
...elational.mapper.provider.MapperPackageProvider | 19 --
.../integration/test/IcebergRESTAsyncPurgeIT.java | 263 +++++++++++++++++++++
.../service/dispatcher/TestIcebergAsyncPurge.java | 260 ++++++++++++++++++++
.../TestIcebergNamespaceOperationExecutor.java | 3 +-
.../TestIcebergTableOperationExecutor.java | 3 +-
.../iceberg/service/rest/IcebergRestTestUtil.java | 8 +-
.../api/event/TestIcebergRequestContext.java | 89 +++++++
16 files changed, 896 insertions(+), 40 deletions(-)
diff --git a/docs/iceberg-rest-service.md b/docs/iceberg-rest-service.md
index d25832d6cb..837256389b 100644
--- a/docs/iceberg-rest-service.md
+++ b/docs/iceberg-rest-service.md
@@ -91,6 +91,26 @@ Please note that, it only takes affect in `gravitino.conf`,
you don't need to sp
The filter in `customFilters` should be a standard javax servlet filter.
You can also specify filter parameters by setting configuration entries in the
style `gravitino.iceberg-rest.<class name of filter>.param.<param
name>=<value>`.
+### Asynchronous table purge
+
+By default, dropping a table with `purgeRequested=true` is synchronous: the
catalog entry and the table files are removed before the `DELETE` returns.
+
+When the Iceberg REST service runs inside Gravitino (as an auxiliary service),
a client can instead request asynchronous purge by adding the header
`X-Gravitino-Async-Purge: true` to `DELETE ...?purgeRequested=true`. The drop
then returns `204 No Content` once the table is removed from the catalog, and
the files are deleted in the background. The table is gone from `LIST`
immediately, but recreating it (`createTable` / `registerTable` with the same
name) returns `409 Conflict` until the [...]
+
+The header name is case-insensitive (per the HTTP standard), but its value
must be exactly `true`. Any other value, or no header, uses the synchronous
default, so standard Iceberg clients are unaffected. Asynchronous purge is only
available in auxiliary mode; in standalone mode the header is ignored.
+
+The settings below tune the background workers and are optional.
+
+| Configuration item | Description
| Default value | Required | Since Version |
+|---------------------------------------------------------------|------------------------------------------------------------------------------------------------------|---------------|----------|---------------|
+| `gravitino.iceberg-rest.async-cleanup.worker-threads` | Worker pool
size per server. Each worker claims and runs cleanup jobs from the shared
backend table. | `2` | No | 1.3.0 |
+| `gravitino.iceberg-rest.async-cleanup.delete-threads` | Server-wide
file-delete pool size shared by cleanup jobs.
| `4` | No | 1.3.0 |
+| `gravitino.iceberg-rest.async-cleanup.delete-batch-size` | Number of
files per bulk-delete batch.
| `1000` | No | 1.3.0 |
+| `gravitino.iceberg-rest.async-cleanup.poll-interval-secs` | Worker
polling interval in seconds. This also controls retry pacing for pending jobs.
| `5` | No | 1.3.0 |
+| `gravitino.iceberg-rest.async-cleanup.heartbeat-timeout-secs` | Age in
seconds after which a running job with no fresh heartbeat can be reclaimed by
another worker. | `300` | No | 1.3.0 |
+| `gravitino.iceberg-rest.async-cleanup.max-attempts` | Number of
failed attempts before a cleanup job is marked `FAILED`.
| `5` | No | 1.3.0 |
+| `gravitino.iceberg-rest.async-cleanup.retention-hours` | Retention
time for terminal `SUCCEEDED` or `FAILED` cleanup rows before pruning.
| `720` | No | 1.3.0 |
+
### Catalog backend configuration
:::info
diff --git
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
index 987fd329f6..529c352cae 100644
---
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
+++
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
@@ -37,6 +37,7 @@ import org.apache.gravitino.utils.ClassUtils;
import org.apache.gravitino.utils.IsolatedClassLoader;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.security.UserGroupInformation;
+import org.apache.iceberg.BaseTable;
import org.apache.iceberg.TableMetadata;
import org.apache.iceberg.Transaction;
import org.apache.iceberg.catalog.Catalog;
@@ -44,6 +45,7 @@ import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.SupportsNamespaces;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.catalog.ViewCatalog;
+import org.apache.iceberg.io.ResolvingFileIO;
import org.apache.iceberg.jdbc.JdbcCatalogWithMetadataLocationSupport;
import org.apache.iceberg.rest.CatalogHandlers;
import org.apache.iceberg.rest.RESTCatalog;
@@ -240,6 +242,36 @@ public class IcebergCatalogWrapper implements
AutoCloseable {
CatalogHandlers.purgeTable(getCatalog(), tableIdentifier);
}
+ /**
+ * Loads current {@link TableMetadata}, bypassing the response cache. Used
by the async cleanup
+ * path to snapshot the metadata location before dropping the catalog entry.
+ *
+ * @param tableIdentifier the table
+ * @return its metadata
+ */
+ public TableMetadata loadTableMetadata(TableIdentifier tableIdentifier) {
+ return ((BaseTable)
getCatalog().loadTable(tableIdentifier)).operations().current();
+ }
+
+ /**
+ * Returns the FileIO implementation configured for this catalog.
+ *
+ * @return the {@code io-impl} class, or the Iceberg default when unset
+ */
+ public String fileIOImpl() {
+ String impl = icebergConfig.get(IcebergConfig.IO_IMPL);
+ return StringUtils.isNotBlank(impl) ? impl :
ResolvingFileIO.class.getName();
+ }
+
+ /**
+ * Returns catalog properties used to reconstruct FileIO in a cleanup worker.
+ *
+ * @return catalog properties snapshotted at enqueue time
+ */
+ public Map<String, String> fileIOProperties() {
+ return getIcebergConfig().getIcebergCatalogProperties();
+ }
+
public LoadTableResponse loadTable(TableIdentifier tableIdentifier) {
Optional<TableMetadata> tableMetadataOptional =
getMetadataCache().getTableMetadata(tableIdentifier);
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
index 17b80ea8bc..ea2b54fa6f 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.iceberg;
import com.google.common.collect.Lists;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import javax.inject.Singleton;
import javax.servlet.Servlet;
import org.apache.gravitino.Configs;
@@ -32,6 +33,8 @@ import
org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
import org.apache.gravitino.iceberg.service.IcebergObjectMapperProvider;
import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupJobStore;
+import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupManager;
import
org.apache.gravitino.iceberg.service.dispatcher.IcebergNamespaceEventDispatcher;
import
org.apache.gravitino.iceberg.service.dispatcher.IcebergNamespaceHookDispatcher;
import
org.apache.gravitino.iceberg.service.dispatcher.IcebergNamespaceOperationDispatcher;
@@ -76,6 +79,7 @@ public class RESTService implements GravitinoAuxiliaryService
{
private IcebergCatalogWrapperManager icebergCatalogWrapperManager;
private IcebergMetricsManager icebergMetricsManager;
+ private Optional<IcebergCleanupManager> cleanupManager;
private IcebergConfigProvider configProvider;
private boolean auxMode;
@@ -119,9 +123,25 @@ public class RESTService implements
GravitinoAuxiliaryService {
skipAuthorizationForRestBackend,
icebergCatalogWrapperManager);
this.icebergMetricsManager = new IcebergMetricsManager(icebergConfig);
+ if (auxMode) {
+ // Async cleanup reuses the entity store's shared relational backend
(connection pool +
+ // per-backend SQL), which is only available when running embedded in
the Gravitino server
+ // (auxiliary mode). In standalone mode the cleanup manager stays empty
and purge requests
+ // fall back to synchronous purge.
+ this.cleanupManager =
+ Optional.of(
+ new IcebergCleanupManager(
+ new
IcebergCleanupJobStore(GravitinoEnv.getInstance().idGenerator()),
+ icebergConfig));
+ } else {
+ this.cleanupManager = Optional.empty();
+ LOG.info(
+ "Async Iceberg table cleanup is only available in auxiliary mode; "
+ + "purge requests with async mode will fall back to synchronous
purge.");
+ }
// Table: HookDispatcher -> EventDispatcher -> OperationExecutor
IcebergTableOperationDispatcher icebergTableOperationDispatcher =
- new IcebergTableOperationExecutor(icebergCatalogWrapperManager);
+ new IcebergTableOperationExecutor(icebergCatalogWrapperManager,
cleanupManager);
IcebergTableOperationDispatcher icebergTableEventDispatcher =
new IcebergTableEventDispatcher(icebergTableOperationDispatcher,
eventBus, metalakeName);
if (authorizationContext.isAuthorizationEnabled()) {
@@ -142,7 +162,7 @@ public class RESTService implements
GravitinoAuxiliaryService {
// Namespace: HookDispatcher -> EventDispatcher -> OperationExecutor
IcebergNamespaceOperationDispatcher namespaceOperationDispatcher =
- new IcebergNamespaceOperationExecutor(icebergCatalogWrapperManager);
+ new IcebergNamespaceOperationExecutor(icebergCatalogWrapperManager,
cleanupManager);
IcebergNamespaceOperationDispatcher icebergNamespaceEventDispatcher =
new IcebergNamespaceEventDispatcher(namespaceOperationDispatcher,
eventBus, metalakeName);
if (authorizationContext.isAuthorizationEnabled()) {
@@ -163,6 +183,8 @@ public class RESTService implements
GravitinoAuxiliaryService {
}
bind(icebergCatalogWrapperManager).to(IcebergCatalogWrapperManager.class).ranked(1);
bind(icebergMetricsManager).to(IcebergMetricsManager.class).ranked(1);
+ cleanupManager.ifPresent(
+ manager ->
bind(manager).to(IcebergCleanupManager.class).ranked(1));
bind(icebergTableDispatcher).to(IcebergTableOperationDispatcher.class).ranked(1);
bind(icebergViewDispatcher).to(IcebergViewOperationDispatcher.class).ranked(1);
bind(icebergNamespaceDispatcher)
@@ -198,11 +220,15 @@ public class RESTService implements
GravitinoAuxiliaryService {
@Override
public void serviceStart() {
icebergMetricsManager.start();
+ cleanupManager.ifPresent(IcebergCleanupManager::start);
if (server != null) {
try {
server.start();
LOG.info("Iceberg REST service started");
} catch (Exception e) {
+ // Stop the components we already started so they don't outlive a
failed startup.
+ cleanupManager.ifPresent(IcebergCleanupManager::close);
+ icebergMetricsManager.close();
throw new RuntimeException(e);
}
}
@@ -223,6 +249,7 @@ public class RESTService implements
GravitinoAuxiliaryService {
if (icebergMetricsManager != null) {
icebergMetricsManager.close();
}
+ cleanupManager.ifPresent(IcebergCleanupManager::close);
}
public void join() {
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 6f715ba1d9..113a6e6a3a 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
@@ -23,9 +23,12 @@ 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.mapper.provider.IcebergCleanupMapperPackageProvider;
import org.apache.gravitino.iceberg.service.cleanup.po.IcebergCleanupJobPO;
import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
import org.apache.gravitino.storage.relational.utils.SessionUtils;
+import org.apache.ibatis.session.Configuration;
/**
* Persistence for {@code iceberg_cleanup_job}, layered on the Gravitino
entity store's shared
@@ -47,6 +50,30 @@ public class IcebergCleanupJobStore {
*/
public IcebergCleanupJobStore(IdGenerator idGenerator) {
this.idGenerator = idGenerator;
+ registerMappers();
+ }
+
+ /**
+ * Registers the cleanup mappers into the entity store's shared MyBatis
configuration.
+ *
+ * <p>Gravitino core builds the shared {@code SqlSessionFactory} during
startup, before any
+ * auxiliary service is loaded, and in deploy mode the iceberg-rest-server
runs in an isolated
+ * auxiliary-service class loader that core cannot see into. Core therefore
cannot discover this
+ * module's mappers, and without them every cleanup query fails with {@code
BindingException}. We
+ * register them here instead, lazily, from within the class loader that can
see them the first
+ * time a store is created. The {@code hasMapper} guard keeps it idempotent
across stores and
+ * threads.
+ */
+ private static void registerMappers() {
+ Configuration configuration =
+
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().getConfiguration();
+ for (Class<?> mapper : new
IcebergCleanupMapperPackageProvider().getMapperClasses()) {
+ synchronized (configuration) {
+ if (!configuration.hasMapper(mapper)) {
+ configuration.addMapper(mapper);
+ }
+ }
+ }
}
/**
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
index cdfd225129..5235819add 100644
---
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
@@ -25,9 +25,15 @@ import
org.apache.gravitino.iceberg.service.cleanup.mapper.IcebergCleanupJobMapp
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.
+ * Lists the Iceberg async-cleanup mappers so the cleanup job store can
register them into the
+ * Gravitino entity store's shared MyBatis configuration and reuse the shared
relational backend
+ * rather than opening its own JDBC connections.
+ *
+ * <p>This implements {@link MapperPackageProvider} but is invoked directly
(not via {@link
+ * java.util.ServiceLoader}): in deploy mode the iceberg-rest-server runs in
an isolated
+ * auxiliary-service class loader that core's service lookup cannot see into,
so {@link
+ * org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupJobStore}
consults this provider when
+ * it registers the mappers.
*/
public class IcebergCleanupMapperPackageProvider implements
MapperPackageProvider {
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergCleanupHelper.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergCleanupHelper.java
new file mode 100644
index 0000000000..b01f30eca0
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergCleanupHelper.java
@@ -0,0 +1,79 @@
+/*
+ * 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.dispatcher;
+
+import java.util.Optional;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupManager;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.exceptions.AlreadyExistsException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Request-path helpers shared by the table and namespace executors for async
table purge. */
+final class IcebergCleanupHelper {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(IcebergCleanupHelper.class);
+
+ private IcebergCleanupHelper() {}
+
+ /**
+ * Returns the catalog entity id for {@code catalogName}. The catalog is
already loaded for the
+ * current request, so this reads its in-memory entity without an extra
entity-store lookup.
+ */
+ static long catalogId(String catalogName) {
+ String metalake = IcebergRESTServerContext.getInstance().metalakeName();
+ return GravitinoEnv.getInstance()
+ .catalogManager()
+ .loadCatalogAndWrap(NameIdentifier.of(metalake, catalogName))
+ .catalog()
+ .entity()
+ .id();
+ }
+
+ /**
+ * Fails a create or register with {@code 409} while a cleanup job still
holds the identifier.
+ * Reusing the name before its files are gone would let the new table share
the old table's
+ * storage prefix. A name with no resolvable catalog entity cannot have a
cleanup job, so it stays
+ * usable.
+ */
+ static void rejectIfBeingPurged(
+ Optional<IcebergCleanupManager> cleanupManager,
+ String catalogName,
+ Namespace namespace,
+ String tableName) {
+ if (cleanupManager.isEmpty()) {
+ return;
+ }
+ long catalogId;
+ try {
+ catalogId = catalogId(catalogName);
+ } catch (RuntimeException e) {
+ LOG.warn("No catalog id for {}; skipping purge check", catalogName, e);
+ return;
+ }
+ if (cleanupManager.get().isNameOccupied(catalogId, namespace.toString(),
tableName)) {
+ throw new AlreadyExistsException(
+ "Table %s.%s is being purged; retry after cleanup completes",
namespace, tableName);
+ }
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
index ebb9c6aac4..364ba55120 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
@@ -21,9 +21,11 @@ package org.apache.gravitino.iceberg.service.dispatcher;
import java.util.HashMap;
import java.util.Map;
+import java.util.Optional;
import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
+import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupManager;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
@@ -42,11 +44,14 @@ public class IcebergNamespaceOperationExecutor implements
IcebergNamespaceOperat
private static final Logger LOG =
LoggerFactory.getLogger(IcebergNamespaceOperationExecutor.class);
- private IcebergCatalogWrapperManager icebergCatalogWrapperManager;
+ private final IcebergCatalogWrapperManager icebergCatalogWrapperManager;
+ private final Optional<IcebergCleanupManager> cleanupManager;
public IcebergNamespaceOperationExecutor(
- IcebergCatalogWrapperManager icebergCatalogWrapperManager) {
+ IcebergCatalogWrapperManager icebergCatalogWrapperManager,
+ Optional<IcebergCleanupManager> cleanupManager) {
this.icebergCatalogWrapperManager = icebergCatalogWrapperManager;
+ this.cleanupManager = cleanupManager;
}
@Override
@@ -121,6 +126,9 @@ public class IcebergNamespaceOperationExecutor implements
IcebergNamespaceOperat
IcebergRequestContext context,
Namespace namespace,
RegisterTableRequest registerTableRequest) {
+ IcebergCleanupHelper.rejectIfBeingPurged(
+ cleanupManager, context.catalogName(), namespace,
registerTableRequest.name());
+
return icebergCatalogWrapperManager
.getCatalogWrapper(context.catalogName())
.registerTable(namespace, registerTableRequest,
context.requestCredentialVending());
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
index e32f2999f7..6307013315 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
@@ -27,13 +27,17 @@ import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.credential.CredentialPrivilege;
+import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
import org.apache.gravitino.iceberg.common.utils.IcebergIdentifierUtils;
import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupJob;
+import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupManager;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
import org.apache.gravitino.utils.HierarchicalSchemaUtil;
+import org.apache.iceberg.TableMetadata;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.rest.requests.CreateTableRequest;
@@ -51,15 +55,22 @@ public class IcebergTableOperationExecutor implements
IcebergTableOperationDispa
private static final Logger LOG =
LoggerFactory.getLogger(IcebergTableOperationExecutor.class);
- private IcebergCatalogWrapperManager icebergCatalogWrapperManager;
+ private final IcebergCatalogWrapperManager icebergCatalogWrapperManager;
+ private final Optional<IcebergCleanupManager> cleanupManager;
- public IcebergTableOperationExecutor(IcebergCatalogWrapperManager
icebergCatalogWrapperManager) {
+ public IcebergTableOperationExecutor(
+ IcebergCatalogWrapperManager icebergCatalogWrapperManager,
+ Optional<IcebergCleanupManager> cleanupManager) {
this.icebergCatalogWrapperManager = icebergCatalogWrapperManager;
+ this.cleanupManager = cleanupManager;
}
@Override
public LoadTableResponse createTable(
IcebergRequestContext context, Namespace namespace, CreateTableRequest
createTableRequest) {
+ IcebergCleanupHelper.rejectIfBeingPurged(
+ cleanupManager, context.catalogName(), namespace,
createTableRequest.name());
+
String authenticatedUser = context.userName();
if (!AuthConstants.ANONYMOUS_USER.equals(authenticatedUser)) {
String existingOwner =
createTableRequest.properties().get(IcebergConstants.OWNER);
@@ -110,15 +121,41 @@ public class IcebergTableOperationExecutor implements
IcebergTableOperationDispa
@Override
public void dropTable(
IcebergRequestContext context, TableIdentifier tableIdentifier, boolean
purgeRequested) {
- if (purgeRequested) {
- icebergCatalogWrapperManager
- .getCatalogWrapper(context.catalogName())
- .purgeTable(tableIdentifier);
- } else {
- icebergCatalogWrapperManager
- .getCatalogWrapper(context.catalogName())
- .dropTable(tableIdentifier);
+ IcebergCatalogWrapper wrapper =
+ icebergCatalogWrapperManager.getCatalogWrapper(context.catalogName());
+ if (!purgeRequested) {
+ wrapper.dropTable(tableIdentifier);
+ return;
+ }
+
+ // Async cleanup is opt-in per request and only wired in auxiliary mode;
otherwise purge inline.
+ if (!context.asyncPurge()) {
+ wrapper.purgeTable(tableIdentifier);
+ return;
}
+
+ // Async purge needs the cleanup manager, which only exists in auxiliary
mode. A request may
+ // still ask for async purge in standalone mode (empty manager); there is
no async engine to
+ // enqueue into, so we fall back to synchronous purge rather than fail the
request.
+ cleanupManager.ifPresentOrElse(
+ manager -> {
+ // Read the metadata location before dropping the catalog entry,
then enqueue the job. The
+ // job deletes only files reachable from this old metadata, so a
table recreated at the
+ // same name (with fresh metadata) is never touched.
+ TableMetadata metadata = wrapper.loadTableMetadata(tableIdentifier);
+ wrapper.dropTable(tableIdentifier);
+ manager.addJob(
+ new IcebergCleanupJob(
+ 0L,
+ IcebergCleanupHelper.catalogId(context.catalogName()),
+ tableIdentifier.namespace().toString(),
+ tableIdentifier.name(),
+ metadata.metadataFileLocation(),
+ wrapper.fileIOImpl(),
+ wrapper.fileIOProperties(),
+ context.userName()));
+ },
+ () -> wrapper.purgeTable(tableIdentifier));
}
@Override
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
index 4881257805..e10eb41734 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
@@ -28,6 +28,9 @@ import org.apache.gravitino.utils.PrincipalUtils;
/** The general request context information for Iceberg REST operations. */
public class IcebergRequestContext {
+ /** Header that opts a drop purge request into asynchronous file cleanup. */
+ public static final String ASYNC_PURGE_HEADER = "X-Gravitino-Async-Purge";
+
/**
* @deprecated Kept only for backward-compatibility and will be removed in
the next major release.
*/
@@ -113,6 +116,24 @@ public class IcebergRequestContext {
return httpHeaders;
}
+ /**
+ * Checks whether this request opted into asynchronous table purge.
+ *
+ * <p>Async purge is opt-in. Standard Iceberg clients send no header and
keep synchronous purge
+ * behavior; a client opts in with {@code X-Gravitino-Async-Purge: true}.
+ *
+ * @return true only when the async purge header explicitly says {@code true}
+ */
+ public boolean asyncPurge() {
+ for (Map.Entry<String, String> header : httpHeaders.entrySet()) {
+ // HTTP header names are case-insensitive; the value is matched exactly
as "true".
+ if (ASYNC_PURGE_HEADER.equalsIgnoreCase(header.getKey())) {
+ return "true".equals(header.getValue().trim());
+ }
+ }
+ return false;
+ }
+
/**
* Checks if the request is for credential vending.
*
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
deleted file mode 100644
index 7ed1ef96c3..0000000000
---
a/iceberg/iceberg-rest-server/src/main/resources/META-INF/services/org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider
+++ /dev/null
@@ -1,19 +0,0 @@
-#
-# 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/integration/test/IcebergRESTAsyncPurgeIT.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTAsyncPurgeIT.java
new file mode 100644
index 0000000000..f798871b57
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergRESTAsyncPurgeIT.java
@@ -0,0 +1,263 @@
+/*
+ * 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.integration.test;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+import org.apache.commons.io.FileUtils;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
+import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.gravitino.integration.test.container.ContainerSuite;
+import org.apache.gravitino.integration.test.util.BaseIT;
+import org.apache.gravitino.integration.test.util.TestDatabaseName;
+import org.apache.gravitino.listener.api.event.IcebergRequestContext;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.AlreadyExistsException;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.io.PositionOutputStream;
+import org.apache.iceberg.rest.RESTCatalog;
+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.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * End-to-end test for async table purge. A table dropped with {@code
purgeRequested=true} plus the
+ * {@link IcebergRequestContext#ASYNC_PURGE_HEADER} should keep its files on
disk while the drop
+ * returns, block recreating the same name with {@code 409} until cleanup
finishes, and have its
+ * files deleted by the background worker.
+ *
+ * <p>It drives the server with the {@link RESTCatalog} client, not Spark:
Spark purges files
+ * client-side and sends {@code purgeRequested=false}, which skips the
server-side path. It uses the
+ * dynamic config provider over a PostgreSQL-backed {@code lakehouse-iceberg}
catalog with a local
+ * {@code file://} warehouse, since cleanup jobs are keyed by catalog id and
the files must be
+ * visible on disk.
+ */
+public class IcebergRESTAsyncPurgeIT extends BaseIT {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(IcebergRESTAsyncPurgeIT.class);
+ private static final String GRAVITINO_ICEBERG_REST_PREFIX =
"gravitino.iceberg-rest.";
+ private static final String METALAKE_NAME = "async_cleanup_metalake";
+ private static final String CATALOG_NAME = "iceberg_cleanup";
+ private static final String USER = "test";
+ private static final String DATABASE_NAME = "purge_db";
+ private static final String TABLE_NAME = "t_async";
+ // Poll interval large enough that the background worker provably cannot
claim the job during the
+ // synchronous in-flight assertions, yet small enough to keep the
eventual-cleanup wait short.
+ private static final int CLEANUP_POLL_INTERVAL_SECS = 5;
+
+ private static final Schema SCHEMA =
+ new Schema(
+ Types.NestedField.required(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "data", Types.StringType.get()));
+
+ private static final ContainerSuite CONTAINER_SUITE =
ContainerSuite.getInstance();
+
+ private Path warehouseDir;
+ private RESTCatalog restCatalog;
+
+ @BeforeAll
+ @Override
+ public void startIntegrationTest() throws Exception {
+
CONTAINER_SUITE.startPostgreSQLContainer(TestDatabaseName.PG_ICEBERG_ASYNC_CLEANUP_IT);
+ warehouseDir =
Files.createTempDirectory("gravitino-iceberg-async-cleanup");
+ ignoreIcebergAuxRestService = false;
+
+ // Simple authentication so the dynamic config provider can authenticate
to Gravitino and the
+ // REST client can identify itself; authorization stays disabled to keep
the test focused.
+ customConfigs.put("gravitino.authenticators", "simple");
+ customConfigs.put("SimpleAuthUserName", USER);
+ customConfigs.put(
+ GRAVITINO_ICEBERG_REST_PREFIX +
IcebergConstants.ICEBERG_REST_CATALOG_CONFIG_PROVIDER,
+ IcebergConstants.DYNAMIC_ICEBERG_CATALOG_CONFIG_PROVIDER_NAME);
+ customConfigs.put(
+ GRAVITINO_ICEBERG_REST_PREFIX + IcebergConstants.GRAVITINO_METALAKE,
METALAKE_NAME);
+ customConfigs.put(
+ GRAVITINO_ICEBERG_REST_PREFIX +
IcebergConstants.ICEBERG_REST_DEFAULT_DYNAMIC_CATALOG_NAME,
+ CATALOG_NAME);
+ customConfigs.put(
+ GRAVITINO_ICEBERG_REST_PREFIX +
IcebergConstants.GRAVITINO_SIMPLE_USERNAME, USER);
+ customConfigs.put(
+ GRAVITINO_ICEBERG_REST_PREFIX +
IcebergConfig.ASYNC_CLEANUP_POLL_INTERVAL_SECS.getKey(),
+ String.valueOf(CLEANUP_POLL_INTERVAL_SECS));
+
+ super.startIntegrationTest();
+ initMetalakeAndCatalog();
+ initRESTCatalog();
+ }
+
+ @AfterAll
+ @Override
+ public void stopIntegrationTest() throws IOException, InterruptedException {
+ if (restCatalog != null) {
+ try {
+ restCatalog.close();
+ } catch (IOException e) {
+ LOG.warn("Failed to close Iceberg REST catalog", e);
+ }
+ restCatalog = null;
+ }
+ try {
+ client.dropMetalake(METALAKE_NAME, true);
+ } catch (Exception e) {
+ LOG.warn("Failed to drop metalake {}", METALAKE_NAME, e);
+ }
+ super.stopIntegrationTest();
+ if (warehouseDir != null) {
+ FileUtils.deleteQuietly(warehouseDir.toFile());
+ }
+ }
+
+ @Test
+ void testAsyncPurgeEndToEnd() {
+ restCatalog.createNamespace(Namespace.of(DATABASE_NAME));
+ TableIdentifier identifier = TableIdentifier.of(DATABASE_NAME, TABLE_NAME);
+ Table table = restCatalog.createTable(identifier, SCHEMA);
+ appendDataFile(table);
+
+ Path tableDir = warehouseDir.resolve(DATABASE_NAME).resolve(TABLE_NAME);
+ Assertions.assertTrue(
+ countRegularFiles(tableDir) > 0,
+ "Table should have metadata and data files on disk before drop");
+
+ // Drop with purgeRequested=true; the async purge header routes this
through the cleanup
+ // manager.
+ restCatalog.dropTable(identifier, true);
+
+ // The worker polls every few seconds, so right after the drop returns the
files must still be
+ // on disk. Synchronous purge would have already deleted them, so this is
what proves the drop
+ // went through the async path.
+ Assertions.assertTrue(
+ countRegularFiles(tableDir) > 0,
+ "Async purge must not delete files synchronously during the drop
request");
+
+ // While the cleanup job is in flight, the dropped name is occupied and
cannot be recreated.
+ AlreadyExistsException occupied =
+ Assertions.assertThrows(
+ AlreadyExistsException.class, () ->
restCatalog.createTable(identifier, SCHEMA));
+ Assertions.assertTrue(
+ occupied.getMessage().contains("being purged"),
+ "Expected a 'being purged' conflict, got: " + occupied.getMessage());
+
+ // The background cleanup worker eventually deletes every file and
releases the name.
+ Awaitility.await()
+ .atMost(60, TimeUnit.SECONDS)
+ .pollInterval(500, TimeUnit.MILLISECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertEquals(
+ 0L,
+ countRegularFiles(tableDir),
+ "Background cleanup worker should have deleted all table
files"));
+ }
+
+ private void initMetalakeAndCatalog() {
+ GravitinoMetalake metalake = client.createMetalake(METALAKE_NAME, "", new
HashMap<>());
+ Map<String, String> props = Maps.newHashMap();
+ props.put(IcebergConstants.CATALOG_BACKEND, "jdbc");
+ props.put(
+ IcebergConstants.URI,
+ CONTAINER_SUITE
+ .getPostgreSQLContainer()
+ .getJdbcUrl(TestDatabaseName.PG_ICEBERG_ASYNC_CLEANUP_IT));
+ props.put(IcebergConstants.GRAVITINO_JDBC_DRIVER, "org.postgresql.Driver");
+ props.put(
+ IcebergConstants.GRAVITINO_JDBC_USER,
+ CONTAINER_SUITE.getPostgreSQLContainer().getUsername());
+ props.put(
+ IcebergConstants.GRAVITINO_JDBC_PASSWORD,
+ CONTAINER_SUITE.getPostgreSQLContainer().getPassword());
+ props.put("gravitino.bypass.jdbc.schema-version", "v1");
+ props.put(IcebergConstants.ICEBERG_JDBC_INITIALIZE, "true");
+ props.put(IcebergConstants.WAREHOUSE, warehouseDir.toUri().toString());
+
+ metalake.createCatalog(
+ CATALOG_NAME, Catalog.Type.RELATIONAL, "lakehouse-iceberg", "async
cleanup IT", props);
+ }
+
+ private void appendDataFile(Table table) {
+ // Purge deletes files by reachability, not content, so a placeholder file
is enough to exercise
+ // data-file cleanup alongside the manifests and metadata the commit
writes.
+ String dataPath = table.location() + "/data/async-purge-it-0.parquet";
+ OutputFile outputFile = table.io().newOutputFile(dataPath);
+ try (PositionOutputStream out = outputFile.create()) {
+ out.write(new byte[] {1, 2, 3, 4});
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to write placeholder data file " +
dataPath, e);
+ }
+ DataFile dataFile =
+ DataFiles.builder(PartitionSpec.unpartitioned())
+ .withPath(dataPath)
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(4L)
+ .withRecordCount(1L)
+ .build();
+ table.newAppend().appendFile(dataFile).commit();
+ }
+
+ private void initRESTCatalog() {
+ String icebergRESTUri = getIcebergRestServiceUri();
+ LOG.info("Iceberg REST uri: {}", icebergRESTUri);
+ Map<String, String> props = new HashMap<>();
+ props.put(CatalogProperties.URI, icebergRESTUri);
+ props.put(CatalogProperties.CACHE_ENABLED, "false");
+ props.put("rest.auth.type", "basic");
+ props.put("rest.auth.basic.username", USER);
+ props.put("rest.auth.basic.password", "mock");
+ // Opt every request from this client into async purge.
+ props.put("header." + IcebergRequestContext.ASYNC_PURGE_HEADER, "true");
+ RESTCatalog catalog = new RESTCatalog();
+ catalog.setConf(new Configuration());
+ catalog.initialize("async_purge", ImmutableMap.copyOf(props));
+ restCatalog = catalog;
+ }
+
+ private long countRegularFiles(Path dir) {
+ if (!Files.exists(dir)) {
+ return 0L;
+ }
+ try (Stream<Path> paths = Files.walk(dir)) {
+ return paths.filter(Files::isRegularFile).count();
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to list files under " + dir, e);
+ }
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergAsyncPurge.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergAsyncPurge.java
new file mode 100644
index 0000000000..3ee6bf6907
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergAsyncPurge.java
@@ -0,0 +1,260 @@
+/*
+ * 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.dispatcher;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.Optional;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.catalog.CatalogManager;
+import org.apache.gravitino.connector.BaseCatalog;
+import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
+import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
+import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupJob;
+import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupManager;
+import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
+import org.apache.gravitino.listener.api.event.IcebergRequestContext;
+import org.apache.gravitino.meta.CatalogEntity;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.AlreadyExistsException;
+import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
+import org.apache.iceberg.rest.requests.RegisterTableRequest;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.MockedStatic;
+
+/**
+ * Covers the async purge request path in the table and namespace executors:
how {@code dropTable}
+ * routes on the {@code X-Gravitino-Async-Purge} header, and how {@code
createTable} / {@code
+ * registerTable} are blocked while a cleanup job holds the identifier.
+ */
+class TestIcebergAsyncPurge {
+
+ private static final long CATALOG_ID = 42L;
+ private static final Namespace DB = Namespace.of("db");
+ private static final TableIdentifier TABLE = TableIdentifier.of("db", "t");
+ private static final Schema SCHEMA =
+ new Schema(Types.NestedField.required(1, "id", Types.LongType.get()));
+
+ @BeforeEach
+ void setUp() {
+ IcebergConfigProvider provider = mock(IcebergConfigProvider.class);
+ when(provider.getMetalakeName()).thenReturn("metalake");
+ when(provider.getDefaultCatalogName()).thenReturn("cat");
+ IcebergRESTServerContext.create(provider, false, false, true, null);
+ }
+
+ // --- dropTable routing ---
+
+ @Test
+ void testAsyncDropEnqueuesJob() {
+ CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+ IcebergCleanupManager cleanup = mock(IcebergCleanupManager.class);
+ TableMetadata metadata = mock(TableMetadata.class);
+
when(metadata.metadataFileLocation()).thenReturn("s3://b/db/t/metadata/0.json");
+ when(wrapper.loadTableMetadata(any())).thenReturn(metadata);
+ when(wrapper.fileIOImpl()).thenReturn("io");
+ when(wrapper.fileIOProperties()).thenReturn(Collections.emptyMap());
+
+ try (MockedStatic<GravitinoEnv> ignored = mockCatalogId()) {
+ tableExecutor(wrapper, Optional.of(cleanup)).dropTable(context(true),
TABLE, true);
+ }
+
+ InOrder ordered = inOrder(wrapper, cleanup);
+ ordered.verify(wrapper).loadTableMetadata(TABLE);
+ ordered.verify(wrapper).dropTable(TABLE);
+ ArgumentCaptor<IcebergCleanupJob> captor =
ArgumentCaptor.forClass(IcebergCleanupJob.class);
+ ordered.verify(cleanup).addJob(captor.capture());
+ IcebergCleanupJob job = captor.getValue();
+ Assertions.assertEquals(CATALOG_ID, job.catalogId());
+ Assertions.assertEquals("db", job.namespace());
+ Assertions.assertEquals("t", job.tableName());
+ Assertions.assertEquals("s3://b/db/t/metadata/0.json",
job.metadataLocation());
+ Assertions.assertEquals("io", job.fileIOImpl());
+ Assertions.assertEquals("alice", job.createdBy());
+ }
+
+ @Test
+ void testSyncPurgeByDefault() {
+ CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+ IcebergCleanupManager cleanup = mock(IcebergCleanupManager.class);
+
+ tableExecutor(wrapper, Optional.of(cleanup)).dropTable(context(false),
TABLE, true);
+
+ verify(wrapper).purgeTable(TABLE);
+ verify(cleanup, never()).addJob(any());
+ }
+
+ @Test
+ void testFallbackToSyncWhenDisabled() {
+ CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+
+ tableExecutor(wrapper, Optional.empty()).dropTable(context(true), TABLE,
true);
+
+ verify(wrapper).purgeTable(TABLE);
+ verify(wrapper, never()).loadTableMetadata(any());
+ }
+
+ @Test
+ void testPlainDropWhenNotPurge() {
+ CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+ IcebergCleanupManager cleanup = mock(IcebergCleanupManager.class);
+
+ tableExecutor(wrapper, Optional.of(cleanup)).dropTable(context(false),
TABLE, false);
+
+ verify(wrapper).dropTable(TABLE);
+ verify(cleanup, never()).addJob(any());
+ }
+
+ // --- create / register tombstone ---
+
+ @Test
+ void testCreateRejectedWhilePurging() {
+ CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+ IcebergCleanupManager cleanup = mock(IcebergCleanupManager.class);
+ when(cleanup.isNameOccupied(CATALOG_ID, "db", "t")).thenReturn(true);
+
+ try (MockedStatic<GravitinoEnv> ignored = mockCatalogId()) {
+ AlreadyExistsException e =
+ Assertions.assertThrows(
+ AlreadyExistsException.class,
+ () ->
+ tableExecutor(wrapper, Optional.of(cleanup))
+ .createTable(context(false), DB, createReq()));
+ Assertions.assertTrue(e.getMessage().contains("being purged"),
e.getMessage());
+ }
+ verify(wrapper, never()).createTable(any(), any(), anyBoolean());
+ }
+
+ @Test
+ void testCreateAllowedWhenNotPurging() {
+ CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+ IcebergCleanupManager cleanup = mock(IcebergCleanupManager.class);
+ when(cleanup.isNameOccupied(CATALOG_ID, "db", "t")).thenReturn(false);
+
+ try (MockedStatic<GravitinoEnv> ignored = mockCatalogId()) {
+ tableExecutor(wrapper, Optional.of(cleanup)).createTable(context(false),
DB, createReq());
+ }
+ verify(wrapper).createTable(any(), any(), anyBoolean());
+ }
+
+ @Test
+ void testRegisterRejectedWhilePurging() {
+ CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+ IcebergCleanupManager cleanup = mock(IcebergCleanupManager.class);
+ when(cleanup.isNameOccupied(CATALOG_ID, "db", "t")).thenReturn(true);
+
+ try (MockedStatic<GravitinoEnv> ignored = mockCatalogId()) {
+ AlreadyExistsException e =
+ Assertions.assertThrows(
+ AlreadyExistsException.class,
+ () ->
+ namespaceExecutor(wrapper, Optional.of(cleanup))
+ .registerTable(context(false), DB, registerReq()));
+ Assertions.assertTrue(e.getMessage().contains("being purged"),
e.getMessage());
+ }
+ verify(wrapper, never()).registerTable(any(), any(), anyBoolean());
+ }
+
+ @Test
+ void testRegisterAllowedWhenNotPurging() {
+ CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+ IcebergCleanupManager cleanup = mock(IcebergCleanupManager.class);
+ when(cleanup.isNameOccupied(CATALOG_ID, "db", "t")).thenReturn(false);
+
+ try (MockedStatic<GravitinoEnv> ignored = mockCatalogId()) {
+ namespaceExecutor(wrapper, Optional.of(cleanup))
+ .registerTable(context(false), DB, registerReq());
+ }
+ verify(wrapper).registerTable(any(), any(), anyBoolean());
+ }
+
+ // --- helpers ---
+
+ private IcebergTableOperationExecutor tableExecutor(
+ CatalogWrapperForREST wrapper, Optional<IcebergCleanupManager> cleanup) {
+ return new IcebergTableOperationExecutor(wrapperManager(wrapper), cleanup);
+ }
+
+ private IcebergNamespaceOperationExecutor namespaceExecutor(
+ CatalogWrapperForREST wrapper, Optional<IcebergCleanupManager> cleanup) {
+ return new IcebergNamespaceOperationExecutor(wrapperManager(wrapper),
cleanup);
+ }
+
+ private static IcebergCatalogWrapperManager
wrapperManager(CatalogWrapperForREST wrapper) {
+ IcebergCatalogWrapperManager manager =
mock(IcebergCatalogWrapperManager.class);
+ when(manager.getCatalogWrapper("cat")).thenReturn(wrapper);
+ return manager;
+ }
+
+ private static IcebergRequestContext context(boolean asyncPurge) {
+ IcebergRequestContext context = mock(IcebergRequestContext.class);
+ when(context.catalogName()).thenReturn("cat");
+ when(context.userName()).thenReturn(asyncPurge ? "alice" :
AuthConstants.ANONYMOUS_USER);
+ when(context.asyncPurge()).thenReturn(asyncPurge);
+ return context;
+ }
+
+ /** Stubs the request-thread catalog-id resolution to {@link #CATALOG_ID}. */
+ private static MockedStatic<GravitinoEnv> mockCatalogId() {
+ MockedStatic<GravitinoEnv> envStatic = mockStatic(GravitinoEnv.class);
+ GravitinoEnv env = mock(GravitinoEnv.class);
+ CatalogManager catalogManager = mock(CatalogManager.class);
+ CatalogManager.CatalogWrapper wrapper =
mock(CatalogManager.CatalogWrapper.class);
+ BaseCatalog<?> catalog = mock(BaseCatalog.class);
+ CatalogEntity entity = mock(CatalogEntity.class);
+ envStatic.when(GravitinoEnv::getInstance).thenReturn(env);
+ when(env.catalogManager()).thenReturn(catalogManager);
+ when(catalogManager.loadCatalogAndWrap(any())).thenReturn(wrapper);
+ when(wrapper.catalog()).thenReturn(catalog);
+ when(catalog.entity()).thenReturn(entity);
+ when(entity.id()).thenReturn(CATALOG_ID);
+ return envStatic;
+ }
+
+ private static CreateTableRequest createReq() {
+ return
CreateTableRequest.builder().withName("t").withSchema(SCHEMA).build();
+ }
+
+ private static RegisterTableRequest registerReq() {
+ return ImmutableRegisterTableRequest.builder()
+ .name("t")
+ .metadataLocation("s3://b/db/t/metadata/0.json")
+ .build();
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceOperationExecutor.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceOperationExecutor.java
index f9cb7353ba..2e0e1b5a8d 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceOperationExecutor.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceOperationExecutor.java
@@ -28,6 +28,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
+import java.util.Optional;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
@@ -53,7 +54,7 @@ public class TestIcebergNamespaceOperationExecutor {
public void setUp() {
mockWrapperManager = mock(IcebergCatalogWrapperManager.class);
mockCatalogWrapper = mock(CatalogWrapperForREST.class);
- executor = new IcebergNamespaceOperationExecutor(mockWrapperManager);
+ executor = new IcebergNamespaceOperationExecutor(mockWrapperManager,
Optional.empty());
mockContext = mock(IcebergRequestContext.class);
when(mockContext.catalogName()).thenReturn("test_catalog");
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableOperationExecutor.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableOperationExecutor.java
index 65e271731d..9c5ebe158a 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableOperationExecutor.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableOperationExecutor.java
@@ -27,6 +27,7 @@ import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.Map;
+import java.util.Optional;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
@@ -56,7 +57,7 @@ public class TestIcebergTableOperationExecutor {
public void setUp() {
mockWrapperManager = mock(IcebergCatalogWrapperManager.class);
mockCatalogWrapper = mock(CatalogWrapperForREST.class);
- executor = new IcebergTableOperationExecutor(mockWrapperManager);
+ executor = new IcebergTableOperationExecutor(mockWrapperManager,
Optional.empty());
mockContext = mock(IcebergRequestContext.class);
when(mockContext.catalogName()).thenReturn("test_catalog");
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
index 0f2c141800..8c572999b6 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
@@ -27,6 +27,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.logging.Level;
import java.util.stream.Stream;
import javax.servlet.http.HttpServletRequest;
@@ -38,6 +39,7 @@ import
org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
import org.apache.gravitino.iceberg.service.IcebergObjectMapperProvider;
import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupManager;
import
org.apache.gravitino.iceberg.service.dispatcher.IcebergNamespaceEventDispatcher;
import
org.apache.gravitino.iceberg.service.dispatcher.IcebergNamespaceOperationDispatcher;
import
org.apache.gravitino.iceberg.service.dispatcher.IcebergNamespaceOperationExecutor;
@@ -146,7 +148,8 @@ public class IcebergRestTestUtil {
EventBus eventBus = new EventBus(eventListenerPlugins);
IcebergTableOperationExecutor icebergTableOperationExecutor =
- new IcebergTableOperationExecutor(icebergCatalogWrapperManager);
+ new IcebergTableOperationExecutor(
+ icebergCatalogWrapperManager,
Optional.of(mock(IcebergCleanupManager.class)));
IcebergTableEventDispatcher icebergTableEventDispatcher =
new IcebergTableEventDispatcher(
icebergTableOperationExecutor, eventBus,
configProvider.getMetalakeName());
@@ -156,7 +159,8 @@ public class IcebergRestTestUtil {
new IcebergViewEventDispatcher(
icebergViewOperationExecutor, eventBus,
configProvider.getMetalakeName());
IcebergNamespaceOperationExecutor icebergNamespaceOperationExecutor =
- new IcebergNamespaceOperationExecutor(icebergCatalogWrapperManager);
+ new IcebergNamespaceOperationExecutor(
+ icebergCatalogWrapperManager,
Optional.of(mock(IcebergCleanupManager.class)));
IcebergNamespaceEventDispatcher icebergNamespaceEventDispatcher =
new IcebergNamespaceEventDispatcher(
icebergNamespaceOperationExecutor, eventBus,
configProvider.getMetalakeName());
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
new file mode 100644
index 0000000000..eeff0219d2
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
@@ -0,0 +1,89 @@
+/*
+ * 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.listener.api.event;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.Enumeration;
+import javax.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestIcebergRequestContext {
+
+ @Test
+ void testNoHeaderIsSync() {
+ Assertions.assertFalse(new IcebergRequestContext(requestWithoutHeader(),
"cat").asyncPurge());
+ }
+
+ @Test
+ void testTrueHeaderIsAsync() {
+ Assertions.assertTrue(
+ new IcebergRequestContext(requestWithAsyncPurgeHeader(" true "),
"cat").asyncPurge());
+ }
+
+ @Test
+ void testFalseHeaderIsSync() {
+ Assertions.assertFalse(
+ new IcebergRequestContext(requestWithAsyncPurgeHeader("false"),
"cat").asyncPurge());
+ }
+
+ @Test
+ void testGarbageHeaderIsSync() {
+ Assertions.assertFalse(
+ new IcebergRequestContext(requestWithAsyncPurgeHeader("yes"),
"cat").asyncPurge());
+ }
+
+ @Test
+ void testUppercaseValueIsSync() {
+ // HTTP header values are case-sensitive; only the exact value "true" opts
in.
+ Assertions.assertFalse(
+ new IcebergRequestContext(requestWithAsyncPurgeHeader("True"),
"cat").asyncPurge());
+ }
+
+ @Test
+ void testHeaderNameIsCaseInsensitive() {
+ Assertions.assertTrue(
+ new IcebergRequestContext(requestWithHeader("x-gravitino-async-purge",
"true"), "cat")
+ .asyncPurge());
+ }
+
+ private static HttpServletRequest requestWithoutHeader() {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getRemoteHost()).thenReturn("localhost");
+ when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration());
+ return request;
+ }
+
+ private static HttpServletRequest requestWithAsyncPurgeHeader(String value) {
+ return requestWithHeader(IcebergRequestContext.ASYNC_PURGE_HEADER, value);
+ }
+
+ private static HttpServletRequest requestWithHeader(String name, String
value) {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ Enumeration<String> headerNames =
Collections.enumeration(Collections.singleton(name));
+ when(request.getRemoteHost()).thenReturn("localhost");
+ when(request.getHeaderNames()).thenReturn(headerNames);
+ when(request.getHeader(name)).thenReturn(value);
+ return request;
+ }
+}