daib commented on issue #10474:
URL: https://github.com/apache/gravitino/issues/10474#issuecomment-4435340335

   # Addressing TreeLock Limitations for Gravitino HA Deployment
   
   ## Background
   
   Gravitino uses `TreeLock` (implemented in 
`core/src/main/java/org/apache/gravitino/lock/`) to ensure consistency and 
atomicity of metadata operations. The lock follows a hierarchical read-write 
locking strategy — acquiring read locks on ancestor nodes and a write lock on 
the target node. This design is managed by `LockManager`, which maintains an 
in-memory tree of `TreeLockNode` instances backed by Java's `ReadWriteLock`.
   
   The core problem in HA mode is straightforward: `TreeLock` is entirely 
in-process. Each Gravitino instance maintains its own isolated lock tree in JVM 
memory. Multiple instances behind a load balancer have no visibility into each 
other's lock state, enabling concurrent conflicting writes to shared metadata.
   
   ---
   
   ## Analysis of Proposed Options
   
   ### Option A — Distributed Lock (ZooKeeper, etcd, Redis)
   
   Option A preserves existing hierarchical lock semantics by replacing the 
in-memory lock tree with a distributed coordination service. While semantically 
correct, it introduces a fundamental performance problem: every metadata 
operation requires a network round trip to acquire and release a lock.
   
   Gravitino is a metadata catalog — a control plane, not a data plane. But 
even at moderate operation frequencies, serializing every metadata read and 
write through an external coordination service creates an unnecessary 
bottleneck. More critically, this approach introduces a new external dependency 
that becomes a potential single point of failure, and adds the operational 
complexity of managing distributed lock lease expiry, fencing tokens, and 
network partition handling.
   
   I have direct production experience with this failure class. While operating 
a large-scale distributed system, I diagnosed a chronic ingestion reliability 
issue caused by Zookeeper observer node stale reads — a subtle but severe 
consistency failure that took months to identify. The lesson: distributed 
coordination services are complex failure surfaces. Adding one to every 
metadata operation is an architectural liability, not just a performance 
concern.
   
   **Recommendation: reject Option A** for the general case. The coordination 
overhead is unjustified given the availability of better alternatives.
   
   ### Option B — Remove TreeLock; Rely on Storage-Level Consistency
   
   Option B eliminates `TreeLock` entirely, pushing concurrency control into 
the storage layer via optimistic locking, CAS operations, or serializable 
transactions. This is architecturally clean — the database is already shared 
across all instances, making it the natural coordination point.
   
   However, Option B has two significant problems. First, it requires 
refactoring every manager class that calls `TreeLockUtils.doWithTreeLock()` — a 
large and risky change surface spanning `CatalogManager`, `MetalakeManager`, 
`TableOperationDispatcher`, and many others. Second, and more fundamentally, 
pure optimistic locking cannot easily express coarse-grained parent-level write 
locks. Consider two concurrent operations: renaming schema `db1` to `db2` while 
simultaneously creating table `db1.newtable`. These conflict at the parent 
level — version-based CAS on individual rows cannot detect this hierarchical 
conflict without additional coordination.
   
   **Recommendation: reject Option B** as the primary approach. The refactoring 
risk is high and the hierarchical locking semantics are genuinely difficult to 
replicate at the storage layer.
   
   ---
   
   ## Proposed Solution: Option C — Partitioned TreeLock
   
   The core insight is to reframe the problem. Rather than asking "how do we 
distribute the lock?", we ask "how do we distribute ownership so each instance 
can run its existing local lock without coordination?"
   
   ### Design Overview
   
   **Partition the metadata tree** at the metalake level (or catalog level for 
finer granularity). Each Gravitino instance owns one or more partitions and is 
the sole authority for TreeLock operations within those partitions. Within a 
partition, the existing in-memory `TreeLock` runs completely unchanged — no 
refactoring, no semantic changes, no new coordination overhead for the common 
case.
   
   **Add a routing layer** that directs each incoming request to the instance 
that owns the relevant partition. The routing layer reads partition ownership 
from a shared database table, ensuring all routers have a consistent view.
   
   **Use the database for coordination** — no new external dependencies. Two 
