Skip to content

[improve][broker] PIP-473 P5.3: metadata-store TC leader election + assignment watch - #25929

Merged
merlimat merged 9 commits into
apache:masterfrom
merlimat:mmerli/pip-473-tc-election
Jun 5, 2026
Merged

[improve][broker] PIP-473 P5.3: metadata-store TC leader election + assignment watch#25929
merlimat merged 9 commits into
apache:masterfrom
merlimat:mmerli/pip-473-tc-election

Conversation

@merlimat

@merlimat merlimat commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Motivation

The scalable-topics transaction coordinator (PIP-473) so far reused the
transaction_coordinator_assign partitioned topic for two jobs: per-partition leader election and client discovery. Both route through the topic / namespace / bundle / load-balancer machinery, so TC coordination liveness is entangled with that machinery — even though the TC keeps no state in the assign-topic (its ledgers are empty; it's purely an ownership token).

This PR makes the scalable-topics TC's leader election depend on the metadata store only, and replaces the topic-lookup-based discovery with a dedicated assignment watch. The coordinator now rests directly on the metadata store it already hard-depends on for every header read/write, instead of on a layer above it.

Note: sharding is unchanged — placement is still client-side round-robin and the TC partition is encoded in TxnID.mostSigBits. Only how a broker becomes leader for partition N and how the client finds that broker change.

Modifications

Broker election

  • Per-partition LeaderElection<TcLeader> over /txn/tc/leader/<N> via the existing CoordinationService.
  • New config transactionCoordinatorScalableTopicsParallelism (default 16) — the degree of coordinator parallelism; replaces the assign-topic partition count for the v5 path.
  • TransactionCoordinatorV5.isLeaderFor(int) now gates both client-connect and the timeout/GC sweeps (was the assign-topic ownership check).

Discovery (new wire commands)

  • CommandWatchTcAssignments / CommandWatchTcAssignmentsUpdate / CommandWatchTcAssignmentsClose + TcAssignment / TcAssignmentsSnapshot.
  • The client opens one watch; the broker replies with the full partition → leader map and re-pushes the full snapshot on every leadership change. No point lookup, no diff/hash — the map is bounded (parallelism, ~16) and changes rarely, so always sending the whole snapshot is simpler and removes a class of apply-ordering/drift bugs.
  • New FeatureFlags.supports_tc_metadata_discovery.

Client

  • TcDiscovery strategy: WatchTcAssignmentsDiscovery (new path) when the broker advertises the feature flag, else AssignTopicTcDiscovery (the existing flow — still the v4 path).
  • TransactionMetaStoreHandler can connect directly to a coordinator's elected leader broker and retarget when a snapshot moves leadership.

Backward compatibility

Purely additive on the wire. The assign-topic remains the v4 / fallback discovery surface during the deprecation window:

  • new client + new broker → assignment watch;
  • old client + new broker → assign-topic lookup (broker still owns the bundle; TC_CLIENT_CONNECT still works);
  • new client + old broker → feature flag absent → falls back to assign-topic.

Defaults are unchanged — the scalable-topics TC stays off; the default flip lands in P5.4.

Tests

  • TransactionCoordinatorV5Test — election + leadership coverage (21 cases).
  • CommandsTcAssignmentsTest — wire round-trip (5 cases).
  • TcMetadataDiscoveryTest — multi-broker docker integration: transaction lifecycle over the assignment watch across all coordinator partitions, and survival of a leader-broker failure (re-election → watch refresh → handler retarget). Wired into the TRANSACTION CI group.
  • Checkstyle clean across broker / client / common / integration.

Scope note: the integration tests exercise the transaction lifecycle (newTransaction / commit / abort) over the discovered connections — the full surface the new client discovery path drives. A data-in-transaction e2e (produce/ack inside a txn) additionally needs the scalable-topic buffer + pending-ack providers and segment:// topics, which land with P5.4 / P6.

merlimat added 5 commits June 3, 2026 20:45
…ssignment watch

Motivation: the scalable-topics transaction coordinator (PIP-473) reused the
transaction_coordinator_assign topic for both leader election and client
discovery, tying TC coordination liveness to the topic/bundle/load-balancer
machinery even though the TC keeps no state in that topic. This makes election
depend only on the metadata store.

Modifications:
- Broker election: per-partition LeaderElection over /txn/tc/leader/<N> via the
  existing CoordinationService; a degree-of-parallelism config
  (transactionCoordinatorScalableTopicsParallelism, default 16) replaces the
  assign-topic partition count. TransactionCoordinatorV5.isLeaderFor(int) now
  gates both client-connect and the timeout/GC sweeps.
- Discovery: new CommandWatchTcAssignments / ...Update / ...Close wire commands.
  The client opens one watch and the broker pushes the full partition->leader
  snapshot, re-pushing it on any leadership change (no point lookup, no diff/
  hash — the map is small and changes rarely). Gated by a new
  supports_tc_metadata_discovery feature flag.
- Client: TcDiscovery strategy selects WatchTcAssignmentsDiscovery (new path)
  when the broker advertises the flag, else the existing assign-topic flow
  (still the v4 path). Handlers connect directly to each coordinator's elected
  leader and retarget when a snapshot moves leadership.
- Tests: TransactionCoordinatorV5Test election/leadership coverage; Commands
  round-trip; multi-broker docker integration test (TcMetadataDiscoveryTest)
  covering the transaction lifecycle over the watch and survival of a leader
  broker failure.

The assign-topic remains in place as the v4 / fallback discovery surface during
the deprecation window; defaults are unchanged (flip lands in P5.4).
startAsync() probed the broker's supports_tc_metadata_discovery flag via
getConnectionToServiceUrl(), which throws InvalidServiceURL for http:// service
URLs (it requires binary-proto lookup). That broke every transaction client
configured with an HTTP service URL — the prior code used
getPartitionedTopicMetadata(), which works over both HTTP and binary.

selectDiscovery() now short-circuits to the assign-topic flow when the lookup
service isn't binary-proto (HTTP URL), since the metadata-store assignment watch
needs a binary connection anyway. Only probe the feature flag when binary lookup
is available.
…n in tests

Two follow-up fixes after the discovery refactor:

- selectDiscovery() now falls back to the assign-topic flow if the feature-flag
  probe connection fails. Fixes testClientStartWithRetry, which uses a multi-host
  service URL with a deliberately-bad endpoint: the single probe connection has no
  cross-host retry, whereas the assign-topic lookup does. Falling back is always
  safe (the assign topic still works against v5 brokers during the deprecation
  window).

- The handler array moved from TransactionCoordinatorClientImpl into the
  TcDiscovery strategies, which broke tests reflecting on the private "handlers"
  field. Add TcDiscovery.handlers() + a @VisibleForTesting
  TransactionCoordinatorClientImpl.getHandlers() accessor and switch
  TransactionClientConnectTest to it (no reflection into private state, per the
  repo testing guideline).
…REFIX_

The scalable-topics TC settings were passed as bare env vars, but
apply-config-from-env.py only applies bare names to keys already present in
broker.conf. transactionCoordinatorScalableTopicsEnabled / ...Parallelism aren't
in broker.conf, so they were silently ignored: the v5 TC never started and the
cluster ran 16 legacy assign-topic coordinators (assertion saw 16, expected 4).

Prefix both new keys with PULSAR_PREFIX_ so they're appended as new config.
transactionCoordinatorEnabled stays bare (it is in broker.conf).

Also make the lifecycle assertion await-based: at startup a partition can be
briefly mid-election and absent from the first assignment snapshot, so loop
until every coordinator has minted a txn rather than assuming a fixed count.
@lhotari

lhotari commented Jun 4, 2026

Copy link
Copy Markdown
Member

the TC partition is encoded in TxnID.mostSigBits

Is is possible to modify transactionCoordinatorScalableTopicsParallelism afterwards? Since aborted transactions will stay around as long as the messages participating in the transaction are retained, does that cause additional constraints?

@lhotari

lhotari commented Jun 4, 2026

Copy link
Copy Markdown
Member

These are the findings of a local Claude Code review. Please check whether these need to be addressed.

1. [BUG] Follower-broker snapshot push races the leader-value cache; no re-push → client stuck on a dead leader indefinitely

TransactionCoordinatorV5.buildAssignmentsSnapshot + onElectionStateChange

Trace for a 3+ broker cluster (A leads partition N; B and C follow; the client's watch is on C):

  • A dies → Deleted notification. MetadataCacheImpl.accept invalidates C's cache entry (MetadataCacheImpl.java:378-379). LeaderElectionImpl.handlePathNotification silently sets NoLeader and re-elects.
  • C loses to B → changeState(Following) fires C's listener synchronously inside handleExistingLeaderValue (LeaderElectionImpl.java:163) — before the cache.refresh(path) / cache.get(path) continuation in elect() (LeaderElectionImpl.java:115-121) repopulates the cache (that's a metadata-store RTT away).
  • The ServerCnx listener task (ctx.executor().execute(...), sub-millisecond) builds the snapshot via getLeaderValueIfPresent() = cache.getIfCached(path)empty → partition N is omitted from the pushed snapshot. Note that MetadataCacheImpl.refresh() is computeIfPresent-only (MetadataCacheImpl.java:358-361), so the Created notification for B's node does not repopulate an invalidated entry either.
  • There is no subsequent trigger: the cache repopulating fires no TC listener, the broker has no periodic/delayed re-push, and the client never re-polls. The client's handler for partition N keeps reconnect-looping against dead broker A "until the next snapshot" — which only arrives if some other leadership change happens later.

The PR description's claim that the client parks a mid-election partition "until a later snapshot fills it in" is therefore not upheld — the later snapshot may never come.

The 2-broker integration test structurally masks this (PulsarClusterSpec.numBrokers = 2): the survivor transitions Following→Leading for every moved partition, and in the Leading path tryToBecomeLeader populates the cache before firing the listener (LeaderElectionImpl.java:194-208), so its snapshots are always correct.

Suggested fixes: build the snapshot from getLeaderValue() (async, loads on miss) instead of getLeaderValueIfPresent(), and/or debounce-re-push a short delay after any change, and/or re-push when a snapshot was incomplete.

2. [BUG] Two paths where the client watch dies silently with no reconnect

WatchTcAssignmentsDiscovery

  • attach(): on a reconnect (initial snapshot already received), if the newly-connected broker doesn't advertise supports_tc_metadata_discovery (e.g. per-broker config drift on transactionCoordinatorScalableTopicsEnabled), initialSnapshotFuture.completeExceptionally(...) is a no-op on the already-completed future and the method returns — no scheduleReconnect(). Discovery is permanently frozen on the last snapshot.
  • onError(): post-initial errors only log. ClientCnx.handleCommandWatchTcAssignmentsUpdate has already removed the session on error, so no connectionClosed() will ever fire for it either. A broker answering NotAllowedError / ServiceNotReady after a reconnect kills the watch forever.

Both leave all handlers pinned to stale leaders with no recovery. Combined with finding 1, eventual consistency of the assignment map is not guaranteed.

3. [BUG] Initial watch open is one-shot — a transient failure hard-fails transaction client startup

TransactionCoordinatorClientImpl.selectDiscovery + WatchTcAssignmentsDiscovery.start

The probe (getConnectionToServiceUrl()) and the watch (getAnyBrokerProxyConnection()) can land on different brokers. If the watch broker rejects the open — e.g. ServiceNotReady while its TC is still initializing, or the feature flag is off there due to config drift — attach() / onError() fail initialSnapshotFuturestartAsync() fails outright. There is no retry for retryable errors, so transaction-enabled client startup can fail nondeterministically. Consider retrying the watch open (with the existing reconnectBackoff) for retryable failures instead of failing startup.

4. [QUALITY] new URI(null) NPE for a non-TLS client when a leader advertises only a TLS URL

TransactionMetaStoreHandler ctor / retargetLeader

HandlerState.setRedirectedClusterURI picks serviceUrl when !isUseTls() (HandlerState.java:56-59); TcAssignment.broker_service_url is optional and the snapshot delivers null for TLS-only brokers. new URI(null) throws NPE, not URISyntaxException — so the constructor's catch doesn't wrap it and retargetLeader's catch doesn't swallow it. It propagates out of handlers.compute inside onSnapshot on the netty read thread → channel teardown → reconnect → repeat. A misconfigured client, but the failure mode should be a clear error, not an exception loop.

5. [QUALITY] transactionCoordinatorScalableTopicsParallelism has no validation or cross-broker consistency enforcement

ServiceConfiguration.java

No minValue = 1 on the @FieldContext (other int configs set it; 0/negative silently yields a coordinator that leads nothing and nextHandler() == null). More importantly, the doc says "Fixed at cluster bring-up," but nothing enforces agreement: brokers with different values run different election sets, and the snapshot's parallelism flip-flops depending on which broker serves the watch. Consider persisting the value in the metadata store on first start and failing/warning on mismatch.

6. [QUALITY] The integration test can't prove the new path is exercised

TcMetadataDiscoveryTest

If selectDiscovery's probe transiently fails, it silently falls back to AssignTopicTcDiscovery — and both tests still pass, since the assign topic is initialized with the same partition count (TC_PARALLELISM). Consider asserting the selected discovery type (e.g. via the "Transaction coordinator discovery selected" log line or a test hook); otherwise a regression that breaks the watch path entirely could go green in CI.

7. [QUALITY] Minor notes

  • AssignTopicTcDiscovery.handlers() reads the handlers field twice (handlers == null ? List.of() : List.of(handlers)) — a concurrent close() between the two reads NPEs. Test-only surface, trivial to fix with a local variable.
  • On broker startup with 16 partitions, each elect completion fires every watcher listener → a connecting client may receive a burst of ~16 full snapshots. Bounded and harmless, but a debounce (which finding 1 wants anyway) would also fix this.

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

LGTM, however please check the local Claude Code review findings about the possible bugs.

Fixes from lhotari's review on apache#25929:

- Snapshot completeness (BUG): buildAssignmentsSnapshot now uses the async
  LeaderElection.getLeaderValue() (loads from the metadata store on a cache miss)
  instead of the cache-only getLeaderValueIfPresent(), so a follower that just
  transitioned for a partition doesn't omit it from the snapshot. ServerCnx
  schedules a short re-push when a snapshot is incomplete (a partition still
  mid-election) or fails to build, so the client converges without an external
  trigger.

- Client watch recovery (BUG): WatchTcAssignmentsDiscovery now reconnects on every
  post-initial death path — a reconnected broker lacking the feature flag, and
  post-initial onError (ClientCnx has already removed the session, so nothing else
  recovers it). Previously these froze the watch on a stale snapshot.

- Initial-open retry (BUG): the initial watch open retries retryable failures up to
  the lookup deadline instead of hard-failing transaction-client startup (the probe
  and the watch can land on different brokers).

- TLS-only URL (NPE): HandlerState.setRedirectedClusterURI throws a clear
  URISyntaxException instead of new URI(null) NPE when no usable URL is selected;
  onSnapshot isolates per-partition failures so one bad leader URL can't tear down
  the watch.

- Parallelism (config): minValue=1; the value is persisted cluster-wide at
  /txn/tc/parallelism on first start and every broker fails fast on mismatch.
  Documented the fixed-at-bring-up constraint (incl. aborted-txn retention).

- Integration test now asserts the metadata-discovery path was actually selected,
  so a watch-path regression can't pass via the silent assign-topic fallback.

- handlers() reads the field into a local to avoid a concurrent-close NPE.
@merlimat

merlimat commented Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the very thorough review — went through every finding against the code; all were legitimate. Addressed in 6acca7f.

On the design question (can parallelism change after bring-up?): No, and your instinct about aborted transactions is exactly the additional constraint. TxnID.mostSigBits encodes the coordinator, so changing parallelism strands existing transaction ids. And because an aborted transaction's records are retained as long as its participating messages are, the value can only ever be reduced once every transaction created under the previous value has been fully cleaned up. I've documented this on the config and now enforce cross-broker agreement (see #5 below).

Findings:

  1. [BUG] follower-snapshot race — confirmed. buildAssignmentsSnapshot used the cache-only getLeaderValueIfPresent(); switched to the async getLeaderValue() (loads from the store on a cache miss), so a freshly-Following broker no longer omits the partition. Added a short re-push from ServerCnx when a snapshot is incomplete or fails to build, so the client converges without relying on a later leadership change. Note: the deepest 3+-broker scenario isn't reproducible by the 2-broker integration test, so I'm relying on the mechanism fix + re-push rather than an e2e proof — flagging that honestly.

  2. [BUG] watch dies silently — confirmed both paths. attach() on reconnect without the flag, and post-initial onError() (you're right that ClientCnx already removed the session so no connectionClosed() fires) now both scheduleReconnect().

  3. [BUG] one-shot initial open — confirmed. The initial open now retries retryable failures up to the lookup deadline instead of failing startAsync().

  4. [QUALITY] new URI(null) NPE — confirmed it's an NPE not URISyntaxException. HandlerState.setRedirectedClusterURI now throws a clear URISyntaxException for a blank selected URL, and onSnapshot isolates per-partition failures.

  5. [QUALITY] parallelism validation/consistency — added minValue=1; the value is persisted at /txn/tc/parallelism on first start and every broker fails fast on mismatch. New unit test.

  6. [QUALITY] test can't prove the new path — fixed; the integration test now asserts isUsingMetadataDiscovery() so a watch-path regression can't pass via the silent assign-topic fallback.

  7. [minor] handlers() now reads the field into a local; the javascript client #1 re-push also bounds the startup snapshot burst.

All affected unit tests + checkstyle pass locally; the integration test compiles and is wired into the TRANSACTION suite.

merlimat added 3 commits June 4, 2026 13:38
…oker-paired connection

The integration test's new assertion (isUsingMetadataDiscovery) caught that a client
connecting through the proxy fell back to assign-topic discovery:

- The proxy's handleBrokerConnected forwarded only supportsTopicWatchers and
  supportsScalableTopics from the broker's CONNECTED, dropping the new
  supportsTcMetadataDiscovery flag. Forward it too.

- selectDiscovery() probed getConnectionToServiceUrl(), which through a proxy returns
  the proxy's lookup-handshake CONNECTED (no broker flags) rather than a broker's. Probe
  getAnyBrokerProxyConnection() instead — it pairs to an actual broker (the same
  connection the watch uses), so the forwarded feature flags reflect the broker. On
  failure it still falls back to assign-topic, so multi-host / transient cases are safe.
…proxied

The watch-mode TransactionMetaStoreHandler connected directly to each leader's
advertised broker URL, which is unreachable when the client talks through a proxy
(the leader address isn't a proxiable target on its own) — causing newTransaction
to time out with "connection: null".

Mirror the scalable-topics client's proven pattern: the watch connection reports
whether it's proxied (ClientCnx.isProxied()); when it is, dial the leader through
the proxy (logical=leader, physical=proxy) via the new
ConnectionHandler.grabCnx(uri, useProxy), otherwise connect directly. The handler
now holds (leaderUri, useProxy), reconnects to the leader on connection loss
(passing the leader URI rather than falling back to the service URL), and
WatchTcAssignmentsDiscovery derives useProxy from the watch connection and selects
the TLS/plaintext leader URL.
…ver IT robust

Verified locally against a built docker image (2-broker + proxy cluster), two
consecutive green runs of TcMetadataDiscoveryTest (lifecycle + failover).

- ConnectionHandler.reconnectLater() called the no-arg grabCnx() on a failed
  connection attempt, falling back to the service URL instead of re-dialling the
  intended broker. For v5 TC metadata-store discovery the per-coordinator handler
  must keep targeting its elected leader, so remember the explicit target
  (explicitHostURI, set by grabCnx(uri,useProxy) and connectionClosed(...hostUrl))
  and reuse it on the error-retry path.

- The integration test's runTxnOnEveryCoordinator spun a tight no-wait retry loop
  and threw the moment a coordinator's handler was still connecting (handlers
  connect asynchronously; after a failover one is briefly mid-reconnect). Replace
  it with a bounded Awaitility wait that ignores transient
  MetaStoreHandlerNotReady/timeout — the assertion is "every coordinator becomes
  reachable", not "on the first attempt". The failover case now relies on this
  shared wait instead of a second nested await.
@merlimat
merlimat merged commit 17ead1d into apache:master Jun 5, 2026
43 checks passed
@merlimat
merlimat deleted the mmerli/pip-473-tc-election branch June 5, 2026 00:41
Comment on lines +208 to +213
// Re-dial the explicit leader target (v5 TC discovery) if set; otherwise the normal
// lookup path. Without this, a first-attempt failure during failover would fall back
// to the service URL and never reach the partition's new leader.
URI target = explicitHostURI;
if (target != null) {
grabCnx(Optional.of(target));

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.

just wondering what happens when the target broker goes down? does the retry loop exit?

@lhotari

lhotari commented Jun 11, 2026

Copy link
Copy Markdown
Member

@merlimat This PR caused a regression, filed as #25997. Could you please take a look? I think that it's a release blocker for 5.0.0-M1.

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