-
Notifications
You must be signed in to change notification settings - Fork 3.8k
[improve][broker] improve readability for the class TopicPolicyListenerWrapper #26277
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the class is not extended, so |
||
|
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment still refers to Also worth noting the semantics changed: the timestamp is now stamped in the constructor, and |
||
| 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) { | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This branch falls through to the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Harmless today, since both Minor, same method: if the listener throws, the second
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is effectively one-shot now: the early return on
Worth either keeping a reset or updating that contract, so the next person to add a retry does not get a silent no-op.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: Millisecond granularity in the log is still easy to keep — |
||
| 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; | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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: eithergetTopicPoliciesAsync(...)completing exceptionally, sothenCombinenever runs. The listener is already registered at that point, soonUpdatecalls 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,initPolicesCacheread errors, and thetopicPoliciesCacheInitTimeoutSecondstimeout added in #26025 for #25294.registerListenerAsynccan also complete exceptionally rather than returningfalse(LegacyAwareTopicPoliciesServiceresolves the backing service through a metadata lookup first), which is a different case fromregistered == false.initializedthen staysfalsefor 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:
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 sharedbroker-client-shared-internal-executorreader thread (orpulsarService.getExecutor()for the cache-init timeout), andBrokerServicechainspreCreateSubscriptionForCompactionIfNeeded->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.