Skip to content

fix: self-heal application-level discovery after transient metadata failure (#3615) - #3625

Merged
Alanxtl merged 6 commits into
apache:developfrom
AsperforMias:fix/3615-metadata-retry
Aug 21, 2026
Merged

fix: self-heal application-level discovery after transient metadata failure (#3615)#3625
Alanxtl merged 6 commits into
apache:developfrom
AsperforMias:fix/3615-metadata-retry

Conversation

@AsperforMias

Copy link
Copy Markdown
Contributor

Description

Fixes #3615

Problem. With application-level service discovery, a single transient MetadataService fetch failure during a provider restart could leave the consumer directory permanently empty (No provider available) until the consumer was restarted. Root cause chain, confirmed by a deterministic docker reproduction (details in this comment):

  1. ServiceInstancesChangedListenerImpl.OnEvent skipped instances whose metadata fetch failed, overwrote the service URLs with the (possibly empty) result, and returned success. No retry queue, no error propagation — and since Nacos only pushes on instance-list changes, no later event ever retried the fetch.
  2. Even when a later push did re-fetch metadata successfully, RegistryDirectory refused to rebuild the invoker: the graceful shutdown of the provider had left a closing tombstone on the instance key (default TTL 30s), and a genuine same-address restart was indistinguishable from a stale pre-shutdown registry snapshot.

Fix (two layers, both required — verified by e2e):

  • Metadata retry (service_instances_changed_listener_impl.go): failed revisions are tracked in unresolvedRevisions and re-resolved by replaying the latest instance snapshot through the existing build path, using one shared timer per listener with capped exponential backoff (1s→30s, 25% jitter, unlimited attempts — a retry cap would re-introduce the permanent failure this fixes). Retries stop naturally when instances leave the snapshot, when the last subscriber is removed, or on registry Destroy() (service_discovery_registry.go). Metadata RPCs no longer run under the listener state mutex (build is serialized by a separate buildMu; the state mutex only guards field access), so a slow/dead provider cannot block event processing.
  • Tombstone restart discrimination (directory.go): the closing tombstone now records the closing invoker's export timestamp. A re-add carrying the same timestamp is a stale pre-shutdown snapshot and stays vetoed (existing graceful-shutdown semantics unchanged); a different timestamp proves a genuine restart, so the tombstone is cleared and the invoker rebuilt immediately. URLs without a timestamp (older providers) keep the conservative veto.

Known limitation: export timestamp is second-precision in dubbo-go (server/action.go), so a stop+restart+export+register completing within the same second would still be vetoed (degrading to pre-fix behavior until the next registry event). This is practically unreachable — graceful stop alone takes seconds. If it ever matters, the principled follow-up is discriminating by metadata revision or aligning the timestamp to milliseconds (Java Dubbo uses System.currentTimeMillis()), which would be a separate, framework-wide discussion.

Out of scope (tracked separately): application-level AddListener failure is likewise never retried — same recovery-gap family, different layer; see #3624. Observability for unresolved revisions (e.g. a gauge) belongs to #3356.

Verification:

  • New unit tests (metadata_retry_test.go, directory_test.go): recovery without any further registry event; retries stop when the instance is removed; only the latest revision is retried; one shared timer across repeated events; timer cancelled on subscriber detach and resumed on re-attach; tombstone vetoes stale re-adds but allows genuine restarts.
  • go test ./registry/... and the listener/directory tests with -race pass; broader regression over registry/metadata/cluster/server/client/common passes.
  • E2E (docker compose: Nacos 2.5.1 + single Triple provider + consumer, application-level discovery, local metadata): with traffic to the provider blocked during its restart, the consumer failed metadata fetches as before, but self-healed seconds after unblocking without a consumer restart, followed by a 30s steady-state check with all calls succeeding. The same scenario on v3.3.2 stays at No provider available permanently.

Checklist

  • I confirm the target branch is develop
  • Code has passed local testing
  • I have added tests that prove my fix is effective or that my feature works

@AsperforMias
AsperforMias force-pushed the fix/3615-metadata-retry branch from 2883588 to 9231ce5 Compare August 9, 2026 10:05
…ailure (apache#3615)

Two stacked recovery gaps made a transient MetadataService fetch failure
permanent for application-level service discovery:

1. ServiceInstancesChangedListenerImpl skipped instances whose metadata
   fetch failed and never retried; with no further registry event the
   consumer directory stayed empty forever. Add a shared retry timer per
   listener: unresolved revisions are re-resolved by replaying the latest
   instance snapshot with capped exponential backoff (1s..30s + jitter,
   unlimited attempts). Retries stop naturally when instances leave the
   snapshot, subscribers are detached, or the registry is destroyed.
   Metadata RPCs no longer run under the listener state mutex.

2. RegistryDirectory's closing tombstone vetoed the rebuild even after a
   successful retry when the provider restarted with the same address,
   because a stale pre-shutdown snapshot and a genuine restart were
   indistinguishable. The tombstone now records the export timestamp and
   only vetoes re-adds carrying the same timestamp; a different timestamp
   proves a genuine restart and clears the tombstone.
@AsperforMias
AsperforMias force-pushed the fix/3615-metadata-retry branch from 9231ce5 to a2e3561 Compare August 9, 2026 10:25
@codecov-commenter

codecov-commenter commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.44444% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.00%. Comparing base (60d1c2a) to head (c73118e).
⚠️ Report is 947 commits behind head on develop.

Files with missing lines Patch % Lines
...try/servicediscovery/service_discovery_registry.go 81.48% 4 Missing and 1 partial ⚠️
...scovery/service_instances_changed_listener_impl.go 98.07% 1 Missing and 1 partial ⚠️
registry/directory/directory.go 92.30% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #3625      +/-   ##
===========================================
+ Coverage    46.76%   56.00%   +9.23%     
===========================================
  Files          295      475     +180     
  Lines        17172    37226   +20054     
===========================================
+ Hits          8031    20848   +12817     
- Misses        8287    14734    +6447     
- Partials       854     1644     +790     

☔ View full report in Codecov by Harness.
📢 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.

@AsperforMias

Copy link
Copy Markdown
Contributor Author

Note on CI: the Integration Test failure is in observability/integration ("Prometheus scrape targets are up"), which is unrelated to this change — the same check fails identically on two other PRs today (run 31297924714, run 31301434603). In this PR's run, the sample's RPCs, traces, and metric series all verified fine; only the Prometheus scrape-target liveness check failed. All code-level checks (Unit Test, Lint, check-fmt, license, CodeQL, RISC-V build, SonarCloud) pass.

Comment thread registry/directory/directory.go Outdated
Comment thread registry/servicediscovery/service_instances_changed_listener_impl.go Outdated
Comment thread registry/servicediscovery/service_instances_changed_listener_impl.go Outdated
- Drop the caller-less hasActiveClosingTombstone wrapper; production code
  uses activeClosingTombstone directly and the tests keep a local helper.
- Demote the per-event "received instance notification" log to debug:
  it fires on every registry push and is too noisy at info level.
- refreshServiceURLs now reports whether every revision resolved, and
  OnEvent surfaces unresolved revisions as an error so registry
  dispatchers log the partial failure instead of it staying silent.
  The retry loop is unchanged and still self-schedules.
Comment thread registry/servicediscovery/service_discovery_registry.go
A retry timer could outlive its listener in three ways:

- scheduleMetadataRetry only checked unresolvedRevisions, so a refresh
  that failed before any subscriber attached (the SubscribeURL order)
  armed the timer, and a listener discarded after losing the install
  race kept retrying — and was kept alive by the timer closure.
- RemoveListener racing with the timer callback still allowed the
  callback to refresh and re-schedule with no subscribers left.
- Destroy raced with an in-flight refresh: stopMetadataRetry canceled
  the pending timer, but the refresh's trailing scheduleMetadataRetry
  armed a new one after the registry was gone.

The listener now has a closed flag; the scheduler and the timer
callback re-check closed and subscriber presence under the mutex, and
a refresh that fails with no subscribers is re-armed by the next
AddListenerAndNotify instead of running detached. SubscribeURL closes
the listener that loses the install race.
closed covered listeners already installed when Destroy runs, but a
SubscribeURL still in its initial GetInstances/metadata phase is
invisible to Destroy's listener sweep: when the call finished after
Destroy returned, it would install the listener, attach the subscriber
and arm a fresh unlimited retry timer on a dead registry (a blocking
fetch probe shows metadata fetches continuing to grow after Destroy).

The registry now carries a destroyed flag. SubscribeURL bails early on
destroyed registries and re-checks the flag under the write lock right
before installing: a late listener is closed and discarded instead of
installed, so no AddListener call and no retry timer can appear after
Destroy.

Adds the deterministic SubscribeURL x Destroy interleaving test
requested in review (blocking fetch probe; verified to fail without
the fix: listener installed, AddListener called, fetches continue)
plus a post-Destroy subscribe no-op test.

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

lgtm

@Alanxtl

Alanxtl commented Aug 20, 2026

Copy link
Copy Markdown
Member

pls fix conflict

…a-retry

# Conflicts:
#	registry/servicediscovery/service_instances_changed_listener_impl.go
…e#3615)

SubscribeURL registers the notify listener under url.ServiceKey()+":"+protocol,
but UnSubscribe removed it with the bare url.ServiceKey(), so the last
subscriber was never detached and the metadata retry timer kept probing after
UnSubscribe. Derive both sides from a shared protocolSubscribeKey helper and
cover the public SubscribeURL -> UnSubscribe lifecycle with a regression test.
@sonarqubecloud

Copy link
Copy Markdown

@AsperforMias

Copy link
Copy Markdown
Contributor Author

pls fix conflict

done

@Alanxtl
Alanxtl merged commit 106675b into apache:develop Aug 21, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3.3.3 version 3.3.3 ☢️ Bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] 应用级 Nacos 服务发现可能在 Provider 重启后永久停留在 No provider

4 participants