From 9dd8c854489dc26e57d6018f158d0c792980fefd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Mon, 17 Nov 2025 09:48:17 +0100 Subject: [PATCH] feat(dashboards): add ttl based caching for datasources (#113999) --- .../pkg/migration/conversion/conversion.go | 5 ++ .../conversion/v1beta1_to_v2alpha1.go | 11 +--- .../schemaversion/datasource_utils.go | 57 ++++++++++++++----- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/conversion.go b/apps/dashboard/pkg/migration/conversion/conversion.go index 9ee376a15ba..75b6389ae4f 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion.go +++ b/apps/dashboard/pkg/migration/conversion/conversion.go @@ -12,6 +12,11 @@ import ( ) func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSourceIndexProvider) error { + // Wrap the provider once with 10s caching for all conversions. + // This prevents repeated DB queries across multiple conversion calls while allowing + // the cache to refresh periodically, making it suitable for long-lived singleton usage. + dsIndexProvider = schemaversion.WrapIndexProviderWithCache(dsIndexProvider) + // v0 conversions if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv1.Dashboard)(nil), withConversionMetrics(dashv0.APIVERSION, dashv1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error { diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 76a630ac0b6..6b8448867f1 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -85,21 +85,16 @@ func ConvertDashboard_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha out.APIVersion = dashv2alpha1.APIVERSION out.Kind = in.Kind - // Wrap the provider to ensure Index() is called only once during this conversion. - // This prevents multiple DB queries and index builds when the provider is used in multiple places - // (e.g., getDefaultDatasourceType, getDatasourceTypeByUID, panel datasource conversions, etc.) - dsIndexProviderWrapped := schemaversion.WrapIndexProviderWithOnce(dsIndexProvider) - // Prepare context with namespace and service identity - // The datasource provider is passed as a parameter (captured in closure when conversions are registered) - ctx, _, err := prepareV1beta1ConversionContext(in, dsIndexProviderWrapped) + // The datasource provider is already wrapped with caching at registration time + ctx, _, err := prepareV1beta1ConversionContext(in, dsIndexProvider) if err != nil { // If context preparation fails, return error to be handled by wrapper // The wrapper will set status and handle gracefully return fmt.Errorf("failed to prepare conversion context: %w", err) } - return convertDashboardSpec_V1beta1_to_V2alpha1(&in.Spec, &out.Spec, scope, ctx, dsIndexProviderWrapped) + return convertDashboardSpec_V1beta1_to_V2alpha1(&in.Spec, &out.Spec, scope, ctx, dsIndexProvider) } func convertDashboardSpec_V1beta1_to_V2alpha1(in *dashv1.DashboardSpec, out *dashv2alpha1.DashboardSpec, scope conversion.Scope, ctx context.Context, dsIndexProvider schemaversion.DataSourceIndexProvider) error { diff --git a/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go b/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go index b2020fa57cc..cc4e388bca3 100644 --- a/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go +++ b/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go @@ -3,44 +3,73 @@ package schemaversion import ( "context" "sync" + "time" ) // Shared utility functions for datasource migrations across different schema versions. // These functions handle the common logic for migrating datasource references from // string names/UIDs to structured reference objects with uid, type, and apiVersion. -// onceIndexProvider wraps a DataSourceIndexProvider to ensure Index() is only called once. +// cachedIndexProvider wraps a DataSourceIndexProvider with time-based caching. // This prevents multiple DB queries and index builds during operations that may call // provider.Index() multiple times (e.g., dashboard conversions with many datasource lookups). +// The cache expires after 10 seconds, allowing it to be used as a long-lived singleton +// while still refreshing periodically. // -// Thread-safe: Uses sync.Once to guarantee single execution even under concurrent access. -type onceIndexProvider struct { +// Thread-safe: Uses sync.RWMutex to guarantee safe concurrent access. +type cachedIndexProvider struct { provider DataSourceIndexProvider - once sync.Once + mu sync.RWMutex index *DatasourceIndex + cachedAt time.Time + cacheTTL time.Duration } -// Index returns the cached index, building it exactly once on first call. -func (p *onceIndexProvider) Index(ctx context.Context) *DatasourceIndex { - p.once.Do(func() { - p.index = p.provider.Index(ctx) - }) +// Index returns the cached index if it's still valid (< 10s old), otherwise rebuilds it. +// Uses RWMutex for efficient concurrent reads when cache is valid. +func (p *cachedIndexProvider) Index(ctx context.Context) *DatasourceIndex { + // Fast path: check if cache is still valid using read lock + p.mu.RLock() + if p.index != nil && time.Since(p.cachedAt) < p.cacheTTL { + idx := p.index + p.mu.RUnlock() + return idx + } + p.mu.RUnlock() + + // Slow path: cache expired or not yet built, acquire write lock + p.mu.Lock() + defer p.mu.Unlock() + + // Double-check: another goroutine might have refreshed the cache + // while we were waiting for the write lock + if p.index != nil && time.Since(p.cachedAt) < p.cacheTTL { + return p.index + } + + // Rebuild the cache + p.index = p.provider.Index(ctx) + p.cachedAt = time.Now() return p.index } -// WrapIndexProviderWithOnce wraps a provider to cache the index for a single operation. +// WrapIndexProviderWithCache wraps a provider to cache the index with a 10-second TTL. // Useful for conversions or migrations that may call provider.Index() multiple times. +// The cache expires after 10 seconds, making it suitable for use as a long-lived singleton +// at the top level of dependency injection while still refreshing periodically. // // Example usage in dashboard conversion: // -// onceDsIndexProvider := schemaversion.WrapIndexProviderWithOnce(dsIndexProvider) -// // Now all calls to onceDsIndexProvider.Index(ctx) return the same cached index -func WrapIndexProviderWithOnce(provider DataSourceIndexProvider) DataSourceIndexProvider { +// cachedDsIndexProvider := schemaversion.WrapIndexProviderWithCache(dsIndexProvider) +// // Now all calls to cachedDsIndexProvider.Index(ctx) return the same cached index +// // for up to 10 seconds before refreshing +func WrapIndexProviderWithCache(provider DataSourceIndexProvider) DataSourceIndexProvider { if provider == nil { return nil } - return &onceIndexProvider{ + return &cachedIndexProvider{ provider: provider, + cacheTTL: 10 * time.Second, } }