github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/tenants/manager.go (about) 1 package tenants 2 3 import ( 4 "context" 5 "fmt" 6 "math" 7 "math/rand/v2" 8 "sync" 9 "time" 10 11 "github.com/grafana/synthetic-monitoring-agent/internal/cache" 12 "github.com/grafana/synthetic-monitoring-agent/internal/pusher" 13 sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring" 14 "github.com/puzpuzpuz/xsync/v4" 15 "github.com/rs/zerolog" 16 ) 17 18 const ( 19 // DefaultCacheTimeout is the default timeout for the tenant cache. 20 DefaultCacheTimeout = 15 * time.Minute 21 defaultCacheJitter = 0.25 // 25% jitter 22 ) 23 24 // isSecretStoreConfigured returns true if the SecretStore has both URL and token configured. 25 func isSecretStoreConfigured(secretStore *sm.SecretStore) bool { 26 return secretStore != nil && secretStore.Url != "" && secretStore.Token != "" 27 } 28 29 type Manager struct { 30 tenantCh <-chan sm.Tenant 31 tenantsClient sm.TenantsClient 32 timeout time.Duration 33 cache cache.Cache 34 fetchMutexes *xsync.Map[int64, *sync.Mutex] // for fetch deduplication. 35 logger zerolog.Logger 36 } 37 38 var _ pusher.TenantProvider = &Manager{} 39 40 // cachedTenant wraps a tenant with caching metadata 41 type cachedTenant struct { 42 Tenant *sm.Tenant 43 ValidUntil time.Time 44 Modified float64 // Track Modified field to detect stale updates 45 } 46 47 // cacheKey generates a cache key for a tenant ID 48 func cacheKey(tenantID int64) string { 49 return fmt.Sprintf("tenant:%d", tenantID) 50 } 51 52 // getFetchMutex returns a mutex for the given tenant ID to prevent concurrent API fetches 53 func (tm *Manager) getFetchMutex(tenantID int64) *sync.Mutex { 54 actual, _ := tm.fetchMutexes.LoadOrStore(tenantID, &sync.Mutex{}) 55 56 return actual 57 } 58 59 // NewManager creates a new tenant manager that is able to 60 // retrieve tenants from the remote API using the specified 61 // tenantsClient or receive them over the provided tenantCh channel. It 62 // will keep them for a duration no longer than `timeout`. 63 // 64 // A new goroutine is started which stops when the provided context is 65 // cancelled. 66 func NewManager(ctx context.Context, tenantsClient sm.TenantsClient, tenantCh <-chan sm.Tenant, timeout time.Duration, cache cache.Cache, logger zerolog.Logger) *Manager { 67 tm := &Manager{ 68 tenantCh: tenantCh, 69 tenantsClient: tenantsClient, 70 timeout: timeout, 71 cache: cache, 72 logger: logger, 73 fetchMutexes: xsync.NewMap[int64, *sync.Mutex](), 74 } 75 76 go tm.run(ctx) 77 78 return tm 79 } 80 81 func (tm *Manager) run(ctx context.Context) { 82 for { 83 select { 84 case <-ctx.Done(): 85 return 86 87 case tenant := <-tm.tenantCh: 88 tm.updateTenant(tenant) 89 } 90 } 91 } 92 93 // calculateValidUntil determines the expiration time for a tenant based on the timeout 94 // and the secret store expiration date (if set), returning the earlier of the two. 95 // If the secret store is not properly configured, the cache is considered invalid immediately. 96 func (tm *Manager) calculateValidUntil(tenant *sm.Tenant) time.Time { 97 now := time.Now() 98 99 tm.logger.Debug(). 100 Int64("tenantId", tenant.Id). 101 Bool("hasSecretStore", tenant.SecretStore != nil). 102 Bool("secretStoreIsConfigured", isSecretStoreConfigured(tenant.SecretStore)). 103 Float64("secretStoreExpiry", func() float64 { 104 if tenant.SecretStore != nil { 105 return tenant.SecretStore.Expiry 106 } 107 108 return 0 109 }()). 110 Dur("configuredTimeout", tm.timeout). 111 Msg("calculating tenant validity period") 112 113 // If secret store is configured but not properly set up, consider cache invalid immediately 114 if tenant.SecretStore != nil && !isSecretStoreConfigured(tenant.SecretStore) { 115 tm.logger.Warn(). 116 Int64("tenantId", tenant.Id). 117 Msg("secret store not properly configured, considering cache invalid") 118 119 return now 120 } 121 122 if tenant.SecretStore != nil && tenant.SecretStore.Expiry > 0 { 123 seconds, nanonseconds := math.Modf(tenant.SecretStore.Expiry) 124 // Subtract MaxScriptedTimeout to ensure the token is valid for the maximum running time. 125 expirationTime := time.Unix(int64(seconds), int64(nanonseconds*1e9)) 126 127 delta := expirationTime.Sub(now) 128 129 tm.logger.Debug(). 130 Int64("tenantId", tenant.Id). 131 Time("secretStoreExpirationTime", expirationTime). 132 Dur("deltaFromNow", delta). 133 Dur("maxScriptedTimeout", sm.MaxScriptedTimeout). 134 Msg("secret store expiry calculation") 135 136 switch { 137 case delta < 0: 138 // The token is already expired, return the current time 139 tm.logger.Warn(). 140 Int64("tenantId", tenant.Id). 141 Time("secretStoreExpirationTime", expirationTime). 142 Msg("secret store token already expired") 143 144 return now 145 146 case delta < sm.MaxScriptedTimeout: 147 // The token is valid for less than MaxScriptedTimeout, return the expiration time 148 tm.logger.Debug(). 149 Int64("tenantId", tenant.Id). 150 Time("validUntil", expirationTime). 151 Msg("using secret store expiration time (less than MaxScriptedTimeout)") 152 153 return expirationTime 154 155 default: 156 // Reduce delta by sm.MaxScriptedTimeout to ensure the token is valid for the maximum running time. 157 delta -= sm.MaxScriptedTimeout 158 159 // Pick the smallest value between the calculated delta and the configured timeout. 160 delta = min(delta, tm.timeout) 161 162 validUntil := now.Add(delta) 163 tm.logger.Debug(). 164 Int64("tenantId", tenant.Id). 165 Time("validUntil", validUntil). 166 Dur("calculatedDelta", delta). 167 Msg("using calculated validity period") 168 169 return validUntil 170 } 171 } 172 173 // In case where the expiration time is set based on the default timeout, 174 // apply a jitter to avoid many agents from hitting the API at the same time. 175 jitterDuration := time.Duration(rand.Float64() * defaultCacheJitter * float64(tm.timeout)) 176 validUntil := now.Add(tm.timeout).Add(jitterDuration) 177 178 tm.logger.Debug(). 179 Int64("tenantId", tenant.Id). 180 Time("validUntil", validUntil). 181 Msg("using configured timeout (no secret store expiry)") 182 183 return validUntil 184 } 185 186 func (tm *Manager) updateTenant(tenant sm.Tenant) { 187 // Use per-tenant mutex to serialize updates for the same tenant ID 188 mutex := tm.getFetchMutex(tenant.Id) 189 mutex.Lock() 190 defer mutex.Unlock() 191 192 ctx := context.Background() 193 key := cacheKey(tenant.Id) 194 195 // Check if there's already a cached tenant 196 var existing cachedTenant 197 198 err := tm.cache.Get(ctx, key, &existing) 199 200 // Only update if this tenant is newer than what's in the cache 201 if err == nil && existing.Tenant != nil && existing.Tenant.Modified >= tenant.Modified { 202 tm.logger.Debug(). 203 Int64("tenantId", tenant.Id). 204 Float64("existingModified", existing.Tenant.Modified). 205 Float64("newModified", tenant.Modified). 206 Msg("skipping tenant update, existing version is newer or equal") 207 208 return 209 } 210 211 // Validate secret store configuration 212 if tenant.SecretStore == nil { 213 tm.logger.Warn(). 214 Int64("tenantId", tenant.Id). 215 Msg("tenant received from API without secret store details") 216 } else if !isSecretStoreConfigured(tenant.SecretStore) { 217 tm.logger.Warn(). 218 Int64("tenantId", tenant.Id). 219 Msg("tenant received from API with incomplete secret store configuration") 220 } 221 222 // Calculate expiration time using existing logic 223 validUntil := tm.calculateValidUntil(&tenant) 224 225 // Create cached tenant 226 cached := cachedTenant{ 227 Tenant: &tenant, 228 ValidUntil: validUntil, 229 Modified: tenant.Modified, 230 } 231 232 // Store in cache without TTL - we'll check ValidUntil manually 233 // This allows us to return stale data if the API is unavailable 234 if err := tm.cache.Set(ctx, key, cached, 0); err != nil { 235 tm.logger.Error(). 236 Err(err). 237 Int64("tenantId", tenant.Id). 238 Msg("failed to update tenant in cache") 239 240 return 241 } 242 243 tm.logger.Debug(). 244 Int64("tenantId", tenant.Id). 245 Time("validUntil", validUntil). 246 Msg("tenant updated in cache") 247 } 248 249 // GetTenant retrieves the tenant specified by `req`, either from the cache 250 // or by making a request to the API. Notice that this method will favour 251 // returning expired tenant data from the cache if new data can not be retrieved 252 // from the API. 253 func (tm *Manager) GetTenant(ctx context.Context, req *sm.TenantInfo) (*sm.Tenant, error) { 254 key := cacheKey(req.Id) 255 now := time.Now() 256 257 // Try to get tenant from cache 258 var cached cachedTenant 259 260 err := tm.cache.Get(ctx, key, &cached) 261 262 // If found and still valid, return it 263 if err == nil && cached.Tenant != nil && cached.ValidUntil.After(now) { 264 tm.logger.Debug(). 265 Int64("tenantId", req.Id). 266 Time("validUntil", cached.ValidUntil). 267 Dur("validFor", cached.ValidUntil.Sub(now)). 268 Msg("returning tenant from cache") 269 270 return cached.Tenant, nil 271 } 272 273 // Cache miss or expired - need to fetch from API 274 // Use per-tenant mutex to prevent concurrent fetches for the same tenant 275 mutex := tm.getFetchMutex(req.Id) 276 mutex.Lock() 277 defer mutex.Unlock() 278 279 // Check cache again after acquiring lock (another goroutine might have fetched it) 280 err = tm.cache.Get(ctx, key, &cached) 281 if err == nil && cached.Tenant != nil && cached.ValidUntil.After(now) { 282 tm.logger.Debug(). 283 Int64("tenantId", req.Id). 284 Msg("tenant was fetched by another goroutine") 285 286 return cached.Tenant, nil 287 } 288 289 // Fetch from API 290 tenant, fetchErr := tm.tenantsClient.GetTenant(ctx, req) 291 292 // Treat every error in the same way, whether it's network or app related. 293 // As example of application errors: If the API has issues reaching the DB, 294 // we still don't want to block the agents. If the tenant is disabled, it 295 // should be propagated through other paths, and this component should act 296 // "silly" on it. 297 if fetchErr != nil { 298 // If we have stale cached data, return it as fallback 299 if err == nil && cached.Tenant != nil { 300 tm.logger.Warn(). 301 Err(fetchErr). 302 Int64("tenantId", req.Id). 303 Time("cachedValidUntil", cached.ValidUntil). 304 Msg("API fetch failed, returning stale cached data") 305 306 return cached.Tenant, nil 307 } 308 309 // No cached data available 310 tm.logger.Error(). 311 Err(fetchErr). 312 Int64("tenantId", req.Id). 313 Msg("failed to retrieve remote tenant information and no cached data available") 314 315 return nil, fetchErr 316 } 317 318 // Validate secret store configuration 319 if tenant.SecretStore == nil { 320 tm.logger.Warn(). 321 Int64("tenantId", req.Id). 322 Msg("tenant retrieved from API without secret store details") 323 } else if !isSecretStoreConfigured(tenant.SecretStore) { 324 tm.logger.Warn(). 325 Int64("tenantId", req.Id). 326 Msg("tenant retrieved from API with incomplete secret store configuration") 327 } 328 329 // Calculate expiration time using existing logic 330 validUntil := tm.calculateValidUntil(tenant) 331 332 // Create cached tenant 333 newCached := cachedTenant{ 334 Tenant: tenant, 335 ValidUntil: validUntil, 336 Modified: tenant.Modified, 337 } 338 339 // Store in cache without TTL - we'll check ValidUntil manually 340 // This allows us to return stale data if the API is unavailable 341 if err := tm.cache.Set(ctx, key, newCached, 0); err != nil { 342 tm.logger.Error(). 343 Err(err). 344 Int64("tenantId", req.Id). 345 Msg("failed to store tenant in cache") 346 // Don't fail the request if cache storage fails 347 } 348 349 tm.logger.Debug(). 350 Int64("tenantId", req.Id). 351 Time("validUntil", validUntil). 352 Msg("tenant retrieved from API") 353 354 return tenant, nil 355 }