new tables provide everything needed:
   
   ```sql
   -- Partition ownership and failover configuration
   CREATE TABLE partition_map (
     partition_key   VARCHAR,    -- e.g. 'metalake1'
     primary_id      VARCHAR,    -- current active instance
     standby_id      VARCHAR,    -- designated failover instance
     tree_version    BIGINT,     -- incremented on every ownership change
     promoted_at     TIMESTAMP,  -- when current primary was promoted
     updated_at      TIMESTAMP
   );
   
   -- Instance liveness detection
   CREATE TABLE instance_heartbeats (
     instance_id     VARCHAR,
     last_seen       TIMESTAMP
   );
   ```
   
   **Cross-partition operations** (rare — schema moves, metalake-level 
operations) use database advisory locks. The common case — operations within a 
single partition — never touches distributed coordination.
   
   ### Key Mechanism: TreeLock Version Tag
   
   When a standby instance is promoted to primary, `tree_version` is 
incremented in `partition_map`. Incoming requests carry the current 
`tree_version` in their header. On receiving a request with a version higher 
than its local version, the instance:
   
   1. Invalidates (clears) its local `TreeLock` subtree for that partition
   2. Processes the request normally — the `TreeLock` rebuilds lazily as 
requests arrive
   3. Updates its local version to the new value
   
   This version tag mechanism elegantly solves the stale state problem without 
requiring pre-warming, changelog replay, or any additional synchronization. The 
database is always the source of truth — the `TreeLock` is purely an ephemeral 
coordination cache that rebuilds on demand.
   
   ### Routing Layer
   
   The routing layer is the primary new piece of code. Its logic is 
straightforward:
   
   ```
   Request arrives for path /metalake1/catalog/db/table
   → Check watchdog — terminate self if stale (see below)
   → Extract partition key: metalake1
   → Read local cache of partition_map: primary_id = instance_A, tree_version = 
7
   → Am I instance_A?
     → Yes: handle locally with existing TreeLock
     → No: proxy to instance_A with tree_version=7 in request header
   → Instance_A receives request, checks version header
     → Version matches current: handle normally
     → Version higher than current: invalidate subtree, rebuild lazily, handle
   ```
   
   One competent engineer can implement this routing layer in approximately two 
weeks. The existing `TreeLock` and all manager classes remain completely 
unchanged.
   
   ### Router Watchdog — Staleness Self-Termination
   
   A critical correctness requirement: a router must never forward requests 
based on stale partition ownership. A router with a stale database connection 
could route to an instance that is no longer primary, causing correctness 
violations.
   
   This is addressed through a **watchdog mechanism**:
   
   - A background thread polls `partition_map` continuously, updating the 
router's local cache
   - Each successful poll resets the watchdog timer
   - **Before forwarding any request**, the router checks the watchdog timestamp
   - If the watchdog has not been updated within **one second** — the router 
**terminates itself**
   
   ```
   Per-request invariant check:
     IF watchdog_last_updated < NOW() - 1 second:
       → Kill self (process exit)
     ELSE:
       → Forward using current local cache
   ```
   
   **Why termination rather than pausing:** A paused router is still alive and 
may resume forwarding on stale state. Termination is unambiguous — the router 
is definitively gone. Kubernetes or the process supervisor restarts it 
immediately. On restart the router reads fresh state from `partition_map` 
before accepting any requests.
   
   **The invariant this enforces:** Every routing decision is made with 
partition ownership information no more than one second old. No router ever 
operates on stale state.
   
   ### Failover Protocol — Grace Period
   
   When any router detects that primary instance A has failed (via stale 
heartbeat in `instance_heartbeats`), it promotes the standby B via CAS on 
`partition_map`:
   
   ```sql
   UPDATE partition_map
   SET primary_id  = 'instance-b',
       tree_version = tree_version + 1,
       promoted_at  = NOW()
   WHERE partition_key = 'metalake1'
   AND   primary_id    = 'instance-a'  -- CAS: only one router wins
   ```
   
   All routers observe the promotion on their next database poll — **within one 
second**, enforced by the watchdog. On detecting a new promotion, each router 
enters a **grace period**:
   
   - New requests for that partition are **buffered** locally — not rejected, 
not forwarded
   - In-flight requests to A are allowed to complete or time out naturally
   - No requests are forwarded to B during the grace period
   
   After the grace period expires, routers forward buffered and new requests to 
