This is an automated email from the ASF dual-hosted git repository.
yuqi1129 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 f742f1100a [#13931] fix(lance): preserve externally owned registered
datasets (#13932)
f742f1100a is described below
commit f742f1100a5a67c198267965134e98a6d7daa2d9
Author: Qi Yu <[email protected]>
AuthorDate: Mon Oct 19 17:19:42 2026 +0800
[#13931] fix(lance): preserve externally owned registered datasets (#13932)
### What changes were proposed in this pull request?
Persist `external=true` for registered Lance tables, including when the
caller omits the flag or sets it to false. Normalize the properties
before consulting a table location provider as well as in the Lance
registration path.
Use a shared ownership predicate for Lance drop/purge, generic location
reclamation and Lance REST deregistration, so older registrations with
missing/false stored flags also preserve data during ordinary removal.
Document the distinction between drop/deregister and explicit purge.
### Why are the changes needed?
A registration only stores catalog metadata, but the default managed
classification currently allows ordinary drop to delete the existing
dataset. Fixing new registrations alone would leave existing records and
location-provider callbacks unsafe.
Fix: #13931
### Does this PR introduce _any_ user-facing change?
Registered Lance tables are external. Ordinary drop, registration
overwrite of a historical registration, schema cascade and
deregistration preserve the registered dataset. Explicit purge and Lance
REST drop remain destructive. Historical stored properties are not
automatically migrated, and non-Lance ownership rules are unchanged.
### How was this patch tested?
- Ran Spotless for all three affected modules.
- Ran `./gradlew :lance:lance-common:test
:catalogs:catalog-lakehouse-generic:test :lance:lance-rest-server:test
-PskipITs -PskipDockerTests=true`: 370 non-Docker tests passed,
including 32 added cases.
- Tests create real local Lance datasets and verify data preservation
through drop, legacy registration overwrite and schema cascade, as well
as explicit purge deletion, provider reclamation and REST deregistration
routing.
- Reverted ownership normalization/protection temporarily: 14 of 23
selected regression cases failed as expected. Restored the fix and reran
all three module suites successfully.
- Docker and deployed client/server E2E tests were not run.
---
.../generic/GenericCatalogOperations.java | 5 +-
.../lakehouse/lance/LanceTableOperations.java | 11 +-
.../generic/TestGenericCatalogOperations.java | 136 ++++++++++++++++++++
.../lakehouse/lance/TestLanceTableOperations.java | 139 +++++++++++++++++++++
docs/lakehouse-generic-lance-table.md | 9 +-
.../gravitino/GravitinoLanceTableOperations.java | 5 +-
.../lance/common/utils/LancePropertiesUtils.java | 37 ++++++
.../common/utils/TestLancePropertiesUtils.java | 36 ++++++
.../TestGravitinoLanceTableOperations.java | 33 +++++
9 files changed, 397 insertions(+), 14 deletions(-)
diff --git
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java
index 54999ca482..fa6626aca4 100644
---
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java
+++
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java
@@ -56,6 +56,7 @@ import org.apache.gravitino.exceptions.NoSuchTableException;
import org.apache.gravitino.exceptions.NonEmptySchemaException;
import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
import org.apache.gravitino.exceptions.TableAlreadyExistsException;
+import org.apache.gravitino.lance.common.utils.LancePropertiesUtils;
import org.apache.gravitino.meta.TableEntity;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.Table;
@@ -287,6 +288,7 @@ public class GenericCatalogOperations implements
CatalogOperations, SupportsSche
// table properties, and the built-in provider returns it verbatim as its
first branch, so a
// catalog on the built-in provider is unaffected. What an allocating
provider does with it is
// the provider's decision: honour it, rewrite it, or reject the creation.
+ properties = LancePropertiesUtils.normalizeTableProperties(properties);
String tableLocation = provisionTableLocation(ident, properties, schema);
Map<String, String> newProperties = Maps.newHashMap(properties);
@@ -438,7 +440,8 @@ public class GenericCatalogOperations implements
CatalogOperations, SupportsSche
// Read the same way the table formats and the property metadata read it:
a catalog that
// disagreed with the format about a value like `external=yes` would skip
reclaiming a location
// the format had just deleted the data under.
- boolean external =
Boolean.parseBoolean(context.tableProperties().get(Table.PROPERTY_EXTERNAL));
+ // Use the same effective ownership as the format, including legacy Lance
registrations.
+ boolean external =
LancePropertiesUtils.isExternalTable(context.tableProperties());
if (dropped && (purge || !external)) {
TableLocationProvider provider = tableLocationProvider;
try {
diff --git
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
index 48d7d955d9..e93913af95 100644
---
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
+++
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
@@ -290,10 +290,7 @@ public class LanceTableOperations extends
ManagedTableOperations {
// dataset) for a table that is about to be deleted anyway.
Table table = super.loadTable(ident);
validateLanceTable(ident, table);
- boolean external =
- Optional.ofNullable(table.properties().get(Table.PROPERTY_EXTERNAL))
- .map(Boolean::parseBoolean)
- .orElse(false);
+ boolean external =
LancePropertiesUtils.isExternalTable(table.properties());
// NOTE: the two steps below (metadata removal and dataset deletion) are
NOT atomic.
// If the process crashes between them, the entity-store record will be
gone but the
@@ -333,10 +330,7 @@ public class LanceTableOperations extends
ManagedTableOperations {
// Use super.loadTable to skip schema-refresh overhead when dropping.
Table table = super.loadTable(ident);
validateLanceTable(ident, table);
- boolean external =
- Optional.ofNullable(table.properties().get(Table.PROPERTY_EXTERNAL))
- .map(Boolean::parseBoolean)
- .orElse(false);
+ boolean external =
LancePropertiesUtils.isExternalTable(table.properties());
// NOTE: the two steps below (metadata removal and dataset deletion) are
NOT atomic.
// If the process crashes between them, the entity-store record will be
gone but the
@@ -409,6 +403,7 @@ public class LanceTableOperations extends
ManagedTableOperations {
throws NoSuchSchemaException, TableAlreadyExistsException {
if (register) {
+ properties = LancePropertiesUtils.normalizeTableProperties(properties);
// Currently, register operation does not read the schema from the
underlying Lance dataset.
// So we can't get the version of the dataset here.
return super.createTable(
diff --git
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
index 5e86bd2ffa..7daaed9a8d 100644
---
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
+++
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
@@ -50,6 +50,8 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import java.io.File;
import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
@@ -58,6 +60,8 @@ import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Catalog;
@@ -92,6 +96,11 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.lance.Dataset;
+import org.lance.WriteParams;
import org.mockito.Mockito;
public class TestGenericCatalogOperations {
@@ -192,6 +201,133 @@ public class TestGenericCatalogOperations {
ImmutableMap.of(), null /* CatalogInfo, unused */, new
GenericCatalog());
}
+ @TempDir private Path lanceDataDir;
+
+ /** The provider must see effective ownership before allocating a registered
table location. */
+ @ParameterizedTest
+ @CsvSource(
+ value = {"NULL", "false", "true"},
+ nullValues = "NULL")
+ void testRegisterPropertiesAreExternalBeforeProvisioning(String external) {
+ NameIdentifier schemaIdent = createSchema();
+ NameIdentifier ident =
+ NameIdentifier.of(METALAKE_NAME, CATALOG_NAME, schemaIdent.name(),
"registered");
+ Map<String, String> properties =
+ Maps.newHashMap(
+ Map.of(
+ Table.PROPERTY_TABLE_FORMAT,
+ "lance",
+ "lance.register",
+ "true",
+ Table.PROPERTY_LOCATION,
+ lanceDataDir.resolve("existing").toString()));
+ properties.putAll(
+
StringIdentifier.newPropertiesWithId(StringIdentifier.fromId(idGenerator.nextId()),
null));
+ if (external != null) {
+ properties.put(Table.PROPERTY_EXTERNAL, external);
+ }
+ Map<String, String> original = Map.copyOf(properties);
+
+ Table created =
+ opsWithFakeProvider.createTable(
+ ident,
+ new Column[0],
+ null,
+ properties,
+ new Transform[0],
+ null,
+ new SortOrder[0],
+ new Index[0]);
+
+ Assertions.assertEquals("true",
created.properties().get(Table.PROPERTY_EXTERNAL));
+ Assertions.assertEquals(
+ "true",
+ FakeTableLocationProvider.provisioned()
+ .get(0)
+ .tableProperties()
+ .get(Table.PROPERTY_EXTERNAL));
+ Assertions.assertEquals(original, properties);
+ Assertions.assertTrue(opsWithFakeProvider.dropTable(ident));
+ Assertions.assertTrue(FakeTableLocationProvider.unprovisioned().isEmpty());
+ }
+
+ /** Legacy registrations preserve real data and avoid provider reclamation
on drop/cascade. */
+ @ParameterizedTest
+ @CsvSource(
+ value = {
+ "NULL,drop",
+ "false,drop",
+ "NULL,purge",
+ "false,purge",
+ "NULL,cascade",
+ "false,cascade"
+ },
+ nullValues = "NULL")
+ void testLegacyRegisteredLanceOwnership(String external, String operation)
throws Exception {
+ NameIdentifier schemaIdent = createSchema();
+ String location = lanceDataDir.resolve("legacy").toString();
+ // Arrow Schema and Gravitino Schema share a simple name in this test
class.
+ try (Dataset ignored =
+ Dataset.write()
+ .schema(
+ new org.apache.arrow.vector.types.pojo.Schema(
+ List.of(Field.nullable("id", new ArrowType.Int(32,
true)))))
+ .uri(location)
+ .mode(WriteParams.WriteMode.CREATE)
+ .execute()) {
+ // The dataset predates its catalog registration.
+ }
+ NameIdentifier ident =
+ NameIdentifier.of(METALAKE_NAME, CATALOG_NAME, schemaIdent.name(),
"legacy");
+ Map<String, String> properties =
+ Maps.newHashMap(
+ Map.of(
+ Table.PROPERTY_TABLE_FORMAT,
+ "lance",
+ "lance.register",
+ "true",
+ Table.PROPERTY_LOCATION,
+ location));
+ if (external != null) {
+ properties.put(Table.PROPERTY_EXTERNAL, external);
+ }
+ TableEntity entity =
+ TableEntity.builder()
+ .withId(idGenerator.nextId())
+ .withName(ident.name())
+ .withNamespace(ident.namespace())
+ .withProperties(properties)
+ .withAuditInfo(
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .build();
+ store.put(entity, false);
+
+ if ("purge".equals(operation)) {
+ Assertions.assertTrue(opsWithFakeProvider.purgeTable(ident));
+ } else if ("cascade".equals(operation)) {
+ Assertions.assertTrue(opsWithFakeProvider.dropSchema(schemaIdent, true));
+ } else {
+ Assertions.assertTrue(opsWithFakeProvider.dropTable(ident));
+ }
+
+ Assertions.assertFalse(opsWithFakeProvider.tableExists(ident));
+ if ("purge".equals(operation)) {
+
Assertions.assertFalse(Files.exists(lanceDataDir.resolve("legacy/_versions")));
+ Assertions.assertEquals(1,
FakeTableLocationProvider.unprovisioned().size());
+ Assertions.assertEquals(
+ location,
+ FakeTableLocationProvider.unprovisioned()
+ .get(0)
+ .tableProperties()
+ .get(Table.PROPERTY_LOCATION));
+ } else {
+
Assertions.assertTrue(FakeTableLocationProvider.unprovisioned().isEmpty());
+ try (Dataset dataset = Dataset.open().uri(location).build()) {
+ Assertions.assertEquals("id",
dataset.getSchema().getFields().get(0).getName());
+ }
+ }
+ }
+
@BeforeEach
public void resetProvider() {
FakeTableLocationProvider.reset();
diff --git
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
index bf48d6464f..3afa32e3c2 100644
---
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
+++
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
@@ -102,6 +102,145 @@ public class TestLanceTableOperations {
lanceTableOps = spy(new LanceTableOperations(store, schemaOps,
idGenerator));
}
+ /** Registered tables persist external ownership and retain the existing
dataset on drop. */
+ @ParameterizedTest
+ @CsvSource(
+ value = {"NULL", "false", "true"},
+ nullValues = "NULL")
+ void testRegisterPersistsExternalAndDropPreservesDataset(String external)
throws Exception {
+ NameIdentifier ident = NameIdentifier.of("schema", "registered");
+ String location = tempDir.resolve("registered").toString();
+ Column[] columns = {Column.of("id", Types.IntegerType.get())};
+ when(idGenerator.nextId()).thenReturn(10L);
+ try (Dataset ignored = lanceTableOps.createDataset(location, columns,
Map.of())) {
+ // Seed a dataset owned by the caller before registering it.
+ }
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put(Table.PROPERTY_TABLE_FORMAT, "lance");
+ properties.put(Table.PROPERTY_LOCATION, location);
+ properties.put(LANCE_TABLE_REGISTER, "true");
+
properties.putAll(StringIdentifier.newPropertiesWithId(StringIdentifier.fromId(1L),
null));
+ if (external != null) {
+ properties.put(Table.PROPERTY_EXTERNAL, external);
+ }
+ Map<String, String> original = Map.copyOf(properties);
+ AtomicReference<TableEntity> stored = new AtomicReference<>();
+ Mockito.doAnswer(
+ invocation -> {
+ stored.set(invocation.getArgument(0));
+ return null;
+ })
+ .when(store)
+ .put(any(TableEntity.class), eq(false));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenAnswer(invocation -> stored.get());
+ when(store.delete(ident, Entity.EntityType.TABLE)).thenReturn(true);
+
+ Table created =
+ PrincipalUtils.doAs(
+ new UserPrincipal("tester"),
+ () ->
+ lanceTableOps.createTable(
+ ident,
+ columns,
+ null,
+ properties,
+ new Transform[0],
+ null,
+ new SortOrder[0],
+ new Index[0]));
+
+ Assertions.assertEquals("true",
created.properties().get(Table.PROPERTY_EXTERNAL));
+ Assertions.assertEquals("true",
stored.get().properties().get(Table.PROPERTY_EXTERNAL));
+ Assertions.assertEquals(
+ "true",
lanceTableOps.loadTable(ident).properties().get(Table.PROPERTY_EXTERNAL));
+ Assertions.assertEquals(original, properties);
+ Assertions.assertTrue(lanceTableOps.dropTable(ident));
+ verify(store).delete(ident, Entity.EntityType.TABLE);
+ try (Dataset dataset = Dataset.open().uri(location).build()) {
+ Assertions.assertEquals("id",
dataset.getSchema().getFields().get(0).getName());
+ }
+ }
+
+ /** Legacy registration flags also protect drop and overwrite, while purge
remains destructive. */
+ @ParameterizedTest
+ @CsvSource(
+ value = {
+ "NULL,drop",
+ "false,drop",
+ "true,drop",
+ "NULL,purge",
+ "false,purge",
+ "true,purge",
+ "NULL,overwrite",
+ "false,overwrite"
+ },
+ nullValues = "NULL")
+ void testLegacyRegistrationRemoval(String external, String operation) throws
Exception {
+ NameIdentifier ident = NameIdentifier.of("schema", "legacy");
+ String location = tempDir.resolve("legacy").toString();
+ Column[] columns = {Column.of("id", Types.IntegerType.get())};
+ when(idGenerator.nextId()).thenReturn(10L);
+ try (Dataset ignored = lanceTableOps.createDataset(location, columns,
Map.of())) {
+ // A historical registration must never transfer ownership to Gravitino
implicitly.
+ }
+ Map<String, String> properties =
+ Maps.newHashMap(
+ Map.of(
+ Table.PROPERTY_TABLE_FORMAT,
+ "lance",
+ Table.PROPERTY_LOCATION,
+ location,
+ LANCE_TABLE_REGISTER,
+ "true"));
+ if (external != null) {
+ properties.put(Table.PROPERTY_EXTERNAL, external);
+ }
+ AtomicReference<TableEntity> stored =
+ new AtomicReference<>(tableEntity(ident, List.of(), properties));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenAnswer(invocation -> stored.get());
+ when(store.delete(ident, Entity.EntityType.TABLE)).thenReturn(true);
+ if ("overwrite".equals(operation)) {
+ Map<String, String> replacement = Maps.newHashMap(properties);
+ replacement.put(LANCE_CREATION_MODE, "OVERWRITE");
+
replacement.putAll(StringIdentifier.newPropertiesWithId(StringIdentifier.fromId(2L),
null));
+ Mockito.doAnswer(
+ invocation -> {
+ stored.set(invocation.getArgument(0));
+ return null;
+ })
+ .when(store)
+ .put(any(TableEntity.class), eq(false));
+ Table replaced =
+ PrincipalUtils.doAs(
+ new UserPrincipal("tester"),
+ () ->
+ lanceTableOps.createTable(
+ ident,
+ columns,
+ null,
+ replacement,
+ new Transform[0],
+ null,
+ new SortOrder[0],
+ new Index[0]));
+ Assertions.assertEquals("true",
replaced.properties().get(Table.PROPERTY_EXTERNAL));
+ } else if ("purge".equals(operation)) {
+ Assertions.assertTrue(lanceTableOps.purgeTable(ident));
+ } else {
+ Assertions.assertTrue(lanceTableOps.dropTable(ident));
+ }
+ verify(store).delete(ident, Entity.EntityType.TABLE);
+ if ("purge".equals(operation)) {
+ Assertions.assertFalse(Files.exists(Paths.get(location, "_versions")));
+ } else {
+ try (Dataset dataset = Dataset.open().uri(location).build()) {
+ Assertions.assertEquals("id",
dataset.getSchema().getFields().get(0).getName());
+ }
+ }
+ }
+
@Test
public void testCreationModeEnum() {
// Test that CreationMode enum has expected values
diff --git a/docs/lakehouse-generic-lance-table.md
b/docs/lakehouse-generic-lance-table.md
index 28bc92e88e..92afc8acb7 100644
--- a/docs/lakehouse-generic-lance-table.md
+++ b/docs/lakehouse-generic-lance-table.md
@@ -297,7 +297,14 @@ tableCatalog.createTable(
#### Register External Tables
-Register existing Lance tables without moving or copying data:
+Register existing Lance tables without moving or copying data. A table with
`lance.register=true`
+is always external, including when the request omits `external` or sets it to
`false`. Dropping it
+through the Gravitino table API removes only the metadata. Explicit
`purgeTable` and Lance REST
+`drop` still delete both the metadata and the dataset; use Lance REST
`deregister` to preserve data.
+
+Existing registrations with a missing or false stored `external` flag receive
the same protection
+when dropped or deregistered. Their stored properties are not automatically
migrated.
+
<Tabs groupId='language' queryString>
<TabItem value="shell" label="Shell">
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
index 300e678246..b72455972d 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
@@ -289,10 +289,7 @@ public class GravitinoLanceTableOperations implements
LanceTableOperations {
// dropTable would delete the underlying Lance data, violating the
deregister contract
// ("It will not delete the underlying lance data"). The TableCatalog
interface does not
// expose a metadata-only removal path, so we fail fast rather than risk
silent data loss.
- boolean external =
- Optional.ofNullable(properties.get(Table.PROPERTY_EXTERNAL))
- .map(Boolean::parseBoolean)
- .orElse(false);
+ boolean external = LancePropertiesUtils.isExternalTable(properties);
if (!external) {
throw new UnsupportedOperationException(
"deregisterTable only supports external tables, but table " +
tableId + " is managed");
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LancePropertiesUtils.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LancePropertiesUtils.java
index aba4087aa2..29cb5db621 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LancePropertiesUtils.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LancePropertiesUtils.java
@@ -21,11 +21,13 @@ package org.apache.gravitino.lance.common.utils;
import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_STORAGE_OPTIONS_PREFIX;
import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_FORMAT;
+import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_REGISTER;
import com.google.common.base.Preconditions;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
+import org.apache.gravitino.rel.Table;
/** Utility methods for Lance storage properties. */
public final class LancePropertiesUtils {
@@ -44,6 +46,36 @@ public final class LancePropertiesUtils {
return LANCE_TABLE_FORMAT.equalsIgnoreCase(tableFormat);
}
+ /**
+ * Returns whether the table's data is externally owned.
+ *
+ * <p>Registered Lance tables are external even when an older registration
omitted or incorrectly
+ * stored the external flag. Other formats retain their explicit external
flag semantics.
+ *
+ * @param tableProperties the stored table properties
+ * @return true for an explicitly external table or a registered Lance table
+ */
+ public static boolean isExternalTable(Map<String, String> tableProperties) {
+ return Boolean.parseBoolean(tableProperties.get(Table.PROPERTY_EXTERNAL))
+ || isRegisteredLanceTable(tableProperties);
+ }
+
+ /**
+ * Makes registered Lance table properties explicitly external without
changing the input map.
+ *
+ * @param tableProperties the table properties
+ * @return a copy with external=true for a registered Lance table, or the
input when unchanged
+ */
+ public static Map<String, String> normalizeTableProperties(Map<String,
String> tableProperties) {
+ if (!isRegisteredLanceTable(tableProperties)
+ || "true".equals(tableProperties.get(Table.PROPERTY_EXTERNAL))) {
+ return tableProperties;
+ }
+ Map<String, String> normalized = new LinkedHashMap<>(tableProperties);
+ normalized.put(Table.PROPERTY_EXTERNAL, "true");
+ return normalized;
+ }
+
/**
* Extracts Lance storage options from a property map.
*
@@ -106,4 +138,9 @@ public final class LancePropertiesUtils {
Collectors.toMap(
entry -> LANCE_STORAGE_OPTIONS_PREFIX + entry.getKey(),
Map.Entry::getValue));
}
+
+ private static boolean isRegisteredLanceTable(Map<String, String>
tableProperties) {
+ return isLanceTableFormat(tableProperties.get(Table.PROPERTY_TABLE_FORMAT))
+ && Boolean.parseBoolean(tableProperties.get(LANCE_TABLE_REGISTER));
+ }
}
diff --git
a/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestLancePropertiesUtils.java
b/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestLancePropertiesUtils.java
index 3a862c7c06..128f352b61 100644
---
a/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestLancePropertiesUtils.java
+++
b/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestLancePropertiesUtils.java
@@ -22,10 +22,13 @@ import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE
import com.google.common.collect.ImmutableMap;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
public class TestLancePropertiesUtils {
@@ -38,6 +41,39 @@ public class TestLancePropertiesUtils {
Assertions.assertFalse(LancePropertiesUtils.isLanceTableFormat(null));
}
+ /** Checks ownership without extending Lance registration semantics to other
formats. */
+ @ParameterizedTest
+ @CsvSource(
+ value = {
+ "lance,true,NULL,true", "LANCE,TRUE,false,true",
"lance,true,true,true",
+ "lance,false,false,false", "lance,NULL,NULL,false",
"lance,false,true,true",
+ "delta,true,false,false", "delta,true,true,true",
"NULL,true,false,false"
+ },
+ nullValues = "NULL")
+ void testEffectiveExternalOwnership(
+ String format, String register, String external, boolean expected) {
+ Map<String, String> properties = new HashMap<>();
+ if (format != null) {
+ properties.put("format", format);
+ }
+ if (register != null) {
+ properties.put("lance.register", register);
+ }
+ if (external != null) {
+ properties.put("external", external);
+ }
+ Map<String, String> original = Map.copyOf(properties);
+
+ Assertions.assertEquals(expected,
LancePropertiesUtils.isExternalTable(properties));
+ Map<String, String> normalized =
LancePropertiesUtils.normalizeTableProperties(properties);
+ if (LancePropertiesUtils.isLanceTableFormat(format) &&
Boolean.parseBoolean(register)) {
+ Assertions.assertEquals("true", normalized.get("external"));
+ } else {
+ Assertions.assertSame(properties, normalized);
+ }
+ Assertions.assertEquals(original, properties);
+ }
+
@Test
public void testGetLanceStorageOptions() {
Map<String, String> properties =
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceTableOperations.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceTableOperations.java
index 079a3a1cd5..9c9f1dfe14 100644
---
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceTableOperations.java
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceTableOperations.java
@@ -33,6 +33,8 @@ import org.apache.gravitino.rel.TableCatalog;
import org.apache.gravitino.rel.TableChange;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
import org.lance.namespace.errors.InvalidInputException;
import org.lance.namespace.model.AlterColumnsEntry;
import org.lance.namespace.model.AlterTableAlterColumnsRequest;
@@ -98,6 +100,37 @@ class TestGravitinoLanceTableOperations {
"Only RENAME alteration is supported currently.",
exception.getMessage());
}
+ @ParameterizedTest
+ @CsvSource(
+ value = {"NULL", "false", "true"},
+ nullValues = "NULL")
+ void testDeregisterLegacyRegisteredTable(String external) {
+ Table table = Mockito.mock(Table.class);
+ Map<String, String> properties =
+ new HashMap<>(
+ Map.of(
+ Table.PROPERTY_TABLE_FORMAT,
+ "lance",
+ "lance.register",
+ "true",
+ "location",
+ "/existing"));
+ if (external != null) {
+ properties.put(Table.PROPERTY_EXTERNAL, external);
+ }
+ Mockito.when(table.properties()).thenReturn(properties);
+ TableCatalog tableCatalog = Mockito.mock(TableCatalog.class);
+
Mockito.when(tableCatalog.loadTable(Mockito.any(NameIdentifier.class))).thenReturn(table);
+
Mockito.when(tableCatalog.dropTable(Mockito.any(NameIdentifier.class))).thenReturn(true);
+
+ Assertions.assertEquals(
+ "/existing",
+ operations(tableCatalog).deregisterTable("catalog.schema.table",
".").getLocation());
+
+ Mockito.verify(tableCatalog).dropTable(NameIdentifier.of("schema",
"table"));
+ Mockito.verify(tableCatalog, Mockito.never()).purgeTable(Mockito.any());
+ }
+
@Test
void testDeregisterTableRejectsManagedTable() {
// Mock a managed table (no PROPERTY_EXTERNAL=true)