nevzheng opened a new issue, #14388:
URL: https://github.com/apache/gravitino/issues/14388
### What would you like to be improved?
**Summary.** A cleanup job that ends `FAILED` releases the table name while
its files may still exist. A recreate at that name lands on the old prefix, the
old data stays readable there, and the documented manual cleanup of the failed
job would then delete the new table's files. **Proposal:** keep `FAILED`
reserving the name, report it on create, and give operators a reset; longer
term, unique table locations.
Async table purge (`DELETE
/iceberg/v1/{prefix}/namespaces/{namespace}/tables/{table}?purgeRequested=true`
with `X-Gravitino-Async-Purge: true`, 1.3.x and `main`) reserves the dropped
identifier while the cleanup job runs: `createTable` and `registerTable` at
that name return `409 Conflict`. The
[docs](https://github.com/apache/gravitino/blob/bb634de/docs/iceberg-rest-service.md#L165)
describe the reservation as held "until the file cleanup finishes".
The reservation is only held while the job is `PENDING` or `RUNNING`. A job
that exhausts `max-attempts` becomes `FAILED`, and `FAILED` releases the name
exactly like `SUCCEEDED`, although `FAILED` by definition means some files may
remain:
-
[`selectUnfinishedJobId`](https://github.com/apache/gravitino/blob/bb634de/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/base/IcebergCleanupJobBaseSQLProvider.java#L163-L174)
filters `state IN ('PENDING', 'RUNNING')`. It backs
[`isNameOccupied`](https://github.com/apache/gravitino/blob/bb634de/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupManager.java#L135)
and
[`rejectIfBeingPurged`](https://github.com/apache/gravitino/blob/bb634de/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergCleanupHelper.java#L50-L74).
-
[`recordFailure`](https://github.com/apache/gravitino/blob/bb634de/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/base/IcebergCleanupJobBaseSQLProvider.java#L137)
sets `FAILED` at the attempt ceiling, from
[`runJob`](https://github.com/apache/gravitino/blob/bb634de/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupManager.java#L280-L291).
- `FAILED` is terminal:
[`selectCandidateJobs`](https://github.com/apache/gravitino/blob/bb634de/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/base/IcebergCleanupJobBaseSQLProvider.java#L69-L70)
never re-selects it, there is no reset or re-enqueue API, and the row is
pruned after `retention-hours` ([default 720
h](https://github.com/apache/gravitino/blob/bb634de/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/mapper/provider/base/IcebergCleanupJobBaseSQLProvider.java#L180-L184)).
- The row is keyed by `(catalog_id, namespace, table_name)`
([schema](https://github.com/apache/gravitino/blob/bb634de/scripts/mysql/schema-1.3.0-mysql.sql#L603-L620))
and stores neither the table UUID nor the table root location.
```mermaid
stateDiagram-v2
direction LR
[*] --> PENDING : addJob (async drop)
PENDING --> RUNNING : takePendingJob
RUNNING --> PENDING : recordFailure, attempts + 1 < max-attempts
RUNNING --> SUCCEEDED : markSucceeded (all files gone)
RUNNING --> FAILED : recordFailure at max-attempts (files may remain)
SUCCEEDED --> [*] : pruned after retention-hours
FAILED --> [*] : pruned after retention-hours, never re-selected
note right of PENDING : name RESERVED (409)
note right of RUNNING : name RESERVED (409)
note right of SUCCEEDED : name FREE
note right of FAILED : name FREE
```
**Why this matters.** The reservation exists because a recreate without an
explicit `location` lands on `<warehouse>/<namespace>/<table>`, the prefix the
old files still occupy ([design
note](https://github.com/apache/gravitino/blob/bb634de/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergCleanupHelper.java#L50-L54),
[design doc
§5.7](https://github.com/apache/gravitino/blob/bb634de/design-docs/async-iceberg-rest-hard-deletion.md#L527-L545)).
That reason applies at least as much after a failure as during the run:
1. The old table's data and metadata stay readable under the new table's
prefix for anyone with storage access. This is the same-name, same-location
recreate the tombstone was meant to close, reached through the failure path.
2. The documented remediation for a `FAILED` row, a manual delete or a
lifecycle rule on the table's prefix
([§5.6](https://github.com/apache/gravitino/blob/bb634de/design-docs/async-iceberg-rest-hard-deletion.md#L496-L506)),
becomes destructive once a new table sits on that prefix, and the row carries
nothing to tell the two tables apart.
3. The window is short and silent. With defaults (`poll-interval-secs=5`,
`max-attempts=5`) a hard failure reaches `FAILED` in about 25 to 30 s, and a
client that saw `409` now gets `200` with no indication that the purge gave up.
What does not go wrong: the worker deletes by reachability from the old
`metadata_location` and data files are UUID-named, so a re-run could not delete
the new table's files, and the new table does not read the leftovers. The
concern is leftover-data exposure and unsafe manual cleanup, not catalog
corruption.
**This is a documented trade-off, not an oversight.**
[§5.6](https://github.com/apache/gravitino/blob/bb634de/design-docs/async-iceberg-rest-hard-deletion.md#L496-L506)
and
[§5.7](https://github.com/apache/gravitino/blob/bb634de/design-docs/async-iceberg-rest-hard-deletion.md#L543-L545)
state that `FAILED` lifts the tombstone so "a failed cleanup never permanently
wedges the name", with the leak handled out of band. This issue asks whether
that default should lean the other way, or at least be visible and
operator-controllable.
### Reproduction
<details>
<summary>Sequence: async drop, 409 while reserved, FAILED after read
failures, then create succeeds on the old prefix</summary>
```mermaid
sequenceDiagram
participant C as Client
participant S as Iceberg REST
participant J as iceberg_cleanup_job
participant W as Cleanup worker
participant F as Storage (reads denied)
C->>S: DELETE .../namespaces/db/tables/t?purgeRequested=true
(X-Gravitino-Async-Purge: true)
S->>J: addJob(db.t) PENDING
S-->>C: 204
C->>S: POST .../namespaces/db/tables {name: t}
S-->>C: 409 "Table db.t is being purged; retry after cleanup completes"
loop max-attempts
W->>F: read metadata.json
F-->>W: AccessDenied
W->>J: recordFailure
end
J->>J: FAILED (no file deleted)
C->>S: POST .../namespaces/db/tables {name: t}
S-->>C: 200, new db.t at <warehouse>/db/t, old metadata.json and data
still there
```
</details>
<details>
<summary>State machine walk on H2 with the real DDL and SQL (proves the
predicate: FAILED is the moment isNameOccupied flips to free)</summary>
State transitions, executed against H2 with the real DDL
([`schema-1.3.0-h2.sql`](https://github.com/apache/gravitino/blob/bb634de/scripts/h2/schema-1.3.0-h2.sql#L616-L633))
and the exact statements from `IcebergCleanupJobBaseSQLProvider`,
`max-attempts=2`:
```text
addJob (DELETE
/iceberg/v1/{prefix}/namespaces/db/tables/t?purgeRequested=true,
X-Gravitino-Async-Purge: true) | state=PENDING attempts=0 last_error=null |
isNameOccupied(db.t): RESERVED (create/register -> 409)
attempt 1: takePendingJob -> markRunning
| state=RUNNING attempts=0 last_error=null | isNameOccupied(db.t): RESERVED
(create/register -> 409)
attempt 1: cleanupFiles threw -> recordFailure(maxAttempts=2)
| state=PENDING attempts=1 last_error=AccessDenied: GetObject metadata.json |
isNameOccupied(db.t): RESERVED (create/register -> 409)
attempt 2: takePendingJob -> markRunning
| state=RUNNING attempts=1 last_error=AccessDenied: GetObject metadata.json |
isNameOccupied(db.t): RESERVED (create/register -> 409)
attempt 2: cleanupFiles threw -> recordFailure(maxAttempts=2)
| state=FAILED attempts=2 last_error=AccessDenied: GetObject metadata.json |
isNameOccupied(db.t): FREE (create/register succeeds)
selectCandidateJobs after FAILED -> no rows (never re-selected)
```
</details>
**Context: #14333.** That issue is in a different method with a different
wrong value: `CatalogUtil.deleteFiles` swallows delete failures inside
[`deleteAll`](https://github.com/apache/gravitino/blob/bb634de/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/cleanup/IcebergCleanupManager.java#L228-L237),
so a job whose deletes are denied is marked `SUCCEEDED`. This issue is the
reservation rule itself. Today a job only reaches `FAILED` through a non-delete
failure (metadata or manifest unreadable, `FileIO` cannot be instantiated).
Once #14333 propagates delete failures, denied deletes land in `FAILED` too,
and this rule then releases the name for them as well. Fixing #14333 alone
makes this more reachable, not less.
End to end: `TestIcebergCleanupFailedJobFreesName` (below), run on `main` at
[`bb634de`](https://github.com/apache/gravitino/commit/bb634de), H2 backend, 3
tests pass. It drives the real `IcebergTableOperationExecutor`,
`IcebergCleanupManager` and relational job store against an `InMemoryCatalog`;
the only double is the worker's `FileIO`.
| Test | Storage fault | Job ends | Files | Name |
| --- | --- | --- | --- | --- |
| `testFailedJobFreesNameWhileFilesRemain` | reads denied | `FAILED` after 2
attempts | all present | released; `createTable` succeeds at the old location |
| `testDeleteDeniedJobIsMarkedSucceededNotFailed` (#14333 contrast) |
deletes denied | `SUCCEEDED` | all present | released |
| `testRenameOntoReservedNameIsNotRejected` | none | `PENDING` | n/a |
`createTable` 409, `renameTable` onto the same name succeeds |
<details>
<summary>JUnit repro (TestIcebergCleanupFailedJobFreesName.java, passes on
main at bb634de) and how to run it</summary>
```
./gradlew :iceberg:iceberg-rest-server:test --tests
'org.apache.gravitino.iceberg.service.cleanup.TestIcebergCleanupFailedJobFreesName'
-PskipITs -PskipDockerTests=true
```
```java
/*
* 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 static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableMap;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.catalog.CatalogManager;
import org.apache.gravitino.catalog.CatalogTestUtils;
import org.apache.gravitino.connector.BaseCatalog;
import org.apache.gravitino.iceberg.common.IcebergConfig;
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.dispatcher.IcebergTableOperationExecutor;
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.gravitino.storage.RandomIdGenerator;
import org.apache.gravitino.storage.relational.TestJDBCBackend;
import org.apache.iceberg.BaseTable;
import org.apache.iceberg.CatalogProperties;
import org.apache.iceberg.DataFile;
import org.apache.iceberg.DataFiles;
import org.apache.iceberg.ManifestFile;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableMetadata;
import org.apache.iceberg.TableMetadataParser;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.apache.iceberg.inmemory.InMemoryCatalog;
import org.apache.iceberg.inmemory.InMemoryFileIO;
import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.io.InputFile;
import org.apache.iceberg.io.OutputFile;
import org.apache.iceberg.rest.requests.CreateTableRequest;
import org.apache.iceberg.rest.requests.RenameTableRequest;
import org.apache.iceberg.rest.responses.LoadTableResponse;
import org.apache.iceberg.types.Types;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
/**
* Reproduces gaps in the async-purge name reservation, end to end through
{@link
* IcebergTableOperationExecutor} with a real {@link IcebergCleanupManager},
the relational job
* store ({@link TestJDBCBackend}: H2 by default) and a real Iceberg table
in an {@link
* InMemoryCatalog}. Only the {@link FileIO} the worker instantiates is a
test double:
*
* <ol>
* <li>a cleanup job that reaches {@code FAILED} after {@code
max-attempts} frees the identifier
* although none of the old table's files were deleted, so a create at
the same name succeeds
* and lands on the old table's location (reached here through a
storage <em>read</em>
* failure, the only kind of failure that currently makes a job {@code
FAILED});
* <li>for contrast, a storage <em>delete</em> failure never reaches
{@code FAILED}: {@code
* CatalogUtil.deleteFiles} swallows it and the job is marked {@code
SUCCEEDED} (that is
* apache/gravitino#14333, a different defect in a different method);
* <li>{@code renameTable} onto an identifier held by a {@code PENDING}
job is not rejected,
* unlike {@code createTable}.
* </ol>
*/
class TestIcebergCleanupFailedJobFreesName extends TestJDBCBackend {
private static final long CATALOG_ID = 100L;
private static final String CATALOG_NAME = "cat";
private static final Namespace DB = Namespace.of("db");
private static final TableIdentifier TABLE = TableIdentifier.of(DB, "t");
private static final TableIdentifier OTHER = TableIdentifier.of(DB,
"other");
private static final Schema SCHEMA =
new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()));
private static final String DATA_FILE =
"memory://warehouse/db/t/data/00000-0.parquet";
private IcebergCleanupJobStore store;
private InMemoryCatalog catalog;
// See TestIcebergCleanupManager: TestJDBCBackend swaps GravitinoEnv's
config/idGenerator and
// never restores them; we also install a mock CatalogManager so
IcebergCleanupHelper.catalogId
// resolves. Snapshot everything and put it back after the class.
private Object originalConfig;
private Object originalIdGenerator;
private Object originalCatalogManager;
@BeforeAll
public void snapshotGravitinoEnv() throws IllegalAccessException {
GravitinoEnv env = GravitinoEnv.getInstance();
originalConfig = FieldUtils.readField(env, "config", true);
originalIdGenerator = FieldUtils.readField(env, "idGenerator", true);
originalCatalogManager = FieldUtils.readField(env, "catalogManager",
true);
IcebergConfigProvider provider = mock(IcebergConfigProvider.class);
when(provider.getMetalakeName()).thenReturn("metalake");
when(provider.getDefaultCatalogName()).thenReturn(CATALOG_NAME);
IcebergRESTServerContext.create(provider, false, false, true, null);
CatalogManager catalogManager = mock(CatalogManager.class);
BaseCatalog<?> baseCatalog = mock(BaseCatalog.class);
CatalogEntity entity = mock(CatalogEntity.class);
CatalogTestUtils.mockDoWithCatalog(catalogManager, baseCatalog);
when(baseCatalog.entity()).thenReturn(entity);
when(entity.id()).thenReturn(CATALOG_ID);
FieldUtils.writeField(env, "catalogManager", catalogManager, true);
}
@AfterAll
public void restoreGravitinoEnv() throws IllegalAccessException {
GravitinoEnv env = GravitinoEnv.getInstance();
FieldUtils.writeField(env, "config", originalConfig, true);
FieldUtils.writeField(env, "idGenerator", originalIdGenerator, true);
FieldUtils.writeField(env, "catalogManager", originalCatalogManager,
true);
}
@BeforeEach
public void prepare() {
store = new IcebergCleanupJobStore(new RandomIdGenerator());
catalog = new InMemoryCatalog();
catalog.initialize(
"test", ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION,
"memory://warehouse"));
catalog.createNamespace(DB);
DelegatingFileIO.delegate = null;
}
/**
* Claim 1: FAILED frees the name while every file of the old table is
still there. The worker
* cannot read the old metadata.json (think: storage credentials revoked
or expired), so every
* attempt throws before any delete, and after max-attempts the job is
FAILED.
*/
@TestTemplate
void testFailedJobFreesNameWhileFilesRemain() {
BaseTable old = createTableWithDataFile();
InMemoryFileIO io = (InMemoryFileIO) old.io();
String oldLocation = old.location();
String oldMetadata = old.operations().current().metadataFileLocation();
List<String> oldFiles = reachableFiles(old);
oldFiles.forEach(file -> Assertions.assertTrue(io.fileExists(file),
file));
DelegatingFileIO.delegate = io;
// max-attempts=2, poll every second: a hard failure reaches FAILED in
~2 polls.
IcebergCleanupManager manager = new IcebergCleanupManager(store,
fastFailConfig());
IcebergTableOperationExecutor executor = executor(manager,
ReadDeniedFileIO.class);
try {
// DELETE
/iceberg/v1/{prefix}/namespaces/db/tables/t?purgeRequested=true
// with X-Gravitino-Async-Purge: true
executor.dropTable(context(true), TABLE, true);
Assertions.assertFalse(catalog.tableExists(TABLE));
long jobId = store.findUnfinishedJobId(CATALOG_ID, "db", "t").get();
Assertions.assertEquals(IcebergCleanupJob.State.PENDING,
store.stateOf(jobId));
Assertions.assertTrue(manager.isNameOccupied(CATALOG_ID, "db", "t"));
// POST /iceberg/v1/{prefix}/namespaces/db/tables {name: t} -> 409
while PENDING.
AlreadyExistsException rejected =
Assertions.assertThrows(
AlreadyExistsException.class,
() -> executor.createTable(context(false), DB, createReq()));
Assertions.assertEquals(
"Table db.t is being purged; retry after cleanup completes",
rejected.getMessage());
// Let the worker burn through max-attempts against storage that
denies every read.
manager.start();
Awaitility.await()
.atMost(20, TimeUnit.SECONDS)
.until(() -> store.stateOf(jobId) ==
IcebergCleanupJob.State.FAILED);
// Nothing was deleted, yet the reservation is gone ...
oldFiles.forEach(file -> Assertions.assertTrue(io.fileExists(file),
file));
Assertions.assertFalse(manager.isNameOccupied(CATALOG_ID, "db", "t"));
Assertions.assertTrue(store.findUnfinishedJobId(CATALOG_ID, "db",
"t").isEmpty());
// ... so the same POST now succeeds, and the new db.t sits on the old
table's prefix.
LoadTableResponse created = executor.createTable(context(false), DB,
createReq());
TableMetadata fresh = created.tableMetadata();
Assertions.assertEquals(oldLocation, fresh.location());
Assertions.assertNotEquals(oldMetadata, fresh.metadataFileLocation());
Assertions.assertTrue(oldMetadata.startsWith(fresh.location() +
"/metadata/"));
Assertions.assertTrue(DATA_FILE.startsWith(fresh.location() +
"/data/"));
// The old metadata.json is still readable under the new table's
prefix and still points at
// the old data file; the new table's own metadata does not reference
it.
TableMetadata leftover = TableMetadataParser.read(io, oldMetadata);
Assertions.assertEquals(1, leftover.snapshots().size());
Assertions.assertEquals(0, fresh.snapshots().size());
Assertions.assertTrue(io.fileExists(DATA_FILE));
} finally {
manager.close();
DelegatingFileIO.delegate = null;
}
}
/**
* Contrast (apache/gravitino#14333): when storage denies deletes instead
of reads, the job does
* NOT reach FAILED. CatalogUtil.deleteFiles logs and swallows every
delete failure, so runJob
* marks the job SUCCEEDED with all files still present. The name is freed
either way, but through
* a different state and a different method than claim 1.
*/
@TestTemplate
void testDeleteDeniedJobIsMarkedSucceededNotFailed() {
BaseTable old = createTableWithDataFile();
InMemoryFileIO io = (InMemoryFileIO) old.io();
List<String> oldFiles = reachableFiles(old);
DelegatingFileIO.delegate = io;
IcebergCleanupManager manager = new IcebergCleanupManager(store,
fastFailConfig());
IcebergTableOperationExecutor executor = executor(manager,
DeleteDeniedFileIO.class);
try {
executor.dropTable(context(true), TABLE, true);
long jobId = store.findUnfinishedJobId(CATALOG_ID, "db", "t").get();
Assertions.assertTrue(manager.isNameOccupied(CATALOG_ID, "db", "t"));
manager.start();
Awaitility.await()
.atMost(20, TimeUnit.SECONDS)
.until(
() ->
store.stateOf(jobId) != IcebergCleanupJob.State.PENDING
&& store.stateOf(jobId) !=
IcebergCleanupJob.State.RUNNING);
Assertions.assertEquals(IcebergCleanupJob.State.SUCCEEDED,
store.stateOf(jobId));
oldFiles.forEach(file -> Assertions.assertTrue(io.fileExists(file),
file));
Assertions.assertFalse(manager.isNameOccupied(CATALOG_ID, "db", "t"));
Assertions.assertDoesNotThrow(() ->
executor.createTable(context(false), DB, createReq()));
} finally {
manager.close();
DelegatingFileIO.delegate = null;
}
}
/** Claim 2: rename is not checked against the reservation, create is. */
@TestTemplate
void testRenameOntoReservedNameIsNotRejected() {
catalog.createTable(OTHER, SCHEMA);
IcebergCleanupManager manager =
new IcebergCleanupManager(store, new IcebergConfig(new HashMap<>()));
IcebergTableOperationExecutor executor = executor(manager,
ReadDeniedFileIO.class);
try {
// A PENDING job holds db.t (worker never started, so it stays
PENDING).
store.addJob(
new IcebergCleanupJob(
0L,
CATALOG_ID,
"db",
"t",
"memory://warehouse/db/t/metadata/0.json",
ReadDeniedFileIO.class.getName(),
ImmutableMap.of(),
"alice"));
Assertions.assertTrue(manager.isNameOccupied(CATALOG_ID, "db", "t"));
// POST /iceberg/v1/{prefix}/namespaces/db/tables {name: t} -> 409.
Assertions.assertThrows(
AlreadyExistsException.class,
() -> executor.createTable(context(false), DB, createReq()));
// POST /iceberg/v1/{prefix}/tables/rename {source: db.other,
destination: db.t} -> 204.
RenameTableRequest rename =
RenameTableRequest.builder().withSource(OTHER).withDestination(TABLE).build();
Assertions.assertDoesNotThrow(() ->
executor.renameTable(context(false), rename));
Assertions.assertTrue(catalog.tableExists(TABLE));
Assertions.assertFalse(catalog.tableExists(OTHER));
Assertions.assertTrue(manager.isNameOccupied(CATALOG_ID, "db", "t"));
} finally {
manager.close();
}
}
// --- helpers ---
private static IcebergConfig fastFailConfig() {
Map<String, String> config = new HashMap<>();
config.put("async-cleanup.worker-threads", "1");
config.put("async-cleanup.poll-interval-secs", "1");
config.put("async-cleanup.max-attempts", "2");
return new IcebergConfig(config);
}
private BaseTable createTableWithDataFile() {
Table table = catalog.createTable(TABLE, SCHEMA);
DataFile dataFile =
DataFiles.builder(PartitionSpec.unpartitioned())
.withPath(DATA_FILE)
.withFileSizeInBytes(10L)
.withRecordCount(1L)
.build();
table.newAppend().appendFile(dataFile).commit();
BaseTable base = (BaseTable) catalog.loadTable(TABLE);
((InMemoryFileIO) base.io()).addFile(DATA_FILE, new byte[] {1});
return base;
}
private static List<String> reachableFiles(BaseTable table) {
List<String> files = new ArrayList<>();
files.add(table.operations().current().metadataFileLocation());
files.add(DATA_FILE);
for (Snapshot snapshot : table.snapshots()) {
files.add(snapshot.manifestListLocation());
for (ManifestFile manifest : snapshot.allManifests(table.io())) {
files.add(manifest.path());
}
}
return files;
}
/** Executor over a mocked REST wrapper that forwards to the in-memory
Iceberg catalog. */
private IcebergTableOperationExecutor executor(
IcebergCleanupManager manager, Class<? extends FileIO> workerFileIO) {
CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
when(wrapper.loadTableMetadata(any()))
.thenAnswer(
inv -> ((BaseTable)
catalog.loadTable(inv.getArgument(0))).operations().current());
doAnswer(
inv -> {
catalog.dropTable(inv.getArgument(0), false);
return null;
})
.when(wrapper)
.dropTable(any());
when(wrapper.fileIOImpl()).thenReturn(workerFileIO.getName());
when(wrapper.fileIOProperties()).thenReturn(ImmutableMap.of());
when(wrapper.createTable(any(), any(), anyBoolean()))
.thenAnswer(
inv -> {
Namespace namespace = inv.getArgument(0);
CreateTableRequest request = inv.getArgument(1);
Table table =
catalog.createTable(
TableIdentifier.of(namespace, request.name()),
request.schema());
return LoadTableResponse.builder()
.withTableMetadata(((BaseTable)
table).operations().current())
.build();
});
doAnswer(
inv -> {
RenameTableRequest request = inv.getArgument(0);
catalog.renameTable(request.source(), request.destination());
return null;
})
.when(wrapper)
.renameTable(any());
IcebergCatalogWrapperManager wrapperManager =
mock(IcebergCatalogWrapperManager.class);
when(wrapperManager.getCatalogWrapper(CATALOG_NAME)).thenReturn(wrapper);
return new IcebergTableOperationExecutor(wrapperManager,
Optional.of(manager));
}
private static IcebergRequestContext context(boolean asyncPurge) {
IcebergRequestContext context = mock(IcebergRequestContext.class);
when(context.catalogName()).thenReturn(CATALOG_NAME);
when(context.userName()).thenReturn(asyncPurge ? "alice" :
AuthConstants.ANONYMOUS_USER);
when(context.asyncPurge()).thenReturn(asyncPurge);
return context;
}
private static CreateTableRequest createReq() {
return
CreateTableRequest.builder().withName("t").withSchema(SCHEMA).build();
}
/**
* FileIO the cleanup worker instantiates by class name (public no-arg
constructor for {@code
* CatalogUtil.loadFileIO}); forwards to the in-memory store shared with
the catalog.
*/
public static class DelegatingFileIO implements FileIO {
static volatile InMemoryFileIO delegate;
@Override
public InputFile newInputFile(String path) {
return delegate.newInputFile(path);
}
@Override
public OutputFile newOutputFile(String path) {
return delegate.newOutputFile(path);
}
@Override
public void deleteFile(String path) {
delegate.deleteFile(path);
}
}
/** Every read fails (credentials revoked / expired); the worker never
gets to a delete. */
public static class ReadDeniedFileIO extends DelegatingFileIO {
@Override
public InputFile newInputFile(String path) {
throw new RuntimeException("AccessDenied: GetObject " + path);
}
}
/** Reads work, every delete fails (a role without DeleteObject). */
public static class DeleteDeniedFileIO extends DelegatingFileIO {
@Override
public void deleteFile(String path) {
throw new RuntimeException("AccessDenied: DeleteObject " + path);
}
}
}
```
</details>
**Related: rename bypasses the reservation.**
[`renameTable`](https://github.com/apache/gravitino/blob/bb634de/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java#L200-L205)
validates the destination name but never calls `rejectIfBeingPurged`, so `POST
/iceberg/v1/{prefix}/tables/rename` onto a `PENDING` or `RUNNING` identifier
succeeds while `createTable` at that identifier returns `409`. Small enough to
fix alongside; can be split into its own issue if preferred.
**A terminal state that needs human intervention has to be visible and
actionable.** Today `FAILED` is neither: no metric, no event, no distinct error
on the next create, and no reset path. The design planned all four and they are
still open ([§5.9
checklist](https://github.com/apache/gravitino/blob/bb634de/design-docs/async-iceberg-rest-hard-deletion.md#L698-L712)).
Releasing the name on top of that means the one moment somebody would notice,
the next create, passes silently. Either `FAILED` keeps the name until it is
acted on, or it has to ship with the alerting and the reset path the design
describes. As it stands the behavior contradicts the feature's own contract
("409 until the file cleanup finishes") in the one case where the files are
known not to be gone.
### How should we improve?
Other REST catalogs avoid the hazard at the location level rather than by
reserving names: Polaris refuses overlapping table locations and can add an
unpredictable suffix to default locations
(`DEFAULT_UNIQUE_TABLE_LOCATION_ENABLED`, [config
reference](https://polaris.apache.org/releases/1.7.0/configuration/configuration-reference/));
Lakekeeper puts a `{uuid}` segment in every default location and rejects
parent or sub-folder overlap ([storage
docs](https://docs.lakekeeper.io/docs/0.13.x/storage/)). Gravitino's REST
server passes the backend catalog's `<warehouse>/<namespace>/<table>` through
unchanged, so the name reservation is currently the only guard.
**Proposed:** 1 + 2 + 3 now; 5 as the longer-term direction. The full set of
alternatives:
1. **Keep `FAILED` reserving the name** (add `FAILED` to the
`selectUnfinishedJobId` predicate) until an operator clears the row. One
predicate and a test; honours "until the file cleanup finishes"; makes the
failure loud at the next create. Needs (3) to be practical, otherwise the name
stays blocked until the row is deleted by hand or pruned.
2. **Say why on create.** With or without (1), a `FAILED` row should produce
a distinct message ("previous purge of db.t failed after N attempts:
<last_error>; files may remain at <location>") instead of a silent success or
the generic "being purged".
3. **Operator reset / re-enqueue** for `FAILED` rows (`FAILED -> PENDING`,
`attempts = 0`), as an admin endpoint or a documented procedure. The design
already calls re-enqueue a manual action; there is no supported way to do it.
4. **Record table UUID and root location in the job row**, so a `FAILED` row
can be reconciled against the live catalog and the reservation can also be
checked by location prefix (covers explicit-`location` recreates).
5. **Unique default locations** for tables created through the REST server
(UUID suffix, as Polaris and Lakekeeper do). Removes the hazard for new tables
regardless of job state; a larger change with layout implications.
6. **Config flag** (`async-cleanup.failed-blocks-reuse`) if there is no
consensus on the default.
Fixing the rename gap (call `rejectIfBeingPurged` on the destination) is
independent of the choice above.
cc @roryqi, since this touches the documented trade-off in the async purge
design.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]