Skip to content

[fix][broker] Prevent topic policy initialization race with a buffering listener wrapper#26044

Merged
nodece merged 4 commits into
apache:masterfrom
lhotari:lh-fix-topic-policy-init-race
Jun 18, 2026
Merged

[fix][broker] Prevent topic policy initialization race with a buffering listener wrapper#26044
nodece merged 4 commits into
apache:masterfrom
lhotari:lh-fix-topic-policy-init-race

Conversation

@lhotari

@lhotari lhotari commented Jun 17, 2026

Copy link
Copy Markdown
Member

Main Issue: #26037

Motivation

When a topic is loaded, its initial topic policies are applied during PersistentTopic#initTopicPolicy /
NonPersistentTopic#initialize by reading the current (global + local) policies and applying them. The topic
also registers as a TopicPolicyListener to receive subsequent updates. There is a race: a live policy update
can arrive via the listener while the initial load is still in flight, and the later-completing initial load
can overwrite the newer live value — leaving the topic with stale topic policies until the next update arrives.
This is a rare corner case, but possible.

Modifications

  • Add TopicPolicyListenerWrapper, a TopicPolicyListener wrapper around the real topic listener. While not
    yet initialized it buffers the latest received global/local policies instead of forwarding them; on
    completeInitialization(loadedGlobal, loadedLocal) it applies, per scope, the latest value received during
    loading (falling back to the loaded value), then forwards all subsequent updates directly. A null (delete)
    update arriving during the init window is ignored — onUpdate(null) is a no-op for topics — without NPEing.
  • PersistentTopic and NonPersistentTopic register the wrapper (via the new AbstractTopic#getTopicPolicyListener
    hook) and call completeInitialization on the per-topic policies-notify thread after the initial load.
    Internal and non-persistent topics complete with null loaded values (they don't load initial topic policies).

Verifying this change

This change is already covered by existing tests and adds a unit test:

  • TopicPolicyListenerWrapperTest — verifies buffering during init, latest-wins precedence over loaded values,
    application of loaded values when nothing was buffered, and the null-delete guard.
  • Existing topic-policy tests (e.g. TopicPoliciesTest, SystemTopicBasedTopicPoliciesServiceTest,
    MessageTTLTest) exercise the wrapper end-to-end for both persistent and non-persistent topics.

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

This is an internal behavior change (topic-policy initialization) and does not change any public API, schema,
configuration, wire protocol, REST endpoint, CLI option, or metric.

lhotari added 2 commits June 18, 2026 01:58
…apper

Introduce TopicPolicyListenerWrapper: during topic-policy initialization it buffers incoming onUpdate notifications and, on completeInitialization, applies the latest received values (preferring them over the loaded values). This fixes a race where a live policy update arriving while PersistentTopic#initTopicPolicy / NonPersistentTopic#initialize is still loading policies could be overwritten by the stale loaded value, leaving the topic policies inconsistent until the next update. PersistentTopic and NonPersistentTopic register the wrapper (via getTopicPolicyListener) and complete its initialization on the per-topic policies-notify thread; non-persistent and internal topics complete it with null loaded values. A null delete arriving during the init window is ignored (onUpdate(null) is a no-op for topics) without NPEing.

Assisted-by: Claude Opus 4.8
Use Optional<TopicPolicies> for the buffered latest global/local policies so a delete received during initialization (onUpdate(null)) is recorded (Optional.empty) and propagated downstream, instead of being ignored and the now-stale loaded value re-applied. A null field means no update was received during init (the loaded value is used); a present Optional holds the received policies; an empty Optional records a delete. Since a delete carries no global/local scope through the TopicPolicyListener interface, it is recorded for both scopes and a later scoped update during init overrides its own scope.

Assisted-by: Claude Opus 4.8
@lhotari lhotari force-pushed the lh-fix-topic-policy-init-race branch from dc9df65 to 98bb275 Compare June 17, 2026 23:00
@lhotari lhotari marked this pull request as ready for review June 17, 2026 23:00
lhotari added 2 commits June 18, 2026 02:31
Reword the onUpdate buffering comment to describe the actual code (a value is
stored as Optional.of(data), a delete as Optional.empty()) instead of the stale
Optional.ofNullable reference, and mark the assign-once createdTimestampNanos field final.

Assisted-by: Claude Opus 4.8
@nodece nodece merged commit b70b3a3 into apache:master Jun 18, 2026
43 checks passed
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