Copilot commented on code in PR #3371:
URL: https://github.com/apache/dubbo-go/pull/3371#discussion_r3367021937
##########
metadata/report/zookeeper/report.go:
##########
@@ -82,13 +108,48 @@ func (m *zookeeperMetadataReport)
PublishAppMetadata(application, revision strin
return err
}
err = m.client.CreateWithValue(k, data)
- if perrors.Is(err, zk.ErrNodeExists) {
- logger.Debug("[Metadata][Zookeeper] try to create the node data
failed. In most cases, it's not a problem. ")
+ if err == zk.ErrNodeExists {
+ _, err = m.client.SetContent(k, data, -1)
+ }
Review Comment:
`CreateWithValue` may return a wrapped `zk.ErrNodeExists` (other codepaths
in this repo handle it via `perrors.Is`). Using direct equality here risks
skipping the update path and leaving existing app-metadata nodes stale when
republishing.
Consider switching to `errors.Is(err, zk.ErrNodeExists)` (or `perrors.Is`),
and similarly for `zk.ErrNoNode` checks if the client wraps errors.
##########
metadata/report/nacos/report.go:
##########
@@ -214,6 +214,70 @@ func (n *nacosMetadataReport)
RemoveServiceAppMappingListener(key string, group
return n.removeServiceMappingListener(key, group)
}
+// UnPublishAppMetadata removes metadata for a specific revision from nacos.
+// This operation is idempotent — deleting a non-existent config returns false
but no error.
+func (n *nacosMetadataReport) UnPublishAppMetadata(application, revision
string) error {
+ // Delete primary config (compatible with java impl)
+ _, err := n.client.Client().DeleteConfig(vo.ConfigParam{
+ DataId: application,
+ Group: revision,
+ })
+ if err != nil {
+ return perrors.WithMessage(err, "Could not delete the metadata")
+ }
+ // Delete legacy config (compatible with dubbo-go 3.1.x).
+ if _, err = n.client.Client().DeleteConfig(vo.ConfigParam{
+ DataId: application + constant.KeySeparator + revision,
+ Group: n.group,
+ }); err != nil {
+ logger.Warnf("[Metadata][Nacos] could not delete legacy
metadata for app=%s rev=%s: %v",
+ application, revision, err)
+ }
+ return nil
+}
+
+// ListAppRevisions lists all stored revisions for an application from nacos.
+func (n *nacosMetadataReport) ListAppRevisions(application string)
([]report.AppRevision, error) {
+ pageNo, pageSize := 1, 500
+ configs, err := n.client.Client().SearchConfig(vo.SearchConfigParam{
+ Search: "accurate",
+ DataId: application,
+ Group: "",
+ PageNo: pageNo,
+ PageSize: pageSize,
+ })
Review Comment:
`ListAppRevisions` only fetches the first page (500) and then proceeds with
a partial set of revisions. This can make GC incomplete for long-lived apps
(revisions beyond the first page will never be considered for cleanup). The
warning log mentions silent drops, but GC correctness still depends on seeing
all revisions.
##########
registry/servicediscovery/service_discovery_registry.go:
##########
@@ -298,12 +320,142 @@ func (s *serviceDiscoveryRegistry) IsAvailable() bool {
}
func (s *serviceDiscoveryRegistry) Destroy() {
+ s.stopMetadataTimers()
err := s.serviceDiscovery.Destroy()
if err != nil {
logger.Errorf("[Registry][ServiceDiscovery] destroy
serviceDiscovery catch error, err=%s", err.Error())
}
}
+func (s *serviceDiscoveryRegistry) stopMetadataTimers() {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+ if s.renewAppMetadataTimer != nil {
+ s.renewAppMetadataTimer.Stop()
+ s.renewAppMetadataTimer = nil
+ }
+}
+
+// ========== renewAppMetadata: daily app-level metadata re-publish ==========
+
+func (s *serviceDiscoveryRegistry) startRenewAppMetadataTimer() {
+ if !s.url.GetParamBool(constant.CycleReportKey, true) {
+ return
+ }
+
+ // Run immediately on start
+ if s.url.GetParamBool(constant.MetadataRenewOnStartupKey, true) {
+ go s.doRenewAppMetadata()
+ }
+
+ delay := s.calculateRenewAppMetadataDelay()
+ s.renewAppMetadataTimer = time.AfterFunc(delay, func() {
+ s.doRenewAppMetadata()
+ // Reschedule for next day
+ s.lock.Lock()
+ if s.renewAppMetadataTimer != nil {
+ s.renewAppMetadataTimer.Reset(24 * time.Hour)
+ }
+ s.lock.Unlock()
+ })
+}
+
+func (s *serviceDiscoveryRegistry) doRenewAppMetadata() {
+ registryID := s.url.GetParam(constant.RegistryIdKey, "")
+ metaInfo := metadata.GetMetadataInfo(registryID)
+ if metaInfo == nil || metaInfo.Revision == "0" {
+ return
+ }
+ metaInfo.LastUpdatedTime = time.Now().UnixMilli()
+ if err := s.metadataReport.PublishAppMetadata(metaInfo.App,
metaInfo.Revision, metaInfo); err != nil {
+ logger.Errorf("[Metadata][renewAppMetadata] failed to
re-publish metadata for app=%s revision=%s: %v", metaInfo.App,
metaInfo.Revision, err)
+ } else {
+ logger.Infof("[Metadata][renewAppMetadata] refreshed metadata
for app=%s revision=%s", metaInfo.App, metaInfo.Revision)
+ }
+
+ // Run garbage collection if enabled, after each renew cycle
+ if s.url.GetParamBool(constant.MetadataGCEnabledKey, true) {
+ s.doGarbageCollect()
+ }
+}
+
+func (s *serviceDiscoveryRegistry) calculateRenewAppMetadataDelay()
time.Duration {
+ now := time.Now()
+ // Next day 2:00 AM
+ nextDay2AM := time.Date(now.Year(), now.Month(), now.Day()+1, 2, 0, 0,
0, now.Location())
+ // Add random offset 0~4 hours to avoid thundering herd
+ randomOffset := time.Duration(rand.Int64N(int64(4 * time.Hour)))
+ return time.Until(nextDay2AM) + randomOffset
+}
+
+// ========== GC: stale revision cleanup ==========
+
+func (s *serviceDiscoveryRegistry) doGarbageCollect() {
+ registryID := s.url.GetParam(constant.RegistryIdKey, "")
+ metaInfo := metadata.GetMetadataInfo(registryID)
+ if metaInfo == nil {
+ return
+ }
+ app := metaInfo.App
+ if app == "" {
+ return
+ }
+
+ // Step 1: List all revisions for this app
+ revisions, err := s.metadataReport.ListAppRevisions(app)
+ if err != nil {
+ logger.Warnf("[Metadata][GC] failed to list app revisions: %v",
err)
+ return
+ }
+ if len(revisions) == 0 {
+ return
+ }
+
+ // Step 2: Filter stale candidates (exceed GC window in days)
+ gcWindowDays := s.url.GetParamByIntValue(constant.MetadataGCWindowKey,
5)
+ if gcWindowDays <= 0 || gcWindowDays > 365 {
+ gcWindowDays = 5
+ }
+ cutoff := time.Now().AddDate(0, 0, -gcWindowDays).UnixMilli()
+ candidates := make(map[string]bool)
+ for _, rev := range revisions {
+ // Skip special revisions
+ if rev.Revision == "0" || rev.Revision == "N/A" || rev.Revision
== "" || rev.Revision == metaInfo.Revision {
+ continue
+ }
+ // ModifyTime == 0 means old metadata produced by a version
that does not set
+ // lastUpdatedTime — never garbage-collect such entries. Only
delete when the
+ // revision is older than gcWindow and no alive instance
references it.
Review Comment:
The comment describes deleting `ModifyTime == 0` entries when they are older
than the GC window, but the current logic never adds `ModifyTime == 0`
revisions to GC candidates. This is confusing for readers and makes it harder
to reason about the actual cleanup semantics.
##########
metadata/report/etcd/report.go:
##########
@@ -111,6 +128,39 @@ func (e *etcdMetadataReport)
RemoveServiceAppMappingListener(key string, group s
return nil
}
+// UnPublishAppMetadata removes metadata for a specific revision from etcd.
+// This operation is idempotent.
+func (e *etcdMetadataReport) UnPublishAppMetadata(application, revision
string) error {
+ key := e.rootDir + constant.PathSeparator + application +
constant.PathSeparator + revision
+ return e.client.Delete(key)
+}
+
+func (e *etcdMetadataReport) ListAppRevisions(application string)
([]report.AppRevision, error) {
+ prefix := e.rootDir + constant.PathSeparator + application +
constant.PathSeparator
+ keys, _, err := e.client.GetChildren(prefix)
+ if err != nil {
+ if perrors.Cause(err) == gxetcd.ErrKVPairNotFound {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ result := make([]report.AppRevision, 0, len(keys))
+ for _, key := range keys {
+ // Extract revision from key suffix (key is full path, revision
is last segment)
+ revision := key[strings.LastIndex(key,
constant.PathSeparator)+1:]
+ val, err := e.client.Get(key)
+ if err != nil {
+ continue // skip if key disappeared between listing and
reading
+ }
+ result = append(result, report.AppRevision{
+ Revision: revision,
+ ModifyTime:
report.ParseMetadataLastUpdatedTime([]byte(val)),
+ })
+ }
+ return result, nil
+}
Review Comment:
`ListAppRevisions` already receives both keys and values from `GetChildren`,
but it discards the values and performs an extra `Get` per key. In real etcd
this becomes an N+1 network round-trip pattern and can noticeably slow down the
daily GC pass for apps with many revisions.
##########
metadata/report/zookeeper/report_test.go:
##########
@@ -34,6 +36,267 @@ import (
"dubbo.apache.org/dubbo-go/v3/metadata/info"
)
+// --- Mock zkClient ---
+// mockZkClient implements zkClient for testing.
+type mockZkClient struct {
+ data map[string][]byte // path -> value
+ stats map[string]*zk.Stat // path -> stat
+ errors map[string]error // path -> error for specific operations
(optional override)
+}
+
+func newMockZkClient() *mockZkClient {
+ return &mockZkClient{
+ data: make(map[string][]byte),
+ stats: make(map[string]*zk.Stat),
+ errors: make(map[string]error),
+ }
+}
+
+func (m *mockZkClient) GetContent(path string) ([]byte, *zk.Stat, error) {
+ if err, ok := m.errors["GetContent:"+path]; ok {
+ return nil, nil, err
+ }
+ v, ok := m.data[path]
+ if !ok {
+ return nil, nil, zk.ErrNoNode
+ }
+ return v, m.stats[path], nil
+}
+
+func (m *mockZkClient) SetContent(path string, data []byte, version int32)
(*zk.Stat, error) {
+ if err, ok := m.errors["SetContent:"+path]; ok {
+ return nil, err
+ }
+ m.data[path] = data
+ stat := &zk.Stat{Version: version + 1, Mtime: int64(version + 1)}
+ m.stats[path] = stat
+ return stat, nil
+}
+
+func (m *mockZkClient) CreateWithValue(path string, data []byte) error {
+ if err, ok := m.errors["CreateWithValue:"+path]; ok {
+ return err
+ }
+ m.data[path] = data
+ stat := &zk.Stat{Version: 0, Mtime: 0}
+ m.stats[path] = stat
+ return nil
+}
Review Comment:
The new zookeeper update behavior (create-or-update on `ErrNodeExists`)
isn't actually exercised by the tests because this mock `CreateWithValue` never
returns `zk.ErrNodeExists` for existing paths. As a result,
`TestPublishAppMetadata_Update` can pass even if the production update branch
is broken.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]