Skip to content

[Fix][broker]Get a fenced error when loading a topic that does not allowed the cluster to access - #26276

Open
poorbarcode wants to merge 5 commits into
apache:masterfrom
poorbarcode:fix/fenced_if_topic_not_allowed
Open

[Fix][broker]Get a fenced error when loading a topic that does not allowed the cluster to access#26276
poorbarcode wants to merge 5 commits into
apache:masterfrom
poorbarcode:fix/fenced_if_topic_not_allowed

Conversation

@poorbarcode

Copy link
Copy Markdown
Contributor

Motivation

The conditions under which the issue occurs:

  • two clusters [cluster-a, cluster-b] share configuration metadata store.
  • enabled namespace-level replication public/default
  • disabled topic-level replication public/default/tp-1: [cluster-b]
    • cluster-a can not access the topic anymore.
  • on cluster-a: you will get a topic fenced error when calling BrokerService.getTopic(topic, createIfMissing).

How it occurs:

  • create topic obj
  • initialize
  • register listener to TopicPoliciesService
  • load latest topic-level policies if exists
    • initialise policies: checkReplication
    • initialise other policies.
  • init finished
  • check replication, which was called by BrokerService
  • check deduplication, which was called by BrokerService

step-4 and step-6, Broker checked replication twice; the first one will delete the topic, and the second one will get a fenced error.