B with the new `tree_version`.
   
   **Grace period duration:**
   
   ```
   grace_period = max(router_poll_interval, max_request_timeout) + safety_buffer
                ≈ 30–60 seconds (configurable per deployment)
   ```
   
   **Why this requires no router-to-router coordination:**
   
   The watchdog guarantees every active router reads the promotion within 1 
second. The grace period is set longer than the maximum in-flight request 
lifetime. Therefore after the grace period:
   
   - Every active router has seen the promotion — guaranteed by watchdog
   - Every in-flight request to A has completed or timed out — guaranteed by 
grace period
   - No router is forwarding to A — follows from both guarantees above
   
   No confirmation tables, no quorum calculation, no router heartbeats, no 
router-to-router communication required.
   
   **Client experience:** Clients observe a brief latency increase (buffering 
during grace period) rather than errors. Significantly better than approaches 
where in-flight requests fail and require client-side retry.
   
   ### Failure Scenarios
   
   #### Clean Crash
   
   ```
   T=0   Instance A crashes
   T=1   Router R1 detects stale heartbeat for A
   T=1   R1 promotes B via CAS: tree_version → 8, promoted_at = T=1
   T=2   All routers read promotion on next poll (watchdog enforces <1s)
   T=2   All routers buffer new requests, allow in-flight to A to expire
   T=32  Grace period expires (30 seconds)
   T=32  All routers forward buffered + new requests to B with tree_version=8
   T=32  B receives first request, detects version 8 > local version 7
   T=32  B invalidates subtree, rebuilds lazily, serves request
         Result: brief buffering latency, no failed requests
   ```
   
   #### Intermittent Failure
   
   The routing layer tracks a rolling health score per instance based on recent 
response success rates. When A's health score drops below a configurable 
threshold, any router initiates promotion to B — triggering the same grace 
period protocol even if A's heartbeat is still arriving. A cannot interfere 
with B's operation because `tree_version` acts as a fencing token: requests 
carrying a stale version are rejected at the instance level.
   
   For persistent hardware degradation (bad machine, memory pressure), the 
monitoring system pages the on-call engineer. The affected machine is cordoned 
and the partition is permanently reassigned. This is the appropriate resolution 
— not complex automatic recovery that might mask underlying hardware issues.
   
   #### Router Failure During Failover
   
   If a router loses database connectivity during the grace period:
   
   - Watchdog detects the failure within one second
   - Router terminates itself
   - Load balancer stops sending traffic to the dead router
   - On restart: router reads fresh `partition_map`, observes the promotion, 
respects remaining grace period or forwards immediately if it has already passed
   
   The system self-heals without any coordination overhead.
   
   #### Stale Write Prevention — Database-Level Fencing
   
   A slow instance may stall between receiving a request and executing the 
database write. Consider:
   
   ```
   T=0   Request routed to A with tree_version=7
   T=1   A acquires TreeLock, begins processing
   T=2   A stalls — GC pause, slow disk, memory pressure
   T=5   A's heartbeat stops, router promotes B, tree_version → 8
   T=40  B processes retry, commits successfully
   T=45  A recovers from stall, proceeds to execute write
         A's in-process tree_version check passed at T=1 — before the stall
         A writes to database — CORRUPTION
   ```
   
   **In-process checks are insufficient.** The vulnerability exists precisely 
in `TreeLockUtils.doWithTreeLock()` — between `lock.lock()` acquiring the 
in-memory TreeLock and `executable.execute()` completing the database write:
   
   ```java
   public static <R, E extends Exception> R doWithTreeLock(
       NameIdentifier identifier, LockType lockType, Executable<R, E> 
executable) throws E {
     TreeLock lock = 
GravitinoEnv.getInstance().lockManager().createTreeLock(identifier);
     try {
       lock.lock(lockType);
       return executable.execute();  // ← stall can occur here, after lock 
acquired
                                     //   before or during database write
     } finally {
       lock.unlock();
     }
   }
   ```
   
   A JVM stall of arbitrary duration can occur inside `executable.execute()`. 
