Skip to content

fix: deduplicate config listeners by identity#121

Merged
nobodyiam merged 2 commits intoapolloconfig:mainfrom
nobodyiam:codex/fix-88-listener-identity
Feb 19, 2026
Merged

fix: deduplicate config listeners by identity#121
nobodyiam merged 2 commits intoapolloconfig:mainfrom
nobodyiam:codex/fix-88-listener-identity

Conversation

@nobodyiam
Copy link
Member

@nobodyiam nobodyiam commented Feb 8, 2026

What's the purpose of this PR

Fixes a listener registration edge case in Spring Cloud bootstrap dual-context initialization.

When two CachedCompositePropertySource listeners share the same name (ApolloBootstrapPropertySources),
AbstractConfig#addChangeListener used List.contains (equals-based) to de-duplicate listeners.
Because Spring PropertySource defines equality by name, the second listener instance could be skipped,
which leaves the active context listener unsubscribed and CachedCompositePropertySource#names stale after key add/delete.

This PR switches listener de-duplication/removal to instance identity (==) semantics and adds a regression test
covering two same-name CachedCompositePropertySource listeners.

Which issue(s) this PR fixes:

Fixes #88

Brief changelog

  • Change AbstractConfig#addChangeListener to deduplicate by object identity instead of equals
  • Change AbstractConfig#removeChangeListener to remove by object identity
  • Add regression test for two same-name CachedCompositePropertySource listeners both receiving change events
  • Update CHANGES.md

Follow this checklist to help us incorporate your contribution quickly and easily:

  • Read the Contributing Guide before making this pull request.
  • Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
  • Write necessary unit tests to verify the code.
  • Run mvn clean test to make sure this pull request doesn't break anything.
  • Update the CHANGES log.

Test run details

  • mvn -pl apollo-client clean test
  • mvn -pl apollo-client -Dtest=AbstractConfigTest,CachedCompositePropertySourceTest,ApolloApplicationContextInitializerTest test

Summary by CodeRabbit

  • Bug Fixes

    • Fixed incorrect de-duplication of change listeners that could lead to stale configuration during dual-context initialization; listeners are now distinguished by instance identity and removals target the specified listener reliably.
  • Tests

    • Added unit tests confirming multiple listeners with the same name each receive notifications and that removing a specific listener instance behaves correctly.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

Replaces equality-based listener deduplication in AbstractConfig with identity-based checks so distinct listener instances with the same name can be registered, removed, and notified independently; adds unit tests validating multi-instance notifications and removal; updates CHANGES.md with a changelog entry.

Changes

Cohort / File(s) Summary
Listener deduplication logic
apollo-client/src/main/java/com/ctrip/framework/apollo/internals/AbstractConfig.java
Replaced m_listeners.contains(listener) with an identity-based containsListenerInstance(...); updated removeChangeListener to remove by instance identity via iteration; preserved public signatures.
Test coverage
apollo-client/src/test/java/com/ctrip/framework/apollo/internals/AbstractConfigTest.java
Added tests for two distinct CachedCompositePropertySource instances with the same name: notification for both, non-conflict when interested keys differ, and removal of a specific listener instance. Added helper CountingCachedCompositePropertySource and utilities.
Changelog
CHANGES.md
Added a bullet describing the fix: change-listener de-duplication now uses identity to avoid stale property-names cache during Spring Cloud bootstrap dual-context initialization.

Sequence Diagram(s)

sequenceDiagram
  participant Initializer as ApolloApplicationContextInitializer
  participant Config as AbstractConfig
  participant SourceA as CachedCompositePropertySource#A
  participant SourceB as CachedCompositePropertySource#B

  Note over Initializer,Config: Bootstrap and application contexts create distinct CachedCompositePropertySource instances with same name

  Initializer->>Config: addChangeListener(listenerA, keysA, prefixesA)
  Config-->>Config: containsListenerInstance(listenerA)? -> false
  Config-->>Config: add listenerA and record interested keys/prefixes

  Initializer->>Config: addChangeListener(listenerB, keysB, prefixesB)
  Config-->>Config: containsListenerInstance(listenerB)? -> false
  Config-->>Config: add listenerB

  Config->>SourceA: fire changeEvent
  SourceA-->>SourceA: onChange -> clear cached names
  SourceA-->>Config: notify listeners (listenerA and/or listenerB as applicable)

  Config->>SourceB: fire changeEvent
  SourceB-->>SourceB: onChange -> clear cached names
  SourceB-->>Config: notify listeners (both distinct instances receive events)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 Two listeners hop with the very same name,
I count each one separately — never the same.
Cache sheds its fur, fresh keys start to play,
Dual contexts wake up and join in the day.
Hooray — the rabbit munches a celebratory clover.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing listener deduplication to use identity instead of equality.
Linked Issues check ✅ Passed All code changes directly address issue #88: identity-based listener deduplication prevents skipping same-named listener instances, ensuring both CachedCompositePropertySource instances receive change events.
Out of Scope Changes check ✅ Passed All changes are within scope: modifications to AbstractConfig listener handling, comprehensive test coverage for same-name listeners, and CHANGES.md documentation updates.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apollo-client/src/main/java/com/ctrip/framework/apollo/internals/AbstractConfig.java (1)

113-123: ⚠️ Potential issue | 🟠 Major

m_interestedKeys/m_interestedKeyPrefixes removal still uses equals, inconsistent with identity-based m_listeners.

When two listeners are equal-but-not-identical (the exact scenario this PR fixes), m_interestedKeys.remove(listener) on line 115 uses equals-based lookup on the ConcurrentHashMap, so removing either listener deletes the shared map entry for both. The surviving listener then has no entry in m_interestedKeys, causing isConfigChangeListenerInterested to treat it as interested in all keys — a silent broadening of its subscription scope.

