chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:40:33 +08:00
commit e071084ebe
982 changed files with 160368 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
# Registry Cache
Cache is a library that provides a caching layer for the go-micro [registry](https://godoc.org/github.com/micro/go-micro/registry#Registry).
If you're looking for caching in your microservices use the [selector](https://micro.mu/docs/fault-tolerance.html#caching-discovery).
## Features
- **Caching**: Caches registry lookups with configurable TTL
- **Stale Cache Fallback**: Returns stale cached data when registry is unavailable
- **Singleflight Protection**: Deduplicates concurrent requests for the same service
- **Adaptive Throttling**: Rate limits failed lookups to prevent cache penetration (new in v5)
## Interface
```go
// Cache is the registry cache interface
type Cache interface {
// embed the registry interface
registry.Registry
// stop the cache watcher
Stop()
}
```
## Usage
### Basic Usage
```go
import (
"github.com/micro/go-micro/registry"
"github.com/micro/go-micro/registry/cache"
)
r := registry.NewRegistry()
cache := cache.New(r)
services, _ := cache.GetService("my.service")
```
### Advanced Configuration
```go
import (
"time"
"github.com/micro/go-micro/registry"
"github.com/micro/go-micro/registry/cache"
)
r := registry.NewRegistry()
// Configure cache with custom options
cache := cache.New(r,
cache.WithTTL(2*time.Minute), // Cache TTL
cache.WithMinimumRetryInterval(10*time.Second), // Throttle failed lookups
)
services, _ := cache.GetService("my.service")
```
## Adaptive Throttling
The cache implements rate limiting on ALL cache refresh attempts (not just errors) to prevent overwhelming the registry. This protects against multiple scenarios:
1. **Registry failures**: When etcd is down/overloaded
2. **Rolling deployments**: When all caches expire simultaneously under high QPS
3. **Cache expiration storms**: When many services expire at once
### How It Works
- **Rate limiting**: Refresh attempts are throttled per-service using `MinimumRetryInterval` (default 5s)
- **Stale cache preference**: If stale cache exists (even if expired), return it instead of calling registry
- **No cache fallback**: If no cache exists, return `ErrNotFound` and rely on gRPC retry
- **Singleflight deduplication**: Concurrent requests are still deduplicated
- **Recovery**: Throttling is reset on successful registry lookup
### Example Scenarios
#### Scenario 1: Registry Failure with Stale Cache
```go
cache := cache.New(etcdRegistry, cache.WithMinimumRetryInterval(10*time.Second))
// Initial lookup populates cache
services, _ := cache.GetService("api") // → Calls etcd, caches result
// Cache expires after TTL
time.Sleep(2 * time.Minute)
// Etcd fails, but we have stale cache
services, err := cache.GetService("api") // → Returns stale cache WITHOUT calling etcd
// err == nil, services contains stale data
```
#### Scenario 2: Rolling Deployment Cache Storm
```go
// Scenario: All 1000 upstream pods watch downstream service
// Downstream does rolling deployment - last pod updated
// All 1000 upstream caches expire simultaneously
// High QPS hits the system at this moment
// First request after cache expiration
services, _ := cache.GetService("downstream") // → Calls etcd, updates lastRefreshAttempt
// Next 999 requests arrive within MinimumRetryInterval
services, _ := cache.GetService("downstream") // → Returns stale cache, NO etcd call
// Rate limiting prevents 999 stampede requests to etcd
```
#### Scenario 3: No Cache Available
```go
// First lookup when etcd is down (no cache exists yet)
_, err := cache.GetService("new-service") // → Calls etcd, fails, records attempt time
// err != nil
// Immediate retry (< 10s later, still no cache)
_, err = cache.GetService("new-service") // → Throttled, returns ErrNotFound immediately
// err == ErrNotFound
// After MinimumRetryInterval
time.Sleep(10 * time.Second)
_, err = cache.GetService("new-service") // → Allowed to retry, calls etcd again
```
This prevents cache penetration scenarios where thousands of concurrent requests hammer a failing or overloaded registry.
+585
View File
@@ -0,0 +1,585 @@
// Package cache provides a registry cache
package cache
import (
"math"
"math/rand"
"sync"
"time"
"golang.org/x/sync/singleflight"
util "go-micro.dev/v6/internal/util/registry"
log "go-micro.dev/v6/logger"
"go-micro.dev/v6/registry"
)
// Cache is the registry cache interface.
type Cache interface {
// embed the registry interface
registry.Registry
// stop the cache watcher
Stop()
}
type Options struct {
Logger log.Logger
// TTL is the cache TTL
TTL time.Duration
// MinimumRetryInterval is the minimum time to wait before retrying a failed service lookup
// This prevents cache penetration when registry is failing and there's no stale cache
MinimumRetryInterval time.Duration
}
type Option func(o *Options)
type cache struct {
opts Options
registry.Registry
// status of the registry
// used to hold onto the cache
// in failure state
status error
// used to prevent cache breakdwon
sg singleflight.Group
cache map[string][]*registry.Service
ttls map[string]time.Time
nttls map[string]map[string]time.Time // node ttls
watched map[string]bool
// used to stop the cache
exit chan bool
// indicate whether its running
watchedRunning map[string]bool
// lastRefreshAttempt tracks the last time we attempted to refresh cache for a service
// This is used to rate limit ALL refresh attempts, not just failed ones
lastRefreshAttempt map[string]time.Time
// registry cache
sync.RWMutex
}
var (
DefaultTTL = time.Minute
// DefaultMinimumRetryInterval is the default minimum time between cache refresh attempts
// This applies to ALL refresh attempts (not just errors) to prevent cache penetration
// during scenarios like rolling deployments where all caches expire simultaneously
DefaultMinimumRetryInterval = 5 * time.Second
)
func backoff(attempts int) time.Duration {
if attempts == 0 {
return time.Duration(0)
}
return time.Duration(math.Pow(10, float64(attempts))) * time.Millisecond
}
func (c *cache) getStatus() error {
c.RLock()
defer c.RUnlock()
return c.status
}
func (c *cache) setStatus(err error) {
c.Lock()
c.status = err
c.Unlock()
}
// isValid checks if the service is valid.
func (c *cache) isValid(services []*registry.Service, ttl time.Time) bool {
// no services exist
if len(services) == 0 {
return false
}
// ttl is invalid
if ttl.IsZero() {
return false
}
// time since ttl is longer than timeout
if time.Since(ttl) > 0 {
return false
}
// a node did not get updated
for _, s := range services {
for _, n := range s.Nodes {
nttl := c.nttls[s.Name][n.Id]
if time.Since(nttl) > 0 {
return false
}
}
}
// ok
return true
}
func (c *cache) quit() bool {
select {
case <-c.exit:
return true
default:
return false
}
}
func (c *cache) del(service string) {
// don't blow away cache in error state
if err := c.status; err != nil {
return
}
// otherwise delete entries
delete(c.cache, service)
delete(c.ttls, service)
delete(c.nttls, service)
delete(c.lastRefreshAttempt, service)
}
func (c *cache) get(service string) ([]*registry.Service, error) {
// read lock
c.RLock()
// check the cache first
services := c.cache[service]
// get cache ttl
ttl := c.ttls[service]
// make a copy
cp := util.Copy(services)
// got services, nodes && within ttl so return cache
if c.isValid(cp, ttl) {
c.RUnlock()
// return services
return cp, nil
}
// Check rate limiting BEFORE entering singleflight
// This prevents blocking when we have stale cache and etcd is down
lastRefresh := c.lastRefreshAttempt[service]
minimumRetryInterval := c.opts.MinimumRetryInterval
if minimumRetryInterval == 0 {
minimumRetryInterval = DefaultMinimumRetryInterval
}
// If we're being rate limited AND have stale cache, return it immediately
// This avoids blocking all goroutines when etcd has long timeout
if !lastRefresh.IsZero() && time.Since(lastRefresh) < minimumRetryInterval && len(cp) > 0 {
c.RUnlock()
// Return stale cache even if expired
return cp, nil
}
// unlock the read lock before potentially blocking operations
c.RUnlock()
// get does the actual request for a service and cache it
get := func(service string, cached []*registry.Service) ([]*registry.Service, error) {
// Use singleflight to deduplicate concurrent requests
val, err, _ := c.sg.Do(service, func() (interface{}, error) {
// Inside singleflight - only one goroutine executes this
// Re-check rate limiting inside singleflight
// (in case another goroutine just completed a refresh)
c.RLock()
currentLastRefresh := c.lastRefreshAttempt[service]
currentMinimumRetryInterval := c.opts.MinimumRetryInterval
if currentMinimumRetryInterval == 0 {
currentMinimumRetryInterval = DefaultMinimumRetryInterval
}
c.RUnlock()
if !currentLastRefresh.IsZero() && time.Since(currentLastRefresh) < currentMinimumRetryInterval {
// We're being rate limited
// Check if we have stale cache to return
c.RLock()
cachedServices := util.Copy(c.cache[service])
c.RUnlock()
if len(cachedServices) > 0 {
// Return stale cache even if expired
return cachedServices, nil
}
// No cache available, return error
return nil, registry.ErrNotFound
}
// Track this refresh attempt
c.Lock()
c.lastRefreshAttempt[service] = time.Now()
c.Unlock()
// Actually call the registry
return c.Registry.GetService(service)
})
services, _ := val.([]*registry.Service)
if err != nil {
// check the cache
if len(cached) > 0 {
// set the error status
c.setStatus(err)
// return the stale cache
return cached, nil
}
// otherwise return error
return nil, err
}
// Success - reset the status
if err := c.getStatus(); err != nil {
c.setStatus(nil)
}
// cache results
cp := util.Copy(services)
c.Lock()
for _, s := range services {
c.updateNodeTTLs(service, s.Nodes)
}
c.set(service, services)
c.Unlock()
return cp, nil
}
// watch service if not watched
c.RLock()
_, ok := c.watched[service]
c.RUnlock()
// check if its being watched
if c.opts.TTL > 0 && !ok {
c.Lock()
// set to watched
c.watched[service] = true
// only kick it off if not running
if !c.watchedRunning[service] {
go c.run(service)
}
c.Unlock()
}
// get and return services
return get(service, cp)
}
func (c *cache) set(service string, services []*registry.Service) {
c.cache[service] = services
c.ttls[service] = time.Now().Add(c.opts.TTL)
}
func (c *cache) updateNodeTTLs(name string, nodes []*registry.Node) {
if c.nttls[name] == nil {
c.nttls[name] = make(map[string]time.Time)
}
for _, node := range nodes {
c.nttls[name][node.Id] = time.Now().Add(c.opts.TTL)
}
// clean up expired nodes
for nodeId, nttl := range c.nttls[name] {
if time.Since(nttl) > 0 {
delete(c.nttls[name], nodeId)
}
}
}
func (c *cache) update(res *registry.Result) {
if res == nil || res.Service == nil {
return
}
c.Lock()
defer c.Unlock()
// only save watched services
if _, ok := c.watched[res.Service.Name]; !ok {
return
}
services, ok := c.cache[res.Service.Name]
if !ok {
// we're not going to cache anything
// unless there was already a lookup
return
}
if len(res.Service.Nodes) == 0 {
switch res.Action {
case "delete":
c.del(res.Service.Name)
}
return
}
// existing service found
var service *registry.Service
var index int
for i, s := range services {
if s.Version == res.Service.Version {
service = s
index = i
}
}
switch res.Action {
case "create", "update":
c.updateNodeTTLs(res.Service.Name, res.Service.Nodes)
if service == nil {
c.set(res.Service.Name, append(services, res.Service))
return
}
// append old nodes to new service
for _, cur := range service.Nodes {
var seen bool
for _, node := range res.Service.Nodes {
if cur.Id == node.Id {
seen = true
break
}
}
if !seen {
res.Service.Nodes = append(res.Service.Nodes, cur)
}
}
services[index] = res.Service
c.set(res.Service.Name, services)
case "delete":
if service == nil {
return
}
var nodes []*registry.Node
// filter cur nodes to remove the dead one
for _, cur := range service.Nodes {
var seen bool
for _, del := range res.Service.Nodes {
if del.Id == cur.Id {
seen = true
break
}
}
if !seen {
nodes = append(nodes, cur)
}
}
// still got nodes, save and return
if len(nodes) > 0 {
service.Nodes = nodes
services[index] = service
c.set(service.Name, services)
return
}
// zero nodes left
// only have one thing to delete
// nuke the thing
if len(services) == 1 {
c.del(service.Name)
return
}
// still have more than 1 service
// check the version and keep what we know
var srvs []*registry.Service
for _, s := range services {
if s.Version != service.Version {
srvs = append(srvs, s)
}
}
// save
c.set(service.Name, srvs)
case "override":
if service == nil {
return
}
c.del(service.Name)
}
}
// run starts the cache watcher loop
// it creates a new watcher if there's a problem.
func (c *cache) run(service string) {
c.Lock()
c.watchedRunning[service] = true
c.Unlock()
logger := c.opts.Logger
// reset watcher on exit
defer func() {
c.Lock()
c.watched = make(map[string]bool)
c.watchedRunning[service] = false
c.Unlock()
}()
var a, b int
for {
// exit early if already dead
if c.quit() {
return
}
// jitter before starting
j := rand.Int63n(100)
time.Sleep(time.Duration(j) * time.Millisecond)
// create new watcher
w, err := c.Watch(registry.WatchService(service))
if err != nil {
if c.quit() {
return
}
d := backoff(a)
c.setStatus(err)
if a > 3 {
logger.Logf(log.DebugLevel, "rcache: ", err, " backing off ", d)
a = 0
}
time.Sleep(d)
a++
continue
}
// reset a
a = 0
// watch for events
if err := c.watch(w); err != nil {
if c.quit() {
return
}
d := backoff(b)
c.setStatus(err)
if b > 3 {
logger.Logf(log.DebugLevel, "rcache: ", err, " backing off ", d)
b = 0
}
time.Sleep(d)
b++
continue
}
// reset b
b = 0
}
}
// watch loops the next event and calls update
// it returns if there's an error.
func (c *cache) watch(w registry.Watcher) error {
// used to stop the watch
stop := make(chan bool)
// manage this loop
go func() {
defer w.Stop()
select {
// wait for exit
case <-c.exit:
return
// we've been stopped
case <-stop:
return
}
}()
for {
res, err := w.Next()
if err != nil {
close(stop)
return err
}
// reset the error status since we succeeded
if err := c.getStatus(); err != nil {
// reset status
c.setStatus(nil)
}
c.update(res)
}
}
func (c *cache) GetService(service string, opts ...registry.GetOption) ([]*registry.Service, error) {
// get the service
services, err := c.get(service)
if err != nil {
return nil, err
}
// if there's nothing return err
if len(services) == 0 {
return nil, registry.ErrNotFound
}
// return services
return services, nil
}
func (c *cache) Stop() {
c.Lock()
defer c.Unlock()
select {
case <-c.exit:
return
default:
close(c.exit)
}
}
func (c *cache) String() string {
return "cache"
}
// New returns a new cache.
func New(r registry.Registry, opts ...Option) Cache {
options := Options{
TTL: DefaultTTL,
MinimumRetryInterval: DefaultMinimumRetryInterval,
Logger: log.DefaultLogger,
}
for _, o := range opts {
o(&options)
}
return &cache{
Registry: r,
opts: options,
watched: make(map[string]bool),
watchedRunning: make(map[string]bool),
cache: make(map[string][]*registry.Service),
ttls: make(map[string]time.Time),
nttls: make(map[string]map[string]time.Time),
lastRefreshAttempt: make(map[string]time.Time),
exit: make(chan bool),
}
}
+530
View File
@@ -0,0 +1,530 @@
package cache
import (
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"go-micro.dev/v6/logger"
"go-micro.dev/v6/registry"
)
// mockRegistry is a mock implementation of registry.Registry for testing
type mockRegistry struct {
callCount int32
delay time.Duration
err error
services []*registry.Service
mu sync.Mutex
}
func (m *mockRegistry) Init(...registry.Option) error {
return nil
}
func (m *mockRegistry) Options() registry.Options {
return registry.Options{}
}
func (m *mockRegistry) Register(*registry.Service, ...registry.RegisterOption) error {
return nil
}
func (m *mockRegistry) Deregister(*registry.Service, ...registry.DeregisterOption) error {
return nil
}
func (m *mockRegistry) GetService(name string, opts ...registry.GetOption) ([]*registry.Service, error) {
// Increment call count
atomic.AddInt32(&m.callCount, 1)
// Simulate delay (e.g., network latency)
if m.delay > 0 {
time.Sleep(m.delay)
}
// Return error if configured
if m.err != nil {
return nil, m.err
}
// Return services
return m.services, nil
}
func (m *mockRegistry) ListServices(...registry.ListOption) ([]*registry.Service, error) {
return nil, nil
}
func (m *mockRegistry) Watch(...registry.WatchOption) (registry.Watcher, error) {
return nil, errors.New("not implemented")
}
func (m *mockRegistry) String() string {
return "mock"
}
func (m *mockRegistry) getCallCount() int32 {
return atomic.LoadInt32(&m.callCount)
}
// TestSingleflightPreventsStampede verifies that concurrent requests for the same service
// only result in a single call to the underlying registry
func TestSingleflightPreventsStampede(t *testing.T) {
mock := &mockRegistry{
delay: 100 * time.Millisecond, // Simulate slow etcd response
services: []*registry.Service{
{
Name: "test.service",
Version: "1.0.0",
Nodes: []*registry.Node{
{Id: "node1", Address: "localhost:9090"},
},
},
},
}
// Type assertion to *cache is necessary to access internal state for verification
c := New(mock, func(o *Options) {
o.TTL = time.Minute
o.Logger = logger.DefaultLogger
}).(*cache)
// Launch 10 concurrent requests for the same service
const concurrency = 10
var wg sync.WaitGroup
wg.Add(concurrency)
results := make([][]*registry.Service, concurrency)
errs := make([]error, concurrency)
for i := 0; i < concurrency; i++ {
go func(idx int) {
defer wg.Done()
services, err := c.GetService("test.service")
results[idx] = services
errs[idx] = err
}(i)
}
wg.Wait()
// Verify that only 1 call was made to the underlying registry
callCount := mock.getCallCount()
if callCount != 1 {
t.Errorf("Expected 1 call to registry, got %d", callCount)
}
// Verify all requests got the same result
for i := 0; i < concurrency; i++ {
if errs[i] != nil {
t.Errorf("Request %d failed: %v", i, errs[i])
}
if len(results[i]) != 1 {
t.Errorf("Request %d got %d services, expected 1", i, len(results[i]))
}
}
}
// TestSingleflightWithError verifies that when etcd fails, only one request is made
// and all concurrent callers receive the error
func TestSingleflightWithError(t *testing.T) {
expectedErr := errors.New("etcd connection failed")
mock := &mockRegistry{
delay: 50 * time.Millisecond,
err: expectedErr,
}
// Type assertion to *cache is necessary to access internal state for verification
c := New(mock, func(o *Options) {
o.TTL = time.Minute
o.Logger = logger.DefaultLogger
}).(*cache)
// Launch concurrent requests
const concurrency = 10
var wg sync.WaitGroup
wg.Add(concurrency)
errs := make([]error, concurrency)
for i := 0; i < concurrency; i++ {
go func(idx int) {
defer wg.Done()
_, err := c.GetService("test.service")
errs[idx] = err
}(i)
}
wg.Wait()
// Verify that only 1 call was made to the underlying registry
callCount := mock.getCallCount()
if callCount != 1 {
t.Errorf("Expected 1 call to registry even on error, got %d", callCount)
}
// Verify all requests got the error
for i := 0; i < concurrency; i++ {
if errs[i] == nil {
t.Errorf("Request %d should have failed", i)
}
}
}
// TestStaleCacheOnError verifies that stale cache is returned when registry fails
func TestStaleCacheOnError(t *testing.T) {
mock := &mockRegistry{
services: []*registry.Service{
{
Name: "test.service",
Version: "1.0.0",
Nodes: []*registry.Node{
{Id: "node1", Address: "localhost:9090"},
},
},
},
}
// Type assertion to *cache is necessary to access internal state for verification
c := New(mock, func(o *Options) {
o.TTL = 100 * time.Millisecond // Short TTL for testing
o.Logger = logger.DefaultLogger
}).(*cache)
// First request - should populate cache
services, err := c.GetService("test.service")
if err != nil {
t.Fatalf("First request failed: %v", err)
}
if len(services) != 1 {
t.Fatalf("Expected 1 service, got %d", len(services))
}
// Wait for cache to expire
time.Sleep(150 * time.Millisecond)
// Configure mock to fail
mock.err = errors.New("etcd unavailable")
// Second request - should return stale cache despite error
services, err = c.GetService("test.service")
if err != nil {
t.Errorf("Should have returned stale cache, got error: %v", err)
}
if len(services) != 1 {
t.Errorf("Expected stale cache with 1 service, got %d", len(services))
}
}
// TestCachePenetrationPrevention verifies the complete flow:
// 1. Cache populated
// 2. Cache expires
// 3. Registry fails
// 4. Concurrent requests don't stampede registry due to rate limiting
// 5. Stale cache is returned without hitting registry
func TestCachePenetrationPrevention(t *testing.T) {
mock := &mockRegistry{
services: []*registry.Service{
{
Name: "test.service",
Version: "1.0.0",
Nodes: []*registry.Node{
{Id: "node1", Address: "localhost:9090"},
},
},
},
}
// Type assertion to *cache is necessary to access internal state for verification
c := New(mock, func(o *Options) {
o.TTL = 100 * time.Millisecond
o.Logger = logger.DefaultLogger
// Use short retry interval to test rate limiting
o.MinimumRetryInterval = 5 * time.Second
}).(*cache)
// Initial request to populate cache
_, err := c.GetService("test.service")
if err != nil {
t.Fatalf("Initial request failed: %v", err)
}
initialCalls := mock.getCallCount()
if initialCalls != 1 {
t.Fatalf("Expected 1 initial call, got %d", initialCalls)
}
// Wait for cache to expire (but not past retry interval)
time.Sleep(150 * time.Millisecond)
// Configure mock to fail with delay
mock.err = errors.New("etcd overloaded")
mock.delay = 100 * time.Millisecond
// Launch many concurrent requests (simulating stampede)
const concurrency = 50
var wg sync.WaitGroup
wg.Add(concurrency)
successCount := int32(0)
for i := 0; i < concurrency; i++ {
go func() {
defer wg.Done()
services, err := c.GetService("test.service")
// Should return stale cache without error (rate limiting prevents registry call)
if err == nil && len(services) > 0 {
atomic.AddInt32(&successCount, 1)
}
}()
}
wg.Wait()
// Verify:
// 1. NO additional calls (rate limiting + stale cache prevented registry access)
totalCalls := mock.getCallCount()
if totalCalls != 1 { // only initial call, no retry due to rate limiting
t.Errorf("Expected 1 total call (rate limiting prevented retry), got %d", totalCalls)
}
// 2. All requests got stale cache (no errors)
if successCount != concurrency {
t.Errorf("Expected all %d requests to succeed with stale cache, got %d", concurrency, successCount)
}
}
// TestThrottlingWithoutStaleCache verifies that the cache throttles requests
// when there's no stale cache and the registry is failing
func TestThrottlingWithoutStaleCache(t *testing.T) {
mock := &mockRegistry{
err: errors.New("etcd connection failed"),
}
// Create cache with short retry interval for testing
c := New(mock, func(o *Options) {
o.TTL = time.Minute
o.MinimumRetryInterval = 2 * time.Second
o.Logger = logger.DefaultLogger
}).(*cache)
// First request - should fail and record the attempt
_, err := c.GetService("test.service")
if err == nil {
t.Fatal("Expected error on first request, got nil")
}
callCount1 := mock.getCallCount()
if callCount1 != 1 {
t.Fatalf("Expected 1 call on first attempt, got %d", callCount1)
}
// Immediate second request - should be throttled (no registry call)
_, err = c.GetService("test.service")
if err == nil {
t.Fatal("Expected error on throttled request, got nil")
}
callCount2 := mock.getCallCount()
if callCount2 != 1 {
t.Errorf("Expected throttling (still 1 call), got %d calls", callCount2)
}
// Wait for retry interval to pass
time.Sleep(2100 * time.Millisecond)
// Third request - should be allowed (makes another registry call)
_, err = c.GetService("test.service")
if err == nil {
t.Fatal("Expected error after retry interval, got nil")
}
callCount3 := mock.getCallCount()
if callCount3 != 2 {
t.Errorf("Expected 2 calls after retry interval, got %d", callCount3)
}
}
// TestThrottlingMultipleConcurrentRequests verifies that throttling works
// correctly with multiple concurrent requests when there's no stale cache
func TestThrottlingMultipleConcurrentRequests(t *testing.T) {
mock := &mockRegistry{
err: errors.New("etcd overloaded"),
delay: 50 * time.Millisecond,
}
c := New(mock, func(o *Options) {
o.TTL = time.Minute
o.MinimumRetryInterval = 1 * time.Second
o.Logger = logger.DefaultLogger
}).(*cache)
// First batch - all concurrent requests should result in single call (singleflight)
const concurrency = 20
var wg sync.WaitGroup
wg.Add(concurrency)
for i := 0; i < concurrency; i++ {
go func() {
defer wg.Done()
c.GetService("test.service")
}()
}
wg.Wait()
callCount1 := mock.getCallCount()
if callCount1 != 1 {
t.Errorf("Expected 1 call (singleflight), got %d", callCount1)
}
// Second batch immediately after - should all be throttled (no new calls)
wg.Add(concurrency)
for i := 0; i < concurrency; i++ {
go func() {
defer wg.Done()
c.GetService("test.service")
}()
}
wg.Wait()
callCount2 := mock.getCallCount()
if callCount2 != 1 {
t.Errorf("Expected throttling (still 1 call), got %d", callCount2)
}
// Wait for retry interval
time.Sleep(1100 * time.Millisecond)
// Third batch - should result in one more call
wg.Add(concurrency)
for i := 0; i < concurrency; i++ {
go func() {
defer wg.Done()
c.GetService("test.service")
}()
}
wg.Wait()
callCount3 := mock.getCallCount()
if callCount3 != 2 {
t.Errorf("Expected 2 calls after retry interval, got %d", callCount3)
}
}
// TestThrottlingDoesNotAffectSuccessfulLookups verifies that throttling
// doesn't interfere with successful service lookups
func TestThrottlingDoesNotAffectSuccessfulLookups(t *testing.T) {
mock := &mockRegistry{
services: []*registry.Service{
{
Name: "test.service",
Version: "1.0.0",
Nodes: []*registry.Node{
{Id: "node1", Address: "localhost:9090"},
},
},
},
}
c := New(mock, func(o *Options) {
o.TTL = 100 * time.Millisecond
o.MinimumRetryInterval = 2 * time.Second
o.Logger = logger.DefaultLogger
}).(*cache)
// Multiple successful requests in quick succession
for i := 0; i < 5; i++ {
services, err := c.GetService("test.service")
if err != nil {
t.Fatalf("Request %d failed: %v", i, err)
}
if len(services) != 1 {
t.Fatalf("Request %d got %d services, expected 1", i, len(services))
}
time.Sleep(10 * time.Millisecond)
}
// First request should hit registry, others should use cache
callCount := mock.getCallCount()
if callCount != 1 {
t.Errorf("Expected 1 call (cached), got %d", callCount)
}
}
// TestThrottlingClearedOnSuccess verifies that failed attempt tracking
// is cleared when a subsequent request succeeds
func TestThrottlingClearedOnSuccess(t *testing.T) {
mock := &mockRegistry{
err: errors.New("temporary failure"),
}
c := New(mock, func(o *Options) {
o.TTL = time.Minute
o.MinimumRetryInterval = 1 * time.Second
o.Logger = logger.DefaultLogger
}).(*cache)
// First request fails
_, err := c.GetService("test.service")
if err == nil {
t.Fatal("Expected error on first request")
}
// Second request immediately after should be throttled
_, err = c.GetService("test.service")
if err == nil {
t.Fatal("Expected error on throttled request")
}
callCount1 := mock.getCallCount()
if callCount1 != 1 {
t.Errorf("Expected 1 call due to throttling, got %d", callCount1)
}
// Wait for retry interval
time.Sleep(1100 * time.Millisecond)
// Fix the mock to return success
mock.mu.Lock()
mock.err = nil
mock.services = []*registry.Service{
{
Name: "test.service",
Version: "1.0.0",
Nodes: []*registry.Node{
{Id: "node1", Address: "localhost:9090"},
},
},
}
mock.mu.Unlock()
// Request should succeed now
services, err := c.GetService("test.service")
if err != nil {
t.Fatalf("Expected success after fix, got error: %v", err)
}
if len(services) != 1 {
t.Fatalf("Expected 1 service, got %d", len(services))
}
// Immediate next request should NOT be throttled (throttling cleared)
services, err = c.GetService("test.service")
if err != nil {
t.Fatalf("Expected cached success, got error: %v", err)
}
if len(services) != 1 {
t.Fatalf("Expected 1 service from cache, got %d", len(services))
}
// Should be 2 calls total (1 failed + 1 success), no additional calls for cached request
callCount2 := mock.getCallCount()
if callCount2 != 2 {
t.Errorf("Expected 2 calls total, got %d", callCount2)
}
}
+29
View File
@@ -0,0 +1,29 @@
package cache
import (
"time"
"go-micro.dev/v6/logger"
)
// WithTTL sets the cache TTL.
func WithTTL(t time.Duration) Option {
return func(o *Options) {
o.TTL = t
}
}
// WithLogger sets the underline logger.
func WithLogger(l logger.Logger) Option {
return func(o *Options) {
o.Logger = l
}
}
// WithMinimumRetryInterval sets the minimum retry interval for failed lookups.
// This prevents cache penetration when registry is failing and there's no stale cache.
func WithMinimumRetryInterval(d time.Duration) Option {
return func(o *Options) {
o.MinimumRetryInterval = d
}
}