Skip to content

[core] Add a ZooKeeper-backed CatalogLock - #10156

Open
JTaky wants to merge 2 commits into
apache:masterfrom
JTaky:oleksandr.nitavskyi/zookeeper-catalog-lock
Open

JTaky wants to merge 2 commits into
apache:masterfrom
JTaky:oleksandr.nitavskyi/zookeeper-catalog-lock

Conversation

@JTaky

@JTaky JTaky commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Purpose:

Address the issue: #2825

Paimon's filesystem catalog over an object store (e.g. S3) needs an external lock to coordinate concurrent commits because rename is not atomic on an object store. Paimon already ships JDBC and Hive lock implementations for this; this adds a ZooKeeper one, selected via lock.type=zookeeper, aiming for the same lease-based coordination guarantees as JDBC locking.

It's built on a Curator InterProcessMutex (ephemeral-node acquire/run/release, matching Paimon's own lock contract), with two safeguards beyond a bare mutex: a session-loss epoch bumped by a ConnectionStateListener, and a synchronous checkExists() round trip against the held znode before snapshot publication. Curator's local ownership bookkeeping alone does not reliably detect session expiry. Detected ownership loss fails the commit, with stable log markers (paimon_lock_lost, paimon_lock_acquire_timeout, paimon_lock_acquire_error, paimon_lock_release_failed) for monitoring.

Like JDBC locking, this does not fence an in-flight non-atomic storage write if ownership expires during publication. Stronger guarantees require storage-side conditional publication or fencing.

Tests:

This has been running in our environment for about a month, protecting concurrent commits from two independent jobs: writer and separate compaction.

@JTaky
JTaky force-pushed the oleksandr.nitavskyi/zookeeper-catalog-lock branch 2 times, most recently from 5ffa113 to b29861d Compare September 30, 2026 12:47
@JTaky
JTaky force-pushed the oleksandr.nitavskyi/zookeeper-catalog-lock branch 5 times, most recently from 1a05e93 to ea48234 Compare October 7, 2026 07:38
@JTaky
JTaky marked this pull request as ready for review October 7, 2026 08:45
@JTaky
JTaky marked this pull request as draft October 7, 2026 08:46
@JTaky
JTaky force-pushed the oleksandr.nitavskyi/zookeeper-catalog-lock branch 4 times, most recently from e2f88fc to 67c11ea Compare October 7, 2026 13:42
@JTaky
JTaky marked this pull request as ready for review October 7, 2026 14:58
@JTaky
JTaky force-pushed the oleksandr.nitavskyi/zookeeper-catalog-lock branch from 67c11ea to fbc0313 Compare October 8, 2026 15:20

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

The ZooKeeper lock has a concrete end-to-end use case for concurrent object-store commits. Reviewed this head in an isolated worktree across locking, client lifecycle and engine dependencies. Standard JDK 8 Maven checks and all 55 existing ZooKeeper/FileSystemCatalog tests passed, including genuine session-loss coverage. Two additional production-class probes expose the issues below: equivalent physical warehouses admit concurrent critical sections, and the Curator dependency version supplied by supported Spark 3.4 fails against a real ZooKeeper server.