Any in-process version check before this call has already passed — it cannot 
protect the write. This is a classic TOCTOU (Time-Of-Check-To-Time-Of-Use) 
race. A post-write check is also insufficient — the committed write is already 
visible to other instances before the check fires, potentially causing 
cascading inconsistency.
   
   **The fencing token must be enforced before the write commits — atomically, 
inside the same transaction.**
   
   **Solution: ThreadLocal + SessionUtils injection + trigger**
   
   All metadata writes flow through `SessionUtils` — specifically 
`doWithCommit()`, `doWithCommitAndFetchResult()`, and `doMultipleWithCommit()`. 
These are the transaction boundaries. The fix injects the `tree_version` as a 
database session variable on the exact connection used for the write, 
guaranteed by injecting immediately after the `SqlSession` connection is 
obtained.
   
   **Step 1 — `doWithTreeLock()` sets a ThreadLocal:**
   
   ```java
   public static <R, E extends Exception> R doWithTreeLock(
       NameIdentifier identifier, LockType lockType, Executable<R, E> 
executable) throws E {
     TreeLock lock = 
GravitinoEnv.getInstance().lockManager().createTreeLock(identifier);
     try {
       lock.lock(lockType);
       // Set ThreadLocal — SessionUtils reads it on the same thread
       TreeVersionContext.set(getTreeVersion(identifier));
       return executable.execute();
     } finally {
       lock.unlock();
       TreeVersionContext.clear();
     }
   }
   ```
   
   **Step 2 — `SessionUtils` injects session variable after obtaining 
connection:**
   
   ```java
   public static <T> void doWithCommit(Class<T> mapperClazz, Consumer<T> 
consumer) {
       try {
           T mapper = SqlSessions.getMapper(mapperClazz);
           SqlSessions.setTreeVersionIfPresent(); // ← inject on same connection
           consumer.accept(mapper);
           SqlSessions.commitAndCloseSqlSession();
       } catch (Throwable t) {
           SqlSessions.rollbackAndCloseSqlSession();
           throw t;
       }
   }
   
   public static <T, R> R doWithCommitAndFetchResult(
       Class<T> mapperClazz, Function<T, R> func) {
       try {
           T mapper = SqlSessions.getMapper(mapperClazz);
           SqlSessions.setTreeVersionIfPresent(); // ← inject on same connection
           R result = func.apply(mapper);
           SqlSessions.commitAndCloseSqlSession();
           return result;
       } catch (Throwable t) {
           SqlSessions.rollbackAndCloseSqlSession();
           throw t;
       }
   }
   
   public static void doMultipleWithCommit(Runnable... operations) {
       SqlSessions.getSqlSession();
       try {
           SqlSessions.setTreeVersionIfPresent(); // ← inject on same connection
           Arrays.stream(operations).forEach(Runnable::run);
           SqlSessions.commitAndCloseSqlSession();
       } catch (Throwable t) {
           SqlSessions.rollbackAndCloseSqlSession();
           throw t;
       }
   }
   ```
   
   **`SqlSessions.setTreeVersionIfPresent()` reads the ThreadLocal:**
   
   ```java
   public static void setTreeVersionIfPresent() {
       Long version = TreeVersionContext.get();
       if (version != null) {
           // PostgreSQL: SET LOCAL gravitino.tree_version = <version>
           // MySQL:      SET @gravitino_tree_version = <version>
           setSessionVariable("gravitino.tree_version", version);
       }
   }
   ```
   
   **Step 3 — Database trigger enforces fencing before commit:**
   
   ```sql
   CREATE OR REPLACE FUNCTION check_tree_version_fencing()
   RETURNS TRIGGER AS $$
   DECLARE
     request_version BIGINT;
     current_version BIGINT;
     p_key           VARCHAR;
   BEGIN
     request_version := current_setting('gravitino.tree_version', true)::BIGINT;
   
     -- Only enforce if tree_version was injected (HA mode)
     IF request_version IS NULL THEN
       RETURN NEW;
     END IF;
   
     p_key := get_partition_key(NEW.entity_path);
   
     SELECT tree_version INTO current_version
     FROM   partition_map
     WHERE  partition_key = p_key;
   
     IF request_version != current_version THEN
       RAISE EXCEPTION
         'Stale partition ownership: request version % != current version %',
         request_version, current_version;
     END IF;
   
     RETURN NEW;
   END;
   $$ LANGUAGE plpgsql;
   
   CREATE TRIGGER enforce_tree_version_fencing
   BEFORE INSERT OR UPDATE ON metadata_table
   FOR EACH ROW EXECUTE FUNCTION check_tree_version_fencing();
   ```
   
   **Why this is connection-safe:**
   
   ```
   doWithTreeLock()
   → TreeVersionContext.set(7)           — ThreadLocal on calling thread
   → executable.execute()
     → doWithCommit() / doMultipleWithCommit()
       → SqlSessions.getMapper()         — connection C1 obtained
       → setTreeVersionIfPresent()       — session variable set on C1
       → mapper executes SQL on C1       — same connection guaranteed
         → trigger fires BEFORE commit   — reads session variable from C1
         → version 7 != 8 → EXCEPTION   — transaction rolls back
   → TreeVersionContext.clear()
   ```
   
   The ThreadLocal carries the version across the call stack on the same 
