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 ab00bd408 feat(router): add router chain snapshot cache (#3305)
ab00bd408 is described below
commit ab00bd40810a944593738270838e8273a44bcb56
Author: Aether <[email protected]>
AuthorDate: Wed Jun 10 21:36:58 2026 +0800
feat(router): add router chain snapshot cache (#3305)
* feat(router): add invoker-snapshot cache for Poolable route
Introduce routerCache on RouterChain, rebuilt in SetInvokers. Add
FindAddrPoolWithInvokers to atomically read snapshot and pool.
* test(router): add unit tests for tag router bitmap cache path
* test(router): add benchmarks for tag router snapshot cache
* fix: strengthen cache invalidation checks to avoid wrong invoker selection
* fix(router): make FindAddrPool return pool and invokers atomically
* fix(router): tag router failover should return untagged providers only
* docs: add comment explaining cache only works when TagRouter is first in
chain
* fix: use atomic.Value for TagRouter.cache to prevent data race
* fix: add defensive check and TODO comment for TagRouter cache
* chore: use roaring.Iterator to avoid intermediate allocation in
collectInvokers
* chore: enable memory allocation reporting in tag router benchmarks
* chore: extract bitmap key prefixes to constants for Poolable routers
* chore: remove unnecessary copy in FindAddrPool
* fix(router): incorrect tag routing and regex overhead for match field
with non-indexed keys
* refactor(router): split tag router code into router.go, cache.go, and
match.go
* fix(router): guard tag router cache path with chain snapshot generation
* fix router cache injection for added routers
---
cluster/router/chain/cache.go | 100 ++++++++++
cluster/router/chain/chain.go | 40 +++-
cluster/router/chain/chain_test.go | 134 +++++++++++++
cluster/router/router.go | 15 +-
cluster/router/tag/cache.go | 213 +++++++++++++++++++++
cluster/router/tag/cache_benchmarks_test.go | 219 +++++++++++++++++++++
cluster/router/tag/match.go | 2 +-
cluster/router/tag/router.go | 21 ++
cluster/router/tag/router_test.go | 286 ++++++++++++++++++++++++++++
common/constant/key.go | 8 +
10 files changed, 1030 insertions(+), 8 deletions(-)
diff --git a/cluster/router/chain/cache.go b/cluster/router/chain/cache.go
new file mode 100644
index 000000000..24ddfb99a
--- /dev/null
+++ b/cluster/router/chain/cache.go
@@ -0,0 +1,100 @@
+/*
+ * 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 chain
+
+import (
+ "sync"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/cluster/router"
+ "dubbo.apache.org/dubbo-go/v3/protocol/base"
+)
+
+// routerCache implements router.Cache by storing pre-computed AddrPool
+// keyed by Poolable.Name(). It is rebuilt in its entirety on each
+// SetInvokers call so that bitmap indices stay aligned with the current
+// invoker snapshot.
+type routerCache struct {
+ mu sync.RWMutex
+ // generation identifies the invoker snapshot this cache was built
from. It mirrors
+ // RouterChain.generation and is set under the same write lock as
invokers/pools so that
+ // FindAddrPool can hand all three out as one consistent snapshot.
+ generation uint64
+ invokers []base.Invoker
+ pools map[string]*poolEntry
+}
+
+type poolEntry struct {
+ pool router.AddrPool
+}
+
+func newRouterCache() *routerCache {
+ return &routerCache{
+ pools: make(map[string]*poolEntry),
+ }
+}
+
+func (c *routerCache) GetInvokers() []base.Invoker {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ ret := make([]base.Invoker, len(c.invokers))
+ copy(ret, c.invokers)
+ return ret
+}
+
+// FindAddrPool returns the address pool, the invoker snapshot, and the
generation of that
+// snapshot for the given Poolable. The returned invokers slice is shared and
must not be
+// modified by the caller. The generation is always returned (even on a miss)
so callers can
+// detect a snapshot rebuilt by a concurrent SetInvokers.
+func (c *routerCache) FindAddrPool(p router.Poolable) (router.AddrPool,
[]base.Invoker, uint64) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ entry, ok := c.pools[p.Name()]
+ if !ok {
+ return nil, nil, c.generation
+ }
+ return entry.pool, c.invokers, c.generation
+}
+
+// FindAddrMeta is a no-op, reserved for future use.
+func (c *routerCache) FindAddrMeta(p router.Poolable) router.AddrMetadata {
+ return nil
+}
+
+// rebuild iterates all Poolable routers whose ShouldPool returns true,
+// calls Pool on each with current invokers, and atomically swaps in the
+// new pools and generation under write lock. The generation must match the
+// RouterChain.generation produced by the same SetInvokers call.
+func (c *routerCache) rebuild(generation uint64, invokers []base.Invoker,
routers []router.PriorityRouter) {
+ newPools := make(map[string]*poolEntry, len(routers))
+ for _, r := range routers {
+ p, ok := r.(router.Poolable)
+ if !ok || !p.ShouldPool() {
+ continue
+ }
+ pool, _ := p.Pool(invokers) // TODO: store AddrMetadata when
needed
+ newPools[p.Name()] = &poolEntry{pool: pool}
+ }
+
+ c.mu.Lock()
+ c.generation = generation
+ c.invokers = invokers
+ c.pools = newPools
+ c.mu.Unlock()
+}
diff --git a/cluster/router/chain/chain.go b/cluster/router/chain/chain.go
index 428e36d56..adbc7a74b 100644
--- a/cluster/router/chain/chain.go
+++ b/cluster/router/chain/chain.go
@@ -49,12 +49,24 @@ type RouterChain struct {
// instance will never delete or recreate.
builtinRouters []router.PriorityRouter
- mutex sync.RWMutex
+ cache *routerCache
+ // generation is bumped under mutex on every SetInvokers and identifies
the current
+ // invoker snapshot. It is handed to Route callers (via invocation
attribute) and stored
+ // in the cache so a router can prove the cached pool belongs to the
snapshot it is routing
+ // for. 0 means SetInvokers has never run, so no cache exists yet.
+ generation uint64
+ mutex sync.RWMutex
}
// Route Loop routers in RouterChain and call Route method to determine the
target invokers list.
func (c *RouterChain) Route(url *common.URL, invocation base.Invocation)
[]base.Invoker {
- invokers := c.snapshotInvokers()
+ invokers, generation := c.snapshotInvokers()
+ // Publish the snapshot generation so Poolable routers can verify their
cache was built
+ // from this same snapshot before taking the bitmap fast path.
generation == 0 means no
+ // SetInvokers has run yet, hence no cache exists, so there is nothing
to publish.
+ if generation > 0 {
+ invocation.SetAttribute(constant.RouterChainCacheGeneration,
generation)
+ }
finalInvokers := make([]base.Invoker, 0, len(invokers))
// multiple invoker may include different methods, find correct invoker
otherwise
// will return the invoker without methods
@@ -66,10 +78,13 @@ func (c *RouterChain) Route(url *common.URL, invocation
base.Invocation) []base.
if len(finalInvokers) == 0 {
finalInvokers = invokers
+ } else if len(finalInvokers) != len(invokers) {
+ invocation.SetAttribute(constant.RouterCacheDisable, true)
}
for _, r := range c.copyRouters() {
finalInvokers = r.Route(finalInvokers, url, invocation)
+ invocation.SetAttribute(constant.RouterCacheDisable, true)
}
return finalInvokers
}
@@ -93,12 +108,27 @@ func (c *RouterChain) AddRouters(routers
[]router.PriorityRouter) {
func (c *RouterChain) SetInvokers(invokers []base.Invoker) {
c.mutex.Lock()
defer c.mutex.Unlock()
+ c.generation++
c.invokers = invokers
+ c.rebuildCache(invokers)
for _, v := range c.routers {
v.Notify(c.invokers)
}
}
+func (c *RouterChain) rebuildCache(invokers []base.Invoker) {
+ if c.cache == nil {
+ c.cache = newRouterCache()
+ }
+ // Re-inject cache so routers added via AddRouters can receive it.
+ for _, r := range c.routers {
+ if accessor, ok := r.(router.CacheAccessor); ok {
+ accessor.SetCache(c.cache)
+ }
+ }
+ c.cache.rebuild(c.generation, invokers, c.routers)
+}
+
// copyRouters make a snapshot copy from RouterChain's router list.
func (c *RouterChain) copyRouters() []router.PriorityRouter {
c.mutex.RLock()
@@ -108,13 +138,13 @@ func (c *RouterChain) copyRouters()
[]router.PriorityRouter {
return ret
}
-// snapshotInvokers returns a copy of current invokers under lock.
-func (c *RouterChain) snapshotInvokers() []base.Invoker {
+// snapshotInvokers returns a copy of current invokers and their generation
under lock.
+func (c *RouterChain) snapshotInvokers() ([]base.Invoker, uint64) {
c.mutex.RLock()
defer c.mutex.RUnlock()
ret := make([]base.Invoker, len(c.invokers))
copy(ret, c.invokers)
- return ret
+ return ret, c.generation
}
// injectStaticRouters injects static router configurations into the router
chain.
diff --git a/cluster/router/chain/chain_test.go
b/cluster/router/chain/chain_test.go
index 96557c608..5ea72d187 100644
--- a/cluster/router/chain/chain_test.go
+++ b/cluster/router/chain/chain_test.go
@@ -18,10 +18,15 @@
package chain
import (
+ "strconv"
+ "sync"
+ "sync/atomic"
"testing"
)
import (
+ "github.com/RoaringBitmap/roaring"
+
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -342,3 +347,132 @@ func TestRouteAppliesRoutersOnSnapshot(t *testing.T) {
assert.Equal(t, 1, r2.called)
assert.Equal(t, 1, r2.lastSize)
}
+
+// TestSetInvokersIncrementsAndPublishesGeneration verifies that each
SetInvokers bumps the
+// chain generation and that Route publishes the current generation into the
invocation so
+// Poolable routers can validate their cache against it.
+func TestSetInvokersIncrementsAndPublishesGeneration(t *testing.T) {
+ consumerURL, err := common.NewURL(testConsumerServiceURL)
+ require.NoError(t, err)
+
+ var publishedGen uint64
+ r := &testPriorityRouter{priority: 1, routeFn: func(invokers
[]base.Invoker, _ *common.URL, inv base.Invocation) []base.Invoker {
+ publishedGen =
inv.GetAttributeWithDefaultValue(constant.RouterChainCacheGeneration,
uint64(0)).(uint64)
+ return invokers
+ }}
+ chain := &RouterChain{routers: []router.PriorityRouter{r}}
+
+ invoker := buildInvoker(t, "dubbo://127.0.0.1:20000/com.demo.Service")
+ for i := 1; i <= 3; i++ {
+ chain.SetInvokers([]base.Invoker{invoker})
+ assert.Equal(t, uint64(i), chain.generation)
+ }
+
+ chain.Route(consumerURL, invocation.NewRPCInvocation("Say", nil, nil))
+ assert.Equal(t, uint64(3), publishedGen, "Route should publish the
current chain generation")
+}
+
+// genCheckRouter is a Poolable router that mirrors TagRouter's cache guard.
On the fast path it
+// asserts the invoker snapshot it received from the cache belongs to the same
generation the
+// chain published, catching any snapshot/generation skew under concurrency.
+type genCheckRouter struct {
+ cache router.Cache
+ violations int64
+ fastPaths int64
+}
+
+func (r *genCheckRouter) Name() string { return "gen-check" }
+func (r *genCheckRouter) ShouldPool() bool { return true }
+func (r *genCheckRouter) SetCache(c router.Cache) { r.cache = c }
+func (r *genCheckRouter) URL() *common.URL { return nil }
+func (r *genCheckRouter) Priority() int64 { return 0 }
+func (r *genCheckRouter) Notify(_ []base.Invoker) {}
+
+func (r *genCheckRouter) Pool(invokers []base.Invoker) (router.AddrPool,
router.AddrMetadata) {
+ // Index every invoker under a single key; the test only needs
FindAddrPool to hit.
+ bm := roaring.New()
+ for i := range invokers {
+ bm.Add(uint32(i))
+ }
+ return router.AddrPool{"all": bm}, nil
+}
+
+func (r *genCheckRouter) Route(invokers []base.Invoker, _ *common.URL, inv
base.Invocation) []base.Invoker {
+ if r.cache == nil {
+ return invokers
+ }
+ pool, full, cacheGen := r.cache.FindAddrPool(r)
+ snapGen :=
inv.GetAttributeWithDefaultValue(constant.RouterChainCacheGeneration,
uint64(0)).(uint64)
+ if pool == nil || cacheGen != snapGen {
+ return invokers // fall back, exactly like TagRouter
+ }
+ atomic.AddInt64(&r.fastPaths, 1)
+ // On the fast path every invoker in the cache snapshot must carry the
published generation.
+ // A mismatch would mean the guard let a snapshot from a concurrent
SetInvokers through.
+ for _, ivk := range full {
+ if ivk.GetURL().GetParam("snapgen", "") !=
strconv.FormatUint(snapGen, 10) {
+ atomic.AddInt64(&r.violations, 1)
+ }
+ }
+ return full
+}
+
+// TestRouteCacheGenerationRace hammers SetInvokers and Route concurrently.
Each invoker set is
+// tagged with the generation that produced it, so the Poolable router can
detect any skew
+// between the published generation and the cache snapshot it routes over. Run
with -race.
+func TestRouteCacheGenerationRace(t *testing.T) {
+ consumerURL, err := common.NewURL(testConsumerServiceURL)
+ require.NoError(t, err)
+
+ r := &genCheckRouter{}
+ chain := &RouterChain{routers: []router.PriorityRouter{r}}
+
+ makeSet := func(gen uint64, n int) []base.Invoker {
+ out := make([]base.Invoker, 0, n)
+ for i := 0; i < n; i++ {
+ u, _ := common.NewURL("dubbo://127.0.0.1:2000" +
strconv.Itoa(i) + "/com.demo.Service")
+ u.SetParam("snapgen", strconv.FormatUint(gen, 10))
+ out = append(out, base.NewBaseInvoker(u))
+ }
+ return out
+ }
+
+ // Seed generation 1 so the cache exists before readers start.
+ chain.SetInvokers(makeSet(1, 3))
+
+ stop := make(chan struct{})
+ var writer sync.WaitGroup
+ writer.Add(1)
+ go func() {
+ defer writer.Done()
+ gen := uint64(1)
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ gen++
+ size := 2 + int(gen%4)
+ chain.SetInvokers(makeSet(gen, size))
+ }
+ }
+ }()
+
+ var readers sync.WaitGroup
+ for i := 0; i < 4; i++ {
+ readers.Add(1)
+ go func() {
+ defer readers.Done()
+ for j := 0; j < 2000; j++ {
+ chain.Route(consumerURL,
invocation.NewRPCInvocation("Say", nil, nil))
+ }
+ }()
+ }
+
+ readers.Wait()
+ close(stop)
+ writer.Wait()
+
+ assert.Equal(t, int64(0), atomic.LoadInt64(&r.violations),
+ "cache snapshot generation must always match the published
generation on the fast path")
+}
diff --git a/cluster/router/router.go b/cluster/router/router.go
index 8c5f1b226..80c4765fc 100644
--- a/cluster/router/router.go
+++ b/cluster/router/router.go
@@ -95,9 +95,20 @@ type Cache interface {
// GetInvokers returns the snapshot of received invokers.
GetInvokers() []base.Invoker
- // FindAddrPool returns address pool associated with the given Poolable
instance.
- FindAddrPool(Poolable) AddrPool
+ // FindAddrPool returns the address pool, the invoker snapshot, and the
generation of that
+ // snapshot in a single locked read. The generation lets callers verify
the pool/invokers
+ // belong to the same generation the chain snapshotted for the current
route, so the bitmap
+ // indices stay aligned with the invoker slice and the route is not
served from a snapshot
+ // produced by a concurrent SetInvokers.
+ FindAddrPool(Poolable) (AddrPool, []base.Invoker, uint64)
// FindAddrMeta returns address metadata associated with the given
Poolable instance.
FindAddrMeta(Poolable) AddrMetadata
}
+
+// CacheAccessor allow routers to receive the invoker-snapshot cache.
+// Implemented by Poolable routers so that RouterChain can pass the cache
+// reference after it is built in SetInvokers.
+type CacheAccessor interface {
+ SetCache(Cache)
+}
diff --git a/cluster/router/tag/cache.go b/cluster/router/tag/cache.go
new file mode 100644
index 000000000..e665d2f61
--- /dev/null
+++ b/cluster/router/tag/cache.go
@@ -0,0 +1,213 @@
+/*
+ * 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 tag
+
+import (
+ "strings"
+)
+
+import (
+ "github.com/RoaringBitmap/roaring"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/cluster/router"
+ "dubbo.apache.org/dubbo-go/v3/common"
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+ "dubbo.apache.org/dubbo-go/v3/global"
+ "dubbo.apache.org/dubbo-go/v3/protocol/base"
+)
+
+func (p *PriorityRouter) Name() string {
+ return "tag"
+}
+
+func (p *PriorityRouter) ShouldPool() bool {
+ return true
+}
+
+// Pool builds bitmap indices for tag, address, and port keys.
+// Key space uses "\x00" as separator.
+// Other Poolable routers should use different prefixes to avoid conflicts.
+func (p *PriorityRouter) Pool(invokers []base.Invoker) (router.AddrPool,
router.AddrMetadata) {
+ pool := make(router.AddrPool)
+ for i, invoker := range invokers {
+ url := invoker.GetURL()
+ upsertBM(pool, constant.PoolKeyAll, i)
+ tag := url.GetParam(constant.Tagkey, "")
+ upsertBM(pool, constant.PoolKeyTagPrefix+tag, i)
+ addr := url.Location
+ if addr != "" {
+ upsertBM(pool, constant.PoolKeyAddrPrefix+addr, i)
+ if idx := strings.LastIndex(addr, ":"); idx > 0 {
+ upsertBM(pool,
constant.PoolKeyPortPrefix+addr[idx+1:], i)
+ }
+ }
+ }
+ return pool, nil
+}
+
+func (p *PriorityRouter) SetCache(cache router.Cache) {
+ if cache != nil {
+ p.cache.Store(cache)
+ }
+}
+
+func (p *PriorityRouter) routeWithPool(invokers []base.Invoker, pool
router.AddrPool, url *common.URL, invocation base.Invocation) []base.Invoker {
+ if len(invokers) == 0 {
+ return invokers
+ }
+ tag := invocation.GetAttachmentWithDefaultValue(constant.Tagkey,
url.GetParam(constant.Tagkey, ""))
+
+ application := invokers[0].GetURL().GetParam(constant.ApplicationKey,
"")
+ key := strings.Join([]string{application,
constant.TagRouterRuleSuffix}, "")
+ value, ok := p.routerConfigs.Load(key)
+ if !ok {
+ return collectInvokers(invokers, p.staticTagMatchBM(pool, tag,
url, invocation))
+ }
+ routerCfg := value.(global.RouterConfig)
+ enabled := routerCfg.Enabled == nil || *routerCfg.Enabled
+ valid := (routerCfg.Valid != nil && *routerCfg.Valid) ||
(routerCfg.Valid == nil && len(routerCfg.Tags) > 0)
+ if !enabled || !valid {
+ return collectInvokers(invokers, p.staticTagMatchBM(pool, tag,
url, invocation))
+ }
+ if tag == "" {
+ return collectInvokers(invokers, p.emptyTagMatchBM(pool,
routerCfg))
+ }
+ bm := p.requestTagMatchBM(pool, url, invocation, routerCfg, tag)
+ if bm == nil {
+ return requestTag(invokers, url, invocation, routerCfg, tag)
+ }
+ return collectInvokers(invokers, bm)
+}
+
+func (p *PriorityRouter) staticTagMatchBM(pool router.AddrPool, tag string,
url *common.URL, invocation base.Invocation) *roaring.Bitmap {
+ if tag != "" {
+ if bm := pool[constant.PoolKeyTagPrefix+tag]; bm != nil &&
!bm.IsEmpty() {
+ return bm
+ }
+ if requestIsForce(url, invocation) {
+ return nil
+ }
+ }
+ return pool[constant.PoolKeyTagPrefix]
+}
+
+func (p *PriorityRouter) requestTagMatchBM(pool router.AddrPool, url
*common.URL, invocation base.Invocation,
+ cfg global.RouterConfig, tag string) *roaring.Bitmap {
+ var (
+ addresses []string
+ match []*common.ParamMatch
+ )
+ for _, tagCfg := range cfg.Tags {
+ if tagCfg.Name == tag {
+ addresses = tagCfg.Addresses
+ match = tagCfg.Match
+ break
+ }
+ }
+
+ // ParamMatch is not bitmap-cached; fall back to requestTag.
+ if len(match) != 0 {
+ return nil
+ }
+
+ var resultBM *roaring.Bitmap
+ if len(addresses) != 0 {
+ resultBM = p.addressesBM(pool, addresses)
+ } else {
+ resultBM = pool[constant.PoolKeyTagPrefix+tag]
+ }
+
+ if (cfg.Force != nil && *cfg.Force) || requestIsForce(url, invocation) {
+ return resultBM
+ }
+ if resultBM != nil && !resultBM.IsEmpty() {
+ return resultBM
+ }
+
+ emptyBM := pool[constant.PoolKeyTagPrefix]
+ if emptyBM == nil {
+ return nil
+ }
+ if len(addresses) == 0 {
+ return emptyBM
+ }
+ addrBM := p.addressesBM(pool, addresses)
+ result := emptyBM.Clone()
+ if addrBM != nil {
+ result.AndNot(addrBM)
+ }
+ return result
+}
+
+func (p *PriorityRouter) emptyTagMatchBM(pool router.AddrPool, cfg
global.RouterConfig) *roaring.Bitmap {
+ bm := pool[constant.PoolKeyTagPrefix]
+ if bm == nil || bm.IsEmpty() {
+ return bm
+ }
+ for _, tagCfg := range cfg.Tags {
+ if len(tagCfg.Addresses) == 0 {
+ continue
+ }
+ addrBM := p.addressesBM(pool, tagCfg.Addresses)
+ if addrBM != nil && !addrBM.IsEmpty() && bm != nil {
+ result := bm.Clone()
+ result.AndNot(addrBM)
+ bm = result
+ }
+ }
+ return bm
+}
+
+func (p *PriorityRouter) addressesBM(pool router.AddrPool, addrs []string)
*roaring.Bitmap {
+ bm := roaring.NewBitmap()
+ for _, addr := range addrs {
+ if ab := pool[constant.PoolKeyAddrPrefix+addr]; ab != nil {
+ bm.Or(ab)
+ }
+ if idx := strings.LastIndex(addr, ":"); idx > 0 && addr[:idx]
== constant.AnyHostValue {
+ if pb := pool[constant.PoolKeyPortPrefix+addr[idx+1:]];
pb != nil {
+ bm.Or(pb)
+ }
+ }
+ }
+ return bm
+}
+
+func collectInvokers(invokers []base.Invoker, bm *roaring.Bitmap)
[]base.Invoker {
+ if bm == nil || bm.IsEmpty() {
+ return []base.Invoker{}
+ }
+ result := make([]base.Invoker, 0, bm.GetCardinality())
+ it := bm.Iterator()
+ for it.HasNext() {
+ idx := it.Next()
+ if int(idx) < len(invokers) {
+ result = append(result, invokers[int(idx)])
+ }
+ }
+ return result
+}
+
+func upsertBM(pool router.AddrPool, key string, idx int) {
+ if _, ok := pool[key]; !ok {
+ pool[key] = roaring.NewBitmap()
+ }
+ pool[key].Add(uint32(idx))
+}
diff --git a/cluster/router/tag/cache_benchmarks_test.go
b/cluster/router/tag/cache_benchmarks_test.go
new file mode 100644
index 000000000..5d07941ae
--- /dev/null
+++ b/cluster/router/tag/cache_benchmarks_test.go
@@ -0,0 +1,219 @@
+/*
+ * 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 tag
+
+import (
+ "fmt"
+ "testing"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/common"
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+ "dubbo.apache.org/dubbo-go/v3/global"
+ "dubbo.apache.org/dubbo-go/v3/protocol/base"
+ "dubbo.apache.org/dubbo-go/v3/protocol/invocation"
+)
+
+// makeBenchInvokers creates n invokers where the first tagged ones carry
tag=gray.
+// All invokers get the given app as ApplicationKey.
+func makeBenchInvokers(n, tagged int, app string) []base.Invoker {
+ invokers := make([]base.Invoker, n)
+ for i := 0; i < n; i++ {
+ u, _ :=
common.NewURL(fmt.Sprintf("dubbo://192.168.0.%d:20000/com.xxx.xxx.UserProvider?interface=com.xxx.xxx.UserProvider&group=&version=3.1.0",
i+1))
+ u.SetParam(constant.ApplicationKey, app)
+ if i < tagged {
+ u.SetParam(constant.Tagkey, "gray")
+ }
+ invokers[i] = base.NewBaseInvoker(u)
+ }
+ return invokers
+}
+
+func newBenchConsumerURL(app string) *common.URL {
+ u, _ :=
common.NewURL(fmt.Sprintf("consumer://127.0.0.1:20000/com.xxx.xxx.UserProvider?interface=com.xxx.xxx.UserProvider&group=&version=3.1.0&%s=%s",
constant.ApplicationKey, app))
+ return u
+}
+
+// BenchmarkRouteStaticTag compares cached vs no-cache paths for static tag
routing.
+// The tag is embedded in invoker URLs; the request carries tag=gray.
+//
+// cached: pool["tag\x00"+tag] single map lookup + bitmap iteration
+// no_cache: filterInvokers linear scan with GetURL().GetParam per invoker
+func BenchmarkRouteStaticTag(b *testing.B) {
+ b.ReportAllocs()
+ for _, n := range []int{10, 100, 1000} {
+ tagged := n * 30 / 100
+ invokers := makeBenchInvokers(n, tagged, "bench-static")
+ url := newBenchConsumerURL("bench-static")
+ invoc := invocation.NewRPCInvocation("GetUser", nil,
map[string]any{constant.Tagkey: "gray"})
+
+ b.Run(fmt.Sprintf("no_cache/N=%d", n), func(b *testing.B) {
+ p, _ := NewTagPriorityRouter()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ p.Route(invokers, url, invoc)
+ }
+ })
+ }
+ for _, n := range []int{10, 100, 1000} {
+ tagged := n * 30 / 100
+ invokers := makeBenchInvokers(n, tagged, "bench-static")
+ url := newBenchConsumerURL("bench-static")
+ invoc := invocation.NewRPCInvocation("GetUser", nil,
map[string]any{constant.Tagkey: "gray"})
+
+ b.Run(fmt.Sprintf("cached/N=%d", n), func(b *testing.B) {
+ p, _ := NewTagPriorityRouter()
+ withCache(p, invokers)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ p.Route(invokers, url, invoc)
+ }
+ })
+ }
+}
+
+// BenchmarkRouteDynamicTagAddr compares cached vs no-cache paths for dynamic
tag
+// routing with an address list. The request carries tag=gray; the dynamic
config
+// specifies 3 addresses for the gray tag.
+//
+// cached: requestTagMatchBM -> addressesBM map lookups + bitmap OR
+// no_cache: requestTag -> filterInvokers + getAddressPredicate O(N*A) nested
loop
+func BenchmarkRouteDynamicTagAddr(b *testing.B) {
+ b.ReportAllocs()
+ for _, n := range []int{10, 100, 1000} {
+ tagged := n * 30 / 100
+ app := "bench-dynaddr"
+ invokers := makeBenchInvokers(n, tagged, app)
+ url := newBenchConsumerURL(app)
+ invoc := invocation.NewRPCInvocation("GetUser", nil,
map[string]any{constant.Tagkey: "gray"})
+ cfgKey := app + constant.TagRouterRuleSuffix
+ cfg := global.RouterConfig{
+ Key: cfgKey,
+ Force: boolPtr(false),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Name: "gray",
+ Addresses: []string{"192.168.0.1:20000",
"192.168.0.2:20000", "192.168.0.3:20000"},
+ }},
+ }
+
+ b.Run(fmt.Sprintf("no_cache/N=%d", n), func(b *testing.B) {
+ p, _ := NewTagPriorityRouter()
+ p.routerConfigs.Store(cfgKey, cfg)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ p.Route(invokers, url, invoc)
+ }
+ })
+ }
+ for _, n := range []int{10, 100, 1000} {
+ tagged := n * 30 / 100
+ app := "bench-dynaddr"
+ invokers := makeBenchInvokers(n, tagged, app)
+ url := newBenchConsumerURL(app)
+ invoc := invocation.NewRPCInvocation("GetUser", nil,
map[string]any{constant.Tagkey: "gray"})
+ cfgKey := app + constant.TagRouterRuleSuffix
+ cfg := global.RouterConfig{
+ Key: cfgKey,
+ Force: boolPtr(false),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Name: "gray",
+ Addresses: []string{"192.168.0.1:20000",
"192.168.0.2:20000", "192.168.0.3:20000"},
+ }},
+ }
+
+ b.Run(fmt.Sprintf("cached/N=%d", n), func(b *testing.B) {
+ p, _ := NewTagPriorityRouter()
+ withCache(p, invokers)
+ p.routerConfigs.Store(cfgKey, cfg)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ p.Route(invokers, url, invoc)
+ }
+ })
+ }
+}
+
+// BenchmarkRouteCacheHitBenefit measures cache benefit at fixed N=100 with
varying
+// tag density (10/50/90 tagged invokers). The dynamic config specifies 3
addresses
+// for the gray tag; the request carries tag=gray.
+//
+// At low tagged ratio the no-cache path scans all N but finds few matches
(O(N));
+// the cached path directly locates a small bitmap (O(result_size)).
+// At high tagged ratio the result set is large and the gap narrows.
+func BenchmarkRouteCacheHitBenefit(b *testing.B) {
+ b.ReportAllocs()
+ n := 100
+ for _, tagged := range []int{10, 50, 90} {
+ app := "bench-hitbenefit"
+ invokers := makeBenchInvokers(n, tagged, app)
+ url := newBenchConsumerURL(app)
+ invoc := invocation.NewRPCInvocation("GetUser", nil,
map[string]any{constant.Tagkey: "gray"})
+ cfgKey := app + constant.TagRouterRuleSuffix
+ cfg := global.RouterConfig{
+ Key: cfgKey,
+ Force: boolPtr(false),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Name: "gray",
+ Addresses: []string{"192.168.0.1:20000",
"192.168.0.2:20000", "192.168.0.3:20000"},
+ }},
+ }
+
+ b.Run(fmt.Sprintf("no_cache/tagged=%d", tagged), func(b
*testing.B) {
+ p, _ := NewTagPriorityRouter()
+ p.routerConfigs.Store(cfgKey, cfg)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ p.Route(invokers, url, invoc)
+ }
+ })
+ }
+ for _, tagged := range []int{10, 50, 90} {
+ app := "bench-hitbenefit"
+ invokers := makeBenchInvokers(n, tagged, app)
+ url := newBenchConsumerURL(app)
+ invoc := invocation.NewRPCInvocation("GetUser", nil,
map[string]any{constant.Tagkey: "gray"})
+ cfgKey := app + constant.TagRouterRuleSuffix
+ cfg := global.RouterConfig{
+ Key: cfgKey,
+ Force: boolPtr(false),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Name: "gray",
+ Addresses: []string{"192.168.0.1:20000",
"192.168.0.2:20000", "192.168.0.3:20000"},
+ }},
+ }
+
+ b.Run(fmt.Sprintf("cached/tagged=%d", tagged), func(b
*testing.B) {
+ p, _ := NewTagPriorityRouter()
+ withCache(p, invokers)
+ p.routerConfigs.Store(cfgKey, cfg)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ p.Route(invokers, url, invoc)
+ }
+ })
+ }
+}
diff --git a/cluster/router/tag/match.go b/cluster/router/tag/match.go
index 621f9afe8..dea37af9e 100644
--- a/cluster/router/tag/match.go
+++ b/cluster/router/tag/match.go
@@ -146,7 +146,7 @@ func requestTag(invokers []base.Invoker, url *common.URL,
invocation base.Invoca
if len(addresses) == 0 {
return result
}
- result = filterInvokers(invokers, addresses, getAddressPredicate(true))
+ result = filterInvokers(result, addresses, getAddressPredicate(true))
logger.Debugf("[Router][Tag] failover match all providers without any
tags, invokers=%+v", result)
return result
}
diff --git a/cluster/router/tag/router.go b/cluster/router/tag/router.go
index bc67be186..635020fd9 100644
--- a/cluster/router/tag/router.go
+++ b/cluster/router/tag/router.go
@@ -20,6 +20,7 @@ package tag
import (
"strings"
"sync"
+ "sync/atomic"
)
import (
@@ -29,6 +30,7 @@ import (
)
import (
+ "dubbo.apache.org/dubbo-go/v3/cluster/router"
"dubbo.apache.org/dubbo-go/v3/common"
conf "dubbo.apache.org/dubbo-go/v3/common/config"
"dubbo.apache.org/dubbo-go/v3/common/constant"
@@ -40,6 +42,7 @@ import (
type PriorityRouter struct {
routerConfigs sync.Map
+ cache atomic.Value // router.Cache
}
func NewTagPriorityRouter() (*PriorityRouter, error) {
@@ -52,6 +55,24 @@ func (p *PriorityRouter) Route(invokers []base.Invoker, url
*common.URL, invocat
logger.Warn("[Router][Tag] invokers from previous router is
empty")
return invokers
}
+
+ // Cache only takes effect when TagRouter is the first router in the
chain.
+ // RouterChain sets RouterCacheDisable=true after each router, so later
routers always skip cache.
+ if v := p.cache.Load(); v != nil {
+ if
!invocation.GetAttributeWithDefaultValue(constant.RouterCacheDisable,
false).(bool) {
+ c := v.(router.Cache)
+ pool, fullInvokers, cacheGen := c.FindAddrPool(p)
+ snapshotGen :=
invocation.GetAttributeWithDefaultValue(constant.RouterChainCacheGeneration,
uint64(0)).(uint64)
+ // Only take the bitmap fast path when the cache
snapshot is the same generation the
+ // chain snapshotted for this call. Otherwise a
concurrent SetInvokers may have rebuilt
+ // the cache between the chain snapshot and this
lookup, so fall through to the original
+ // matcher over the invokers passed in (finalInvokers).
+ if pool != nil && fullInvokers != nil && cacheGen ==
snapshotGen {
+ return p.routeWithPool(fullInvokers, pool, url,
invocation)
+ }
+ }
+ }
+
// get application name from invoker to look up tag routing config
application := invokers[0].GetURL().GetParam(constant.ApplicationKey,
"")
key := strings.Join([]string{application,
constant.TagRouterRuleSuffix}, "")
diff --git a/cluster/router/tag/router_test.go
b/cluster/router/tag/router_test.go
index 630d573a9..6a9a291aa 100644
--- a/cluster/router/tag/router_test.go
+++ b/cluster/router/tag/router_test.go
@@ -28,6 +28,7 @@ import (
)
import (
+ "dubbo.apache.org/dubbo-go/v3/cluster/router"
"dubbo.apache.org/dubbo-go/v3/common"
common_cfg "dubbo.apache.org/dubbo-go/v3/common/config"
"dubbo.apache.org/dubbo-go/v3/common/constant"
@@ -546,3 +547,288 @@ func TestRouteNilDefaults(t *testing.T) {
assert.Len(t, result, 3)
}
+
+type mockCache struct {
+ invokers []base.Invoker
+ pool router.AddrPool
+ generation uint64
+}
+
+func (m *mockCache) GetInvokers() []base.Invoker { return m.invokers }
+func (m *mockCache) FindAddrPool(_ router.Poolable) (router.AddrPool,
[]base.Invoker, uint64) {
+ return m.pool, m.invokers, m.generation
+}
+func (m *mockCache) FindAddrMeta(_ router.Poolable) router.AddrMetadata {
return nil }
+
+func newCacheRouter() *PriorityRouter {
+ initUrl()
+ p, _ := NewTagPriorityRouter()
+ return p
+}
+
+func makeInvokers(tag1, tag2, tag3 string) []base.Invoker {
+ u1, _ :=
common.NewURL("dubbo://192.168.0.1:20000/com.xxx.xxx.UserProvider?interface=com.xxx.xxx.UserProvider&group=&version=3.1.0")
+ u2, _ :=
common.NewURL("dubbo://192.168.0.2:20000/com.xxx.xxx.UserProvider?interface=com.xxx.xxx.UserProvider&group=&version=3.1.0")
+ u3, _ :=
common.NewURL("dubbo://192.168.0.3:20000/com.xxx.xxx.UserProvider?interface=com.xxx.xxx.UserProvider&group=&version=3.1.0")
+ if tag1 != "" {
+ u1.SetParam(constant.Tagkey, tag1)
+ }
+ if tag2 != "" {
+ u2.SetParam(constant.Tagkey, tag2)
+ }
+ if tag3 != "" {
+ u3.SetParam(constant.Tagkey, tag3)
+ }
+ return []base.Invoker{
+ base.NewBaseInvoker(u1),
+ base.NewBaseInvoker(u2),
+ base.NewBaseInvoker(u3),
+ }
+}
+
+func withCache(p *PriorityRouter, invokers []base.Invoker) {
+ pool, _ := p.Pool(invokers)
+ p.cache.Store(&mockCache{invokers: invokers, pool: pool})
+}
+
+func boolPtr(v bool) *bool { return &v }
+
+func TestRouteBitmapStaticTag(t *testing.T) {
+ p := newCacheRouter()
+ invokers := makeInvokers("gray", "gray", "")
+ withCache(p, invokers)
+
+ t.Run("request has tag, returns only matching invokers", func(t
*testing.T) {
+ attachments := map[string]any{constant.Tagkey: "gray"}
+ result := p.Route(invokers, consumerUrl,
invocation.NewRPCInvocation("GetUser", nil, attachments))
+ assert.Len(t, result, 2)
+ for _, r := range result {
+ assert.Equal(t, "gray",
r.GetURL().GetParam(constant.Tagkey, ""))
+ }
+ })
+
+ t.Run("request has no tag, returns untagged invokers", func(t
*testing.T) {
+ result := p.Route(invokers, consumerUrl,
invocation.NewRPCInvocation("GetUser", nil, nil))
+ assert.Len(t, result, 1)
+ assert.Empty(t, result[0].GetURL().GetParam(constant.Tagkey,
""))
+ })
+
+ t.Run("request has non-matching tag with force, returns empty", func(t
*testing.T) {
+ attachments := map[string]any{constant.Tagkey: "nonexistent",
constant.ForceUseTag: "true"}
+ result := p.Route(invokers, consumerUrl,
invocation.NewRPCInvocation("GetUser", nil, attachments))
+ assert.Empty(t, result)
+ })
+}
+
+func TestRouteBitmapDynamicTagAddress(t *testing.T) {
+ p := newCacheRouter()
+ invokers := makeInvokers("gray", "gray", "")
+ withCache(p, invokers)
+
+ p.routerConfigs.Store(consumerUrl.GetParam(constant.ApplicationKey,
"")+constant.TagRouterRuleSuffix, global.RouterConfig{
+ Key: consumerUrl.Service() + constant.TagRouterRuleSuffix,
+ Force: boolPtr(false),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Name: "gray",
+ Addresses: []string{"192.168.0.1:20000"},
+ }},
+ })
+
+ t.Run("address matches only selected invoker", func(t *testing.T) {
+ attachments := map[string]any{constant.Tagkey: "gray"}
+ result := p.Route(invokers, consumerUrl,
invocation.NewRPCInvocation("GetUser", nil, attachments))
+ assert.Len(t, result, 1)
+ assert.Equal(t, "192.168.0.1:20000",
result[0].GetURL().Location)
+ })
+}
+
+func TestRouteBitmapDynamicTagAnyHost(t *testing.T) {
+ p := newCacheRouter()
+ invokers := makeInvokers("gray", "gray", "gray")
+ withCache(p, invokers)
+
+ p.routerConfigs.Store(consumerUrl.GetParam(constant.ApplicationKey,
"")+constant.TagRouterRuleSuffix, global.RouterConfig{
+ Key: consumerUrl.Service() + constant.TagRouterRuleSuffix,
+ Force: boolPtr(true),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Name: "gray",
+ Addresses: []string{constant.AnyHostValue + ":20000"},
+ }},
+ })
+
+ t.Run("anyhost address matches all invokers on that port", func(t
*testing.T) {
+ attachments := map[string]any{constant.Tagkey: "gray"}
+ result := p.Route(invokers, consumerUrl,
invocation.NewRPCInvocation("GetUser", nil, attachments))
+ assert.Len(t, result, 3)
+ })
+}
+
+func TestRouteBitmapDynamicEmptyTag(t *testing.T) {
+ p := newCacheRouter()
+ invokers := makeInvokers("gray", "", "")
+ withCache(p, invokers)
+
+ p.routerConfigs.Store(consumerUrl.GetParam(constant.ApplicationKey,
"")+constant.TagRouterRuleSuffix, global.RouterConfig{
+ Key: consumerUrl.Service() + constant.TagRouterRuleSuffix,
+ Force: boolPtr(false),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Addresses: []string{"192.168.0.1:20000"},
+ }},
+ })
+
+ t.Run("empty request tag with address exclusion", func(t *testing.T) {
+ result := p.Route(invokers, consumerUrl,
invocation.NewRPCInvocation("GetUser", nil, nil))
+ assert.Len(t, result, 2)
+ for _, r := range result {
+ assert.NotEqual(t, "192.168.0.1:20000",
r.GetURL().Location)
+ }
+ })
+}
+
+func TestRouteDynamicParamExactFallback(t *testing.T) {
+ p := newCacheRouter()
+ invokers := makeInvokers("gray", "gray", "")
+ invokers[1].GetURL().SetParam("version", "v2")
+ withCache(p, invokers)
+
+ p.routerConfigs.Store(consumerUrl.GetParam(constant.ApplicationKey,
"")+constant.TagRouterRuleSuffix, global.RouterConfig{
+ Key: consumerUrl.Service() + constant.TagRouterRuleSuffix,
+ Force: boolPtr(false),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Name: "gray",
+ Match: []*common.ParamMatch{
+ {Key: "version", Value:
common.StringMatch{Exact: "v2"}},
+ },
+ }},
+ })
+
+ t.Run("match falls back to requestTag and filters correctly", func(t
*testing.T) {
+ attachments := map[string]any{constant.Tagkey: "gray"}
+ result := p.Route(invokers, consumerUrl,
invocation.NewRPCInvocation("GetUser", nil, attachments))
+ assert.Len(t, result, 1)
+ assert.Equal(t, "192.168.0.2:20000",
result[0].GetURL().Location)
+ })
+}
+
+func TestRouteBitmapMatchFallback(t *testing.T) {
+ p := newCacheRouter()
+ invokers := makeInvokers("gray", "gray", "")
+ withCache(p, invokers)
+
+ p.routerConfigs.Store(consumerUrl.GetParam(constant.ApplicationKey,
"")+constant.TagRouterRuleSuffix, global.RouterConfig{
+ Key: consumerUrl.Service() + constant.TagRouterRuleSuffix,
+ Force: boolPtr(false),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Name: "gray",
+ Match: []*common.ParamMatch{
+ {Key: "environment", Value:
common.StringMatch{Exact: "prod"}},
+ },
+ }},
+ })
+
+ t.Run("unindexed param key falls back to original path", func(t
*testing.T) {
+ attachments := map[string]any{constant.Tagkey: "gray"}
+ result := p.Route(invokers, consumerUrl,
invocation.NewRPCInvocation("GetUser", nil, attachments))
+ assert.Len(t, result, 1)
+ assert.Empty(t, result[0].GetURL().GetParam(constant.Tagkey,
""))
+ })
+}
+
+func TestRouteBitmapEquivalence(t *testing.T) {
+ // Same scenario via bitmap and original path should produce identical
results.
+ invokers := makeInvokers("gray", "gray", "")
+
+ pCache := newCacheRouter()
+ withCache(pCache, invokers)
+
pCache.routerConfigs.Store(consumerUrl.GetParam(constant.ApplicationKey,
"")+constant.TagRouterRuleSuffix, global.RouterConfig{
+ Key: consumerUrl.Service() + constant.TagRouterRuleSuffix,
+ Force: boolPtr(false),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Name: "gray",
+ Addresses: []string{"192.168.0.1:20000"},
+ }},
+ })
+
+ pFallback := newCacheRouter()
+
pFallback.routerConfigs.Store(consumerUrl.GetParam(constant.ApplicationKey,
"")+constant.TagRouterRuleSuffix, global.RouterConfig{
+ Key: consumerUrl.Service() + constant.TagRouterRuleSuffix,
+ Force: boolPtr(false),
+ Enabled: boolPtr(true),
+ Valid: boolPtr(true),
+ Tags: []global.Tag{{
+ Name: "gray",
+ Addresses: []string{"192.168.0.1:20000"},
+ }},
+ })
+
+ t.Run("bitmap and fallback produce same result", func(t *testing.T) {
+ attachments := map[string]any{constant.Tagkey: "gray"}
+ invoc := invocation.NewRPCInvocation("GetUser", nil,
attachments)
+
+ rCache := pCache.Route(invokers, consumerUrl, invoc)
+ rFallback := pFallback.Route(invokers, consumerUrl, invoc)
+
+ assert.Len(t, rCache, len(rFallback))
+ for i := range rCache {
+ assert.Equal(t, rCache[i].GetURL().Location,
rFallback[i].GetURL().Location)
+ }
+ })
+}
+
+// withCacheGen stores a cache built from invokers at the given generation,
mirroring what
+// RouterChain.SetInvokers does so a test can simulate a specific cache
generation.
+func withCacheGen(p *PriorityRouter, invokers []base.Invoker, generation
uint64) {
+ pool, _ := p.Pool(invokers)
+ p.cache.Store(&mockCache{invokers: invokers, pool: pool, generation:
generation})
+}
+
+// TestRouteGenerationGuard reproduces the race Alan flagged: the cache is
built on [a,b,c],
+// but Route is called with the chain's current snapshot [a]. When the
invocation's published
+// generation does not match the cache generation (a concurrent SetInvokers
rebuilt the cache
+// between the chain snapshot and this lookup), the bitmap fast path must be
skipped so the
+// route stays confined to the invokers passed in.
+func TestRouteGenerationGuard(t *testing.T) {
+ p := newCacheRouter()
+ full := makeInvokers("gray", "gray", "") // [a(gray), b(gray), c()]
+ withCacheGen(p, full, 5) // cache snapshot is
generation 5
+ subset := []base.Invoker{full[0]} // chain snapshot for this
call is just [a]
+
+ newInvoc := func(gen uint64) base.Invocation {
+ inv := invocation.NewRPCInvocation("GetUser", nil,
map[string]any{constant.Tagkey: "gray"})
+ inv.SetAttribute(constant.RouterChainCacheGeneration, gen)
+ return inv
+ }
+
+ t.Run("stale generation falls back to the invokers passed in", func(t
*testing.T) {
+ // snapshot generation 4 != cache generation 5 -> must not use
the [a,b,c] cache.
+ result := p.Route(subset, consumerUrl, newInvoc(4))
+ assert.Len(t, result, 1)
+ assert.Equal(t, full[0].GetURL().Location,
result[0].GetURL().Location)
+ })
+
+ t.Run("missing generation falls back to the invokers passed in", func(t
*testing.T) {
+ // No RouterChainCacheGeneration published -> default 0 !=
cache generation 5 -> fall back.
+ inv := invocation.NewRPCInvocation("GetUser", nil,
map[string]any{constant.Tagkey: "gray"})
+ result := p.Route(subset, consumerUrl, inv)
+ assert.Len(t, result, 1)
+ assert.Equal(t, full[0].GetURL().Location,
result[0].GetURL().Location)
+ })
+
+ t.Run("matching generation takes the bitmap path over the cached
snapshot", func(t *testing.T) {
+ // snapshot generation 5 == cache generation 5 -> bitmap path
over [a,b,c] -> [a,b].
+ result := p.Route(subset, consumerUrl, newInvoc(5))
+ assert.Len(t, result, 2)
+ })
+}
diff --git a/common/constant/key.go b/common/constant/key.go
index d680cf986..bab746aaf 100644
--- a/common/constant/key.go
+++ b/common/constant/key.go
@@ -357,6 +357,8 @@ const (
RouterScopeApplication = "application"
ForceKey = "force"
TrafficDisableKey = "trafficDisable"
+ RouterCacheDisable = "routerCacheDisable"
+ RouterChainCacheGeneration = "routerChainCacheGeneration"
Arguments = "arguments"
Attachments = "attachments"
Param = "param"
@@ -364,6 +366,12 @@ const (
Wildcard = "wildcard"
MeshRouterFactoryKey = "mesh"
DefaultRouteConditionSubSetWeight = 100
+
+ // Poolable router bitmap key prefixes
+ PoolKeyTagPrefix = "tag\x00"
+ PoolKeyAddrPrefix = "addr\x00"
+ PoolKeyPortPrefix = "port\x00"
+ PoolKeyAll = "*"
)
// Auth filter