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 cdf6f2f8b feat(metadata): strengthen service-app mapping consistency,
retry and… (#3373)
cdf6f2f8b is described below
commit cdf6f2f8b5b5b1ece181540002c4b12730dcea60
Author: Yuxuan Lv <[email protected]>
AuthorDate: Wed Jun 10 09:18:03 2026 +0800
feat(metadata): strengthen service-app mapping consistency, retry and…
(#3373)
* feat(metadata): strengthen service-app mapping consistency, retry and
dedup (#3354)
Make interface-to-app mapping registration safe under concurrent providers
and
give it a proper retry policy.
- Optimistic concurrency across all backends so concurrent appends no longer
clobber each other: etcd (GetValAndRev + UpdateWithRev), zookeeper
(versioned
SetContent), nacos (CasMd5). Each backend wraps its native conflict
(ErrCompareFail / ErrBadVersion / ErrNodeExists / nacos publish failure)
into
the shared report.ErrMappingCASConflict sentinel via %w.
- Graded retry: registerWithRetry retries only CAS conflicts (errors.Is)
with
exponential backoff + jitter, and returns permanent errors immediately
instead of burning the whole retry budget.
- Extract shared logic: report.MergeServiceAppMapping (whole-element dedup,
fixing the strings.Contains substring false positive and the leading-comma
bug on empty values) and report.DecodeServiceAppNames (skips empty
elements).
- Listener cleanup: zookeeper removal via CacheListener.RemoveKeyListeners;
etcd documents the listener as unsupported instead of silently succeeding.
- Tests: helper unit tests plus a concurrency test that reproduces the
lost-update bug and proves CAS preserves every writer (200 writers /
20 readers, passes under -race).
Known nacos-only limitation (documented in code): CasMd5 is an optimistic
UPDATE and cannot guard the first INSERT, so the initial concurrent
registration of a brand-new interface can still race. etcd and zookeeper are
not affected.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix(metadata): address review feedback on mapping consistency (#3354)
- nacos: stop swallowing the getConfig read error. On a failed read the old
value was treated as empty, so registration would publish only the current
app and overwrite an existing set (e.g. appA,appB -> appC). Return the
error
instead so an existing mapping is never clobbered. A genuinely absent
config
still returns ("", nil) and takes the first-write path.
- zookeeper: CacheListener.DataChange now builds the set via
report.DecodeServiceAppNames, so mapping change events no longer surface
empty app names from legacy/malformed comma-separated values (",app",
"app,,other"). Added a listener test covering this.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix(metadata): treat nacos "config data not exist" as empty in mapping
registration
The previous commit returned any getConfig error from
RegisterServiceAppMapping.
Nacos signals a never-written key with a "config data not exist" error (not
an
empty value), so the first registration of a fresh interface failed and the
provider panicked on service export (broke the registry/nacos integration
test).
Only treat genuine read failures (network/auth/server) as errors; the
not-found
signal is handled as an empty old value so the first write can create the
key.
Detection mirrors config_center/nacos's isConfigNotExistErr.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix(metadata): drop insecure rand jitter from mapping retry backoff
SonarCloud flagged math/rand as an insecure PRNG. The jitter only spread
out contending writers and is not worth a crypto/rand dependency, so use
plain exponential backoff instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix(metadata): suppress SonarCloud weak-hash hotspot on Nacos CAS MD5
The MD5 in nacos report is the checksum mandated by the Nacos CAS wire
protocol (PublishConfig forwards CasMd5 for the server to compare), not a
security hash, so the algorithm is not ours to change. Mark it NOSONAR
with an explanation to clear the quality-gate security hotspot.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
metadata/mapping/metadata/service_name_mapping.go | 50 ++++-
.../service_name_mapping_concurrency_test.go | 223 +++++++++++++++++++++
.../mapping/metadata/service_name_mapping_test.go | 12 +-
metadata/report/etcd/report.go | 36 +++-
metadata/report/mapping.go | 87 ++++++++
metadata/report/mapping_test.go | 66 ++++++
metadata/report/nacos/report.go | 73 ++++---
metadata/report/zookeeper/listener.go | 17 +-
metadata/report/zookeeper/listener_test.go | 26 +++
metadata/report/zookeeper/report.go | 32 +--
metadata/report/zookeeper/report_test.go | 20 +-
11 files changed, 566 insertions(+), 76 deletions(-)
diff --git a/metadata/mapping/metadata/service_name_mapping.go
b/metadata/mapping/metadata/service_name_mapping.go
index 1346436b2..717864da5 100644
--- a/metadata/mapping/metadata/service_name_mapping.go
+++ b/metadata/mapping/metadata/service_name_mapping.go
@@ -18,7 +18,9 @@
package metadata
import (
+ "errors"
"sync"
+ "time"
)
import (
@@ -33,11 +35,17 @@ import (
"dubbo.apache.org/dubbo-go/v3/common/extension"
"dubbo.apache.org/dubbo-go/v3/metadata"
"dubbo.apache.org/dubbo-go/v3/metadata/mapping"
+ "dubbo.apache.org/dubbo-go/v3/metadata/report"
)
-const (
- DefaultGroup = "mapping"
- retryTimes = 10
+const DefaultGroup = "mapping"
+
+// retry policy for mapping registration. These are vars rather than consts so
they can be
+// tuned (and made near-instant in tests).
+var (
+ retryTimes = 10
+ retryBaseInterval = 100 * time.Millisecond
+ retryMaxInterval = 2 * time.Second
)
func init() {
@@ -73,19 +81,41 @@ func (d *ServiceNameMapping) Map(url *common.URL) error {
return perrors.New("can not registering mapping to remote cause
no metadata report instance found")
}
for _, metadataReport := range metadataReports {
- var err error
- for i := 0; i < retryTimes; i++ {
- if err =
metadataReport.RegisterServiceAppMapping(serviceInterface, DefaultGroup,
appName); err == nil {
- break
- }
- }
- if err != nil {
+ if err := registerWithRetry(metadataReport, serviceInterface,
DefaultGroup, appName); err != nil {
return err
}
}
return nil
}
+// registerWithRetry registers the interface-to-app mapping, retrying only on
CAS conflicts
+// (report.ErrMappingCASConflict) with exponential backoff. Any other error is
returned
+// immediately, since retrying it would not help.
+func registerWithRetry(r report.MetadataReport, serviceInterface, group,
appName string) error {
+ var err error
+ for i := 0; i < retryTimes; i++ {
+ err = r.RegisterServiceAppMapping(serviceInterface, group,
appName)
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, report.ErrMappingCASConflict) {
+ return err
+ }
+ time.Sleep(backoff(i))
+ }
+ return err
+}
+
+// backoff returns the delay before retry attempt i: retryBaseInterval*2^i
capped at
+// retryMaxInterval.
+func backoff(attempt int) time.Duration {
+ d := retryBaseInterval << attempt
+ if d <= 0 || d > retryMaxInterval {
+ d = retryMaxInterval
+ }
+ return d
+}
+
// 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, "")
diff --git a/metadata/mapping/metadata/service_name_mapping_concurrency_test.go
b/metadata/mapping/metadata/service_name_mapping_concurrency_test.go
new file mode 100644
index 000000000..578bc828b
--- /dev/null
+++ b/metadata/mapping/metadata/service_name_mapping_concurrency_test.go
@@ -0,0 +1,223 @@
+/*
+ * 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 metadata
+
+import (
+ "fmt"
+ "sync"
+ "testing"
+)
+
+import (
+ gxset "github.com/dubbogo/gost/container/set"
+
+ "github.com/stretchr/testify/assert"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/metadata/info"
+ "dubbo.apache.org/dubbo-go/v3/metadata/mapping"
+ "dubbo.apache.org/dubbo-go/v3/metadata/report"
+)
+
+// versionedStore is an in-memory key/value store with a per-key version,
modeling the
+// compare-and-swap primitive a real metadata center (etcd ModRevision, zk
Stat.Version,
+// nacos content MD5) provides.
+type versionedStore struct {
+ mu sync.Mutex
+ data map[string]versionedEntry
+}
+
+type versionedEntry struct {
+ val string
+ ver int64
+}
+
+func newVersionedStore() *versionedStore {
+ return &versionedStore{data: make(map[string]versionedEntry)}
+}
+
+func (s *versionedStore) get(key string) (string, int64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ e := s.data[key]
+ return e.val, e.ver
+}
+
+// cas writes val only if the current version equals ver, returning whether it
was applied.
+func (s *versionedStore) cas(key, val string, ver int64) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.data[key].ver != ver {
+ return false
+ }
+ s.data[key] = versionedEntry{val: val, ver: ver + 1}
+ return true
+}
+
+// put writes unconditionally, modeling the old read-modify-write behavior
without CAS.
+func (s *versionedStore) put(key, val string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.data[key] = versionedEntry{val: val, ver: s.data[key].ver + 1}
+}
+
+// stubReport satisfies the non-mapping parts of report.MetadataReport.
+type stubReport struct{}
+
+func (stubReport) GetAppMetadata(string, string) (*info.MetadataInfo, error)
{ return nil, nil }
+func (stubReport) PublishAppMetadata(string, string, *info.MetadataInfo) error
{ return nil }
+func (stubReport) GetServiceAppMapping(string, string,
mapping.MappingListener) (*gxset.HashSet, error) {
+ return nil, nil
+}
+func (stubReport) RemoveServiceAppMappingListener(string, string) error {
return nil }
+
+// casReport registers mappings with optimistic concurrency against a
versionedStore, exactly
+// as the etcd/zk/nacos reports now do, returning report.ErrMappingCASConflict
on conflict.
+type casReport struct {
+ stubReport
+ store *versionedStore
+}
+
+func (r *casReport) RegisterServiceAppMapping(key, group, value string) error {
+ full := group + "/" + key
+ old, ver := r.store.get(full)
+ merged, changed := report.MergeServiceAppMapping(old, value)
+ if !changed {
+ return nil
+ }
+ if !r.store.cas(full, merged, ver) {
+ return report.ErrMappingCASConflict
+ }
+ return nil
+}
+
+func (r *casReport) GetServiceAppMapping(key, group string, _
mapping.MappingListener) (*gxset.HashSet, error) {
+ val, _ := r.store.get(group + "/" + key)
+ return report.DecodeServiceAppNames(val), nil
+}
+
+// fastRetry sets a near-zero backoff and the given retry budget, returning a
restore func.
+func fastRetry(times int) func() {
+ ot, ob, om := retryTimes, retryBaseInterval, retryMaxInterval
+ retryTimes, retryBaseInterval, retryMaxInterval = times, 0, 0
+ return func() { retryTimes, retryBaseInterval, retryMaxInterval = ot,
ob, om }
+}
+
+// TestNaiveReadModifyWriteLosesConcurrentUpdate reproduces the bug the issue
is about: two
+// providers that read the same value and write back unconditionally clobber
each other.
+func TestNaiveReadModifyWriteLosesConcurrentUpdate(t *testing.T) {
+ store := newVersionedStore()
+ const key = "mapping/Iface"
+
+ // Both providers read the initial empty value...
+ oldA, _ := store.get(key)
+ oldB, _ := store.get(key)
+ // ...each merges its own app and writes back without a version check.
+ mergedA, _ := report.MergeServiceAppMapping(oldA, "appA")
+ store.put(key, mergedA)
+ mergedB, _ := report.MergeServiceAppMapping(oldB, "appB")
+ store.put(key, mergedB)
+
+ val, _ := store.get(key)
+ got := report.DecodeServiceAppNames(val)
+ assert.False(t, got.Contains("appA"), "appA was silently lost by the
second write")
+ assert.True(t, got.Contains("appB"))
+}
+
+// TestCASRejectsConcurrentUpdate shows the fix: the second writer's CAS
fails, and after a
+// re-read both apps survive.
+func TestCASRejectsConcurrentUpdate(t *testing.T) {
+ store := newVersionedStore()
+ const key = "mapping/Iface"
+
+ oldA, verA := store.get(key)
+ oldB, verB := store.get(key) // both read version 0
+
+ mergedA, _ := report.MergeServiceAppMapping(oldA, "appA")
+ assert.True(t, store.cas(key, mergedA, verA)) // A wins, version -> 1
+
+ mergedB, _ := report.MergeServiceAppMapping(oldB, "appB")
+ assert.False(t, store.cas(key, mergedB, verB)) // B's stale version is
rejected
+
+ // B re-reads and retries; nothing is lost.
+ oldB2, verB2 := store.get(key)
+ mergedB2, _ := report.MergeServiceAppMapping(oldB2, "appB")
+ assert.True(t, store.cas(key, mergedB2, verB2))
+
+ val, _ := store.get(key)
+ got := report.DecodeServiceAppNames(val)
+ assert.True(t, got.Contains("appA"))
+ assert.True(t, got.Contains("appB"))
+}
+
+// TestRegisterWithRetryConcurrentNoLostUpdate drives the real
registerWithRetry loop with many
+// writers racing on the same interface key, while readers concurrently read
it. It asserts no
+// app is lost and that a reader never observes the set shrink. Run with -race
to also catch
+// data races.
+func TestRegisterWithRetryConcurrentNoLostUpdate(t *testing.T) {
+ defer fastRetry(10000)()
+ store := newVersionedStore()
+ r := &casReport{store: store}
+
+ const writers = 200
+ const readers = 20
+
+ // concurrent readers: every successful registration only appends, so a
reader must never
+ // see the set get smaller or contain a malformed entry.
+ stop := make(chan struct{})
+ var readerWg sync.WaitGroup
+ for i := 0; i < readers; i++ {
+ readerWg.Add(1)
+ go func() {
+ defer readerWg.Done()
+ prev := 0
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ set, err :=
r.GetServiceAppMapping("Iface", DefaultGroup, nil)
+ assert.NoError(t, err)
+ assert.GreaterOrEqual(t, set.Size(),
prev)
+ assert.False(t, set.Contains(""))
+ prev = set.Size()
+ }
+ }
+ }()
+ }
+
+ var writerWg sync.WaitGroup
+ for i := 0; i < writers; i++ {
+ writerWg.Add(1)
+ go func(i int) {
+ defer writerWg.Done()
+ assert.NoError(t, registerWithRetry(r, "Iface",
DefaultGroup, fmt.Sprintf("app-%d", i)))
+ }(i)
+ }
+ writerWg.Wait()
+ close(stop)
+ readerWg.Wait()
+
+ val, _ := store.get(DefaultGroup + "/Iface")
+ got := report.DecodeServiceAppNames(val)
+ assert.Equal(t, writers, got.Size())
+ for i := 0; i < writers; i++ {
+ assert.True(t, got.Contains(fmt.Sprintf("app-%d", i)), "app-%d
was lost", i)
+ }
+}
diff --git a/metadata/mapping/metadata/service_name_mapping_test.go
b/metadata/mapping/metadata/service_name_mapping_test.go
index d1783289a..c279ff4c0 100644
--- a/metadata/mapping/metadata/service_name_mapping_test.go
+++ b/metadata/mapping/metadata/service_name_mapping_test.go
@@ -97,11 +97,19 @@ func TestServiceNameMappingMap(t *testing.T) {
err = ins.Map(serviceUrl)
require.NoError(t, err)
})
- t.Run("test error", func(t *testing.T) {
-
mockReport.On("RegisterServiceAppMapping").Return(errors.New("mock
error")).Times(retryTimes)
+ t.Run("non-conflict error returns immediately", func(t *testing.T) {
+ // a generic error is not retriable, so
RegisterServiceAppMapping is called exactly once
+
mockReport.On("RegisterServiceAppMapping").Return(errors.New("mock
error")).Once()
err = ins.Map(serviceUrl)
require.Error(t, err, "test mapping error")
})
+ t.Run("CAS conflict retries up to retryTimes", func(t *testing.T) {
+ const conflictRetries = 3
+ defer fastRetry(conflictRetries)()
+
mockReport.On("RegisterServiceAppMapping").Return(report.ErrMappingCASConflict).Times(conflictRetries)
+ err = ins.Map(serviceUrl)
+ require.Error(t, err, "conflict exhausts the retry budget")
+ })
mockReport.AssertExpectations(t)
}
diff --git a/metadata/report/etcd/report.go b/metadata/report/etcd/report.go
index a69cdcb76..182b18898 100644
--- a/metadata/report/etcd/report.go
+++ b/metadata/report/etcd/report.go
@@ -19,6 +19,7 @@ package etcd
import (
"encoding/json"
+ "fmt"
"strings"
)
@@ -79,34 +80,47 @@ func (e *etcdMetadataReport)
PublishAppMetadata(application, revision string, in
// RegisterServiceAppMapping map the specified Dubbo service interface to
current Dubbo app name
func (e *etcdMetadataReport) RegisterServiceAppMapping(key string, group
string, value string) error {
path := e.rootDir + constant.PathSeparator + group +
constant.PathSeparator + key
- oldVal, err := e.client.Get(path)
+ oldVal, rev, err := e.client.GetValAndRev(path)
if perrors.Cause(err) == gxetcd.ErrKVPairNotFound {
- return e.client.Put(path, value)
+ if cErr := e.client.Create(path, value); cErr != nil {
+ if perrors.Cause(cErr) == gxetcd.ErrCompareFail {
+ return fmt.Errorf("create mapping %s: %w",
path, report.ErrMappingCASConflict)
+ }
+ return cErr
+ }
+ return nil
} else if err != nil {
return err
}
- if strings.Contains(oldVal, value) {
+ merged, changed := report.MergeServiceAppMapping(oldVal, value)
+ if !changed {
return nil
}
- value = oldVal + constant.CommaSeparator + value
- return e.client.Put(path, value)
+ if uErr := e.client.UpdateWithRev(path, merged, rev); uErr != nil {
+ if perrors.Cause(uErr) == gxetcd.ErrCompareFail {
+ return fmt.Errorf("update mapping %s: %w", path,
report.ErrMappingCASConflict)
+ }
+ return uErr
+ }
+ return nil
}
// GetServiceAppMapping get the app names from the specified Dubbo service
interface
func (e *etcdMetadataReport) GetServiceAppMapping(key string, group string,
listener mapping.MappingListener) (*gxset.HashSet, error) {
path := e.rootDir + constant.PathSeparator + group +
constant.PathSeparator + key
+ if listener != nil {
+ logger.Warnf("etcd metadata report does not support service
mapping listener, "+
+ "mapping changes of %s will not be notified", path)
+ }
v, err := e.client.Get(path)
if err != nil {
return nil, err
}
- appNames := strings.Split(v, constant.CommaSeparator)
- set := gxset.NewSet()
- for _, app := range appNames {
- set.Add(app)
- }
- return set, nil
+ return report.DecodeServiceAppNames(v), nil
}
+// RemoveServiceAppMappingListener is a no-op: etcd metadata report does not
register a mapping
+// listener (see GetServiceAppMapping), so there is nothing to remove.
func (e *etcdMetadataReport) RemoveServiceAppMappingListener(key string, group
string) error {
return nil
}
diff --git a/metadata/report/mapping.go b/metadata/report/mapping.go
new file mode 100644
index 000000000..2f9c7952d
--- /dev/null
+++ b/metadata/report/mapping.go
@@ -0,0 +1,87 @@
+/*
+ * 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 report
+
+import (
+ "errors"
+ "strings"
+)
+
+import (
+ gxset "github.com/dubbogo/gost/container/set"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+)
+
+// ErrMappingCASConflict is returned by
MetadataReport.RegisterServiceAppMapping when an
+// optimistic-concurrency (compare-and-swap) write loses a race against a
concurrent writer.
+//
+// It is a *retriable* error: the caller should re-read the latest value,
re-merge its
+// application name, and try again. Each backend wraps its native conflict
error
+// (etcd ErrCompareFail, ZooKeeper ErrBadVersion / ErrNodeExists, Nacos CAS
publish failure)
+// with this sentinel using fmt.Errorf("...: %w", ...), so the upper layer can
classify
+// retriable vs. permanent failures with errors.Is and only burn its retry
budget on the
+// former.
+var ErrMappingCASConflict = errors.New("service-app mapping CAS conflict")
+
+// MergeServiceAppMapping merges application into oldVal, the comma-separated
set of
+// application names stored under a single interface key in the metadata
center.
+//
+// It compares whole elements rather than substrings. The previous
implementations used
+// strings.Contains(oldVal, application), which produced false positives —
e.g. registering
+// "order" was wrongly treated as already-present when the set contained
"order-service",
+// so "order" could never be written. It also never emits an empty element,
fixing the
+// leading-comma bug ("" + "," + app => ",app") triggered when oldVal is empty.
+//
+// It returns the merged value and whether oldVal changed. changed == false
means application
+// was already present and no write (hence no CAS round-trip) is required.
+//
+// New names are appended to the end rather than rebuilt from a set: this
keeps existing
+// ordering and makes the written bytes change minimally and
deterministically, which is
+// what optimistic concurrency relies on. Rebuilding from a Go map/set would
reorder the
+// value nondeterministically, producing spurious CAS conflicts and MD5 churn
even when the
+// logical content is unchanged.
+func MergeServiceAppMapping(oldVal, application string) (string, bool) {
+ if oldVal == "" {
+ return application, true
+ }
+ for _, app := range strings.Split(oldVal, constant.CommaSeparator) {
+ if app == application {
+ return oldVal, false
+ }
+ }
+ return oldVal + constant.CommaSeparator + application, true
+}
+
+// DecodeServiceAppNames parses the comma-separated application set stored
under an interface
+// key into a HashSet, skipping empty elements. A blank value yields an empty
set rather than a
+// set containing one empty string, which strings.Split("", ",") would
otherwise produce.
+func DecodeServiceAppNames(val string) *gxset.HashSet {
+ set := gxset.NewSet()
+ if val == "" {
+ return set
+ }
+ for _, app := range strings.Split(val, constant.CommaSeparator) {
+ if app != "" {
+ set.Add(app)
+ }
+ }
+ return set
+}
diff --git a/metadata/report/mapping_test.go b/metadata/report/mapping_test.go
new file mode 100644
index 000000000..cb40e1b73
--- /dev/null
+++ b/metadata/report/mapping_test.go
@@ -0,0 +1,66 @@
+/*
+ * 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 report
+
+import (
+ "testing"
+)
+
+import (
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMergeServiceAppMapping(t *testing.T) {
+ tests := []struct {
+ name string
+ oldVal string
+ application string
+ wantVal string
+ wantChanged bool
+ }{
+ {"empty old value", "", "appA", "appA", true},
+ {"append new app", "appA", "appB", "appA,appB", true},
+ {"already present", "appA,appB", "appA", "appA,appB", false},
+ {"already present last", "appA,appB", "appB", "appA,appB",
false},
+ // regression: strings.Contains would treat "order" as already
present in
+ // "order-service" and never write it. Whole-element matching
must not.
+ {"substring is not membership", "order-service", "order",
"order-service,order", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, changed := MergeServiceAppMapping(tt.oldVal,
tt.application)
+ assert.Equal(t, tt.wantVal, got)
+ assert.Equal(t, tt.wantChanged, changed)
+ })
+ }
+}
+
+func TestDecodeServiceAppNames(t *testing.T) {
+ // blank value yields an empty set, not a set containing one empty
string.
+ assert.True(t, DecodeServiceAppNames("").Empty())
+
+ set := DecodeServiceAppNames("appA,appB")
+ assert.Equal(t, 2, set.Size())
+ assert.True(t, set.Contains("appA"))
+ assert.True(t, set.Contains("appB"))
+
+ // empty elements from stray separators are skipped.
+ set = DecodeServiceAppNames("appA,,appB,")
+ assert.Equal(t, 2, set.Size())
+ assert.False(t, set.Contains(""))
+}
diff --git a/metadata/report/nacos/report.go b/metadata/report/nacos/report.go
index a1772d5ca..fa77626ae 100644
--- a/metadata/report/nacos/report.go
+++ b/metadata/report/nacos/report.go
@@ -18,7 +18,9 @@
package nacos
import (
+ "crypto/md5"
"encoding/json"
+ "fmt"
"strings"
)
@@ -141,12 +143,15 @@ func (n *nacosMetadataReport) addListener(key string,
group string, notify mappi
})
}
+// isConfigNotExistErr reports whether err is Nacos's "config data not exist"
signal, which it
+// returns (rather than an empty value) for a key that has never been written.
It must be treated
+// as an empty old value so the first registration can create the mapping.
+func isConfigNotExistErr(err error) bool {
+ return err != nil && strings.Contains(strings.ToLower(err.Error()),
"config data not exist")
+}
+
func callback(notify mapping.MappingListener, dataId, data string) {
- appNames := strings.Split(data, constant.CommaSeparator)
- set := gxset.NewSet()
- for _, app := range appNames {
- set.Add(app)
- }
+ set := report.DecodeServiceAppNames(data)
if err := notify.OnEvent(registry.NewServiceMappingChangedEvent(dataId,
set)); err != nil {
logger.Errorf("[Metadata][Nacos] serviceMapping callback
err=%v", err)
}
@@ -161,26 +166,49 @@ func (n *nacosMetadataReport)
removeServiceMappingListener(key string, group str
// RegisterServiceAppMapping map the specified Dubbo service interface to
current Dubbo app name
func (n *nacosMetadataReport) RegisterServiceAppMapping(key string, group
string, value string) error {
- oldVal, _ := n.getConfig(vo.ConfigParam{
+ oldVal, err := n.getConfig(vo.ConfigParam{
DataId: key,
Group: group,
})
- if oldVal != "" {
- oldApps := strings.Split(oldVal, constant.CommaSeparator)
- if len(oldApps) > 0 {
- for _, app := range oldApps {
- if app == value {
- return nil
- }
- }
- }
- value = oldVal + constant.CommaSeparator + value
+ if err != nil && !isConfigNotExistErr(err) {
+ // A real read failure (network/auth/server): do not treat it
as an empty value, or we
+ // would publish only our app and overwrite an existing set.
"config data not exist" is
+ // Nacos's not-found signal, which is fine here and proceeds to
the first write.
+ return err
}
- return n.storeMetadata(vo.ConfigParam{
+ merged, changed := report.MergeServiceAppMapping(oldVal, value)
+ if !changed {
+ return nil
+ }
+ param := vo.ConfigParam{
DataId: key,
Group: group,
- Content: value,
- })
+ Content: merged,
+ }
+ if oldVal != "" {
+ // CasMd5 is an optimistic UPDATE: Nacos publishes only if the
server content still
+ // matches what we read, detecting concurrent appends. It
cannot guard the first INSERT
+ // (Nacos has no create-if-absent), so the initial concurrent
registration of a
+ // brand-new interface can still race. This is a known
Nacos-only limitation; the
+ // etcd and zookeeper reports do not have it.
+ //
+ // The MD5 here is not a security mechanism: it is the checksum
the Nacos CAS wire
+ // protocol requires (PublishConfig forwards CasMd5 to the
server, which compares it
+ // against the stored content's MD5). The algorithm is dictated
by Nacos, not chosen
+ // by us, so the weak-hash warning does not apply. NOSONAR
+ param.CasMd5 = fmt.Sprintf("%x", md5.Sum([]byte(oldVal))) //
NOSONAR: Nacos CAS protocol checksum, not security
+ }
+ if err := n.storeMetadata(param); err != nil {
+ if param.CasMd5 != "" {
+ // Nacos surfaces a CAS rejection and a transport error
the same way, so they
+ // cannot be told apart here. Treat the failure as a
retriable conflict rather
+ // than risk dropping a real concurrent update; the
underlying error is preserved
+ // for diagnosis.
+ return fmt.Errorf("publish mapping %s (%v): %w", key,
err, report.ErrMappingCASConflict)
+ }
+ return err
+ }
+ return nil
}
// GetServiceAppMapping get the app names from the specified Dubbo service
interface
@@ -201,12 +229,7 @@ func (n *nacosMetadataReport) GetServiceAppMapping(key
string, group string, lis
if v == "" {
return nil, perrors.New("There is no service app mapping data.")
}
- appNames := strings.Split(v, constant.CommaSeparator)
- set := gxset.NewSet()
- for _, e := range appNames {
- set.Add(e)
- }
- return set, nil
+ return report.DecodeServiceAppNames(v), nil
}
// RemoveServiceAppMappingListener remove the serviceMapping listener from
metadata center
diff --git a/metadata/report/zookeeper/listener.go
b/metadata/report/zookeeper/listener.go
index 97fb15987..e2f7dc5d2 100644
--- a/metadata/report/zookeeper/listener.go
+++ b/metadata/report/zookeeper/listener.go
@@ -23,13 +23,13 @@ import (
)
import (
- gxset "github.com/dubbogo/gost/container/set"
"github.com/dubbogo/gost/log/logger"
)
import (
"dubbo.apache.org/dubbo-go/v3/common/constant"
"dubbo.apache.org/dubbo-go/v3/metadata/mapping"
+ "dubbo.apache.org/dubbo-go/v3/metadata/report"
"dubbo.apache.org/dubbo-go/v3/registry"
"dubbo.apache.org/dubbo-go/v3/remoting"
"dubbo.apache.org/dubbo-go/v3/remoting/zookeeper"
@@ -116,14 +116,19 @@ func (l *CacheListener) RemoveListener(key string,
listener mapping.MappingListe
}
}
+// RemoveKeyListeners drops all listeners registered for key so its mapping
change events stop
+// being dispatched. The dispatcher goroutine is shared by the whole mapping
group and is kept
+// alive (other keys still need it); it is released when the report is closed.
The key's
+// underlying ZooKeeper watch is not unregistered here, as ZkEventListener
exposes no per-path
+// unlisten, so the server may keep sending now-ignored events for the key.
+func (l *CacheListener) RemoveKeyListeners(key string) {
+ l.keyListeners.Delete(key)
+}
+
// DataChange changes all listeners' event
func (l *CacheListener) DataChange(event remoting.Event) bool {
if listeners, ok := l.keyListeners.Load(event.Path); ok {
- appNames := strings.Split(event.Content,
constant.CommaSeparator)
- set := gxset.NewSet()
- for _, e := range appNames {
- set.Add(e)
- }
+ set := report.DecodeServiceAppNames(event.Content)
err := listeners.(*ListenerSet).ForEach(func(listener
mapping.MappingListener) error {
return
listener.OnEvent(registry.NewServiceMappingChangedEvent(l.pathToKey(event.Path),
set))
})
diff --git a/metadata/report/zookeeper/listener_test.go
b/metadata/report/zookeeper/listener_test.go
index 8d28269ab..23d3d7bcc 100644
--- a/metadata/report/zookeeper/listener_test.go
+++ b/metadata/report/zookeeper/listener_test.go
@@ -26,6 +26,7 @@ import (
)
import (
+ gxset "github.com/dubbogo/gost/container/set"
"github.com/dubbogo/gost/gof/observer"
"github.com/stretchr/testify/assert"
@@ -36,6 +37,7 @@ import (
import (
"dubbo.apache.org/dubbo-go/v3/metadata/mapping"
+ "dubbo.apache.org/dubbo-go/v3/registry"
"dubbo.apache.org/dubbo-go/v3/remoting"
)
@@ -43,6 +45,7 @@ type mockMappingListener struct {
eventCount atomic.Int32
mu sync.Mutex
onEventErr error
+ lastNames *gxset.HashSet
}
func newMockMappingListener() *mockMappingListener { return
&mockMappingListener{} }
@@ -51,6 +54,9 @@ func (m *mockMappingListener) OnEvent(e observer.Event) error
{
m.mu.Lock()
defer m.mu.Unlock()
m.eventCount.Add(1)
+ if sm, ok := e.(*registry.ServiceMappingChangeEvent); ok {
+ m.lastNames = sm.GetServiceNames()
+ }
return m.onEventErr
}
@@ -164,3 +170,23 @@ func TestCacheListenerPathToKey(t *testing.T) {
assert.Equal(t, "com.example.Service",
cl.pathToKey("/dubbo/mapping/com.example.Service"))
assert.Empty(t, cl.pathToKey(""))
}
+
+// TestCacheListenerDataChangeFiltersEmptyAppNames ensures a change event
built from a
+// malformed/legacy comma-separated value (stray separators) does not surface
empty app names
+// to the listener.
+func TestCacheListenerDataChangeFiltersEmptyAppNames(t *testing.T) {
+ cl := NewCacheListener("/dubbo", nil)
+ key := "/dubbo/mapping/com.example.Service"
+
+ ml := newMockMappingListener()
+ listenerSet := NewListenerSet()
+ listenerSet.Add(ml)
+ cl.keyListeners.Store(key, listenerSet)
+
+ assert.True(t, cl.DataChange(remoting.Event{Path: key, Action:
remoting.EventTypeUpdate, Content: ",appA,,appB,"}))
+ require.NotNil(t, ml.lastNames)
+ assert.Equal(t, 2, ml.lastNames.Size())
+ assert.True(t, ml.lastNames.Contains("appA"))
+ assert.True(t, ml.lastNames.Contains("appB"))
+ assert.False(t, ml.lastNames.Contains(""))
+}
diff --git a/metadata/report/zookeeper/report.go
b/metadata/report/zookeeper/report.go
index 577267847..5e3b2d2e4 100644
--- a/metadata/report/zookeeper/report.go
+++ b/metadata/report/zookeeper/report.go
@@ -19,6 +19,7 @@ package zookeeper
import (
"encoding/json"
+ "fmt"
"strings"
)
@@ -94,17 +95,27 @@ func (m *zookeeperMetadataReport)
RegisterServiceAppMapping(key string, group st
path := m.rootDir + group + constant.PathSeparator + key
v, state, err := m.client.GetContent(path)
if err == zk.ErrNoNode {
- return m.client.CreateWithValue(path, []byte(value))
+ if cErr := m.client.CreateWithValue(path, []byte(value)); cErr
!= nil {
+ if perrors.Is(cErr, zk.ErrNodeExists) {
+ return fmt.Errorf("create mapping %s: %w",
path, report.ErrMappingCASConflict)
+ }
+ return cErr
+ }
+ return nil
} else if err != nil {
return err
}
- oldValue := string(v)
- if strings.Contains(oldValue, value) {
+ merged, changed := report.MergeServiceAppMapping(string(v), value)
+ if !changed {
return nil
}
- value = oldValue + constant.CommaSeparator + value
- _, err = m.client.SetContent(path, []byte(value), state.Version)
- return err
+ if _, sErr := m.client.SetContent(path, []byte(merged), state.Version);
sErr != nil {
+ if perrors.Is(sErr, zk.ErrBadVersion) {
+ return fmt.Errorf("update mapping %s: %w", path,
report.ErrMappingCASConflict)
+ }
+ return sErr
+ }
+ return nil
}
// GetServiceAppMapping get the app names from the specified Dubbo service
interface
@@ -120,15 +131,12 @@ func (m *zookeeperMetadataReport)
GetServiceAppMapping(key string, group string,
if err != nil {
return nil, err
}
- appNames := strings.Split(string(v), constant.CommaSeparator)
- set := gxset.NewSet()
- for _, e := range appNames {
- set.Add(e)
- }
- return set, nil
+ return report.DecodeServiceAppNames(string(v)), nil
}
func (m *zookeeperMetadataReport) RemoveServiceAppMappingListener(key string,
group string) error {
+ path := m.rootDir + group + constant.PathSeparator + key
+ m.cacheListener.RemoveKeyListeners(path)
return nil
}
diff --git a/metadata/report/zookeeper/report_test.go
b/metadata/report/zookeeper/report_test.go
index 8bbb47f5a..d17db0eef 100644
--- a/metadata/report/zookeeper/report_test.go
+++ b/metadata/report/zookeeper/report_test.go
@@ -19,7 +19,6 @@ package zookeeper
import (
"encoding/json"
- "strings"
"testing"
)
@@ -32,6 +31,7 @@ import (
"dubbo.apache.org/dubbo-go/v3/common"
"dubbo.apache.org/dubbo-go/v3/common/constant"
"dubbo.apache.org/dubbo-go/v3/metadata/info"
+ "dubbo.apache.org/dubbo-go/v3/metadata/report"
)
func TestMetadataInfoSerialization(t *testing.T) {
@@ -62,19 +62,19 @@ func TestMetadataInfoSerialization(t *testing.T) {
func TestRegisterServiceAppMappingValueMerge(t *testing.T) {
tests := []struct {
oldValue, newValue, expected string
+ wantChanged bool
}{
- {"app1", "app2", "app1,app2"},
- {"app1,app2", "app1", "app1,app2"},
- {"", "app1", ",app1"},
+ {"app1", "app2", "app1,app2", true},
+ {"app1,app2", "app1", "app1,app2", false},
+ // empty old value must not produce a leading comma (was
",app1")
+ {"", "app1", "app1", true},
+ // substring must not be mistaken for membership (was wrongly
treated as present)
+ {"app1-extra", "app1", "app1-extra,app1", true},
}
for _, tt := range tests {
- var result string
- if strings.Contains(tt.oldValue, tt.newValue) {
- result = tt.oldValue
- } else {
- result = tt.oldValue + "," + tt.newValue
- }
+ result, changed := report.MergeServiceAppMapping(tt.oldValue,
tt.newValue)
assert.Equal(t, tt.expected, result)
+ assert.Equal(t, tt.wantChanged, changed)
}
}