thread. `SessionUtils` sets the session variable immediately after obtaining 
the connection — on that exact connection. The trigger fires inside the 
transaction — write is never committed.
   
   **Read-only operations** — `getWithoutCommit()` and `doWithoutCommit()` — 
are unchanged. No session variable injection needed — these don't write, the 
trigger does not fire.
   
   **Deployment prerequisite:**
   
   This approach requires either no connection pooler or session-mode pooling 
(e.g. pgbouncer in session mode). Transaction-mode or statement-mode poolers 
may reset session variables between statements. For such deployments, 
`setTreeVersionIfPresent()` should be called within an explicit transaction 
boundary to guarantee the session variable persists through the write.
   
   **What this achieves:**
   
   - Write rejected before commit — no inconsistency window possible
   - All manager classes — completely unchanged
   - All SQL statements — completely unchanged
   - `Executable` interface and all call sites — completely unchanged
   - `doWithTreeLock()` — two lines added: set/clear ThreadLocal
   - `SessionUtils` — one line added to each of the three write methods
   - `SqlSessions` — one new method `setTreeVersionIfPresent()`
   - New class `TreeVersionContext` — ~10 lines, ThreadLocal wrapper
   
   #### Retry Safety
   
   When a client times out and retries, both the original stalled instance and 
the retry target may attempt the same write. All write operations carry a 
client-generated idempotent request ID as a second line of defense:
   
   ```sql
   INSERT INTO metadata_operations (request_id, operation, entity_path, ...)
   ON CONFLICT (request_id) DO NOTHING
   ```
   
   The database deduplicates on `request_id`. However, the database-level 
fencing token is the primary and unconditional guard — it rejects stale writes 
regardless of retry behavior, request IDs, or client implementation.
   
   ### Deployment Tiers
   
   **Tier 1 — Lazy Takeover (minimal complexity):**
   Any healthy instance claims an orphaned partition via CAS on 
`partition_map`. No designated standby required. Suitable for development 
environments and deployments where grace period buffering latency is acceptable.
   
   **Tier 2 — Designated Standby (faster, more predictable):**
   Each partition has a designated standby registered in `partition_map`. On 
primary failure, the standby claims the partition immediately without a 
claiming race. Suitable for production deployments with stricter availability 
requirements.
   
   Both tiers use the same watchdog and grace period protocol — only the 
standby assignment logic differs.
   
   ### Correctness Bound
   
   A key property of this design: **it is never worse than Option A in any 
scenario.**
   
   Let:
   - N = total metadata operations
   - C = cross-partition operations (0 ≤ C ≤ N)
   - `d_distributed` = cost of distributed lock operation
   - `d_local` = cost of local TreeLock operation
   
   Option A cost: N × d_distributed
   Option C cost: C × d_distributed + (N - C) × d_local
   
   Since d_local << d_distributed and C ≤ N:
   
   **Option C cost ≤ Option A cost always.**
   
   In the common case where C approaches 0, Option C approaches pure local 
TreeLock performance — identical to current single-instance behavior. In the 
degenerate case where every operation is cross-partition, Option C degrades to 
Option A — never worse than the baseline.
   
   ### Future Scalability
   
   The partitioning primitive extends naturally to database scalability. When a 
