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  }