Files
traefikoidc/universal_cache_singleton.go
T
lukaszraczylo 1b6c8616fd fix(refresh): coalesce refresh-token grants + bound goroutines + cache hot path (target v0.8.27) (#131)
* fix(refresh): wire RefreshCoordinator into the live refresh path

The RefreshCoordinator existed but was never instantiated. The actual
refresh path used only session.refreshMutex, which is per-SessionData
instance - and SessionData is pulled from a sync.Pool per request -
so concurrent requests sharing a refresh token had ZERO coordination.

Symptom: when access_token expired (e.g. 5min Zitadel default), every
in-flight request from a polling client (Grafana panels) entered the
refresh path simultaneously and POSTed the same refresh_token to the
IdP. With refresh-token rotation enabled (Zitadel/Authentik default),
only one grant succeeded; the rest got invalid_grant and each cleared
the entire session. Subsequent requests then thrashed in re-auth loops.

This commit:
- adds refreshCoordinator field on TraefikOidc
- instantiates it in NewWithContext with DefaultRefreshCoordinatorConfig
- shuts it down in Close() under shutdownOnce
- routes refreshToken() through the coordinator via coordinatedTokenRefresh,
  which collapses concurrent grants to a single upstream call per
  refresh_token hash
- exports refreshCoordinatorSessionID for both internal hashing and the
  middleware-level wireup so dedup keys stay aligned

Behavioural notes:
- nil-coordinator fallback preserves existing tests that build TraefikOidc
  literals without going through the constructor
- followers receive the same TokenResponse/error as the leader, so no
  per-instance code paths change
- existing TestGetNewTokenWithRefreshToken_Concurrency still passes
  because it hits GetNewTokenWithRefreshToken directly, below the
  coordinator boundary

Tests:
- refresh_coordinator_wireup_test.go: 50 concurrent refreshes coalesce
  to <=2 upstream calls; distinct tokens still run in parallel; nil
  coordinator falls back cleanly

* perf(cache): bound L1 backfill goroutines in HybridBackend

Get() and GetMany() previously spawned a goroutine per L2 hit to write
the value through to L1. Under sustained polling traffic (e.g. a Grafana
dashboard refreshing every 30s with N panels) this minted thousands of
goroutines, each running in Yaegi - directly contributing to the
~1000% CPU spike that pairs with the refresh-token herd.

Replace the per-hit goroutines with a single l1BackfillWorker fed by
l1BackfillBuffer, mirroring the existing asyncWriteBuffer/asyncWriteWorker
pattern for L2 writes. Buffer overflow drops the backfill (counted via
l1BackfillDrops) - a dropped backfill just means the next L2 hit for
that key re-queues it, which is safe.

Tests:
- TestHybridBackend_L1BackfillBounded: 1000 distinct L2 hits keep
  goroutine count within +20 of baseline (pre-fix it grew by ~1000)
- TestHybridBackend_L1BackfillFullDrops: drops are accounted for when
  the buffer is saturated and the worker is stopped

* feat(refresh): implement isRefreshTokenExpired heuristic

Replace the placeholder `return false` with a real check based on the
issued_at timestamp that SetRefreshToken already stamps into the session.
Gated by a new MaxRefreshTokenAgeSeconds config field (default 21600 =
6h, matching the existing comment). 0 disables the check.

This wires the previously-dead refreshTokenExpired branch in middleware.go,
which short-circuits AJAX requests with a 401 instead of letting them
hammer the IdP for a refresh token that's almost certainly stale - the
classic Grafana-after-long-pause failure mode.

Behaviour:
- maxRefreshTokenAge=0 disables the check (preserves prior behaviour)
- legacy sessions without issued_at still attempt one refresh; the IdP
  remains the source of truth on first try
- nil-receiver and nil-session guards keep test code that builds
  TraefikOidc literals safe

Tests:
- TestIsRefreshTokenExpired_DisabledWhenAgeZero
- TestIsRefreshTokenExpired_LegacySessionWithoutTimestamp
- TestIsRefreshTokenExpired_WithinWindow
- TestIsRefreshTokenExpired_BeyondWindow
- TestIsRefreshTokenExpired_NilGuards

* perf(token): skip parseJWT on cache hit in VerifyToken

The token cache fast-return existed but ran AFTER parseJWT, so every
validation paid for base64 + JSON unmarshal even on a hit. Under bursty
traffic (e.g. 10+ concurrent panel requests on every Grafana dashboard
refresh, each calling validateStandardTokens which verifies BOTH the
access token and the ID token), this is two redundant parses per
request multiplied by the panel count.

Move the cache lookup ahead of parseJWT. On a hit the function returns
nil immediately. On a miss the original flow runs unchanged.

Also nil-guard t.tokenCache to keep partial-literal test instances safe
(matches the same pattern we already use for tokenBlacklist).

Tests:
- TestVerifyToken_CacheHitSkipsParse: cache pre-populated with claims
  for a token whose body would fail parseJWT - returns nil iff the
  fast-path bypasses the parse
- TestVerifyToken_CacheMissStillParses: a syntactically valid but
  unsigned token still errors past parseJWT on cache miss

* feat(refresh): cross-replica refresh-grant dedup via shared cache

The in-process RefreshCoordinator added in 9f96d8c already collapses
concurrent refresh-token grants on a single Traefik replica. With the
plugin's existing Redis (Dragonfly) cache infrastructure available, we
can extend that dedup across replicas: if pod A refreshes a token at
T+0 and pod B receives a request for the same session at T+1, pod B
should reuse pod A's result rather than POSTing the now-rotated refresh
token to the IdP.

Implementation:
- Add a refreshResultCache to UniversalCacheManager (memory-only when
  Redis is disabled, Redis-backed in production via the existing
  hybrid/Redis-only mode selection)
- Expose it through CacheManager.GetSharedRefreshResultCache and on the
  TraefikOidc struct as refreshResultCache (CacheInterface)
- Inside the closure passed to RefreshCoordinator.CoordinateRefresh,
  consult the cache first; on hit return immediately, on miss exchange
  with the IdP and populate the cache for peers
- 5s TTL: long enough for siblings to observe, short enough that a
  rotated refresh token cannot be re-supplied after the IdP has moved on
- Errors are intentionally NOT cached - peers must always be able to
  retry on their own

Pragmatic choice: optimistic cache rather than a hard distributed lock.
- A hard lock (SET NX + poll) doubles Redis RTT and risks dead-locks
  if a Traefik pod dies mid-grant.
- The user's BGP+Local externalTrafficPolicy already pins ingress for
  a session to one node in steady state, so cross-pod racing is rare.
- This optimistic path catches the rare failover case without adding
  failure modes.

Tests:
- TestCoordinatedTokenRefresh_CrossReplicaCacheHit: pre-populated cache
  short-circuits the upstream call entirely (0 IdP calls)
- TestCoordinatedTokenRefresh_PopulatesCrossReplicaCache: leader stores
  a successful result for peers to find
- TestCoordinatedTokenRefresh_ErrorIsNotCached: invalid_grant must not
  poison the dedup cache - peers must retry independently
2026-04-30 18:52:39 +01:00

590 lines
19 KiB
Go

package traefikoidc
import (
"context"
"sync"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
"github.com/lukaszraczylo/traefikoidc/internal/cache/resilience"
)
// UniversalCacheManager manages all cache instances using the universal cache
// It runs a single consolidated cleanup goroutine for all caches, reducing
// goroutine count and CPU overhead compared to per-cache cleanup routines.
type UniversalCacheManager struct {
sharedBackend backends.CacheBackend
ctx context.Context
tokenTypeCache *UniversalCache
jwkCache *UniversalCache
sessionCache *UniversalCache
introspectionCache *UniversalCache
tokenCache *UniversalCache
metadataCache *UniversalCache
dcrCredentialsCache *UniversalCache // DCR credentials storage for distributed environments
sessionInvalidationCache *UniversalCache // Session invalidation cache for backchannel/front-channel logout
refreshResultCache *UniversalCache // Short-lived cross-replica refresh-result dedup (paired with RefreshCoordinator)
logger *Logger
blacklistCache *UniversalCache
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.RWMutex
cleanupStarted bool
}
var (
universalCacheManager *UniversalCacheManager
universalCacheManagerOnce sync.Once
)
// GetUniversalCacheManager returns the singleton universal cache manager
func GetUniversalCacheManager(logger *Logger) *UniversalCacheManager {
universalCacheManagerOnce.Do(func() {
if logger == nil {
logger = GetSingletonNoOpLogger()
}
ctx, cancel := context.WithCancel(context.Background())
universalCacheManager = &UniversalCacheManager{
logger: logger,
ctx: ctx,
cancel: cancel,
}
// Initialize with default in-memory backends
initializeDefaultCaches(universalCacheManager, logger)
// Start single consolidated cleanup goroutine for all caches
// This replaces 7 individual cleanup goroutines with 1
universalCacheManager.startConsolidatedCleanup()
})
return universalCacheManager
}
// GetUniversalCacheManagerWithConfig returns the singleton universal cache manager with Redis configuration
func GetUniversalCacheManagerWithConfig(logger *Logger, redisConfig *RedisConfig) *UniversalCacheManager {
universalCacheManagerOnce.Do(func() {
if logger == nil {
logger = GetSingletonNoOpLogger()
}
ctx, cancel := context.WithCancel(context.Background())
universalCacheManager = &UniversalCacheManager{
logger: logger,
ctx: ctx,
cancel: cancel,
}
if redisConfig != nil && redisConfig.Enabled {
logger.Infof("Initializing cache manager with Redis backend: %s", redisConfig.Address)
initializeCachesWithRedis(universalCacheManager, logger, redisConfig)
} else {
logger.Info("Initializing cache manager with memory-only backend")
initializeDefaultCaches(universalCacheManager, logger)
}
// Start single consolidated cleanup goroutine for all caches
// This replaces 7 individual cleanup goroutines with 1
universalCacheManager.startConsolidatedCleanup()
})
return universalCacheManager
}
// initializeDefaultCaches initializes caches with memory-only backends
func initializeDefaultCaches(manager *UniversalCacheManager, logger *Logger) {
// Initialize token cache - CRITICAL FIX: Reduced from 5000 to 1000
manager.tokenCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000, // CRITICAL FIX: Reduced from 5000 to 1000 items
MaxMemoryBytes: 5 * 1024 * 1024, // CRITICAL FIX: Added 5MB memory limit
DefaultTTL: 1 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize blacklist cache
manager.blacklistCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 24 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize metadata cache with grace periods
manager.metadataCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeMetadata,
MaxSize: 100,
DefaultTTL: 1 * time.Hour,
MetadataConfig: &MetadataCacheConfig{
GracePeriod: 5 * time.Minute,
ExtendedGracePeriod: 15 * time.Minute,
MaxGracePeriod: 30 * time.Minute,
SecurityCriticalMaxGracePeriod: 15 * time.Minute,
SecurityCriticalFields: []string{
"jwks_uri",
"token_endpoint",
"authorization_endpoint",
"issuer",
},
},
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize JWK cache
manager.jwkCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeJWK,
MaxSize: 200,
DefaultTTL: 1 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize session cache - CRITICAL FIX: Reduced from 10000 to 2000
manager.sessionCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeSession,
MaxSize: 2000, // CRITICAL FIX: Reduced from 10000 to 2000 items
MaxMemoryBytes: 5 * 1024 * 1024, // CRITICAL FIX: Added 5MB memory limit
DefaultTTL: 30 * time.Minute,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize introspection cache for OAuth 2.0 Token Introspection (RFC 7662)
manager.introspectionCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken, // Use token cache type for introspection results
MaxSize: 1000, // Cache up to 1000 introspection results
DefaultTTL: 5 * time.Minute, // Short TTL for security (introspect frequently)
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize token type cache for performance optimization
manager.tokenTypeCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken, // Use token cache type for token type detection
MaxSize: 2000, // Cache up to 2000 token type detections
DefaultTTL: 5 * time.Minute, // 5 minute TTL for token type detection
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize session invalidation cache for backchannel/front-channel logout
// This cache stores invalidated session IDs and subjects to revoke sessions
manager.sessionInvalidationCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeSession,
MaxSize: 5000, // Support many concurrent invalidations
DefaultTTL: 25 * time.Hour, // Slightly longer than session max age (24h)
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Refresh-result cache: short-lived store keyed by sha256(refreshToken).
// In Redis-backed mode this gives cross-replica dedup of refresh grants;
// in memory-only mode it's effectively redundant with RefreshCoordinator
// but safe and cheap to keep.
manager.refreshResultCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 5 * time.Second,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
}
// initializeCachesWithRedis initializes caches with Redis/Hybrid backends based on configuration
func initializeCachesWithRedis(manager *UniversalCacheManager, logger *Logger, redisConfig *RedisConfig) {
// Apply defaults to Redis config
redisConfig.ApplyDefaults()
// Create Redis backend
redisBackendConfig := &backends.Config{
Type: backends.BackendTypeRedis,
RedisAddr: redisConfig.Address,
RedisPassword: redisConfig.Password,
RedisDB: redisConfig.DB,
RedisPrefix: redisConfig.KeyPrefix,
PoolSize: redisConfig.PoolSize,
EnableMetrics: true,
}
// Use concrete type to avoid Yaegi reflection issues with interface assignment
// The concrete type will be automatically converted to interface when needed
baseBackend, err := backends.NewRedisBackend(redisBackendConfig)
if err != nil {
logger.Errorf("Failed to create Redis backend: %v. Falling back to memory-only mode.", err)
initializeDefaultCaches(manager, logger)
return
}
// Build the backend with optional wrappers
var redisBackend backends.CacheBackend = baseBackend
// Wrap with circuit breaker if enabled
if redisConfig.EnableCircuitBreaker {
cbConfig := resilience.DefaultCircuitBreakerConfig()
cbConfig.MaxFailures = redisConfig.CircuitBreakerThreshold
cbConfig.Timeout = time.Duration(redisConfig.CircuitBreakerTimeout) * time.Second
cbConfig.OnStateChange = func(from, to resilience.State) {
logger.Infof("Circuit breaker state changed from %s to %s", from, to)
}
redisBackend = resilience.NewCircuitBreakerBackend(redisBackend, cbConfig)
logger.Info("Redis backend wrapped with circuit breaker")
}
// Wrap with health checker if enabled
if redisConfig.EnableHealthCheck {
hcConfig := &resilience.HealthCheckConfig{
CheckInterval: time.Duration(redisConfig.HealthCheckInterval) * time.Second,
Timeout: 5 * time.Second,
HealthyThreshold: 2,
UnhealthyThreshold: 3,
OnStatusChange: func(from, to resilience.HealthStatus) {
logger.Infof("Redis backend health status changed from %s to %s", from, to)
},
}
redisBackend = resilience.NewHealthCheckBackend(redisBackend, hcConfig)
logger.Info("Redis backend wrapped with health checker")
}
// Store the fully-wrapped shared backend in the manager so it can be closed properly
manager.sharedBackend = redisBackend
// Decide which backend to use based on cache mode
var createBackend func(cacheType CacheType) backends.CacheBackend
switch redisConfig.CacheMode {
case "redis":
// Redis-only mode
createBackend = func(cacheType CacheType) backends.CacheBackend {
return redisBackend
}
logger.Info("Using Redis-only cache backend")
case "hybrid":
// Hybrid mode is not currently supported due to interface incompatibilities
// Fall back to Redis-only mode
logger.Info("Hybrid mode not currently supported, using Redis-only mode")
createBackend = func(cacheType CacheType) backends.CacheBackend {
return redisBackend
}
default:
// Memory-only mode (fallback)
logger.Infof("Invalid cache mode: %s. Using memory-only mode.", redisConfig.CacheMode)
initializeDefaultCaches(manager, logger)
return
}
// Initialize token cache with backend
manager.tokenCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
MaxMemoryBytes: 5 * 1024 * 1024,
DefaultTTL: 1 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend(CacheTypeToken),
)
// Initialize blacklist cache (CRITICAL - must be consistent across replicas)
manager.blacklistCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 24 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend("blacklist"),
)
// Initialize metadata cache
manager.metadataCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeMetadata,
MaxSize: 100,
DefaultTTL: 1 * time.Hour,
MetadataConfig: &MetadataCacheConfig{
GracePeriod: 5 * time.Minute,
ExtendedGracePeriod: 15 * time.Minute,
MaxGracePeriod: 30 * time.Minute,
SecurityCriticalMaxGracePeriod: 15 * time.Minute,
SecurityCriticalFields: []string{
"jwks_uri",
"token_endpoint",
"authorization_endpoint",
"issuer",
},
},
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend(CacheTypeMetadata),
)
// Initialize JWK cache
manager.jwkCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeJWK,
MaxSize: 200,
DefaultTTL: 1 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend(CacheTypeJWK),
)
// Session cache stays memory-only (high volume, local state)
manager.sessionCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeSession,
MaxSize: 2000,
MaxMemoryBytes: 5 * 1024 * 1024,
DefaultTTL: 30 * time.Minute,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Introspection cache uses backend for sharing results
manager.introspectionCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 5 * time.Minute,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend(CacheTypeToken),
)
// Token type cache stays memory-only (local optimization)
manager.tokenTypeCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 2000,
DefaultTTL: 5 * time.Minute,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// DCR credentials cache - CRITICAL for distributed DCR across multiple nodes
// Uses Redis backend to share client credentials across all Traefik replicas
manager.dcrCredentialsCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeGeneral,
MaxSize: 100, // Few providers expected
DefaultTTL: 30 * 24 * time.Hour, // 30 days default (credentials are long-lived)
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend("dcr"),
)
// Session invalidation cache - CRITICAL for distributed backchannel/front-channel logout
// Uses Redis backend to share session invalidations across all Traefik replicas
manager.sessionInvalidationCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeSession,
MaxSize: 5000, // Support many concurrent invalidations
DefaultTTL: 25 * time.Hour, // Slightly longer than session max age (24h)
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend("session_invalidation"),
)
// Refresh-result cache - shared via Redis so concurrent refreshes across
// Traefik replicas can dedup their grants. The 5s TTL is long enough for
// peers to observe a recent refresh and short enough that a stale entry
// can't be replayed against a now-rotated refresh token.
manager.refreshResultCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 5 * time.Second,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend("refresh_result"),
)
logger.Infof("Cache manager initialized with %s backend configuration", redisConfig.CacheMode)
}
// startConsolidatedCleanup starts a single cleanup goroutine for all caches
// This reduces goroutine count from 7 to 1 and consolidates cleanup operations
func (m *UniversalCacheManager) startConsolidatedCleanup() {
m.mu.Lock()
if m.cleanupStarted {
m.mu.Unlock()
return
}
m.cleanupStarted = true
m.mu.Unlock()
m.wg.Add(1)
go func() {
defer m.wg.Done()
// Use 5-minute interval for consolidated cleanup
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-m.ctx.Done():
return
case <-ticker.C:
m.performConsolidatedCleanup()
}
}
}()
m.logger.Info("UniversalCacheManager: Started consolidated cleanup routine for all caches")
}
// performConsolidatedCleanup runs cleanup on all caches in sequence
// This is more efficient than parallel cleanup as it reduces lock contention
func (m *UniversalCacheManager) performConsolidatedCleanup() {
m.mu.RLock()
caches := []*UniversalCache{
m.tokenCache,
m.blacklistCache,
m.metadataCache,
m.jwkCache,
m.sessionCache,
m.introspectionCache,
m.tokenTypeCache,
m.dcrCredentialsCache,
m.sessionInvalidationCache,
m.refreshResultCache,
}
m.mu.RUnlock()
for _, cache := range caches {
if cache != nil {
// Each cache.Cleanup() is self-contained and handles its own locking
cache.Cleanup()
}
}
m.logger.Debugf("UniversalCacheManager: Consolidated cleanup completed for all caches")
}
// GetTokenCache returns the token cache
func (m *UniversalCacheManager) GetTokenCache() *UniversalCache {
m.mu.RLock()
defer m.mu.RUnlock()
return m.tokenCache
}
// GetBlacklistCache returns the blacklist cache
func (m *UniversalCacheManager) GetBlacklistCache() *UniversalCache {
m.mu.RLock()
defer m.mu.RUnlock()
return m.blacklistCache
}
// GetMetadataCache returns the metadata cache
func (m *UniversalCacheManager) GetMetadataCache() *UniversalCache {
m.mu.RLock()
defer m.mu.RUnlock()
return m.metadataCache
}
// GetJWKCache returns the JWK cache
func (m *UniversalCacheManager) GetJWKCache() *UniversalCache {
m.mu.RLock()
defer m.mu.RUnlock()
return m.jwkCache
}
// GetIntrospectionCache returns the token introspection cache
func (m *UniversalCacheManager) GetIntrospectionCache() *UniversalCache {
m.mu.RLock()
defer m.mu.RUnlock()
return m.introspectionCache
}
// GetTokenTypeCache returns the token type detection cache
func (m *UniversalCacheManager) GetTokenTypeCache() *UniversalCache {
m.mu.RLock()
defer m.mu.RUnlock()
return m.tokenTypeCache
}
// GetSessionInvalidationCache returns the session invalidation cache for backchannel/front-channel logout
func (m *UniversalCacheManager) GetSessionInvalidationCache() *UniversalCache {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessionInvalidationCache
}
// GetRefreshResultCache returns the short-lived refresh-result cache used to
// coalesce refresh-token grants across Traefik replicas.
func (m *UniversalCacheManager) GetRefreshResultCache() *UniversalCache {
m.mu.RLock()
defer m.mu.RUnlock()
return m.refreshResultCache
}
// GetDCRCredentialsCache returns the DCR credentials cache for distributed storage
func (m *UniversalCacheManager) GetDCRCredentialsCache() *UniversalCache {
m.mu.RLock()
defer m.mu.RUnlock()
return m.dcrCredentialsCache
}
// Close shuts down all caches and the consolidated cleanup routine
func (m *UniversalCacheManager) Close() error {
// Stop the consolidated cleanup routine first
if m.cancel != nil {
m.cancel()
}
// Wait for cleanup routine to finish
m.wg.Wait()
m.mu.Lock()
defer m.mu.Unlock()
// Close all caches first (they won't close the shared backend)
for _, cache := range []*UniversalCache{
m.tokenCache, m.blacklistCache, m.metadataCache, m.jwkCache, m.sessionCache, m.introspectionCache, m.tokenTypeCache, m.dcrCredentialsCache, m.sessionInvalidationCache, m.refreshResultCache,
} {
if cache != nil {
_ = cache.Close() // Safe to ignore: best effort cache cleanup
}
}
// Now close the shared backend if present
if m.sharedBackend != nil {
if err := m.sharedBackend.Close(); err != nil {
m.logger.Infof("Failed to close shared cache backend: %v", err)
} else {
m.logger.Info("UniversalCacheManager: Closed shared backend")
}
}
m.cleanupStarted = false
m.logger.Info("UniversalCacheManager: Closed all caches and cleanup routine")
return nil
}
// ResetUniversalCacheManagerForTesting resets the singleton for testing purposes only
// This should only be called in test code to ensure proper cleanup between tests
func ResetUniversalCacheManagerForTesting() {
if universalCacheManager != nil {
_ = universalCacheManager.Close() // Safe to ignore: test cleanup best effort
}
universalCacheManagerOnce = sync.Once{}
universalCacheManager = nil
}