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 21c348be20 [#11027][#11178] fix(lance): Refresh Lance table schema
from dataset on load (#11176)
21c348be20 is described below
commit 21c348be20912ce726bf8c83eef60ccacc566ab2
Author: Qi Yu <[email protected]>
AuthorDate: Mon Jun 8 15:43:27 2026 +0800
[#11027][#11178] fix(lance): Refresh Lance table schema from dataset on
load (#11176)
### What changes were proposed in this pull request?
This PR updates Lance table loading in the Generic Lakehouse catalog to
repair Gravitino table metadata from the underlying Lance dataset when
needed.
The refresh behavior is:
- `DECLARED_AND_EMPTY` by default: refresh tables marked with
`lance.declared=true` or tables with empty stored columns.
- `VERSION_CHECK`: open the Lance dataset during `loadTable`, compare
its version with the stored `lance.version`, and refresh columns when
the version changes.
When a declared table has been materialized, the refresh persists its
actual columns and dataset version and removes `lance.declared`. As a
result, Lance REST `describeTable` reports `is_only_declared=false`
after the first data write.
This PR also supports the Lance `loadDetailedMetadata` option and
documents the new catalog property `lance.schema-refresh-mode`.
### Why are the changes needed?
Spark staged create/declare flows register a table in Gravitino before
writing the actual Lance dataset. After the write, Gravitino can still
contain empty columns and the immutable `lance.declared=true` marker.
This causes the Web UI and client APIs to return an empty schema and
causes `describeTable` to incorrectly report that the materialized table
is still declaration-only.
Refreshing the stored metadata from the Lance dataset during `loadTable`
repairs both states.
Fix: #11027
Fix: #11178
Fix: #9501
### Does this PR introduce _any_ user-facing change?
Yes.
A new Generic Lakehouse catalog property is added:
- `lance.schema-refresh-mode`
- `DECLARED_AND_EMPTY`: the default; repairs declared tables and tables
with empty stored columns.
- `VERSION_CHECK`: refreshes table columns when the Lance dataset
version changes.
Materialized declared tables now return their actual schema and report
`is_only_declared=false`. No generic Table API parameter is added.
### How was this patch tested?
- Added unit tests for declared-table repair, empty stored schemas,
dataset version changes, genuinely empty datasets, missing locations,
dataset open failures, and concurrent metadata repair.
- Added Lance REST and Spark integration coverage for schema refresh and
declared-table state.
- `./gradlew :catalogs:catalog-lakehouse-generic:spotlessApply
:lance:lance-rest-server:spotlessApply
:catalogs:catalog-lakehouse-generic:test --tests
org.apache.gravitino.catalog.lakehouse.lance.TestLanceTableOperations
--tests
org.apache.gravitino.catalog.lakehouse.generic.TestPropertiesMetadata
:lance:lance-rest-server:compileTestJava -PskipWeb=true
-PskipDockerTests=true`
- `./gradlew :docs:build`
---
.../generic/GenericCatalogPropertiesMetadata.java | 12 +
.../lakehouse/lance/LanceTableOperations.java | 335 +++++++++-
.../lakehouse/generic/TestPropertiesMetadata.java | 8 +-
.../lakehouse/lance/TestLanceTableOperations.java | 701 +++++++++++++++++++++
.../catalog/TableOperationDispatcher.java | 6 +
docs/lakehouse-generic-catalog.md | 11 +-
docs/lakehouse-generic-lance-table.md | 37 +-
.../lance/common/ops/LanceTableOperations.java | 8 +-
.../gravitino/GravitinoLanceTableOperations.java | 10 +-
.../lance/common/utils/LanceConstants.java | 1 +
.../lance/service/rest/LanceTableOperations.java | 7 +-
.../gravitino/TestGravitinoLanceModeParsing.java | 2 +-
.../integration/test/LanceSparkRESTServiceIT.java | 4 +
.../service/rest/TestLanceNamespaceOperations.java | 9 +-
14 files changed, 1107 insertions(+), 44 deletions(-)
diff --git
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogPropertiesMetadata.java
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogPropertiesMetadata.java
index dd805181f8..7bd6f58605 100644
---
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogPropertiesMetadata.java
+++
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogPropertiesMetadata.java
@@ -19,8 +19,10 @@
package org.apache.gravitino.catalog.lakehouse.generic;
+import static org.apache.gravitino.connector.PropertyEntry.enumPropertyEntry;
import static
org.apache.gravitino.connector.PropertyEntry.stringOptionalPropertyEntry;
import static
org.apache.gravitino.connector.PropertyEntry.stringOptionalPropertyPrefixEntry;
+import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_SCHEMA_REFRESH_MODE;
import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_STORAGE_OPTIONS_PREFIX;
import com.google.common.collect.ImmutableList;
@@ -28,6 +30,7 @@ import com.google.common.collect.Maps;
import java.util.List;
import java.util.Map;
import org.apache.gravitino.Catalog;
+import org.apache.gravitino.catalog.lakehouse.lance.LanceTableOperations;
import org.apache.gravitino.connector.BaseCatalogPropertiesMetadata;
import org.apache.gravitino.connector.PropertyEntry;
@@ -50,6 +53,15 @@ public class GenericCatalogPropertiesMetadata extends
BaseCatalogPropertiesMetad
false /* immutable */,
null, /* defaultValue */
false /* hidden */,
+ false /* reserved */),
+ enumPropertyEntry(
+ LANCE_SCHEMA_REFRESH_MODE,
+ "Controls when Lance table schemas are refreshed from the
underlying dataset.",
+ false /* required */,
+ false /* immutable */,
+ LanceTableOperations.SchemaRefreshMode.class,
+ LanceTableOperations.SchemaRefreshMode.DECLARED_AND_EMPTY,
+ false /* hidden */,
false /* reserved */));
PROPERTIES_METADATA = Maps.uniqueIndex(propertyEntries,
PropertyEntry::getName);
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 89c99d5365..38dbf0e90d 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
@@ -20,31 +20,41 @@ package org.apache.gravitino.catalog.lakehouse.lance;
import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_CREATION_MODE;
import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_REGISTER;
+import static org.apache.gravitino.rel.Column.DEFAULT_VALUE_NOT_SET;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import java.io.IOException;
+import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
-import org.apache.arrow.memory.RootAllocator;
+import java.util.stream.IntStream;
import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.Schema;
import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
import org.apache.gravitino.EntityStore;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.catalog.ManagedSchemaOperations;
import org.apache.gravitino.catalog.ManagedTableOperations;
+import org.apache.gravitino.connector.GenericColumn;
import org.apache.gravitino.connector.GenericTable;
import org.apache.gravitino.connector.SupportsSchemas;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
import org.apache.gravitino.exceptions.NoSuchSchemaException;
import org.apache.gravitino.exceptions.NoSuchTableException;
import org.apache.gravitino.exceptions.TableAlreadyExistsException;
import org.apache.gravitino.lance.common.ops.gravitino.LanceDataTypeConverter;
import org.apache.gravitino.lance.common.utils.LanceConstants;
import org.apache.gravitino.lance.common.utils.LancePropertiesUtils;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.ColumnEntity;
+import org.apache.gravitino.meta.TableEntity;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.Table;
import org.apache.gravitino.rel.TableChange;
@@ -53,6 +63,7 @@ import org.apache.gravitino.rel.expressions.sorts.SortOrder;
import org.apache.gravitino.rel.expressions.transforms.Transform;
import org.apache.gravitino.rel.indexes.Index;
import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.utils.PrincipalUtils;
import org.lance.Dataset;
import org.lance.ReadOptions;
import org.lance.WriteParams;
@@ -74,6 +85,41 @@ public class LanceTableOperations extends
ManagedTableOperations {
OVERWRITE
}
+ /**
+ * Controls when Gravitino refreshes a Lance table's stored columns from the
underlying dataset.
+ *
+ * <p>{@link #DECLARED_AND_EMPTY} is the default. It covers two
complementary repair cases:
+ *
+ * <ul>
+ * <li><b>Declared tables</b> ({@code lance.declared=true}): metadata-only
tables whose schema
+ * has not yet been written to Gravitino (e.g. Spark staged-create
flow).
+ * <li><b>Empty stored columns</b>: tables where Gravitino has no column
metadata but the Lance
+ * dataset at the table's location already carries a real schema (e.g.
tables registered via
+ * the register-table path before schema was captured).
+ * </ul>
+ *
+ * <p><b>Zero-column Lance dataset:</b> when the dataset has no columns,
Gravitino records the
+ * checked dataset version ({@code lance.version}) without modifying stored
columns. Subsequent
+ * {@code loadTable} calls skip the dataset open because the stored version
acts as a "confirmed
+ * empty" marker. The dataset is re-examined only after the stored version
changes (for example
+ * via an explicit {@code alterTable}) or when the mode is switched to
{@link #VERSION_CHECK}.
+ */
+ public enum SchemaRefreshMode {
+ /**
+ * Default mode. Refreshes stored columns from the Lance dataset for
declared tables ({@code
+ * lance.declared=true}) and for tables whose Gravitino column list is
empty. Also repairs
+ * empty-column tables that were registered before their schema was
captured.
+ */
+ DECLARED_AND_EMPTY,
+ /**
+ * Opens the Lance dataset on every {@code loadTable}, compares the
dataset version with the
+ * stored {@code lance.version}, and refreshes columns when the version
has changed. The version
+ * is the sole gating factor: if the version is unchanged the schema read
is skipped even when
+ * stored columns are empty.
+ */
+ VERSION_CHECK
+ }
+
private final EntityStore store;
private final ManagedSchemaOperations schemaOps;
@@ -114,6 +160,68 @@ public class LanceTableOperations extends
ManagedTableOperations {
catalogProperties == null ? Map.of() :
ImmutableMap.copyOf(catalogProperties);
}
+ @Override
+ public Table loadTable(NameIdentifier ident) throws NoSuchTableException {
+ Table table = super.loadTable(ident);
+ // Spark staged create can write the actual schema only to the Lance
dataset path. Refresh
+ // Gravitino metadata when the stored table is declared-only, empty, or
configured to track
+ // Lance dataset versions.
+ boolean declaredOnly = isDeclaredOnly(table);
+ boolean emptySchema = table.columns().length == 0;
+ SchemaRefreshMode refreshMode = schemaRefreshMode();
+ if (!declaredOnly && !emptySchema && refreshMode ==
SchemaRefreshMode.DECLARED_AND_EMPTY) {
+ return table;
+ }
+ // Empty-schema table that was already confirmed against a stored version:
skip the dataset
+ // open. The stored lance.version acts as a "checked at this version"
marker written on the
+ // first confirmation. VERSION_CHECK mode does not take this shortcut — it
opens the dataset
+ // every time to compare the current version.
+ if (!declaredOnly
+ && emptySchema
+ &&
StringUtils.isNotBlank(table.properties().get(LanceConstants.LANCE_TABLE_VERSION))
+ && refreshMode == SchemaRefreshMode.DECLARED_AND_EMPTY) {
+ return table;
+ }
+
+ String location = table.properties().get(Table.PROPERTY_LOCATION);
+ if (StringUtils.isBlank(location)) {
+ return table;
+ }
+
+ Map<String, String> storageOptions =
+ LancePropertiesUtils.resolveLanceStorageOptions(catalogProperties,
table.properties());
+ Column[] columns;
+ long datasetVersion;
+ try (Dataset dataset = openDataset(location, storageOptions)) {
+ datasetVersion = dataset.version();
+ if (refreshMode == SchemaRefreshMode.VERSION_CHECK
+ && !declaredOnly
+ && !isDatasetVersionChanged(table, datasetVersion)) {
+ return table;
+ }
+ columns = extractColumns(dataset.getSchema());
+ } catch (Exception e) {
+ LOG.debug(
+ "Failed to load Lance schema from location {} for table {}. Return
stored metadata.",
+ location,
+ ident,
+ e);
+ return table;
+ }
+
+ if (columns.length == 0) {
+ // Dataset is genuinely empty: record the checked version so future
DECLARED_AND_EMPTY loads
+ // can skip the dataset open (see the early-return above). Declared
tables are excluded
+ // because their lance.declared flag is the authoritative "not yet
written" signal.
+ if (!declaredOnly) {
+ return recordCheckedEmptyVersion(ident, datasetVersion);
+ }
+ return table;
+ }
+
+ return repairTableMetadata(ident, columns, datasetVersion);
+ }
+
@Override
public Table createTable(
NameIdentifier ident,
@@ -184,26 +292,18 @@ public class LanceTableOperations extends
ManagedTableOperations {
// After making changes to the Lance dataset, we need to update the table
metadata in
// Gravitino. If there's any failure during this process, the code will
throw an exception
// and the update won't be applied in Gravitino.
- GenericTable table = (GenericTable) super.alterTable(ident, changes);
- Map<String, String> updatedProperties = new HashMap<>(table.properties());
- updatedProperties.put(LanceConstants.LANCE_TABLE_VERSION,
String.valueOf(version));
- return GenericTable.builder()
- .withName(table.name())
- .withColumns(table.columns())
- .withComment(table.comment())
- .withProperties(updatedProperties)
- .withAuditInfo(table.auditInfo())
- .withPartitioning(table.partitioning())
- .withSortOrders(table.sortOrder())
- .withDistribution(table.distribution())
- .withIndexes(table.index())
- .build();
+ TableChange[] metadataChanges = Arrays.copyOf(changes, changes.length + 1);
+ metadataChanges[changes.length] =
+ TableChange.setProperty(LanceConstants.LANCE_TABLE_VERSION,
String.valueOf(version));
+ return super.alterTable(ident, metadataChanges);
}
@Override
public boolean purgeTable(NameIdentifier ident) {
try {
- Table table = loadTable(ident);
+ // Use super.loadTable to avoid triggering an unnecessary schema-refresh
(which may open the
+ // dataset) for a table that is about to be deleted anyway.
+ Table table = super.loadTable(ident);
boolean external =
Optional.ofNullable(table.properties().get(Table.PROPERTY_EXTERNAL))
.map(Boolean::parseBoolean)
@@ -238,7 +338,8 @@ public class LanceTableOperations extends
ManagedTableOperations {
@Override
public boolean dropTable(NameIdentifier ident) {
try {
- Table table = loadTable(ident);
+ // Use super.loadTable to skip schema-refresh overhead when dropping.
+ Table table = super.loadTable(ident);
boolean external =
Optional.ofNullable(table.properties().get(Table.PROPERTY_EXTERNAL))
.map(Boolean::parseBoolean)
@@ -324,7 +425,6 @@ public class LanceTableOperations extends
ManagedTableOperations {
LancePropertiesUtils.resolveLanceStorageOptions(catalogProperties,
properties);
try (Dataset ignored =
Dataset.write()
- .allocator(new RootAllocator())
.schema(convertColumnsToArrowSchema(columns))
.uri(location)
.mode(WriteParams.WriteMode.CREATE)
@@ -365,7 +465,7 @@ public class LanceTableOperations extends
ManagedTableOperations {
}
}
- private org.apache.arrow.vector.types.pojo.Schema
convertColumnsToArrowSchema(Column[] columns) {
+ private Schema convertColumnsToArrowSchema(Column[] columns) {
List<Field> fields =
Arrays.stream(columns)
.map(
@@ -373,7 +473,198 @@ public class LanceTableOperations extends
ManagedTableOperations {
LanceDataTypeConverter.CONVERTER.toArrowField(
col.name(), col.dataType(), col.nullable()))
.collect(Collectors.toList());
- return new org.apache.arrow.vector.types.pojo.Schema(fields);
+ return new Schema(fields);
+ }
+
+ private SchemaRefreshMode schemaRefreshMode() {
+ return
Optional.ofNullable(catalogProperties.get(LanceConstants.LANCE_SCHEMA_REFRESH_MODE))
+ .map(mode -> mode.trim().replace('-', '_').toUpperCase())
+ .map(SchemaRefreshMode::valueOf)
+ .orElse(SchemaRefreshMode.DECLARED_AND_EMPTY);
+ }
+
+ private boolean isDeclaredOnly(Table table) {
+ return isDeclaredOnly(table.properties());
+ }
+
+ private boolean isDeclaredOnly(Map<String, String> properties) {
+ return
Optional.ofNullable(properties.get(LanceConstants.LANCE_TABLE_DECLARED))
+ .map(Boolean::parseBoolean)
+ .orElse(false);
+ }
+
+ private boolean isDatasetVersionChanged(Table table, long datasetVersion) {
+ return isDatasetVersionChanged(table.properties(), datasetVersion);
+ }
+
+ private boolean isDatasetVersionChanged(Map<String, String> properties, long
datasetVersion) {
+ String version = properties.get(LanceConstants.LANCE_TABLE_VERSION);
+ if (StringUtils.isBlank(version)) {
+ return true;
+ }
+
+ try {
+ return Long.parseLong(version) != datasetVersion;
+ } catch (NumberFormatException e) {
+ return true;
+ }
+ }
+
+ private Column[] extractColumns(Schema arrowSchema) {
+ return arrowSchema.getFields().stream()
+ .map(
+ field ->
+ Column.of(
+ field.getName(),
+ LanceDataTypeConverter.CONVERTER.toGravitino(field),
+ null,
+ field.isNullable(),
+ false,
+ DEFAULT_VALUE_NOT_SET))
+ .toArray(Column[]::new);
+ }
+
+ private Table repairTableMetadata(NameIdentifier ident, Column[] columns,
long datasetVersion) {
+ try {
+ TableEntity tableEntity =
+ store.update(
+ ident,
+ TableEntity.class,
+ Entity.EntityType.TABLE,
+ current -> {
+ if (!needsSchemaRefresh(current, datasetVersion)) {
+ return current;
+ }
+ return replaceColumnsFromDataset(current, columns,
datasetVersion);
+ });
+ return toGenericTable(tableEntity);
+ } catch (NoSuchEntityException e) {
+ throw new NoSuchTableException(e, "Table %s does not exist", ident);
+ } catch (EntityAlreadyExistsException e) {
+ throw new IllegalArgumentException("Failed to repair table " + ident, e);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to repair table " + ident, e);
+ }
+ }
+
+ private Table recordCheckedEmptyVersion(NameIdentifier ident, long
datasetVersion) {
+ try {
+ TableEntity tableEntity =
+ store.update(
+ ident,
+ TableEntity.class,
+ Entity.EntityType.TABLE,
+ current -> {
+ if (!isDatasetVersionChanged(current.properties(),
datasetVersion)) {
+ return current;
+ }
+ Map<String, String> updatedProperties = new
HashMap<>(current.properties());
+ updatedProperties.put(
+ LanceConstants.LANCE_TABLE_VERSION,
String.valueOf(datasetVersion));
+ // Always use an empty column list: the dataset is confirmed
empty at this version.
+ // Using current.columns() would preserve stale columns when
the dataset schema was
+ // cleared externally, causing future VERSION_CHECK loads to
return stale metadata
+ // permanently (the version sentinel would match but columns
would be wrong).
+ return TableEntity.builder()
+ .withId(current.id())
+ .withName(current.name())
+ .withNamespace(current.namespace())
+ .withComment(current.comment())
+ .withColumns(List.of())
+ .withProperties(updatedProperties)
+ .withPartitioning(current.partitioning())
+ .withDistribution(current.distribution())
+ .withSortOrders(current.sortOrders())
+ .withIndexes(current.indexes())
+ .withAuditInfo(
+ AuditInfo.builder()
+ .withCreator(current.auditInfo().creator())
+ .withCreateTime(current.auditInfo().createTime())
+
.withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
+ .withLastModifiedTime(Instant.now())
+ .build())
+ .build();
+ });
+ return toGenericTable(tableEntity);
+ } catch (NoSuchEntityException e) {
+ throw new NoSuchTableException(e, "Table %s does not exist", ident);
+ } catch (EntityAlreadyExistsException e) {
+ throw new IllegalArgumentException("Failed to record empty version for
table " + ident, e);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to record empty version for table " +
ident, e);
+ }
+ }
+
+ private boolean needsSchemaRefresh(TableEntity tableEntity, long
datasetVersion) {
+ return isDeclaredOnly(tableEntity.properties())
+ || tableEntity.columns().isEmpty()
+ || isDatasetVersionChanged(tableEntity.properties(), datasetVersion);
+ }
+
+ private TableEntity replaceColumnsFromDataset(
+ TableEntity tableEntity, Column[] columns, long datasetVersion) {
+ Map<String, String> updatedProperties = new
HashMap<>(tableEntity.properties());
+ updatedProperties.put(LanceConstants.LANCE_TABLE_VERSION,
String.valueOf(datasetVersion));
+ updatedProperties.remove(LanceConstants.LANCE_TABLE_DECLARED);
+
+ AuditInfo columnAuditInfo =
+ AuditInfo.builder()
+ .withCreator(PrincipalUtils.getCurrentPrincipal().getName())
+ .withCreateTime(Instant.now())
+ .build();
+ List<ColumnEntity> columnEntities =
+ IntStream.range(0, columns.length)
+ .mapToObj(
+ i ->
+ ColumnEntity.toColumnEntity(
+ columns[i], i, idGenerator.nextId(), columnAuditInfo))
+ .collect(Collectors.toList());
+
+ return TableEntity.builder()
+ .withId(tableEntity.id())
+ .withName(tableEntity.name())
+ .withNamespace(tableEntity.namespace())
+ .withComment(tableEntity.comment())
+ .withColumns(columnEntities)
+ .withProperties(updatedProperties)
+ .withPartitioning(tableEntity.partitioning())
+ .withDistribution(tableEntity.distribution())
+ .withSortOrders(tableEntity.sortOrders())
+ .withIndexes(tableEntity.indexes())
+ .withAuditInfo(
+ AuditInfo.builder()
+ .withCreator(tableEntity.auditInfo().creator())
+ .withCreateTime(tableEntity.auditInfo().createTime())
+
.withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
+ .withLastModifiedTime(Instant.now())
+ .build())
+ .build();
+ }
+
+ private GenericTable toGenericTable(TableEntity tableEntity) {
+ return GenericTable.builder()
+ .withName(tableEntity.name())
+ .withComment(tableEntity.comment())
+ .withColumns(
+
tableEntity.columns().stream().map(this::toGenericColumn).toArray(Column[]::new))
+ .withProperties(tableEntity.properties())
+ .withAuditInfo(tableEntity.auditInfo())
+ .withSortOrders(tableEntity.sortOrders())
+ .withPartitioning(tableEntity.partitioning())
+ .withDistribution(tableEntity.distribution())
+ .withIndexes(tableEntity.indexes())
+ .build();
+ }
+
+ private GenericColumn toGenericColumn(ColumnEntity columnEntity) {
+ return GenericColumn.builder()
+ .withName(columnEntity.name())
+ .withComment(columnEntity.comment())
+ .withAutoIncrement(columnEntity.autoIncrement())
+ .withNullable(columnEntity.nullable())
+ .withType(columnEntity.dataType())
+ .withDefaultValue(columnEntity.defaultValue())
+ .build();
}
/**
@@ -435,8 +726,10 @@ public class LanceTableOperations extends
ManagedTableOperations {
}
Dataset openDataset(String location, Map<String, String> storageOptions) {
+ // Do not pass an explicit allocator: OpenDatasetBuilder then sets
selfManagedAllocator=true
+ // so Dataset.close() will release the off-heap memory. Passing new
RootAllocator() sets
+ // selfManagedAllocator=false and leaks the allocator on every call.
return Dataset.open()
- .allocator(new RootAllocator())
.uri(location)
.readOptions(new
ReadOptions.Builder().setStorageOptions(storageOptions).build())
.build();
diff --git
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestPropertiesMetadata.java
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestPropertiesMetadata.java
index 5371a63f5a..a57474be2e 100644
---
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestPropertiesMetadata.java
+++
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestPropertiesMetadata.java
@@ -22,6 +22,7 @@ package org.apache.gravitino.catalog.lakehouse.generic;
import com.google.common.collect.ImmutableMap;
import java.util.Map;
import org.apache.gravitino.Schema;
+import org.apache.gravitino.catalog.lakehouse.lance.LanceTableOperations;
import org.apache.gravitino.connector.PropertiesMetadata;
import org.apache.gravitino.rel.Table;
import org.junit.jupiter.api.Assertions;
@@ -44,7 +45,8 @@ public class TestPropertiesMetadata {
Map<String, String> catalogProperties =
ImmutableMap.of(
"location", "/tmp/test1",
- "lance.storage.endpoint", "http://minio:9000");
+ "lance.storage.endpoint", "http://minio:9000",
+ "lance.schema-refresh-mode", "VERSION_CHECK");
String catalogLocation =
(String)
@@ -55,6 +57,10 @@ public class TestPropertiesMetadata {
Assertions.assertEquals(
"http://minio:9000",
catalogPropertiesMetadata.getOrDefault(catalogProperties,
"lance.storage.endpoint"));
+
Assertions.assertTrue(catalogPropertiesMetadata.containsProperty("lance.schema-refresh-mode"));
+ Assertions.assertEquals(
+ LanceTableOperations.SchemaRefreshMode.VERSION_CHECK,
+ catalogPropertiesMetadata.getOrDefault(catalogProperties,
"lance.schema-refresh-mode"));
}
@Test
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 7362f903cb..59b2a41ff9 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
@@ -19,18 +19,37 @@
package org.apache.gravitino.catalog.lakehouse.lance;
import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_CREATION_MODE;
+import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_SCHEMA_REFRESH_MODE;
import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_STORAGE_OPTIONS_PREFIX;
+import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_DECLARED;
+import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_VERSION;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.common.collect.Maps;
+import java.time.Instant;
+import java.util.List;
import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.apache.gravitino.Entity;
import org.apache.gravitino.EntityStore;
import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.UserPrincipal;
import org.apache.gravitino.catalog.ManagedSchemaOperations;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.ColumnEntity;
+import org.apache.gravitino.meta.TableEntity;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.Table;
import org.apache.gravitino.rel.TableChange;
@@ -39,6 +58,7 @@ import
org.apache.gravitino.rel.expressions.transforms.Transform;
import org.apache.gravitino.rel.indexes.Index;
import org.apache.gravitino.rel.types.Types;
import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.utils.PrincipalUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -100,6 +120,274 @@ public class TestLanceTableOperations {
new Index[0]));
}
+ @Test
+ public void testLoadDeclaredTableSchemaFromLocation() throws Exception {
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("declared-table").toString();
+ TableEntity tableEntity =
+ tableEntity(
+ ident,
+ List.of(),
+ Map.of(
+ Table.PROPERTY_LOCATION,
+ location,
+ LANCE_TABLE_DECLARED,
+ "true",
+ LANCE_STORAGE_OPTIONS_PREFIX + "endpoint",
+ "http://endpoint"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+ when(idGenerator.nextId()).thenReturn(10L, 11L);
+ when(store.update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenAnswer(
+ invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<TableEntity, TableEntity> updater =
invocation.getArgument(3);
+ return updater.apply(tableEntity);
+ });
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema())
+ .thenReturn(
+ new Schema(
+ List.of(
+ Field.nullable("id", new ArrowType.Int(32, true)),
+ Field.nullable("name", new ArrowType.Utf8()))));
+ when(dataset.version()).thenReturn(8L);
+ Mockito.doReturn(dataset)
+ .when(lanceTableOps)
+ .openDataset(location, Map.of("endpoint", "http://endpoint"));
+
+ Table loadedTable =
+ PrincipalUtils.doAs(new UserPrincipal("tester"), () ->
lanceTableOps.loadTable(ident));
+
+ Assertions.assertEquals(2, loadedTable.columns().length);
+ Assertions.assertEquals("id", loadedTable.columns()[0].name());
+ Assertions.assertEquals(Types.IntegerType.get(),
loadedTable.columns()[0].dataType());
+ Assertions.assertEquals("name", loadedTable.columns()[1].name());
+ Assertions.assertEquals(Types.StringType.get(),
loadedTable.columns()[1].dataType());
+ Assertions.assertEquals("8",
loadedTable.properties().get(LANCE_TABLE_VERSION));
+
Assertions.assertFalse(loadedTable.properties().containsKey(LANCE_TABLE_DECLARED));
+ }
+
+ @Test
+ public void testLoadTableWithStoredColumnsDoesNotReadLocation() throws
Exception {
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("normal-table").toString();
+ TableEntity tableEntity =
+ tableEntity(
+ ident,
+ List.of(
+ ColumnEntity.builder()
+ .withId(10L)
+ .withName("id")
+ .withDataType(Types.IntegerType.get())
+ .withPosition(0)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build()),
+ Map.of(Table.PROPERTY_LOCATION, location));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+
+ Table loadedTable = lanceTableOps.loadTable(ident);
+
+ Assertions.assertEquals(1, loadedTable.columns().length);
+ Assertions.assertEquals("id", loadedTable.columns()[0].name());
+ verify(lanceTableOps, never()).openDataset(anyString(), any());
+ }
+
+ @Test
+ public void testVersionCheckRefreshesSchemaFromLocation() throws Exception {
+ lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE,
"version-check"));
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("version-check-table").toString();
+ TableEntity tableEntity =
+ tableEntity(
+ ident,
+ List.of(
+ ColumnEntity.builder()
+ .withId(10L)
+ .withName("old_col")
+ .withDataType(Types.StringType.get())
+ .withPosition(0)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build()),
+ Map.of(Table.PROPERTY_LOCATION, location, LANCE_TABLE_VERSION,
"8"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+ when(idGenerator.nextId()).thenReturn(11L, 12L);
+ when(store.update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenAnswer(
+ invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<TableEntity, TableEntity> updater =
invocation.getArgument(3);
+ return updater.apply(tableEntity);
+ });
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema())
+ .thenReturn(
+ new Schema(
+ List.of(
+ Field.nullable("id", new ArrowType.Int(32, true)),
+ Field.nullable("name", new ArrowType.Utf8()))));
+ when(dataset.version()).thenReturn(9L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loadedTable =
+ PrincipalUtils.doAs(new UserPrincipal("tester"), () ->
lanceTableOps.loadTable(ident));
+
+ Assertions.assertEquals(2, loadedTable.columns().length);
+ Assertions.assertEquals("id", loadedTable.columns()[0].name());
+ Assertions.assertEquals("name", loadedTable.columns()[1].name());
+ Assertions.assertEquals("9",
loadedTable.properties().get(LANCE_TABLE_VERSION));
+ }
+
+ @Test
+ public void testVersionCheckSkipsRefreshWhenVersionIsCurrent() throws
Exception {
+ lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE,
"version-check"));
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("current-version-table").toString();
+ TableEntity tableEntity =
+ tableEntity(
+ ident,
+ List.of(
+ ColumnEntity.builder()
+ .withId(10L)
+ .withName("id")
+ .withDataType(Types.IntegerType.get())
+ .withPosition(0)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build()),
+ Map.of(Table.PROPERTY_LOCATION, location, LANCE_TABLE_VERSION,
"9"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.version()).thenReturn(9L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loadedTable = lanceTableOps.loadTable(ident);
+
+ Assertions.assertEquals(1, loadedTable.columns().length);
+ Assertions.assertEquals("id", loadedTable.columns()[0].name());
+ verify(dataset, never()).getSchema();
+ verify(store, never())
+ .update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE),
any());
+ }
+
+ @Test
+ public void
testVersionCheckRefreshIsIdempotentWhenCurrentEntityWasAlreadyRepaired()
+ throws Exception {
+ lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE,
"version-check"));
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location =
tempDir.resolve("concurrent-version-check-table").toString();
+ TableEntity staleTableEntity =
+ tableEntity(
+ ident,
+ List.of(
+ ColumnEntity.builder()
+ .withId(10L)
+ .withName("old_col")
+ .withDataType(Types.StringType.get())
+ .withPosition(0)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build()),
+ Map.of(Table.PROPERTY_LOCATION, location, LANCE_TABLE_VERSION,
"8"));
+ TableEntity alreadyRepairedTableEntity =
+ tableEntity(
+ ident,
+ List.of(
+ ColumnEntity.builder()
+ .withId(11L)
+ .withName("id")
+ .withDataType(Types.IntegerType.get())
+ .withPosition(0)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build(),
+ ColumnEntity.builder()
+ .withId(12L)
+ .withName("name")
+ .withDataType(Types.StringType.get())
+ .withPosition(1)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build()),
+ Map.of(Table.PROPERTY_LOCATION, location, LANCE_TABLE_VERSION,
"9"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(staleTableEntity);
+ when(store.update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenAnswer(
+ invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<TableEntity, TableEntity> updater =
invocation.getArgument(3);
+ return updater.apply(alreadyRepairedTableEntity);
+ });
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema())
+ .thenReturn(
+ new Schema(
+ List.of(
+ Field.nullable("id", new ArrowType.Int(32, true)),
+ Field.nullable("name", new ArrowType.Utf8()))));
+ when(dataset.version()).thenReturn(9L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loadedTable =
+ PrincipalUtils.doAs(new UserPrincipal("tester"), () ->
lanceTableOps.loadTable(ident));
+
+ Assertions.assertEquals(2, loadedTable.columns().length);
+ Assertions.assertEquals("id", loadedTable.columns()[0].name());
+ Assertions.assertEquals("name", loadedTable.columns()[1].name());
+ Assertions.assertEquals("9",
loadedTable.properties().get(LANCE_TABLE_VERSION));
+ }
+
+ @Test
+ public void testAlterTablePersistsUpdatedLanceVersion() throws Exception {
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("alter-table").toString();
+ TableEntity tableEntity =
+ tableEntity(
+ ident,
+ List.of(
+ ColumnEntity.builder()
+ .withId(10L)
+ .withName("id")
+ .withDataType(Types.IntegerType.get())
+ .withPosition(0)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build()),
+ Map.of(Table.PROPERTY_LOCATION, location, LANCE_TABLE_VERSION,
"8"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+ AtomicReference<TableEntity> storedTable = new AtomicReference<>();
+ when(store.update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenAnswer(
+ invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<TableEntity, TableEntity> updater =
invocation.getArgument(3);
+ TableEntity updated = updater.apply(tableEntity);
+ storedTable.set(updated);
+ return updated;
+ });
+
+ Dataset dataset = mock(Dataset.class);
+ Version version = mock(Version.class);
+ when(dataset.getVersion()).thenReturn(version);
+ when(version.getId()).thenReturn(9L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table alteredTable =
+ PrincipalUtils.doAs(
+ new UserPrincipal("tester"),
+ () ->
+ lanceTableOps.alterTable(
+ ident, TableChange.deleteColumn(new String[] {"id"},
false)));
+
+ Assertions.assertEquals("9",
alteredTable.properties().get(LANCE_TABLE_VERSION));
+ Assertions.assertEquals("9",
storedTable.get().properties().get(LANCE_TABLE_VERSION));
+ }
+
@Test
public void testHandleLanceTableChangeRespectsOrder() {
Table table = mock(Table.class);
@@ -168,4 +456,417 @@ public class TestLanceTableOperations {
Mockito.verify(dataset).dropColumns(anyList());
Mockito.verify(dataset).getVersion();
}
+
+ @Test
+ public void
testVersionCheckSkipsSchemaReadForEmptySchemaWhenVersionUnchanged() throws
Exception {
+ lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE,
"version-check"));
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("empty-version-check").toString();
+ // Table has empty columns but a known stored version (confirmed-empty
state)
+ TableEntity tableEntity =
+ tableEntity(
+ ident, List.of(), Map.of(Table.PROPERTY_LOCATION, location,
LANCE_TABLE_VERSION, "5"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.version()).thenReturn(5L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loaded = lanceTableOps.loadTable(ident);
+
+ Assertions.assertEquals(0, loaded.columns().length);
+ // version unchanged — schema read must be skipped even though columns are
empty
+ verify(dataset, never()).getSchema();
+ verify(store, never())
+ .update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE),
any());
+ }
+
+ @Test
+ public void testEmptyDatasetRecordsVersionOnFirstLoad() throws Exception {
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("empty-dataset-first").toString();
+ TableEntity tableEntity =
+ tableEntity(ident, List.of(), Map.of(Table.PROPERTY_LOCATION,
location));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+ when(store.update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenAnswer(
+ invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<TableEntity, TableEntity> updater =
invocation.getArgument(3);
+ return updater.apply(tableEntity);
+ });
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema()).thenReturn(new Schema(List.of()));
+ when(dataset.version()).thenReturn(3L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loaded =
+ PrincipalUtils.doAs(new UserPrincipal("tester"), () ->
lanceTableOps.loadTable(ident));
+
+ Assertions.assertEquals(0, loaded.columns().length);
+ Assertions.assertEquals("3", loaded.properties().get(LANCE_TABLE_VERSION));
+ verify(store).update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any());
+ }
+
+ @Test
+ public void testEmptyDatasetSkipsOpenWhenVersionAlreadyRecorded() throws
Exception {
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("empty-dataset-second").toString();
+ // Simulate the table after first-load recorded lance.version=3 but
columns still empty
+ TableEntity tableEntity =
+ tableEntity(
+ ident, List.of(), Map.of(Table.PROPERTY_LOCATION, location,
LANCE_TABLE_VERSION, "3"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+
+ Table loaded = lanceTableOps.loadTable(ident);
+
+ Assertions.assertEquals(0, loaded.columns().length);
+ verify(lanceTableOps, never()).openDataset(anyString(), any());
+ verify(store, never())
+ .update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE),
any());
+ }
+
+ //
---------------------------------------------------------------------------
+ // VERSION_CHECK: dataset becomes empty while stored columns are non-empty
+ //
---------------------------------------------------------------------------
+
+ @Test
+ public void testVersionCheckClearsStaleColumnsWhenDatasetBecomesEmpty()
throws Exception {
+ // Regression test for: VERSION_CHECK + stored columns non-empty + dataset
schema becomes empty.
+ // recordCheckedEmptyVersion must clear the stale stored columns (not
preserve them via
+ // current.columns()), otherwise the version sentinel locks in permanently
stale metadata.
+ lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE,
"version-check"));
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location =
tempDir.resolve("stale-columns-empty-dataset").toString();
+ TableEntity tableEntity =
+ tableEntity(
+ ident,
+ List.of(
+ ColumnEntity.builder()
+ .withId(10L)
+ .withName("id")
+ .withDataType(Types.IntegerType.get())
+ .withPosition(0)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build(),
+ ColumnEntity.builder()
+ .withId(11L)
+ .withName("name")
+ .withDataType(Types.StringType.get())
+ .withPosition(1)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build()),
+ Map.of(Table.PROPERTY_LOCATION, location, LANCE_TABLE_VERSION,
"8"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+ when(store.update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenAnswer(
+ invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<TableEntity, TableEntity> updater =
invocation.getArgument(3);
+ return updater.apply(tableEntity);
+ });
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema()).thenReturn(new Schema(List.of()));
+ when(dataset.version()).thenReturn(9L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loaded =
+ PrincipalUtils.doAs(new UserPrincipal("tester"), () ->
lanceTableOps.loadTable(ident));
+
+ // Stale columns must be cleared; storing [id, name] here would
permanently lock them in.
+ Assertions.assertEquals(0, loaded.columns().length);
+ // The new dataset version must be persisted so future VERSION_CHECK loads
see the match.
+ Assertions.assertEquals("9", loaded.properties().get(LANCE_TABLE_VERSION));
+ }
+
+ @Test
+ public void testVersionCheckStaleColumnsAreNotReturnedOnSubsequentLoad()
throws Exception {
+ // After the fix: once stale columns are cleared and version=9 is
recorded, the next loadTable
+ // must early-return with empty columns (not re-open the dataset and not
return stale data).
+ lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE,
"version-check"));
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("subsequent-empty-load").toString();
+ // Simulate the store state after the first load cleared stale columns.
+ TableEntity tableEntity =
+ tableEntity(
+ ident, List.of(), Map.of(Table.PROPERTY_LOCATION, location,
LANCE_TABLE_VERSION, "9"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.version()).thenReturn(9L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loaded = lanceTableOps.loadTable(ident);
+
+ Assertions.assertEquals(0, loaded.columns().length);
+ // Version matched: schema read must be skipped entirely.
+ verify(dataset, never()).getSchema();
+ verify(store, never())
+ .update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE),
any());
+ }
+
+ //
---------------------------------------------------------------------------
+ // VERSION_CHECK: empty stored columns
+ //
---------------------------------------------------------------------------
+
+ @Test
+ public void testVersionCheckFirstLoadEmptyStoredColumnsEmptyDataset() throws
Exception {
+ // VERSION_CHECK + no stored version + empty stored columns + dataset is
also empty.
+ // Should open dataset, read schema (empty), and record version without
creating any columns.
+ lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE,
"version-check"));
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("vc-empty-first-load").toString();
+ TableEntity tableEntity =
+ tableEntity(ident, List.of(), Map.of(Table.PROPERTY_LOCATION,
location));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+ when(store.update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenAnswer(
+ invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<TableEntity, TableEntity> updater =
invocation.getArgument(3);
+ return updater.apply(tableEntity);
+ });
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema()).thenReturn(new Schema(List.of()));
+ when(dataset.version()).thenReturn(5L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loaded =
+ PrincipalUtils.doAs(new UserPrincipal("tester"), () ->
lanceTableOps.loadTable(ident));
+
+ Assertions.assertEquals(0, loaded.columns().length);
+ Assertions.assertEquals("5", loaded.properties().get(LANCE_TABLE_VERSION));
+ verify(store).update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any());
+ }
+
+ @Test
+ public void
testVersionCheckEmptyStoredColumnsVersionBumpedDatasetStillEmpty() throws
Exception {
+ // VERSION_CHECK + stored empty columns with version=5 + dataset bumped to
version=6 (still
+ // empty). Should detect version change, read schema (empty), and update
version to 6.
+ lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE,
"version-check"));
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("vc-empty-version-bump").toString();
+ TableEntity tableEntity =
+ tableEntity(
+ ident, List.of(), Map.of(Table.PROPERTY_LOCATION, location,
LANCE_TABLE_VERSION, "5"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+ when(store.update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenAnswer(
+ invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<TableEntity, TableEntity> updater =
invocation.getArgument(3);
+ return updater.apply(tableEntity);
+ });
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema()).thenReturn(new Schema(List.of()));
+ when(dataset.version()).thenReturn(6L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loaded =
+ PrincipalUtils.doAs(new UserPrincipal("tester"), () ->
lanceTableOps.loadTable(ident));
+
+ Assertions.assertEquals(0, loaded.columns().length);
+ Assertions.assertEquals("6", loaded.properties().get(LANCE_TABLE_VERSION));
+ }
+
+ //
---------------------------------------------------------------------------
+ // DECLARED_AND_EMPTY: non-declared empty stored columns with real dataset
schema
+ //
---------------------------------------------------------------------------
+
+ @Test
+ public void
testDeclaredAndEmptyRepairsNonDeclaredEmptyStoredColumnsFromRealDataset()
+ throws Exception {
+ // DECLARED_AND_EMPTY + non-declared table + empty stored columns (no
version) + dataset has a
+ // real schema. loadTable should open the dataset, read the schema, and
persist the columns.
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location =
tempDir.resolve("dae-empty-stored-real-schema").toString();
+ TableEntity tableEntity =
+ tableEntity(ident, List.of(), Map.of(Table.PROPERTY_LOCATION,
location));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+ when(idGenerator.nextId()).thenReturn(20L, 21L);
+ when(store.update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenAnswer(
+ invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<TableEntity, TableEntity> updater =
invocation.getArgument(3);
+ return updater.apply(tableEntity);
+ });
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema())
+ .thenReturn(
+ new Schema(
+ List.of(
+ Field.nullable("col_a", new ArrowType.Int(64, true)),
+ Field.nullable("col_b", new ArrowType.Bool()))));
+ when(dataset.version()).thenReturn(7L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loaded =
+ PrincipalUtils.doAs(new UserPrincipal("tester"), () ->
lanceTableOps.loadTable(ident));
+
+ Assertions.assertEquals(2, loaded.columns().length);
+ Assertions.assertEquals("col_a", loaded.columns()[0].name());
+ Assertions.assertEquals("col_b", loaded.columns()[1].name());
+ Assertions.assertEquals("7", loaded.properties().get(LANCE_TABLE_VERSION));
+ }
+
+ //
---------------------------------------------------------------------------
+ // Edge cases: no location, dataset open failure
+ //
---------------------------------------------------------------------------
+
+ @Test
+ public void testLoadTableWithNoLocationReturnsStoredMetadata() throws
Exception {
+ // A table without a PROPERTY_LOCATION must return stored metadata
immediately without
+ // attempting to open any dataset, even in VERSION_CHECK mode.
+ lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE,
"version-check"));
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ // Declared table with empty columns and no location triggers the
schema-refresh branch.
+ TableEntity tableEntity = tableEntity(ident, List.of(),
Map.of(LANCE_TABLE_DECLARED, "true"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+
+ Table loaded = lanceTableOps.loadTable(ident);
+
+ Assertions.assertEquals(0, loaded.columns().length);
+ verify(lanceTableOps, never()).openDataset(anyString(), any());
+ verify(store, never())
+ .update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE),
any());
+ }
+
+ @Test
+ public void testLoadTableFallsBackToStoredMetadataWhenDatasetOpenFails()
throws Exception {
+ // If the Lance dataset cannot be opened (e.g. storage not accessible),
loadTable must return
+ // the stored metadata rather than propagating the exception.
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("broken-dataset").toString();
+ TableEntity tableEntity =
+ tableEntity(ident, List.of(), Map.of(Table.PROPERTY_LOCATION,
location));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+ Mockito.doThrow(new RuntimeException("storage unavailable"))
+ .when(lanceTableOps)
+ .openDataset(eq(location), any());
+
+ Table loaded = lanceTableOps.loadTable(ident);
+
+ Assertions.assertEquals(0, loaded.columns().length);
+ verify(store, never())
+ .update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE),
any());
+ }
+
+ //
---------------------------------------------------------------------------
+ // Declared table: dataset open but no recordCheckedEmptyVersion
+ //
---------------------------------------------------------------------------
+
+ @Test
+ public void testDeclaredTableWithEmptyDatasetDoesNotRecordVersion() throws
Exception {
+ // Declared tables use lance.declared=true as the "not yet written"
signal. When the dataset is
+ // empty, the caller should NOT record the checked version (that would
advance the version
+ // sentinel while the declared flag is still present). The returned table
must stay unchanged.
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("declared-empty-dataset").toString();
+ TableEntity tableEntity =
+ tableEntity(
+ ident,
+ List.of(),
+ Map.of(Table.PROPERTY_LOCATION, location, LANCE_TABLE_DECLARED,
"true"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema()).thenReturn(new Schema(List.of()));
+ when(dataset.version()).thenReturn(3L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loaded =
+ PrincipalUtils.doAs(new UserPrincipal("tester"), () ->
lanceTableOps.loadTable(ident));
+
+ // lance.declared is still present (no schema written yet), no version
recorded.
+ Assertions.assertTrue(
+ Boolean.parseBoolean(loaded.properties().get(LANCE_TABLE_DECLARED)),
+ "lance.declared must still be set");
+ Assertions.assertNull(
+ loaded.properties().get(LANCE_TABLE_VERSION), "lance.version must not
be recorded");
+ verify(store, never())
+ .update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE),
any());
+ }
+
+ //
---------------------------------------------------------------------------
+ // VERSION_CHECK: declared table always refreshes even when version matches
+ //
---------------------------------------------------------------------------
+
+ @Test
+ public void
testVersionCheckDeclaredTableAlwaysRefreshesDespiteVersionMatch() throws
Exception {
+ // In VERSION_CHECK mode the early-return is gated on !declaredOnly, so
declared tables must
+ // always open the dataset and repair their schema regardless of the
stored lance.version.
+ lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE,
"version-check"));
+ NameIdentifier ident = NameIdentifier.of("schema", "table");
+ String location = tempDir.resolve("vc-declared-version-match").toString();
+ // Stored version already matches the dataset version.
+ TableEntity tableEntity =
+ tableEntity(
+ ident,
+ List.of(),
+ Map.of(
+ Table.PROPERTY_LOCATION,
+ location,
+ LANCE_TABLE_DECLARED,
+ "true",
+ LANCE_TABLE_VERSION,
+ "9"));
+ when(store.get(eq(ident), eq(Entity.EntityType.TABLE),
eq(TableEntity.class)))
+ .thenReturn(tableEntity);
+ when(idGenerator.nextId()).thenReturn(30L);
+ when(store.update(eq(ident), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenAnswer(
+ invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<TableEntity, TableEntity> updater =
invocation.getArgument(3);
+ return updater.apply(tableEntity);
+ });
+
+ Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema())
+ .thenReturn(new Schema(List.of(Field.nullable("id", new
ArrowType.Int(32, true)))));
+ when(dataset.version()).thenReturn(9L);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location,
Map.of());
+
+ Table loaded =
+ PrincipalUtils.doAs(new UserPrincipal("tester"), () ->
lanceTableOps.loadTable(ident));
+
+ // Schema must be read and persisted — declared tables bypass the version
early-return.
+ Assertions.assertEquals(1, loaded.columns().length);
+ Assertions.assertEquals("id", loaded.columns()[0].name());
+ Assertions.assertFalse(
+ loaded.properties().containsKey(LANCE_TABLE_DECLARED),
+ "lance.declared must be removed after schema is written");
+ verify(dataset).getSchema();
+ }
+
+ private static TableEntity tableEntity(
+ NameIdentifier ident, List<ColumnEntity> columns, Map<String, String>
properties) {
+ return TableEntity.builder()
+ .withId(1L)
+ .withName(ident.name())
+ .withNamespace(ident.namespace())
+ .withComment("comment")
+ .withColumns(columns)
+ .withProperties(properties)
+ .withAuditInfo(
+
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.EPOCH).build())
+ .build();
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
index c2646f4b40..f6ef8d9c3b 100644
---
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
+++
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
@@ -886,7 +886,13 @@ public class TableOperationDispatcher extends
OperationDispatcher implements Tab
.withId(entity.id())
.withName(entity.name())
.withNamespace(entity.namespace())
+ .withComment(entity.comment())
+ .withProperties(entity.properties())
.withColumns(columnsUpdateResult.getRight())
+ .withPartitioning(entity.partitioning())
+ .withDistribution(entity.distribution())
+ .withSortOrders(entity.sortOrders())
+ .withIndexes(entity.indexes())
.withAuditInfo(
AuditInfo.builder()
.withCreator(entity.auditInfo().creator())
diff --git a/docs/lakehouse-generic-catalog.md
b/docs/lakehouse-generic-catalog.md
index 0f24628c55..263b552c88 100644
--- a/docs/lakehouse-generic-catalog.md
+++ b/docs/lakehouse-generic-catalog.md
@@ -44,10 +44,11 @@ For detailed information on available operations, see
[Manage Relational Metadat
### Catalog Properties
-| Property | Description | Example
| Required | Since Version |
-|------------|----------------------------------------------|-------------------------|----------|---------------|
-| `provider` | Catalog provider type |
`lakehouse-generic` | Yes | 1.1.0 |
-| `location` | Root storage path for all schemas and tables |
`s3://bucket/lakehouse` | No | 1.1.0 |
+| Property | Description
| Example | Required | Since Version |
+|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------|----------|---------------|
+| `provider` | Catalog provider type
| `lakehouse-generic` | Yes | 1.1.0 |
+| `location` | Root storage path for all schemas and tables
| `s3://bucket/lakehouse` | No | 1.1.0 |
+| `lance.schema-refresh-mode` | Lance table schema refresh mode.
`DECLARED_AND_EMPTY` (default) refreshes declared tables and tables with empty
stored columns. `VERSION_CHECK` additionally refreshes when the Lance dataset
version changes. | `DECLARED_AND_EMPTY` | No | 1.3.0 |
#### Key Property: `location`
@@ -199,4 +200,4 @@ For additional operations, refer to [Schema Operations
documentation](./manage-r
Since different lakehouse table formats have varying capabilities, table
operation support may differ. The following are table operations for different
lakehouse formats:
-- [Lance Format Support](./lakehouse-generic-lance-table.md)
\ No newline at end of file
+- [Lance Format Support](./lakehouse-generic-lance-table.md)
diff --git a/docs/lakehouse-generic-lance-table.md
b/docs/lakehouse-generic-lance-table.md
index 6e88c301a8..cdbd0bb79a 100644
--- a/docs/lakehouse-generic-lance-table.md
+++ b/docs/lakehouse-generic-lance-table.md
@@ -101,14 +101,14 @@ For Arrow types not natively mapped in Gravitino, use the
`External(arrow_field_
Required and optional properties for tables in a Generic Lakehouse Catalog:
-| Property | Description
| Default | Required | Since
Version |
-|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------------|---------------|
-| `format` | Table format: `lance`, only `lance` is fully
supported.
| (none) | Yes |
1.1.0 |
-| `location` | Storage path for table metadata and data, Lance
supports: S3, GCS, OSS, AZ, File, Memory and file-object-store.
| (none) | Conditional* |
1.1.0 |
-| `external` | Whether the data directory is an external location.
If it's `true`, dropping a table will only remove metadata in Gravitino and
will not delete the data directory, and purge table will delete both. For a
non-external table, dropping will drop both.
| false | No |
1.1.0 |
-| `lance.creation-mode` | Create mode: for create table, it can be `CREATE`,
`EXIST_OK` or `OVERWRITE`. and it should be `CREATE` or `OVERWRITE` for
registering tables
| `CREATE` | No
| 1.1.0 |
-| `lance.register` | Whether it is a register table operation. If it's
`true`, This API will not create data directory actually and it's the user's
responsibility to create and manage the data directory. `false` it will
actually create a table.
| false | No
| 1.1.0 |
-| `lance.storage.xxxx` | Any additional storage-specific properties required
by Lance format (e.g., S3 credentials, HDFS configs). Replace `xxxx` with
actual property names. For example, we can use
`lance.storage.aws_access_key_id` to set S3 aws_access_key_id when using a S3
location, for detail, refer to https://lancedb.com/docs/storage/integrations/
| (none) | No | 1.1.0 |
+| Property | Description
| Default | Required | Since Version |
+|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------------|---------------|
+| `format` | Table format: `lance`, only `lance` is fully
supported.
| (none) | Yes | 1.1.0
|
+| `location` | Storage path for table metadata and data, Lance
supports: S3, GCS, OSS, AZ, File, Memory and file-object-store.
| (none) | Conditional* | 1.1.0
|
+| `external` | Whether the data directory is an external location.
If it's `true`, dropping a table will only remove metadata in Gravitino and
will not delete the data directory, and purge table will delete both. For a
non-external table, dropping will drop both.
| false | No | 1.1.0
|
+| `lance.creation-mode` | Create mode: for create table, it can be `CREATE`,
`EXIST_OK` or `OVERWRITE`. and it should be `CREATE` or `OVERWRITE` for
registering tables
| `CREATE` | No |
1.1.0 |
+| `lance.register` | Whether it is a register table operation. If it's
`true`, This API will not create data directory actually and it's the user's
responsibility to create and manage the data directory. `false` it will
actually create a table.
| false | No |
1.1.0 |
+| `lance.storage.xxxx` | Any additional storage-specific properties required
by Lance format (e.g., S3 credentials, HDFS configs). Replace `xxxx` with
actual property names. For example, we can use
`lance.storage.aws_access_key_id` to set S3 aws_access_key_id when using a S3
location, for detail, refer to https://lancedb.com/docs/storage/integrations/
| (none) | No | 1.1.0 |
- `CREATE`: Create a new table, fail if the table already exists.
- `EXIST_OK`: Create a new table if it does not exist, otherwise do nothing.
@@ -118,6 +118,27 @@ Required and optional properties for tables in a Generic
Lakehouse Catalog:
Also set additional properties specific to your lakehouse format or custom
requirements.
+### Schema Refresh
+
+For Lance tables, Gravitino stores table columns in its metadata store. Some
Lance writers can also
+update the dataset directly at the Lance location. To keep Gravitino metadata
in sync, the Generic
+Lakehouse catalog supports catalog-level schema refresh modes:
+
+| Mode | Behavior
|
+|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `DECLARED_AND_EMPTY` | Default. Refreshes schema from the Lance dataset for
two cases: (1) declared tables (`lance.declared=true`) whose schema has not yet
been written to Gravitino; (2) tables whose Gravitino column list is empty, for
example tables registered before their schema was captured. |
+| `VERSION_CHECK` | Opens the Lance dataset on every `loadTable`,
compares the dataset version with `lance.version`, and refreshes columns when
the version has changed.
|
+
+Use `VERSION_CHECK` only when tables may be modified directly through the
Lance path outside
+Gravitino. It adds a dataset version check to every `loadTable` call.
+
+:::note Zero-column Lance dataset
+If a Lance dataset genuinely has no columns, `DECLARED_AND_EMPTY` mode records
the checked dataset
+version (`lance.version`) on the first `loadTable` call. Subsequent loads skip
opening the dataset
+as long as the stored version is unchanged. Once columns are written to the
dataset, the next
+`VERSION_CHECK` load or an explicit `alterTable` will detect the change and
repair the schema.
+:::
+
### Table Operations
Table operations follow standard relational catalog patterns. See [Table
Operations](./manage-relational-metadata-using-gravitino.md#table-operations)
for comprehensive documentation.
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/LanceTableOperations.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/LanceTableOperations.java
index 6bdfcb5054..41774fdc96 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/LanceTableOperations.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/LanceTableOperations.java
@@ -36,10 +36,16 @@ public interface LanceTableOperations {
* @param delimiter the delimiter used in the namespace
* @param version the version of the table to describe, if null, describe
the latest version
* @param checkDeclared whether to populate the is_only_declared response
field
+ * @param loadDetailedMetadata whether to include column schema in the
response; when false the
+ * schema field is omitted for a lightweight existence/property check
* @return the table description
*/
DescribeTableResponse describeTable(
- String tableId, String delimiter, Optional<Long> version, boolean
checkDeclared);
+ String tableId,
+ String delimiter,
+ Optional<Long> version,
+ boolean checkDeclared,
+ boolean loadDetailedMetadata);
/**
* Create a new table.
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 21a53a8600..5321b6bb74 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
@@ -99,7 +99,11 @@ public class GravitinoLanceTableOperations implements
LanceTableOperations {
@Override
public DescribeTableResponse describeTable(
- String tableId, String delimiter, Optional<Long> version, boolean
checkDeclared) {
+ String tableId,
+ String delimiter,
+ Optional<Long> version,
+ boolean checkDeclared,
+ boolean loadDetailedMetadata) {
if (!version.isEmpty()) {
throw new UnsupportedOperationException(
"Describing specific table version is not supported. It should be
null to indicate the"
@@ -126,7 +130,9 @@ public class GravitinoLanceTableOperations implements
LanceTableOperations {
response.setMetadata(table.properties());
response.setProperties(table.properties());
response.setLocation(table.properties().get(LANCE_LOCATION));
- response.setSchema(toJsonArrowSchema(table.columns()));
+ if (loadDetailedMetadata) {
+ response.setSchema(toJsonArrowSchema(table.columns()));
+ }
response.setVersion(
Optional.ofNullable(table.properties().get(LANCE_TABLE_VERSION))
.map(Long::valueOf)
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LanceConstants.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LanceConstants.java
index f5faaad1c2..197388e8d4 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LanceConstants.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LanceConstants.java
@@ -40,6 +40,7 @@ public class LanceConstants {
public static final String LANCE_TABLE_VERSION = "lance.version";
// Mark whether the table is declared only in metadata without creating a
Lance dataset.
public static final String LANCE_TABLE_DECLARED = "lance.declared";
+ public static final String LANCE_SCHEMA_REFRESH_MODE =
"lance.schema-refresh-mode";
public static final String LANCE_TABLE_FORMAT = "lance";
}
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
index 5114b475c3..5279f9cadd 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
@@ -96,10 +96,15 @@ public class LanceTableOperations {
boolean shouldCheckDeclared =
Optional.ofNullable(checkDeclared)
.orElse(request != null &&
Boolean.TRUE.equals(request.getCheckDeclared()));
+ // loadDetailedMetadata defaults to true when absent so callers that
omit the field
+ // still receive the full schema in the response.
+ boolean shouldLoadDetailedMetadata =
+ request == null ||
!Boolean.FALSE.equals(request.getLoadDetailedMetadata());
DescribeTableResponse response =
lanceNamespace
.asTableOps()
- .describeTable(tableId, delimiter, version, shouldCheckDeclared);
+ .describeTable(
+ tableId, delimiter, version, shouldCheckDeclared,
shouldLoadDetailedMetadata);
return Response.ok(response).build();
} catch (Exception e) {
return LanceExceptionMapper.toRESTResponse(tableId, e);
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceModeParsing.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceModeParsing.java
index cadfccebdf..d27799c1f8 100644
---
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceModeParsing.java
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceModeParsing.java
@@ -215,7 +215,7 @@ class TestGravitinoLanceModeParsing {
GravitinoLanceTableOperations operations =
newTableOperations(tableCatalog);
DescribeTableResponse response =
- operations.describeTable("catalog.schema.table", ".",
Optional.empty(), true);
+ operations.describeTable("catalog.schema.table", ".",
Optional.empty(), true, true);
Assertions.assertEquals(Boolean.TRUE, response.getIsOnlyDeclared());
Assertions.assertEquals(Boolean.FALSE, response.getManagedVersioning());
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceSparkRESTServiceIT.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceSparkRESTServiceIT.java
index cebf03abc1..3704247919 100644
---
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceSparkRESTServiceIT.java
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceSparkRESTServiceIT.java
@@ -182,6 +182,10 @@ public class LanceSparkRESTServiceIT extends BaseIT {
Table table = catalog.asTableCatalog().loadTable(tableIdentifier);
assertTableLocationAndFormat(table, schemaName, tableName);
+ Assertions.assertEquals(2, table.columns().length);
+ Assertions.assertEquals(
+ Set.of("id", "score"),
+ Arrays.stream(table.columns()).map(column ->
column.name()).collect(Collectors.toSet()));
}
@Test
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java
index a7ab97120a..a80c70a06b 100644
---
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java
@@ -633,7 +633,8 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
DescribeTableResponse createTableResponse = new DescribeTableResponse();
createTableResponse.setLocation("/path/to/describe_table");
createTableResponse.setMetadata(ImmutableMap.of("key", "value"));
- when(tableOps.describeTable(any(), any(), any(),
anyBoolean())).thenReturn(createTableResponse);
+ when(tableOps.describeTable(any(), any(), any(), anyBoolean(),
anyBoolean()))
+ .thenReturn(createTableResponse);
DescribeTableRequest tableRequest = new DescribeTableRequest();
Response resp =
@@ -649,11 +650,11 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
Assertions.assertEquals(createTableResponse.getLocation(),
response.getLocation());
Assertions.assertEquals(createTableResponse.getMetadata(),
response.getMetadata());
Mockito.verify(tableOps)
- .describeTable(eq(tableIds), eq(delimiter), eq(Optional.empty()),
eq(true));
+ .describeTable(eq(tableIds), eq(delimiter), eq(Optional.empty()),
eq(true), eq(true));
// Test not found exception
Mockito.reset(tableOps);
- when(tableOps.describeTable(any(), any(), any(), anyBoolean()))
+ when(tableOps.describeTable(any(), any(), any(), anyBoolean(),
anyBoolean()))
.thenThrow(new TableNotFoundException("Table not found", "",
tableIds));
resp =
target(String.format("/v1/table/%s/describe", tableIds))
@@ -664,7 +665,7 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
// Test runtime exception
Mockito.reset(tableOps);
- when(tableOps.describeTable(any(), any(), any(), anyBoolean()))
+ when(tableOps.describeTable(any(), any(), any(), anyBoolean(),
anyBoolean()))
.thenThrow(new RuntimeException("Runtime exception"));
resp =
target(String.format("/v1/table/%s/describe", tableIds))