/** A warehouse URI cannot appear in a znode path literally, so it is hashed. */
static String hashWarehouse(String warehouse) {
String normalized = normalizeS3Path(warehouse.trim());

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.

[P1] Normalize the physical warehouse before deriving its lock namespace

CatalogFactory normalizes the storage warehouse with Paimon Path, but passes the original options through CatalogLockContext. This hash handles S3 scheme aliases and trailing slashes, not the same Path normalization. Thus s3://test-bucket/a//b and s3://test-bucket/a/b address the identical physical warehouse but use different znodes (dot segments have the same problem). I verified Path equality and, with the real embedded ZooKeeper server and two factory-created locks, held the first table lock while a second thread successfully entered the same db/table through the other warehouse spelling. The new lock therefore does not serialize those two writers/compaction jobs, exposing the object-store snapshot overwrite risk it is intended to prevent. Please normalize with Paimon Path as CatalogFactory does before applying the S3 alias folding and hashing; add an actual mutual-exclusion test for these physical path aliases.

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 warehouse path is normalized with Paimon Path before S3 alias folding and hashing. Added an embedded-ZooKeeper test that verifies equivalent paths contend for the same table lock.

Comment thread paimon-core/pom.xml
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>${curator.version}</version>
<scope>provided</scope>

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.

[P2] Make the compatible Curator runtime available to engine users

These new dependencies are provided, so the published engine artifacts neither include nor transitively supply the Curator version used by the new lock. Supported Spark 3.4.3 supplies curator-recipes 2.13.0 (the default Hadoop 2.8.5 stack also has 2.7.1); isOwnedByCurrentThread is package-private in those versions, whereas this implementation invokes it from outside that package. Against the same real ZooKeeper server, the production factory/runWithLock probe succeeds with the PR test classpath (Curator 5.6.0) but fails with the Curator client/framework/recipes 2.13.0 set: IllegalAccessError at runWithLock:154 and again at close:247. Core tests pin the newer libraries and do not exercise that engine runtime. Flink relocated classes also cannot satisfy these unrelocated imports. Please ship an isolated compatible dependency set, or provide explicit compatible-library installation requirements and engine-classpath tests, so enabling lock.type=zookeeper on a supported distribution actually works.

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 lock no longer calls isOwnedByCurrentThread(); it uses getLockPath() and checks the session epoch and lock node. A Spark 3.4 test exercises the production reentrant path with Curator 2.13.0. The docs also state which unshaded dependencies engines must provide; Flink’s relocated Curator alone is insufficient.

@JTaky
JTaky force-pushed the oleksandr.nitavskyi/zookeeper-catalog-lock branch 3 times, most recently from cc837e7 to b7eb6b1 Compare October 14, 2026 11:48

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

Requirement fit: SUPPORTED. Implementation: FINDINGS. The previous physical-warehouse namespace and Spark Curator access issues are repaired, but the filesystem use case in the PR purpose is still not connected to this implementation.

[P1] Wire ZooKeeper selection into the filesystem catalog

The new ZooKeeperCatalogLockFactory is discoverable, but FileSystemCatalog still inherits AbstractCatalog.lockFactory() returning Optional.empty() (AbstractCatalog.java:143–145). FileSystemCatalogFactory/CatalogFactory do not install another locking policy. CatalogEnvironment.snapshotCommit consequently obtains EmptyLock through either catalog reconstruction or the null-factory path. Setting metastore=filesystem, lock.enabled=true and lock.type=zookeeper therefore silently leaves actual table commits unlocked. Only the existing Hive/JDBC catalog overrides currently discover lock.type.

I reproduced this through the public CatalogFactory.createCatalog entry point, with a real embedded ZooKeeper server, three independently loaded catalogs/writers/committers and six successful one-row commits per writer. A local FileIO deliberately models non-atomic object-store snapshot publication (check existence, delayed overwrite): the configured ZooKeeper case retains only 6 of the expected 18 rows and admits three concurrent snapshot publishers, exactly like the disabled-lock control. Explicitly injecting this PR's factory into the catalog policy and preserving it through catalogLoader reconstruction, with the rest of the same table/commit/FileIO path unchanged, retains all 18 unique rows and limits publication concurrency to one. No production object store or credentials were used.

Please make the supported filesystem catalog actually resolve this lock configuration and preserve it when reconstructing its catalog/table environment, then cover concurrent real table commits from catalog-created tables. Direct factory construction in the existing lock/Spark tests bypasses this missing integration. If filesystem support is intentionally deferred, narrow the advertised behavior and reject the ineffective configuration rather than implying these commits are protected.

Verification: normal JDK 8 checks and 57 ZooKeeper/FileSystemCatalog tests passed. The real Spark 3.4.3 runtime test passed with Curator client/framework/recipes 2.13.0 and ZooKeeper 3.6.3 verified in the test classpath. Additional real-server client-lifecycle stress passed 800 concurrent create/double-close operations and 12 last-close/new-client races. Both current-head Core CI jobs fail the introduced documentation check described inline; the separate Python 3.6 REST filter failure is outside this Java diff. No source fixes were pushed.

ConfigOptions.key("lock-acquire-timeout")
.durationType()
.defaultValue(Duration.ofMinutes(8))
.withDescription("Maximum time to wait for acquiring the ZooKeeper lock.");

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.

[P2] Keep the shared lock-acquire-timeout definition consistent

This introduces the existing JDBC key lock-acquire-timeout with the same type/default but a different description. ConfigOptionsDocsCompletenessITCase explicitly rejects distinct definitions of one key, so both current-head Core CI jobs fail with "Ambiguous option lock-acquire-timeout due to distinct descriptions (JdbcCatalogOptions vs ZooKeeperCatalogLockOptions)." This is a deterministic failure in the required integration checks, not a dependency-download problem. Please use the existing generic description (or a shared ConfigOption definition) and update the generated ZooKeeper configuration HTML consistently. The default can remain eight minutes.

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.

Addressed. lock-acquire-timeout is now a shared CatalogOptions definition reused by JDBC and ZooKeeper. The generated catalog and ZooKeeper configuration tables were regenerated, and ConfigOptionsDocsCompletenessITCase passes.

@JTaky
JTaky force-pushed the oleksandr.nitavskyi/zookeeper-catalog-lock branch 2 times, most recently from 0272972 to 74e2252 Compare October 16, 2026 08:17
@JTaky

JTaky commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Requirement fit: SUPPORTED. Implementation: FINDINGS. The previous physical-warehouse namespace and Spark Curator access issues are repaired, but the filesystem use case in the PR purpose is still not connected to this implementation.

[P1] Wire ZooKeeper selection into the filesystem catalog

The new ZooKeeperCatalogLockFactory is discoverable, but FileSystemCatalog still inherits AbstractCatalog.lockFactory() returning Optional.empty() (AbstractCatalog.java:143–145). FileSystemCatalogFactory/CatalogFactory do not install another locking policy. CatalogEnvironment.snapshotCommit consequently obtains EmptyLock through either catalog reconstruction or the null-factory path. Setting metastore=filesystem, lock.enabled=true and lock.type=zookeeper therefore silently leaves actual table commits unlocked. Only the existing Hive/JDBC catalog overrides currently discover lock.type.
described inline; the separate Python 3.6 REST filter failure is outside this Java diff. No source fixes were pushed.

Addressed. FileSystemCatalog now resolves the configured catalog lock factory using the shared lock.enabled and lock.type options. The original CatalogContext is retained by FileSystemCatalogLoader, so reconstructed catalogs use the same locking policy. I added an end-to-end test that creates a filesystem catalog and table through CatalogFactory and verifies that the real table commit blocks behind the corresponding ZooKeeper lock.

@JTaky
JTaky force-pushed the oleksandr.nitavskyi/zookeeper-catalog-lock branch 2 times, most recently from 933b05c to 25e426b Compare October 16, 2026 13:42

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

Requirement fit: SUPPORTED. Implementation: FINDINGS. The filesystem factory wiring, shared option definition and Spark Curator compatibility are repaired. I ran 101 Core/JDBC/ZooKeeper tests plus the configuration-doc completeness and actual Spark 3.4 Curator runtime tests with regular Maven checks. An actual CatalogFactory concurrent-commit probe over simulated non-atomic snapshot publication retained 18/18 rows with this lock (peak publication concurrency 1), versus 6/18 without it (peak 3). SPI loading without any Curator/ZooKeeper jars and concurrent client-reference lifecycle probes also passed.

One cancellation-path issue remains below. The current JDK 11 CI failure is an ANTLR plugin dependency-resolution failure, not a test result.

boolean nodeGone;
try {
nodeGone = current.node == null || client.checkExists().forPath(current.node) == null;
} catch (Exception 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.

[P2] Preserve interruption while verifying or releasing the lock

When checkExists() is waiting for reconnection, Curator can throw InterruptedException from internalBlockUntilConnectedOrTimedOut outside its usual retry-loop interrupt restoration. This catch clears the interruption into nodeGone, and the later IllegalStateException drops its cause. The snapshot publication failure is then treated as retryable, so an interrupted commit can continue and publish. With a real CatalogFactory table and embedded ZooKeeper, I disconnected the server in a normal pre-publication callback, interrupted the commit thread while verification waited, then restarted the server: HEAD returned success on attempt 2 with interrupt=false and snapshot 1 published. A control changing only interrupt restoration in this class stopped on attempt 1 with interrupt=true. Please handle interrupted verification separately, restore the thread flag and retain the cause so cancellation stops retrying; preserve interruption in the scope-release catch as well, which currently bypasses Lock.release restoration.

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 point. Fixed.

Interrupted checkExists() now restores the thread’s interrupt flag and throws with the InterruptedException retained as the cause, instead of treating it as a missing lock node. Scope release now passes exceptions to Lock.release, which preserves interruption and suppresses release errors onto the operation failure. I also restored interruption during explicit close(). Added focused tests for all three paths, including a check that interrupted verification does not reach publication.

@JTaky
JTaky force-pushed the oleksandr.nitavskyi/zookeeper-catalog-lock branch 2 times, most recently from 4699575 to f805e5f Compare October 20, 2026 11:53

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

Requirement fit: SUPPORTED. No new implementation regression found after checking the interruption fix and running 189 Core/JDBC/REST/ZooKeeper and Spark 3.4 runtime tests with regular Maven checks. The JDK 8 CI failure I inspected is a Maven Central connection reset resolving scalatest-compatible, rather than a lock test failure.

Production-boundary note: the session/node checks validate ownership before publication; they do not fence an already in-flight non-atomic object-store publication. With two actual RenamingSnapshotCommit instances and embedded ZooKeeper, I paused A inside simulated non-atomic snapshot publication after validation, expired its session, let B publish snapshot 1, and resumed A. Both returned true, but snapshot 1 contained A, overwriting B. A control checking ownership after the pause rejected A and retained B, but that only moves the window and is not a complete fix for an in-flight write.

This is the existing finite-lease/publication protocol limitation, not a ZooKeeper-specific regression or a request to add another preflight check. Please keep the safety claim bounded accordingly: loss detected before publication fails visibly; loss during a non-atomic publication still needs storage-side conditional publication/fencing for a stronger guarantee. The PR description currently says the synchronous check runs after the callable, while the current production contract validates before publication.

@JTaky
JTaky force-pushed the oleksandr.nitavskyi/zookeeper-catalog-lock branch from f805e5f to c4c91bb Compare October 26, 2026 13:57
@JTaky

JTaky commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Requirement fit: SUPPORTED. No new implementation regression found after checking the interruption fix and running 189 Core/JDBC/REST/ZooKeeper and Spark 3.4 runtime tests with regular Maven checks. The JDK 8 CI failure I inspected is a Maven Central connection reset resolving scalatest-compatible, rather than a lock test failure.

Production-boundary note: the session/node checks validate ownership before publication; they do not fence an already in-flight non-atomic object-store publication. With two actual RenamingSnapshotCommit instances and embedded ZooKeeper, I paused A inside simulated non-atomic snapshot publication after validation, expired its session, let B publish snapshot 1, and resumed A. Both returned true, but snapshot 1 contained A, overwriting B. A control checking ownership after the pause rejected A and retained B, but that only moves the window and is not a complete fix for an in-flight write.

This is the existing finite-lease/publication protocol limitation, not a ZooKeeper-specific regression or a request to add another preflight check. Please keep the safety claim bounded accordingly: loss detected before publication fails visibly; loss during a non-atomic publication still needs storage-side conditional publication/fencing for a stronger guarantee. The PR description currently says the synchronous check runs after the callable, while the current production contract validates before publication.

Thanks for the detailed review and reproduction. Agreed: the ownership check runs before publication and does not fence an in-flight non-atomic write after session expiry. We aim for the same lease-based coordination guarantees as JDBC locking, not stronger storage-side fencing.

I’ve added this limitation to the documentation. I’ll also correct the PR description’s outdated “after the callable” wording and narrow its safety claims.

Rebased onto the latest master and pushed.

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.

2 participants