2026-08-03T03:23:46,878+0000 [bookkeeper-ml-scheduler-OrderedScheduler-0-0] ERROR io.streamnative.pulsar.handlers.kop.TopicLoadingService - [[id: 0xe3b25093, L:/***- R:/127.0.0.6:***]][RequestHeader(***)] Failed to getTopic persistent://public/default/tp-1
java.util.concurrent.CompletionException: org.apache.pulsar.broker.service.BrokerServiceException$TopicFencedException: Topic is already fenced
	at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(Unknown Source)
	at java.base/java.util.concurrent.CompletableFuture.uniComposeStage(Unknown Source)
	at java.base/java.util.concurrent.CompletableFuture.thenCompose(Unknown Source)
	at org.apache.pulsar.broker.service.persistent.PersistentTopic.lambda$removeTopicIfLocalClusterNotAllowed$71(PersistentTopic.java:2086)
	at java.base/java.util.concurrent.CompletableFuture.uniComposeStage(Unknown Source)
	at java.base/java.util.concurrent.CompletableFuture.thenCompose(Unknown Source)
	at org.apache.pulsar.broker.service.persistent.PersistentTopic.removeTopicIfLocalClusterNotAllowed(PersistentTopic.java:2082)
	at org.apache.pulsar.broker.service.persistent.PersistentTopic.checkReplication(PersistentTopic.java:1948)
	at io.streamnative.pulsar.handlers.kop.topic.KopPersistentTopic.checkReplication(KopPersistentTopic.java:185)
	at org.apache.pulsar.broker.service.BrokerService$2.lambda$openLedgerComplete$1(BrokerService.java:1887)
	at java.base/java.util.concurrent.CompletableFuture$UniCompose.tryFire(Unknown Source)
	at java.base/java.util.concurrent.CompletableFuture.postComplete(Unknown Source)
	at java.base/java.util.concurrent.CompletableFuture.complete(Unknown Source)
	at org.apache.pulsar.metadata.cache.impl.MetadataCacheImpl.lambda$readValueFromStore$1(MetadataCacheImpl.java:171)
	at java.base/java.util.concurrent.CompletableFuture.uniWhenComplete(Unknown Source)
	at java.base/java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(Unknown Source)
	at java.base/java.util.concurrent.CompletableFuture.postComplete(Unknown Source)
	at java.base/java.util.concurrent.CompletableFuture.postFire(Unknown Source)
	at java.base/java.util.concurrent.CompletableFuture$UniCompose.tryFire(Unknown Source)

Modifications

Let the method checkReplication just be called once.

Todo list, which will be improved with separate PRs

  • make it throw a NotAllowed error in the above case; it might break something.
    • It should also improve performance, to avoid loading the managed ledger up.
  • improve readability for the class TopicPolicyListenerWrapper

Does this pull request potentially affect one of the following parts:

If the box was checked, please highlight the changes

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

@lhotari

lhotari commented Aug 5, 2026

Copy link
Copy Markdown
Member

@poorbarcode 2 tests fail

  Gradle suite > Gradle test > org.apache.pulsar.broker.service.ReplicatorTest > testDoNotReplicateSystemTopic FAILED
      java.lang.AssertionError: expected [0] but found [2]
          at org.testng.Assert.fail(Assert.java:111)
          at org.testng.Assert.failNotEquals(Assert.java:1590)
          at org.testng.Assert.assertEqualsImpl(Assert.java:150)
          at org.testng.Assert.assertEquals(Assert.java:132)
          at org.testng.Assert.assertEquals(Assert.java:1431)
          at org.testng.Assert.assertEquals(Assert.java:1395)
          at org.testng.Assert.assertEquals(Assert.java:1441)
          at org.apache.pulsar.broker.service.ReplicatorTest.testDoNotReplicateSystemTopic(ReplicatorTest.java:1642)
  
  
  111 tests completed, 2 failed, 66 skipped
  Gradle suite > Gradle test > org.apache.pulsar.broker.service.OneWayReplicatorUsingGlobalPartitionedTest > testRemoveCluster[2](namespace) FAILED
      java.lang.AssertionError: expected [false] but found [true]
          at org.testng.Assert.fail(Assert.java:111)
          at org.testng.Assert.failNotEquals(Assert.java:1590)
          at org.testng.Assert.assertFalse(Assert.java:79)
          at org.testng.Assert.assertFalse(Assert.java:89)
          at org.apache.pulsar.broker.service.OneWayReplicatorUsingGlobalPartitionedTest.testRemoveCluster(OneWayReplicatorUsingGlobalPartitionedTest.java:350)

@gaoran10

gaoran10 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Could we also strengthen the method removeTopicIfLocalClusterNotAllowed? Such as adding a topic state check before force deleting the topic.

@poorbarcode

Copy link
Copy Markdown
Contributor Author

@gaoran10

Could we also strengthen the method removeTopicIfLocalClusterNotAllowed? Such as adding a topic state check before force deleting the topic.

Could you detail the solution you mentioned? And what result will the caller of BrokerService.getTopic(topic, createIfMissing) get if there is a deletion in progress?

@poorbarcode
poorbarcode requested review from gaoran10 and removed request for gaoran10 August 6, 2026 04:24

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

Thanks for tracking this down — the diagnosis is right, and I confirmed the double-invocation exists on master, branch-4.0 and branch-4.2, so the fix is genuinely applicable to the backport targets. I also confirmed that 74381c2f75c does fix the system-topic replication regression: I traced the load path end to end and could not find a way for a SystemTopic to reach startReplicator() any more, so ReplicatorTest.testDoNotReplicateSystemTopic should pass again.

That said, I think the current mechanism has a structural problem worth resolving before this merges, and since it is labelled for release/4.0.14 and release/4.2.5 the bar is higher than for a master-only change.

1. The load path no longer dispatches through checkReplication()

BrokerService.java:2058 now calls initCheckReplication(), which goes tryInitializeReplicationCheck()internalCheckReplication() directly. The virtual checkReplication() — the Topic interface method declared at Topic.java:198 — is never dispatched on the load path, and initCheckReplication() is not on the interface at all.

Topics are constructed through the public extension point at BrokerService.java:2052 (newTopic(..., PersistentTopic.class)TopicFactory), so this silently disables subclass overrides. Three things convince me this is real rather than theoretical:

  • It already bit this PR. 74381c2f75c exists precisely because SystemTopic.checkReplication() stopped being honoured, and the fix was to hand-write a parallel initCheckReplication() override.
  • It is still biting in-tree. PersistentTopicInitializeDelayTest.MyPersistentTopic.checkReplication() (line 127, installed via conf.setTopicFactoryClassName(...) at line 49) exists specifically to intercept the load-path replication check. It is no longer reached, so testTopicInitializeDelay is now vacuous and would not catch a re-regression of the bug it guards.
  • Third parties cannot be hand-patched. The stack trace in the PR description shows KopPersistentTopic.checkReplication(KopPersistentTopic.java:185) in exactly that frame.

One defence would be that the topic-policy path still calls the virtual checkReplication() — but TopicPolicyListenerWrapper.emitInitialPolicies (lines 145-152) only fires onUpdate when a policy value is non-null. For topics with no topic-level policies (the common case) there is no virtual checkReplication() call anywhere in the load path.

2. The Boolean from initCheckReplication() is discarded, so getTopic() returns a deleted topic

initCheckReplication() resolves to this.isClosingOrDeleting (PersistentTopic.java:2030-2035), but the only call site binds it to v and never reads it (BrokerService.java:2059). A repo-wide search finds no consumer.

So when removeTopicIfLocalClusterNotAllowed()deleteForcefully() deletes the topic, the check now completes normally, the chain continues through checkDeduplicationStatus() and reaches topicFuture.complete(Optional.of(persistentTopic)) at BrokerService.java:2069 — with isFenced and isClosingOrDeleting both true (unfenceTopicToResume() only runs on delete failure).

The caller gets a topic that rejects every operation: AbstractTopic.java:1123 for producers, PersistentTopic.java:1009-1011 for subscribe, :4783 for publish. Before this PR the load failed loudly with TopicFencedException; now it is a silent broken success. Your own TODO says the right answer is a NotAllowed error, and I agree — I would rather see that land here than ship the intermediate state, because the new assertion at OneWayReplicatorUsingGlobalPartitionedTest.java:352 currently locks in the broken behaviour and would have to be changed again by the follow-up.

Worth noting the default config makes this the normal path: with brokerDeduplicationEnabled=false, checkStatus() is a silent no-op on the deleted ledger. With dedup enabled it instead fails with a ManagedLedgerFencedException, which is arguably more confusing than the original error.

3. Follow-on: an unreclaimable entry in multiLayerTopicsMap

Because the chain now succeeds, it reaches addTopicToStatsMaps (BrokerService.java:2097) after delete() already ran removeTopicFromCache (PersistentTopic.java:1682). That re-inserts the deleted topic into multiLayerTopicsMap but not into topics.

The entry then cannot be cleaned up: every later removeTopicFromCache returns at the identity guard if (topics.get(topic) != createTopicFuture) (BrokerService.java:2767), which sits before the multiLayerTopicsMap cleanup at :2779, and bundle unload iterates topics. Since multiLayerTopicsMap feeds pulsarStats.updateStats(...) (:2466) and the topic metrics exporter, a dead PersistentTopic stays visible in broker-stats and Prometheus until restart.

4. The one-shot guard skips transaction system topics

SystemTopic.java:79-81 calls super.removeTopicIfLocalClusterNotAllowed() without going through tryInitializeReplicationCheck(), so no CAS is taken and initializedReplicationCheck is never completed. SystemTopic.checkReplication() (line 92) calls the same method, and the two paths do not deduplicate against each other — so for __transaction_buffer_snapshot / __transaction_pending_ack the original TopicFencedException looks reachable via the periodic sweep, onPoliciesUpdate, or unfenceReplicatorsToResume.

Flagging this one as medium confidence: the missing guard is unambiguous, but I have not verified how often topic policies actually fire for these topics, so I am unsure how often it triggers in practice.

5. The new CompletableFuture<Boolean> contract is inconsistent

Three branches of SystemTopic.initCheckReplication() return three different things:

Branch Returns Base contract (PersistentTopic.java:2030-2035)
policies topic (:76) real isClosingOrDeleting
txn topic (:80) hard-coded true should be isClosingOrDeleting — reports "removed" even when the cluster is allowed and removeTopicIfLocalClusterNotAllowed() returned false
otherwise (:82) completedFuture(null) — a null Boolean non-null

This is harmless only while nobody reads the value. But the moment the follow-up wires it up (e.g. .thenCompose(deleted -> deleted ? ... : ...)), healthy transaction system topics would skip the rest of their load and transaction_coordinator_assign / heartbeat topics would NPE on unboxing. Since this shape would ship in 4.0.14/4.2.5, the follow-up could not fix it without another behaviour change on the maintenance branches.

6. A synchronous throw in internalCheckReplication() strands the topic

tryInitializeReplicationCheck() (PersistentTopic.java:2037-2039) commits the CAS before calling internalCheckReplication(), with no try/catch. That method does synchronous work before its first async hop — topicPolicies.getReplicationClusters().get(), getConfiguration().getClusterName(), and checkAllowedCluster() dereferencing getPulsarResources().getNamespaceResources().

If any of those throws, nothing can ever complete initializedReplicationCheck — the only completion site is the whenComplete behind the already-consumed CAS. A later initCheckReplication() then returns a future that never completes, so getTopic() hangs until topicLoadTimeoutSeconds instead of failing fast with the actual error. Also, initCheckReplication() calls tryInitializeReplicationCheck() as its first statement, so a synchronous throw escapes a method returning CompletableFuture, which CODING.md disallows.

7. API surface and duplication

tryInitializeReplicationCheck() and internalCheckReplication() are public with no callers outside PersistentTopic — both can be private (initCheckReplication() does need to stay public, different package). The @Getter on initializedReplicationCheck (:343) has no callers and hands out the raw mutable future; checkReplication() at :2056 returns that same instance, so a stray cancel() would permanently poison the topic. None of the four new members has javadoc, though tryInitializeReplicationCheck()'s false ("someone else owns the check", not "the check failed") is exactly the subtlety checkReplication() depends on — compare checkReplicationAndRetryOnFailure() at :1985, which is package-private and @VisibleForTesting.

SystemTopic also now duplicates the same dispatch twice (lines 74-83 and 86-95), already diverging in return type and in what the txn branch yields, so any future change has to be applied to both across three release branches. NonPersistentTopic is left on checkReplication() (BrokerService.java:1610), so the asymmetry is only for PersistentTopic.

8. The namespace assertion documents unrelated behaviour, and looks flaky

"Namespace missing local cluster name" comes from PulsarWebResource.java:878, reached from the lookup layer via the __change_events reader bootstrap at BrokerService.java:1345 — before loadOrCreatePersistentTopic, so the line this PR changed never executes in that parameterization. The double-invocation cannot occur there either (no __change_events → no topic-level policies → no onUpdate), so the assertion captures pre-existing master behaviour rather than the fix.

It also looks timing-dependent: whether the exception occurs depends on policyCacheInitMap no longer holding the namespace's completed init future (SystemTopicBasedTopicPoliciesService.java:697-700), and cleanPoliciesCacheInitMap only runs on bundle unload, which internalSetNamespaceReplicationClusters does not trigger. I would expect intermittent failures.

Net effect: the test now blesses two different outcomes for the same semantics ("the local cluster may not serve this topic") — one returns a dead topic, one throws a lookup error — and neither is the NotAllowed error the TODO names.

9. Test details

Awaitility.await().atMost(1, TimeUnit.HOURS) at line 348 sits inside @Test(timeOut = 60_000), so the method timeout always wins and the Awaitility diagnostic is lost; the neighbouring await uses Duration.ofSeconds(60). The "topic" branch at line 352 is the one covering the actual motivating scenario, but it asserts only assertFalse(isCompletedExceptionally()) and nothing about the returned Optional<Topic>. catch (Exception e) at line 358 is fine with respect to fail() (TestNG throws AssertionError), but it accepts any exception type and only substring-matches, so a TimeoutException would report expected [true] but found [false] with the cause lost; e.getMessage() is also unguarded for null. CODING.md prefers AssertJ here — assertThatThrownBy(...).hasRootCauseInstanceOf(...).hasMessageContaining(...). Minor: two stray blank lines at 362-363.

Suggested direction

I think most of the above collapses if the latch lives inside the overridable method instead of beside it — i.e. keep BrokerService calling checkReplication() and do the deduplication there. That preserves the extension contract, needs no new public API, and lets SystemTopic.initCheckReplication() and the Boolean disappear entirely.

Even better, if you are open to it: the real non-idempotent operation is delete(). close() already has the right idiom — at PersistentTopic.java:1790-1798 it returns the in-progress operation when isClosingOrDeleting, whereas delete() at :1573-1576 fails with TopicFencedException despite populating the same closeFutures at :1622 and completing them at :1739. Making delete() return the in-progress deletion would fix the load chain, the periodic sweep, the policy listeners and the unfence path in one place, and would make the change safe to backport as-is.

I verified the premise holds on both backport targets (branch-4.0 PersistentTopic.java:500/:3696, BrokerService.java:1893; branch-4.2 :502/:3681, :1925), so this is worth getting right rather than narrowing in scope — findings 1-3 are observable behaviour changes and the new public members would be frozen into the maintenance API.

Comment thread pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java Outdated
.thenCompose(__ -> context.trace("replication",
persistentTopic.checkReplication()))
persistentTopic.initCheckReplication()))
.thenCompose(v -> context.trace("deduplication",

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.

v here is the Boolean that initCheckReplication() went to the trouble of computing (this.isClosingOrDeleting), and it's discarded — there is no consumer of it anywhere in the repo.

The consequence is that when the replication check force-deletes the topic, the chain keeps going and completes topicFuture with Optional.of(persistentTopic) at line 2069 for a topic that is deleted and permanently fenced. Callers then fail on every produce/subscribe instead of getting the clear error they used to get.

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 by removing the Boolean return value entirely. The initial check now has the same Void completion contract as checkReplication(). Returning a clear NotAllowedException after removal is explicitly documented in the PR Motivation as a separate follow-up because it may be behavior-breaking.

}

public boolean tryInitializeReplicationCheck() {
if (replicationInitialized.compareAndSet(false, true)) {

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.

The CAS is committed before internalCheckReplication() is invoked, and there's no try/catch around it. That method does real synchronous work before its first async hop — topicPolicies.getReplicationClusters().get(), getConfiguration().getClusterName(), and checkAllowedCluster() dereferencing getPulsarResources().getNamespaceResources().

If any of those throws synchronously, nothing can ever complete initializedReplicationCheck — this whenComplete is the only completion site and the CAS is already consumed. A later initCheckReplication() then returns a future that never completes, and getTopic() hangs until topicLoadTimeoutSeconds instead of failing fast with the real error.

Related: initCheckReplication() calls this as its first statement, so such a throw escapes a method declared to return CompletableFuture, which CODING.md disallows.

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 for the initialization path. initializeCheckReplication() catches a synchronous exception from the overridable checkReplication() call and completes initialReplicationCheck exceptionally, so the consumed CAS cannot leave topic loading waiting on an incomplete future.

}
// Since the txn system topic is not allowed to access anymore, we should delete data.
if (SystemTopicNames.isTransactionBufferOrPendingAckSystemTopicName(TopicName.get(topic))) {
return super.removeTopicIfLocalClusterNotAllowed().thenApply(__ -> true);

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.

Two things here:

  1. removeTopicIfLocalClusterNotAllowed() already returns a meaningful Boolean (true if the topic was deleted, false when the local cluster is allowed), and it's discarded in favour of a hard-coded true. So a healthy txn system topic reports "closing or deleting". That's inert today only because the caller ignores the value — but wiring it up is exactly what the TODO proposes.

  2. This branch bypasses tryInitializeReplicationCheck() entirely, so no CAS is taken and initializedReplicationCheck is never completed. SystemTopic.checkReplication() below calls the same method, and the two don't deduplicate against each other — so for __transaction_buffer_snapshot / __transaction_pending_ack the original TopicFencedException still looks reachable via the periodic sweep or a policy update. Medium confidence on how often that actually fires, since I haven't checked how often topic policies apply to these topics.

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 by removing SystemTopic.initializeCheckReplication(). The load-time method now dispatches through SystemTopic.checkReplication(), retaining its existing transaction-system-topic behavior and completing the shared initial future from that result.

assertTrue(future.isDone());
});
if ("topic".equals(removeClusterLevel)) {
assertFalse(future.isCompletedExceptionally());

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.

This branch is the one that covers the PR's actual motivating scenario (shared config store via usingGlobalZK, topic-level clusters excluding local at lines 299-300), but it asserts only that the future didn't fail — nothing about the Optional<Topic> it holds.

As it stands that topic is deleted and fenced, so this assertion currently locks in the behaviour from my summary comment rather than the intended one. Asserting the topic is present and usable (or, once the NotAllowed change lands, that the load fails cleanly) would make this test defend the right outcome.

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 topic-level branch intentionally verifies the scoped behavior of this PR: loading no longer fails with the second TopicFencedException. The PR Motivation documents making this path fail clearly with NotAllowedException as a separate follow-up because it may be behavior-breaking; that follow-up will strengthen the expected load result accordingly.

try {
future.get();
fail("Should have thrown an exception since the __change_event topic can not be access anymore");
} catch (Exception e) {

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.

fail() is safe here since TestNG throws AssertionError, so this catch won't swallow it. But it accepts any Exception and only substring-matches, so a FAILED_TO_LOAD_TOPIC_TIMEOUT_EXCEPTION or a policy-cache error would report expected [true] but found [false] with the real cause nowhere in the output. e.getMessage() is also unguarded for null.

CODING.md prefers AssertJ for this: assertThatThrownBy(future::get).hasRootCauseInstanceOf(...).hasMessageContaining(...) asserts the type and prints the actual message on failure.

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 current namespace-level exception assertion intentionally remains message-based. The PR Motivation documents standardizing this outcome as NotAllowedException in a separate follow-up; after that behavior is introduced, this test can assert its precise root-cause type.

@poorbarcode

Copy link
Copy Markdown
Contributor Author

@lhotari I have pushed the updates in d3c3e42 and replied to each of your review threads. Could you please take another look when you have time? Thank you for the detailed review.

@lhotari

lhotari commented Aug 7, 2026

Copy link
Copy Markdown
Member

Thanks for the rework — this is a much better shape. Routing the load path back through the virtual checkReplication() and letting initializeCheckReplication() own only the once-only latch addresses the structural concern directly, and dropping the SystemTopic override in favour of that is the right simplification. Specifically, I agree these are now genuinely resolved:

  • the extension contract — TopicFactory subclass overrides and SystemTopic.checkReplication() are dispatched again in every ordering;
  • the Boolean contract — clean Void, no discarded result, no hard-coded true, no null;
  • the mutable-future exposure — field is private final and both entry points return a derived stage;
  • internalCheckReplication() is private, and keeping initializeCheckReplication() public is justified and now documented;
  • the one-hour Awaitility timeout.

A few things still need attention before this can go in, and one of them is blocking CI right now.

1. CI is red — one over-length line

Build and License check failed at d3c3e42ae9b on :pulsar-broker:checkstyleMain (Checkstyle violations by severity: [error:1]), and because that job failed every unit-test job was skipped. So nothing at this head has been validated by CI, including both modified tests.

The violation is PersistentTopic.java:2034 — 123 characters against the 120 limit (buildtools/src/main/resources/pulsar/checkstyle.xml:37-40, severity error):

     * {@link #checkReplication()} to preserve overrides supplied by {@link org.apache.pulsar.broker.service.TopicFactory}.

Wrapping it, or importing TopicFactory and using {@link TopicFactory}, fixes it.

2. The new invocation-count assertion cannot hold

PersistentTopicInitializeDelayTest.java:79 asserts checkReplicationInvocationCount.get() == 1, but the counter is a static AtomicInteger that is never reset, and the topic is loaded more than once before the assertion runs:

  1. admin.topics().createNonPartitionedTopic(topicName) loads the topic — PersistentTopicsBase.internalCreateNonPartitionedTopicAsync calls getTopic(topicName, true, properties) (line 367), which builds a MyPersistentTopic via the factory. No topic policies exist yet, so the load chain's initializeCheckReplication() wins the CAS and dispatches the override → count = 1.
  2. admin.topicPolicies().setMaxConsumers(topicName, 10) delivers a live policy update to the already-loaded topic → onUpdateapplyUpdatedTopicPoliciescheckReplicationAndRetryOnFailurecheckReplication()count = 2.
  3. After unload, the final getTopic() builds a fresh instance whose initTopicPolicy now loads the maxConsumers policy and emits it → onUpdatecheckReplication()count = 3. (The dedup then works correctly — the load chain does not re-invoke — but the counter has already accumulated the two earlier lifecycle invocations.)

So the assertion should fail with 3 (and MyPersistentTopic's constructor can emit an extra onUpdate of its own, so ≥ 3). Resetting the counter immediately before the final getTopic() — and asserting 1 from that point — would test what you actually want, which is that a single load produces a single virtual invocation.

I'd like to see this test green before merge rather than after, since it is the only coverage for the extension-contract fix.

3. The once-only invariant still doesn't hold in two cases

(a) When initializeCheckReplication() wins the CAS. It sets the flag, then calls the virtual checkReplication(), which sees the flag already set and falls through to internalCheckReplication(). That's correct for that call — but any other caller arriving during the load also sees the flag set and starts its own independent internalCheckReplication() that is not connected to initialReplicationCheck. The load then waits only on the first one.

(b) When a topic has both a local and a global topic policy. TopicPolicyListenerWrapper.completeInitialization() calls emitInitialPolicies twice — once for the local scope, once for the global scope (the local-before-global ordering is deliberate and documented there). Each emit that has a value fires onUpdateapplyUpdatedTopicPoliciescheckReplicationAndRetryOnFailurecheckReplication(). The first wins the CAS; the second falls through to a second, independent internalCheckReplication().

That second case matters because it is the shape of the motivating scenario: replication clusters are set globally (topicPolicies(true)) while a local policy also exists — which is exactly what testRemoveCluster sets up. Both checks then race into removeTopicIfLocalClusterNotAllowed()deleteForcefully(); one wins and the other hits delete()'s isClosingOrDeleting guard. If the loser happens to be the CAS winner whose result feeds initialReplicationCheck, the load fails with TopicFencedException — the original symptom.

If the latch instead sat inside removeTopicIfLocalClusterNotAllowed()/delete() (returning the in-progress deletion, the way close() already does at PersistentTopic.java:1790-1798), both of these would be closed by construction regardless of how many callers arrive.

4. Transaction system topics bypass the latch entirely

SystemTopic.checkReplication() returns super.removeTopicIfLocalClusterNotAllowed().thenAccept(...) for __transaction_buffer_snapshot / __transaction_pending_ack (lines 79-81) and completedFuture(null) for other system topics — neither reaches super.checkReplication(), so neither touches the CAS. The dedup contract silently depends on every override reaching the latch.

Concretely: if the policy path invokes the override first, the removal runs and isClosingOrDeleting is set, but the CAS is still unconsumed — so initializeCheckReplication() then wins it, dispatches the override again, and the second deleteForcefully() hits the fenced guard, failing the load with the exact error this PR fixes.

Reachability is narrower than the general case — it needs a topic-level policy on a transaction system topic — but nothing prevents that: registerListener has no system-topic filter, initTopicPolicy only skips ExtensibleLoadManagerImpl internal topics, and topic policies on __transaction_buffer_snapshot do occur in the codebase already. Worth either closing the hole or documenting the assumption that overrides must call super.checkReplication().

5. The try/catch is only on one of the two CAS sites

initializeCheckReplication() guards its dispatch (lines 2038-2049) precisely because the callee "can fail before returning its future" — but the mirror-image CAS branch in checkReplication() calls internalCheckReplication() bare. That's the branch that actually runs first in the common with-policies ordering, since the policy path executes inline inside initialize().

A synchronous throw there consumes the CAS without ever completing initialReplicationCheck; the exception is then only logged by onUpdate's exceptionally, and the load chain's initializeCheckReplication() returns a future that never completes — so getTopic() hangs until topicLoadTimeoutSeconds and fails with a generic timeout, root cause swallowed. The trigger is a rare state (a closing metadata store or executor), but the fix is trivial: give that branch the same guard, or funnel both sites through one guarded helper.

On the two deferrals

NotAllowedException / the error contract — I'm fine deferring this. It is behaviour-changing, and returning the topic is pre-existing behaviour rather than something this PR introduces. Could you link a tracked issue for it in the description, though? "Todo list" bullets tend to evaporate, and this one is load-bearing for the test assertions that are being left weak in the meantime.

The multiLayerTopicsMap leak — this one I don't think should be deferred. It is a different kind of problem from the error contract: after the topic is force-deleted and removeTopicFromCache has cleared it from both maps, the load chain reaches addTopicToStatsMaps (BrokerService.java:2097) and re-inserts the dead topic into multiLayerTopicsMap only. Nothing can then remove it — the identity guard at BrokerService.java:2767 (topics.get(topic) != createTopicFuture) returns before the map cleanup, and bundle unload iterates topics, which no longer holds the entry. The map feeds pulsarStats.updateStats(...) and the Prometheus exporter, so the broker keeps reporting a deleted topic and retains the PersistentTopic and its ManagedLedger until restart, once per removed topic.

That is an unbounded broker-side resource and metrics leak, and this PR is labelled release/4.0.14 and release/4.2.5. Deferring an error-message shape to a follow-up is reasonable; deferring a leak into two patch releases is a harder sell. If you'd rather not widen this PR, could it at least get its own tracked issue and a note in the description?

Minor

  • Two consecutive blank lines remain at OneWayReplicatorUsingGlobalPartitionedTest.java:362-363.
  • The Awaitility.await().untilAsserted(...) now uses the 10s default while the future's own deadline is topicLoadTimeoutSeconds (60s). For a two-cluster load that also performs a forced delete, 10s may be tight on a loaded CI runner. Awaiting the future directly with an explicit bound — future.get(30, TimeUnit.SECONDS) — would be simpler, deterministic, and would surface the cause on failure.
  • initializeCheckReplication() is public and non-final on a class third parties subclass; making it final would keep the latch protocol from becoming an accidental extension point on the maintenance branches.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants