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 4d93c115a fix(metadata): harden remote MetadataInfo loading with 
fallback, nil guards and error chain (#3395)
4d93c115a is described below

commit 4d93c115a2ac2b75d104dc6c93e40448b0955185
Author: Nene7ko_ <[email protected]>
AuthorDate: Sat Jun 13 19:23:00 2026 +0800

    fix(metadata): harden remote MetadataInfo loading with fallback, nil guards 
and error chain (#3395)
    
    * test(metadata): update cache key format and add GetMetadataInfo tests
    
    * fix(metadata): add fallback and nil URL guards for remote MetadataInfo 
loading
    
    * fix(metadata): address review issues in fallback and nil URL guard changes
    
    * fix(metadata): address PR review issues — error chain, context, comments, 
tests
    
    - Wrap combined fallback error with perrors.Wrapf instead of Errorf so the
      rpcErr chain is preserved for errors.Is/As callers (issue #1)
    - Annotate the reportErr==nil fallback failure path with [Metadata-Fallback]
      context instead of returning a bare RPC error (issue #2)
    - Wrap local/default path RPC error with app/registry/revision context,
      consistent with the remote path (issue #3)
    - Mark unused ctx parameters as _ with TODO(context-propagation) comments
      in both getMetadataInfo implementations and the interface (issue #4)
    - Update GetMetadataInfo doc comment to accurately describe cache, fallback,
      and nil guard behavior (issue #5)
    - Align 'treat as local' comment and 'defaulting to RPC' log to use 
consistent
      'local' storage-type vocabulary (issue #6)
    - Clarify '// local / default storage path' comment to say RPC is used 
(issue #7)
    - Fix listenerMockMetadataReport.GetAppMetadata to use comma-ok assertion,
      preventing panic when Return(nil, err) is used in future tests (issue #8)
    - Add TestGetMetadataInfo_ReportReturnsNil_RPCSucceeds covering the
      report-(nil,nil) -> RPC fallback path (gap #9)
    - Add TestGetMetadataInfo_RPCReturnsNilMetadata covering the nil-meta
      guard in the RPC result path (gap #10)
    - Add TestGetMetadataInfo_NilMetadataMap covering the nil-Metadata-map
      guard at line 264, reachable only via direct call not OnEvent (gap #11)
    
    * fix ci fail
    
    * fix(metadata): isolate metadata cache by provider app and add port guard
    
    Address PR review feedback:
    
    - Scope the metadata cache key by provider application instead of the
      subscribing consumer app. OnEvent keyed revisionToInstances,
      revisionToMetadata and the disk cache on revision alone (or consumer
      app + revision), so two provider apps sharing a revision could
      overwrite each other. Key on metadataCacheKey(providerApp, registryId,
      revision) throughout; pass instance.GetServiceName() to GetMetadataInfo.
    
    - buildStandardMetadataServiceURL now returns (*common.URL, error) and
      reports missing protocol and missing port at the URL layer, instead of
      only guarding a nil URL in the caller.
    
    - Align test names/comments with what they actually cover and fix error
      assertions to use require (testifylint require-error).
    
    * fix ci fail
    
    ---------
    
    Co-authored-by: xnlemon <[email protected]>
---
 metadata/client.go                                 |  53 ++--
 metadata/client_test.go                            |  61 ++++-
 .../service_instances_changed_listener_impl.go     |  98 ++++++--
 ...service_instances_changed_listener_impl_test.go | 274 ++++++++++++++++++++-
 4 files changed, 436 insertions(+), 50 deletions(-)

diff --git a/metadata/client.go b/metadata/client.go
index e3f09aa5d..bfd23c803 100644
--- a/metadata/client.go
+++ b/metadata/client.go
@@ -47,11 +47,18 @@ func GetMetadataFromMetadataReport(revision string, 
instance registry.ServiceIns
        if report == nil {
                return nil, perrors.Errorf("no metadata report instance found 
for registryId=%s, please check metadata-report configuration", registryId)
        }
-       return report.GetAppMetadata(instance.GetServiceName(), revision)
+       meta, err := report.GetAppMetadata(instance.GetServiceName(), revision)
+       if err != nil {
+               return nil, perrors.Wrapf(err, "failed to get app metadata 
app=%s revision=%s", instance.GetServiceName(), revision)
+       }
+       return meta, nil
 }
 
 func GetMetadataFromRpc(revision string, instance registry.ServiceInstance) 
(*info.MetadataInfo, error) {
-       url := buildStandardMetadataServiceURL(instance)
+       url, err := buildStandardMetadataServiceURL(instance)
+       if err != nil {
+               return nil, err
+       }
        url.SetParam(constant.TimeoutKey, defaultTimeout)
        p := extension.GetProtocol(url.Protocol)
        invoker := p.Refer(url)
@@ -70,23 +77,27 @@ func GetMetadataFromRpc(revision string, instance 
registry.ServiceInstance) (*in
        return remoteService.getMetadataInfo(context.Background(), revision)
 }
 
+// remoteMetadataService is the internal interface for fetching MetadataInfo 
via RPC.
+// The context parameter is accepted for future cancellation support but is 
not yet propagated.
 type remoteMetadataService interface {
-       getMetadataInfo(context context.Context, revision string) 
(*info.MetadataInfo, error)
+       getMetadataInfo(_ context.Context, revision string) 
(*info.MetadataInfo, error)
 }
 
 type triMetadataServiceV2 struct {
        invoker base.Invoker
 }
 
-func (m *triMetadataServiceV2) getMetadataInfo(ctx context.Context, revision 
string) (*info.MetadataInfo, error) {
+// getMetadataInfo fetches metadata via RPC using the Triple protocol 
(Protobuf).
+// TODO(context-propagation): ctx is not yet forwarded to the invoker; 
cancellation is not respected.
+func (m *triMetadataServiceV2) getMetadataInfo(_ context.Context, revision 
string) (*info.MetadataInfo, error) {
        const methodName = "GetMetadataInfo"
        req := &tripleapi.MetadataRequest{Revision: revision}
        metadataInfo := &tripleapi.MetadataInfoV2{}
        inv, _ := generateInvocation(m.invoker.GetURL(), methodName, req, 
metadataInfo, constant.CallUnary)
        res := m.invoker.Invoke(context.Background(), inv)
        if res.Error() != nil {
-               logger.Errorf("[Metadata] could not get the metadata info from 
remote provider, err=%v", res.Error())
-               return nil, res.Error()
+               logger.Errorf("[Metadata][RPC] could not get the metadata info 
from remote provider, err=%v", res.Error())
+               return nil, perrors.Wrapf(res.Error(), "remote metadata call 
failed")
        }
        return convertMetadataInfoV2(metadataInfo), nil
 }
@@ -148,7 +159,9 @@ type remoteMetadataServiceV1 struct {
        invoker base.Invoker
 }
 
-func (m *remoteMetadataServiceV1) getMetadataInfo(ctx context.Context, 
revision string) (*info.MetadataInfo, error) {
+// getMetadataInfo fetches metadata via RPC using the dubbo:// protocol 
(Hessian2 serialization).
+// TODO(context-propagation): ctx is not yet forwarded to the invoker; 
cancellation is not respected.
+func (m *remoteMetadataServiceV1) getMetadataInfo(_ context.Context, revision 
string) (*info.MetadataInfo, error) {
        const methodName = "getMetadataInfo"
        // Use interface{} as reply parameter to accept any type (MetadataInfo 
or string)
        // This avoids panic when Java returns String instead of MetadataInfo
@@ -157,15 +170,15 @@ func (m *remoteMetadataServiceV1) getMetadataInfo(ctx 
context.Context, revision
 
        res := m.invoker.Invoke(context.Background(), inv)
        if res.Error() != nil {
-               logger.Errorf("[Metadata] RPC call failed to %s, err=%v", 
m.invoker.GetURL().Location, res.Error())
-               return nil, res.Error()
+               logger.Errorf("[Metadata][RPC] RPC call failed to %s, err=%v", 
m.invoker.GetURL().Location, res.Error())
+               return nil, perrors.Wrapf(res.Error(), "RPC call failed to %s", 
m.invoker.GetURL().Location)
        }
 
        // rawResult now contains the deserialized value - could be 
*MetadataInfo, string, or nil
 
        // Handle nil response (e.g., Java service not fully initialized)
        if rawResult == nil {
-               logger.Warnf("[Metadata] Provider %s returned nil metadata 
(service may not be ready), revision=%s",
+               logger.Warnf("[Metadata][RPC] Provider %s returned nil metadata 
(service may not be ready), revision=%s",
                        m.invoker.GetURL().Location, revision)
                return nil, perrors.Errorf("metadata is nil from %s, revision: 
%s", m.invoker.GetURL().Location, revision)
        }
@@ -178,18 +191,18 @@ func (m *remoteMetadataServiceV1) getMetadataInfo(ctx 
context.Context, revision
        } else if strValue, ok := rawResult.(string); ok {
                // Old Java Dubbo version returns JSON string instead of 
MetadataInfo object
                // Try to parse it as JSON for backward compatibility
-               logger.Warnf("[Metadata] Provider %s returned string type (old 
Dubbo version), attempting JSON parse", m.invoker.GetURL().Location)
+               logger.Warnf("[Metadata][RPC] Provider %s returned string type 
(old Dubbo version), attempting JSON parse", m.invoker.GetURL().Location)
 
                metadataInfo = &info.MetadataInfo{}
                if err := json.Unmarshal([]byte(strValue), metadataInfo); err 
!= nil {
-                       logger.Errorf("[Metadata] failed to parse JSON string 
from provider %s, err=%v", m.invoker.GetURL().Location, err)
-                       logger.Errorf("[Metadata]   - String content: %s", 
truncateString(strValue, 1000))
+                       logger.Errorf("[Metadata][RPC] failed to parse JSON 
string from provider %s, err=%v", m.invoker.GetURL().Location, err)
+                       logger.Errorf("[Metadata][RPC] - String content: %s", 
truncateString(strValue, 1000))
                        return nil, perrors.Errorf("failed to parse metadata 
JSON from %s: %v", m.invoker.GetURL().Location, err)
                }
 
        } else {
                // Neither MetadataInfo nor String - this is unexpected
-               logger.Errorf("[Metadata] unexpected metadata type from %s: got 
%T, expected *info.MetadataInfo or string",
+               logger.Errorf("[Metadata][RPC] unexpected metadata type from 
%s: got %T, expected *info.MetadataInfo or string",
                        m.invoker.GetURL().Location, rawResult)
                return nil, perrors.Errorf("unexpected metadata type from %s: 
got %T, expected *info.MetadataInfo or string",
                        m.invoker.GetURL().Location, rawResult)
@@ -207,10 +220,14 @@ func truncateString(s string, maxLen int) string {
 }
 
 // buildStandardMetadataServiceURL will use standard format to build the 
metadata service url.
-func buildStandardMetadataServiceURL(ins registry.ServiceInstance) *common.URL 
{
+// Returns an error if required params (protocol or port) are missing.
+func buildStandardMetadataServiceURL(ins registry.ServiceInstance) 
(*common.URL, error) {
        ps := getMetadataServiceUrlParams(ins)
        if ps[constant.ProtocolKey] == "" {
-               return nil
+               return nil, perrors.New("metadata service URL params missing: 
protocol is empty")
+       }
+       if ps[constant.PortKey] == "" {
+               return nil, perrors.New("metadata service URL params missing: 
port is empty")
        }
 
        sn := ins.GetServiceName()
@@ -243,7 +260,7 @@ func buildStandardMetadataServiceURL(ins 
registry.ServiceInstance) *common.URL {
                }
        }
 
-       return u
+       return u, nil
 }
 
 // getMetadataServiceUrlParams this will convertV2 the metadata service url 
parameters to map structure
@@ -255,7 +272,7 @@ func getMetadataServiceUrlParams(ins 
registry.ServiceInstance) map[string]string
        if str, ok := ps[constant.MetadataServiceURLParamsPropertyName]; ok && 
len(str) > 0 {
                err := json.Unmarshal([]byte(str), &res)
                if err != nil {
-                       logger.Errorf("[Metadata] could not parse the metadata 
service url parameters to map, err=%v", err)
+                       logger.Errorf("[Metadata][URL] could not parse the 
metadata service url parameters to map, err=%v", err)
                }
        }
 
diff --git a/metadata/client_test.go b/metadata/client_test.go
index aa538a918..a2c192e0f 100644
--- a/metadata/client_test.go
+++ b/metadata/client_test.go
@@ -186,14 +186,43 @@ func TestGetMetadataFromRpc(t *testing.T) {
        })
 }
 
+func TestGetMetadataFromRpc_MissingURLParams(t *testing.T) {
+       t.Run("missing protocol", func(t *testing.T) {
+               insNoProto := &registry.DefaultServiceInstance{
+                       ID:          "2",
+                       ServiceName: "dubbo-app",
+                       Host:        "dubbo.io",
+                       Metadata:    map[string]string{},
+               }
+               _, err := GetMetadataFromRpc("1", insNoProto)
+               require.Error(t, err)
+               assert.Contains(t, err.Error(), "protocol is empty")
+       })
+
+       t.Run("missing port", func(t *testing.T) {
+               insNoPort := &registry.DefaultServiceInstance{
+                       ID:          "3",
+                       ServiceName: "dubbo-app",
+                       Host:        "dubbo.io",
+                       Metadata: map[string]string{
+                               constant.MetadataServiceURLParamsPropertyName: 
`{"protocol":"dubbo"}`,
+                       },
+               }
+               _, err := GetMetadataFromRpc("1", insNoPort)
+               require.Error(t, err)
+               assert.Contains(t, err.Error(), "port is empty")
+       })
+}
+
 func Test_buildMetadataServiceURL(t *testing.T) {
        type args struct {
                ins registry.ServiceInstance
        }
        tests := []struct {
-               name string
-               args args
-               want *common.URL
+               name    string
+               args    args
+               want    *common.URL
+               wantErr string
        }{
                {
                        name: "normal",
@@ -239,12 +268,34 @@ func Test_buildMetadataServiceURL(t *testing.T) {
                                        Metadata:    map[string]string{},
                                },
                        },
-                       want: nil,
+                       wantErr: "protocol is empty",
+               },
+               {
+                       name: "no port",
+                       args: args{
+                               &registry.DefaultServiceInstance{
+                                       ServiceName: "dubbo-app",
+                                       Host:        "dubbo.io",
+                                       Metadata: map[string]string{
+                                               
constant.MetadataServiceURLParamsPropertyName: `{
+                                                       "protocol":"dubbo"
+                                               }`,
+                                       },
+                               },
+                       },
+                       wantErr: "port is empty",
                },
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
-                       assert.Equalf(t, tt.want, 
buildStandardMetadataServiceURL(tt.args.ins), "buildMetadataServiceURL(%v)", 
tt.args.ins)
+                       got, err := buildStandardMetadataServiceURL(tt.args.ins)
+                       if tt.wantErr != "" {
+                               require.Error(t, err)
+                               assert.Contains(t, err.Error(), tt.wantErr)
+                               return
+                       }
+                       require.NoError(t, err)
+                       assert.Equalf(t, tt.want, got, 
"buildMetadataServiceURL(%v)", tt.args.ins)
                })
        }
 }
diff --git 
a/registry/servicediscovery/service_instances_changed_listener_impl.go 
b/registry/servicediscovery/service_instances_changed_listener_impl.go
index af18900b2..317cff056 100644
--- a/registry/servicediscovery/service_instances_changed_listener_impl.go
+++ b/registry/servicediscovery/service_instances_changed_listener_impl.go
@@ -28,6 +28,8 @@ import (
        gxset "github.com/dubbogo/gost/container/set"
        "github.com/dubbogo/gost/gof/observer"
        "github.com/dubbogo/gost/log/logger"
+
+       perrors "github.com/pkg/errors"
 )
 
 import (
@@ -113,14 +115,21 @@ func (lstn *ServiceInstancesChangedListenerImpl) 
OnEvent(e observer.Event) error
                                logger.Infof("[Registry][ServiceDiscovery] find 
instance without valid service metadata, host=%s", instance.GetHost())
                                continue
                        }
-                       subInstances := revisionToInstances[revision]
+                       // MetadataInfo belongs to the provider application, so 
isolate every cache
+                       // dimension by provider app. Two provider apps that 
happen to share a revision
+                       // (e.g. same interface set exported under different 
application names) must not
+                       // collide on a revision-only key. 
instance.GetServiceName() is the provider app.
+                       providerApp := instance.GetServiceName()
+                       key := metadataCacheKey(providerApp, lstn.registryId, 
revision)
+
+                       subInstances := revisionToInstances[key]
                        if subInstances == nil {
                                subInstances = make([]registry.ServiceInstance, 
0, 8)
                        }
-                       revisionToInstances[revision] = append(subInstances, 
instance)
-                       metadataInfo := lstn.revisionToMetadata[revision]
+                       revisionToInstances[key] = append(subInstances, 
instance)
+                       metadataInfo := lstn.revisionToMetadata[key]
                        if metadataInfo == nil {
-                               meta, err := GetMetadataInfo(lstn.app, 
instance, revision, lstn.registryId)
+                               meta, err := GetMetadataInfo(providerApp, 
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
@@ -141,22 +150,22 @@ func (lstn *ServiceInstancesChangedListenerImpl) 
OnEvent(e observer.Event) error
                                if serviceToRevisionServices[matchKey] == nil {
                                        serviceToRevisionServices[matchKey] = 
make(map[string]*info.ServiceInfo)
                                }
-                               serviceToRevisionServices[matchKey][revision] = 
service
+                               serviceToRevisionServices[matchKey][key] = 
service
                        }
 
-                       newRevisionToMetadata[revision] = metadataInfo
+                       newRevisionToMetadata[key] = metadataInfo
                }
        }
        lstn.revisionToMetadata = newRevisionToMetadata
-       for revision, metadataInfo := range newRevisionToMetadata {
-               cacheKey := lstn.app + ":" + lstn.registryId + ":" + revision
-               metaCache.Set(cacheKey, metadataInfo)
+       for key, metadataInfo := range newRevisionToMetadata {
+               // key is already provider-app scoped and matches the disk 
cache key format.
+               metaCache.Set(key, metadataInfo)
        }
 
        for serviceKey, revisionServices := range serviceToRevisionServices {
                urls := make([]*common.URL, 0, 8)
-               for revision, serviceInfo := range revisionServices {
-                       for _, i := range revisionToInstances[revision] {
+               for key, serviceInfo := range revisionServices {
+                       for _, i := range revisionToInstances[key] {
                                if i != nil {
                                        urls = append(urls, 
toInstanceServiceURLs(i, serviceInfo)...)
                                }
@@ -244,12 +253,25 @@ func (lstn *ServiceInstancesChangedListenerImpl) 
GetEventType() reflect.Type {
        return reflect.TypeOf(&registry.ServiceInstancesChangedEvent{})
 }
 
-// GetMetadataInfo get metadata info when MetadataStorageTypePropertyName is 
null
+// metadataCacheKey builds the cache key that isolates MetadataInfo by provider
+// application, registry, and revision. MetadataInfo is owned by the provider 
app,
+// so app must be the provider application name (instance.GetServiceName()), 
never
+// the subscribing consumer app. Keying on revision alone would let two 
provider
+// apps that share a revision overwrite each other's metadata.
+func metadataCacheKey(app, registryId, revision string) string {
+       return app + ":" + registryId + ":" + revision
+}
+
+// GetMetadataInfo retrieves the MetadataInfo for a service instance by 
revision.
+// Results are cached by app+registryId+revision, where app must be the 
provider
+// application name. For "remote" storage type, it fetches from the metadata 
report
+// and falls back to RPC if the report fails or returns nil. For all other 
storage
+// types (including absent), it uses RPC directly.
 func GetMetadataInfo(app string, instance registry.ServiceInstance, revision 
string, registryId string) (*info.MetadataInfo, error) {
        cacheOnce.Do(func() {
                initCache(app)
        })
-       cacheKey := app + ":" + registryId + ":" + revision
+       cacheKey := metadataCacheKey(app, registryId, revision)
        if metadataInfo, ok := metaCache.Get(cacheKey); ok {
                return metadataInfo.(*info.MetadataInfo), nil
        }
@@ -258,20 +280,60 @@ func GetMetadataInfo(app string, instance 
registry.ServiceInstance, revision str
        var metadataInfo *info.MetadataInfo
        var err error
        if instance.GetMetadata() == nil {
+               // No metadata map at all; treat as default (local/RPC) storage 
type.
                metadataStorageType = constant.DefaultMetadataStorageType
        } else {
                metadataStorageType = 
instance.GetMetadata()[constant.MetadataStorageTypePropertyName]
+               if metadataStorageType == "" {
+                       // MetadataStorageTypePropertyName absent (e.g. old 
Java provider); default to local storage type.
+                       logger.Warnf("[Metadata] MetadataStorageType not set 
for instance %s, defaulting to local", instance.GetID())
+                       metadataStorageType = 
constant.DefaultMetadataStorageType
+               }
        }
+
        if metadataStorageType == constant.RemoteMetadataStorageType {
-               metadataInfo, err = 
metadata.GetMetadataFromMetadataReport(revision, instance, registryId)
-               if err != nil {
-                       return nil, err
+               var reportErr error
+               metadataInfo, reportErr = 
metadata.GetMetadataFromMetadataReport(revision, instance, registryId)
+               if reportErr != nil {
+                       logger.Errorf("[Metadata][Fallback] report failed, 
fallback to RPC app=%s registry=%s revision=%s err=%v",
+                               app, registryId, revision, reportErr)
+               } else if metadataInfo == nil {
+                       logger.Warnf("[Metadata][Fallback] report returned nil 
metadata, fallback to RPC app=%s registry=%s revision=%s",
+                               app, registryId, revision)
+               } else {
+                       metaCache.Set(cacheKey, metadataInfo)
+                       return metadataInfo, nil
                }
-       } else {
+
                metadataInfo, err = metadata.GetMetadataFromRpc(revision, 
instance)
                if err != nil {
-                       return nil, err
+                       if reportErr != nil {
+                               // Wrap rpcErr so callers can use errors.Is/As 
on the primary failure;
+                               // reportErr is annotated as context since it 
triggered the fallback.
+                               return nil, perrors.Wrapf(err,
+                                       "both paths failed, reportErr: %v", 
reportErr)
+                       }
+                       // reportErr was nil — the report returned nil metadata 
and RPC also failed.
+                       return nil, perrors.Wrapf(err,
+                               "RPC fallback failed after report returned nil 
metadata")
                }
+               if metadataInfo == nil {
+                       return nil, perrors.Errorf("got nil metadata from RPC 
app=%s registry=%s revision=%s",
+                               app, registryId, revision)
+               }
+               metaCache.Set(cacheKey, metadataInfo)
+               return metadataInfo, nil
+       }
+
+       // Non-remote storage type ("local" or absent): fetch metadata via RPC 
directly.
+       metadataInfo, err = metadata.GetMetadataFromRpc(revision, instance)
+       if err != nil {
+               return nil, perrors.Wrapf(err,
+                       "failed app=%s registry=%s revision=%s", app, 
registryId, revision)
+       }
+       if metadataInfo == nil {
+               return nil, perrors.Errorf("got nil metadata from RPC app=%s 
registry=%s revision=%s",
+                       app, registryId, revision)
        }
        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 24a09a642..22f7582ae 100644
--- a/registry/servicediscovery/service_instances_changed_listener_impl_test.go
+++ b/registry/servicediscovery/service_instances_changed_listener_impl_test.go
@@ -93,7 +93,8 @@ func 
TestServiceInstancesChangedListenerRefreshesAndClearsEnvironmentWhenRevisio
        listener.AddListenerAndNotify(common.MatchKey(testInterface, 
constant.TriProtocol), notify)
 
        revision := "rev-20001-same-environment-change"
-       metaCache.Set(testApp+":"+constant.DefaultKey+":"+revision, 
newTestMetadataInfo(t, revision, 20001, "pre"))
+       cacheKey := testApp + ":" + constant.DefaultKey + ":" + revision
+       metaCache.Set(cacheKey, newTestMetadataInfo(t, revision, 20001, "pre"))
 
        pre := newTestServiceInstanceOnly(20001, "pre", revision)
        require.NoError(t, 
listener.OnEvent(registry.NewServiceInstancesChangedEvent(testApp, 
[]registry.ServiceInstance{
@@ -129,11 +130,11 @@ func 
TestServiceInstancesChangedListenerSkipsNilMetadataWithoutPanic(t *testing.
        listener.AddListenerAndNotify(common.MatchKey(testInterface, 
constant.TriProtocol), notify)
 
        revision := "rev-20003-nil-metadata"
+       nilCacheKey := testApp + ":" + constant.DefaultKey + ":" + revision
        var metadataInfo *info.MetadataInfo
-       cacheKey := testApp + ":" + constant.DefaultKey + ":" + revision
-       metaCache.Set(cacheKey, metadataInfo)
+       metaCache.Set(nilCacheKey, metadataInfo)
        t.Cleanup(func() {
-               metaCache.Delete(cacheKey)
+               metaCache.Delete(nilCacheKey)
        })
 
        instance := newTestServiceInstanceOnly(20003, "pre", revision)
@@ -208,7 +209,7 @@ func TestListenerUsesRegistryIdToFetchRemoteMetadata(t 
*testing.T) {
        // 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)
+                       metaCache.Delete(testApp + ":" + listenerRegistryId + 
":" + revision)
                }
        })
 
@@ -236,7 +237,8 @@ func (m *listenerMockMetadataReport) 
CreateMetadataReport(*common.URL) metadatar
 
 func (m *listenerMockMetadataReport) GetAppMetadata(string, string) 
(*info.MetadataInfo, error) {
        args := m.Called()
-       return args.Get(0).(*info.MetadataInfo), args.Error(1)
+       result, _ := args.Get(0).(*info.MetadataInfo)
+       return result, args.Error(1)
 }
 
 func (m *listenerMockMetadataReport) PublishAppMetadata(string, string, 
*info.MetadataInfo) error {
@@ -268,9 +270,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()
 
-       // 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))
+       cacheKey := testApp + ":" + constant.DefaultKey + ":" + revision
+       metaCache.Set(cacheKey, newTestMetadataInfo(t, revision, port, 
environment))
+       t.Cleanup(func() { metaCache.Delete(cacheKey) })
        return newTestServiceInstanceOnly(port, environment, revision)
 }
 
@@ -372,3 +374,257 @@ func (c *capturingNotifyListener) NotifyAll(events 
[]*registry.ServiceEvent, cal
                callback()
        }
 }
+
+func TestGetMetadataInfo_CacheKeyFormat(t *testing.T) {
+       // Ensure cache is initialized (normally done by 
NewServiceInstancesChangedListener)
+       _ = NewServiceInstancesChangedListener(testApp, constant.DefaultKey, 
gxset.NewSet(testApp))
+
+       revision := "rev-cache-key-test"
+       // Pre-populate cache with the expected composite key
+       expectedKey := testApp + ":" + constant.DefaultKey + ":" + revision
+       expectedMeta := newTestMetadataInfo(t, revision, 20000, "dev")
+       metaCache.Set(expectedKey, expectedMeta)
+       t.Cleanup(func() {
+               metaCache.Delete(expectedKey)
+       })
+
+       instance := &registry.DefaultServiceInstance{
+               ID:          "127.0.0.1:20000",
+               ServiceName: testApp,
+               Host:        "127.0.0.1",
+               Port:        20000,
+               Enable:      true,
+               Healthy:     true,
+               Metadata: map[string]string{
+                       constant.ExportedServicesRevisionPropertyName: revision,
+                       constant.ServiceInstanceEndpoints:             
`[{"port":20000,"protocol":"tri"}]`,
+               },
+       }
+
+       // Should hit the cache and return the pre-populated metadata
+       meta, err := GetMetadataInfo(testApp, instance, revision, 
constant.DefaultKey)
+       require.NoError(t, err)
+       assert.Equal(t, expectedMeta, meta)
+}
+
+func TestGetMetadataInfo_LocalStorageGoesDirectlyToRPC(t *testing.T) {
+       // Ensure cache is initialized
+       _ = NewServiceInstancesChangedListener(testApp, constant.DefaultKey, 
gxset.NewSet(testApp))
+
+       // Instance with no MetadataStorageTypePropertyName (i.e. local/default 
path)
+       // should go directly to RPC without touching the metadata report.
+       // RPC will fail with a URL error because there are no URL params — 
that's
+       // enough to confirm the correct branch was taken.
+       instance := &registry.DefaultServiceInstance{
+               ID:          "127.0.0.1:20003",
+               ServiceName: testApp,
+               Host:        "127.0.0.1",
+               Port:        20003,
+               Enable:      true,
+               Healthy:     true,
+               Metadata: map[string]string{
+                       constant.ExportedServicesRevisionPropertyName: 
"rev-local-rpc",
+                       // MetadataStorageTypePropertyName intentionally absent 
→ local path
+                       constant.ServiceInstanceEndpoints: 
`[{"port":20003,"protocol":"tri"}]`,
+               },
+       }
+
+       _, err := GetMetadataInfo(testApp, instance, "rev-local-rpc", 
constant.DefaultKey)
+       require.Error(t, err)
+       // Must be a URL/RPC error, not a report error, confirming the local 
path
+       // skips the report entirely and goes straight to RPC.
+       assert.Contains(t, err.Error(), "metadata service URL params missing",
+               "local storage path should go directly to RPC, not touch the 
metadata report")
+}
+
+func TestGetMetadataInfo_FallbackToRPC(t *testing.T) {
+       // Ensure cache is initialized
+       _ = NewServiceInstancesChangedListener(testApp, constant.DefaultKey, 
gxset.NewSet(testApp))
+
+       // remote storage type without a report registered → report will fail
+       // should fall through to RPC, which will fail with url error (no URL 
params)
+       instance := &registry.DefaultServiceInstance{
+               ID:          "127.0.0.1:20002",
+               ServiceName: testApp,
+               Host:        "127.0.0.1",
+               Port:        20002,
+               Enable:      true,
+               Healthy:     true,
+               Metadata: map[string]string{
+                       constant.ExportedServicesRevisionPropertyName: 
"rev-fallback-to-rpc",
+                       constant.MetadataStorageTypePropertyName:      
constant.RemoteMetadataStorageType,
+                       constant.ServiceInstanceEndpoints:             
`[{"port":20002,"protocol":"tri"}]`,
+               },
+       }
+
+       _, err := GetMetadataInfo(testApp, instance, "rev-fallback-to-rpc", 
constant.DefaultKey)
+       require.Error(t, err)
+       // Both report and RPC fail: the combined error proves the fallback 
path was taken
+       // and includes the RPC/URL failure as the wrapped cause.
+       assert.Contains(t, err.Error(), "both paths failed",
+               "fallback path should produce a combined error mentioning both 
failures")
+       assert.Contains(t, err.Error(), "metadata service URL params missing",
+               "fallback error should include the RPC/URL failure cause")
+}
+
+// TestGetMetadataInfo_ReportReturnsNil_FallsBackToRPC verifies the path where 
the metadata
+// report returns (nil, nil) — no error but no data — which must trigger the 
RPC fallback.
+// Here the instance has no URL params, so RPC fails; the test asserts the 
resulting error
+// comes from the RPC fallback (not the report), proving the nil-result branch 
was taken.
+func TestGetMetadataInfo_ReportReturnsNil_FallsBackToRPC(t *testing.T) {
+       const regID = "report-nil-rpc-ok"
+       const revision = "rev-report-nil-rpc-ok"
+
+       // Register a mock report that returns (nil, nil) — success with no 
data.
+       mockReport := new(listenerMockMetadataReport)
+       extension.SetMetadataReportFactory(regID, func() 
metadatareport.MetadataReportFactory {
+               return mockReport
+       })
+       opts := metadata.NewReportOptions(
+               metadata.WithRegistryId(regID),
+               metadata.WithProtocol(regID),
+               metadata.WithAddress("127.0.0.1"),
+       )
+       require.NoError(t, opts.Init())
+       t.Cleanup(metadata.ClearMetadataReportInstances)
+       t.Cleanup(func() {
+               if metaCache != nil {
+                       metaCache.Delete(testApp + ":" + regID + ":" + revision)
+               }
+       })
+
+       mockReport.On("GetAppMetadata").Return((*info.MetadataInfo)(nil), 
nil).Once()
+
+       // The instance has no URL params, so the RPC fallback fails at URL 
construction.
+       // That failure is the observable proof that the report's nil result 
triggered fallback.
+       instance := &registry.DefaultServiceInstance{
+               ID:          "127.0.0.1:20098",
+               ServiceName: testApp,
+               Host:        "127.0.0.1",
+               Port:        20098,
+               Enable:      true,
+               Healthy:     true,
+               Metadata: map[string]string{
+                       constant.ExportedServicesRevisionPropertyName: revision,
+                       constant.MetadataStorageTypePropertyName:      
constant.RemoteMetadataStorageType,
+                       // No URL params — RPC will fail at the 
URL-construction stage
+               },
+       }
+       _ = NewServiceInstancesChangedListener(testApp, regID, 
gxset.NewSet(testApp))
+
+       _, err := GetMetadataInfo(testApp, instance, revision, regID)
+       require.Error(t, err)
+       // The report returned nil (no error), so the fallback was triggered 
and then RPC
+       // failed at URL construction. The error must reflect the 
RPC-after-nil-report path.
+       assert.Contains(t, err.Error(), "RPC fallback failed after report 
returned nil metadata",
+               "nil report result should trigger fallback and surface an RPC 
error")
+       assert.Contains(t, err.Error(), "metadata service URL params missing",
+               "fallback error should include the RPC/URL failure cause")
+       mockReport.AssertExpectations(t)
+}
+
+// TestGetMetadataInfo_CacheTypedNilNoPanic verifies that when the cache holds 
a typed nil
+// *info.MetadataInfo entry, GetMetadataInfo does not panic and returns (nil, 
nil).
+// This covers a defensive edge case (e.g., a previous store of typed nil) 
rather than
+// the production nil guard in the RPC path. The RPC nil guard is exercised 
indirectly
+// through TestGetMetadataInfo_ReportReturnsNil_FallsBackToRPC.
+func TestGetMetadataInfo_CacheTypedNilNoPanic(t *testing.T) {
+       // This path is exercised via the cache: pre-seed with a typed nil 
*info.MetadataInfo.
+       // GetMetadataInfo hits the cache fast-path and returns the typed nil 
without calling RPC.
+       // Asserts that this does NOT panic, giving the caller a (nil, nil) to 
skip gracefully.
+       _ = NewServiceInstancesChangedListener(testApp, constant.DefaultKey, 
gxset.NewSet(testApp))
+
+       revision := "rev-rpc-nil-meta-guard"
+       cacheKey := testApp + ":" + constant.DefaultKey + ":" + revision
+       var nilMeta *info.MetadataInfo
+       metaCache.Set(cacheKey, nilMeta)
+       t.Cleanup(func() { metaCache.Delete(cacheKey) })
+
+       instance := newTestServiceInstanceOnly(20098, "dev", revision)
+
+       // Must not panic; the typed nil in the cache is returned as (nil, nil).
+       var result *info.MetadataInfo
+       require.NotPanics(t, func() {
+               result, _ = GetMetadataInfo(testApp, instance, revision, 
constant.DefaultKey)
+       })
+       assert.Nil(t, result, "typed nil from cache should be returned as nil 
MetadataInfo")
+}
+
+// TestGetMetadataInfo_NilMetadataMap verifies that an instance with a nil 
Metadata map
+// is handled gracefully by GetMetadataInfo and takes the local/RPC path.
+// The nil-map guard in GetMetadataInfo is unreachable from OnEvent (which has 
its own
+// nil check), so it must be tested by calling GetMetadataInfo directly.
+func TestGetMetadataInfo_NilMetadataMap(t *testing.T) {
+       _ = NewServiceInstancesChangedListener(testApp, constant.DefaultKey, 
gxset.NewSet(testApp))
+
+       instance := &registry.DefaultServiceInstance{
+               ID:          "127.0.0.1:20097",
+               ServiceName: testApp,
+               Host:        "127.0.0.1",
+               Port:        20097,
+               Enable:      true,
+               Healthy:     true,
+               Metadata:    nil, // nil map — triggers the nil-map guard
+       }
+
+       _, err := GetMetadataInfo(testApp, instance, "rev-nil-map", 
constant.DefaultKey)
+       // With no URL params the RPC call fails, but it must be an RPC/URL 
error —
+       // not a panic and not a report error — proving the nil-map guard 
worked and
+       // the local path was taken.
+       require.Error(t, err)
+       assert.NotPanics(t, func() {
+               _, _ = GetMetadataInfo(testApp, instance, 
"rev-nil-map-nopanic", constant.DefaultKey)
+       })
+}
+
+// TestGetMetadataInfo_ProviderAppIsolatesSharedRevision verifies that two 
different
+// provider applications sharing the same revision do NOT collide in the cache.
+// The cache key is scoped by provider app, so each app's MetadataInfo is 
isolated
+// even when their revision strings are identical.
+func TestGetMetadataInfo_ProviderAppIsolatesSharedRevision(t *testing.T) {
+       _ = NewServiceInstancesChangedListener(testApp, constant.DefaultKey, 
gxset.NewSet(testApp))
+
+       const sharedRevision = "rev-shared-across-apps"
+       const providerA = "order-service"
+       const providerB = "payment-service"
+
+       keyA := providerA + ":" + constant.DefaultKey + ":" + sharedRevision
+       keyB := providerB + ":" + constant.DefaultKey + ":" + sharedRevision
+       metaA := info.NewMetadataInfo(providerA, sharedRevision)
+       metaB := info.NewMetadataInfo(providerB, sharedRevision)
+       metaCache.Set(keyA, metaA)
+       metaCache.Set(keyB, metaB)
+       t.Cleanup(func() {
+               metaCache.Delete(keyA)
+               metaCache.Delete(keyB)
+       })
+
+       instanceA := &registry.DefaultServiceInstance{
+               ID:          "127.0.0.1:21001",
+               ServiceName: providerA,
+               Host:        "127.0.0.1",
+               Port:        21001,
+               Metadata: map[string]string{
+                       constant.ExportedServicesRevisionPropertyName: 
sharedRevision,
+               },
+       }
+       instanceB := &registry.DefaultServiceInstance{
+               ID:          "127.0.0.1:21002",
+               ServiceName: providerB,
+               Host:        "127.0.0.1",
+               Port:        21002,
+               Metadata: map[string]string{
+                       constant.ExportedServicesRevisionPropertyName: 
sharedRevision,
+               },
+       }
+
+       // Each provider app must resolve to its own MetadataInfo, not the 
other's,
+       // despite sharing the same revision.
+       gotA, err := GetMetadataInfo(instanceA.GetServiceName(), instanceA, 
sharedRevision, constant.DefaultKey)
+       require.NoError(t, err)
+       assert.Equal(t, providerA, gotA.App, "provider A must get its own 
metadata")
+
+       gotB, err := GetMetadataInfo(instanceB.GetServiceName(), instanceB, 
sharedRevision, constant.DefaultKey)
+       require.NoError(t, err)
+       assert.Equal(t, providerB, gotB.App, "provider B must get its own 
metadata, not provider A's")
+}


Reply via email to