mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-13 09:45:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
230cd3a4fe |
@@ -921,6 +921,82 @@ func TestMemoryTestOrchestrator_StopLeakDetection_CoverageBoost(t *testing.T) {
|
|||||||
// CHUNK MANAGER TESTS
|
// CHUNK MANAGER TESTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestChunkManager_GetSessionCount_CoverageBoost(t *testing.T) {
|
||||||
|
logger := NewLogger("debug")
|
||||||
|
cm := NewChunkManager(logger)
|
||||||
|
if cm == nil {
|
||||||
|
t.Fatal("Expected non-nil ChunkManager")
|
||||||
|
}
|
||||||
|
defer cm.Shutdown()
|
||||||
|
|
||||||
|
// Test GetSessionCount
|
||||||
|
count := cm.GetSessionCount()
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("Expected 0 sessions, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkManager_GetMemoryStats_CoverageBoost(t *testing.T) {
|
||||||
|
logger := NewLogger("debug")
|
||||||
|
cm := NewChunkManager(logger)
|
||||||
|
if cm == nil {
|
||||||
|
t.Fatal("Expected non-nil ChunkManager")
|
||||||
|
}
|
||||||
|
defer cm.Shutdown()
|
||||||
|
|
||||||
|
// Test GetMemoryStats
|
||||||
|
stats := cm.GetMemoryStats()
|
||||||
|
if stats == nil {
|
||||||
|
t.Fatal("Expected non-nil stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify expected keys exist
|
||||||
|
if _, ok := stats["active_sessions"]; !ok {
|
||||||
|
t.Error("Expected active_sessions key in stats")
|
||||||
|
}
|
||||||
|
if _, ok := stats["max_sessions"]; !ok {
|
||||||
|
t.Error("Expected max_sessions key in stats")
|
||||||
|
}
|
||||||
|
if _, ok := stats["bytes_allocated"]; !ok {
|
||||||
|
t.Error("Expected bytes_allocated key in stats")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkManager_CanCreateSession_CoverageBoost(t *testing.T) {
|
||||||
|
logger := NewLogger("debug")
|
||||||
|
cm := NewChunkManager(logger)
|
||||||
|
if cm == nil {
|
||||||
|
t.Fatal("Expected non-nil ChunkManager")
|
||||||
|
}
|
||||||
|
defer cm.Shutdown()
|
||||||
|
|
||||||
|
// Test CanCreateSession - should be true initially
|
||||||
|
canCreate, err := cm.CanCreateSession()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !canCreate {
|
||||||
|
t.Error("Expected CanCreateSession to return true when empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkManager_EmergencyCleanup_CoverageBoost(t *testing.T) {
|
||||||
|
logger := NewLogger("debug")
|
||||||
|
cm := NewChunkManager(logger)
|
||||||
|
if cm == nil {
|
||||||
|
t.Fatal("Expected non-nil ChunkManager")
|
||||||
|
}
|
||||||
|
defer cm.Shutdown()
|
||||||
|
|
||||||
|
// Test EmergencyCleanup - should not panic on empty session map
|
||||||
|
cm.EmergencyCleanup()
|
||||||
|
|
||||||
|
// Verify no sessions exist
|
||||||
|
if cm.GetSessionCount() != 0 {
|
||||||
|
t.Error("Expected 0 sessions after cleanup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
|
func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
|
||||||
logger := NewLogger("debug")
|
logger := NewLogger("debug")
|
||||||
cm := NewChunkManager(logger)
|
cm := NewChunkManager(logger)
|
||||||
@@ -932,7 +1008,8 @@ func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
|
|||||||
// Test CleanupExpiredSessions - should not panic on empty session map
|
// Test CleanupExpiredSessions - should not panic on empty session map
|
||||||
cm.CleanupExpiredSessions()
|
cm.CleanupExpiredSessions()
|
||||||
|
|
||||||
if len(cm.sessionMap) != 0 {
|
// Verify no sessions exist
|
||||||
|
if cm.GetSessionCount() != 0 {
|
||||||
t.Error("Expected 0 sessions after cleanup")
|
t.Error("Expected 0 sessions after cleanup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Entra ID refresh tokens start with "1." and contain two dots, but are not JWTs.
|
|
||||||
func TestRefreshTokenWithDotsIsOpaque(t *testing.T) {
|
|
||||||
cm := NewChunkManager(NewLogger(""))
|
|
||||||
entra := "1.AXwAbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-" +
|
|
||||||
"." + strings.Repeat("Ab9_-xYz+/", 40) +
|
|
||||||
"." + strings.Repeat("Qw3rTy7u!~", 40)
|
|
||||||
|
|
||||||
t.Run("refresh config accepts dotted opaque token", func(t *testing.T) {
|
|
||||||
if res := cm.validateToken(entra, RefreshTokenConfig); res.Error != nil {
|
|
||||||
t.Fatalf("refresh token rejected: %v", res.Error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("access config still validates dotted token as JWT", func(t *testing.T) {
|
|
||||||
res := cm.validateToken(entra, AccessTokenConfig)
|
|
||||||
if res.Error == nil {
|
|
||||||
t.Fatal("malformed JWT-shaped access token was accepted")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("refresh config still rejects control characters", func(t *testing.T) {
|
|
||||||
bad := strings.Replace(entra, "AXwA", "AX\x01A", 1)
|
|
||||||
if res := cm.validateToken(bad, RefreshTokenConfig); res.Error == nil {
|
|
||||||
t.Fatal("refresh token with control character was accepted")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("valid JWT refresh token keeps JWT validation", func(t *testing.T) {
|
|
||||||
future := time.Now().Add(time.Hour).Unix()
|
|
||||||
jwt := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(future + 3600), "iat": float64(future)})
|
|
||||||
res := cm.validateToken(jwt, RefreshTokenConfig)
|
|
||||||
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
|
|
||||||
t.Fatalf("error = %v, want issued-in-future from the JWT path", res.Error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("malformed JWT refresh token stays rejected", func(t *testing.T) {
|
|
||||||
future := time.Now().Add(time.Hour).Unix()
|
|
||||||
jwt := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(future + 3600), "iat": float64(time.Now().Unix())})
|
|
||||||
parts := strings.Split(jwt, ".")
|
|
||||||
corrupt := parts[0] + "." + parts[1] + ".!!!not-base64!!!"
|
|
||||||
if res := cm.validateToken(corrupt, RefreshTokenConfig); res.Error == nil {
|
|
||||||
t.Fatal("JWT with a corrupt signature was accepted as opaque")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+230
-45
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -28,43 +27,12 @@ type TokenConfig struct {
|
|||||||
MaxChunkSize int
|
MaxChunkSize int
|
||||||
AllowOpaqueTokens bool
|
AllowOpaqueTokens bool
|
||||||
RequireJWTFormat bool
|
RequireJWTFormat bool
|
||||||
// AlwaysOpaque skips JWT checks even when the value contains dots.
|
|
||||||
AlwaysOpaque bool
|
|
||||||
// FallbackToOpaque validates a dotted token as opaque when it fails JWT
|
|
||||||
// validation. Entra ID refresh tokens have two dots but are not JWTs.
|
|
||||||
FallbackToOpaque bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimsJWT reports whether the first segment decodes to a JSON header with an
|
|
||||||
// "alg" member. A token that does so claims to be a JWT, so a malformed one
|
|
||||||
// must stay rejected rather than fall back to opaque validation.
|
|
||||||
func claimsJWT(token string) bool {
|
|
||||||
seg, _, _ := strings.Cut(token, ".")
|
|
||||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(seg, "="))
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var header map[string]any
|
|
||||||
if json.Unmarshal(raw, &header) != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, ok := header["alg"]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// jwtShaped reports whether token must pass JWT validation.
|
|
||||||
func (c TokenConfig) jwtShaped(token string) bool {
|
|
||||||
return c.RequireJWTFormat || (c.AllowOpaqueTokens && !c.AlwaysOpaque && strings.Contains(token, "."))
|
|
||||||
}
|
|
||||||
|
|
||||||
// opaqueShaped reports whether token must pass opaque-token validation.
|
|
||||||
func (c TokenConfig) opaqueShaped(token string) bool {
|
|
||||||
return c.AllowOpaqueTokens && (c.AlwaysOpaque || !strings.Contains(token, "."))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global session tracking to prevent memory leaks across all instances
|
// Global session tracking to prevent memory leaks across all instances
|
||||||
var (
|
var (
|
||||||
globalSessionCount int64 = 0
|
globalSessionCount int64 = 0
|
||||||
|
globalMaxSessions int64 = 5000 // CRITICAL FIX: Global limit of 5000 total sessions
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResetGlobalSessionCounters resets global session tracking for testing
|
// ResetGlobalSessionCounters resets global session tracking for testing
|
||||||
@@ -96,7 +64,6 @@ var (
|
|||||||
MaxChunkSize: maxCookieSize,
|
MaxChunkSize: maxCookieSize,
|
||||||
AllowOpaqueTokens: true,
|
AllowOpaqueTokens: true,
|
||||||
RequireJWTFormat: false,
|
RequireJWTFormat: false,
|
||||||
FallbackToOpaque: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IDTokenConfig = TokenConfig{
|
IDTokenConfig = TokenConfig{
|
||||||
@@ -333,9 +300,6 @@ func (cm *ChunkManager) processSingleToken(token string, compressed bool, config
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - TokenRetrievalResult with the validated token or validation error.
|
// - TokenRetrievalResult with the validated token or validation error.
|
||||||
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
|
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
|
||||||
if config.FallbackToOpaque && strings.Contains(token, ".") && !claimsJWT(token) {
|
|
||||||
config.AlwaysOpaque = true
|
|
||||||
}
|
|
||||||
// Content-only checks depend on the token string alone, so a token that
|
// Content-only checks depend on the token string alone, so a token that
|
||||||
// passed them once skips them. Expiry and freshness always run.
|
// passed them once skips them. Expiry and freshness always run.
|
||||||
key := contentValidKey(token, config)
|
key := contentValidKey(token, config)
|
||||||
@@ -351,9 +315,7 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
|
|||||||
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
|
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
|
||||||
return TokenRetrievalResult{Token: "", Error: contentErr}
|
return TokenRetrievalResult{Token: "", Error: contentErr}
|
||||||
}
|
}
|
||||||
if !config.AlwaysOpaque {
|
times = cm.tokenTimesOf(token)
|
||||||
times = cm.tokenTimesOf(token)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if expErr := cm.validateTokenExpiration(times, config); expErr != nil {
|
if expErr := cm.validateTokenExpiration(times, config); expErr != nil {
|
||||||
@@ -370,7 +332,8 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
|
|||||||
return TokenRetrievalResult{Token: token, Error: nil}
|
return TokenRetrievalResult{Token: token, Error: nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
isJWT := strings.Count(token, ".") == 2 && !config.AlwaysOpaque
|
dotCount := strings.Count(token, ".")
|
||||||
|
isJWT := dotCount == 2
|
||||||
|
|
||||||
if config.RequireJWTFormat && !config.AllowOpaqueTokens {
|
if config.RequireJWTFormat && !config.AllowOpaqueTokens {
|
||||||
// Only accept JWT format tokens
|
// Only accept JWT format tokens
|
||||||
@@ -722,7 +685,7 @@ func (cm *ChunkManager) validateTokenSize(token string, config TokenConfig) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.jwtShaped(token) {
|
if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) {
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
headerLen := len(parts[0])
|
headerLen := len(parts[0])
|
||||||
@@ -746,7 +709,7 @@ func (cm *ChunkManager) validateTokenSize(token string, config TokenConfig) erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.opaqueShaped(token) {
|
if config.AllowOpaqueTokens && !strings.Contains(token, ".") {
|
||||||
if tokenLen > config.MaxLength {
|
if tokenLen > config.MaxLength {
|
||||||
err := fmt.Errorf("%s opaque token unusually large (%d bytes)", config.Type, tokenLen)
|
err := fmt.Errorf("%s opaque token unusually large (%d bytes)", config.Type, tokenLen)
|
||||||
return err
|
return err
|
||||||
@@ -789,13 +752,13 @@ func (cm *ChunkManager) validateTokenContent(token string, config TokenConfig) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.jwtShaped(token) {
|
if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) {
|
||||||
if err := cm.validateJWTContent(token, config); err != nil {
|
if err := cm.validateJWTContent(token, config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.opaqueShaped(token) {
|
if config.AllowOpaqueTokens && !strings.Contains(token, ".") {
|
||||||
if err := cm.validateOpaqueTokenContent(token, config); err != nil {
|
if err := cm.validateOpaqueTokenContent(token, config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1280,4 +1243,226 @@ func (cm *ChunkManager) CleanupExpiredSessions(force ...bool) {
|
|||||||
cm.logger.Debugf("Cleaned up %d expired sessions, freed %d bytes",
|
cm.logger.Debugf("Cleaned up %d expired sessions, freed %d bytes",
|
||||||
len(expiredKeys), totalBytesFreed)
|
len(expiredKeys), totalBytesFreed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce max sessions limit
|
||||||
|
if len(cm.sessionMap) > cm.maxSessions {
|
||||||
|
cm.enforceSessionLimit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceSessionLimit removes oldest sessions when limit is exceeded
|
||||||
|
// Uses partial sort (O(n) for finding k smallest) instead of full sort (O(n log n))
|
||||||
|
func (cm *ChunkManager) enforceSessionLimit() {
|
||||||
|
currentLocal := len(cm.sessionMap)
|
||||||
|
currentGlobal := atomic.LoadInt64(&globalSessionCount)
|
||||||
|
|
||||||
|
// CRITICAL FIX: Aggressive eviction when approaching limits
|
||||||
|
shouldEvict := false
|
||||||
|
targetCapacity := cm.maxSessions
|
||||||
|
|
||||||
|
// Check global limit first (more critical)
|
||||||
|
if currentGlobal >= globalMaxSessions {
|
||||||
|
shouldEvict = true
|
||||||
|
targetCapacity = cm.maxSessions / 4 // Aggressive reduction to 25%
|
||||||
|
} else if currentGlobal >= globalMaxSessions*8/10 { // 80% of global
|
||||||
|
shouldEvict = true
|
||||||
|
targetCapacity = cm.maxSessions / 2 // Reduce to 50%
|
||||||
|
} else if currentLocal >= cm.maxSessions {
|
||||||
|
shouldEvict = true
|
||||||
|
targetCapacity = cm.maxSessions * 3 / 4 // Reduce to 75%
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldEvict {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how many sessions to remove
|
||||||
|
excessCount := currentLocal - targetCapacity
|
||||||
|
if excessCount <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use partial selection instead of full sort for better performance
|
||||||
|
// For finding k oldest sessions, we only need O(n) operations
|
||||||
|
keysToRemove := cm.findOldestSessions(excessCount)
|
||||||
|
|
||||||
|
totalBytesFreed := int64(0)
|
||||||
|
removedCount := int64(0)
|
||||||
|
|
||||||
|
for _, key := range keysToRemove {
|
||||||
|
if entry, exists := cm.sessionMap[key]; exists {
|
||||||
|
totalBytesFreed += entry.SizeEstimate
|
||||||
|
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
|
||||||
|
removedCount++
|
||||||
|
}
|
||||||
|
delete(cm.sessionMap, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update global count
|
||||||
|
if removedCount > 0 {
|
||||||
|
atomic.AddInt64(&globalSessionCount, -removedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.logger.Infof("Enforced session limit: removed %d excess sessions, freed %d bytes",
|
||||||
|
len(keysToRemove), totalBytesFreed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOldestSessions returns keys of the k oldest sessions efficiently
|
||||||
|
// Uses a simple approach: find the kth oldest timestamp, then collect all older entries
|
||||||
|
func (cm *ChunkManager) findOldestSessions(k int) []string {
|
||||||
|
if k <= 0 || len(cm.sessionMap) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if k >= len(cm.sessionMap) {
|
||||||
|
// Remove all sessions
|
||||||
|
keys := make([]string, 0, len(cm.sessionMap))
|
||||||
|
for key := range cm.sessionMap {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all timestamps with keys
|
||||||
|
type sessionAge struct {
|
||||||
|
lastUsed time.Time
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions := make([]sessionAge, 0, len(cm.sessionMap))
|
||||||
|
for key, entry := range cm.sessionMap {
|
||||||
|
sessions = append(sessions, sessionAge{key: key, lastUsed: entry.LastUsed})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial sort: get the k smallest elements using selection
|
||||||
|
// This is O(n*k) which is better than O(n log n) when k << n
|
||||||
|
for i := 0; i < k; i++ {
|
||||||
|
minIdx := i
|
||||||
|
for j := i + 1; j < len(sessions); j++ {
|
||||||
|
if sessions[j].lastUsed.Before(sessions[minIdx].lastUsed) {
|
||||||
|
minIdx = j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if minIdx != i {
|
||||||
|
sessions[i], sessions[minIdx] = sessions[minIdx], sessions[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the k oldest
|
||||||
|
result := make([]string, k)
|
||||||
|
for i := 0; i < k; i++ {
|
||||||
|
result[i] = sessions[i].key
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanCreateSession checks if a new session can be created within limits
|
||||||
|
func (cm *ChunkManager) CanCreateSession() (bool, error) {
|
||||||
|
cm.mutex.RLock()
|
||||||
|
currentCount := len(cm.sessionMap)
|
||||||
|
cm.mutex.RUnlock()
|
||||||
|
|
||||||
|
// Hard limit check - never exceed maxSessions
|
||||||
|
if currentCount >= cm.maxSessions {
|
||||||
|
cm.logger.Error("Cannot create session: at maximum limit (%d)", cm.maxSessions)
|
||||||
|
return false, fmt.Errorf("session storage at maximum capacity (%d sessions)", cm.maxSessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emergency cleanup at 90% capacity
|
||||||
|
emergencyThreshold := int(float64(cm.maxSessions) * 0.9)
|
||||||
|
if currentCount >= emergencyThreshold {
|
||||||
|
cm.logger.Info("Session storage at %d%% capacity, triggering emergency cleanup",
|
||||||
|
(currentCount*100)/cm.maxSessions)
|
||||||
|
cm.EmergencyCleanup()
|
||||||
|
|
||||||
|
// Recheck after cleanup
|
||||||
|
cm.mutex.RLock()
|
||||||
|
newCount := len(cm.sessionMap)
|
||||||
|
cm.mutex.RUnlock()
|
||||||
|
|
||||||
|
if newCount >= cm.maxSessions {
|
||||||
|
return false, fmt.Errorf("session storage full even after emergency cleanup (%d sessions)", newCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmergencyCleanup performs aggressive session cleanup when approaching limits
|
||||||
|
func (cm *ChunkManager) EmergencyCleanup() {
|
||||||
|
cm.mutex.Lock()
|
||||||
|
defer cm.mutex.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
removed := 0
|
||||||
|
|
||||||
|
// Remove any expired sessions first
|
||||||
|
expiredKeys := make([]string, 0)
|
||||||
|
for key, entry := range cm.sessionMap {
|
||||||
|
if now.After(entry.ExpiresAt) || now.Sub(entry.LastUsed) > cm.sessionTTL {
|
||||||
|
expiredKeys = append(expiredKeys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range expiredKeys {
|
||||||
|
if entry, exists := cm.sessionMap[key]; exists {
|
||||||
|
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
|
||||||
|
}
|
||||||
|
delete(cm.sessionMap, key)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still over 80% capacity, remove oldest sessions more aggressively
|
||||||
|
targetCapacity := int(float64(cm.maxSessions) * 0.8)
|
||||||
|
if len(cm.sessionMap) > targetCapacity {
|
||||||
|
excessCount := len(cm.sessionMap) - targetCapacity
|
||||||
|
|
||||||
|
// Use efficient partial sort to find oldest sessions
|
||||||
|
keysToRemove := cm.findOldestSessions(excessCount)
|
||||||
|
|
||||||
|
for _, key := range keysToRemove {
|
||||||
|
if entry, exists := cm.sessionMap[key]; exists {
|
||||||
|
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
|
||||||
|
}
|
||||||
|
delete(cm.sessionMap, key)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.lastCleanup = now
|
||||||
|
cm.logger.Infof("Emergency cleanup completed: removed %d sessions, %d remaining",
|
||||||
|
removed, len(cm.sessionMap))
|
||||||
|
|
||||||
|
// Log memory stats after emergency cleanup (read only, no forced GC)
|
||||||
|
cm.logger.Infof("Sessions after emergency cleanup: %d, Tracked bytes: %d",
|
||||||
|
len(cm.sessionMap), atomic.LoadInt64(&cm.bytesAllocated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionCount returns the current number of active sessions (for monitoring)
|
||||||
|
func (cm *ChunkManager) GetSessionCount() int {
|
||||||
|
cm.mutex.RLock()
|
||||||
|
defer cm.mutex.RUnlock()
|
||||||
|
return len(cm.sessionMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemoryStats returns memory usage statistics for monitoring
|
||||||
|
func (cm *ChunkManager) GetMemoryStats() map[string]interface{} {
|
||||||
|
cm.mutex.RLock()
|
||||||
|
sessionCount := len(cm.sessionMap)
|
||||||
|
cm.mutex.RUnlock()
|
||||||
|
|
||||||
|
stats := make(map[string]interface{})
|
||||||
|
stats["active_sessions"] = sessionCount
|
||||||
|
stats["max_sessions"] = cm.maxSessions
|
||||||
|
stats["bytes_allocated"] = atomic.LoadInt64(&cm.bytesAllocated)
|
||||||
|
stats["peak_sessions"] = atomic.LoadInt64(&cm.peakSessions)
|
||||||
|
stats["cleanup_count"] = atomic.LoadInt64(&cm.cleanupCount)
|
||||||
|
stats["session_ttl_hours"] = cm.sessionTTL.Hours()
|
||||||
|
|
||||||
|
// Update peak sessions
|
||||||
|
if int64(sessionCount) > atomic.LoadInt64(&cm.peakSessions) {
|
||||||
|
atomic.StoreInt64(&cm.peakSessions, int64(sessionCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
|
|||||||
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
|
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
|
||||||
// not the interpreted plugin). This build-stamped constant is therefore the
|
// not the interpreted plugin). This build-stamped constant is therefore the
|
||||||
// single source of truth for the version reported by anonymous usage telemetry.
|
// single source of truth for the version reported by anonymous usage telemetry.
|
||||||
const traefikoidcPluginVersion = "1.0.64"
|
const traefikoidcPluginVersion = "1.0.62"
|
||||||
|
|||||||
Reference in New Issue
Block a user