Skip to content

[#12345] improvement(core): add OCC for table writes - #12551

Merged
jerryshao merged 15 commits into
mainfrom
feat/12345-occ-table
Aug 26, 2026
Merged

[#12345] improvement(core): add OCC for table writes#12551
jerryshao merged 15 commits into
mainfrom
feat/12345-occ-table

Conversation

@yuqi1129

@yuqi1129 yuqi1129 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

  • Use current_version as the OCC token for table alter and direct delete.
  • Execute the root table CAS before version, column, and dependent-row writes in one transaction.
  • Distinguish a concurrent version change from a deleted, renamed, or moved table.
  • Let optimistic-lock conflicts reach the caller on the managed paths, which write to the store directly. The best-effort OperationDispatcher.operateOnEntity helper keeps swallowing them: only external entities reach it, and there the catalog was already changed, so failing would invite a retry that re-applies a non-idempotent change.
  • Retry only optimistic-lock conflicts for idempotent Lance metadata repair.
  • Add comments explaining the transaction ordering, CAS predicates, conflict classification, and retry behavior.
  • Add comprehensive unit and cross-database tests.

This PR depends on #12456.

Two things are deliberately left out. The conflict classification (tableWriteFailure) and the locking select now exist once per entity type; extracting them is a follow-up once the series is done, because what differs per entity is the mapper, which identity columns to compare, and schema's physical-to-logical conversion, so doing it now means refactoring three merged services from inside a fourth. And a version conflict while dropping is tracked in #12597, which covers every entity type rather than this path alone.

Why are the changes needed?

Concurrent table writes could overwrite the winning version metadata, while a stale delete could remove data belonging to a newer table version. Some optimistic-lock conflicts were also swallowed or treated as generic IO failures.

Fix: #12345

Does this PR introduce any user-facing change?

Stale managed-table writes now fail with the existing optimistic-lock conflict response instead of silently overwriting newer metadata. Reads and the import path are unchanged: loading an entity still repairs the Gravitino copy on a best-effort basis, so a load that loses a version race keeps returning the entity. No API or property is added.

How was this patch tested?

  • Ran 48 TableMetaService cases across H2, MySQL, and PostgreSQL.
  • Ran all 16 TestTableOperationDispatcher tests.
  • Ran 4 SQL provider OCC tests.
  • Ran 27 Lance table and concurrent-repair tests.
  • Ran Spotless and git diff --check.

Advance the schema OCC version on every alter and guard alter and drop
with a compare-and-set on the observed version, classifying a failed
CAS as either a stale conflict or a missing entity.

Make managed schema creation insert-only so a concurrent same-name
create returns SchemaAlreadyExistsException instead of overwriting the
winner, and take a shared lock on the parent catalog row so a schema
cannot be created below a catalog that is being dropped. Serialize
hierarchical ancestor materialization and schema drops through the
catalog row so overlapping cascades share one lock order.

Lock the parent schema row before writing a table, view, fileset,
function, model, or topic, and check views and functions before a
non-cascade schema drop.

Accepted tradeoff: a hierarchical schema create that materializes
implicit ancestors takes an exclusive lock on the catalog row, because
two concurrent creates can both find the same ancestor missing and both
insert it, and a shared lock does not prevent that under MySQL
REPEATABLE READ.
H2 is also the default embedded backend, not only a test backend. Spell out
that falling back to an exclusive lock serializes schema creations under one
catalog there and can surface as an H2 lock timeout.
Review feedback: the concurrency-critical parts need comments so a reader can
follow why the statements are ordered the way they are.

- Say what the catalog row lock buys on a schema create, and why a nested name
  has to take it exclusively while a plain name does not.
- Say why both drop paths delete the schema row before looking at its children,
  and why every drop takes catalog before schema.
- Say what the shared schema lock in front of a table, view, fileset, function,
  model, or topic write is for, and that only a cross-schema rename needs it.
- Say why the alter UPDATE compares only the version, what zero affected rows
  can mean, and why a partial cascade must roll back.
- Say why managed schema creation is insert-only now.
- Correct the schemaWriteFailure comment: sessions run at READ_COMMITTED, so
  the locking read is there to wait out an in-flight writer.
…overwrite

Carry the fix that #12455 already made for catalogs over to schemas, so both
sides of the hierarchy follow the same rule.

- Advance current_version on all four schema upsert paths (single and batch, on
  MySQL/H2 and PostgreSQL) instead of writing the initial version back, which
  would let a writer holding an older version still pass its own version check.
- Name the table on the PostgreSQL assignments: a bare column on that side of
  ON CONFLICT is ambiguous there, which is how the previous CI run broke.
- Add TestSchemaMetaPostgreSQLProvider to pin both rules without a database, so
  they are checked on every run and not only in the Docker-backed CI job.
- Cover the race this PR is meant to close: a catalog cascade that holds the
  catalog row makes a concurrent schema create wait and then report the catalog
  as missing, leaving no orphan behind. Reading the cascade snapshot moved into
  a package-private method so the test can pause exactly at that point, the
  same seam MetalakeMetaService already offers.
- Say that the H2 shared-lock fallback affects H2 backends, not just tests.
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Code Coverage Report

Overall Project 68.14% +0.15% 🟢
Files changed 80.12% 🟢

Module Coverage
aliyun 19.74% 🔴
api 52.94% 🟢
authorization-common 85.96% 🟢
authorization-ranger 4.38% 🔴
aws 53.54% 🟢
azure 32.1% 🔴
catalog-common 9.9% 🔴
catalog-fileset 80.02% 🟢
catalog-glue 69.24% 🟢
catalog-hive 82.96% 🟢
catalog-jdbc-common 45.69% 🟢
catalog-jdbc-doris 82.44% 🟢
catalog-jdbc-mysql 79.33% 🟢
catalog-jdbc-postgresql 83.39% 🟢
catalog-jdbc-starrocks 79.16% 🟢
catalog-kafka 76.99% 🟢
catalog-lakehouse-generic 60.55% +2.8% 🟢
catalog-lakehouse-hudi 79.1% 🟢
catalog-lakehouse-iceberg 85.86% 🟢
catalog-lakehouse-paimon 84.26% 🟢
catalog-model 77.99% 🟢
cli 44.48% 🟢
client-java 78.07% 🟢
common 52.29% 🟢
core 83.44% +0.12% 🟢
filesystem-hadoop3 76.45% 🟢
flink 0.0% 🔴
flink-common 50.29% 🟢
flink-runtime 0.0% 🔴
gcp 32.2% 🔴
hadoop-auth 68.0% 🟢
hadoop-common 17.84% 🔴
hive-metastore-common 53.4% 🟢
iceberg-aliyun-bundle 0.0% 🔴
iceberg-common 64.75% 🟢
iceberg-rest-server 75.38% 🟢
idp-basic 85.98% 🟢
integration-test-common 0.0% 🔴
jobs 62.92% 🟢
lance-common 32.81% 🔴
lance-rest-server 64.78% 🟢
lineage 53.02% 🟢
optimizer 83.24% 🟢
optimizer-api 21.95% 🔴
server 87.51% 🟢
server-common 79.59% 🟢
spark 28.57% 🔴
spark-common 48.92% 🟢
tencent 69.84% 🟢
trino-connector 47.41% 🟢
Files
Module File Coverage
catalog-lakehouse-generic LanceTableOperations.java 62.43% 🟢
core TableMetaPostgreSQLProvider.java 100.0% 🟢
TableMetaService.java 100.0% 🟢
OperationDispatcher.java 93.86% 🟢
TableMetaSQLProviderFactory.java 86.21% 🟢
TableOperationDispatcher.java 84.3% 🟢
TableMetaBaseSQLProvider.java 80.95% 🟢
TablePO.java 77.98% 🟢
TableMetaMapper.java 0.0% 🔴

@yuqi1129
yuqi1129 marked this pull request as ready for review August 24, 2026 06:45
Copilot AI lite review requested due to automatic review settings August 24, 2026 06:45

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@yuqi1129
yuqi1129 marked this pull request as draft August 24, 2026 06:46
@yuqi1129
yuqi1129 marked this pull request as ready for review August 24, 2026 12:30
@yuqi1129 yuqi1129 self-assigned this Aug 24, 2026

@jerryshao jerryshao 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.

Automated review (Claude Code) of the OCC-for-table-writes change. Left inline notes on a few spots worth a look before merge.

One more that doesn't map to a changed line (so can't be left inline): TableOperationDispatcher.dropTable/purgeTable call store.delete() inside their own generic catch (Exception e) { throw new RuntimeException(e); }, which would swallow the new OptimisticLockException that TableMetaService.deleteTable can now throw (e.g. a concurrent alterTable bumping current_version between the read and the delete transaction). The alter path was explicitly wired to propagate this signal — should drop/purge do the same instead of flattening it into a plain RuntimeException?

Comment thread core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java Outdated
Cleanup pass over the OCC change, no behavior change.

- Give TablePO a copy builder so tablePOWithPersistedVersions swaps the two
  version columns instead of restating all sixteen. Restating them meant a
  column added later would be silently blanked on the overwrite path, with no
  compile error to catch it.
- Say why selectTableMetaByIdForUpdate cannot be written as
  selectTableMetaById(id) + " FOR UPDATE" like the metalake, catalog and schema
  providers: that select LEFT JOINs table_version_info, and locking the
  nullable side of an outer join is rejected by PostgreSQL and locks the wrong
  rows on MySQL.
- Drop a redundant snapshot read and a single-statement lambda block.
- Build the PostgreSQL SQL assertions from TableMetaMapper.TABLE_NAME and add
  the unqualified-reference guard, matching the catalog and schema tests.
- Collapse the repeated test scaffolding: one column() factory, one
  copyTableWithColumns, one assertion helper for the two stale-alter cases, and
  one runWhileSchemaDeleteUncommitted for the two schema-delete races.
…lter paths

operateOnEntity is a best-effort helper: all nine call sites treat a null
result as "the store write did not land, carry on". Letting an
OptimisticLockException out of it turned that into a hard failure for every
caller, including the read paths.

That is too blunt. On the load and import paths the external catalog is the
source of truth, so failing to write the Gravitino copy is allowed and the next
load repairs it. The worst case was updateColumnsIfNecessaryWhenLoad, reached
from loadTable: two concurrent loads of a table whose columns drifted made the
loser fail a plain read, which is the very race the Lance repair retry exists
to absorb.

Keep operateOnEntity best-effort and add operateOnEntityAndPropagateConflict
for the four alter paths, where the store write is the operation the user asked
for and reporting success on a lost race would hide the loss. The conflict is
now logged on the best-effort paths instead of being lost among generic
failures, and a new entity type added later has to opt in to hard failure.
The old comment said the two rows must move together and that ordering keeps a
losing writer from overwriting the winner's version row, but not why the
overwrite would happen in the first place, which made it easy to read as if the
version row were protected on its own. It is not: the upsert is keyed by
(table_id, version) and has no version predicate.

Name the key, say the upsert is unguarded, and walk through two writers racing
from version 5 to 6 so the reason the CAS has to come first is on the page.
@yuqi1129

Copy link
Copy Markdown
Contributor Author

Also addressed the non-inline review note in 1d874db: TableOperationDispatcher.dropTable and purgeTable now rethrow OptimisticLockException before the generic exception wrapper, preserving the conflict contract. Both paths have dispatcher regression coverage.

if (droppedFromCatalog) {
try {
store.delete(ident, TABLE);
} catch (OptimisticLockException e) {

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.

Correctness: concurrent-alter-vs-drop race can permanently orphan the internal table_meta row.

Sequence: (1) doWithCatalog(...).dropTable(ident) succeeds against the external catalog (droppedFromCatalog = true, external data is now gone); (2) store.delete(ident, TABLE) loses its new version CAS (softDeleteTableMetasByTableId now requires current_version = #{currentVersion}) because another writer concurrently altered the table between the initial read and this delete, so it throws OptimisticLockException; (3) that exception now propagates straight out of dropTable instead of being retried.

Because the external table is already gone, a client retry of the same dropTable call will have doWithCatalog(...).dropTable(ident) report "not found" (see e.g. HiveCatalogOperations.dropTable, which returns false when the table is already absent), so droppedFromCatalog becomes false on retry and the if (droppedFromCatalog) { store.delete(...) } block — the only place that ever calls store.delete — is skipped entirely. The internal Gravitino table_meta row is then permanently orphaned; no later drop attempt, and no existing reconciliation job (OrphanedSchemaCleanup only targets schemas, not tables), will ever remove it.

Contrast with alterTable, which deliberately swallows the same kind of conflict via OperationDispatcher.operateOnEntity with an explicit retry-safety rationale ("failing the request would encourage a retry that could apply the external change twice"). Drop/purge propagate instead, but retry-safety is actually worse here — it silently blocks all future cleanup of the internal entity. The same issue applies to purgeTable below (line 476).

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.

The sequence is real. I opened #12597 to handle it for every entity type instead of patching this one path.

Two things pushed me that way. Removing the catch would not fix it: without it the conflict falls into catch (Exception e) and is rethrown as RuntimeException, so the request still fails and the retry still no-ops. The orphan comes from the CAS being able to fail at all. And the same shape is in dropSchema and dropView; schema already has OCC, so dropSchema can hit this today, and it has no OptimisticLockException catch, so a schema conflict surfaces as a generic failure rather than a conflict. dropTopic differs again - its store.delete is not gated on droppedFromCatalog, so a retry does re-attempt it.

There is also a design question underneath: retrying the delete until it wins is close to not checking the version on that path at all, since delete is idempotent and a conflict only means the row moved on. #12597 lists that alongside a shared retry and an orphan-cleanup job. Note the block already documents that an out-of-band drop can leave a stale registration needing separate cleanup, so this is a new trigger for an accepted outcome rather than a new class of outcome.

tablePO.getTableId(), tablePO.getCurrentVersion()));
}

private RuntimeException tableWriteFailure(NameIdentifier identifier, TablePO observedTablePO) {

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.

Reuse: this classification algorithm is now duplicated a third time.

tableWriteFailure (lock-and-reread via selectTableMetaByIdForUpdate, compare natural-key fields, return NoSuchEntityException or ExceptionUtils.concurrentModification) is essentially a line-for-line copy of SchemaMetaService.schemaWriteFailure and CatalogMetaService.catalogWriteFailure (added by the earlier schema-OCC PR). No shared helper was extracted, so a future fix to the classification logic (e.g. adding a field to compare, or changing lock semantics) has to be made identically in three places — and it already wasn't kept in sync once (the schema/catalog versions and this one already differ slightly in structure). Consider extracting one generic writeFailure(NameIdentifier, T observed, Function<Long,T> lockingLookup, BiPredicate<T,T> sameParent, EntityType) helper that all three services call.

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.

Agreed on the duplication. I would rather extract it when the series is done than in this PR: what genuinely differs per entity is the mapper and its locking select, which identity columns to compare (metalake has no parent, table compares name plus schema, catalog and metalake ids), and schema's physicalToLogicalSchemaPO conversion before comparing. Doing it now means refactoring three already-merged services from inside a fourth. I will note the deferral in the PR description so the fifth copy does not land by default.

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.

Can you create an issue for this?

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.

tracked in #12639

R ret = null;
try {
ret = fn.apply(ident);
} catch (OptimisticLockException e) {

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.

Altitude: this special case depends on an unenforced fact about callers.

The comment concedes operateOnEntity is safe to swallow OptimisticLockException in only because "managed operations do not use this best-effort helper" — a fact about callers, not something this generic dispatcher-level helper can verify or enforce. If any future managed-table code path (or a copy/paste in a new dispatcher) is ever routed through operateOnEntity, an OCC conflict on that path would silently be downgraded to a log warning instead of surfacing — exactly the class of bug this PR is fixing everywhere else. Consider making the strict-vs-best-effort choice explicit at each call site (e.g. two named helpers, operateOnEntityBestEffort/operateOnEntityStrict, or a boolean parameter) rather than relying on this dispatcher-wide catch ordering plus a code comment to keep the invariant true.

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.

see above.

* @param tableId the table ID
* @return the locking select SQL
*/
public String selectTableMetaByIdForUpdate(@Param("tableId") Long tableId) {

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.

Reuse (minor): this locking-select pattern is now written a third time.

selectTableMetaByIdForUpdate (single-table "SELECT ... WHERE id = #{id} AND deleted_at = 0 FOR UPDATE" projection) mirrors selectSchemaMetaByIdForUpdate and selectCatalogMetaByIdForUpdate added by the earlier schema/catalog OCC work — same shape modulo column/table names, with no shared SQL-fragment helper across the three providers. Not blocking, just flagging alongside the similar duplication in TableMetaService.tableWriteFailure for a possible follow-up cleanup.

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.

Same call as for tableWriteFailure: deferred to a follow-up cleanup once the series is done, and noted in the PR description rather than left silent.


// Copies every column of the source row. When a field is added to TablePO above, add it here
// too, otherwise callers that copy a row would silently blank it.
private Builder(TablePO source) {

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.

Simplification/maintenance risk: hand-rolled full-field copy constructor.

This copy constructor duplicates every TablePO field by hand, guarded only by the comment "When a field is added to TablePO above, add it here too, otherwise callers that copy a row would silently blank it." That's a maintenance trap enforced by convention rather than the compiler — a future field addition that misses this constructor fails silently (no compile error, no test failure unless the new field happens to be asserted). No shared "copy a PO" utility exists elsewhere in the codebase (e.g. SchemaPO's equivalent need is met via a narrower copySchemaPOWithName-style static builder), so this exact hazard is likely to recur for the next PO that needs copy semantics.

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.

The hand-written copy is still there, but it is no longer guarded only by a comment: TestTablePO#testCopyBuilderCarriesEveryField compares every declared field of source and copy reflectively, and the fixture asserts each field was set, so a field added later without a matching line in the builder fails the test rather than silently blanking on copy.

Removing the hand-written copy altogether would mean changing how TablePO is written, which I would rather not do inside this PR.

// winner's ID. The upsert already holds that row until commit, so read the
// database-derived identity and version back through the same natural key.
TablePO storedPO =
mapper.selectTableMetaBySchemaIdAndName(

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.

Efficiency/simplification: unconditional extra round trip + unexplained guard on the overwrite path.

Two related points in this overwrite branch of insertTable:

  1. selectTableMetaBySchemaIdAndName now runs on every insertTable(..., overwrite=true) call, not only when the natural key actually collided with a different table_id. It's needed because current_version/last_version are now computed inside the UPDATE (current_version + 1) rather than passed in, so Java has to read the result back — this adds a fixed extra round trip to the common overwrite path that didn't exist before this PR (previously insertTableVersionOnDuplicateKeyUpdate(po) used the pre-computed po directly).
  2. The storedPO.getCurrentVersion() > POConverters.INIT_VERSION guard a few lines below (before soft-deleting the prior version row) isn't explained by a comment — it reads as if it exists to avoid an unnecessary no-op delete for the "brand new row via INSERT branch" case, but that's worth confirming/documenting explicitly, since a reader has to reconstruct why the guard is only sometimes needed.

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.

Added a comment for the guard: there is an earlier version row to retire only when the upsert updated an existing table, which moves the version from N to N+1; a fresh insert leaves it at the initial version with no earlier row.

On the extra round trip, I could not find a way to avoid it. The version is now derived inside the statement (current_version + 1), so the resulting version has to be read back before table_version_info can be keyed by it. Deriving it with a subquery would need three different spellings for MySQL, PostgreSQL and H2. The read itself is a single-table select, cheaper than selectTableMetaById, which joins table_version_info. Happy to revisit if you see a portable way to fold it in.

…le in the code

- Spell out why swallowing a conflict in operateOnEntity is safe. The old
  comment asserted that managed operations do not use the helper, which a
  reader cannot check from there. Name the checks instead: alterTable,
  alterSchema and alterView return earlier for managed entities, and no catalog
  reports managed storage for topics.
- Say what the INIT_VERSION guard on the overwrite path is for: there is an
  earlier version row to retire only when the upsert updated an existing table.
- Pin TablePO's copy builder with a reflective test. The builder lists the
  fields by hand, so a field added later without a matching line there would be
  blanked on every copy with nothing to catch it. The test compares every
  declared field and refuses to pass if the fixture leaves one unset.
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.

[Subtask] Implement version-CAS OCC for table

3 participants