Skip to content

Context-aware instance manager #1352

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions backend/app/instance_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ import (
// InstanceFactoryFunc factory method for creating app instances.
type InstanceFactoryFunc func(ctx context.Context, settings backend.AppInstanceSettings) (instancemgmt.Instance, error)

// NewInstanceManager creates a new app instance manager,
// NewInstanceManager creates a new app instance manager.
//
// This is a helper method for calling NewInstanceProvider and creating a new instancemgmt.InstanceProvider,
// and providing that to instancemgmt.New.
// and providing that to instancemgmt.NewInstanceManagerWrapper.
func NewInstanceManager(fn InstanceFactoryFunc) instancemgmt.InstanceManager {
ip := NewInstanceProvider(fn)
return instancemgmt.New(ip)
return instancemgmt.NewInstanceManagerWrapper(ip)
}

// NewInstanceProvider create a new app instance provider,
Expand Down
2 changes: 1 addition & 1 deletion backend/datasource/instance_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type InstanceFactoryFunc func(ctx context.Context, settings backend.DataSourceIn
// and providing that to instancemgmt.New.
func NewInstanceManager(fn InstanceFactoryFunc) instancemgmt.InstanceManager {
ip := NewInstanceProvider(fn)
return instancemgmt.New(ip)
return instancemgmt.NewInstanceManagerWrapper(ip)
}

// NewInstanceProvider create a new data source instance provuder,
Expand Down
53 changes: 53 additions & 0 deletions backend/instancemgmt/context_aware_manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package instancemgmt

import (
"context"

"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles"
)

// NewInstanceManagerWrapper creates a new instance manager that dynamically selects
// between standard and TTL instance managers based on feature toggles from the Grafana config.
func NewInstanceManagerWrapper(provider InstanceProvider) InstanceManager {
return &instanceManagerWrapper{
provider: provider,
standardManager: New(provider),
ttlManager: NewTTLInstanceManager(provider),
}
}

// instanceManagerWrapper is a wrapper that dynamically selects the appropriate
// instance manager implementation based on feature toggles in the context.
type instanceManagerWrapper struct {
provider InstanceProvider
standardManager InstanceManager
ttlManager InstanceManager
}

// selectManager returns the appropriate instance manager based on the feature toggle
// from the Grafana config in the plugin context.
func (c *instanceManagerWrapper) selectManager(_ context.Context, pluginContext backend.PluginContext) InstanceManager {
// Check if TTL instance manager feature toggle is enabled
if pluginContext.GrafanaConfig != nil {
featureToggles := pluginContext.GrafanaConfig.FeatureToggles()
if featureToggles.IsEnabled(featuretoggles.TTLInstanceManager) {
return c.ttlManager
}
}

// Default to standard instance manager
return c.standardManager
}

// Get returns an Instance using the appropriate manager based on feature toggles.
func (c *instanceManagerWrapper) Get(ctx context.Context, pluginContext backend.PluginContext) (Instance, error) {
manager := c.selectManager(ctx, pluginContext)
return manager.Get(ctx, pluginContext)
}

// Do provides an Instance as argument to fn using the appropriate manager based on feature toggles.
func (c *instanceManagerWrapper) Do(ctx context.Context, pluginContext backend.PluginContext, fn InstanceCallbackFunc) error {
manager := c.selectManager(ctx, pluginContext)
return manager.Do(ctx, pluginContext, fn)
}
115 changes: 115 additions & 0 deletions backend/instancemgmt/context_aware_manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package instancemgmt

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles"
)

func TestInstanceManagerWrapper(t *testing.T) {
ctx := context.Background()
tip := &testInstanceProvider{}
im := NewInstanceManagerWrapper(tip)

t.Run("Should use standard manager when feature toggle is disabled", func(t *testing.T) {
pCtx := backend.PluginContext{
OrgID: 1,
AppInstanceSettings: &backend.AppInstanceSettings{
Updated: time.Now(),
},
GrafanaConfig: backend.NewGrafanaCfg(map[string]string{
featuretoggles.EnabledFeatures: "",
}),
}

manager := im.(*instanceManagerWrapper).selectManager(ctx, pCtx)
require.IsType(t, &instanceManager{}, manager)
})

t.Run("Should use TTL manager when feature toggle is enabled", func(t *testing.T) {
pCtx := backend.PluginContext{
OrgID: 1,
AppInstanceSettings: &backend.AppInstanceSettings{
Updated: time.Now(),
},
GrafanaConfig: backend.NewGrafanaCfg(map[string]string{
featuretoggles.EnabledFeatures: featuretoggles.TTLInstanceManager,
}),
}

manager := im.(*instanceManagerWrapper).selectManager(ctx, pCtx)
require.IsType(t, &instanceManagerWithTTL{}, manager)
})

t.Run("Should use standard manager when GrafanaConfig is nil", func(t *testing.T) {
pCtx := backend.PluginContext{
OrgID: 1,
AppInstanceSettings: &backend.AppInstanceSettings{
Updated: time.Now(),
},
GrafanaConfig: nil,
}

manager := im.(*instanceManagerWrapper).selectManager(ctx, pCtx)
require.IsType(t, &instanceManager{}, manager)
})

t.Run("Should use TTL manager when feature toggle is enabled with other flags", func(t *testing.T) {
pCtx := backend.PluginContext{
OrgID: 1,
AppInstanceSettings: &backend.AppInstanceSettings{
Updated: time.Now(),
},
GrafanaConfig: backend.NewGrafanaCfg(map[string]string{
featuretoggles.EnabledFeatures: "someOtherFlag," + featuretoggles.TTLInstanceManager + ",anotherFlag",
}),
}

manager := im.(*instanceManagerWrapper).selectManager(ctx, pCtx)
require.IsType(t, &instanceManagerWithTTL{}, manager)
})

t.Run("Should delegate Get calls correctly", func(t *testing.T) {
// Test with TTL manager enabled
pCtx := backend.PluginContext{
OrgID: 1,
AppInstanceSettings: &backend.AppInstanceSettings{
Updated: time.Now(),
},
GrafanaConfig: backend.NewGrafanaCfg(map[string]string{
featuretoggles.EnabledFeatures: featuretoggles.TTLInstanceManager,
}),
}

instance, err := im.Get(ctx, pCtx)
require.NoError(t, err)
require.NotNil(t, instance)
require.Equal(t, pCtx.OrgID, instance.(*testInstance).orgID)
})

t.Run("Should delegate Do calls correctly", func(t *testing.T) {
// Test with standard manager (no feature toggle)
pCtx := backend.PluginContext{
OrgID: 2,
AppInstanceSettings: &backend.AppInstanceSettings{
Updated: time.Now(),
},
GrafanaConfig: backend.NewGrafanaCfg(map[string]string{
featuretoggles.EnabledFeatures: "",
}),
}

var receivedInstance *testInstance
err := im.Do(ctx, pCtx, func(instance *testInstance) {
receivedInstance = instance
})
require.NoError(t, err)
require.NotNil(t, receivedInstance)
require.Equal(t, pCtx.OrgID, receivedInstance.orgID)
})
}
17 changes: 17 additions & 0 deletions backend/instancemgmt/doc.go
Original file line number Diff line number Diff line change
@@ -1,2 +1,19 @@
// Package instancemgmt provides utilities for managing plugin instances.
//
// This package offers several instance manager implementations:
//
// 1. Standard Instance Manager (New): Uses sync.Map for caching instances
// and disposes them when they need updates.
//
// 2. TTL Instance Manager (NewTTLInstanceManager): Uses TTL-based caching
// that automatically evicts instances after a configurable time period.
//
// 3. Instance Manager Wrapper (NewInstanceManagerWrapper):
// Dynamically selects between standard and TTL managers based on
// feature toggles from the Grafana config in the context.
//
// The context-aware manager checks the "ttlInstanceManager" feature toggle
// from the Grafana configuration and automatically uses the appropriate
// underlying implementation. This allows runtime switching without requiring
// plugin restarts or static configuration.
package instancemgmt
37 changes: 29 additions & 8 deletions backend/instancemgmt/instance_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ var (
Name: "active_instances",
Help: "The number of active plugin instances",
})
disposeTTL = 5 * time.Second
)

