Skip to content

feat(rest): support table commit leases - #474

Merged
lxy-9602 merged 1 commit into
apache:mainfrom
SteNicholas:PAIMON-470
Oct 28, 2026
Merged

lxy-9602 merged 1 commit into
apache:mainfrom
SteNicholas:PAIMON-470

Conversation

@SteNicholas

Copy link
Copy Markdown
Member

Purpose

Linked issue: close #470

Java Paimon added table commit leases to the REST catalog (apache/paimon#10491, ownership
clarified in apache/paimon#10588). paimon-cpp knew neither option, so a writer configured with
rest.commit.acquire-lock=true committed without a lease and without an error, raced writers
that hold one, and was rejected by the server while a Java writer held the lease.

Port the client side of REST commit leases for commits through WithCatalog(), aligned with
Java RESTCatalogLock and RESTCatalogLockFactory.

  • Add rest.commit.lock-enabled (table property) and rest.commit.acquire-lock (writer
    option), both default false. Asking to acquire without the table capability fails with the
    Java message before any REST request; the table property alone does not make a writer acquire.
    A commit lease also requires the table UUID passed to WithTableId(), and a request-only
    commit built with UseRESTCatalogCommit() rejects rest.commit.acquire-lock=true.
  • Add the commit-lock and commit-lock/renew endpoints with CommitLockRequest and
    CommitLockResponse.
  • Add an internal CommitLock/CommitLockScope interface mirroring Java Lock.
    SnapshotCommit::AcquireLock() returns the scope of one publication attempt,
    CatalogSnapshotCommit holds the lock created by VersionManagedCatalog::CreateCommitLock(),
    and RestCatalog returns a RestCommitLock bound to the branch identifier, table UUID and
    commit user.
  • RestCommitLock polls every min(100ms, commit.timeout) while another writer holds the
    lease, without using up commit.max-retries, and rejects a grant for another commit user or
    without a positive leaseMillis. Each scope renews its lease every leaseMillis / 3 on a
    detached thread and tracks the deadline on a monotonic clock from when each grant was
    requested, so a refused or failed renewal, a changed leaseMillis or a local expiry
    invalidates it. Ending a scope only stops renewing; there is no unlock request. A new
    RestHttpClient::Cancellation aborts the renewal transfer and its retry backoff.
  • FileStoreCommitImpl acquires a scope before reading the head of each attempt, checks it
    right before SnapshotCommit::Commit() and releases it before waiting to retry. A failed
    acquisition or a lost scope publishes nothing and is retried within the commit.max-retries
    and commit.timeout budget of a conflict; an attempt that lost its scope removes the
    manifests it wrote, now including changelog manifests and their list. RollbackToAsLatest
    holds a scope too and fails when it cannot acquire or loses it.

File-system commits are unchanged.

Tests

  • RestCommitLockTest in src/paimon/rest/rest_commit_lock_test.cpp, ported from Java
    RESTCatalogLockTest:
    • MissingIdentityIsRejectedBeforeAnyRequest: no table UUID, commit user or valid timeout.
    • AcquirePollsUntilGranted and ContentionTimesOutWithoutRenewing: contention polls until
      granted, or fails once the timeout elapses.
    • FailedAcquisitionStartsNoLease: 400, 403 and 404 map to invalid argument, no permission
      and table not found; a grant for another commit user or with leaseMillis=0 and a renewal
      thread that cannot start fail the acquisition without renewing.
    • RenewalExtendsTheLeaseFromEachRequest, FailedRenewalInvalidatesTheLease and
      LocalExpiryInvalidatesTheLease: the deadline is measured from each request; a renewal
      answered acquired=false, an error, another owner or a changed leaseMillis, and a local
      expiry, invalidate the lease.
    • EndingTheScopeDoesNotWaitForARenewalInFlight,
      LateAnswerToAnEndedScopeCannotAffectTheNextScope and
      LeasesOfDifferentWritersDoNotInterfere.
  • src/paimon/rest/rest_catalog_test.cpp, following Java RESTCommitLockIntegrationTest and
    CommitLockProtocolTest:
    • RestCatalogTest.CommitLockAddressesTheTableOrBranch: leases address the table or branch,
      and system tables are rejected.
    • RestCatalogCommitLeaseTest.LeaseIsAcquiredBeforeTheHeadAndAgainAfterAConflict,
      LeaseNeedsBothOptionsAndTheTableUuid, ContendedLeaseIsAwaitedUntilCommitTimeout,
      LeaseExpiredLocallyBeforePublicationSendsNoCommit and
      RenewalRefusedBeforePublicationSendsNoCommit.
  • FileStoreCommitImplTest in src/paimon/core/operation/file_store_commit_impl_test.cpp:
    TestCatalogCommitHoldsALockScopePerAttempt, TestCatalogCommitRetriesOnceTheLockScopeIsLost,
    TestCatalogCommitRetriesAFailedLockAcquisition, TestCommitWithoutAUsableLockIsNotCreated
    and TestRollbackToAsLatestHoldsALockScope; TestCatalogCommitToBranchTakesTheBranchSnapshot
    checks the lock is bound to the branch identifier.
  • RestHttpClientTest.CancellationStopsWaitingForAResponse, CancellationEndsARetryBackoff
    and CancelledRequestIsNotSent in src/paimon/rest/rest_http_client_test.cpp.
  • CatalogSnapshotCommitTest.TestAcquireLockUsesTheLockItWasGiven.
  • RestMessagesTest.CommitLockRequestRoundTrip and CommitLockResponseRoundTrip,
    ResourcePathsTest.WithPrefix, and CoreOptionsTest.TestDefaultValue and TestFromMap.

API and Format

include/paimon/defs.h adds Options::REST_COMMIT_LOCK_ENABLED and
Options::REST_COMMIT_ACQUIRE_LOCK. No storage format changes.

On the wire, a writer with rest.commit.acquire-lock=true sends POST .../commit-lock and
POST .../commit-lock/renew with tableId and commitUser, as the Java client does. Other
requests are unchanged.

Documentation

Yes. docs/source/user_guide/catalog.rst describes both options, how a lease is acquired,
renewed and checked per attempt, how commit.timeout bounds the wait, and that a request-only
commit cannot hold a lease.

Generative AI tooling

Generated-by: Claude Code 2.1.311 (Claude Opus 5.5)

🤖 Generated with Claude Code

Port REST catalog table commit leases from Java Paimon. The table
property rest.commit.lock-enabled turns on server-side lease
enforcement, and a writer that sets rest.commit.acquire-lock acquires
a lease for each publication attempt. Both default to false. Asking to
acquire without the table capability, without a table UUID, or in a
request-only commit built with UseRESTCatalogCommit() fails when the
commit is created.

Add an internal CommitLock/CommitLockScope interface mirroring Java
Lock. SnapshotCommit exposes AcquireLock(), CatalogSnapshotCommit
holds the lock created by VersionManagedCatalog::CreateCommitLock(),
and RestCatalog returns a RestCommitLock bound to the branch
identifier, table UUID and commit user.

RestCommitLock acquires a lease through the commit-lock endpoint. While
another writer holds it, acquisition logs that it is waiting, then polls
until commit.timeout without using up commit.max-retries; the timeout is
checked after each response rather than interrupting a request in
flight. A grant for another commit user or without a positive lease
duration is rejected with an error naming the expected and actual value,
and a renewal thread that cannot be started fails the acquisition
instead of throwing.

Each scope renews its lease through commit-lock/renew every third of
the lease duration and tracks the deadline on a monotonic clock from
when each grant was requested, so a refused or failed renewal, a
changed lease duration or a local expiry invalidates it. There is no
unlock request: ending a scope only stops renewing, and never waits for
a renewal in flight, even one refreshing credentials, since renewal
runs on a detached thread sharing the lease state. The renewal request
is cancelled through a new RestHttpClient::Cancellation, which aborts
the libcurl transfer and ends a retry backoff.

FileStoreCommitImpl acquires a scope before reading the head of each
attempt, checks it in CommitSnapshotImpl right before
SnapshotCommit::Commit(), and releases it before waiting to retry. A
failed acquisition, or a scope found invalid, publishes nothing and is
retried within the commit.max-retries and commit.timeout budget of a
conflict. An attempt that lost its scope removes the manifests it
wrote, as one that lost a race does, and that cleanup now also covers
changelog manifests and their list. Running out of retries reports
the lease failure instead of a conflict. RollbackToAsLatest holds a
scope too; failing to acquire or losing it fails the rollback, and a
lost scope also removes the manifests it wrote.
@SteNicholas
SteNicholas requested a review from lucasfang October 27, 2026 12:03

@lucasfang lucasfang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

+1

@lxy-9602 lxy-9602 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

+1

@lxy-9602
lxy-9602 merged commit 1586ec4 into apache:main Oct 28, 2026
16 checks passed
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 REST commit leases for catalog-managed commits

3 participants