Fix instance push loss during initial subscribe in ServiceDiscoveryRegistry - #16393
Fix instance push loss during initial subscribe in ServiceDiscoveryRegistry#16393hnpkso wants to merge 2 commits into
Conversation
Three assertions in ServiceDiscoveryRegistryTest, each pinpointing a distinct defect in ServiceDiscoveryRegistry.subscribeURLs(). All three fail on unmodified 3.3 source; the accompanying fix commit turns them green. - testSubscribeURLsRegistersPushCallbackBeforePull verifies (via InOrder) that addServiceInstancesChangedListener is invoked before getInstances. Fails today because register happens after the pull. - testSubscribeURLsSkipsPullWhenPushAlreadyPopulated simulates a push that runs synchronously from addServiceInstancesChangedListener and populates allInstances before the pull loop. Verifies the subsequent pull is skipped. Fails today because the pull runs unconditionally. - testSubscribeURLs's pre-existing verify(...times(2)) on addServiceInstancesChangedListener updates to times(1), reflecting that register now happens once per listener lifetime rather than once per subscribeURLs() call.
…gistry subscribeURLs() called getInstances() and processed the result before registering the push callback via addServiceInstancesChangedListener(). The pull path includes a synchronous metadata fetch that can take seconds, and any push arriving inside this window was absorbed by the discovery SDK's local cache without ever reaching Dubbo. The listener then held a permanently stale allInstances snapshot until an unrelated push happened or the process restarted. Observed in production against Nacos 2.x (Dubbo 3.2.19, APPLICATION_FIRST): a provider that Nacos removed 92ms after subscribe began remained a ghost invoker for the entire process lifetime and caused recurring "Fail to decode request" errors. Fix: - Move addServiceInstancesChangedListener inside the listener-creation branch and ahead of the pull loop, so pushes arriving during pull are delivered rather than dropped. - Serialize the pull loop with synchronized(listener), sharing the intrinsic lock that doOnEvent already holds, so pull and push cannot interleave writes to allInstances. - Skip the pull for any serviceName that a push has already populated. Push wins over stale pull. addServiceInstancesChangedListener now runs once per listener lifetime rather than once per subscribeURLs() call. All in-tree ServiceDiscovery implementations already no-op the duplicate call via instanceListeners.add(); the change is observationally equivalent.
eca9c2d to
8e0f8f2
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 3.3 #16393 +/- ##
============================================
+ Coverage 60.90% 60.91% +0.01%
- Complexity 11763 11766 +3
============================================
Files 1953 1953
Lines 89266 89269 +3
Branches 13471 13472 +1
============================================
+ Hits 54367 54378 +11
+ Misses 29319 29315 -4
+ Partials 5580 5576 -4
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Fixes a race in ServiceDiscoveryRegistry.subscribeURLs() where instance-change push notifications could be lost during the initial subscribe window (between the initial pull and listener registration), leaving allInstances stale for the lifetime of the process.
Changes:
- Register
addServiceInstancesChangedListener(...)before the initialgetInstances(...)pull to avoid missing pushes during initialization. - Serialize the initialization pull path with the listener’s push-handling path by synchronizing on the
ServiceInstancesChangedListener, and skip the pull when a push has already populatedallInstancesfor a service. - Update/add unit tests to reproduce the race deterministically and assert correct ordering/behavior (including adjusted listener-registration call count).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| dubbo-registry/dubbo-registry-api/src/main/java/org/apache/dubbo/registry/client/ServiceDiscoveryRegistry.java | Reorders listener registration ahead of initial pulls and synchronizes initialization to prevent push-loss and stale-overwrite. |
| dubbo-registry/dubbo-registry-api/src/test/java/org/apache/dubbo/registry/client/ServiceDiscoveryRegistryTest.java | Adds/updates tests to enforce registration-before-pull ordering and “push wins over stale pull” behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
PR: Fix instance push loss during initial subscribe in ServiceDiscoveryRegistry
Base branch:
apache/dubbo:3.3(also applies cleanly to3.2and3.4)Commits: 2 (test-only
f982a15294, then fix8e0f8f2d81). Check out either to see the failing-then-passing tests.What this fixes
Fixes #16392.
ServiceDiscoveryRegistry.subscribeURLs()currently:serviceDiscovery.getInstances(serviceName)— the initial pull,listener.onEvent(...)which synchronously fetches metadata (can take seconds),serviceDiscovery.addServiceInstancesChangedListener(...)to arm the push callback.Any push arriving between step 1 and step 3 is absorbed by the discovery SDK's local cache and never reaches Dubbo.
allInstancesis then permanently stale until either (a) a later, independent push happens for the same appName, or (b) the process restarts.We hit this in production (Nacos 2.x, Dubbo 3.2.19): a provider Nacos had removed 92 ms after our subscribe started remained a ghost invoker for the entire process lifetime, producing recurring
Fail to decode requesterrors.Timeline and full analysis are in the linked issue.
The change
dubbo-registry-api/…/ServiceDiscoveryRegistry.java—subscribeURLs():Register the push callback before the initial pull.
addServiceInstancesChangedListener(...)moves inside theif (listener == null)guard, ahead of the pull loop. Any push arriving during/after the pull now flows through the listener's already-armeddoOnEventpath.Serialize the pull with the push callback. Wrap the pull loop in
synchronized(serviceInstancesChangedListener).doOnEventis asynchronizedmethod on the same object; both now share the same intrinsic lock, so a push arriving during initialization queues cleanly instead of interleaving writes toallInstances.Push wins over stale pull. Inside the synchronized region, check
listener.getAllInstances().containsKey(serviceName)before pulling. If a push has already populated this service, treat push as authoritative and skip the pull. This prevents a slow pull (reading a stale SDK cache) from overwriting a fresher push snapshot.Diff is under 30 lines.
Before/after verification
Two commits are separated intentionally so the failing-then-passing tests can be observed directly:
The red-to-green transition is the reproduction — no Nacos or external system needed.
Full module test suite:
Tests run: 111, Failures: 0.Notes on behavior changes
addServiceInstancesChangedListenercardinality: now called once per listener lifetime, previously once persubscribeURLs()call. All in-treeServiceDiscoveryimplementations already treat duplicate calls as no-ops viainstanceListeners.add()early-return, so the change is observationally equivalent. ThetestSubscribeURLsassertion updates fromtimes(2)totimes(1)accordingly.RegistryEvent.toSsEventmetric cardinality rides along with (1): it now fires once per listener creation instead of once per subscribe. If per-subscribe metric cardinality is preferred,MetricsEventBus.post(...)can be split back out of the register lambda without affecting correctness — happy to adjust.Push executor threads may briefly park on the listener intrinsic lock during initial subscribe, bounded by the metadata fetch duration (~500 ms in our production). Previously such pushes were silently dropped, so this is a strict improvement.
Scope
This PR only fixes the push-loss race in
subscribeURLs().NacosServiceDiscoveryand otherServiceDiscoveryimplementations are untouched; no new SPI contract. Any unrelated concerns in the same code path are out of scope for this change.Related
MappingListenerside of the same subscribe path (initial-apps wasnullwhen the listener was constructed). The instance-list side, addressed here, has the same shape of bug (populate-before-arm) but a different failure mode (silent instance loss vs. spurious mapping event).Commits
f982a15294—Add failing tests for instance push loss during initial subscribe. Tests only, fails on unmodified 3.3.8e0f8f2d81—Fix instance push loss during initial subscribe in ServiceDiscoveryRegistry. Source fix, turns tests green.Feel free to squash on merge; the two commits exist purely to make the red→green transition observable during review.