Skip to content

[fix][broker] Don't serve topic policies from a cache whose init future has not completed - #26513

Open
alexandrebrg wants to merge 1 commit into
apache:masterfrom
alexandrebrg:fix/topic-policies-stale-cache-generation
Open

[fix][broker] Don't serve topic policies from a cache whose init future has not completed#26513
alexandrebrg wants to merge 1 commit into
apache:masterfrom
alexandrebrg:fix/topic-policies-stale-cache-generation

Conversation

@alexandrebrg

@alexandrebrg alexandrebrg commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Related: #26137, #20763

Motivation

We recently had an issue where a namespace had a retention smaller than a topic, and this bug triggered a purge of data from a topic

SystemTopicBasedTopicPoliciesService.getTopicPoliciesAsync can return Optional.empty() for a topic that has topic-level policies, without any exception or log line, when a namespace-bundle bounce (unload followed by reload of the namespace's last bundle on the same broker) lands inside the thread hop the method performs between awaiting the namespace's policy-cache initialization and reading the cache. A topic load that hits this window builds its ManagedLedgerConfig from the namespace retention instead of the topic retention, and nothing repairs it afterwards. The consequence -- a topic silently enforcing its namespace retention instead of its own while the admin API keeps reporting the topic value -- is what surfaced in a production cluster; the interleaving itself is not claimed to have been captured there. The tests in this PR reproduce it without sleeps or timing tuning: the unpatched failure only requires the replacement generation not to finish loading within the thread hop.

All line numbers below refer to master at the merge base of this PR, before the change.

The mechanism

Annotated excerpt of getTopicPoliciesAsync (pulsar-broker/src/main/java/org/apache/pulsar/broker/service/SystemTopicBasedTopicPoliciesService.java, lines 580-606, quoted verbatim; the annotations follow the excerpt, keyed by line number):

580          final CompletableFuture<Boolean> preparedFuture = prepareInitPoliciesCacheAsync(topicName.getNamespaceObject());
581          // switch thread to avoid potential metadata thread cost and recursive deadlock
582          return preparedFuture.thenComposeAsync(inserted -> {
583              // initialized : policies
584              final Mutable<Pair<Boolean, Optional<TopicPolicies>>> policiesFutureHolder = new MutableObject<>();
585              // NOTICE: avoid using any callback with lock scope to avoid deadlock
586              policyCacheInitMap.compute(namespace, (___, existingFuture) -> {
587                  if (!inserted || existingFuture != null) {
588                      final var partitionedTopicName = TopicName.get(topicName.getPartitionedTopicName());
589                      final var policies = Optional.ofNullable(switch (type) {
590                          case GLOBAL_ONLY -> globalPoliciesCache.get(partitionedTopicName);
591                          case LOCAL_ONLY -> policiesCache.get(partitionedTopicName);
592                      });
593                      policiesFutureHolder.setValue(Pair.of(true, policies));
594                  } else {
595                      policiesFutureHolder.setValue(Pair.of(false, null));
596                  }
597                  return existingFuture;
598              });
599              final var p = policiesFutureHolder.get();
600              if (!p.getLeft()) {
601                  log.info()
602                          .attr("namespace", namespace)
603                          .log("The future of has been removed from cache, retry getTopicPolicies again");
604                  return getTopicPoliciesAsync(topicName, type);
605              }
606              return CompletableFuture.completedFuture(p.getRight());
  • 580: prepareInitPoliciesCacheAsync(namespace) is a real await. policyCacheInitMap holds one CompletableFuture<Void> per namespace -- one "generation" of that namespace's policy cache -- installed with putIfAbsent (line 649) and completed once the __change_events reader has drained (line 667). The inserted it resolves to is true both when this call installed the future (line 697) and when it joined an existing one (line 699); it is false only when the service is closed (line 636) or the namespace is missing or deleted (line 644).
  • 581-582: thenComposeAsync with no executor hands the continuation to CompletableFuture's default async executor (the common ForkJoinPool when its parallelism is above 1, otherwise a new thread per task), so it runs on another thread after preparedFuture completes. Between the completion of preparedFuture and the execution of the lambda, policyCacheInitMap can change.
  • 586-587: the guarantee that was just awaited is re-derived from the map, and the re-derivation is weaker than the await: existingFuture != null asks whether some init future is present, never whether it is complete. A future installed a moment ago that has not read a single event yet passes.
  • 588-593: the caches are read and the result is returned as final (line 606).
  • 594-596 and 599-604: the retry branch, taken only when the map has no entry for the namespace.

The interleaving

  1. A topic load calls getTopicPoliciesAsync(topic, LOCAL_ONLY). Generation N of the namespace is complete, so preparedFuture completes with inserted == true and the continuation of line 582 is queued on the common pool.
  2. Before it runs, the namespace's last bundle is unloaded: removeOwnedNamespaceBundleAsync (line 724) calls cleanPoliciesCacheInitMap(namespace) (line 935), which, inside policyCacheInitMap.compute, removes every policiesCache and globalPoliciesCache entry of the namespace and returns null, i.e. removes the map entry (lines 944-950).
  3. The bundle is loaded again: addOwnedNamespaceBundleAsync (line 610) calls prepareInitPoliciesCacheAsync(namespace), which putIfAbsents a new, incomplete future -- generation N+1 -- and starts a new reader (lines 647-658).
  4. The queued continuation runs. inserted is true and existingFuture is generation N+1, non-null, so line 587 takes the read branch on a cache that was wiped in step 2 and not yet repopulated.
  5. Optional.empty() is returned as a final answer for a topic that has policies. No exception, no log line.

Steps 2 and 3 are the ordinary bundle ownership callbacks registered in start() (lines 740-752); the defect is only their ordering against a queued continuation.

A wipe without a new generation is harmless: with the map entry gone, existingFuture == null selects the retry branch, getTopicPoliciesAsync calls prepareInitPoliciesCacheAsync again, installs and awaits a fresh generation and reads a populated cache. It is the newly installed, still-loading generation that turns a retry into a silent empty read. The injection seam used by the tests therefore replays exactly the two production calls of a bounce -- cleanPoliciesCacheInitMap, then prepareInitPoliciesCacheAsync -- in that window, and nothing else.

The other done-checks in this file all ask for "present AND complete": existingFuture == null || existingFuture.isDone() (line 250), chainedFuture != null && chainedFuture.isDone() (line 301), initFuture != null && !initFuture.isDone() (line 972). Line 587 is the outlier. Before PIP-376 (#23319) this read was guarded by initialized == null || !initialized.isDone() (in the lines that commit removed); #23319 replaced it with the current predicate, evaluated synchronously in a thenAccept, and #23786 moved the continuation behind the executor-less thenComposeAsync hop ("switch thread to avoid potential metadata thread cost and recursive deadlock"), which widened the gap between the generation's completion and the compute from a few instructions into a thread-pool hand-off.

Consequence: the retention is burned in and the repair is lost

BrokerService.getManagedLedgerConfig (line 2405; policy resolution at lines 2434-2475) reads the topic policies with GetType.LOCAL_ONLY (line 2418) and GetType.GLOBAL_ONLY (line 2422) through getTopicPoliciesBypassSystemTopic, which delegates to getTopicPoliciesAsync (line 1494). An Optional.empty() there is indistinguishable from "this topic has no retention policy": retentionPolicies stays null (lines 2446-2450), the namespace retention_policies -- or the broker defaults -- are taken instead (lines 2465-2475) and written into the ManagedLedgerConfig with setRetentionTime / setRetentionSizeInMB (lines 2557-2559) before the managed ledger is opened. A failed read, by contrast, fails the topic load loudly; the remedies discussed in #26137 key off that failure signal and cannot see this silent empty.

AbstractTopic.initTopicPolicy() (lines 629-670) reads the same policies again (GLOBAL_ONLY at lines 648-649, LOCAL_ONLY at lines 652-653) and hands them to TopicPolicyListenerWrapper.completeInitialization(global.orElse(null), local.orElse(null)) (lines 657-658). TopicPolicyListenerWrapper.emitInitialPolicies (lines 145-152) emits nothing when the loaded value is null and nothing was received during initialization, so an empty read there repairs nothing. One topic load performs five policy reads in total. BrokerService.getTopic (lines 1379-1382) issues the first one, a LOCAL_ONLY read whose result is discarded, purely to await the namespace's policy-cache initialization before the load proceeds; getManagedLedgerConfig then reads twice and initTopicPolicy() twice more. The retention is wrong if the getManagedLedgerConfig pair is stale, and it stays wrong if the initTopicPolicy() pair is stale too.

With topicPolicyListenerReplayEnabled=false (the default since #26134, ServiceConfiguration line 2057) no later broadcast re-notifies the topic once generation N+1 has finished loading, so the wrong retention persists for the life of the topic instance while admin.topicPolicies().getRetention(topic, true) keeps returning the topic value. Data is trimmed to the namespace retention.

Related changes

Modifications

  1. SystemTopicBasedTopicPoliciesService.getTopicPoliciesAsync: read the caches only when the init future currently owning the namespace has completed successfully.

    -                if (!inserted || existingFuture != null) {
    +                if (!inserted || (existingFuture != null && existingFuture.isDone()
    +                        && !existingFuture.isCompletedExceptionally())) {

    (Excerpt of the hunk: the six comment lines the patch adds above the predicate are omitted here.)

    A future still loading, or one already dropped from the map, falls through to the existing retry branch, which re-awaits through prepareInitPoliciesCacheAsync before reading again. A comment above the predicate explains why isDone() is required (the bounce-inside-the-hop interleaving). prepareInitPoliciesCacheAsync, the !inserted short-circuit (service closed, or namespace being deleted -- unchanged behaviour: it still answers from the caches as they are) and the retry branch itself are untouched.

    One narrow behaviour change is worth naming: a generation that completed exceptionally is now routed to the retry as well, and that retry re-enters prepareInitPoliciesCacheAsync, whose existingFuture.thenApply(__ -> true) (line 699) propagates the failure to the caller. The window is short -- between initNamespacePolicyFuture.completeExceptionally(ex) (lines 680/683, and the timeout path at line 778) and the identity-guarded policyCacheInitMap.remove(namespace, initFuture) inside cleanupFailedPolicyCacheInit (line 826) -- and a read landing in it previously returned the still-populated cache, since that cleanup deliberately leaves cached policies in place (lines 812-814). Failing the topic load is the safe direction here: it is the signal [Bug] Topic policy loading errors are ignored which can result in data loss #26137 is about, and it is loud, where the previous behaviour was to serve a cache nobody was awaiting.

  2. The retry log line. "The future of has been removed from cache, retry getTopicPolicies again" lost its subject when the {} placeholder was dropped in the slog migration ([improve] PIP-467: Convert pulsar-broker module logging from SLF4J to slog #25535), and "removed" describes only one of the three cases the branch now covers. It now reads "Policy cache init future is missing, failed or not yet complete, retry getTopicPolicies again", same level (INFO, an existing line) and same structured namespace attribute. The level is kept deliberately: a bundle bounce is a low-frequency ownership event, and the line fires at most once per policy read in flight at that instant, since reads that start while the replacement generation is still loading await it instead of retrying.

  3. Tests, all in pulsar-broker/src/test/java/org/apache/pulsar/broker/service/:

    • StaleCacheGenerationInjectingTopicPoliciesService (new, package-private helper): a SystemTopicBasedTopicPoliciesService whose prepareInitPoliciesCacheAsync override, while armed for a namespace, lets the production method complete and then replays a bundle bounce -- cleanPoliciesCacheInitMap(namespace) followed by super.prepareInitPoliciesCacheAsync(namespace) fired and forgotten, as addOwnedNamespaceBundleAsync does -- waits off-thread (polling on the broker's scheduled executor, never blocking the caller's thread) until a generation owns the namespace again, and only then hands back the original inserted. The bounce therefore runs before the future the caller awaits completes, so the continuation of getTopicPoliciesAsync is guaranteed, not merely likely, to resume onto a replaced, still-loading generation; the reproduction needs no sleep and no timing tuning. It fires only when the awaited generation is complete and non-exceptional, which is the production precondition, one bounce at a time (two overlapping bounces would drop and fail each other's still-loading replacement, aborting an unrelated in-flight read), and at most budget times (arm(namespace, budget) / disarm()), so a broker that retries its way out of the stale read converges. staleInjectionCount() counts only the bounces actually handed back in the state under test -- replacement generation installed and still loading -- so neither test can pass or fail vacuously. No cache is edited by hand, nothing is stubbed and no failure is injected: the seam only chooses when two production methods run.
    • SystemTopicBasedTopicPoliciesServiceTest#testGetTopicPoliciesWhenBundleBounceReplacesPolicyCacheGeneration (new method in the existing class): the direct regression test on getTopicPoliciesAsync.
    • TopicPoliciesStaleCacheGenerationRetentionTest (new class): the end-to-end effect on the live ManagedLedgerConfig. It extends MockedPulsarServiceBaseTest rather than SharedPulsarBaseTest because it closes and replaces the broker's TopicPoliciesService, which would leak into every other class sharing a runtime.

Verifying this change

  • Make sure that the change passes the CI checks.

This change added tests and can be verified as follows:

  • SystemTopicBasedTopicPoliciesServiceTest#testGetTopicPoliciesWhenBundleBounceReplacesPolicyCacheGeneration closes the current service and installs the injecting one with FieldUtils.writeField(pulsar, "topicPoliciesService", ...), as the existing tests of that class do, and additionally calls start(pulsar) so the replacement receives bundle-ownership callbacks like the production service. It creates a topic with a local policy, waits until generation 1 of the namespace is complete and the policy is visible in the cache, arms four bounces, issues a single getTopicPoliciesAsync(topic, LOCAL_ONLY), and asserts that at least one bounce was handed back a still-loading replacement generation (so the next assertions cannot hold vacuously), that the result is present, and that it carries the policy that was set.

    • On unpatched master it fails with:

      java.lang.AssertionError: [getTopicPoliciesAsync returned Optional.empty() for a topic that has a policy: a namespace-bundle bounce replaced the namespace's policy-cache generation while the read was hopping threads, and the read served the wiped cache of the new, still-loading generation instead of retrying] 
      Expecting Optional to contain a value but it was empty.
      
    • With the fix it passes, and the test output contains the retry line Policy cache init future is missing, failed or not yet complete, retry getTopicPolicies again four times -- once per injected bounce, i.e. every bounce became a retry instead of an empty read. The whole class (20 tests) passes with the fix.

  • TopicPoliciesStaleCacheGenerationRetentionTest#testTopicRetentionSurvivesPolicyCacheGenerationReplacementDuringLoad runs a broker with topicLevelPoliciesEnabled=true, systemTopicEnabled=true, defaultNumberOfNamespaceBundles=1, defaultRetentionTimeInMinutes=0 and defaultRetentionSizeInMB=0, a namespace retention of 30 min and a topic-level retention of 300 min (synthetic values; only which of the two ends up live matters). A control load asserts that the live topic.getManagedLedger().getConfig().getRetentionTimeMillis() equals 300 min. The topic is then unloaded (waiting until getTopicReference is empty), the seam is armed with a budget of 8, the topic is loaded again and the seam is disarmed. The test asserts that at least one bounce was handed back a still-loading replacement generation, that both the PersistentTopic and the ManagedLedger instance differ from the control leg (ManagedLedgerFactoryImpl caches managed ledgers by name and ignores the config passed on a hit, so a reused instance would be asserting on the control leg's config), that the policy store still reports 300 min, and -- inside a bounded 10 s Awaitility, so that an out-of-band repair would still turn it green -- that the live retention equals 300 min.

    • On unpatched master it fails with:

      org.awaitility.core.ConditionTimeoutException: Assertion condition defined as a Lambda expression in org.apache.pulsar.broker.service.TopicPoliciesStaleCacheGenerationRetentionTest [the topic-policy reads made stale by the namespace-bundle bounces returned Optional.empty(), so the topic silently fell back to namespace retention: the live ManagedLedgerConfig enforces the namespace value of 30 minutes instead of the topic value of 300 minutes] 
      expected: 18000000L
       but was: 1800000L within 10 seconds.
      

      (1 800 000 ms is the 30 min namespace value; 18 000 000 ms is the 300 min topic value.)

    • With the fix it passes, and the retry line appears eight times -- the bounce budget. All eight land on the preliminary LOCAL_ONLY read in BrokerService.getTopic, which is where the load spends the whole budget: the retries are sequential, since each one re-enters the seam, so the first read consumes it all. The Loaded topic latency breakdown of the armed load shows it -- its first topic policies stage takes 534 ms against 902 us on the control load, while its later topic policies stages stay at 2 ms, 1 ms and 13 ms -- so the four retention-deciding reads run against a settled generation and the topic keeps its 300 min retention. The predicate itself is exercised directly by the unit test above.

Both tests fail on the unpatched code for the real reason (the empty read, the namespace retention), not because the seam forces internal state, and neither uses a sleep or a tuned delay: the unpatched failure only requires the replacement generation not to finish loading within the thread hop. In both, the vacuity assertion on staleInjectionCount() precedes the failing assertion and passes, so a run that never opened the window would fail there instead of reporting a false negative. ./gradlew :pulsar-broker:checkstyleMain :pulsar-broker:checkstyleTest and ./gradlew quickCheck pass on the change.

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

None of these apply: no configuration default changes, no public API or threading change; the fix only routes one more case into a retry path that already existed.

…re has not completed

getTopicPoliciesAsync awaits prepareInitPoliciesCacheAsync, hops
threads and re-derives that guarantee from policyCacheInitMap as
"existingFuture != null". A namespace-bundle bounce landing inside
the hop wipes both policy caches and installs a new, still-loading
init future, so the resumed read served the wiped cache:
Optional.empty() for a topic that has policies, which a topic load
then turned into the namespace retention burned into its
ManagedLedgerConfig.

Read the caches only from a complete, non-failed init future and
retry otherwise; add a bounce-injecting test seam, a unit test and
an end-to-end retention test.

Assisted-by: Claude Code (Fable 5.1)

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

The completion guard addresses the stale-generation read. Please make the regression fixture control the replacement reader until the read reaches the decision under test; its current counter does not establish that the stale-read window was exercised. The reflection cleanup is nonblocking.

// Fire and forget, exactly like the bundle-load path: the caller is not awaiting this generation.
super.prepareInitPoliciesCacheAsync(namespace).exceptionally(ignored -> null);
return awaitGenerationInstalled(namespace,
System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(GENERATION_INSTALL_TIMEOUT_MILLIS))

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.

[QUALITY] Hold the replacement reader until the stale-read decision is exercised

This counter samples the replacement before the common-pool continuation in SystemTopicBasedTopicPoliciesService.java:582 runs. The replacement reader is free to populate the cache and complete between this increment and that continuation, so the old implementation can return the correct policy with a positive counter. Conversely, if the replacement finishes before this check, the fixed implementation can fail the positive-counter assertion even though every read was correct. An incomplete future also does not mean the target policy has not already been read. Please gate the replacement reader before it reads that policy, observe the original read reach its decision or retry, and then release the gate. That would make the regression exercise the intended interleaving without depending on reader speed or executor scheduling.

// interleaved into the policy reads a topic load performs.
pulsar.getTopicPoliciesService().close();
injectingService = new StaleCacheGenerationInjectingTopicPoliciesService(pulsar);
FieldUtils.writeField(pulsar, "topicPoliciesService", injectingService, 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.

[QUALITY] Replace the new private-field reflection with a typed test seam

Nonblocking: this and SystemTopicBasedTopicPoliciesServiceTest.java:913 add reflective writes to PulsarService's private service field. CODING.md explicitly disallows new reflection into private state, even though older tests still use it. Please use a typed @VisibleForTesting injection seam so a field rename or type change is checked by the compiler instead of failing at runtime.

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