aisrefix-commits commented on issue #10474:
URL: https://github.com/apache/gravitino/issues/10474#issuecomment-4654162382

   ## Proposed direction: keep TreeLock, make the storage layer the cross-node 
authority
   
   After reading the existing proposals and walking the code, I'd like to float 
a pragmatic, **incremental** direction and get feedback before implementing it 
phase by phase.
   
   **Overall idea (one sentence):** TreeLock stays as-is for intra-node 
serialization; we wrap an "OCC conflict → bounded retry" loop around the 
`executable.execute()` it already guards, so the DB's version check becomes the 
final cross-node arbiter. Changes land in only two places — the storage layer 
and the lock-utility entry point — while managers/dispatchers/SQL statements 
stay essentially untouched.
   
   This works because the storage layer already has the primitives, they're 
just underused:
   
   - Every entity table has `current_version`/`last_version`, and UPDATEs 
already check them (`*MetaBaseSQLProvider`). What's missing is a **typed 
conflict exception + a retry** — not the OCC guard itself.
   - Transaction boundaries are centralized in `SessionUtils` (`doWithCommit`, 
`doWithCommitAndFetchResult`, `doMultipleWithCommit`), so a guard can be 
injected in one place.
   - `EntityChangeLogPoller` + the `entity_change_log` mapper already exist on 