single database becomes a bottleneck, each partition maps to a dedicated 
database shard:
   
   ```
   metalake1 → Instance A → Database Shard 1
   metalake2 → Instance B → Database Shard 2
   ```
   
   The routing layer already knows the partition key for every request. 
Database shard routing is a natural extension of the same logic. Cross-shard 
operations use the same distributed lock mechanism as cross-partition 
operations today.
   
   ---
   
   ## Migration Strategy
   
   The migration is entirely additive. No existing code changes are required.
   
   **Phase 1 — Infrastructure (no behavior change):**
   Deploy `partition_map` and `instance_heartbeats`. Configure a single 
instance owning all partitions. All requests route to that instance — identical 
to current behavior.
   
   **Phase 2 — Multi-instance validation:**
   Add a second instance with distinct partitions. Validate routing correctness 
and TreeLock version invalidation. Monitor cross-partition operation frequency 
to calibrate partition granularity.
   
   **Phase 3 — Standby configuration:**
   Enable designated standby instances. Validate grace period failover 
behavior. Tune watchdog interval and grace period duration for the deployment's 
SLA.
   
   **Phase 4 — Full HA:**
   Scale to desired instance count. Each phase is independently deployable and 
reversible.
   
   ---
   
   ## Implementation Notes
   
   I am happy to implement the routing layer as part of this contribution. I 
estimate approximately two weeks for a complete, tested implementation 
including:
   
   - Partition map management and instance heartbeat renewal
   - Request routing with tree_version tag propagation
   - Watchdog mechanism with self-termination on database staleness
   - Grace period buffering on promotion detection
   - Health score monitoring for intermittent failure detection
   - Cross-partition distributed lock acquisition
   - Idempotent request ID handling
   - ThreadLocal injection in `doWithTreeLock()`, `SessionUtils`, and database 
trigger
   
   **New database tables required (2 total):**
   
   ```sql
   partition_map        -- partition ownership, tree_version, promoted_at
   instance_heartbeats  -- instance liveness detection
   ```
   
   **Existing code changes (minimal):**
   
   - `TreeLockUtils.doWithTreeLock()` — set/clear `TreeVersionContext` 
ThreadLocal around `executable.execute()`
   - `SessionUtils.doWithCommit()` — one line after `getMapper()`
   - `SessionUtils.doWithCommitAndFetchResult()` — one line after `getMapper()`
   - `SessionUtils.doMultipleWithCommit()` — one line after `getSqlSession()`
   - `SqlSessions` — one new method `setTreeVersionIfPresent()`
   - `TreeVersionContext` — new ThreadLocal wrapper class, ~10 lines
   - Database trigger on metadata write tables
   
   All manager classes, all SQL statements, `TreeLock`, `LockManager`, 
`TreeLockNode`, and all call sites remain completely unchanged.
   
   ---
   
   ## Summary
   
   | Aspect | Option A | Option B | Option C |
   |--------|----------|----------|----------|
   | External dependency | Yes (ZK/etcd) | No | No |
   | Code changes | Minimal | Significant refactor | `doWithTreeLock()` + 
`SessionUtils` + trigger |
   | Performance | Every op has network cost | Local + retry overhead | Local 
for common case |
   | Correctness | Strong | Complex to implement | Equivalent to Option A |
   | Router safety | N/A | N/A | Watchdog self-termination |
   | Failover mechanism | Complex lease management | N/A | Grace period + 
version tag |
   | Router coordination | N/A | N/A | None required |
   | Migration risk | Low | High | Very low |
   | New DB tables | External service | 0 | 2 |
   | Future scalability | Limited | Good | Extends to DB sharding |
   
   Option C preserves existing `TreeLock` semantics entirely, adds zero new 
external dependencies, and is provably never worse than Option A. The watchdog 
mechanism ensures no router ever operates on stale partition state — 
self-termination on database staleness eliminates the need for router-to-router 
coordination entirely. The grace period approach provides clean failover with 
no failed requests and no complex synchronization protocols. Database-level 
fencing tokens enforce ownership atomically at the write layer, closing the 
TOCTOU gap that in-process checks cannot. The design provides a clear migration 
path from single-instance to fully partitioned HA deployment, and extends 
naturally to database-level sharding as Gravitino scales.
   


-- 
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