Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public abstract class AbstractTopic implements Topic, TopicPolicyListener {

// Wraps this topic as a TopicPolicyListener so topic-policy updates received while the initial policy is still
// loading are buffered and applied in order once initTopicPolicy() completes initialization.
protected final TopicPolicyListenerWrapper topicPolicyListener = new TopicPolicyListenerWrapper(this);
protected final TopicPolicyListenerWrapper topicPolicyListener;

// Prefix for replication cursors
protected final String replicatorPrefix;
Expand Down Expand Up @@ -211,6 +211,7 @@ public abstract class AbstractTopic implements Topic, TopicPolicyListener {

public AbstractTopic(String topic, BrokerService brokerService) {
this.topic = topic;
this.topicPolicyListener = new TopicPolicyListenerWrapper(this, topic);
this.log = LOG.with().attr("topic", topic).build();
this.namespace = TopicName.get(topic).getNamespaceObject();
// Pin the per-topic policies-notify thread once. BrokerService#getTopicPoliciesNotifyThread centralizes
Expand Down Expand Up @@ -622,19 +623,14 @@ protected void unregisterTopicPolicyListener() {
* topic load, which removes the need to broadcast every topic's policy when a namespace's policy cache finishes
* loading (see {@code topicPolicyListenerReplayEnabled}).
*
* <p>Each call re-initializes the listener wrapper and, whatever the outcome, always completes its initialization
* afterwards, so the wrapper never stays in the buffering phase (dropping updates) even if policy loading fails.
* This makes the method safe to run again (e.g. a future retry); runs are expected to be serialized.
* <p>This method is invoked once during topic initialization. The listener wrapper is one-shot for a topic
* instance and does not support re-initialization.
*/
protected CompletableFuture<Void> initTopicPolicy() {
final var topicPoliciesService = brokerService.getPulsar().getTopicPoliciesService();
final var partitionedTopicName = TopicName.getPartitionedTopicName(topic);

// Begin a fresh initialization phase: updates are buffered until initialization completes below. This resets
// any previous phase so the method can be run again.
topicPolicyListener.startInitialization();
CompletableFuture<Void> initTopicPolicyFuture =
topicPoliciesService.registerListenerAsync(partitionedTopicName, topicPolicyListener)
return topicPoliciesService.registerListenerAsync(partitionedTopicName, topicPolicyListener)
.thenCompose(registered -> {
if (!registered) {
return CompletableFuture.completedFuture(null);
Expand All @@ -659,14 +655,6 @@ protected CompletableFuture<Void> initTopicPolicy() {
getPoliciesNotifyThread());
}).thenCompose(Function.identity());
});

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.

I think this is a blocker. Removing the terminal handler here is safe only if the wrapper never buffers — but buffering came back in this revision, so the two changes are now in conflict.

The description says only "failed to register listener" and "system topic does not need to load topic-level policies" skip completeInitialization. There is a third path: either getTopicPoliciesAsync(...) completing exceptionally, so thenCombine never runs. The listener is already registered at that point, so onUpdate calls do arrive — and with buffering restored they are swallowed rather than forwarded.

That path is reachable from ordinary failures inside prepareInitPoliciesCacheAsync: namespace-policies read failure, reader creation failure, initPolicesCache read errors, and the topicPoliciesCacheInitTimeoutSeconds timeout added in #26025 for #25294. registerListenerAsync can also complete exceptionally rather than returning false (LegacyAwareTopicPoliciesService resolves the backing service through a metadata lookup first), which is a different case from registered == false.

initialized then stays false for the life of the topic instance. The only escape hatches are live updates on both scopes or a delete, and global topic policies require geo-replication — so on a topic with only local policies, every later policy update is dropped silently. initialize() swallows the load failure, so the topic loads healthy and just stops reacting to policy changes.

The early-trigger optimisation can stay; it just needs the phase to always end:

return initTopicPolicyFuture.whenCompleteAsync((v, ex) -> {
    topicPolicyListener.completeInitializationUnlessAlreadyCompleted();
}, getPoliciesNotifyThread());

Restoring it also keeps the completion of initTopicPolicy() on the per-topic policies-notify thread. Without it, an upstream failure completes this future on the shared broker-client-shared-internal-executor reader thread (or pulsarService.getExecutor() for the cache-init timeout), and BrokerService chains preCreateSubscriptionForCompactionIfNeeded -> checkReplication -> checkDeduplicationStatus -> topicFuture.complete(...) with plain non-async stages from there. That is the thread-affinity #26037 was about, so "The threading model" is worth checking on the PR checklist.

// Whatever the outcome -- success, failure, or the listener not being registered -- make sure the wrapper
// leaves the initialization (buffering) phase, so it forwards any buffered value plus all future live updates
// instead of dropping them. This is a no-op when the loaded policies were already applied above. Return the
// whenComplete stage (not initTopicPolicyFuture) so the returned future completes only after this has run, and
// whenComplete's pass-through semantics carry the original success or failure to the caller's initialize().
return initTopicPolicyFuture.whenCompleteAsync((v, ex) -> {
topicPolicyListener.completeInitializationUnlessAlreadyCompleted();
}, getPoliciesNotifyThread());
}

protected boolean isSameAddressProducersExceeded(Producer producer) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,59 +19,50 @@

package org.apache.pulsar.broker.service;

import java.util.Optional;
import io.github.merlimat.slog.Logger;
import java.util.concurrent.TimeUnit;
import lombok.CustomLog;
import org.apache.pulsar.common.policies.data.TopicPolicies;

import org.jspecify.annotations.Nullable;
/**
* This TopicPolicyListener is used as a wrapper for the real TopicPolicyListener.
* This prevents a race condition in initialization where the topic policy state can change while the topic policy
* state is being applied to the topic in AbstractTopic#initTopicPolicy(). The impact of the race condition is that the
* topic policy state would be left inconsistent until another update arrives. This is a rare corner case, but possible.
* Loaded policy: the policy obtained when creating the topic.
* Live policy: the policy notified from {@link TopicPoliciesService}.
*
* This class lets the live policy have higher priority than the loaded policy, in order to fix the following race
* condition:
* 1. init thread: Creates a topic.
* 2. init thread: Registers a listener to receive notification from {@link TopicPoliciesService}.
* 3. notification thread: Receives a live policy.
* 4. init thread: Applies loaded topic policies (the older policy).
* 5. Issue occurs: loaded topic policies overwrite live policies.
*
* <p>Updates received while initializing are buffered (only the latest per scope is kept) and applied by
* {@link #completeInitialization}; updates received afterwards are forwarded immediately. The wrapper is reusable so
* that AbstractTopic#initTopicPolicy() can be run again -- for example to retry it, which this enables but does not
* implement. {@link #startInitialization()} begins a new buffering phase and initialization completes at most once per
* phase. Concurrent initialization phases are not supported; {@code initTopicPolicy} runs are serialized by the caller.
* The class initialize both newest local policy and newest global policy at the same time to guarantee correctness.
*/
@CustomLog
public class TopicPolicyListenerWrapper implements TopicPolicyListener {

private static final Logger LOG = Logger.get(TopicPolicyListenerWrapper.class);
private static final long INITIALIZATION_WARNING_LOG_INTERVAL_MILLIS = TimeUnit.SECONDS.toMillis(30);
protected final Logger log;

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.

Nit: the class is not extended, so private final would be more accurate than protected final.


private final TopicPolicyListener realTopicListener;
// The latest value received during initialization, per scope. A null reference means no update was
// received during initialization (the loaded value should be used); an Optional that is present holds the
// received policies, and an empty Optional records that a delete (onUpdate(null)) was received, so the
// loaded value must not be applied. Optional is used because the map-like field cannot itself hold null
// while still distinguishing "not received" (null) from "received a delete" (Optional.empty()).
private Optional<TopicPolicies> latestGlobalPolicies;
private Optional<TopicPolicies> latestLocalPolicies;
private boolean initialized;
// received during initialization (the loaded value should be used);
private TopicPolicies latestGlobalPolicies;
private TopicPolicies latestLocalPolicies;
// Timestamp when the current initialization phase started, set by startInitialization(). Used only to warn if the
// phase takes too long (i.e. completeInitialization was never called after policy loading started).
private long initializationStartedNanos;
private static final long INITIALIZATION_WARNING_LOG_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(30);
private final long initializationStartedMillis;
Comment on lines 51 to +53

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 comment still refers to startInitialization(), which no longer exists.

Also worth noting the semantics changed: the timestamp is now stamped in the constructor, and AbstractTopic constructs the wrapper in its own constructor — so the timer starts when the topic object is created rather than when policy loading begins. The warning therefore measures topic construction plus policy loading. That is probably still a useful signal, but the field name and comment suggest something narrower.

private boolean initialized;
private int lastIntervalLogged;

public TopicPolicyListenerWrapper(TopicPolicyListener realTopicListener) {
public TopicPolicyListenerWrapper(TopicPolicyListener realTopicListener, String topic) {
this.realTopicListener = realTopicListener;
startInitialization();
this.log = LOG.with().attr("topic", topic).build();
this.initializationStartedMillis = System.currentTimeMillis();
}

/**
* Starts (or restarts) the initialization phase: {@link #onUpdate} buffers updates (keeping only the latest per
* scope) instead of forwarding them, until {@link #completeInitialization} applies them. Called at the start of
* every {@code initTopicPolicy} run so the method can be re-run cleanly (which is what would let a future change
* retry it; no retry is implemented here). Runs before the listener is registered, so no update can arrive before
* the phase (and its warning timer) has started.
* Handles live updates. Once a live update is applied, loaded policies will be skipped.
*/
public synchronized void startInitialization() {
initialized = false;
latestGlobalPolicies = null;
latestLocalPolicies = null;
initializationStartedNanos = System.nanoTime();
}

@Override
public synchronized void onUpdate(TopicPolicies data) {
if (initialized) {
Expand All @@ -81,86 +72,57 @@ public synchronized void onUpdate(TopicPolicies data) {

maybeLogWarning();

// Record the latest value received during initialization so it can be applied (preferring it over the
// loaded value) in completeInitialization. A received value is stored as Optional.of(data) and a delete
// as Optional.empty(), so the delete is propagated downstream instead of being lost.
// May receive a null value when the following two cases happen:
// 1. User calls `pulsar-admin topicPolicies delete`, broker will delete both local and global policies.
// 2. The topic was deleted.
if (data == null) {
// A delete (onUpdate(null)) does not carry the global/local scope through the listener interface,
// so record it for both scopes; a later scoped update received during initialization still
// overrides its own scope.
latestGlobalPolicies = Optional.empty();
latestLocalPolicies = Optional.empty();
// Now we got the both newest value of global and local policy, we can trigger initialize.
doInitPolicies(null, null);
Comment on lines 78 to +80

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 falls through to the latestGlobalPolicies != null && latestLocalPolicies != null check below. It happens to be safe only because doInitPolicies clears both fields as its last act — so the guard reads false and it does not initialise twice. An explicit return here would make that obvious and stop it depending on doInitPolicies's internals.

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.

Fixed

return;
} else if (data.isGlobalPolicies()) {
latestGlobalPolicies = Optional.of(data);
latestGlobalPolicies = data;
} else {
latestLocalPolicies = Optional.of(data);
latestLocalPolicies = data;
}
}

/**
* Complete initialization of the TopicPolicyListenerWrapper and emit the latest policies to the real listener.
*
* @param loadedGlobalPolicies the loaded global policies
* @param loadedLocalPolicies the loaded local policies
*/
public synchronized void completeInitialization(TopicPolicies loadedGlobalPolicies,
TopicPolicies loadedLocalPolicies) {
// Idempotent: an initialization phase completes at most once. initTopicPolicy runs a terminal
// completeInitializationUnlessAlreadyCompleted() after applying the loaded policies, so a later call must be a
// no-op and must not re-emit policies. A new phase is started explicitly via startInitialization().
if (initialized) {
return;
// Now we got the both newest value of global and local policy, we can trigger initialize.
if (latestGlobalPolicies != null && latestLocalPolicies != null) {
doInitPolicies(latestLocalPolicies, latestGlobalPolicies);
}
}

// The listener might have received a newer value (or a delete) than the loaded one while the loading
// was happening; prefer the latest value received during initialization, falling back to the loaded
// value only when nothing was received for that scope.
//
// Emit the local policy before the global policy. A local topic policy takes precedence over a global one,
// so applying the local value first means that by the time the global value is applied the local override is
// already in place and the merged (local-wins) result is what takes effect. Emitting the global value first
// would briefly apply it on its own and let a global-only setting act before the local policy overrides it --
// e.g. a compaction subscription being created for a global compaction policy even though the local policy
// disables compaction. This does not fully solve such ordering hazards, but it removes them whenever a local
// policy exists. When no local policy exists nothing is emitted for the local scope (see emitInitialPolicies),
// so this ordering does not change behavior for topics that only have a global policy.
emitInitialPolicies(latestLocalPolicies, loadedLocalPolicies);
emitInitialPolicies(latestGlobalPolicies, loadedGlobalPolicies);

latestGlobalPolicies = null;
latestLocalPolicies = null;
private void doInitPolicies(TopicPolicies local, TopicPolicies global) {
initialized = true;
realTopicListener.onUpdate(local);
realTopicListener.onUpdate(global);
// help for GC.
latestLocalPolicies = null;
latestGlobalPolicies = null;
}
Comment on lines +93 to 100

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.

onUpdate(local) and onUpdate(global) are called unconditionally, so a topic with only a local policy also gets onUpdate(null) for the global scope, and a topic with no topic policies gets two. The new tests pin this (shouldApplyLocalBeforeGlobalWhenOnlyGlobalLoaded asserts containsExactly(null, loadedGlobal)); the old emitInitialPolicies deliberately emitted nothing for an absent scope.

Harmless today, since both PersistentTopic#onUpdate and NonPersistentTopic#onUpdate return early on null. But null means "policies deleted" in the listener contract, and the fact that it is currently ignored is a separate bug worth fixing — deleting a topic policy does not reset the topic's effective policies today. If that gets fixed, every topic load would then wipe its own policies, because loading emits null for the absent scope. Emitting nothing when a scope has no value keeps that future fix safe.

Minor, same method: if the listener throws, the second onUpdate is skipped and the "help for GC" clearing never runs, while initialized is already true. A try/finally would make both hold.

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.

Fixed


/**
* Completes initialization with no loaded policies, unless it has already completed. Used as a safety net at the
* end of {@code initTopicPolicy} so the wrapper always leaves the buffering phase -- even when the listener was not
* registered or policy loading failed -- and therefore stops dropping updates: it emits any buffered value and
* forwards all future live updates. A no-op once initialization has completed (e.g. with loaded policies).
* Initializes policies (including local and global policies) when a topic is created; skips if a live update
* has already been received.
*/
public synchronized void completeInitializationUnlessAlreadyCompleted() {
completeInitialization(null, null);
}

private void emitInitialPolicies(Optional<TopicPolicies> latestReceived, TopicPolicies loaded) {
if (latestReceived != null) {
// A value (or a delete) was received during initialization; it supersedes the loaded value.
realTopicListener.onUpdate(latestReceived.orElse(null));
} else if (loaded != null) {
realTopicListener.onUpdate(loaded);
public synchronized void completeInitialization(@Nullable TopicPolicies globalLoaded,
@Nullable TopicPolicies localLoaded) {
if (initialized) {
return;
}
// Now we got the both newest value of global and local policy, we can trigger initialize.
TopicPolicies local = latestLocalPolicies != null ? latestLocalPolicies : localLoaded;
TopicPolicies global = latestGlobalPolicies != null ? latestGlobalPolicies : globalLoaded;
doInitPolicies(local, global);
Comment on lines +106 to +114

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 is effectively one-shot now: the early return on initialized plus no reset means a second initTopicPolicy() run loads both policies and applies neither. That is fine for today's callers (both PersistentTopic and NonPersistentTopic call it exactly once per instance), but it contradicts the javadoc on AbstractTopic#initTopicPolicy(), which this PR leaves in place:

Each call re-initializes the listener wrapper ... This makes the method safe to run again (e.g. a future retry).

Worth either keeping a reset or updating that contract, so the next person to add a retry does not get a silent no-op.

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.

Fixed

}

// warn if the initialization takes too long and updates have been received
// this helps detect issues where completeInitialization didn't get called after loading policies
private void maybeLogWarning() {
long durationNanos = System.nanoTime() - initializationStartedNanos;
int warningLogIntervalCount = (int) (durationNanos / INITIALIZATION_WARNING_LOG_INTERVAL_NANOS);
long durationMillis = System.currentTimeMillis() - initializationStartedMillis;

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 description motivates this as "we don't need to be precise to nanoseconds", but the issue is monotonicity rather than precision: System.currentTimeMillis() is wall-clock and can step (NTP correction, manual adjustment), which makes this duration jump or go negative and produces spurious or missed warnings. System.nanoTime() is the right source for an elapsed time.

Millisecond granularity in the log is still easy to keep — TimeUnit.NANOSECONDS.toMillis(...) on the nanoTime delta gives you both.

int warningLogIntervalCount = (int) (durationMillis / INITIALIZATION_WARNING_LOG_INTERVAL_MILLIS);
if (warningLogIntervalCount > lastIntervalLogged) {
log.warn().attr("topicPolicyListener", realTopicListener)
.attr("sinceInitializationStartedMs", TimeUnit.NANOSECONDS.toMillis(durationNanos))
.log("TopicPolicyUpdate buffered. TopicPolicyListenerWrapper initialization phase took too long. "
+ "completeInitialization should have been called to complete the phase.");
log.warn().attr("sinceInitializationStartedMs", durationMillis).log("TopicPolicyListenerWrapper"
+ " initialization phase took too long. "
+ "completeInitialization should have been called to complete the phase.");
lastIntervalLogged = warningLogIntervalCount;
}
}
Expand Down
Loading
Loading