`main` (mapper from merged #10914); it just isn't wired into `GravitinoEnv` yet.
   
   ### Races to close
   
   | # | Race | Scenario | Root cause |
   | --- | --- | --- | --- |
   | R1 | alter/rename TOCTOU | Nodes A and B alter/rename the same entity; one 
update silently overwrites the other | No cross-node lock; both pass their 
local TreeLock |
   | R2 | Orphaned child | A's `dropSchema` commits while B's `createTable` 
under that schema also commits | create takes the schema's **in-process** write 
lock; no cross-node parent guard |
   | R3 | ABA lost update | metalake/catalog V1 → V2 → back to a V1-equivalent; 
stale writer passes OCC | `nextVersion = lastVersion` (no increment) for 
metalake/catalog |
   | R4 | Stale cache | A commits a change; B keeps serving stale cached 
metadata | `EntityChangeLogPoller` not wired into `GravitinoEnv` |
   
   ### Phased plan (each phase independently shippable & reversible)
   
   **Phase 1 — Activate OCC (closes R1).** The key phase.
   - Add a typed `EntityConcurrentModificationException` (~10 lines).
   - In each `*MetaService` write, throw it on a 0-row update instead of the 
generic `IOException` (`TableMetaService`, `SchemaMetaService`, 
`CatalogMetaService`, `MetalakeMetaService`, `FilesetMetaService`, 
`TopicMetaService`, `ModelMetaService`, …). Caveat: confirm 0-row means 
"version mismatch" and not "entity gone"; if ambiguous, re-read before retrying 
and throw `NoSuchEntityException` if it's gone.
   - Add a bounded retry in `TreeLockUtils.doWithTreeLock`, **WRITE ops only**: 
catch the exception → back off (10/20/40ms) → re-run the `executable`. 
Precondition: the `executable` must be a "re-read → apply change → write" 
closure so a retry sees the fresh `current_version`. Retry exhaustion → HTTP 
409 via the server `ExceptionMapper`. Counts/backoff become config keys.
   - Why it's sufficient: `UPDATE ... WHERE current_version = N` runs under the 
DB row write lock; only one of two racers transitions `N → N+1`, the other gets 
0 rows and retries at the fresh version. DB atomicity *is* the cross-node 
serialization point — no external coordinator.
   
   **Phase 2 — Version monotonicity (closes R3).**
   - `POConverters.updateMetalakePOWithVersion` / `updateCatalogPOWithVersion` 
increment `current_version` like `updateTablePOWithVersionAndSchemaId` already 
does (today they set `nextVersion = lastVersion`). With strictly increasing 
versions, an ABA cycle can't slip past OCC. No schema change.
   - Risk point: confirm the metalake/catalog UPDATE `WHERE` actually carries 
`current_version` (they rely on full-field matching today); if not, add the 
version condition in `*MetaBaseSQLProvider` so OCC truly applies to these two 
entity types.
   
   **Phase 3 — Parent-existence guard for create/drop (closes R2).**
   - create/drop take a **parent-level** write lock that row-level OCC can't 
reproduce. Make the child insert atomically conditional on a live parent, in 
the same transaction:
     ```sql
     INSERT INTO table_meta (...)
     SELECT ..., sm.schema_id, ...
     FROM   schema_meta sm
     WHERE  sm.schema_id = #{schemaId} AND sm.deleted_at = 0;
     -- 0 rows inserted ⇒ schema concurrently dropped ⇒ NoSuchSchemaException
     ```
   - One per parent→child relationship (schema→table/fileset/topic/model, 
catalog→schema, metalake→catalog). Pure SQL, advanced entity by entity. (A FK 
with `ON DELETE` semantics would be cleaner long-term but needs a cross-dialect 
migration + soft-delete change — deferred.)
   
   **Phase 4 — Cross-node cache coherence (closes R4).**
   - Wire the existing `EntityChangeLogPoller` into `GravitinoEnv` behind a 
default-off flag (e.g. `gravitino.cache.entityChangeLog.enabled`). Each node 
polls `entity_change_log` on an independent cursor and invalidates the matching 
cache entries. DB stays the source of truth; staleness bounded by the poll 
interval. Default off ⇒ single-node behavior unchanged.
   
   **Phase 5 (optional) — Pluggable `LockBackend` SPI (#11020).**
   - Adopt the `LockBackend` abstraction: `InProcessLockBackend` (default, 
wraps today's TreeLock) and an opt-in `JdbcLockBackend` (pessimistic `SELECT … 
FOR UPDATE`/`FOR SHARE` on a path-keyed lock table) for deployments wanting 
explicit coarse-grained cross-node locks. OCC stays the unconditional floor; a 
JDBC backend would need a TTL + reaper for crashed holders. Stronger 
active-standby topologies (leader election + epoch fencing) can layer above 
this later.
   
   ### Why this over the alternatives
   
   - **vs Option A (ZK/etcd per-write):** no new external HA dependency / SPOF, 
and no network round-trip on every metadata op. A fencing token would still 
have to propagate into each DB write to be correct — at which point the 
external lock is largely redundant with the DB-level guard.
   - **vs Option B (remove TreeLock):** keeps cheap, correct intra-node 
serialization and avoids a big-bang refactor; pure row-level OCC can't express 
coarse parent-level locks (handled here by the Phase 3 conditional insert).
   - **vs Option C (partitioned ownership):** no 
router/watchdog/grace-period/trigger machinery and a much smaller blast radius.
   
   Performance-wise, OCC pays nothing on the happy path and only a re-read on 
an actual same-entity conflict (rare for metadata), and it keeps unrelated 
entities fully parallel — which matters for bursty concurrent DDL.
   
   ### Validation
   
   Two-node, shared-DB integration tests exercising R1–R4 (concurrent rename of 
the same table; `dropSchema` vs `createTable`; metalake/catalog ABA; cross-node 
read-after-write under cache), plus OCC unit tests (forced 0-row update → retry 
succeeds / exhausts to 409), and the full existing single-node suite with flags 
off to confirm zero regression. Suggested metrics: OCC retry rate, change-log 
poll lag (`maxId − lastConsumedId`), structural-op P99 latency.
   
   ---
   
   I'm happy to start with **Phases 1+2** as the first reviewable PR — they're 
the smallest change and close the main alter/rename + ABA races (~1 exception 
class + 1 line per service + ~15 lines in `TreeLockUtils` + 2 spots in 
`POConverters`). Does this overall direction sound reasonable before I proceed? 
Feedback on any of the phases — especially the Phase 1 retry-closure assumption 
and the Phase 2 `WHERE current_version` check — would be very welcome.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to