This is an automated email from the ASF dual-hosted git repository.

AlexStocks pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go.git


The following commit(s) were added to refs/heads/develop by this push:
     new fc7f14f35 feat(metadata): Clarify metadata report selection for 
multi-registry and multi-instance scenarios (#3369)
fc7f14f35 is described below

commit fc7f14f35369350e8d319fe9bbd6deaa4b0d259c
Author: Nene7ko_ <[email protected]>
AuthorDate: Wed Jun 10 12:58:29 2026 +0800

    feat(metadata): Clarify metadata report selection for multi-registry and 
multi-instance scenarios (#3369)
    
    * fix(metadata): make GetMetadataReport() deterministic in multi-registry 
setup
    
    * fix(metadata): Remove() fans out to all reports, consistent with Map()
    
    * fix(metadata): thread registryId through GetMetadataFromMetadataReport 
call chain
    
    * fix(metadata): scope revision calculation to per-registry services, 
thread registryId through createInstance
    
    * fix(metadata): GetMetadataReportByRegistry returns nil for unknown 
registryId instead of silently falling back to wrong registry
    
    When a caller provides a specific non-empty registryId that has no
    registered report, the previous implementation silently fell back to
    GetMetadataReport() and returned an arbitrary report from a different
    registry. This undermined the per-registry scoping added in the earlier
    commits: metadata for registry A could be fetched from registry B's
    report without any indication.
    
    Fix: return nil (with a Warnf log) when a specific registryId is not
    found. The existing nil-guard in GetMetadataFromMetadataReport (client.go)
    already surfaces this as an error to the caller, which is the correct
    behaviour.
    
    The empty-string path is unchanged: it still delegates to
    GetMetadataReport() because callers that pass "" have no registry
    context and should receive the stable default.
    
    Also expose ClearMetadataReportInstances() to allow cross-package test
    isolation of the package-level instances map.
    
    * fix(metadata): ServiceNameMapping.Remove collects all errors instead of 
returning only the last one
    
    The previous implementation used a lastErr variable that was overwritten
    on each iteration. If the first report failed and the second succeeded,
    the error was silently discarded and the caller saw no error despite a
    partial failure — leaving a stale mapping in the first registry.
    
    Fix: collect all errors with errors.Join so the caller receives the full
    failure picture. The loop continues past individual failures (best-effort
    fan-out) so a transient error in one registry does not prevent removal
    from the others.
    
    Add a comment explaining the intentional behavioural difference from
    Map(), which is fail-fast: Map stops at the first report failure, while
    Remove continues and collects all errors so every reachable registry gets
    the removal attempt.
    
    * test: expand coverage for per-registry metadata report selection
    
    Three gaps identified in PR review, all addressed:
    
    1. metadata/client_test.go — rewrite TestGetMetadataFromMetadataReport
       - Add sub-test: specific registryId routes to its own report (not 
default)
       - Add sub-test: unknown registryId returns error, not silent 
wrong-registry result
       - Each sub-test now resets instances independently to prevent state 
leakage
    
    2. registry/servicediscovery/customizer/service_revision_customizer_test.go
       - Add TestExportedRevisionMissingRegistryIdYieldsZero: instance with no
         RegistryIdKey gets revision "0" and does not borrow another registry's
         service list
       - Add TestSubscribedRevisionMissingRegistryIdYieldsZero: same for 
subscribed
       - Add t.Cleanup(ClearMetadataReportInstances) + unique registry-id 
prefixes
         to all four tests to prevent cross-test global map pollution
    
    3. registry/servicediscovery/service_instances_changed_listener_impl_test.go
       - Add TestListenerUsesRegistryIdToFetchRemoteMetadata: creates a listener
         with registryId="remote-reg-test", registers a mock MetadataReport 
under
         that id, triggers OnEvent with a RemoteMetadataStorageType instance, 
and
         asserts via mock.AssertExpectations that the correct per-registry 
report
         was called — end-to-end proof that registryId threads from
         NewServiceInstancesChangedListener through to 
GetMetadataFromMetadataReport
    
    * chore: translate Chinese comments to English, remove UTF-8 BOM
    
    - metadata/report_instance_test.go: translate inline comments and
      testify assertion messages in TestGetMetadataReportIsDeterministic
      from Chinese to English, consistent with the rest of the file
    
    - registry/servicediscovery/customizer/service_revision_customizer.go:
      translate Customize doc-comments for both exported and subscribed
      customizers from Chinese to English
    
    - registry/servicediscovery/service_instances_changed_listener_impl_test.go:
      strip the UTF-8 BOM (EF BB BF) that was introduced at line 1;
      Go toolchain accepts BOMs but they are not idiomatic and cause
      spurious diffs in some editors and CI tools
    
    * style(metadata): fix import formatting
    
    * fix(registry): upgrade missing-registryId log from Warn to Error with 
accurate consequence
    
    The previous Warnf said 'revision will be empty', which is wrong on two
    counts: the actual computed revision is "0" (not empty), and revision
    "0" causes OnEvent to silently skip the instance entirely, making it
    permanently invisible to all consumers.
    
    Upgrade to Errorf and describe the actual outcome so operators can
    diagnose misconfigurations immediately. Applies to both exported and
    subscribed revision customizers.
    
    * fix(registry): scope metaCache key to (registryId, revision) to prevent 
cross-registry metadata poisoning
    
    The cache in GetMetadataInfo was keyed by revision string only. In a
    multi-registry setup, if two registries happen to produce the same
    revision value (e.g. identical service sets yielding the same CRC, or
    test fixtures using literal revision strings), the second registry's
    listener would get a cache hit and return MetadataInfo fetched via a
    different registry's report — silently serving wrong endpoints with no
    error surfaced.
    
    Fix: use registryId+":"+revision as the cache key for both Get and Set,
    so each registry's metadata is cached independently.
    
    Update all test helpers and direct metaCache.Set/Delete calls to use
    the same composite key (with constant.DefaultKey as the registryId for
    all existing single-registry test listeners).
    
    * docs(registry): restore TODO for multi-instance metadata service URL 
alignment
    
    The previous commit removed the TODO without fixing the underlying issue.
    GetMetadataService() still returns a global singleton, so all instances
    across registries receive the same metadata service URL regardless of
    which registry they belong to.
    
    Restore the TODO with a more detailed explanation so the gap is visible
    to future contributors. The fix requires per-registry MetadataService
    support and is out of scope for this PR.
    
    * Revert "docs(registry): restore TODO for multi-instance metadata service 
URL alignment"
    
    This reverts commit 5d7bcc6cf69f6d05646c4e61beda666fc31ad4eb.
    
    * style(metadata): fix import formatting
    
    * fix(debug) : fix review problems
    
    * style(metadata): fix format issue && revert some changes
    
    * fix(metadata): update tests and comment to match default fallback in 
GetMetadataReportByRegistry
    
    GetMetadataReportByRegistry intentionally falls back to the 'default'
    report when a specific registryId is not found. This supports standalone
    metadata-report configs registered under 'default' being used by named
    registries (e.g. nacos, zk). Update tests and doc comment to reflect
    this behavior instead of the old 'return nil for unknown id' contract.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix: address review comments on clarify-metadata-report-selection
    
    - fix(registry): unify registryId default to constant.DefaultKey before
      GetMetadataInfo lookup in RegisterService, eliminating empty-string
      fallback mismatch and redundant GetParam calls
    - fix(metadata): scope metaCache key to app:registryId:revision to
      prevent cross-registry cache collisions
    - fix(metadata/mapping): bind MappingListener to deterministic primary
      report via GetMetadataReport() instead of non-deterministic i==0
    - fix(metadata): include registryId in GetMetadataFromMetadataReport
      error message for easier misconfiguration diagnosis
    - test: add t.Cleanup to remove AddService/AddSubscribeURL entries and
      prevent global state leakage between tests
    - docs: restore TODO comment explaining GetMetadataService() singleton
      limitation in multi-registry metadata service URL alignment
    
    * fix: fix test isolation and cache key format after review changes
    
    - fix(test): update metaCache keys in 
service_instances_changed_listener_impl_test
      to match new app:registryId:revision format
    - test: use t.Name()+UnixNano as regID in service_discovery_registry_test
      to prevent cross-test global state pollution without expanding production 
API
    
    ---------
    
    Co-authored-by: xnlemon <[email protected]>
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 metadata/client.go                                 |   6 +-
 metadata/client_test.go                            |  59 ++++++-
 metadata/mapping/metadata/service_name_mapping.go  |  50 +++++-
 .../mapping/metadata/service_name_mapping_test.go  | 100 ++++++++++++
 metadata/report_instance.go                        |  42 ++++-
 metadata/report_instance_test.go                   |  56 ++++++-
 registry/nacos/service_discovery_test.go           |   2 +-
 .../metadata_service_url_params_customizer.go      |   5 +-
 .../customizer/service_revision_customizer.go      |  35 +++--
 .../customizer/service_revision_customizer_test.go | 173 +++++++++++++++++++++
 .../servicediscovery/service_discovery_registry.go |  22 +--
 .../service_discovery_registry_test.go             |  16 +-
 .../service_instances_changed_listener_impl.go     |  18 ++-
 ...service_instances_changed_listener_impl_test.go | 129 +++++++++++++--
 14 files changed, 642 insertions(+), 71 deletions(-)

diff --git a/metadata/client.go b/metadata/client.go
index 7d49e92ef..f6b1ffd33 100644
--- a/metadata/client.go
+++ b/metadata/client.go
@@ -42,10 +42,10 @@ import (
 
 const defaultTimeout = "5s" // s
 
-func GetMetadataFromMetadataReport(revision string, instance 
registry.ServiceInstance) (*info.MetadataInfo, error) {
-       report := GetMetadataReport()
+func GetMetadataFromMetadataReport(revision string, instance 
registry.ServiceInstance, registryId string) (*info.MetadataInfo, error) {
+       report := GetMetadataReportByRegistry(registryId)
        if report == nil {
-               return nil, perrors.New("no metadata report instance 
found,please check ")
+               return nil, perrors.Errorf("no metadata report instance found 
for registryId=%s, please check metadata-report configuration", registryId)
        }
        return report.GetAppMetadata(instance.GetServiceName(), revision)
 }
diff --git a/metadata/client_test.go b/metadata/client_test.go
index 2f065f7b4..e5de35c97 100644
--- a/metadata/client_test.go
+++ b/metadata/client_test.go
@@ -35,6 +35,7 @@ import (
        "dubbo.apache.org/dubbo-go/v3/common/constant"
        "dubbo.apache.org/dubbo-go/v3/common/extension"
        "dubbo.apache.org/dubbo-go/v3/metadata/info"
+       "dubbo.apache.org/dubbo-go/v3/metadata/report"
        "dubbo.apache.org/dubbo-go/v3/protocol/base"
        "dubbo.apache.org/dubbo-go/v3/protocol/result"
        _ "dubbo.apache.org/dubbo-go/v3/proxy/proxy_factory"
@@ -62,22 +63,64 @@ var (
 )
 
 func TestGetMetadataFromMetadataReport(t *testing.T) {
+       t.Cleanup(func() { instances = make(map[string]report.MetadataReport) })
+
        t.Run("no report instance", func(t *testing.T) {
-               _, err := GetMetadataFromMetadataReport("1", ins)
+               instances = make(map[string]report.MetadataReport)
+               _, err := GetMetadataFromMetadataReport("1", ins, "default")
                require.Error(t, err)
        })
-       mockReport := new(mockMetadataReport)
-       defer mockReport.AssertExpectations(t)
-       instances["default"] = mockReport
-       t.Run("normal", func(t *testing.T) {
+
+       t.Run("default registry routes to default report", func(t *testing.T) {
+               instances = make(map[string]report.MetadataReport)
+               mockReport := new(mockMetadataReport)
+               defer mockReport.AssertExpectations(t)
+               instances["default"] = mockReport
+
                mockReport.On("GetAppMetadata").Return(metadataInfo, nil).Once()
-               got, err := GetMetadataFromMetadataReport("1", ins)
+               got, err := GetMetadataFromMetadataReport("1", ins, "default")
+               require.NoError(t, err)
+               assert.Equal(t, metadataInfo, got)
+       })
+
+       t.Run("specific registryId routes to its own report", func(t 
*testing.T) {
+               instances = make(map[string]report.MetadataReport)
+               defaultReport := new(mockMetadataReport)
+               specificReport := new(mockMetadataReport)
+               defer defaultReport.AssertExpectations(t)
+               defer specificReport.AssertExpectations(t)
+               instances["default"] = defaultReport
+               instances["reg-a"] = specificReport
+
+               // specificReport must be called; defaultReport must NOT be 
called
+               specificReport.On("GetAppMetadata").Return(metadataInfo, 
nil).Once()
+               got, err := GetMetadataFromMetadataReport("1", ins, "reg-a")
+               require.NoError(t, err)
+               assert.Equal(t, metadataInfo, got)
+       })
+
+       t.Run("unknown registryId falls back to default report", func(t 
*testing.T) {
+               instances = make(map[string]report.MetadataReport)
+               defaultReport := new(mockMetadataReport)
+               defer defaultReport.AssertExpectations(t)
+               instances["default"] = defaultReport
+
+               // When the specific registryId is not found, it falls back to 
"default"
+               // so the default report's GetAppMetadata is called
+               defaultReport.On("GetAppMetadata").Return(metadataInfo, 
nil).Once()
+               got, err := GetMetadataFromMetadataReport("1", ins, 
"nonexistent-registry")
                require.NoError(t, err)
                assert.Equal(t, metadataInfo, got)
        })
-       t.Run("error", func(t *testing.T) {
+
+       t.Run("report error propagated", func(t *testing.T) {
+               instances = make(map[string]report.MetadataReport)
+               mockReport := new(mockMetadataReport)
+               defer mockReport.AssertExpectations(t)
+               instances["default"] = mockReport
+
                mockReport.On("GetAppMetadata").Return(metadataInfo, 
errors.New("mock error")).Once()
-               _, err := GetMetadataFromMetadataReport("1", ins)
+               _, err := GetMetadataFromMetadataReport("1", ins, "default")
                require.Error(t, err)
        })
 }
diff --git a/metadata/mapping/metadata/service_name_mapping.go 
b/metadata/mapping/metadata/service_name_mapping.go
index 717864da5..c0f730ce7 100644
--- a/metadata/mapping/metadata/service_name_mapping.go
+++ b/metadata/mapping/metadata/service_name_mapping.go
@@ -119,18 +119,56 @@ func backoff(attempt int) time.Duration {
 // Get will return the application-level services. If not found, the empty set 
will be returned.
 func (d *ServiceNameMapping) Get(url *common.URL, listener 
mapping.MappingListener) (*gxset.HashSet, error) {
        serviceInterface := url.GetParam(constant.InterfaceKey, "")
-       metadataReport := metadata.GetMetadataReport()
-       if metadataReport == nil {
+       metadataReports := metadata.GetMetadataReports()
+       if len(metadataReports) == 0 {
                return nil, perrors.New("can not get mapping in remote cause no 
metadata report instance found")
        }
-       return metadataReport.GetServiceAppMapping(serviceInterface, 
DefaultGroup, listener)
+       // Attach the listener to the stable primary report only 
(GetMetadataReport uses
+       // a deterministic selection: prefer "default", otherwise lexicographic 
first).
+       // GetMetadataReports() iterates a map so its order is 
non-deterministic; using
+       // i==0 as the anchor would bind the listener to a random backend each 
run.
+       primaryReport := metadata.GetMetadataReport()
+       var result *gxset.HashSet
+       var errs []error
+       for _, metadataReport := range metadataReports {
+               var reportListener mapping.MappingListener
+               if metadataReport == primaryReport {
+                       reportListener = listener
+               }
+               set, err := 
metadataReport.GetServiceAppMapping(serviceInterface, DefaultGroup, 
reportListener)
+               if err != nil {
+                       errs = append(errs, err)
+                       continue
+               }
+               if result == nil {
+                       result = set
+               } else {
+                       result.Add(set.Values()...)
+               }
+       }
+       if result == nil {
+               return nil, errors.Join(errs...)
+       }
+       return result, nil
 }
 
+// Remove removes the service-to-app mapping for the given URL from all
+// registered metadata reports. Unlike Map (which stops on the first failure),
+// Remove is best-effort: it attempts every report and returns all errors
+// joined together so the caller can see the full failure picture. The
+// intent is to avoid leaving stale entries in any registry due to a transient
+// error in one of the others.
 func (d *ServiceNameMapping) Remove(url *common.URL) error {
        serviceInterface := url.GetParam(constant.InterfaceKey, "")
-       metadataReport := metadata.GetMetadataReport()
-       if metadataReport == nil {
+       metadataReports := metadata.GetMetadataReports()
+       if len(metadataReports) == 0 {
                return perrors.New("can not remove mapping in remote cause no 
metadata report instance found")
        }
-       return metadataReport.RemoveServiceAppMappingListener(serviceInterface, 
DefaultGroup)
+       var errs []error
+       for _, metadataReport := range metadataReports {
+               if err := 
metadataReport.RemoveServiceAppMappingListener(serviceInterface, DefaultGroup); 
err != nil {
+                       errs = append(errs, err)
+               }
+       }
+       return errors.Join(errs...)
 }
diff --git a/metadata/mapping/metadata/service_name_mapping_test.go 
b/metadata/mapping/metadata/service_name_mapping_test.go
index c279ff4c0..fb40bc56d 100644
--- a/metadata/mapping/metadata/service_name_mapping_test.go
+++ b/metadata/mapping/metadata/service_name_mapping_test.go
@@ -19,6 +19,7 @@ package metadata
 
 import (
        "errors"
+       "sync"
        "testing"
 )
 
@@ -147,6 +148,105 @@ func initMock() (*mockMetadataReport, error) {
        return metadataReport, err
 }
 
+func initMockWithId(t *testing.T, registryId string) *mockMetadataReport {
+       t.Helper()
+       mockReport := new(mockMetadataReport)
+       extension.SetMetadataReportFactory(registryId, func() 
report.MetadataReportFactory {
+               return mockReport
+       })
+       opts := metadata.NewReportOptions(
+               metadata.WithRegistryId(registryId),
+               metadata.WithProtocol(registryId),
+               metadata.WithAddress("127.0.0.1"),
+       )
+       require.NoError(t, opts.Init())
+       return mockReport
+}
+
+func TestServiceNameMappingRemoveFansOutToAllReports(t *testing.T) {
+       metadata.ClearMetadataReportInstances()
+       t.Cleanup(metadata.ClearMetadataReportInstances)
+       serviceNameMappingOnce = sync.Once{}
+       serviceNameMappingInstance = nil
+
+       r1 := initMockWithId(t, "reg-a")
+       r2 := initMockWithId(t, "reg-b")
+
+       ins := GetNameMappingInstance()
+       serviceUrl := common.NewURLWithOptions(
+               common.WithInterface("org.example.FooService"),
+               common.WithParamsValue(constant.ApplicationKey, "foo-app"),
+       )
+
+       r1.On("RemoveServiceAppMappingListener").Return(nil).Once()
+       r2.On("RemoveServiceAppMappingListener").Return(nil).Once()
+
+       err := ins.Remove(serviceUrl)
+       require.NoError(t, err)
+       r1.AssertExpectations(t)
+       r2.AssertExpectations(t)
+}
+
+func TestServiceNameMappingRemoveCollectsAllErrors(t *testing.T) {
+       metadata.ClearMetadataReportInstances()
+       t.Cleanup(metadata.ClearMetadataReportInstances)
+       serviceNameMappingOnce = sync.Once{}
+       serviceNameMappingInstance = nil
+
+       r1 := initMockWithId(t, "reg-c")
+       r2 := initMockWithId(t, "reg-d")
+
+       ins := GetNameMappingInstance()
+       serviceUrl := common.NewURLWithOptions(
+               common.WithInterface("org.example.BarService"),
+               common.WithParamsValue(constant.ApplicationKey, "bar-app"),
+       )
+
+       err1 := errors.New("r1 failure")
+       err2 := errors.New("r2 failure")
+
+       // both reports fail
+       r1.On("RemoveServiceAppMappingListener").Return(err1).Once()
+       r2.On("RemoveServiceAppMappingListener").Return(err2).Once()
+
+       err := ins.Remove(serviceUrl)
+       require.Error(t, err)
+       // both individual errors must be present in the returned error
+       require.ErrorIs(t, err, err1)
+       require.ErrorIs(t, err, err2)
+       r1.AssertExpectations(t)
+       r2.AssertExpectations(t)
+}
+
+func TestServiceNameMappingRemoveContinuesAfterPartialFailure(t *testing.T) {
+       metadata.ClearMetadataReportInstances()
+       t.Cleanup(metadata.ClearMetadataReportInstances)
+       serviceNameMappingOnce = sync.Once{}
+       serviceNameMappingInstance = nil
+
+       r1 := initMockWithId(t, "reg-e")
+       r2 := initMockWithId(t, "reg-f")
+
+       ins := GetNameMappingInstance()
+       serviceUrl := common.NewURLWithOptions(
+               common.WithInterface("org.example.BazService"),
+               common.WithParamsValue(constant.ApplicationKey, "baz-app"),
+       )
+
+       removeErr := errors.New("r1 partial failure")
+
+       // r1 fails, r2 succeeds — the loop must not short-circuit
+       r1.On("RemoveServiceAppMappingListener").Return(removeErr).Once()
+       r2.On("RemoveServiceAppMappingListener").Return(nil).Once()
+
+       err := ins.Remove(serviceUrl)
+       // the joined error only contains r1's error; the call should error
+       require.ErrorIs(t, err, removeErr)
+       // both reports must have been called despite r1's failure
+       r1.AssertExpectations(t)
+       r2.AssertExpectations(t)
+}
+
 type listener struct {
 }
 
diff --git a/metadata/report_instance.go b/metadata/report_instance.go
index f1157ca18..55cf516df 100644
--- a/metadata/report_instance.go
+++ b/metadata/report_instance.go
@@ -18,11 +18,12 @@
 package metadata
 
 import (
+       "sort"
        "time"
 )
 
 import (
-       "github.com/dubbogo/gost/container/set"
+       gxset "github.com/dubbogo/gost/container/set"
        "github.com/dubbogo/gost/log/logger"
 )
 
@@ -41,6 +42,12 @@ var (
        instances = make(map[string]report.MetadataReport)
 )
 
+// ClearMetadataReportInstances resets the package-level instances map.
+// Intended for test isolation only; do not call in production code.
+func ClearMetadataReportInstances() {
+       instances = make(map[string]report.MetadataReport)
+}
+
 func addMetadataReport(registryId string, url *common.URL) error {
        fac := extension.GetMetadataReportFactory(url.Protocol)
        if fac == nil {
@@ -51,21 +58,46 @@ func addMetadataReport(registryId string, url *common.URL) 
error {
        return nil
 }
 
+// GetMetadataReport returns a single metadata report for callers that lack
+// registry context. It prefers the "default" registry's report; when absent
+// it falls back to the lexicographically first registry id so the selection
+// is always stable across calls.
 func GetMetadataReport() report.MetadataReport {
-       for _, v := range instances {
-               return v
+       if r, ok := instances[constant.DefaultKey]; ok {
+               return r
+       }
+       keys := make([]string, 0, len(instances))
+       for k := range instances {
+               keys = append(keys, k)
+       }
+       sort.Strings(keys)
+       if len(keys) > 0 {
+               return instances[keys[0]]
        }
        return nil
 }
 
+// GetMetadataReportByRegistry returns the metadata report bound to the given
+// registry id. When the registry id is empty the caller has no registry 
context,
+// so the stable default returned by GetMetadataReport is used. When a specific
+// (non-empty) registry id is not found, it falls back to the "default" report
+// if one exists. This handles the common case where a standalone 
metadata-report
+// config is registered under "default" while named registries (e.g. nacos, zk)
+// need to use it. nil is returned only when neither the specific id nor 
"default"
+// is registered.
 func GetMetadataReportByRegistry(registry string) report.MetadataReport {
        if len(registry) == 0 {
-               registry = constant.DefaultKey
+               return GetMetadataReport()
        }
        if r, ok := instances[registry]; ok {
                return r
        }
-       return GetMetadataReport()
+       if r, ok := instances[constant.DefaultKey]; ok {
+               logger.Infof("[Metadata] no metadata report bound to 
registryId=%s, falling back to default", registry)
+               return r
+       }
+       logger.Warnf("[Metadata] no metadata report found for registryId=%s", 
registry)
+       return nil
 }
 
 func GetMetadataReports() []report.MetadataReport {
diff --git a/metadata/report_instance_test.go b/metadata/report_instance_test.go
index 88a631a54..dfe31b78c 100644
--- a/metadata/report_instance_test.go
+++ b/metadata/report_instance_test.go
@@ -176,13 +176,61 @@ func TestGetMetadataReport(t *testing.T) {
        assert.NotNil(t, GetMetadataReport())
 }
 
+func TestGetMetadataReportIsDeterministic(t *testing.T) {
+       instances = make(map[string]report.MetadataReport)
+       r1 := new(mockMetadataReport)
+       r2 := new(mockMetadataReport)
+       // "aaa" sorts before "zzz" alphabetically, neither is "default"
+       instances["zzz"] = r1
+       instances["aaa"] = r2
+
+       // without a "default" key, must always return the alphabetically first 
entry (r2)
+       for range 20 {
+               got := GetMetadataReport()
+               assert.Equal(t, r2, got, "expected the report for 'aaa'")
+       }
+
+       // when the "default" key exists, must always return it (r1), taking 
priority over alphabetical order
+       instances[constant.DefaultKey] = r1
+       for range 20 {
+               got := GetMetadataReport()
+               assert.Equal(t, r1, got, "expected the report for 'default'")
+       }
+}
+
 func TestGetMetadataReportByRegistry(t *testing.T) {
        instances = make(map[string]report.MetadataReport)
+       // nothing registered: all paths return nil
+       assert.Nil(t, GetMetadataReportByRegistry(""))
        assert.Nil(t, GetMetadataReportByRegistry("reg"))
-       instances["default"] = new(mockMetadataReport)
-       assert.NotNil(t, GetMetadataReportByRegistry("default"))
-       assert.NotNil(t, GetMetadataReportByRegistry("reg"))
-       assert.NotNil(t, GetMetadataReportByRegistry(""))
+
+       defaultReport := new(mockMetadataReport)
+       instances["default"] = defaultReport
+
+       // exact hit
+       assert.Equal(t, defaultReport, GetMetadataReportByRegistry("default"))
+       // empty string → no registry context → falls through to 
GetMetadataReport() → "default"
+       assert.Equal(t, defaultReport, GetMetadataReportByRegistry(""))
+       // specific but unknown id → falls back to "default"
+       assert.Equal(t, defaultReport, GetMetadataReportByRegistry("reg"))
+}
+
+func TestGetMetadataReportByRegistryFallsBackDeterministically(t *testing.T) {
+       instances = make(map[string]report.MetadataReport)
+       rA := new(mockMetadataReport)
+       rB := new(mockMetadataReport)
+       instances["aaa"] = rA // lex-first
+       instances["zzz"] = rB
+
+       // known key → exact report
+       assert.Equal(t, rA, GetMetadataReportByRegistry("aaa"))
+       assert.Equal(t, rB, GetMetadataReportByRegistry("zzz"))
+
+       // unknown specific id → nil when no "default" is registered
+       assert.Nil(t, GetMetadataReportByRegistry("unknown-registry"))
+
+       // empty string → falls through to GetMetadataReport() → lex-first 
("aaa" → rA)
+       assert.Equal(t, rA, GetMetadataReportByRegistry(""))
 }
 
 func TestGetMetadataReports(t *testing.T) {
diff --git a/registry/nacos/service_discovery_test.go 
b/registry/nacos/service_discovery_test.go
index 1c068a2bc..5ee5ff201 100644
--- a/registry/nacos/service_discovery_test.go
+++ b/registry/nacos/service_discovery_test.go
@@ -150,7 +150,7 @@ func TestFunction(t *testing.T) {
        hs := gxset.NewSet()
        hs.Add(testName)
 
-       sicl := servicediscovery.NewServiceInstancesChangedListener("test_app", 
hs)
+       sicl := servicediscovery.NewServiceInstancesChangedListener("test_app", 
constant.DefaultKey, hs)
        sicl.AddListenerAndNotify(testName, tn)
        err = sd.AddListener(sicl)
        require.NoError(t, err)
diff --git 
a/registry/servicediscovery/customizer/metadata_service_url_params_customizer.go
 
b/registry/servicediscovery/customizer/metadata_service_url_params_customizer.go
index eee5645af..45e558d06 100644
--- 
a/registry/servicediscovery/customizer/metadata_service_url_params_customizer.go
+++ 
b/registry/servicediscovery/customizer/metadata_service_url_params_customizer.go
@@ -56,7 +56,10 @@ func (m *metadataServiceURLParamsMetadataCustomizer) 
GetPriority() int {
 }
 
 func (m *metadataServiceURLParamsMetadataCustomizer) Customize(instance 
registry.ServiceInstance) {
-       //todo Multi-instance metadata alignment needs to be improved
+       // TODO: GetMetadataService() is a global singleton and returns the 
same metadata service URL
+       // regardless of which registry this instance belongs to. In a 
multi-registry setup each
+       // registry should expose its own metadata service URL. This requires 
per-registry metadata
+       // service tracking and is left for a follow-up.
        url, _ := metadata.GetMetadataService().GetMetadataServiceURL()
        if url == nil {
                // when metadata service is not exported the url will be 
nil,this is because metadata type is remote
diff --git 
a/registry/servicediscovery/customizer/service_revision_customizer.go 
b/registry/servicediscovery/customizer/service_revision_customizer.go
index a9598d997..e3b220da7 100644
--- a/registry/servicediscovery/customizer/service_revision_customizer.go
+++ b/registry/servicediscovery/customizer/service_revision_customizer.go
@@ -49,12 +49,20 @@ func (e *exportedServicesRevisionMetadataCustomizer) 
GetPriority() int {
        return 1
 }
 
-// Customize calculate the revision for exported urls and then put it into 
instance metadata
+// Customize calculates the revision of exported services scoped to the 
registry,
+// preventing different instances from getting the same revision due to a 
merged cross-registry service list in multi-registry setups.
 func (e *exportedServicesRevisionMetadataCustomizer) Customize(instance 
registry.ServiceInstance) {
-       urls, err := metadata.GetMetadataService().GetExportedServiceURLs()
-       if err != nil {
-               logger.Errorf("[Registry][ServiceDiscovery] get metadata 
service url is error, err=%v", err)
-               return
+       registryId := instance.GetMetadata()[constant.RegistryIdKey]
+       if len(registryId) == 0 {
+               // revision will be "0" (no services found for empty key), 
which causes OnEvent to skip
+               // this instance entirely — ensure RegistryIdKey is set before 
customizers run.
+               logger.Errorf("[Registry][ServiceDiscovery] instance has no 
registryId in metadata; " +
+                       "exported revision will be \"0\" and this instance will 
be invisible to consumers")
+       }
+       metaInfo := metadata.GetMetadataInfo(registryId)
+       var urls []*common.URL
+       if metaInfo != nil {
+               urls = metaInfo.GetExportedServiceURLs()
        }
        revision := resolveRevision(urls)
        if len(revision) == 0 {
@@ -70,12 +78,19 @@ func (e *subscribedServicesRevisionMetadataCustomizer) 
GetPriority() int {
        return 2
 }
 
-// Customize calculate the revision for subscribed urls and then put it into 
instance metadata
+// Customize calculates the revision of subscribed services scoped to the 
registry.
 func (e *subscribedServicesRevisionMetadataCustomizer) Customize(instance 
registry.ServiceInstance) {
-       urls, err := metadata.GetMetadataService().GetSubscribedURLs()
-       if err != nil {
-               logger.Errorf("[Registry][ServiceDiscovery] get metadata 
subscribed url is error, err=%v", err)
-               return
+       registryId := instance.GetMetadata()[constant.RegistryIdKey]
+       if len(registryId) == 0 {
+               // revision will be "0" (no subscriptions found for empty key), 
which causes OnEvent to skip
+               // this instance entirely — ensure RegistryIdKey is set before 
customizers run.
+               logger.Errorf("[Registry][ServiceDiscovery] instance has no 
registryId in metadata; " +
+                       "subscribed revision will be \"0\" and this instance 
will be invisible to consumers")
+       }
+       metaInfo := metadata.GetMetadataInfo(registryId)
+       var urls []*common.URL
+       if metaInfo != nil {
+               urls = metaInfo.GetSubscribedURLs()
        }
        revision := resolveRevision(urls)
        if len(revision) == 0 {
diff --git 
a/registry/servicediscovery/customizer/service_revision_customizer_test.go 
b/registry/servicediscovery/customizer/service_revision_customizer_test.go
new file mode 100644
index 000000000..8ecaa763e
--- /dev/null
+++ b/registry/servicediscovery/customizer/service_revision_customizer_test.go
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package customizer
+
+import (
+       "testing"
+)
+
+import (
+       "github.com/stretchr/testify/assert"
+)
+
+import (
+       "dubbo.apache.org/dubbo-go/v3/common"
+       "dubbo.apache.org/dubbo-go/v3/common/constant"
+       "dubbo.apache.org/dubbo-go/v3/metadata"
+       "dubbo.apache.org/dubbo-go/v3/registry"
+)
+
+// TestExportedRevisionIsRegistryScoped verifies that when two registries 
export different
+// service sets, their instances get different revision values — not a merged 
cross-registry one.
+func TestExportedRevisionIsRegistryScoped(t *testing.T) {
+       metadata.ClearMetadataReportInstances()
+       t.Cleanup(func() {
+               metadata.ClearMetadataReportInstances()
+       })
+
+       urlA := common.NewURLWithOptions(
+               common.WithInterface("org.example.ServiceA"),
+               common.WithParamsValue(constant.ApplicationKey, "app"),
+               common.WithPort("20880"),
+       )
+       urlB := common.NewURLWithOptions(
+               common.WithInterface("org.example.ServiceB"),
+               common.WithParamsValue(constant.ApplicationKey, "app"),
+               common.WithPort("20881"),
+       )
+
+       metadata.AddService("rev-reg-a", urlA)
+       metadata.AddService("rev-reg-b", urlB)
+       t.Cleanup(func() {
+               metadata.RemoveService("rev-reg-a", urlA)
+               metadata.RemoveService("rev-reg-b", urlB)
+       })
+
+       cus := &exportedServicesRevisionMetadataCustomizer{}
+
+       instA := &registry.DefaultServiceInstance{
+               Metadata: map[string]string{constant.RegistryIdKey: 
"rev-reg-a"},
+       }
+       cus.Customize(instA)
+       revA := 
instA.GetMetadata()[constant.ExportedServicesRevisionPropertyName]
+
+       instB := &registry.DefaultServiceInstance{
+               Metadata: map[string]string{constant.RegistryIdKey: 
"rev-reg-b"},
+       }
+       cus.Customize(instB)
+       revB := 
instB.GetMetadata()[constant.ExportedServicesRevisionPropertyName]
+
+       assert.NotEqual(t, revA, revB, "different registries with different 
services should produce different revisions")
+       assert.NotEqual(t, "0", revA, "reg-a has a service, revision should not 
be 0")
+       assert.NotEqual(t, "0", revB, "reg-b has a service, revision should not 
be 0")
+}
+
+// TestExportedRevisionMissingRegistryIdYieldsZero verifies that an instance 
with no
+// RegistryIdKey gets revision "0" (no services found), and does not panic or 
use
+// another registry's service list.
+func TestExportedRevisionMissingRegistryIdYieldsZero(t *testing.T) {
+       metadata.ClearMetadataReportInstances()
+       t.Cleanup(metadata.ClearMetadataReportInstances)
+
+       // Register a service under a real registry so we can confirm it is NOT 
used
+       urlA := common.NewURLWithOptions(
+               common.WithInterface("org.example.ShouldBeIsolated"),
+               common.WithParamsValue(constant.ApplicationKey, "app"),
+               common.WithPort("20880"),
+       )
+       metadata.AddService("some-registry", urlA)
+       t.Cleanup(func() {
+               metadata.RemoveService("some-registry", urlA)
+       })
+
+       cus := &exportedServicesRevisionMetadataCustomizer{}
+
+       inst := &registry.DefaultServiceInstance{
+               Metadata: map[string]string{}, // no RegistryIdKey
+       }
+       cus.Customize(inst)
+       rev := inst.GetMetadata()[constant.ExportedServicesRevisionPropertyName]
+
+       // GetMetadataInfo("") returns nil → resolveRevision(nil) == "0"
+       assert.Equal(t, "0", rev, "instance with no registryId should get 
revision 0, not borrow another registry's service list")
+}
+
+// TestSubscribedRevisionIsRegistryScoped mirrors the exported test for 
subscribed URLs.
+func TestSubscribedRevisionIsRegistryScoped(t *testing.T) {
+       metadata.ClearMetadataReportInstances()
+       t.Cleanup(metadata.ClearMetadataReportInstances)
+
+       urlA := common.NewURLWithOptions(
+               common.WithInterface("org.example.SubA"),
+               common.WithParamsValue(constant.ApplicationKey, "app"),
+               common.WithPort("20880"),
+       )
+       urlB := common.NewURLWithOptions(
+               common.WithInterface("org.example.SubB"),
+               common.WithParamsValue(constant.ApplicationKey, "app"),
+               common.WithPort("20881"),
+       )
+
+       metadata.AddSubscribeURL("sub-reg-a", urlA)
+       metadata.AddSubscribeURL("sub-reg-b", urlB)
+       t.Cleanup(func() {
+               metadata.RemoveSubscribeURL("sub-reg-a", urlA)
+               metadata.RemoveSubscribeURL("sub-reg-b", urlB)
+       })
+
+       cus := &subscribedServicesRevisionMetadataCustomizer{}
+
+       instA := &registry.DefaultServiceInstance{
+               Metadata: map[string]string{constant.RegistryIdKey: 
"sub-reg-a"},
+       }
+       cus.Customize(instA)
+       revA := 
instA.GetMetadata()[constant.SubscribedServicesRevisionPropertyName]
+
+       instB := &registry.DefaultServiceInstance{
+               Metadata: map[string]string{constant.RegistryIdKey: 
"sub-reg-b"},
+       }
+       cus.Customize(instB)
+       revB := 
instB.GetMetadata()[constant.SubscribedServicesRevisionPropertyName]
+
+       assert.NotEqual(t, revA, revB, "different registries with different 
subscriptions should produce different revisions")
+}
+
+// TestSubscribedRevisionMissingRegistryIdYieldsZero mirrors the exported 
missing-key test.
+func TestSubscribedRevisionMissingRegistryIdYieldsZero(t *testing.T) {
+       metadata.ClearMetadataReportInstances()
+       t.Cleanup(metadata.ClearMetadataReportInstances)
+
+       urlA := common.NewURLWithOptions(
+               common.WithInterface("org.example.SubIsolated"),
+               common.WithParamsValue(constant.ApplicationKey, "app"),
+               common.WithPort("20880"),
+       )
+       metadata.AddSubscribeURL("some-registry", urlA)
+       t.Cleanup(func() {
+               metadata.RemoveSubscribeURL("some-registry", urlA)
+       })
+
+       cus := &subscribedServicesRevisionMetadataCustomizer{}
+       inst := &registry.DefaultServiceInstance{
+               Metadata: map[string]string{},
+       }
+       cus.Customize(inst)
+       rev := 
inst.GetMetadata()[constant.SubscribedServicesRevisionPropertyName]
+
+       assert.Equal(t, "0", rev, "instance with no registryId should get 
revision 0, not borrow another registry's subscriptions")
+}
diff --git a/registry/servicediscovery/service_discovery_registry.go 
b/registry/servicediscovery/service_discovery_registry.go
index 6d07dd318..e01d42154 100644
--- a/registry/servicediscovery/service_discovery_registry.go
+++ b/registry/servicediscovery/service_discovery_registry.go
@@ -86,13 +86,14 @@ func newServiceDiscoveryRegistry(url *common.URL) 
(registry.Registry, error) {
 }
 
 func (s *serviceDiscoveryRegistry) RegisterService() error {
-       metaInfo := 
metadata.GetMetadataInfo(s.url.GetParam(constant.RegistryIdKey, ""))
+       registryId := s.url.GetParam(constant.RegistryIdKey, 
constant.DefaultKey)
+       metaInfo := metadata.GetMetadataInfo(registryId)
        if metaInfo == nil {
-               panic("no metada info found of registry id " + 
s.url.GetParam(constant.RegistryIdKey, ""))
+               panic("no metada info found of registry id " + registryId)
        }
        urls := metaInfo.GetExportedServiceURLs()
        for _, url := range urls {
-               instance := createInstance(metaInfo, url)
+               instance := createInstance(metaInfo, url, registryId)
                metaInfo.Revision = 
instance.GetMetadata()[constant.ExportedServicesRevisionPropertyName]
                if metadata.GetMetadataType() == 
constant.RemoteMetadataStorageType {
                        if s.metadataReport == nil {
@@ -115,9 +116,12 @@ func (s *serviceDiscoveryRegistry) RegisterService() error 
{
        return nil
 }
 
-func createInstance(meta *info.MetadataInfo, url *common.URL) 
registry.ServiceInstance {
+func createInstance(meta *info.MetadataInfo, url *common.URL, registryId 
string) registry.ServiceInstance {
        params := make(map[string]string, 8)
        params[constant.MetadataStorageTypePropertyName] = 
metadata.GetMetadataType()
+       // Expose the registry this instance belongs to so that customizers 
(e.g. revision
+       // calculators) can scope their work to the correct per-registry 
service set.
+       params[constant.RegistryIdKey] = registryId
        // Keep routing attributes visible on the registered instance as well 
as in service metadata.
        if environment := url.GetParam(constant.EnvironmentKey, ""); 
len(environment) > 0 {
                params[constant.EnvironmentKey] = environment
@@ -212,11 +216,11 @@ func (s *serviceDiscoveryRegistry) UnSubscribe(url 
*common.URL, listener registr
 }
 
 func (s *serviceDiscoveryRegistry) 
syncExportedMetadataAfterUnregister(targetURL *common.URL, origin 
[]registry.ServiceInstance, keep []registry.ServiceInstance) error {
-       registryID, exist := s.url.GetNonDefaultParam(constant.RegistryIdKey)
+       registryId, exist := s.url.GetNonDefaultParam(constant.RegistryIdKey)
        if !exist {
                return nil
        }
-       metadataInfo := metadata.GetMetadataInfo(registryID)
+       metadataInfo := metadata.GetMetadataInfo(registryId)
        if metadataInfo == nil {
                return nil
        }
@@ -225,14 +229,14 @@ func (s *serviceDiscoveryRegistry) 
syncExportedMetadataAfterUnregister(targetURL
        if len(origin) > 0 {
                metadataInfo.ReplaceExportedServices(keepURLs)
        } else if targetURL != nil {
-               metadata.RemoveService(registryID, targetURL)
+               metadata.RemoveService(registryId, targetURL)
        }
        remainingURLs := metadataInfo.GetExportedServiceURLs()
        if len(remainingURLs) == 0 {
                metadataInfo.Revision = "0"
                return nil
        }
-       instance := createInstance(metadataInfo, remainingURLs[0])
+       instance := createInstance(metadataInfo, remainingURLs[0], registryId)
        revision := 
instance.GetMetadata()[constant.ExportedServicesRevisionPropertyName]
        metadataInfo.Revision = revision
        if len(keepURLs) == 0 {
@@ -360,7 +364,7 @@ func (s *serviceDiscoveryRegistry) SubscribeURL(url 
*common.URL, notify registry
        protocolServiceKey := url.ServiceKey() + ":" + protocol
        listener := s.serviceListeners[serviceNamesKey]
        if listener == nil {
-               listener = 
NewServiceInstancesChangedListener(url.GetParam(constant.ApplicationKey, ""), 
services)
+               listener = 
NewServiceInstancesChangedListener(url.GetParam(constant.ApplicationKey, ""), 
s.url.GetParam(constant.RegistryIdKey, constant.DefaultKey), services)
                for _, serviceNameTmp := range services.Values() {
                        serviceName := serviceNameTmp.(string)
                        instances := 
s.serviceDiscovery.GetInstances(serviceName)
diff --git a/registry/servicediscovery/service_discovery_registry_test.go 
b/registry/servicediscovery/service_discovery_registry_test.go
index 46f295705..77c83b7d6 100644
--- a/registry/servicediscovery/service_discovery_registry_test.go
+++ b/registry/servicediscovery/service_discovery_registry_test.go
@@ -57,7 +57,7 @@ const (
 // TestServiceDiscoveryRegistryRegister verifies the registration process.
 func TestServiceDiscoveryRegistryRegister(t *testing.T) {
        mockSD, mockMapping := setupEnvironment(t)
-       regID := fmt.Sprintf("mock-reg-%d", time.Now().UnixNano())
+       regID := fmt.Sprintf("mock-reg-%s-%d", t.Name(), time.Now().UnixNano())
 
        registryURL, err := common.NewURL(testRegistryURL,
                common.WithParamsValue(constant.RegistryKey, "mock"),
@@ -127,7 +127,7 @@ func TestServiceDiscoveryRegistrySubscribe(t *testing.T) {
 func TestServiceDiscoveryRegistryUnSubscribe(t *testing.T) {
        mockSD, mockMapping := setupEnvironment(t)
        mockMapping.data[testInterface] = gxset.NewSet(testApp)
-       regID := fmt.Sprintf("mock-reg-%d", time.Now().UnixNano())
+       regID := fmt.Sprintf("mock-reg-%s-%d", t.Name(), time.Now().UnixNano())
 
        registryURL, _ := common.NewURL(testRegistryURL,
                common.WithParamsValue(constant.RegistryKey, "mock"),
@@ -159,7 +159,7 @@ func 
TestServiceDiscoveryRegistryUnSubscribeKeepsMetadataOnRemoveFailure(t *test
        mockSD, mockMapping := setupEnvironment(t)
        mockMapping.data[testInterface] = gxset.NewSet(testApp)
        mockMapping.removeErr = errors.New("mock remove failed")
-       regID := fmt.Sprintf("mock-reg-%d", time.Now().UnixNano())
+       regID := fmt.Sprintf("mock-reg-%s-%d", t.Name(), time.Now().UnixNano())
 
        registryURL, _ := common.NewURL(testRegistryURL,
                common.WithParamsValue(constant.RegistryKey, "mock"),
@@ -188,7 +188,7 @@ func 
TestServiceDiscoveryRegistryUnSubscribeKeepsMetadataOnRemoveFailure(t *test
 
 func TestServiceDiscoveryRegistryUnRegisterSyncsBulkMetadataCleanup(t 
*testing.T) {
        mockSD, mockMapping := setupEnvironment(t)
-       regID := fmt.Sprintf("mock-reg-%d", time.Now().UnixNano())
+       regID := fmt.Sprintf("mock-reg-%s-%d", t.Name(), time.Now().UnixNano())
 
        registryURL, err := common.NewURL(testRegistryURL,
                common.WithParamsValue(constant.RegistryKey, "mock"),
@@ -244,7 +244,7 @@ func 
TestServiceDiscoveryRegistryUnRegisterSyncsBulkMetadataCleanup(t *testing.T
 
 func TestServiceDiscoveryRegistryUnRegisterServicePartialFailSyncsMetadata(t 
*testing.T) {
        mockSD, mockMapping := setupEnvironment(t)
-       regID := fmt.Sprintf("mock-reg-%d", time.Now().UnixNano())
+       regID := fmt.Sprintf("mock-reg-%s-%d", t.Name(), time.Now().UnixNano())
 
        registryURL, err := common.NewURL(testRegistryURL,
                common.WithParamsValue(constant.RegistryKey, "mock"),
@@ -294,7 +294,7 @@ func 
TestServiceDiscoveryRegistryUnRegisterServicePartialFailSyncsMetadata(t *te
        assert.Equal(t, providerURL1, metaInfo.GetExportedServiceURLs()[0])
        assert.Len(t, metaInfo.Services, 1)
 
-       expectedRevision := createInstance(metaInfo, 
providerURL1).GetMetadata()[constant.ExportedServicesRevisionPropertyName]
+       expectedRevision := createInstance(metaInfo, providerURL1, 
regID).GetMetadata()[constant.ExportedServicesRevisionPropertyName]
        assert.Equal(t, expectedRevision, metaInfo.Revision)
        assert.True(t, mockSD.updateCalled)
        assert.Contains(t, mockSD.updatedIDs, providerURL1.Address())
@@ -302,7 +302,7 @@ func 
TestServiceDiscoveryRegistryUnRegisterServicePartialFailSyncsMetadata(t *te
 
 func 
TestServiceDiscoveryRegistryUnRegisterWithoutTrackedInstancesReconcilesMetadata(t
 *testing.T) {
        _, mockMapping := setupEnvironment(t)
-       regID := fmt.Sprintf("mock-reg-%d", time.Now().UnixNano())
+       regID := fmt.Sprintf("mock-reg-%s-%d", t.Name(), time.Now().UnixNano())
 
        registryURL, err := common.NewURL(testRegistryURL,
                common.WithParamsValue(constant.RegistryKey, "mock"),
@@ -344,7 +344,7 @@ func 
TestServiceDiscoveryRegistryUnRegisterWithoutTrackedInstancesReconcilesMeta
        assert.Equal(t, providerURL2, metaInfo.GetExportedServiceURLs()[0])
        assert.Len(t, metaInfo.Services, 1)
 
-       expectedRevision := createInstance(metaInfo, 
providerURL2).GetMetadata()[constant.ExportedServicesRevisionPropertyName]
+       expectedRevision := createInstance(metaInfo, providerURL2, 
regID).GetMetadata()[constant.ExportedServicesRevisionPropertyName]
        assert.Equal(t, expectedRevision, metaInfo.Revision)
 }
 
diff --git 
a/registry/servicediscovery/service_instances_changed_listener_impl.go 
b/registry/servicediscovery/service_instances_changed_listener_impl.go
index 13030d55d..af18900b2 100644
--- a/registry/servicediscovery/service_instances_changed_listener_impl.go
+++ b/registry/servicediscovery/service_instances_changed_listener_impl.go
@@ -58,6 +58,7 @@ func initCache(app string) {
 // ServiceInstancesChangedListenerImpl The Service Discovery Changed  Event 
Listener
 type ServiceInstancesChangedListenerImpl struct {
        app                string
+       registryId         string
        serviceNames       *gxset.HashSet
        listeners          map[string]registry.NotifyListener
        serviceUrls        map[string][]*common.URL
@@ -66,12 +67,13 @@ type ServiceInstancesChangedListenerImpl struct {
        mutex              sync.Mutex
 }
 
-func NewServiceInstancesChangedListener(app string, services *gxset.HashSet) 
registry.ServiceInstancesChangedListener {
+func NewServiceInstancesChangedListener(app string, registryId string, 
services *gxset.HashSet) registry.ServiceInstancesChangedListener {
        cacheOnce.Do(func() {
                initCache(app)
        })
        return &ServiceInstancesChangedListenerImpl{
                app:                app,
+               registryId:         registryId,
                serviceNames:       services,
                listeners:          make(map[string]registry.NotifyListener),
                serviceUrls:        make(map[string][]*common.URL),
@@ -118,7 +120,7 @@ func (lstn *ServiceInstancesChangedListenerImpl) OnEvent(e 
observer.Event) error
                        revisionToInstances[revision] = append(subInstances, 
instance)
                        metadataInfo := lstn.revisionToMetadata[revision]
                        if metadataInfo == nil {
-                               meta, err := GetMetadataInfo(lstn.app, 
instance, revision)
+                               meta, err := GetMetadataInfo(lstn.app, 
instance, revision, lstn.registryId)
                                if err != nil {
                                        // Skip this instance if metadata fetch 
fails (e.g., old Java Dubbo version)
                                        // Try next instance with same revision
@@ -147,7 +149,8 @@ func (lstn *ServiceInstancesChangedListenerImpl) OnEvent(e 
observer.Event) error
        }
        lstn.revisionToMetadata = newRevisionToMetadata
        for revision, metadataInfo := range newRevisionToMetadata {
-               metaCache.Set(revision, metadataInfo)
+               cacheKey := lstn.app + ":" + lstn.registryId + ":" + revision
+               metaCache.Set(cacheKey, metadataInfo)
        }
 
        for serviceKey, revisionServices := range serviceToRevisionServices {
@@ -242,11 +245,12 @@ func (lstn *ServiceInstancesChangedListenerImpl) 
GetEventType() reflect.Type {
 }
 
 // GetMetadataInfo get metadata info when MetadataStorageTypePropertyName is 
null
-func GetMetadataInfo(app string, instance registry.ServiceInstance, revision 
string) (*info.MetadataInfo, error) {
+func GetMetadataInfo(app string, instance registry.ServiceInstance, revision 
string, registryId string) (*info.MetadataInfo, error) {
        cacheOnce.Do(func() {
                initCache(app)
        })
-       if metadataInfo, ok := metaCache.Get(revision); ok {
+       cacheKey := app + ":" + registryId + ":" + revision
+       if metadataInfo, ok := metaCache.Get(cacheKey); ok {
                return metadataInfo.(*info.MetadataInfo), nil
        }
 
@@ -259,7 +263,7 @@ func GetMetadataInfo(app string, instance 
registry.ServiceInstance, revision str
                metadataStorageType = 
instance.GetMetadata()[constant.MetadataStorageTypePropertyName]
        }
        if metadataStorageType == constant.RemoteMetadataStorageType {
-               metadataInfo, err = 
metadata.GetMetadataFromMetadataReport(revision, instance)
+               metadataInfo, err = 
metadata.GetMetadataFromMetadataReport(revision, instance, registryId)
                if err != nil {
                        return nil, err
                }
@@ -269,6 +273,6 @@ func GetMetadataInfo(app string, instance 
registry.ServiceInstance, revision str
                        return nil, err
                }
        }
-       metaCache.Set(revision, metadataInfo)
+       metaCache.Set(cacheKey, metadataInfo)
        return metadataInfo, nil
 }
diff --git 
a/registry/servicediscovery/service_instances_changed_listener_impl_test.go 
b/registry/servicediscovery/service_instances_changed_listener_impl_test.go
index a289de37c..24a09a642 100644
--- a/registry/servicediscovery/service_instances_changed_listener_impl_test.go
+++ b/registry/servicediscovery/service_instances_changed_listener_impl_test.go
@@ -26,18 +26,23 @@ import (
        gxset "github.com/dubbogo/gost/container/set"
 
        "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/mock"
        "github.com/stretchr/testify/require"
 )
 
 import (
        "dubbo.apache.org/dubbo-go/v3/common"
        "dubbo.apache.org/dubbo-go/v3/common/constant"
+       "dubbo.apache.org/dubbo-go/v3/common/extension"
+       "dubbo.apache.org/dubbo-go/v3/metadata"
        "dubbo.apache.org/dubbo-go/v3/metadata/info"
+       "dubbo.apache.org/dubbo-go/v3/metadata/mapping"
+       metadatareport "dubbo.apache.org/dubbo-go/v3/metadata/report"
        "dubbo.apache.org/dubbo-go/v3/registry"
 )
 
 func TestServiceInstancesChangedListenerAggregatesSameServiceAcrossRevisions(t 
*testing.T) {
-       listener := NewServiceInstancesChangedListener(testApp, 
gxset.NewSet(testApp))
+       listener := NewServiceInstancesChangedListener(testApp, 
constant.DefaultKey, gxset.NewSet(testApp))
        notify := &capturingNotifyListener{}
        listener.AddListenerAndNotify(common.MatchKey(testInterface, 
constant.TriProtocol), notify)
 
@@ -52,7 +57,7 @@ func 
TestServiceInstancesChangedListenerAggregatesSameServiceAcrossRevisions(t *
 }
 
 func 
TestServiceInstancesChangedListenerRefreshesURLsOnProviderRemoveAndRestart(t 
*testing.T) {
-       listener := NewServiceInstancesChangedListener(testApp, 
gxset.NewSet(testApp))
+       listener := NewServiceInstancesChangedListener(testApp, 
constant.DefaultKey, gxset.NewSet(testApp))
        notify := &capturingNotifyListener{}
        listener.AddListenerAndNotify(common.MatchKey(testInterface, 
constant.TriProtocol), notify)
 
@@ -83,12 +88,12 @@ func 
TestServiceInstancesChangedListenerRefreshesURLsOnProviderRemoveAndRestart(
 }
 
 func 
TestServiceInstancesChangedListenerRefreshesAndClearsEnvironmentWhenRevisionIsUnchanged(t
 *testing.T) {
-       listener := NewServiceInstancesChangedListener(testApp, 
gxset.NewSet(testApp))
+       listener := NewServiceInstancesChangedListener(testApp, 
constant.DefaultKey, gxset.NewSet(testApp))
        notify := &capturingNotifyListener{}
        listener.AddListenerAndNotify(common.MatchKey(testInterface, 
constant.TriProtocol), notify)
 
        revision := "rev-20001-same-environment-change"
-       metaCache.Set(revision, newTestMetadataInfo(t, revision, 20001, "pre"))
+       metaCache.Set(testApp+":"+constant.DefaultKey+":"+revision, 
newTestMetadataInfo(t, revision, 20001, "pre"))
 
        pre := newTestServiceInstanceOnly(20001, "pre", revision)
        require.NoError(t, 
listener.OnEvent(registry.NewServiceInstancesChangedEvent(testApp, 
[]registry.ServiceInstance{
@@ -119,15 +124,16 @@ func 
TestServiceInstancesChangedListenerRefreshesAndClearsEnvironmentWhenRevisio
 }
 
 func TestServiceInstancesChangedListenerSkipsNilMetadataWithoutPanic(t 
*testing.T) {
-       listener := NewServiceInstancesChangedListener(testApp, 
gxset.NewSet(testApp))
+       listener := NewServiceInstancesChangedListener(testApp, 
constant.DefaultKey, gxset.NewSet(testApp))
        notify := &capturingNotifyListener{}
        listener.AddListenerAndNotify(common.MatchKey(testInterface, 
constant.TriProtocol), notify)
 
        revision := "rev-20003-nil-metadata"
        var metadataInfo *info.MetadataInfo
-       metaCache.Set(revision, metadataInfo)
+       cacheKey := testApp + ":" + constant.DefaultKey + ":" + revision
+       metaCache.Set(cacheKey, metadataInfo)
        t.Cleanup(func() {
-               metaCache.Delete(revision)
+               metaCache.Delete(cacheKey)
        })
 
        instance := newTestServiceInstanceOnly(20003, "pre", revision)
@@ -145,11 +151,114 @@ func TestCreateInstanceCarriesEnvironmentMetadata(t 
*testing.T) {
        meta := info.NewMetadataInfo(testApp, "")
        providerURL := newTestProviderURL(t, 20001, "pre")
 
-       instance := createInstance(meta, providerURL)
+       instance := createInstance(meta, providerURL, constant.DefaultKey)
 
        assert.Equal(t, "pre", instance.GetMetadata()[constant.EnvironmentKey])
 }
 
+// TestListenerUsesRegistryIdToFetchRemoteMetadata verifies that when a 
listener is
+// created with a specific registryId, GetMetadataInfo threads that id through 
to
+// GetMetadataFromMetadataReport so the correct per-registry metadata report 
is used.
+func TestListenerUsesRegistryIdToFetchRemoteMetadata(t *testing.T) {
+       const listenerRegistryId = "remote-reg-test"
+       const revision = "rev-remote-reg-test"
+
+       // Register a mock metadata report under the listener's registry id
+       mockReport := new(listenerMockMetadataReport)
+       extension.SetMetadataReportFactory(listenerRegistryId, func() 
metadatareport.MetadataReportFactory {
+               return mockReport
+       })
+       opts := metadata.NewReportOptions(
+               metadata.WithRegistryId(listenerRegistryId),
+               metadata.WithProtocol(listenerRegistryId),
+               metadata.WithAddress("127.0.0.1"),
+       )
+       require.NoError(t, opts.Init())
+       t.Cleanup(metadata.ClearMetadataReportInstances)
+
+       // Build the MetadataInfo that the mock report will return
+       serviceURL, err := common.NewURL(
+               fmt.Sprintf("tri://127.0.0.1:20099/%s", testInterface),
+               common.WithInterface(testInterface),
+               common.WithMethods([]string{"Greet"}),
+               common.WithParamsValue(constant.ApplicationKey, testApp),
+       )
+       require.NoError(t, err)
+       svc := info.NewServiceInfoWithURL(serviceURL)
+       expectedMeta := info.NewMetadataInfoWithParams(testApp, revision, 
map[string]*info.ServiceInfo{
+               svc.GetMatchKey(): svc,
+       })
+       mockReport.On("GetAppMetadata").Return(expectedMeta, nil).Once()
+
+       // Create a service instance that requests remote metadata storage
+       remoteInstance := &registry.DefaultServiceInstance{
+               ID:          "127.0.0.1:20099",
+               ServiceName: testApp,
+               Host:        "127.0.0.1",
+               Port:        20099,
+               Enable:      true,
+               Healthy:     true,
+               Metadata: map[string]string{
+                       constant.ExportedServicesRevisionPropertyName: revision,
+                       constant.MetadataStorageTypePropertyName:      
constant.RemoteMetadataStorageType,
+                       constant.ServiceInstanceEndpoints:             
`[{"port":20099,"protocol":"tri"}]`,
+               },
+       }
+
+       // Remove the cache entry after the test so it doesn't bleed into other 
tests.
+       t.Cleanup(func() {
+               if metaCache != nil {
+                       metaCache.Delete(listenerRegistryId + ":" + revision)
+               }
+       })
+
+       listener := NewServiceInstancesChangedListener(testApp, 
listenerRegistryId, gxset.NewSet(testApp))
+       notify := &capturingNotifyListener{}
+       listener.AddListenerAndNotify(common.MatchKey(testInterface, 
constant.TriProtocol), notify)
+
+       require.NoError(t, 
listener.OnEvent(registry.NewServiceInstancesChangedEvent(testApp, 
[]registry.ServiceInstance{remoteInstance})))
+
+       // The mock report must have been called exactly once, proving the 
correct report was used
+       mockReport.AssertExpectations(t)
+       // The notify listener must have received the service from the fetched 
metadata
+       assert.NotEmpty(t, notify.events, "expected service events from the 
remote metadata fetch")
+}
+
+// listenerMockMetadataReport is a mock MetadataReport (and its factory) for
+// TestListenerUsesRegistryIdToFetchRemoteMetadata.
+type listenerMockMetadataReport struct {
+       mock.Mock
+}
+
+func (m *listenerMockMetadataReport) CreateMetadataReport(*common.URL) 
metadatareport.MetadataReport {
+       return m
+}
+
+func (m *listenerMockMetadataReport) GetAppMetadata(string, string) 
(*info.MetadataInfo, error) {
+       args := m.Called()
+       return args.Get(0).(*info.MetadataInfo), args.Error(1)
+}
+
+func (m *listenerMockMetadataReport) PublishAppMetadata(string, string, 
*info.MetadataInfo) error {
+       args := m.Called()
+       return args.Error(0)
+}
+
+func (m *listenerMockMetadataReport) RegisterServiceAppMapping(string, string, 
string) error {
+       args := m.Called()
+       return args.Error(0)
+}
+
+func (m *listenerMockMetadataReport) GetServiceAppMapping(string, string, 
mapping.MappingListener) (*gxset.HashSet, error) {
+       args := m.Called()
+       return args.Get(0).(*gxset.HashSet), args.Error(1)
+}
+
+func (m *listenerMockMetadataReport) RemoveServiceAppMappingListener(string, 
string) error {
+       args := m.Called()
+       return args.Error(0)
+}
+
 func newTestServiceInstance(t *testing.T, port int, environment string) 
registry.ServiceInstance {
        t.Helper()
 
@@ -159,7 +268,9 @@ func newTestServiceInstance(t *testing.T, port int, 
environment string) registry
 func newTestServiceInstanceWithRevision(t *testing.T, port int, environment 
string, revision string) registry.ServiceInstance {
        t.Helper()
 
-       metaCache.Set(revision, newTestMetadataInfo(t, revision, port, 
environment))
+       // Pre-populate the cache under the composite key used by 
GetMetadataInfo.
+       // All test listeners use constant.DefaultKey as their registryId.
+       metaCache.Set(testApp+":"+constant.DefaultKey+":"+revision, 
newTestMetadataInfo(t, revision, port, environment))
        return newTestServiceInstanceOnly(port, environment, revision)
 }
 

Reply via email to