Skip to content

[#12382] feat(lance): Support AddColumn via Gravitino API - #12383

Open
bbiiaaoo wants to merge 2 commits into
apache:mainfrom
bbiiaaoo:add_column
Open

[#12382] feat(lance): Support AddColumn via Gravitino API#12383
bbiiaaoo wants to merge 2 commits into
apache:mainfrom
bbiiaaoo:add_column

Conversation

@bbiiaaoo

@bbiiaaoo bbiiaaoo commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

This PR adds first-phase AddColumn support for Lance tables through the Gravitino table API.

The implementation:

  • Supports nullable, top-level, append-only columns without default values or auto-increment.
  • Batches multiple AddColumn changes into one Dataset.addColumns call.
  • Uses Lance's native NULL backfill for existing rows.
  • Hydrates declared or empty stored metadata before applying the incremental column changes.
  • Reuses super.alterTable to persist the requested columns, lance.version, and remove lance.declared.

The implementation follows the existing Lance alter-table consistency model. It does not introduce custom metadata CAS, physical rollback, or strict schema reconstruction.

Why are the changes needed?

Lance tables currently cannot add columns through the Gravitino table API.

This change provides initial AddColumn support while keeping Lance as the source of truth and keeping broader Lance-to-Gravitino schema reconciliation outside the scope of this PR.

Fix: #12382

Does this PR introduce any user-facing change?

Yes.

Users can add nullable, top-level columns to Lance tables through the Gravitino table API. Existing rows are backfilled with NULL.

The Lance REST /add_columns endpoint is not included in this change.

How was this patch tested?

  • Added unit tests for batched AddColumn, validation, declared/empty metadata hydration, and zero-column declared tables.
  • Added integration coverage verifying that historical rows are backfilled with NULL and multiple columns produce one Lance version.
  • Added integration coverage for direct AddColumn on a declared table without a preceding loadTable.

Commands:

  • ./gradlew spotlessApply
  • ./gradlew :catalogs:catalog-lakehouse-generic:test --tests org.apache.gravitino.catalog.lakehouse.lance.TestLanceTableOperations -PskipITs
  • ./gradlew :catalogs:catalog-lakehouse-generic:check -PskipITs

@roryqi
roryqi requested a review from yuqi1129 August 6, 2026 03:54
@bbiiaaoo

bbiiaaoo commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Hi @yuqi1129 @FANNG1 , could you please take a look at this PR when you have time?

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Code Coverage Report

Overall Project 68.39% +0.23% 🟢
Files changed 76.25% 🟢

Module Coverage
aliyun 1.72% 🔴
api 49.37% +0.32% 🟢
authorization-common 85.96% 🟢
aws 42.04% 🟢
azure 2.47% 🔴
catalog-common 9.92% 🔴
catalog-fileset 79.74% +11.78% 🟢
catalog-glue 68.95% 🟢
catalog-hive 79.4% 🟢
catalog-jdbc-common 45.7% 🟢
catalog-jdbc-doris 81.8% 🟢
catalog-jdbc-mysql 79.33% 🟢
catalog-jdbc-postgresql 83.39% 🟢
catalog-jdbc-starrocks 79.16% 🟢
catalog-kafka 77.01% 🟢
catalog-lakehouse-generic 62.75% +5.43% 🟢
catalog-lakehouse-hudi 79.1% 🟢
catalog-lakehouse-iceberg 85.93% 🟢
catalog-lakehouse-paimon 84.23% 🟢
catalog-model 77.72% 🟢
cli 44.48% 🟢
client-java 78.27% +0.73% 🟢
common 52.57% -0.11% 🟢
core 83.28% -0.4% 🟢
filesystem-hadoop3 77.28% 🟢
flink 0.0% 🔴
flink-common 48.68% 🟢
flink-runtime 0.0% 🔴
gcp 14.12% 🔴
hadoop-auth 68.0% 🟢
hadoop-common 12.7% 🔴
hive-metastore-common 53.4% 🟢
iceberg-aliyun-bundle 0.0% 🔴
iceberg-common 64.75% 🟢
iceberg-rest-server 75.03% 🟢
idp-basic 86.02% 🟢
integration-test-common 0.0% 🔴
jobs 62.92% 🟢
lance-common 31.75% 🔴
lance-rest-server 63.47% 🟢
lineage 53.02% 🟢
optimizer 83.24% 🟢
optimizer-api 21.95% 🔴
server 85.88% -0.01% 🟢
server-common 76.69% 🟢
spark 28.57% 🔴
spark-common 45.89% 🟢
tencent 69.84% 🟢
trino-connector 40.29% 🟢
Files
Module File Coverage
api SecretUrn.java 88.0% 🟢
FilesetCatalog.java 0.0% 🔴
catalog-fileset FilesetCatalogOperations.java 81.81% 🟢
catalog-lakehouse-generic LanceTableOperations.java 65.85% 🟢
client-java FilesetCatalog.java 100.0% 🟢
common FilesetCreateRequest.java 0.0% 🔴
core FilesetEventDispatcher.java 100.0% 🟢
SecretPropertyUtils.java 96.97% 🟢
FilesetOperationDispatcher.java 93.33% 🟢
FilesetNormalizeDispatcher.java 90.91% 🟢
FilesetHookDispatcher.java 85.29% 🟢
SecretManager.java 75.29% 🟢
CatalogManager.java 70.34% 🟢
BaseCatalog.java 62.5% 🟢
server FilesetOperations.java 85.58% 🟢

@FANNG1

FANNG1 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Code review

Found 1 issue:

  1. AddColumn deterministically fails on declared-only or empty-stored-columns tables. alterTable loads the entity via loadTableEntity(ident), a raw store.get() that bypasses the repair-on-load logic in loadTable, and TableOperationDispatcher.alterTable does not call loadTable beforehand. The new addColumns then builds expectedCurrentFields from table.columns() and hard-checks it against the real dataset schema, so for a declared table (lance.declared=true, created with new Column[0]) or a table whose stored columns are still empty — both normal states under the default DECLARED_AND_EMPTY refresh mode, per the class Javadoc — the pre-check throws a misleading OptimisticLockException about schema inconsistency. The pre-existing DeleteColumn/RenameColumn/AddIndex paths are unaffected because they never consult table.columns(). No test covers add-column against a declared or empty-stored-columns table.

throws NoSuchSchemaException, TableAlreadyExistsException {
TableEntity loadedEntity = loadTableEntity(ident);
Table loadedTable = toGenericTable(loadedEntity);
List<Field> fieldsToAdd = prepareFieldsToAdd(loadedTable, changes);
long version = handleLanceTableChange(loadedTable, changes, fieldsToAdd);

Table table, List<Field> fieldsToAdd, String location, Map<String, String> storageOptions) {
List<Field> expectedCurrentFields = convertColumnsToArrowSchema(table.columns()).getFields();
List<Field> expectedUpdatedFields = new ArrayList<>(expectedCurrentFields);
expectedUpdatedFields.addAll(fieldsToAdd);
boolean addAttempted = false;
try (Dataset dataset = openDataset(location, storageOptions)) {
validateSchemaMatches(expectedCurrentFields, dataset.getSchema(), location, "before add");
addAttempted = true;

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@bbiiaaoo

Copy link
Copy Markdown
Contributor Author

@FANNG1 Thanks for catching this. Fixed by hydrating the Lance schema before handling AddColumn and reloading the TableEntity afterward as the optimistic-lock snapshot. Added regression tests for both declared tables and non-declared tables with empty stored columns.

@bbiiaaoo

Copy link
Copy Markdown
Contributor Author

Hi @FANNG1 @yuqi1129 , would it be acceptable to simplify the AddColumn implementation by following the existing Lance alter-table flow?

The proposed flow is:

  1. Load the table and hydrate the Lance schema if necessary.
  2. Add columns through Dataset.addColumns.
  3. Reuse super.alterTable to update the Gravitino columns and lance.version.

This would remove the custom metadata CAS and physical rollback logic, and would provide the same best-effort consistency model as the existing DeleteColumn/RenameColumn paths.

Would this simpler approach be acceptable for the first-phase AddColumn support?

@bbiiaaoo
bbiiaaoo marked this pull request as draft August 11, 2026 06:29
@yuqi1129

Copy link
Copy Markdown
Contributor

Hi @FANNG1 @yuqi1129 , would it be acceptable to simplify the AddColumn implementation by following the existing Lance alter-table flow?

The proposed flow is:

  1. Load the table and hydrate the Lance schema if necessary.
  2. Add columns through Dataset.addColumns.
  3. Reuse super.alterTable to update the Gravitino columns and lance.version.

This would remove the custom metadata CAS and physical rollback logic, and would provide the same best-effort consistency model as the existing DeleteColumn/RenameColumn paths.

Would this simpler approach be acceptable for the first-phase AddColumn support?

It's fine currently. In fact, we need to review the Lance directory as the single source of truth; no matter how much effort we put in, there is still a high possibility that the data in Gravitino and Lance differ.

try {
return persistAddedColumns(ident, loadedEntity, changes, version);
} catch (RuntimeException metadataFailure) {
rollbackAddedColumns(loadedTable, fieldsToAdd, metadataFailure);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you roll back the column created in the Lance dataset when writing column information fails?

@yuqi1129 yuqi1129 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the work here! The add/verify/rollback sequence itself looks correct (I checked that Dataset.checkoutLatest() is in-place and getSchema() is not cached in lance-core 6.0.0). I left three comments, two of which I think can permanently break AddColumn on otherwise-healthy tables.

Also verified as fine, in case it saves anyone else the trip: no double rollback on the persistAddedColumns path, ColumnPosition.defaultPos() is a singleton so the equals position check holds over REST, appendAddedColumns position assignment is safe because ManagedTableOperations.applyChanges re-indexes, and OptimisticLockException maps to 409 rather than 500.


private long addColumns(
Table table, List<Field> fieldsToAdd, String location, Map<String, String> storageOptions) {
List<Field> expectedCurrentFields = convertColumnsToArrowSchema(table.columns()).getFields();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "expected" schema is rebuilt from Gravitino metadata through LanceDataTypeConverter and then compared to the live Lance schema with an exact positional field comparison (see fieldMatches below). But toGravitino -> fromGravitino is not round-trip lossless, so several classes of perfectly consistent tables will fail the pre-check at line 847 and never be able to add a column again:

  • Timestamp with time zone: toGravitino maps any tz to TimestampType.withTimeZone(p), and fromGravitino always re-emits Timestamp(unit, "UTC") (the converter even carries // todo: need timeZoneId for timestamp with time zone). A dataset with timestamp[us, tz=Asia/Shanghai] never matches.
  • List: toGravitino drops the list child field name, and toArrowField hard-codes it back as "element", while datasets written by pyarrow / arrow-rs name it "item".
  • Decimal: fromGravitino always emits bit width 128, so a decimal256 column never matches.

The resulting OptimisticLockException surfaces as HTTP 409, which tells the client to retry, but the condition is permanent — under the default DECLARED_AND_EMPTY refresh mode a non-empty table is never re-hydrated, so there's no supported way to clear it.

The same strict comparison is reused for the post-add check at line 851, which has the mirror problem: if Lance normalizes the written field in any way, an add that actually succeeded gets rolled back and reported as a failure.

Would it be possible to compare against the schema actually read from the dataset (e.g. snapshot it before the add and diff that against the post-add schema), rather than against a re-derived one?

try {
return persistAddedColumns(ident, loadedEntity, changes, version);
} catch (RuntimeException metadataFailure) {
rollbackAddedColumns(loadedTable, fieldsToAdd, metadataFailure);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rollback can leave Gravitino metadata listing columns that no longer exist physically.

persistAddedColumns fails the CAS whenever !current.equals(expectedEntity). With lance.schema-refresh-mode=VERSION_CHECK, a concurrent loadTable landing in the window between dataset.addColumns() committing and the CAS running will hydrate the new schema into the entity store and bump lance.version via repairTableMetadata. That both (a) makes this CAS fail and (b) means the store already contains the added columns. We then call rollbackAddedColumns, which physically drops them from the dataset, and rethrow — net result is metadata referencing columns Lance doesn't have, and under the default DECLARED_AND_EMPTY mode a non-empty table is never re-checked, so it stays that way.

The dispatcher only takes a READ tree-lock for non-rename changes (TableOperationDispatcher.alterTable), so this interleaving isn't excluded.

One option: make the CAS compare only the fields that actually matter (columns + lance.version) and skip the physical rollback when the stored schema already reflects the add.

return true;
}

private boolean fieldMatches(Field expected, Field actual) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor / lower severity, but it lands on the same path: column types Lance/Arrow can't map back to a Gravitino type — notably fixed_size_list vector columns, arguably the flagship Lance type — are stored as Types.ExternalType whose catalogString is the serialized Arrow Field, including its original name.

RenameColumn is supported for Lance tables and rewrites only the ColumnEntity name, leaving the embedded JSON name stale. convertColumnsToArrowSchema at line 841 then hits the EXTERNAL branch of toArrowField, which does Preconditions.checkArgument(name.equals(field.getName()), "expected field name %s but got %s"). So after renaming a vector column, every subsequent AddColumn on that table dies with a confusing IllegalArgumentException before any Lance work happens.

Either rewrite the embedded name on rename, or override the name when reconstructing the Arrow field from ExternalType.

@FANNG1

FANNG1 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

+1 to reusing the existing Lance alterTable flow for the first-phase AddColumn support.

One architectural concern remains: super.alterTable applies the requested changes incrementally to the Gravitino metadata, so it may still produce a Gravitino schema that differs from the schema actually committed by Lance, especially when the two sides have already drifted or Lance normalizes the resulting schema. Since the Lance dataset is the source of truth, replacing or reconciling the Gravitino schema from the actual underlying dataset after the physical change may be more robust.

I think that broader reconciliation change can be handled in a separate PR to keep this one focused. What do you think?

- reuse the existing Lance alter-table flow for AddColumn
- batch nullable top-level columns into one Lance schema commit
- hydrate declared or empty table metadata before adding columns
- persist the Lance version and clear the declared marker
- add validation, unit tests, integration tests, and documentation
@bbiiaaoo
bbiiaaoo marked this pull request as ready for review August 11, 2026 09:27
@bbiiaaoo

Copy link
Copy Markdown
Contributor Author

Thanks @FANNG1 and @yuqi1129 for the suggestions.

I have simplified and force-pushed the implementation based on the latest main.

The updated implementation now:

  1. Validates nullable, top-level, append-only AddColumn changes.
  2. Hydrates declared or empty stored metadata before adding columns.
  3. Batches the new fields into one Dataset.addColumns call.
  4. Reuses super.alterTable to persist the requested columns and lance.version, and removes lance.declared.

I removed the strict Gravitino-to-Arrow schema reconstruction and comparison, custom metadata CAS, manual TableEntity construction, and physical rollback logic.

I agree that broader reconciliation from the Lance dataset back to Gravitino metadata should be handled in a separate PR, with Lance treated as the source of truth.

Unit tests, the relevant integration tests, and the module check pass locally. Could you please take another look?


Set<String> columnNames = new HashSet<>();
List<Field> fieldsToAdd = new ArrayList<>(changes.length);
for (TableChange change : changes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to merge the loop above and the loop here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. I merged AddColumn detection, validation, and Arrow field construction into a single loop.

metadataChanges[changes.length] =
TableChange.setProperty(LanceConstants.LANCE_TABLE_VERSION, String.valueOf(version));
if (!fieldsToAdd.isEmpty()) {
metadataChanges[changes.length + 1] =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use ArrayUtils.add() directly with explicitly copying it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another problem is why the index is changes.length + 1, not changes.length here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I replaced the manual array copying and indexing with sequential ArrayUtils.add calls.

// Adding all fields in one call creates one Lance schema version and backfills existing
// rows with null for the new nullable columns.
dataset.addColumns(fieldsToAdd);
dataset.checkoutLatest();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you handle the case where there exist addColumn and dropColumn or AddIndex at the same time? Have you already excluded such scenarios?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed AddColumn and other table changes are rejected because the Lance physical schema update must be completed separately from the existing alter-table operations.

I added explicit tests for AddColumn combined with DeleteColumn in both orders, as well as AddColumn combined with AddIndex.

}

@Test
public void testAlterTableAddsNullableColumnsInSingleLanceCommit() throws Exception {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you covered the case where we changed Lance to succeed but failed to write metadata to Gravitino, and then It's still okay when we load the table?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a regression test for this failure window.

If the Lance add-columns commit succeeds but the Gravitino metadata update fails, a subsequent load in VERSION_CHECK mode detects the changed Lance version and refreshes both the schema and version from the underlying dataset.

Under the default DECLARED_AND_EMPTY mode, an existing non-empty stored schema is not automatically reconciled. Broader reconciliation in that mode can be addressed separately as part of the Lance-as-source-of-truth follow-up work.

- validate AddColumn changes and build Arrow fields in one pass
- use ArrayUtils.add for internal metadata changes
- add coverage for mixed AddColumn operations
- verify version-check recovery after a metadata update failure
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Support adding nullable columns to Lance tables via Gravitino API

3 participants