The same issue applies to addChangeListener: m_interestedKeys.put(listener2, ...) overwrites the entry keyed by listener1 (since ConcurrentHashMap uses equals), so listener1's interested-key set is silently replaced by listener2's.

Consider switching these maps to an IdentityHashMap (wrapped with Collections.synchronizedMap for thread safety) to match the identity semantics used for m_listeners.

Proposed fix sketch
-  private final Map<ConfigChangeListener, Set<String>> m_interestedKeys = Maps.newConcurrentMap();
-  private final Map<ConfigChangeListener, Set<String>> m_interestedKeyPrefixes = Maps.newConcurrentMap();
+  private final Map<ConfigChangeListener, Set<String>> m_interestedKeys =
+      Collections.synchronizedMap(new IdentityHashMap<>());
+  private final Map<ConfigChangeListener, Set<String>> m_interestedKeyPrefixes =
+      Collections.synchronizedMap(new IdentityHashMap<>());

And in removeChangeListener, move the map removals inside the identity check so only the matched instance's entries are cleaned up:

   public boolean removeChangeListener(ConfigChangeListener listener) {
-    m_interestedKeys.remove(listener);
-    m_interestedKeyPrefixes.remove(listener);
     for (ConfigChangeListener addedListener : m_listeners) {
       if (addedListener == listener) {
+        m_interestedKeys.remove(addedListener);
+        m_interestedKeyPrefixes.remove(addedListener);
         return m_listeners.remove(addedListener);
       }
     }
     return false;
   }
🧹 Nitpick comments (1)
apollo-client/src/test/java/com/ctrip/framework/apollo/internals/AbstractConfigTest.java (1)

128-156: Good regression test that clearly validates the fix.

The precondition assertions on lines 140-141 are excellent — they explicitly verify the conditions (assertNotSame + assertEquals) that trigger the bug.

Minor robustness note: Thread.sleep(100) on line 152 is inherently racy. The existing tests in this class use SettableFuture with a timeout for deterministic waiting. Consider adopting the same pattern here for consistency and CI stability.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a Spring Cloud bootstrap dual-context edge case where change listeners that are different instances but equals()-equal (e.g., Spring PropertySource equality-by-name) could be incorrectly de-duplicated, leading to missed notifications and stale cached property names.

Changes:

  • Switch AbstractConfig change-listener de-duplication from equals() to instance identity (==).
  • Update listener removal logic to attempt identity-based removal.
  • Add a regression test covering two same-name CachedCompositePropertySource listeners and update CHANGES.md.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
apollo-client/src/main/java/com/ctrip/framework/apollo/internals/AbstractConfig.java Changes listener registration/removal logic to use identity semantics.
apollo-client/src/test/java/com/ctrip/framework/apollo/internals/AbstractConfigTest.java Adds regression test for two equals()-equal but distinct listener instances both receiving events.
CHANGES.md Documents the fix in release notes.
Comments suppressed due to low confidence (1)

apollo-client/src/main/java/com/ctrip/framework/apollo/internals/AbstractConfig.java:109

  • m_interestedKeys/m_interestedKeyPrefixes are still keyed by ConfigChangeListener in a ConcurrentMap, so they still use equals/hashCode. For listeners where equals is not identity (e.g., Spring PropertySource equality-by-name), registering two distinct instances will collide and overwrite each other’s interested-keys/prefixes, and later lookups/removals won’t be identity-safe. To truly switch to identity semantics, store per-listener metadata with identity keys (e.g., wrap the listener in an identity-based key, or replace the separate maps with a single registration object that holds listener + filters).
  public void addChangeListener(ConfigChangeListener listener, Set<String> interestedKeys, Set<String> interestedKeyPrefixes) {
    if (!containsListenerInstance(listener)) {
      m_listeners.add(listener);
      if (interestedKeys != null && !interestedKeys.isEmpty()) {
        m_interestedKeys.put(listener, Sets.newHashSet(interestedKeys));
      }
      if (interestedKeyPrefixes != null && !interestedKeyPrefixes.isEmpty()) {
        m_interestedKeyPrefixes.put(listener, Sets.newHashSet(interestedKeyPrefixes));
      }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@nobodyiam nobodyiam force-pushed the codex/fix-88-listener-identity branch from 6d32daa to ad4aa96 Compare February 8, 2026 04:56
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@codecov
Copy link

codecov bot commented Feb 19, 2026

Codecov Report

❌ Patch coverage is 72.72727% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.01%. Comparing base (d4b76f8) to head (110a449).
⚠️ Report is 19 commits behind head on main.

Files with missing lines Patch % Lines
...rip/framework/apollo/internals/AbstractConfig.java 72.72% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main     #121      +/-   ##
============================================
+ Coverage     68.68%   71.01%   +2.32%     
- Complexity     1503     1634     +131     
============================================
  Files           212      224      +12     
  Lines          6396     6727     +331     
  Branches        647      679      +32     
============================================
+ Hits           4393     4777     +384     
+ Misses         1673     1600      -73     
- Partials        330      350      +20     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@nobodyiam nobodyiam merged commit e087759 into apolloconfig:main Feb 19, 2026
16 checks passed
@nobodyiam nobodyiam deleted the codex/fix-88-listener-identity branch February 19, 2026 03:21
@github-actions github-actions bot locked and limited conversation to collaborators Feb 19, 2026
@nobodyiam nobodyiam added this to the 2.5.0 milestone Feb 19, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SpringCloud 环境 @ConfigurationProperties 无法监听到新增和删除 key 问题

1 participant

Comments