const defaultDisposeTTL = 5 * time.Second // Time to wait before disposing an instance

// Instance is a marker interface for an instance.
type Instance interface{}

Expand Down Expand Up @@ -73,21 +74,33 @@ type InstanceProvider interface {

// New create a new instance manager.
func New(provider InstanceProvider) InstanceManager {
return newInstanceManager(provider, defaultDisposeTTL)
}

func newInstanceManager(provider InstanceProvider, disposeTTL time.Duration) *instanceManager {
if provider == nil {
panic("provider cannot be nil")
}

if disposeTTL <= 0 {
disposeTTL = defaultDisposeTTL
}

return &instanceManager{
provider: provider,
cache: sync.Map{},
locker: newLocker(),
provider: provider,
cache: sync.Map{},
locker: newLocker(),
disposeTTL: disposeTTL,
}
}

type instanceManager struct {
locker *locker
provider InstanceProvider
cache sync.Map
locker *locker
provider InstanceProvider
cache sync.Map
disposeTTL time.Duration

disposeMutex sync.Mutex // Mutex to protect disposeTTL access in tests
}

func (im *instanceManager) Get(ctx context.Context, pluginContext backend.PluginContext) (Instance, error) {
Expand Down Expand Up @@ -121,7 +134,7 @@ func (im *instanceManager) Get(ctx context.Context, pluginContext backend.Plugin
}

if disposer, valid := ci.instance.(InstanceDisposer); valid {
time.AfterFunc(disposeTTL, func() {
time.AfterFunc(im.disposeTTL, func() {
disposer.Dispose()
activeInstances.Dec()
})
Expand Down Expand Up @@ -157,6 +170,14 @@ func (im *instanceManager) Do(ctx context.Context, pluginContext backend.PluginC
return nil
}

// setDisposeTTL sets the TTL for disposing instances.
// This method is only used for testing purposes to control the dispose timing behavior.
func (im *instanceManager) setDisposeTTL(ttl time.Duration) {
im.disposeMutex.Lock()
defer im.disposeMutex.Unlock()
im.disposeTTL = ttl
}

func callInstanceHandlerFunc(fn InstanceCallbackFunc, instance interface{}) {
var params = []reflect.Value{}
params = append(params, reflect.ValueOf(instance))
Expand Down
19 changes: 5 additions & 14 deletions backend/instancemgmt/instance_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import (
"testing"
"time"

"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/stretchr/testify/require"

"github.com/grafana/grafana-plugin-sdk-go/backend"
)

func TestInstanceManager(t *testing.T) {
Expand All @@ -21,7 +22,7 @@ func TestInstanceManager(t *testing.T) {
}

tip := &testInstanceProvider{}
im := New(tip)
im := newInstanceManager(tip, time.Millisecond)

t.Run("When getting instance should create a new instance", func(t *testing.T) {
instance, err := im.Get(ctx, pCtx)
Expand All @@ -43,11 +44,6 @@ func TestInstanceManager(t *testing.T) {
Updated: time.Now(),
},
}
origDisposeTTL := disposeTTL
disposeTTL = time.Millisecond
t.Cleanup(func() {
disposeTTL = origDisposeTTL
})
newInstance, err := im.Get(ctx, pCtxUpdated)

t.Run("New instance should be created", func(t *testing.T) {
Expand Down Expand Up @@ -110,12 +106,6 @@ func TestInstanceManagerConcurrency(t *testing.T) {
})

t.Run("Check possible race condition issues when re-creating instance on settings update", func(t *testing.T) {
origDisposeTTL := disposeTTL
disposeTTL = time.Millisecond
t.Cleanup(func() {
disposeTTL = origDisposeTTL
})

ctx := context.Background()
initialPCtx := backend.PluginContext{
OrgID: 1,
Expand All @@ -124,7 +114,8 @@ func TestInstanceManagerConcurrency(t *testing.T) {
},
}
tip := &testInstanceProvider{}
im := New(tip)
im := New(tip).(*instanceManager)
im.setDisposeTTL(time.Millisecond)
// Creating initial instance with old contexts
instanceToDispose, _ := im.Get(ctx, initialPCtx)

Expand Down
Loading