This is an automated email from the ASF dual-hosted git repository.
Alanxtl 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 2046d8dec Feat: Add ZK config cache (#3612)
2046d8dec is described below
commit 2046d8decbf889e7676be963cdad5e74d1bf66fd
Author: wm_03 <[email protected]>
AuthorDate: Tue Sep 1 17:13:17 2026 +0800
Feat: Add ZK config cache (#3612)
* feat: add Zookeeper config cache
* test: cover Zookeeper cache and watch flows
* refactor(zookeeper): bound config cache path locks
* fix(zookeeper): bound config cache entries
* refactor(zookeeper): track watcher ownership
* fix(zookeeper): bound automatic watches
* fix(zookeeper): complete config watch lifecycle
* fix(zookeeper): align listener paths and empty config events
* fix(zookeeper): restore and clean up watches on reconnect
* fix(zookeeper): safely remove expired cache entries
* fix(zookeeper): restore business watches with cache disabled
* fix(zookeeper): release configuration listener wait group
* ci: configure ZooKeeper dependency for tests
* fix(zookeeper): adapt config watches to upstream API
* test(zookeeper): cover session-aware watch registration
* test(zookeeper): cover retired watch lifecycle
* fix(zookeeper): restore watches across sessions
* fix(zookeeper): synchronize config watch lifecycle
* fix(zookeeper): synchronize watch ownership and retries
* fix(zookeeper): reject stale watch results
* fix(zookeeper): synchronize listener watch retries
* fix: Add the TTL upper and lower bounds
* annotations: Add relevant annotations for the core concepts
---
.github/workflows/github-actions.yml | 26 +
common/constant/key.go | 1 +
config_center/zookeeper/config_cache.go | 703 +++++++++++++++++++++++++++
config_center/zookeeper/config_cache_test.go | 572 ++++++++++++++++++++++
config_center/zookeeper/impl.go | 150 +++++-
config_center/zookeeper/impl_test.go | 338 +++++++++++++
config_center/zookeeper/listener.go | 345 ++++++++++++-
config_center/zookeeper/listener_test.go | 452 ++++++++++++++++-
remoting/zookeeper/listener.go | 186 +++++--
remoting/zookeeper/listener_test.go | 207 ++++++++
10 files changed, 2892 insertions(+), 88 deletions(-)
diff --git a/.github/workflows/github-actions.yml
b/.github/workflows/github-actions.yml
index 8fec237c7..13bdce900 100644
--- a/.github/workflows/github-actions.yml
+++ b/.github/workflows/github-actions.yml
@@ -48,6 +48,32 @@ jobs:
- name: Setup Go
uses: ./.github/actions/setup-go
+ - name: Setup Java for ZooKeeper tests
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '8'
+
+ - name: Prepare ZooKeeper test dependency
+ run: |
+ set -euo pipefail
+
+ go mod download github.com/dubbogo/gost
+
+ gost_dir="$(go list -m -f '{{.Dir}}' github.com/dubbogo/gost)"
+ jar_path="$(find "$gost_dir/database/kv/zk" \
+ -type f \
+ -path '*/contrib/fatjar/zookeeper-*-fatjar.jar' \
+ -print -quit)"
+
+ test -n "$jar_path"
+ java -version
+
+ zk_path="${jar_path%/contrib/fatjar/*}"
+ test -f "$jar_path"
+
+ echo "ZOOKEEPER_PATH=$zk_path" >> "$GITHUB_ENV"
+
- name: Run unit tests
run: make test
diff --git a/common/constant/key.go b/common/constant/key.go
index bcd35c26a..8b24dbde3 100644
--- a/common/constant/key.go
+++ b/common/constant/key.go
@@ -261,6 +261,7 @@ const (
ConfigSecretKey = "config-center.secret"
ConfigBackupConfigKey = "config-center.isBackupConfig"
ConfigBackupConfigPathKey = "config-center.backupConfigPath"
+ ConfigCacheTTLKey = "config-center.cache-ttl"
ConfigRootPathParamKey = "dubbo.config-center.root-path"
)
diff --git a/config_center/zookeeper/config_cache.go
b/config_center/zookeeper/config_cache.go
new file mode 100644
index 000000000..a3d525dea
--- /dev/null
+++ b/config_center/zookeeper/config_cache.go
@@ -0,0 +1,703 @@
+/*
+ * 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 zookeeper
+
+import (
+ "errors"
+ "sync"
+ "time"
+)
+
+import (
+ "github.com/go-zookeeper/zk"
+
+ "github.com/hashicorp/golang-lru"
+)
+
+const (
+ pathLockShardCount = 128
+ maxCacheEntries = 1024
+ maxAutoWatches = 1024
+ maxCacheLoadRetries = 3
+)
+
+var errWatchRegistrationStale = errors.New("zookeeper watch registration
became stale")
+var errBusinessWatchCanceled = errors.New("zookeeper business watch
registration canceled")
+
+// watchOperation represents one in-flight watch registration. Concurrent
+// callers for the same path wait on done instead of registering another watch,
+// while token prevents a late result from completing a newer operation.
+type watchOperation struct {
+ token uint64
+ done chan struct{}
+ err error
+ completed bool
+}
+
+// configCacheEntry keeps node existence separate from content so a missing
+// node is distinguishable from an existing node whose content is empty.
+type configCacheEntry struct {
+ content string
+ exists bool
+ expiresAt time.Time
+}
+
+// watchRegistration captures the event channel returned by ZooKeeper and the
+// sessions around both watch registration and its optional follow-up read. A
+// result is usable only when these operations belong to one stable session.
+type watchRegistration struct {
+ events <-chan zk.Event
+ beforeSessionID int64
+ afterSessionID int64
+ readBeforeSessionID int64
+ readAfterSessionID int64
+}
+
+// resolve reports the session associated with the registration. If
registration
+// crosses a session boundary, it also rejects a watch whose channel has
already
+// received an event.
+func (r watchRegistration) resolve() (int64, bool) {
+ if r.events == nil || r.afterSessionID == 0 {
+ return 0, false
+ }
+ if r.beforeSessionID != r.afterSessionID {
+ select {
+ case <-r.events:
+ return 0, false
+ default:
+ }
+ }
+ return r.resultSessionID(), true
+}
+
+// sessionStable reports whether registration and any follow-up read stayed in
+// the same ZooKeeper session. Zero pairs are allowed for operations that did
+// not access a connection, but all observed non-zero IDs must agree.
+func (r watchRegistration) sessionStable() bool {
+ if !stableSessionPair(r.beforeSessionID, r.afterSessionID) ||
+ !stableSessionPair(r.readBeforeSessionID, r.readAfterSessionID)
{
+ return false
+ }
+ var sessionID int64
+ for _, id := range []int64{
+ r.beforeSessionID,
+ r.afterSessionID,
+ r.readBeforeSessionID,
+ r.readAfterSessionID,
+ } {
+ if id == 0 {
+ continue
+ }
+ if sessionID == 0 {
+ sessionID = id
+ continue
+ }
+ if sessionID != id {
+ return false
+ }
+ }
+ return true
+}
+
+func (r watchRegistration) resultSessionID() int64 {
+ if r.readAfterSessionID != 0 {
+ return r.readAfterSessionID
+ }
+ return r.afterSessionID
+}
+
+func stableSessionPair(beforeSessionID, afterSessionID int64) bool {
+ return (beforeSessionID == 0 && afterSessionID == 0) ||
+ (beforeSessionID != 0 && beforeSessionID == afterSessionID)
+}
+
+// configWatchState tracks the lifecycle and ownership of one concrete-path
+// watch. Auto watches accelerate cache updates and consume the bounded auto
+// quota; business watches are retained while configuration listeners exist.
+type configWatchState struct {
+ registered bool // ZooKeeper accepted the watch registration.
+ pending bool // A registration is in flight and reserves
ownership.
+ auto bool // The cache, rather than a business
listener, owns the watch.
+ retired bool // Business ownership ended, but no auto
quota was available.
+ sessionID int64 // ZooKeeper session associated with the
registered or pending watch.
+ pendingOp *watchOperation // Shared completion state for the in-flight
registration.
+}
+
+func (s configWatchState) tracked() bool {
+ return s.registered || s.pending
+}
+
+func (s configWatchState) holdsAutoSlot() bool {
+ return s.auto && s.tracked()
+}
+
+func (s configWatchState) holdsAutoWatch() bool {
+ return s.auto && s.registered
+}
+
+func (s configWatchState) holdsAutoReservation() bool {
+ return s.auto && s.pending
+}
+
+func (s configWatchState) pendingOpToken() uint64 {
+ if s.pendingOp == nil {
+ return 0
+ }
+ return s.pendingOp.token
+}
+
+// configCache combines a bounded read-through cache with the concrete-path
+// watch state needed to keep cached values current between TTL refreshes.
+type configCache struct {
+ ttl time.Duration
+
+ stateLock sync.RWMutex
+ entries *lru.Cache // Bounded LRU containing both
existing and missing nodes.
+ watches map[string]configWatchState
+ autoWatchCount int
+ autoWatchReservations int
+ generation uint64 // Incremented on reset to reject results
from earlier cache epochs.
+ sessionID int64 // Current ZooKeeper session observed by
the latest reset.
+ nextWatchToken uint64
+
+ // Fixed shards serialize cache and listener transitions for the same
path
+ // without retaining one lock for every path ever requested.
+ pathLocks [pathLockShardCount]sync.Mutex
+}
+
+func newConfigCache(ttl time.Duration) configCache {
+ entries, err := lru.New(maxCacheEntries)
+ if err != nil {
+ panic(err)
+ }
+ return configCache{
+ ttl: ttl,
+ entries: entries,
+ watches: make(map[string]configWatchState),
+ }
+}
+
+func (c *configCache) enabled() bool {
+ return c.ttl > 0
+}
+
+func (c *configCache) load(
+ path string,
+ loader func(bool) (configCacheEntry, watchRegistration, error),
+) (configCacheEntry, error) {
+ if !c.enabled() {
+ generation, _ := c.snapshot(path)
+ entry, registration, err := loader(false)
+ if err == nil && !c.loadResultCurrent(generation, registration)
{
+ return configCacheEntry{}, errWatchRegistrationStale
+ }
+ return entry, err
+ }
+ if entry, ok := c.getFresh(path); ok {
+ return entry, nil
+ }
+
+ pathLock := c.pathLock(path)
+ pathLock.Lock()
+ defer pathLock.Unlock()
+
+ watchRetries := 0
+ for attempt := 0; attempt <= maxCacheLoadRetries; attempt++ {
+ if entry, ok := c.getFresh(path); ok {
+ return entry, nil
+ }
+
+ generation, registerWatch, token := c.prepareLoad(path)
+ entry, registration, err := loader(registerWatch)
+ if registerWatch {
+ stored := c.finishWatchRegistrationLocked(path,
generation, token, registration)
+ if !stored || !c.loadResultCurrent(generation,
registration) {
+ if watchRetries == 0 && attempt <
maxCacheLoadRetries {
+ watchRetries++
+ continue
+ }
+ fallbackEntry, fallbackRegistration,
fallbackErr := loader(false)
+ if fallbackErr != nil {
+ return configCacheEntry{}, fallbackErr
+ }
+ if !c.loadResultCurrent(generation,
fallbackRegistration) {
+ if attempt == maxCacheLoadRetries {
+ return configCacheEntry{},
errWatchRegistrationStale
+ }
+ continue
+ }
+ if c.storeLoadEntry(path, generation,
fallbackEntry) {
+ return fallbackEntry, nil
+ }
+ if attempt == maxCacheLoadRetries {
+ return configCacheEntry{},
errWatchRegistrationStale
+ }
+ continue
+ }
+ } else if !c.loadResultCurrent(generation, registration) {
+ if attempt == maxCacheLoadRetries {
+ return configCacheEntry{},
errWatchRegistrationStale
+ }
+ continue
+ }
+
+ if err != nil {
+ if attempt == maxCacheLoadRetries &&
!c.isCurrentGeneration(generation) {
+ return configCacheEntry{},
errWatchRegistrationStale
+ }
+ if !c.isCurrentGeneration(generation) {
+ continue
+ }
+ return configCacheEntry{}, err
+ }
+
+ if c.storeLoadEntry(path, generation, entry) {
+ return entry, nil
+ }
+ if attempt == maxCacheLoadRetries {
+ return configCacheEntry{}, errWatchRegistrationStale
+ }
+ }
+ return configCacheEntry{}, errWatchRegistrationStale
+}
+
+func (c *configCache) prepareLoad(path string) (uint64, bool, uint64) {
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+
+ generation := c.generation
+ if c.watches[path].tracked() {
+ return generation, false, 0
+ }
+
+ op := c.newWatchOperationLocked()
+ pendingState := configWatchState{auto: true, pending: true, sessionID:
c.sessionID, pendingOp: op}
+ if !c.setWatchStateLocked(path, pendingState) {
+ return generation, false, 0
+ }
+ return generation, true, op.token
+}
+
+func (c *configCache) store(path string, entry configCacheEntry) {
+ if !c.enabled() {
+ return
+ }
+ pathLock := c.pathLock(path)
+ pathLock.Lock()
+ defer pathLock.Unlock()
+
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+ c.storeEntryLocked(path, entry)
+}
+
+func (c *configCache) storeAtGenerationLocked(path string, generation uint64,
entry configCacheEntry) {
+ if !c.enabled() {
+ return
+ }
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+ if c.generation == generation {
+ c.storeEntryLocked(path, entry)
+ }
+}
+
+func (c *configCache) storeLocked(path string, entry configCacheEntry) {
+ if !c.enabled() {
+ return
+ }
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+ c.storeEntryLocked(path, entry)
+}
+
+func (c *configCache) getFresh(path string) (configCacheEntry, bool) {
+ c.stateLock.RLock()
+ value, ok := c.entries.Get(path)
+ if !ok {
+ c.stateLock.RUnlock()
+ return configCacheEntry{}, false
+ }
+ entry := value.(configCacheEntry)
+ if entry.expiresAt.After(time.Now()) {
+ c.stateLock.RUnlock()
+ return entry, true
+ }
+ c.stateLock.RUnlock()
+
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+ value, ok = c.entries.Get(path)
+ if !ok {
+ return configCacheEntry{}, false
+ }
+ entry = value.(configCacheEntry)
+ if entry.expiresAt.After(time.Now()) {
+ return entry, true
+ }
+ c.entries.Remove(path)
+ return configCacheEntry{}, false
+}
+
+func (c *configCache) storeEntryLocked(path string, entry configCacheEntry) {
+ entry.expiresAt = time.Now().Add(c.ttl)
+ c.entries.Add(path, entry)
+}
+
+func (c *configCache) snapshot(path string) (uint64, configWatchState) {
+ c.stateLock.RLock()
+ defer c.stateLock.RUnlock()
+ return c.generation, c.watches[path]
+}
+
+func (c *configCache) isCurrentGeneration(generation uint64) bool {
+ c.stateLock.RLock()
+ defer c.stateLock.RUnlock()
+ return c.generation == generation
+}
+
+// loadResultCurrent is the final fence before a ZooKeeper read can affect the
+// cache: both the cache generation and every observed session must be current.
+func (c *configCache) loadResultCurrent(generation uint64, registration
watchRegistration) bool {
+ if !registration.sessionStable() {
+ return false
+ }
+ c.stateLock.RLock()
+ defer c.stateLock.RUnlock()
+ if c.generation != generation {
+ return false
+ }
+ resultSessionID := registration.resultSessionID()
+ return c.sessionID == 0 ||
+ (resultSessionID != 0 && resultSessionID == c.sessionID)
+}
+
+func (c *configCache) storeLoadEntry(path string, generation uint64, entry
configCacheEntry) bool {
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+ if c.generation != generation {
+ return false
+ }
+ c.storeEntryLocked(path, entry)
+ return true
+}
+
+func (c *configCache) newWatchOperationLocked() *watchOperation {
+ c.nextWatchToken++
+ return &watchOperation{
+ token: c.nextWatchToken,
+ done: make(chan struct{}),
+ }
+}
+
+func (c *configCache) completeWatchOperationLocked(op *watchOperation, err
error) {
+ if op == nil || op.completed {
+ return
+ }
+ op.err = err
+ op.completed = true
+ close(op.done)
+}
+
+func (c *configCache) clearWatchStateLocked(path string, err error) {
+ current := c.watches[path]
+ if current.pending {
+ c.completeWatchOperationLocked(current.pendingOp, err)
+ }
+ c.setWatchStateLocked(path, configWatchState{})
+}
+
+func (c *configCache) setWatch(path string, watchState configWatchState) bool {
+ pathLock := c.pathLock(path)
+ pathLock.Lock()
+ defer pathLock.Unlock()
+
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+ return c.setWatchStateLocked(path, watchState)
+}
+
+// setWatchStateLocked is the single accounting point for auto watch capacity.
+// The caller must hold stateLock while replacing a path's watch state.
+func (c *configCache) setWatchStateLocked(path string, watchState
configWatchState) bool {
+ current := c.watches[path]
+ if !watchState.pending {
+ watchState.pendingOp = nil
+ }
+ if watchState.auto && !c.enabled() {
+ return false
+ }
+ if watchState.holdsAutoSlot() && !current.holdsAutoSlot() &&
+ c.autoWatchCount+c.autoWatchReservations >= maxAutoWatches {
+ return false
+ }
+ if current.holdsAutoWatch() {
+ c.autoWatchCount--
+ }
+ if current.holdsAutoReservation() {
+ c.autoWatchReservations--
+ }
+ if !watchState.tracked() {
+ delete(c.watches, path)
+ return true
+ }
+ c.watches[path] = watchState
+ if watchState.holdsAutoWatch() {
+ c.autoWatchCount++
+ }
+ if watchState.holdsAutoReservation() {
+ c.autoWatchReservations++
+ }
+ return true
+}
+
+func (c *configCache) finishWatchRegistration(path string, generation, token
uint64, registration watchRegistration) bool {
+ pathLock := c.pathLock(path)
+ pathLock.Lock()
+ defer pathLock.Unlock()
+ return c.finishWatchRegistrationLocked(path, generation, token,
registration)
+}
+
+// finishWatchRegistrationLocked commits a pending watch only when its token,
+// cache generation, and ZooKeeper session still match the current state.
+func (c *configCache) finishWatchRegistrationLocked(path string, generation,
token uint64, registration watchRegistration) bool {
+ sessionID, active := registration.resolve()
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+
+ watchState := c.watches[path]
+ if !watchState.pending {
+ return false
+ }
+ if (watchState.pendingOp != nil && watchState.pendingOp.token != token)
||
+ (watchState.pendingOp == nil && token != 0) {
+ return false
+ }
+ generationCurrent := c.generation == generation
+ sessionCurrent := c.sessionID == 0 ||
+ (sessionID != 0 && sessionID == c.sessionID)
+ if !active || !generationCurrent || !registration.sessionStable() ||
!sessionCurrent {
+ c.clearWatchStateLocked(path, errWatchRegistrationStale)
+ return false
+ }
+ op := watchState.pendingOp
+ watchState.registered = true
+ watchState.pending = false
+ watchState.sessionID = sessionID
+ watchState.pendingOp = nil
+ stored := c.setWatchStateLocked(path, watchState)
+ if stored {
+ c.completeWatchOperationLocked(op, nil)
+ }
+ return stored
+}
+
+func (c *configCache) ensureBusinessWatch(
+ path string,
+ register func() (watchRegistration, error),
+) error {
+ return c.ensureBusinessWatchWithRetry(path, register, 1)
+}
+
+func (c *configCache) ensureBusinessWatchWithRetry(
+ path string,
+ register func() (watchRegistration, error),
+ maxRetries int,
+) error {
+ return c.ensureBusinessWatchWithRetryIf(path, register, maxRetries, nil)
+}
+
+func (c *configCache) ensureBusinessWatchWithRetryIf(
+ path string,
+ register func() (watchRegistration, error),
+ maxRetries int,
+ stillNeeded func() bool,
+) error {
+ for attempt := 0; ; attempt++ {
+ if stillNeeded != nil && !stillNeeded() {
+ return errBusinessWatchCanceled
+ }
+ err := c.ensureBusinessWatchOnce(path, register)
+ if err == nil && stillNeeded != nil && !stillNeeded() {
+ return errBusinessWatchCanceled
+ }
+ if !errors.Is(err, errWatchRegistrationStale) || attempt >=
maxRetries {
+ return err
+ }
+ }
+}
+
+func (c *configCache) ensureBusinessWatchOnce(
+ path string,
+ register func() (watchRegistration, error),
+) error {
+ pathLock := c.pathLock(path)
+ pathLock.Lock()
+ c.stateLock.Lock()
+ watchState := c.watches[path]
+ if watchState.registered {
+ watchState.auto = false
+ watchState.retired = false
+ c.setWatchStateLocked(path, watchState)
+ c.stateLock.Unlock()
+ pathLock.Unlock()
+ return nil
+ }
+ if watchState.pending {
+ watchState.auto = false
+ watchState.retired = false
+ c.setWatchStateLocked(path, watchState)
+ op := watchState.pendingOp
+ c.stateLock.Unlock()
+ pathLock.Unlock()
+ if op == nil {
+ return nil
+ }
+ <-op.done
+ return op.err
+ }
+ generation := c.generation
+ sessionID := c.sessionID
+ op := c.newWatchOperationLocked()
+ c.setWatchStateLocked(path, configWatchState{
+ pending: true,
+ sessionID: sessionID,
+ pendingOp: op,
+ })
+ c.stateLock.Unlock()
+ pathLock.Unlock()
+
+ registration, err := register()
+ pathLock.Lock()
+ defer pathLock.Unlock()
+ if err != nil {
+ c.stateLock.Lock()
+ current := c.watches[path]
+ if current.pendingOp == op {
+ c.clearWatchStateLocked(path, err)
+ } else {
+ c.completeWatchOperationLocked(op, err)
+ }
+ c.stateLock.Unlock()
+ return err
+ }
+ if c.finishWatchRegistrationLocked(path, generation, op.token,
registration) {
+ return nil
+ }
+ c.stateLock.Lock()
+ c.completeWatchOperationLocked(op, errWatchRegistrationStale)
+ c.stateLock.Unlock()
+ return errWatchRegistrationStale
+}
+
+func (c *configCache) releaseBusinessWatchLocked(path string) {
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+ watchState, ok := c.watches[path]
+ if !ok || watchState.auto {
+ return
+ }
+
+ autoWatchState := watchState
+ autoWatchState.auto = true
+ autoWatchState.retired = false
+ if c.setWatchStateLocked(path, autoWatchState) {
+ return
+ }
+ watchState.retired = true
+ c.setWatchStateLocked(path, watchState)
+}
+
+func (c *configCache) beginWatchRenewalLocked(path string, hasListeners bool)
(uint64, configWatchState, bool) {
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+ generation := c.generation
+ watchState, ok := c.watches[path]
+ if !ok {
+ if !hasListeners {
+ return generation, watchState, false
+ }
+ c.setWatchStateLocked(path, configWatchState{
+ pending: true,
+ sessionID: c.sessionID,
+ pendingOp: c.newWatchOperationLocked(),
+ })
+ return generation, c.watches[path], true
+ }
+ if watchState.pending {
+ if hasListeners {
+ watchState.auto = false
+ watchState.retired = false
+ c.setWatchStateLocked(path, watchState)
+ }
+ return generation, c.watches[path], false
+ }
+ if watchState.retired && !hasListeners {
+ c.setWatchStateLocked(path, configWatchState{})
+ return generation, configWatchState{}, false
+ }
+ if hasListeners {
+ watchState.auto = false
+ watchState.retired = false
+ } else if !watchState.auto {
+ c.setWatchStateLocked(path, configWatchState{})
+ return generation, configWatchState{}, false
+ }
+ watchState.registered = false
+ watchState.pending = true
+ watchState.pendingOp = c.newWatchOperationLocked()
+ c.setWatchStateLocked(path, watchState)
+ return generation, c.watches[path], true
+}
+
+func (c *configCache) cancelPendingLocked(path string) {
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+ if c.watches[path].pending {
+ c.clearWatchStateLocked(path, errWatchRegistrationStale)
+ }
+}
+
+// reset starts a new cache generation and clears cached values. Registered
+// watches from the same session are preserved because the ZooKeeper client
+// restores them on reconnect. Pending registrations remain for their
completion
+// path to reject by generation, while registered watches from older sessions
are
+// removed.
+func (c *configCache) reset(sessionID int64) {
+ c.stateLock.Lock()
+ defer c.stateLock.Unlock()
+
+ c.generation++
+ c.sessionID = sessionID
+ c.entries.Purge()
+ for path, watchState := range c.watches {
+ if watchState.pending || watchState.sessionID == sessionID {
+ continue
+ }
+ c.setWatchStateLocked(path, configWatchState{})
+ }
+}
+
+func (c *configCache) pathLock(path string) *sync.Mutex {
+ var hash uint32 = 2166136261
+ for i := 0; i < len(path); i++ {
+ hash ^= uint32(path[i])
+ hash *= 16777619
+ }
+ return &c.pathLocks[hash%pathLockShardCount]
+}
diff --git a/config_center/zookeeper/config_cache_test.go
b/config_center/zookeeper/config_cache_test.go
new file mode 100644
index 000000000..297655d4a
--- /dev/null
+++ b/config_center/zookeeper/config_cache_test.go
@@ -0,0 +1,572 @@
+/*
+ * 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 zookeeper
+
+import (
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+import (
+ "github.com/go-zookeeper/zk"
+
+ "github.com/stretchr/testify/require"
+)
+
+func newTestWatchRegistration(sessionID int64) watchRegistration {
+ return watchRegistration{
+ events: make(chan zk.Event, 1),
+ beforeSessionID: sessionID,
+ afterSessionID: sessionID,
+ }
+}
+
+func TestConfigCacheLoadAndExpiry(t *testing.T) {
+ cache := newConfigCache(20 * time.Millisecond)
+ var loads atomic.Int32
+ loader := func(registerWatch bool) (configCacheEntry,
watchRegistration, error) {
+ count := loads.Add(1)
+ if registerWatch {
+ return configCacheEntry{content: string(rune('0' +
count)), exists: true},
+ newTestWatchRegistration(1), nil
+ }
+ return configCacheEntry{content: string(rune('0' + count)),
exists: true}, watchRegistration{}, nil
+ }
+
+ first, err := cache.load("/path", loader)
+ require.NoError(t, err)
+ second, err := cache.load("/path", loader)
+ require.NoError(t, err)
+ require.Equal(t, first.content, second.content)
+ require.Equal(t, int32(1), loads.Load())
+
+ require.Eventually(t, func() bool {
+ entry, loadErr := cache.load("/path", loader)
+ return loadErr == nil && entry.content == "2"
+ }, time.Second, 5*time.Millisecond)
+ require.Equal(t, int32(2), loads.Load())
+}
+
+func TestConfigCacheUsesFixedPathLockShards(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ locks := make(map[*sync.Mutex]struct{})
+ pathLock := cache.pathLock("/path")
+
+ for i := range 4096 {
+ locks[cache.pathLock(fmt.Sprintf("/path/%d", i))] = struct{}{}
+ }
+
+ require.Same(t, pathLock, cache.pathLock("/path"))
+ require.LessOrEqual(t, len(locks), pathLockShardCount)
+}
+
+func TestConfigCacheBoundsEntriesUnderKeyChurn(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+
+ for i := range 4096 {
+ cache.store(fmt.Sprintf("/path/%d", i), configCacheEntry{
+ content: fmt.Sprintf("value-%d", i),
+ exists: true,
+ })
+ }
+
+ require.Equal(t, maxCacheEntries, cache.entries.Len())
+}
+
+func TestConfigCacheStoresMissingEntry(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ cache.store("/missing", configCacheEntry{exists: false})
+
+ require.Equal(t, 1, cache.entries.Len())
+ entry, ok := cache.getFresh("/missing")
+ require.True(t, ok)
+ require.False(t, entry.exists)
+}
+
+func TestConfigCacheEvictsLeastRecentlyUsed(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ for i := range maxCacheEntries {
+ cache.store(fmt.Sprintf("/path/%d", i),
configCacheEntry{exists: true})
+ }
+
+ _, ok := cache.getFresh("/path/0")
+ require.True(t, ok)
+ cache.store("/new", configCacheEntry{exists: true})
+
+ _, ok = cache.getFresh("/path/1")
+ require.False(t, ok)
+ _, ok = cache.getFresh("/path/0")
+ require.True(t, ok)
+}
+
+func TestConfigCacheRemovesExpiredEntryOnAccess(t *testing.T) {
+ cache := newConfigCache(10 * time.Millisecond)
+ cache.store("/path", configCacheEntry{exists: true})
+ require.Equal(t, 1, cache.entries.Len())
+ time.Sleep(20 * time.Millisecond)
+ require.Equal(t, 1, cache.entries.Len())
+
+ _, ok := cache.getFresh("/path")
+ require.False(t, ok)
+ require.Zero(t, cache.entries.Len())
+}
+
+func TestConfigCacheConcurrentLoadsRemainBounded(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ var wg sync.WaitGroup
+ errs := make(chan error, 4096)
+
+ for i := range 4096 {
+ path := fmt.Sprintf("/path/%d", i)
+ wg.Go(func() {
+ _, err := cache.load(path, func(registerWatch bool)
(configCacheEntry, watchRegistration, error) {
+ if registerWatch {
+ return configCacheEntry{exists: true},
newTestWatchRegistration(1), nil
+ }
+ return configCacheEntry{exists: true},
watchRegistration{}, nil
+ })
+ errs <- err
+ })
+ }
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ require.NoError(t, err)
+ }
+
+ require.Equal(t, maxCacheEntries, cache.entries.Len())
+}
+
+func TestConfigCacheConcurrentAutoWatchRegistrationsRemainBounded(t
*testing.T) {
+ cache := newConfigCache(time.Minute)
+ var registrations atomic.Int32
+ var wg sync.WaitGroup
+ errs := make(chan error, 4096)
+
+ for i := range 4096 {
+ path := fmt.Sprintf("/watch/%d", i)
+ wg.Go(func() {
+ _, err := cache.load(path, func(registerWatch bool)
(configCacheEntry, watchRegistration, error) {
+ if registerWatch {
+ registrations.Add(1)
+ return configCacheEntry{exists: true},
newTestWatchRegistration(1), nil
+ }
+ return configCacheEntry{exists: true},
watchRegistration{}, nil
+ })
+ errs <- err
+ })
+ }
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ require.NoError(t, err)
+ }
+
+ require.Equal(t, int32(maxAutoWatches), registrations.Load())
+ require.Equal(t, maxAutoWatches, cache.autoWatchCount)
+ require.Zero(t, cache.autoWatchReservations)
+}
+
+func TestConfigCacheWatchUpdateWinsOverLoad(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ loadStarted := make(chan struct{})
+ releaseLoad := make(chan struct{})
+ loadDone := make(chan struct{})
+ go func() {
+ defer close(loadDone)
+ _, _ = cache.load("/path", func(registerWatch bool)
(configCacheEntry, watchRegistration, error) {
+ close(loadStarted)
+ <-releaseLoad
+ if registerWatch {
+ return configCacheEntry{content: "old", exists:
true}, newTestWatchRegistration(1), nil
+ }
+ return configCacheEntry{content: "old", exists: true},
watchRegistration{}, nil
+ })
+ }()
+
+ <-loadStarted
+ updateDone := make(chan struct{})
+ go func() {
+ defer close(updateDone)
+ cache.store("/path", configCacheEntry{content: "new", exists:
true})
+ }()
+ close(releaseLoad)
+ <-loadDone
+ <-updateDone
+
+ entry, ok := cache.getFresh("/path")
+ require.True(t, ok)
+ require.Equal(t, "new", entry.content)
+}
+
+func TestConfigCacheResetDiscardsInFlightLoad(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ loadStarted := make(chan struct{})
+ releaseLoad := make(chan struct{})
+ type loadResult struct {
+ entry configCacheEntry
+ err error
+ }
+ result := make(chan loadResult, 1)
+ var loads atomic.Int32
+
+ go func() {
+ entry, err := cache.load("/path", func(bool) (configCacheEntry,
watchRegistration, error) {
+ if loads.Add(1) == 1 {
+ close(loadStarted)
+ <-releaseLoad
+ return configCacheEntry{content: "old", exists:
true}, newTestWatchRegistration(1), nil
+ }
+ return configCacheEntry{content: "new", exists: true},
newTestWatchRegistration(2), nil
+ })
+ result <- loadResult{entry: entry, err: err}
+ }()
+
+ <-loadStarted
+ cache.reset(2)
+ _, ok := cache.getFresh("/path")
+ require.False(t, ok)
+ _, watchState := cache.snapshot("/path")
+ require.True(t, watchState.pending)
+ require.Equal(t, 1, cache.autoWatchReservations)
+ close(releaseLoad)
+
+ load := <-result
+ require.NoError(t, load.err)
+ require.Equal(t, "new", load.entry.content)
+ require.Equal(t, int32(2), loads.Load())
+ entry, ok := cache.getFresh("/path")
+ require.True(t, ok)
+ require.Equal(t, "new", entry.content)
+ _, watchState = cache.snapshot("/path")
+ require.True(t, watchState.registered)
+ require.Equal(t, int64(2), watchState.sessionID)
+}
+
+func TestConfigCacheRejectsInFlightWatchAfterGenerationReset(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ loadStarted := make(chan struct{})
+ releaseLoad := make(chan struct{})
+ result := make(chan error, 1)
+ decisions := make(chan bool, 2)
+ var loads atomic.Int32
+ var registrations atomic.Int32
+
+ go func() {
+ _, err := cache.load("/path", func(registerWatch bool)
(configCacheEntry, watchRegistration, error) {
+ decisions <- registerWatch
+ if loads.Add(1) == 1 {
+ registrations.Add(1)
+ close(loadStarted)
+ <-releaseLoad
+ return configCacheEntry{content: "old", exists:
true}, newTestWatchRegistration(1), nil
+ }
+ registrations.Add(1)
+ return configCacheEntry{content: "new", exists: true},
newTestWatchRegistration(1), nil
+ })
+ result <- err
+ }()
+
+ <-loadStarted
+ cache.reset(1)
+ close(releaseLoad)
+
+ require.NoError(t, <-result)
+ require.Equal(t, int32(2), loads.Load())
+ require.Equal(t, int32(2), registrations.Load())
+ require.True(t, <-decisions)
+ require.True(t, <-decisions)
+ entry, ok := cache.getFresh("/path")
+ require.True(t, ok)
+ require.Equal(t, "new", entry.content)
+ _, watchState := cache.snapshot("/path")
+ require.True(t, watchState.registered)
+ require.Equal(t, int64(1), watchState.sessionID)
+}
+
+func TestConfigCacheResetPreservesOnlyCurrentSessionWatches(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ cache.store("/entry", configCacheEntry{content: "value", exists: true})
+ require.True(t, cache.setWatch("/active", configWatchState{
+ registered: true,
+ auto: true,
+ sessionID: 2,
+ }))
+ require.True(t, cache.setWatch("/retired", configWatchState{
+ registered: true,
+ retired: true,
+ sessionID: 2,
+ }))
+ require.True(t, cache.setWatch("/stale", configWatchState{
+ registered: true,
+ auto: true,
+ sessionID: 1,
+ }))
+ require.True(t, cache.setWatch("/pending", configWatchState{
+ pending: true,
+ auto: true,
+ sessionID: 1,
+ }))
+
+ cache.reset(2)
+
+ _, ok := cache.getFresh("/entry")
+ require.False(t, ok)
+ _, active := cache.snapshot("/active")
+ require.True(t, active.registered)
+ _, retired := cache.snapshot("/retired")
+ require.True(t, retired.registered)
+ require.True(t, retired.retired)
+ _, stale := cache.snapshot("/stale")
+ require.False(t, stale.tracked())
+ _, pending := cache.snapshot("/pending")
+ require.True(t, pending.pending)
+ require.Equal(t, 1, cache.autoWatchCount)
+ require.Equal(t, 1, cache.autoWatchReservations)
+}
+
+func TestWatchRegistrationResolveAcrossSession(t *testing.T) {
+ t.Run("current session watch remains active", func(t *testing.T) {
+ registration := watchRegistration{
+ events: make(chan zk.Event, 1),
+ beforeSessionID: 1,
+ afterSessionID: 2,
+ }
+
+ sessionID, active := registration.resolve()
+ require.True(t, active)
+ require.Equal(t, int64(2), sessionID)
+ })
+
+ t.Run("invalidated watch is discarded", func(t *testing.T) {
+ events := make(chan zk.Event, 1)
+ events <- zk.Event{Type: zk.EventNotWatching}
+ close(events)
+ registration := watchRegistration{
+ events: events,
+ beforeSessionID: 1,
+ afterSessionID: 2,
+ }
+
+ sessionID, active := registration.resolve()
+ require.False(t, active)
+ require.Zero(t, sessionID)
+ })
+}
+
+func TestWatchRegistrationSessionStabilityIncludesFollowUpRead(t *testing.T) {
+ registration := watchRegistration{
+ events: make(chan zk.Event, 1),
+ beforeSessionID: 1,
+ afterSessionID: 1,
+ readBeforeSessionID: 2,
+ readAfterSessionID: 2,
+ }
+ require.False(t, registration.sessionStable())
+
+ registration.readBeforeSessionID = 1
+ registration.readAfterSessionID = 1
+ require.True(t, registration.sessionStable())
+}
+
+func TestConfigCacheResetDiscardsInFlightBusinessWatch(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ registerStarted := make(chan struct{})
+ releaseRegister := make(chan struct{})
+ result := make(chan error, 1)
+ var registrations atomic.Int32
+
+ go func() {
+ result <- cache.ensureBusinessWatch("/path", func()
(watchRegistration, error) {
+ if registrations.Add(1) == 1 {
+ close(registerStarted)
+ <-releaseRegister
+ return newTestWatchRegistration(1), nil
+ }
+ return newTestWatchRegistration(2), nil
+ })
+ }()
+
+ <-registerStarted
+ cache.reset(2)
+ _, pendingState := cache.snapshot("/path")
+ require.True(t, pendingState.pending)
+ close(releaseRegister)
+
+ require.NoError(t, <-result)
+ require.Equal(t, int32(2), registrations.Load())
+ _, watchState := cache.snapshot("/path")
+ require.True(t, watchState.registered)
+ require.False(t, watchState.auto)
+ require.Equal(t, int64(2), watchState.sessionID)
+}
+
+func
TestConfigCacheConcurrentBusinessWatchRegistrationSharesPendingOperation(t
*testing.T) {
+ cache := newConfigCache(time.Minute)
+ registerStarted := make(chan struct{})
+ releaseRegister := make(chan struct{})
+ var registrations atomic.Int32
+ register := func() (watchRegistration, error) {
+ if registrations.Add(1) == 1 {
+ close(registerStarted)
+ <-releaseRegister
+ }
+ return newTestWatchRegistration(1), nil
+ }
+
+ firstResult := make(chan error, 1)
+ go func() { firstResult <- cache.ensureBusinessWatch("/path", register)
}()
+ <-registerStarted
+
+ secondResult := make(chan error, 1)
+ go func() { secondResult <- cache.ensureBusinessWatch("/path",
register) }()
+ time.Sleep(10 * time.Millisecond)
+ require.Equal(t, int32(1), registrations.Load())
+
+ close(releaseRegister)
+ require.NoError(t, <-firstResult)
+ require.NoError(t, <-secondResult)
+ require.Equal(t, int32(1), registrations.Load())
+ _, watchState := cache.snapshot("/path")
+ require.True(t, watchState.registered)
+ require.False(t, watchState.auto)
+}
+
+func TestConfigCacheRegistrationRequiresCurrentGenerationAndSession(t
*testing.T) {
+ cache := newConfigCache(time.Minute)
+ cache.reset(1)
+
+ generation, registerWatch, token := cache.prepareLoad("/path")
+ require.True(t, registerWatch)
+
+ cache.reset(1)
+ require.False(t, cache.finishWatchRegistration("/path", generation,
token, newTestWatchRegistration(1)))
+ _, watchState := cache.snapshot("/path")
+ require.False(t, watchState.tracked())
+
+ generation, registerWatch, token = cache.prepareLoad("/path")
+ require.True(t, registerWatch)
+ cache.stateLock.Lock()
+ cache.sessionID = 2
+ cache.stateLock.Unlock()
+ require.False(t, cache.finishWatchRegistration("/path", generation,
token, newTestWatchRegistration(1)))
+ _, watchState = cache.snapshot("/path")
+ require.False(t, watchState.tracked())
+}
+
+func TestConfigCacheLoadRetryIsBoundedWhenGenerationKeepsChanging(t
*testing.T) {
+ cache := newConfigCache(time.Minute)
+ var loads atomic.Int32
+ started := time.Now()
+ _, err := cache.load("/path", func(registerWatch bool)
(configCacheEntry, watchRegistration, error) {
+ loads.Add(1)
+ sessionID := time.Now().UnixNano()
+ cache.reset(sessionID)
+ if registerWatch {
+ return configCacheEntry{content: "stale", exists:
true}, newTestWatchRegistration(sessionID), nil
+ }
+ return configCacheEntry{content: "stale", exists: true},
watchRegistration{}, nil
+ })
+
+ require.ErrorIs(t, err, errWatchRegistrationStale)
+ require.LessOrEqual(t, loads.Load(), int32(2*(maxCacheLoadRetries+1)+1))
+ require.Less(t, time.Since(started), time.Second)
+}
+
+func TestConfigCacheBusinessWatchRegistrationRetryIsBounded(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ var registrations atomic.Int32
+ invalidRegistration := func() (watchRegistration, error) {
+ registrations.Add(1)
+ events := make(chan zk.Event, 1)
+ events <- zk.Event{Type: zk.EventNotWatching}
+ close(events)
+ return watchRegistration{
+ events: events,
+ beforeSessionID: 1,
+ afterSessionID: 2,
+ }, nil
+ }
+
+ err := cache.ensureBusinessWatchWithRetry("/path", invalidRegistration,
1)
+ require.ErrorIs(t, err, errWatchRegistrationStale)
+ require.Equal(t, int32(2), registrations.Load())
+ _, watchState := cache.snapshot("/path")
+ require.False(t, watchState.tracked())
+}
+
+func TestConfigCacheLoadRejectsStaleRegistrationBeforeStoringEntry(t
*testing.T) {
+ cache := newConfigCache(time.Minute)
+ var registrations atomic.Int32
+
+ loader := func(registerWatch bool) (configCacheEntry,
watchRegistration, error) {
+ if !registerWatch {
+ return configCacheEntry{content: "fallback", exists:
true}, watchRegistration{}, nil
+ }
+ if registrations.Add(1) == 1 {
+ events := make(chan zk.Event, 1)
+ events <- zk.Event{Type: zk.EventNotWatching}
+ close(events)
+ return configCacheEntry{content: "stale", exists:
true}, watchRegistration{
+ events: events,
+ beforeSessionID: 1,
+ afterSessionID: 2,
+ }, nil
+ }
+ return configCacheEntry{content: "current", exists: true},
newTestWatchRegistration(2), nil
+ }
+
+ entry, err := cache.load("/path", loader)
+ require.NoError(t, err)
+ require.Equal(t, "current", entry.content)
+ require.Equal(t, int32(2), registrations.Load())
+ cached, ok := cache.getFresh("/path")
+ require.True(t, ok)
+ require.Equal(t, "current", cached.content)
+}
+
+func TestConfigCacheLoadFallsBackAfterRepeatedStaleRegistrations(t *testing.T)
{
+ cache := newConfigCache(time.Minute)
+ var registrations atomic.Int32
+ var fallbacks atomic.Int32
+
+ loader := func(registerWatch bool) (configCacheEntry,
watchRegistration, error) {
+ if !registerWatch {
+ fallbacks.Add(1)
+ return configCacheEntry{content: "fallback", exists:
true}, watchRegistration{}, nil
+ }
+ registrations.Add(1)
+ events := make(chan zk.Event, 1)
+ events <- zk.Event{Type: zk.EventNotWatching}
+ close(events)
+ return configCacheEntry{content: "stale", exists: true},
watchRegistration{
+ events: events,
+ beforeSessionID: 1,
+ afterSessionID: 2,
+ }, nil
+ }
+
+ entry, err := cache.load("/path", loader)
+ require.NoError(t, err)
+ require.Equal(t, "fallback", entry.content)
+ require.Equal(t, int32(2), registrations.Load())
+ require.Equal(t, int32(1), fallbacks.Load())
+}
diff --git a/config_center/zookeeper/impl.go b/config_center/zookeeper/impl.go
index fdf4aa383..ff554bcb9 100644
--- a/config_center/zookeeper/impl.go
+++ b/config_center/zookeeper/impl.go
@@ -24,6 +24,7 @@ import (
"strconv"
"strings"
"sync"
+ "time"
)
import (
@@ -45,7 +46,10 @@ import (
)
const (
- pathSeparator = "/"
+ pathSeparator = "/"
+ defaultConfigCacheTTL = 30 * time.Second
+ minConfigCacheTTL = time.Second
+ maxConfigCacheTTL = 10 * time.Minute
)
type zookeeperDynamicConfiguration struct {
@@ -60,6 +64,7 @@ type zookeeperDynamicConfiguration struct {
// listenerLock sync.Mutex
listener *zookeeper.ZkEventListener
cacheListener *CacheListener
+ cache configCache
parser parser.ConfigurationParser
base64Enabled bool
@@ -67,20 +72,25 @@ type zookeeperDynamicConfiguration struct {
func newZookeeperDynamicConfiguration(url *common.URL)
(*zookeeperDynamicConfiguration, error) {
rootPath := url.GetParam(constant.ConfigRootPathParamKey,
"/dubbo/config")
+ cacheTTL, err :=
parseConfigCacheTTL(url.GetParam(constant.ConfigCacheTTLKey, ""))
+ if err != nil {
+ return nil, err
+ }
c := &zookeeperDynamicConfiguration{
url: url,
rootPath: rootPath,
+ cache: newConfigCache(cacheTTL),
}
logger.Infof("[ConfigCenter][Zookeeper] new Zookeeper ConfigCenter with
Configuration, zkConfig=%v url=%v", c, c.GetURL())
if v := url.GetParam("base64", ""); v != "" {
- base64Enabled, err := strconv.ParseBool(v)
- if err != nil {
- panic("value of base64 must be bool, error=" +
err.Error())
+ base64Enabled, parseErr := strconv.ParseBool(v)
+ if parseErr != nil {
+ panic("value of base64 must be bool, error=" +
parseErr.Error())
}
c.base64Enabled = base64Enabled
}
- err := zookeeper.ValidateZookeeperClient(c, url.Location)
+ err = zookeeper.ValidateZookeeperClient(c, url.Location)
if err != nil {
logger.Errorf("[ConfigCenter][Zookeeper] zookeeper client start
error, err=%v", err)
return nil, err
@@ -96,16 +106,14 @@ func newZookeeperDynamicConfiguration(url *common.URL)
(*zookeeperDynamicConfigu
// Start listener
c.listener = zookeeper.NewZkEventListener(c.client)
- c.cacheListener = NewCacheListener(c.rootPath, c.listener)
+ c.cacheListener = newCacheListener(c.rootPath, c.listener, &c.cache)
c.listener.ListenConfigurationEvent(c.rootPath, c.cacheListener)
return c, nil
}
// AddListener add listener for key
-// TODO this method should has a parameter 'group', and it does not now, so we
should concat group and key with '/' manually
func (c *zookeeperDynamicConfiguration) AddListener(key string, listener
config_center.ConfigurationListener, options ...config_center.Option) {
- key =
strings.Join([]string{c.GetURL().GetParam(constant.ConfigNamespaceKey,
config_center.DefaultGroup), key}, "/")
- qualifiedKey := buildPath(c.rootPath, key)
+ qualifiedKey := c.getPropertiesPath(key, options...)
c.cacheListener.AddListener(qualifiedKey, listener)
}
@@ -119,11 +127,34 @@ func buildPath(rootPath, subPath string) string {
return path.Clean(fullPath)
}
-func (c *zookeeperDynamicConfiguration) RemoveListener(key string, listener
config_center.ConfigurationListener, opions ...config_center.Option) {
- c.cacheListener.RemoveListener(key, listener)
+func (c *zookeeperDynamicConfiguration) RemoveListener(key string, listener
config_center.ConfigurationListener, options ...config_center.Option) {
+ qualifiedKey := c.getPropertiesPath(key, options...)
+ c.cacheListener.RemoveListener(qualifiedKey, listener)
}
func (c *zookeeperDynamicConfiguration) GetProperties(key string, opts
...config_center.Option) (string, error) {
+ path := c.getPropertiesPath(key, opts...)
+ entry, err := c.cache.load(path, func(registerWatch bool)
(configCacheEntry, watchRegistration, error) {
+ return c.loadProperties(path, registerWatch)
+ })
+ if err != nil {
+ return "", err
+ }
+ if !entry.exists {
+ return "", nil
+ }
+ if !c.base64Enabled {
+ return entry.content, nil
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(entry.content)
+ if err != nil {
+ return "", perrors.WithStack(err)
+ }
+ return string(decoded), nil
+}
+
+func (c *zookeeperDynamicConfiguration) getPropertiesPath(key string, opts
...config_center.Option) string {
tmpOpts := config_center.NewOptions(opts...)
/**
* when group is not null, we are getting startup configs from Config
Center, for example:
@@ -134,23 +165,88 @@ func (c *zookeeperDynamicConfiguration) GetProperties(key
string, opts ...config
} else {
key = c.GetURL().GetParam(constant.ConfigNamespaceKey,
config_center.DefaultGroup) + "/" + key
}
- content, _, err := c.client.GetContent(c.rootPath + "/" + key)
- if errors.Is(err, zk.ErrNoNode) {
- logger.Warnf("[ConfigCenter][Zookeeper] query rule fail, key=%s
err=%v", key, err)
- return "", nil
- }
- if err != nil {
- return "", perrors.WithStack(err)
+ return buildPath(c.rootPath, key)
+}
+
+func (c *zookeeperDynamicConfiguration) loadProperties(path string,
registerWatch bool) (configCacheEntry, watchRegistration, error) {
+ if !c.cache.enabled() || !registerWatch {
+ beforeSessionID := c.client.Conn.SessionID()
+ content, _, err := c.client.GetContent(path)
+ afterSessionID := c.client.Conn.SessionID()
+ registration := watchRegistration{
+ beforeSessionID: beforeSessionID,
+ afterSessionID: afterSessionID,
+ }
+ if errors.Is(err, zk.ErrNoNode) {
+ logger.Warnf("[ConfigCenter][Zookeeper] query rule
fail, key=%s err=%v", path, err)
+ return configCacheEntry{exists: false}, registration,
nil
+ }
+ if err != nil {
+ return configCacheEntry{}, registration,
perrors.WithStack(err)
+ }
+ return configCacheEntry{content: string(content), exists:
true}, registration, nil
}
- if !c.base64Enabled {
- return string(content), nil
+
+ for {
+ beforeSessionID := c.client.Conn.SessionID()
+ content, _, events, err := c.client.Conn.GetW(path)
+ registration := watchRegistration{
+ events: events,
+ beforeSessionID: beforeSessionID,
+ afterSessionID: c.client.Conn.SessionID(),
+ }
+ if err == nil {
+ return configCacheEntry{content: string(content),
exists: true}, registration, nil
+ }
+ if !errors.Is(err, zk.ErrNoNode) {
+ return configCacheEntry{}, watchRegistration{},
perrors.WithStack(err)
+ }
+
+ beforeSessionID = c.client.Conn.SessionID()
+ exists, _, events, watchErr := c.client.Conn.ExistsW(path)
+ registration = watchRegistration{
+ events: events,
+ beforeSessionID: beforeSessionID,
+ afterSessionID: c.client.Conn.SessionID(),
+ }
+ if watchErr != nil {
+ return configCacheEntry{}, watchRegistration{},
perrors.WithStack(watchErr)
+ }
+ if !exists {
+ logger.Warnf("[ConfigCenter][Zookeeper] query rule
fail, key=%s err=%v", path, err)
+ return configCacheEntry{exists: false}, registration,
nil
+ }
+
+ readBeforeSessionID := c.client.Conn.SessionID()
+ content, _, getErr := c.client.Conn.Get(path)
+ readAfterSessionID := c.client.Conn.SessionID()
+ registration.readBeforeSessionID = readBeforeSessionID
+ registration.readAfterSessionID = readAfterSessionID
+ if errors.Is(getErr, zk.ErrNoNode) {
+ continue
+ }
+ if getErr != nil {
+ return configCacheEntry{}, registration,
perrors.WithStack(getErr)
+ }
+ return configCacheEntry{content: string(content), exists:
true}, registration, nil
}
+}
- decoded, err := base64.StdEncoding.DecodeString(string(content))
+func parseConfigCacheTTL(value string) (time.Duration, error) {
+ if value == "" {
+ return defaultConfigCacheTTL, nil
+ }
+ ttl, err := time.ParseDuration(value)
if err != nil {
- return "", perrors.WithStack(err)
+ return 0, perrors.Wrapf(err, "invalid %s value %q",
constant.ConfigCacheTTLKey, value)
}
- return string(decoded), nil
+ if ttl < 0 {
+ return 0, perrors.Errorf("%s must not be negative",
constant.ConfigCacheTTLKey)
+ }
+ if ttl > 0 && (ttl < minConfigCacheTTL || ttl > maxConfigCacheTTL) {
+ return 0, perrors.Errorf("%s must be 0 or between %s and %s",
constant.ConfigCacheTTLKey, minConfigCacheTTL, maxConfigCacheTTL)
+ }
+ return ttl, nil
}
// GetInternalProperty For zookeeper, getConfig and getConfigs have the same
meaning.
@@ -274,6 +370,14 @@ func (c *zookeeperDynamicConfiguration) closeConfigs() {
}
func (c *zookeeperDynamicConfiguration) RestartCallBack() bool {
+ var sessionID int64
+ if c.client != nil && c.client.Conn != nil {
+ sessionID = c.client.Conn.SessionID()
+ }
+ c.cache.reset(sessionID)
+ if c.cacheListener != nil {
+ c.cacheListener.restoreBusinessWatches()
+ }
return true
}
diff --git a/config_center/zookeeper/impl_test.go
b/config_center/zookeeper/impl_test.go
index af2f9dbc7..52644f4d8 100644
--- a/config_center/zookeeper/impl_test.go
+++ b/config_center/zookeeper/impl_test.go
@@ -19,9 +19,12 @@ package zookeeper
import (
"crypto/rand"
+ "encoding/base64"
"encoding/hex"
+ "fmt"
"os"
"testing"
+ "time"
)
import (
@@ -35,8 +38,17 @@ import (
import (
"dubbo.apache.org/dubbo-go/v3/common"
"dubbo.apache.org/dubbo-go/v3/config_center"
+ remotingzookeeper "dubbo.apache.org/dubbo-go/v3/remoting/zookeeper"
)
+type channelConfigListener struct {
+ events chan *config_center.ConfigChangeEvent
+}
+
+func (l *channelConfigListener) Process(event
*config_center.ConfigChangeEvent) {
+ l.events <- event
+}
+
// zkAddrEnvKey mirrors the environment variable gost's
// NewZookeeperClientFromEnv reads to locate a ZooKeeper server (see
// database/kv/zk/client.go in dubbogo/gost); it isn't exported there, so the
@@ -59,6 +71,17 @@ func failOrSkipZkUnavailable(t *testing.T, err error) {
t.Skipf("skip zk setup: %v", err)
}
+func newZookeeperTestClient(t *testing.T, name string)
(*gxzookeeper.ZookeeperClient, <-chan zk.Event) {
+ t.Helper()
+ client, events, err := gxzookeeper.NewZookeeperClientFromEnv(name,
5*time.Second)
+ if err != nil {
+ failOrSkipZkUnavailable(t, err)
+ return nil, nil
+ }
+ t.Cleanup(func() { client.Close() })
+ return client, events
+}
+
// newTestRoot returns a randomly named zookeeper root path unique to the
// calling test, and registers a t.Cleanup that recursively removes it via
// client once the test finishes, so runs stay isolated on a shared
@@ -193,6 +216,321 @@ func TestGetPropertiesWithZk(t *testing.T) {
require.Empty(t, empty)
}
+func TestLoadPropertiesRegistersWatchOnlyWhenInactive(t *testing.T) {
+ client, events := newZookeeperTestClient(t, "watch-selection")
+ root := newTestRoot(t, client)
+
+ cfg := &zookeeperDynamicConfiguration{
+ rootPath: root,
+ client: client,
+ url: mustURL(t, "registry://127.0.0.1:2181"),
+ cache: newConfigCache(time.Minute),
+ }
+ activePath := cfg.getPath("active", "group")
+ inactivePath := cfg.getPath("inactive", "group")
+ require.NoError(t, cfg.PublishConfig("active", "group", "v1"))
+ require.NoError(t, cfg.PublishConfig("inactive", "group", "v1"))
+
+ waitForEvent := func(path string, timeout time.Duration) bool {
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+ for {
+ select {
+ case event := <-events:
+ if event.Path == path && event.Type ==
zk.EventNodeDataChanged {
+ return true
+ }
+ case <-timer.C:
+ return false
+ }
+ }
+ }
+
+ _, registration, err := cfg.loadProperties(activePath, false)
+ require.NoError(t, err)
+ require.Nil(t, registration.events)
+ _, stat, err := client.GetContent(activePath)
+ require.NoError(t, err)
+ _, err = client.SetContent(activePath, []byte("v2"), stat.Version)
+ require.NoError(t, err)
+ require.False(t, waitForEvent(activePath, time.Second))
+
+ _, registration, err = cfg.loadProperties(inactivePath, true)
+ require.NoError(t, err)
+ require.NotNil(t, registration.events)
+ _, stat, err = client.GetContent(inactivePath)
+ require.NoError(t, err)
+ _, err = client.SetContent(inactivePath, []byte("v2"), stat.Version)
+ require.NoError(t, err)
+ require.True(t, waitForEvent(inactivePath, time.Second))
+}
+
+func TestListenerUsesGroupOption(t *testing.T) {
+ client, _ := newZookeeperTestClient(t, "listener-group")
+ root := newTestRoot(t, client)
+
+ zkListener := remotingzookeeper.NewZkEventListener(client)
+ defer zkListener.Close()
+ cfg := &zookeeperDynamicConfiguration{
+ rootPath: root,
+ client: client,
+ url: mustURL(t, "registry://127.0.0.1:2181"),
+ cache: newConfigCache(time.Minute),
+ listener: zkListener,
+ }
+ cfg.cacheListener = newCacheListener(cfg.rootPath, zkListener,
&cfg.cache)
+ key := "app.properties"
+ group := "custom"
+ path := cfg.getPropertiesPath(key, config_center.WithGroup(group))
+ rec := &recListener{}
+
+ cfg.AddListener(key, rec, config_center.WithGroup(group))
+ _, ok := cfg.cacheListener.keyListeners.Load(path)
+ require.True(t, ok)
+
+ cfg.RemoveListener(key, rec, config_center.WithGroup(group))
+ _, ok = cfg.cacheListener.keyListeners.Load(path)
+ require.False(t, ok)
+}
+
+func TestGetPropertiesFallsBackToTTLAtAutoWatchLimit(t *testing.T) {
+ client, events := newZookeeperTestClient(t, "watch-limit")
+ root := newTestRoot(t, client)
+
+ cfg := &zookeeperDynamicConfiguration{
+ rootPath: root,
+ client: client,
+ url: mustURL(t, "registry://127.0.0.1:2181"),
+ cache: newConfigCache(time.Minute),
+ }
+ for i := range maxAutoWatches {
+ require.True(t, cfg.cache.setWatch(fmt.Sprintf("/watch/%d", i),
configWatchState{
+ registered: true,
+ auto: true,
+ sessionID: client.Conn.SessionID(),
+ }))
+ }
+
+ require.NoError(t, cfg.PublishConfig("fallback", "group", "v1"))
+ value, err := cfg.GetProperties("fallback",
config_center.WithGroup("group"))
+ require.NoError(t, err)
+ require.Equal(t, "v1", value)
+
+ path := cfg.getPath("fallback", "group")
+ _, watchState := cfg.cache.snapshot(path)
+ require.False(t, watchState.tracked())
+ require.Equal(t, maxAutoWatches, cfg.cache.autoWatchCount)
+ require.Zero(t, cfg.cache.autoWatchReservations)
+ _, stat, err := client.GetContent(path)
+ require.NoError(t, err)
+ _, err = client.SetContent(path, []byte("v2"), stat.Version)
+ require.NoError(t, err)
+
+ timer := time.NewTimer(100 * time.Millisecond)
+ defer timer.Stop()
+ for {
+ select {
+ case event := <-events:
+ if event.Path == path && event.Type ==
zk.EventNodeDataChanged {
+ t.Fatal("TTL fallback should not register an
auto watch")
+ }
+ case <-timer.C:
+ value, err = cfg.GetProperties("fallback",
config_center.WithGroup("group"))
+ require.NoError(t, err)
+ require.Equal(t, "v1", value)
+ return
+ }
+ }
+}
+
+func TestGetPropertiesCacheUpdatedByWatch(t *testing.T) {
+ client, _ := newZookeeperTestClient(t, "cache-watch")
+ go (&gxzookeeper.DefaultHandler{}).HandleZkEvent(client)
+ root := newTestRoot(t, client)
+
+ cfg := &zookeeperDynamicConfiguration{
+ rootPath: root,
+ client: client,
+ done: make(chan struct{}),
+ url: mustURL(t, "registry://127.0.0.1:2181"),
+ cache: newConfigCache(time.Minute),
+ }
+ cfg.listener = remotingzookeeper.NewZkEventListener(client)
+ cfg.cacheListener = newCacheListener(cfg.rootPath, cfg.listener,
&cfg.cache)
+ cfg.listener.ListenConfigurationEvent(cfg.rootPath, cfg.cacheListener)
+ defer cfg.listener.Close()
+
+ require.NoError(t, cfg.PublishConfig("file.properties", "grp", "v1"))
+ value, err := cfg.GetProperties("file.properties",
config_center.WithGroup("grp"))
+ require.NoError(t, err)
+ require.Equal(t, "v1", value)
+
+ // ListenConfigurationEvent registers asynchronously; wait before
triggering the watch.
+ time.Sleep(50 * time.Millisecond)
+ watchPath := cfg.getPropertiesPath("file.properties",
config_center.WithGroup("grp"))
+ _, stat, err := client.GetContent(watchPath)
+ require.NoError(t, err)
+ _, err = client.SetContent(watchPath, []byte("v2"), stat.Version)
+ require.NoError(t, err)
+
+ require.Eventually(t, func() bool {
+ value, getErr := cfg.GetProperties("file.properties",
config_center.WithGroup("grp"))
+ return getErr == nil && value == "v2"
+ }, time.Second, 10*time.Millisecond)
+
+ require.NoError(t, cfg.RemoveConfig("file.properties", "grp"))
+ require.Eventually(t, func() bool {
+ entry, ok := cfg.cache.getFresh(watchPath)
+ return ok && !entry.exists
+ }, time.Second, 10*time.Millisecond)
+}
+
+func TestGetPropertiesDecodesCachedBase64(t *testing.T) {
+ cfg := &zookeeperDynamicConfiguration{
+ rootPath: "/dubbo/config",
+ url: mustURL(t, "registry://127.0.0.1:2181"),
+ cache: newConfigCache(time.Minute),
+ base64Enabled: true,
+ }
+ path := cfg.getPropertiesPath("key", config_center.WithGroup("group"))
+ cfg.cache.store(path, configCacheEntry{
+ content: base64.StdEncoding.EncodeToString([]byte("value")),
+ exists: true,
+ })
+
+ value, err := cfg.GetProperties("key", config_center.WithGroup("group"))
+ require.NoError(t, err)
+ require.Equal(t, "value", value)
+}
+
+func TestRestartCallBackResetsCache(t *testing.T) {
+ client, _ := newZookeeperTestClient(t, "restart-watch-reset")
+
+ cfg := &zookeeperDynamicConfiguration{cache:
newConfigCache(time.Minute), client: client}
+ path := "/dubbo/config/group/key"
+ pendingPath := "/dubbo/config/group/pending"
+ _, _, _, err := client.Conn.ExistsW(path)
+ require.NoError(t, err)
+ cfg.cache.store(path, configCacheEntry{content: "value", exists: true})
+ cfg.cache.setWatch(path, configWatchState{
+ registered: true,
+ auto: true,
+ sessionID: client.Conn.SessionID(),
+ })
+ cfg.cache.setWatch(pendingPath, configWatchState{
+ auto: true,
+ pending: true,
+ sessionID: client.Conn.SessionID(),
+ })
+
+ require.True(t, cfg.RestartCallBack())
+ _, ok := cfg.cache.getFresh(path)
+ require.False(t, ok)
+ _, watchState := cfg.cache.snapshot(path)
+ require.True(t, watchState.registered)
+ _, pendingWatchState := cfg.cache.snapshot(pendingPath)
+ require.True(t, pendingWatchState.pending)
+ require.Equal(t, 1, cfg.cache.autoWatchCount)
+ require.Equal(t, 1, cfg.cache.autoWatchReservations)
+}
+
+func TestRestartCallBackRestoresBusinessListener(t *testing.T) {
+ client, _ := newZookeeperTestClient(t, "restart-business-watch")
+ go (&gxzookeeper.DefaultHandler{}).HandleZkEvent(client)
+ root := newTestRoot(t, client)
+
+ cfg := &zookeeperDynamicConfiguration{
+ rootPath: root,
+ client: client,
+ url: mustURL(t, "registry://127.0.0.1:2181"),
+ cache: newConfigCache(time.Minute),
+ }
+ cfg.listener = remotingzookeeper.NewZkEventListener(client)
+ cfg.cacheListener = newCacheListener(cfg.rootPath, cfg.listener,
&cfg.cache)
+ cfg.listener.ListenConfigurationEvent(cfg.rootPath, cfg.cacheListener)
+ defer cfg.listener.Close()
+
+ key := "app.properties"
+ group := "group"
+ path := cfg.getPropertiesPath(key, config_center.WithGroup(group))
+ recorder := &channelConfigListener{events: make(chan
*config_center.ConfigChangeEvent, 2)}
+ require.NoError(t, cfg.PublishConfig(key, group, "v1"))
+ time.Sleep(50 * time.Millisecond)
+ cfg.AddListener(key, recorder, config_center.WithGroup(group))
+ _, previousWatch := cfg.cache.snapshot(path)
+ require.True(t, previousWatch.registered)
+ require.False(t, previousWatch.auto)
+
+ require.True(t, cfg.RestartCallBack())
+ _, restoredWatch := cfg.cache.snapshot(path)
+ require.True(t, restoredWatch.registered)
+ require.Equal(t, previousWatch.sessionID, restoredWatch.sessionID)
+ require.False(t, restoredWatch.auto)
+
+ for _, value := range []string{"v2", "v3"} {
+ _, stat, getErr := client.GetContent(path)
+ require.NoError(t, getErr)
+ _, setErr := client.SetContent(path, []byte(value),
stat.Version)
+ require.NoError(t, setErr)
+
+ select {
+ case event := <-recorder.events:
+ require.Equal(t, key, event.Key)
+ require.Equal(t, value, event.Value)
+ case <-time.After(time.Second):
+ t.Fatalf("listener did not receive configuration value
%q", value)
+ }
+ }
+}
+
+func TestRestartCallBackRestoresBusinessListenerWhenCacheDisabled(t
*testing.T) {
+ client, _ := newZookeeperTestClient(t,
"restart-business-watch-cache-disabled")
+ go (&gxzookeeper.DefaultHandler{}).HandleZkEvent(client)
+ root := newTestRoot(t, client)
+
+ cfg := &zookeeperDynamicConfiguration{
+ rootPath: root,
+ client: client,
+ url: mustURL(t, "registry://127.0.0.1:2181"),
+ cache: newConfigCache(0),
+ }
+ cfg.listener = remotingzookeeper.NewZkEventListener(client)
+ cfg.cacheListener = newCacheListener(cfg.rootPath, cfg.listener,
&cfg.cache)
+ defer cfg.listener.Close()
+
+ key := "app.properties"
+ group := "group"
+ path := cfg.getPropertiesPath(key, config_center.WithGroup(group))
+ recorder := &channelConfigListener{events: make(chan
*config_center.ConfigChangeEvent, 2)}
+ require.NoError(t, cfg.PublishConfig(key, group, "v1"))
+ time.Sleep(50 * time.Millisecond)
+ cfg.listener.ListenConfigurationEvent(cfg.rootPath, cfg.cacheListener)
+ cfg.AddListener(key, recorder, config_center.WithGroup(group))
+ _, watchState := cfg.cache.snapshot(path)
+ require.True(t, watchState.registered)
+ watchState.sessionID--
+ require.True(t, cfg.cache.setWatch(path, watchState))
+ require.True(t, cfg.RestartCallBack())
+ _, restoredWatch := cfg.cache.snapshot(path)
+ require.True(t, restoredWatch.registered)
+ require.Equal(t, client.Conn.SessionID(), restoredWatch.sessionID)
+
+ for _, value := range []string{"v2", "v3"} {
+ _, stat, getErr := client.GetContent(path)
+ require.NoError(t, getErr)
+ _, setErr := client.SetContent(path, []byte(value),
stat.Version)
+ require.NoError(t, setErr)
+
+ select {
+ case event := <-recorder.events:
+ require.Equal(t, key, event.Key)
+ require.Equal(t, value, event.Value)
+ case <-time.After(time.Second):
+ t.Fatalf("listener did not receive configuration value
%q", value)
+ }
+ }
+}
+
func mustURL(t *testing.T, raw string) *common.URL {
t.Helper()
u, err := common.NewURL(raw)
diff --git a/config_center/zookeeper/listener.go
b/config_center/zookeeper/listener.go
index d59692210..07097b649 100644
--- a/config_center/zookeeper/listener.go
+++ b/config_center/zookeeper/listener.go
@@ -22,6 +22,12 @@ import (
"sync"
)
+import (
+ "github.com/dubbogo/gost/log/logger"
+
+ "github.com/go-zookeeper/zk"
+)
+
import (
"dubbo.apache.org/dubbo-go/v3/common/constant"
"dubbo.apache.org/dubbo-go/v3/config_center"
@@ -34,60 +40,349 @@ import (
// CacheListener defines keyListeners and rootPath
type CacheListener struct {
// key is zkNode Path and value is set of listeners
- keyListeners sync.Map
+ keyListeners sync.Map
+ // eventGeneration carries the cache epoch and watch ownership from
event
+ // consumption through watch renewal to the final cache update.
+ eventGeneration sync.Map
zkEventListener *zookeeper.ZkEventListener
rootPath string
+ cache *configCache
+}
+
+// watchEventState is the snapshot attached to one consumed watch event. It
+// prevents a delayed renewal or DataChange callback from updating a newer
+// cache generation or completing a different pending registration.
+type watchEventState struct {
+ generation uint64
+ sessionID int64
+ auto bool
+ token uint64
}
// NewCacheListener creates a new CacheListener
func NewCacheListener(rootPath string, listener *zookeeper.ZkEventListener)
*CacheListener {
- return &CacheListener{zkEventListener: listener, rootPath: rootPath}
+ return newCacheListener(rootPath, listener, nil)
+}
+
+func newCacheListener(rootPath string, listener *zookeeper.ZkEventListener,
cache *configCache) *CacheListener {
+ return &CacheListener{zkEventListener: listener, rootPath: rootPath,
cache: cache}
+}
+
+func (l *CacheListener) registerWatcher(key string) (watchRegistration, error)
{
+ conn := l.zkEventListener.Client.Conn
+ beforeSessionID := conn.SessionID()
+ _, _, events, err := conn.ExistsW(key)
+ return watchRegistration{
+ events: events,
+ beforeSessionID: beforeSessionID,
+ afterSessionID: conn.SessionID(),
+ }, err
}
// AddListener will add a listener if loaded
func (l *CacheListener) AddListener(key string, listener
config_center.ConfigurationListener) {
// FIXME do not use Client.ExistW, cause it has a bug(can not watch zk
node that do not exist)
- _, _, _, err := l.zkEventListener.Client.Conn.ExistsW(key)
+ l.addListenerWithRegister(key, listener, func() (watchRegistration,
error) {
+ return l.registerWatcher(key)
+ })
+}
+
+func (l *CacheListener) addListenerWithRegister(
+ key string,
+ listener config_center.ConfigurationListener,
+ register func() (watchRegistration, error),
+) {
+ if l.cache == nil {
+ if _, err := register(); err != nil {
+ return
+ }
+ l.storeListener(key, listener)
+ return
+ }
+
+ pathLock := l.cache.pathLock(key)
+ pathLock.Lock()
+ added := l.storeListener(key, listener)
+ pathLock.Unlock()
+ stillNeeded := func() bool {
+ pathLock.Lock()
+ defer pathLock.Unlock()
+ return l.hasListener(key, listener)
+ }
+ err := l.cache.ensureBusinessWatchWithRetryIf(key, register, 1,
stillNeeded)
+ pathLock.Lock()
+ defer pathLock.Unlock()
+ present := l.hasListener(key, listener)
+ if err != nil && added && present {
+ _, last := l.removeListener(key, listener)
+ if last {
+ l.cache.releaseBusinessWatchLocked(key)
+ }
+ return
+ }
+ if !present && !l.hasListeners(key) {
+ l.cache.releaseBusinessWatchLocked(key)
+ }
+}
+
+func (l *CacheListener) storeListener(key string, listener
config_center.ConfigurationListener) bool {
// reference from
https://stackoverflow.com/questions/34018908/golang-why-dont-we-have-a-set-datastructure
// make a map[your type]struct{} like set in java
- if err != nil {
+ listeners, loaded := l.keyListeners.LoadOrStore(key,
map[config_center.ConfigurationListener]struct{}{listener: {}})
+ if !loaded {
+ return true
+ }
+ listenerSet :=
listeners.(map[config_center.ConfigurationListener]struct{})
+ if _, exists := listenerSet[listener]; exists {
+ return false
+ }
+ listenerSet[listener] = struct{}{}
+ l.keyListeners.Store(key, listenerSet)
+ return true
+}
+
+func (l *CacheListener) restoreBusinessWatches() {
+ if l.cache == nil || l.zkEventListener == nil ||
+ l.zkEventListener.Client == nil ||
l.zkEventListener.Client.Conn == nil {
return
}
- listeners, loaded := l.keyListeners.LoadOrStore(key,
map[config_center.ConfigurationListener]struct{}{listener: {}})
- if loaded {
-
listeners.(map[config_center.ConfigurationListener]struct{})[listener] =
struct{}{}
- l.keyListeners.Store(key, listeners)
+
+ l.keyListeners.Range(func(key, _ any) bool {
+ path := key.(string)
+ err := l.cache.ensureBusinessWatchWithRetry(path, func()
(watchRegistration, error) {
+ return l.registerWatcher(path)
+ }, 1)
+ if err != nil {
+ logger.Warnf("[ConfigCenter][Zookeeper] restore
configuration watcher failed, path=%s err=%v", path, err)
+ return true
+ }
+ pathLock := l.cache.pathLock(path)
+ pathLock.Lock()
+ if !l.hasListeners(path) {
+ l.cache.releaseBusinessWatchLocked(path)
+ }
+ pathLock.Unlock()
+ return true
+ })
+}
+
+// WatchStateChanged consumes the current watch state and reserves its renewal.
+// It returns whether the event loop should use ExistsW to register a new watch
+// or plain Exists when the path no longer has watch ownership.
+func (l *CacheListener) WatchStateChanged(path string) bool {
+ if l.cache == nil {
+ return true
+ }
+ pathLock := l.cache.pathLock(path)
+ pathLock.Lock()
+ defer pathLock.Unlock()
+ generation, watchState, registerWatch :=
l.cache.beginWatchRenewalLocked(path, l.hasListeners(path))
+ l.eventGeneration.Store(path, watchEventState{
+ generation: generation,
+ sessionID: watchState.sessionID,
+ auto: watchState.auto,
+ token: watchState.pendingOpToken(),
+ })
+ return registerWatch
+}
+
+// WatchRegistered validates a renewed watch against the event generation,
+// operation token, and ZooKeeper session. The result tells the event loop to
+// accept the read, reload without another watch, or discard the stale event.
+func (l *CacheListener) WatchRegistered(path string, events <-chan zk.Event,
beforeSessionID, afterSessionID int64) zookeeper.WatchRegistrationResult {
+ if l.cache == nil {
+ return zookeeper.WatchRegistrationAccepted
+ }
+ pathLock := l.cache.pathLock(path)
+ pathLock.Lock()
+ state, ok := l.eventGeneration.Load(path)
+ if !ok {
+ pathLock.Unlock()
+ return zookeeper.WatchRegistrationDiscarded
+ }
+ eventState := state.(watchEventState)
+ registration := watchRegistration{
+ events: events,
+ beforeSessionID: beforeSessionID,
+ afterSessionID: afterSessionID,
}
+ stored := l.cache.finishWatchRegistrationLocked(path,
eventState.generation, eventState.token, registration)
+ currentGeneration := l.cache.isCurrentGeneration(eventState.generation)
+ if stored && registration.sessionStable() && currentGeneration {
+ pathLock.Unlock()
+ return zookeeper.WatchRegistrationAccepted
+ }
+ reload := eventState.auto || (stored && !currentGeneration)
+ if reload {
+ l.eventGeneration.Store(path, eventState)
+ } else {
+ l.eventGeneration.Delete(path)
+ }
+ pathLock.Unlock()
+
+ if reload {
+ return zookeeper.WatchRegistrationReload
+ }
+ if l.retryBusinessWatch(path, eventState.sessionID,
eventState.generation) {
+ pathLock.Lock()
+ if _, ok := l.eventGeneration.Load(path); !ok {
+ l.eventGeneration.Store(path, eventState)
+ }
+ pathLock.Unlock()
+ return zookeeper.WatchRegistrationReload
+ }
+ return zookeeper.WatchRegistrationDiscarded
+}
+
+// WatchStateChangeFailed releases a pending renewal. If business listeners
+// remain and the connection has a live session, it may retry the watch after
+// the cache generation or ZooKeeper session has changed.
+func (l *CacheListener) WatchStateChangeFailed(path string) {
+ if l.cache == nil {
+ return
+ }
+ pathLock := l.cache.pathLock(path)
+ pathLock.Lock()
+ state, ok := l.eventGeneration.LoadAndDelete(path)
+ if !ok {
+ pathLock.Unlock()
+ return
+ }
+ l.cache.cancelPendingLocked(path)
+ pathLock.Unlock()
+ eventState := state.(watchEventState)
+ l.retryBusinessWatch(path, eventState.sessionID, eventState.generation)
+}
+
+func (l *CacheListener) retryBusinessWatch(path string, previousSessionID
int64, previousGeneration uint64) bool {
+ pathLock := l.cache.pathLock(path)
+ pathLock.Lock()
+ hasListeners := l.hasListeners(path)
+ pathLock.Unlock()
+ if !hasListeners || l.zkEventListener == nil ||
+ l.zkEventListener.Client == nil ||
l.zkEventListener.Client.Conn == nil {
+ return false
+ }
+
+ conn := l.zkEventListener.Client.Conn
+ currentGeneration, _ := l.cache.snapshot(path)
+ if conn.State() != zk.StateHasSession ||
+ (conn.SessionID() == previousSessionID && currentGeneration ==
previousGeneration) {
+ return false
+ }
+ if err := l.cache.ensureBusinessWatchWithRetry(path, func()
(watchRegistration, error) {
+ return l.registerWatcher(path)
+ }, 1); err != nil {
+ logger.Warnf("[ConfigCenter][Zookeeper] retry configuration
watcher failed, path=%s err=%v", path, err)
+ return false
+ }
+ pathLock = l.cache.pathLock(path)
+ pathLock.Lock()
+ defer pathLock.Unlock()
+ if !l.hasListeners(path) {
+ l.cache.releaseBusinessWatchLocked(path)
+ return false
+ }
+ return true
+}
+
+func (l *CacheListener) hasListeners(path string) bool {
+ _, ok := l.keyListeners.Load(path)
+ return ok
+}
+
+func (l *CacheListener) hasListener(path string, listener
config_center.ConfigurationListener) bool {
+ listeners, ok := l.keyListeners.Load(path)
+ if !ok {
+ return false
+ }
+ _, ok =
listeners.(map[config_center.ConfigurationListener]struct{})[listener]
+ return ok
}
// RemoveListener will delete a listener if loaded
func (l *CacheListener) RemoveListener(key string, listener
config_center.ConfigurationListener) {
+ if l.cache == nil {
+ l.removeListener(key, listener)
+ return
+ }
+
+ pathLock := l.cache.pathLock(key)
+ pathLock.Lock()
+ defer pathLock.Unlock()
+ removed, last := l.removeListener(key, listener)
+ if removed && last {
+ l.cache.releaseBusinessWatchLocked(key)
+ }
+}
+
+func (l *CacheListener) removeListener(key string, listener
config_center.ConfigurationListener) (bool, bool) {
listeners, loaded := l.keyListeners.Load(key)
- if loaded {
-
delete(listeners.(map[config_center.ConfigurationListener]struct{}), listener)
+ if !loaded {
+ return false, false
+ }
+ listenerSet :=
listeners.(map[config_center.ConfigurationListener]struct{})
+ if _, exists := listenerSet[listener]; !exists {
+ return false, false
+ }
+ delete(listenerSet, listener)
+ if len(listenerSet) != 0 {
+ l.keyListeners.Store(key, listenerSet)
+ return true, false
}
+ l.keyListeners.Delete(key)
+ return true, true
}
-// DataChange changes all listeners' event
+// DataChange updates the read-through cache before notifying a snapshot of the
+// business listeners. Events tied to an older generation cannot repopulate a
+// cache that has already been reset.
func (l *CacheListener) DataChange(event remoting.Event) bool {
- changeType := event.Action
- if event.Content == "" {
- changeType = remoting.EventTypeDel
+ if l.cache != nil {
+ entry := configCacheEntry{content: event.Content, exists: true}
+ if event.Action == remoting.EventTypeDel {
+ entry = configCacheEntry{exists: false}
+ }
+ pathLock := l.cache.pathLock(event.Path)
+ pathLock.Lock()
+ if state, ok := l.eventGeneration.LoadAndDelete(event.Path); ok
{
+ l.cache.storeAtGenerationLocked(event.Path,
state.(watchEventState).generation, entry)
+ } else {
+ l.cache.storeLocked(event.Path, entry)
+ }
+ pathLock.Unlock()
}
key, group := l.pathToKeyGroup(event.Path)
- defer metrics.Publish(metricsConfigCenter.NewIncMetricEvent(key, group,
changeType, metricsConfigCenter.Zookeeper))
- if listeners, ok := l.keyListeners.Load(event.Path); ok {
- for listener := range
listeners.(map[config_center.ConfigurationListener]struct{}) {
- listener.Process(&config_center.ConfigChangeEvent{
- Key: key,
- Value: event.Content,
- ConfigType: changeType,
- })
- }
- return true
+ defer metrics.Publish(metricsConfigCenter.NewIncMetricEvent(key, group,
event.Action, metricsConfigCenter.Zookeeper))
+ listeners := l.snapshotListeners(event.Path)
+ for _, listener := range listeners {
+ listener.Process(&config_center.ConfigChangeEvent{
+ Key: key,
+ Value: event.Content,
+ ConfigType: event.Action,
+ })
+ }
+ return len(listeners) != 0
+}
+
+func (l *CacheListener) snapshotListeners(path string)
[]config_center.ConfigurationListener {
+ if l.cache != nil {
+ pathLock := l.cache.pathLock(path)
+ pathLock.Lock()
+ defer pathLock.Unlock()
+ }
+
+ listeners, ok := l.keyListeners.Load(path)
+ if !ok {
+ return nil
+ }
+ listenerSet :=
listeners.(map[config_center.ConfigurationListener]struct{})
+ result := make([]config_center.ConfigurationListener, 0,
len(listenerSet))
+ for listener := range listenerSet {
+ result = append(result, listener)
}
- return false
+ return result
}
func (l *CacheListener) pathToKeyGroup(path string) (string, string) {
diff --git a/config_center/zookeeper/listener_test.go
b/config_center/zookeeper/listener_test.go
index 7835ad8b4..e84e1d4e1 100644
--- a/config_center/zookeeper/listener_test.go
+++ b/config_center/zookeeper/listener_test.go
@@ -18,12 +18,23 @@
package zookeeper
import (
+ "fmt"
+ "sync"
+ "sync/atomic"
"testing"
+ "time"
+)
+
+import (
+ "github.com/go-zookeeper/zk"
+
+ "github.com/stretchr/testify/require"
)
import (
"dubbo.apache.org/dubbo-go/v3/config_center"
"dubbo.apache.org/dubbo-go/v3/remoting"
+ remotingzookeeper "dubbo.apache.org/dubbo-go/v3/remoting/zookeeper"
)
type recListener struct {
@@ -50,7 +61,8 @@ func TestCacheListenerDataChange(t *testing.T) {
}
func TestCacheListenerDataChangeEmptyContent(t *testing.T) {
- l := &CacheListener{rootPath: "/dubbo/config"}
+ cache := newConfigCache(time.Minute)
+ l := &CacheListener{rootPath: "/dubbo/config", cache: &cache}
path := "/dubbo/config/group/app"
rec := &recListener{}
l.keyListeners.Store(path,
map[config_center.ConfigurationListener]struct{}{rec: {}})
@@ -59,9 +71,375 @@ func TestCacheListenerDataChangeEmptyContent(t *testing.T) {
if !ok {
t.Fatalf("expected listeners to be notified")
}
- if len(rec.events) != 1 || rec.events[0].ConfigType !=
remoting.EventTypeDel {
+ if len(rec.events) != 1 || rec.events[0].ConfigType !=
remoting.EventTypeAdd {
t.Fatalf("unexpected events %+v", rec.events)
}
+ entry, ok := cache.getFresh(path)
+ if !ok || !entry.exists || entry.content != "" {
+ t.Fatalf("empty configuration should be cached as existing:
%+v", entry)
+ }
+
+ l.DataChange(remoting.Event{Path: path, Action: remoting.EventTypeDel})
+ if len(rec.events) != 2 || rec.events[1].ConfigType !=
remoting.EventTypeDel {
+ t.Fatalf("unexpected events %+v", rec.events)
+ }
+ entry, ok = cache.getFresh(path)
+ if !ok || entry.exists {
+ t.Fatalf("deleted configuration should be cached as missing:
%+v", entry)
+ }
+}
+
+func TestCacheListenerIgnoresEventAcrossReset(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ l := &CacheListener{rootPath: "/dubbo/config", cache: &cache}
+ path := "/dubbo/config/group/app"
+ cache.setWatch(path, configWatchState{registered: true, auto: true,
sessionID: 1})
+ require.True(t, l.WatchStateChanged(path))
+ cache.reset(2)
+ require.Equal(t, remotingzookeeper.WatchRegistrationReload,
+ l.WatchRegistered(path, make(chan zk.Event, 1), 1, 1))
+
+ _, ok := cache.getFresh(path)
+ if ok {
+ t.Fatal("event started before reset should not repopulate
cache")
+ }
+ _, watchState := cache.snapshot(path)
+ if watchState.tracked() {
+ t.Fatal("event started before reset should not reactivate watch
state")
+ }
+}
+
+func TestCacheListenerReloadDataChangeRejectsResetGeneration(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ path := "/dubbo/config/group/app"
+ cache.reset(1)
+ cache.setWatch(path, configWatchState{registered: true, auto: true,
sessionID: 1})
+ l := &CacheListener{rootPath: "/dubbo/config", cache: &cache}
+
+ require.True(t, l.WatchStateChanged(path))
+ eventStateValue, ok := l.eventGeneration.Load(path)
+ require.True(t, ok)
+ require.Equal(t, uint64(1),
eventStateValue.(watchEventState).generation)
+
+ cache.reset(2)
+ events := make(chan zk.Event, 1)
+ events <- zk.Event{Type: zk.EventNotWatching}
+ close(events)
+ require.Equal(t, remotingzookeeper.WatchRegistrationReload,
+ l.WatchRegistered(path, events, 1, 1))
+ _, ok = l.eventGeneration.Load(path)
+ require.True(t, ok)
+
+ l.DataChange(remoting.Event{Path: path, Action:
remoting.EventTypeUpdate, Content: "stale"})
+ _, ok = cache.getFresh(path)
+ require.False(t, ok)
+ _, ok = l.eventGeneration.Load(path)
+ require.False(t, ok)
+}
+
+func TestAddListenerPromotesAutoWatch(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ path := "/dubbo/config/group/app"
+ cache.setWatch(path, configWatchState{registered: true, auto: true,
sessionID: 1})
+ l := &CacheListener{cache: &cache}
+ rec := &recListener{}
+
+ require.NotPanics(t, func() {
+ l.AddListener(path, rec)
+ })
+
+ _, watchState := cache.snapshot(path)
+ require.True(t, watchState.registered)
+ require.False(t, watchState.auto)
+ require.Zero(t, cache.autoWatchCount)
+ listeners, ok := l.keyListeners.Load(path)
+ require.True(t, ok)
+ _, ok =
listeners.(map[config_center.ConfigurationListener]struct{})[rec]
+ require.True(t, ok)
+}
+
+func TestAddListenerReactivatesRetiredWatch(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ path := "/dubbo/config/group/app"
+ cache.setWatch(path, configWatchState{
+ registered: true,
+ retired: true,
+ sessionID: 1,
+ })
+ l := &CacheListener{cache: &cache}
+ rec := &recListener{}
+
+ l.AddListener(path, rec)
+
+ _, watchState := cache.snapshot(path)
+ require.True(t, watchState.registered)
+ require.False(t, watchState.retired)
+ require.False(t, watchState.auto)
+ _, ok := l.keyListeners.Load(path)
+ require.True(t, ok)
+}
+
+func TestCacheListenerConcurrentAddListenerSharesPendingRegistration(t
*testing.T) {
+ cache := newConfigCache(time.Minute)
+ l := newCacheListener("/dubbo/config", nil, &cache)
+ path := "/dubbo/config/group/app"
+ first := &recListener{}
+ second := &recListener{}
+ registerStarted := make(chan struct{})
+ releaseRegister := make(chan struct{})
+ var registrations atomic.Int32
+ register := func() (watchRegistration, error) {
+ if registrations.Add(1) == 1 {
+ close(registerStarted)
+ <-releaseRegister
+ }
+ return newTestWatchRegistration(1), nil
+ }
+
+ var wg sync.WaitGroup
+ wg.Go(func() { l.addListenerWithRegister(path, first, register) })
+ <-registerStarted
+ wg.Go(func() { l.addListenerWithRegister(path, second, register) })
+ time.Sleep(10 * time.Millisecond)
+ require.Equal(t, int32(1), registrations.Load())
+
+ close(releaseRegister)
+ wg.Wait()
+ require.Equal(t, int32(1), registrations.Load())
+ listeners, ok := l.keyListeners.Load(path)
+ require.True(t, ok)
+ listenerSet :=
listeners.(map[config_center.ConfigurationListener]struct{})
+ require.Len(t, listenerSet, 2)
+ _, watchState := cache.snapshot(path)
+ require.True(t, watchState.registered)
+ require.False(t, watchState.auto)
+}
+
+func TestAddListenerRegistrationFailureDoesNotRemoveExistingListener(t
*testing.T) {
+ cache := newConfigCache(time.Minute)
+ l := newCacheListener("/dubbo/config", nil, &cache)
+ path := "/dubbo/config/group/app"
+ rec := &recListener{}
+ l.storeListener(path, rec)
+
+ l.addListenerWithRegister(path, rec, func() (watchRegistration, error) {
+ return watchRegistration{}, errWatchRegistrationStale
+ })
+
+ listeners, ok := l.keyListeners.Load(path)
+ require.True(t, ok)
+ listenerSet :=
listeners.(map[config_center.ConfigurationListener]struct{})
+ _, ok = listenerSet[rec]
+ require.True(t, ok)
+}
+
+func TestAddListenerStopsRetryWhenListenerIsRemoved(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ l := newCacheListener("/dubbo/config", nil, &cache)
+ path := "/dubbo/config/group/app"
+ rec := &recListener{}
+ registerStarted := make(chan struct{})
+ releaseRegister := make(chan struct{})
+ var registrations atomic.Int32
+ register := func() (watchRegistration, error) {
+ registrations.Add(1)
+ close(registerStarted)
+ <-releaseRegister
+ events := make(chan zk.Event, 1)
+ events <- zk.Event{Type: zk.EventNotWatching}
+ close(events)
+ return watchRegistration{
+ events: events,
+ beforeSessionID: 1,
+ afterSessionID: 2,
+ }, nil
+ }
+
+ done := make(chan struct{})
+ go func() {
+ l.addListenerWithRegister(path, rec, register)
+ close(done)
+ }()
+ <-registerStarted
+ l.RemoveListener(path, rec)
+ close(releaseRegister)
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("AddListener did not stop after listener removal")
+ }
+
+ require.Equal(t, int32(1), registrations.Load())
+ _, ok := l.keyListeners.Load(path)
+ require.False(t, ok)
+ _, watchState := cache.snapshot(path)
+ require.False(t, watchState.tracked())
+}
+
+func TestRemovingOneListenerDoesNotReleaseSharedBusinessWatch(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ l := newCacheListener("/dubbo/config", nil, &cache)
+ path := "/dubbo/config/group/app"
+ first := &recListener{}
+ second := &recListener{}
+ registerStarted := make(chan struct{})
+ releaseRegister := make(chan struct{})
+ var registrations atomic.Int32
+ register := func() (watchRegistration, error) {
+ if registrations.Add(1) == 1 {
+ close(registerStarted)
+ <-releaseRegister
+ }
+ return newTestWatchRegistration(1), nil
+ }
+
+ var wg sync.WaitGroup
+ wg.Go(func() { l.addListenerWithRegister(path, first, register) })
+ <-registerStarted
+ wg.Go(func() { l.addListenerWithRegister(path, second, register) })
+ time.Sleep(10 * time.Millisecond)
+ l.RemoveListener(path, first)
+ close(releaseRegister)
+ wg.Wait()
+
+ require.Equal(t, int32(1), registrations.Load())
+ require.True(t, l.hasListener(path, second))
+ _, watchState := cache.snapshot(path)
+ require.True(t, watchState.registered)
+ require.False(t, watchState.auto)
+}
+
+func TestWatchStateChangedUsesCurrentBusinessOwnership(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ path := "/dubbo/config/group/app"
+ require.True(t, cache.setWatch(path, configWatchState{
+ registered: true,
+ auto: true,
+ sessionID: 1,
+ }))
+ l := &CacheListener{cache: &cache}
+ rec := &recListener{}
+
+ l.AddListener(path, rec)
+ require.True(t, l.WatchStateChanged(path))
+
+ _, watchState := cache.snapshot(path)
+ require.True(t, watchState.pending)
+ require.False(t, watchState.auto)
+ require.Zero(t, cache.autoWatchReservations)
+}
+
+func TestAddListenerRegistersBusinessWatchAtAutoWatchLimit(t *testing.T) {
+ client, _ := newZookeeperTestClient(t, "business-watch-limit")
+ root := newTestRoot(t, client)
+
+ cache := newConfigCache(time.Minute)
+ for i := range maxAutoWatches {
+ require.True(t, cache.setWatch(fmt.Sprintf("/auto/%d", i),
configWatchState{
+ registered: true,
+ auto: true,
+ sessionID: 1,
+ }))
+ }
+ zkListener := remotingzookeeper.NewZkEventListener(client)
+ defer zkListener.Close()
+ l := newCacheListener(root, zkListener, &cache)
+ path := root + "/group/app"
+ rec := &recListener{}
+
+ l.AddListener(path, rec)
+
+ _, watchState := cache.snapshot(path)
+ require.True(t, watchState.registered)
+ require.False(t, watchState.auto)
+ require.Equal(t, maxAutoWatches, cache.autoWatchCount)
+ require.Zero(t, cache.autoWatchReservations)
+ listeners, ok := l.keyListeners.Load(path)
+ require.True(t, ok)
+ _, ok =
listeners.(map[config_center.ConfigurationListener]struct{})[rec]
+ require.True(t, ok)
+}
+
+func TestCacheListenerPreservesAutoWatchOwnershipOnRenewal(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ path := "/dubbo/config/group/app"
+ cache.setWatch(path, configWatchState{registered: true, auto: true,
sessionID: 1})
+ l := &CacheListener{cache: &cache}
+
+ require.True(t, l.WatchStateChanged(path))
+ _, watchState := cache.snapshot(path)
+ require.False(t, watchState.registered)
+ require.True(t, watchState.pending)
+ require.True(t, watchState.auto)
+ require.Zero(t, cache.autoWatchCount)
+ require.Equal(t, 1, cache.autoWatchReservations)
+
+ require.Equal(t, remotingzookeeper.WatchRegistrationAccepted,
+ l.WatchRegistered(path, make(chan zk.Event, 1), 1, 1))
+ _, watchState = cache.snapshot(path)
+ require.True(t, watchState.registered)
+ require.True(t, watchState.auto)
+ require.False(t, watchState.pending)
+ require.Equal(t, 1, cache.autoWatchCount)
+ require.Zero(t, cache.autoWatchReservations)
+}
+
+func TestCacheListenerRetriesInvalidatedWatchInNewSession(t *testing.T) {
+ client, _ := newZookeeperTestClient(t,
"retry-invalidated-business-watch")
+ root := newTestRoot(t, client)
+ zkListener := remotingzookeeper.NewZkEventListener(client)
+ defer zkListener.Close()
+
+ cache := newConfigCache(time.Minute)
+ currentSessionID := client.Conn.SessionID()
+ cache.reset(currentSessionID)
+ path := root + "/group/app"
+ previousSessionID := currentSessionID - 1
+ require.True(t, cache.setWatch(path, configWatchState{
+ pending: true,
+ sessionID: previousSessionID,
+ }))
+ l := newCacheListener(root, zkListener, &cache)
+ l.keyListeners.Store(path,
map[config_center.ConfigurationListener]struct{}{&recListener{}: {}})
+ l.eventGeneration.Store(path, watchEventState{
+ generation: cache.generation,
+ sessionID: previousSessionID,
+ })
+ events := make(chan zk.Event, 1)
+ events <- zk.Event{Type: zk.EventNotWatching}
+ close(events)
+
+ require.Equal(t, remotingzookeeper.WatchRegistrationReload,
+ l.WatchRegistered(path, events, previousSessionID,
currentSessionID))
+ _, watchState := cache.snapshot(path)
+ require.True(t, watchState.registered)
+ require.False(t, watchState.pending)
+ require.False(t, watchState.auto)
+ require.Equal(t, currentSessionID, watchState.sessionID)
+}
+
+func TestCacheListenerDiscardsStaleBusinessRegistrationWithoutListener(t
*testing.T) {
+ cache := newConfigCache(time.Minute)
+ path := "/dubbo/config/group/app"
+ require.True(t, cache.setWatch(path, configWatchState{
+ pending: true,
+ sessionID: 1,
+ }))
+ l := &CacheListener{cache: &cache}
+ l.eventGeneration.Store(path, watchEventState{
+ generation: cache.generation,
+ sessionID: 1,
+ })
+ events := make(chan zk.Event, 1)
+ events <- zk.Event{Type: zk.EventNotWatching}
+ close(events)
+
+ result := l.WatchRegistered(path, events, 1, 2)
+ require.Equal(t, remotingzookeeper.WatchRegistrationDiscarded, result)
+ _, watchState := cache.snapshot(path)
+ require.False(t, watchState.tracked())
+ _, ok := l.eventGeneration.Load(path)
+ require.False(t, ok)
}
func TestCacheListenerPathToKeyGroup(t *testing.T) {
@@ -73,14 +451,74 @@ func TestCacheListenerPathToKeyGroup(t *testing.T) {
}
func TestCacheListenerRemoveListener(t *testing.T) {
- l := &CacheListener{}
+ cache := newConfigCache(time.Minute)
+ l := &CacheListener{cache: &cache}
key := "k"
rec := &recListener{}
+ cache.setWatch(key, configWatchState{registered: true, sessionID: 1})
l.keyListeners.Store(key,
map[config_center.ConfigurationListener]struct{}{rec: {}})
+
l.RemoveListener(key, rec)
- if m, ok := l.keyListeners.Load(key); ok {
- if _, exists :=
m.(map[config_center.ConfigurationListener]struct{})[rec]; exists {
- t.Fatalf("listener should be removed")
- }
+
+ _, ok := l.keyListeners.Load(key)
+ require.False(t, ok)
+ _, watchState := cache.snapshot(key)
+ require.True(t, watchState.registered)
+ require.True(t, watchState.auto)
+ require.Equal(t, 1, cache.autoWatchCount)
+}
+
+func TestCacheListenerRemoveListenerRetiresWatchAtAutoLimit(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ for i := range maxAutoWatches {
+ require.True(t, cache.setWatch(fmt.Sprintf("/auto/%d", i),
configWatchState{
+ registered: true,
+ auto: true,
+ sessionID: 1,
+ }))
}
+ path := "/dubbo/config/group/app"
+ require.True(t, cache.setWatch(path, configWatchState{
+ registered: true,
+ sessionID: 1,
+ }))
+ l := newCacheListener("/dubbo/config", nil, &cache)
+ rec := &recListener{}
+ l.keyListeners.Store(path,
map[config_center.ConfigurationListener]struct{}{rec: {}})
+
+ l.RemoveListener(path, rec)
+
+ _, ok := l.keyListeners.Load(path)
+ require.False(t, ok)
+ _, watchState := cache.snapshot(path)
+ require.True(t, watchState.registered)
+ require.True(t, watchState.retired)
+ require.False(t, watchState.auto)
+ require.Equal(t, maxAutoWatches, cache.autoWatchCount)
+
+ require.False(t, l.WatchStateChanged(path))
+ _, watchState = cache.snapshot(path)
+ require.False(t, watchState.tracked())
+ require.False(t, l.DataChange(remoting.Event{Path: path, Action:
remoting.EventTypeUpdate, Content: "value"}))
+ entry, ok := cache.getFresh(path)
+ require.True(t, ok)
+ require.Equal(t, "value", entry.content)
+}
+
+func TestCacheListenerResidualEventDoesNotRenewWatch(t *testing.T) {
+ cache := newConfigCache(time.Minute)
+ path := "/dubbo/config/group/app"
+ l := &CacheListener{rootPath: "/dubbo/config", cache: &cache}
+
+ require.False(t, l.WatchStateChanged(path))
+ _, watchState := cache.snapshot(path)
+ require.False(t, watchState.tracked())
+ require.False(t, l.DataChange(remoting.Event{Path: path, Action:
remoting.EventTypeUpdate, Content: "value"}))
+
+ entry, ok := cache.getFresh(path)
+ require.True(t, ok)
+ require.True(t, entry.exists)
+ require.Equal(t, "value", entry.content)
+ _, ok = l.eventGeneration.Load(path)
+ require.False(t, ok)
}
diff --git a/remoting/zookeeper/listener.go b/remoting/zookeeper/listener.go
index 782b84797..c50a91951 100644
--- a/remoting/zookeeper/listener.go
+++ b/remoting/zookeeper/listener.go
@@ -56,6 +56,27 @@ type ZkEventListener struct {
exit chan struct{}
}
+// WatchRegistrationResult tells the configuration event loop how to continue
+// after a listener validates a newly registered ZooKeeper watch.
+type WatchRegistrationResult uint8
+
+const (
+ // WatchRegistrationAccepted keeps the watch and processes the current
read.
+ WatchRegistrationAccepted WatchRegistrationResult = iota
+ // WatchRegistrationReload re-reads the node without registering
another watch.
+ WatchRegistrationReload
+ // WatchRegistrationDiscarded stops processing a stale event.
+ WatchRegistrationDiscarded
+)
+
+// configurationWatchStateListener lets the config center validate watch state
+// while the remoting layer remains responsible for ZooKeeper reads and events.
+type configurationWatchStateListener interface {
+ WatchStateChanged(path string) bool
+ WatchRegistered(path string, events <-chan zk.Event, beforeSessionID,
afterSessionID int64) WatchRegistrationResult
+ WatchStateChangeFailed(path string)
+}
+
// NewZkEventListener returns a EventListener instance
func NewZkEventListener(client *gxzookeeper.ZookeeperClient) *ZkEventListener {
return &ZkEventListener{
@@ -84,45 +105,15 @@ func (l *ZkEventListener) ListenServiceNodeEvent(zkPath
string, listener remotin
func (l *ZkEventListener) ListenConfigurationEvent(zkPath string, listener
remoting.DataListener) {
l.wg.Add(1)
go func(zkPath string, listener remoting.DataListener) {
+ defer l.wg.Done()
var eventChan = make(chan zk.Event, 16)
l.Client.RegisterEvent(zkPath, eventChan)
+ watchStateListener, tracksWatchState :=
listener.(configurationWatchStateListener)
for {
select {
case event := <-eventChan:
logger.Infof("[Remoting][Zookeeper]Receive
configuration change event:%#v", event)
- if event.Type == zk.EventNodeChildrenChanged ||
event.Type == zk.EventNotWatching {
- continue
- }
- // 1. Re-set watcher for the zk node
- _, _, _, err :=
l.Client.Conn.ExistsW(event.Path)
- if err != nil {
-
logger.Warnf("[Remoting][Zookeeper]Re-set watcher error, err=%v", err)
- continue
- }
-
- action := remoting.EventTypeAdd
- var content string
- if event.Type == zk.EventNodeDeleted {
- action = remoting.EventTypeDel
- } else {
- // 2. Try to get new configuration
value of the zk node
- // Notice: The order of step 1 and step
2 cannot be swapped, if you get value(with timestamp t1)
- // before re-set the watcher(with
timestamp t2), and some one change the data of the zk node after
- // t2 but before t1, you may get the
old value, and the new value will not trigger the event.
- contentBytes, _, err :=
l.Client.Conn.Get(event.Path)
- if err != nil {
-
logger.Warnf("[Remoting][Zookeeper] get config value error, err=%v", err)
- continue
- }
- content = string(contentBytes)
- logger.Debugf("[Remoting][Zookeeper]
successfully get new config value=%s", string(content))
- }
-
- listener.DataChange(remoting.Event{
- Path: event.Path,
- Action: remoting.EventType(action),
- Content: content,
- })
+ l.processConfigurationEvent(event, listener,
watchStateListener, tracksWatchState)
case <-l.exit:
return
}
@@ -131,6 +122,135 @@ func (l *ZkEventListener) ListenConfigurationEvent(zkPath
string, listener remot
}(zkPath, listener)
}
+type configurationEventReader struct {
+ exists func(registerWatch bool) (bool, <-chan zk.Event, int64, int64,
error)
+ content func() ([]byte, int64, int64, error)
+}
+
+// processConfigurationEvent renews a one-shot watch when requested before
+// reading the latest value, then lets watch-aware listeners reject results
from
+// stale sessions.
+func (l *ZkEventListener) processConfigurationEvent(
+ event zk.Event,
+ listener remoting.DataListener,
+ watchStateListener configurationWatchStateListener,
+ tracksWatchState bool,
+) {
+ reader := configurationEventReader{
+ exists: func(registerWatch bool) (bool, <-chan zk.Event, int64,
int64, error) {
+ beforeSessionID := l.Client.Conn.SessionID()
+ var (
+ exists bool
+ watchEvents <-chan zk.Event
+ err error
+ )
+ if registerWatch {
+ exists, _, watchEvents, err =
l.Client.Conn.ExistsW(event.Path)
+ } else {
+ exists, _, err =
l.Client.Conn.Exists(event.Path)
+ }
+ return exists, watchEvents, beforeSessionID,
l.Client.Conn.SessionID(), err
+ },
+ content: func() ([]byte, int64, int64, error) {
+ beforeSessionID := l.Client.Conn.SessionID()
+ content, _, err := l.Client.Conn.Get(event.Path)
+ return content, beforeSessionID,
l.Client.Conn.SessionID(), err
+ },
+ }
+ processConfigurationEvent(event, listener, watchStateListener,
tracksWatchState, reader)
+}
+
+func processConfigurationEvent(
+ event zk.Event,
+ listener remoting.DataListener,
+ watchStateListener configurationWatchStateListener,
+ tracksWatchState bool,
+ reader configurationEventReader,
+) {
+ if event.Type == zk.EventNotWatching || event.Type ==
zk.EventNodeChildrenChanged {
+ return
+ }
+ registerWatch := true
+ if tracksWatchState {
+ registerWatch = watchStateListener.WatchStateChanged(event.Path)
+ }
+
+ // Re-set the watcher before reading the value so a concurrent update
cannot
+ // occur between the read and watch registration.
+ exists, watchEvents, beforeSessionID, afterSessionID, err :=
reader.exists(registerWatch)
+ if err != nil {
+ if tracksWatchState {
+ watchStateListener.WatchStateChangeFailed(event.Path)
+ }
+ logger.Warnf("[Remoting][Zookeeper]Re-set watcher error,
err=%v", err)
+ return
+ }
+ if tracksWatchState && registerWatch {
+ registrationResult := watchStateListener.WatchRegistered(
+ event.Path, watchEvents, beforeSessionID,
afterSessionID,
+ )
+ if registrationResult == WatchRegistrationDiscarded {
+ return
+ }
+ if registrationResult == WatchRegistrationAccepted &&
+ !sessionStable(beforeSessionID, afterSessionID) {
+ watchStateListener.WatchStateChangeFailed(event.Path)
+ return
+ }
+ if registrationResult == WatchRegistrationReload {
+ exists, _, beforeSessionID, afterSessionID, err =
reader.exists(false)
+ if err != nil {
+
watchStateListener.WatchStateChangeFailed(event.Path)
+ logger.Warnf("[Remoting][Zookeeper] reload
config existence error, err=%v", err)
+ return
+ }
+ if !sessionStable(beforeSessionID, afterSessionID) {
+
watchStateListener.WatchStateChangeFailed(event.Path)
+ return
+ }
+ }
+ }
+ if !tracksWatchState || !registerWatch {
+ if !sessionStable(beforeSessionID, afterSessionID) {
+ if tracksWatchState {
+
watchStateListener.WatchStateChangeFailed(event.Path)
+ }
+ return
+ }
+ }
+
+ action := remoting.EventTypeDel
+ var content string
+ if exists {
+ action = remoting.EventTypeAdd
+ contentBytes, beforeSessionID, afterSessionID, err :=
reader.content()
+ if err != nil {
+ if tracksWatchState {
+
watchStateListener.WatchStateChangeFailed(event.Path)
+ }
+ logger.Warnf("[Remoting][Zookeeper] get config value
error, err=%v", err)
+ return
+ }
+ if !sessionStable(beforeSessionID, afterSessionID) {
+ if tracksWatchState {
+
watchStateListener.WatchStateChangeFailed(event.Path)
+ }
+ return
+ }
+ content = string(contentBytes)
+ logger.Debugf("[Remoting][Zookeeper] successfully get new
config value=%s", content)
+ }
+
+ listener.DataChange(remoting.Event{Path: event.Path, Action: action,
Content: content})
+}
+
+// sessionStable reports whether a ZooKeeper operation completed in the session
+// in which it started. A zero pair represents a reader without session data.
+func sessionStable(beforeSessionID, afterSessionID int64) bool {
+ return (beforeSessionID == 0 && afterSessionID == 0) ||
+ (beforeSessionID != 0 && beforeSessionID == afterSessionID)
+}
+
// listenServiceNodeEvent watches a single zk node and reports changes via
listener.
func (l *ZkEventListener) listenServiceNodeEvent(zkPath string, listener
...remoting.DataListener) bool {
l.pathMapLock.Lock()
diff --git a/remoting/zookeeper/listener_test.go
b/remoting/zookeeper/listener_test.go
index 64e042b85..d4f6f5844 100644
--- a/remoting/zookeeper/listener_test.go
+++ b/remoting/zookeeper/listener_test.go
@@ -20,14 +20,221 @@ package zookeeper
import (
"net/url"
"testing"
+ "time"
)
import (
+ gxzookeeper "github.com/dubbogo/gost/database/kv/zk"
+
+ "github.com/go-zookeeper/zk"
+
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/remoting"
)
+type recordingDataListener struct {
+ events []remoting.Event
+}
+
+func (l *recordingDataListener) DataChange(event remoting.Event) bool {
+ l.events = append(l.events, event)
+ return true
+}
+
+type recordingWatchStateListener struct {
+ registerWatch bool
+ result WatchRegistrationResult
+ changed int
+ registered int
+ failed int
+}
+
+func (l *recordingWatchStateListener) WatchStateChanged(string) bool {
+ l.changed++
+ return l.registerWatch
+}
+
+func (l *recordingWatchStateListener) WatchRegistered(string, <-chan zk.Event,
int64, int64) WatchRegistrationResult {
+ l.registered++
+ return l.result
+}
+
+func (l *recordingWatchStateListener) WatchStateChangeFailed(string) {
+ l.failed++
+}
+
func TestZkPath(t *testing.T) {
zkPath := "io.grpc.examples.helloworld.GreeterGrpc$IGreeter"
zkPath = url.QueryEscape(zkPath)
assert.Equal(t, "io.grpc.examples.helloworld.GreeterGrpc%24IGreeter",
zkPath)
}
+
+func TestListenConfigurationEventStopsOnClose(t *testing.T) {
+ client, err := gxzookeeper.NewZookeeperClient(
+ "remoting-listener-event-loop",
+ []string{"127.0.0.1:0"},
+ false,
+ gxzookeeper.WithZkTimeOut(100*time.Millisecond),
+ )
+ require.NoError(t, err)
+ defer client.Close()
+
+ eventListener := NewZkEventListener(client)
+ eventListener.ListenConfigurationEvent("/config",
&recordingDataListener{})
+ // ListenConfigurationEvent registers asynchronously before waiting on
exit.
+ time.Sleep(50 * time.Millisecond)
+
+ closed := make(chan struct{})
+ go func() {
+ eventListener.Close()
+ close(closed)
+ }()
+ select {
+ case <-closed:
+ case <-time.After(2 * time.Second):
+ t.Fatal("ListenConfigurationEvent goroutine did not exit after
Close")
+ }
+}
+
+func TestProcessConfigurationEventRejectsUnstableMissingNodeRead(t *testing.T)
{
+ dataListener := &recordingDataListener{}
+ watchListener := &recordingWatchStateListener{}
+ reader := configurationEventReader{
+ exists: func(bool) (bool, <-chan zk.Event, int64, int64, error)
{
+ return false, nil, 1, 2, nil
+ },
+ content: func() ([]byte, int64, int64, error) {
+ t.Fatalf("content read should not happen for a missing
node")
+ return nil, 0, 0, nil
+ },
+ }
+
+ processConfigurationEvent(
+ zk.Event{Type: zk.EventNodeDataChanged, Path: "/config"},
+ dataListener,
+ watchListener,
+ true,
+ reader,
+ )
+
+ require.Empty(t, dataListener.events)
+ require.Equal(t, 1, watchListener.failed)
+}
+
+func TestProcessConfigurationEventHandlesMissingNodeAndWatchResults(t
*testing.T) {
+ t.Run("ttl fallback reports stable deletion", func(t *testing.T) {
+ dataListener := &recordingDataListener{}
+ watchListener := &recordingWatchStateListener{}
+ processConfigurationEvent(
+ zk.Event{Type: zk.EventNodeDataChanged, Path:
"/config"},
+ dataListener,
+ watchListener,
+ true,
+ configurationEventReader{
+ exists: func(register bool) (bool, <-chan
zk.Event, int64, int64, error) {
+ require.False(t, register)
+ return false, nil, 1, 1, nil
+ },
+ content: func() ([]byte, int64, int64, error) {
+ t.Fatalf("content read should not
happen for a missing node")
+ return nil, 0, 0, nil
+ },
+ },
+ )
+
+ require.Len(t, dataListener.events, 1)
+ require.Equal(t, remoting.EventTypeDel,
dataListener.events[0].Action)
+ require.Zero(t, watchListener.failed)
+ })
+
+ t.Run("accepted watch reports updated content", func(t *testing.T) {
+ dataListener := &recordingDataListener{}
+ watchListener := &recordingWatchStateListener{
+ registerWatch: true,
+ result: WatchRegistrationAccepted,
+ }
+ processConfigurationEvent(
+ zk.Event{Type: zk.EventNodeDataChanged, Path:
"/config"},
+ dataListener,
+ watchListener,
+ true,
+ configurationEventReader{
+ exists: func(register bool) (bool, <-chan
zk.Event, int64, int64, error) {
+ require.True(t, register)
+ return true, make(chan zk.Event), 1, 1,
nil
+ },
+ content: func() ([]byte, int64, int64, error) {
+ return []byte("value"), 1, 1, nil
+ },
+ },
+ )
+
+ require.Len(t, dataListener.events, 1)
+ require.Equal(t, remoting.EventTypeAdd,
dataListener.events[0].Action)
+ require.Equal(t, "value", dataListener.events[0].Content)
+ require.Equal(t, 1, watchListener.registered)
+ })
+
+ t.Run("discarded watch does not notify", func(t *testing.T) {
+ dataListener := &recordingDataListener{}
+ watchListener := &recordingWatchStateListener{
+ registerWatch: true,
+ result: WatchRegistrationDiscarded,
+ }
+ processConfigurationEvent(
+ zk.Event{Type: zk.EventNodeDataChanged, Path:
"/config"},
+ dataListener,
+ watchListener,
+ true,
+ configurationEventReader{
+ exists: func(bool) (bool, <-chan zk.Event,
int64, int64, error) {
+ return true, make(chan zk.Event), 1, 1,
nil
+ },
+ content: func() ([]byte, int64, int64, error) {
+ t.Fatalf("content read should not
happen for a discarded watch")
+ return nil, 0, 0, nil
+ },
+ },
+ )
+
+ require.Empty(t, dataListener.events)
+ })
+
+ t.Run("reload uses an ordinary stable existence read", func(t
*testing.T) {
+ dataListener := &recordingDataListener{}
+ watchListener := &recordingWatchStateListener{
+ registerWatch: true,
+ result: WatchRegistrationReload,
+ }
+ var calls int
+ processConfigurationEvent(
+ zk.Event{Type: zk.EventNodeDeleted, Path: "/config"},
+ dataListener,
+ watchListener,
+ true,
+ configurationEventReader{
+ exists: func(register bool) (bool, <-chan
zk.Event, int64, int64, error) {
+ calls++
+ if calls == 1 {
+ require.True(t, register)
+ return true, make(chan
zk.Event), 1, 1, nil
+ }
+ require.False(t, register)
+ return false, nil, 1, 1, nil
+ },
+ content: func() ([]byte, int64, int64, error) {
+ t.Fatalf("content read should not
happen after reload reports missing")
+ return nil, 0, 0, nil
+ },
+ },
+ )
+
+ require.Len(t, dataListener.events, 1)
+ require.Equal(t, remoting.EventTypeDel,
dataListener.events[0].Action)
+ require.Equal(t, 2, calls)
+ })
+}