Storage: Add ManagedDualWriter mode (#100231)

This commit is contained in:
Ryan McKinley authored and GitHub committed 2025-02-12 15:51:37 +03:00
1 parent c4e32680b0
commit befa028784
14 files changed
+511

No files matched your search

@@ -214,6 +214,7 @@ export interface FeatureToggles {
unifiedStorageSearch?: boolean;
unifiedStorageSearchSprinkles?: boolean;
unifiedStorageSearchPermissionFiltering?: boolean;
managedDualWriter?: boolean;
pluginsSriChecks?: boolean;
unifiedStorageBigObjectsSupport?: boolean;
timeRangeProvider?: boolean;
+2
View File
@@ -157,6 +157,7 @@ import (
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/userimpl"
"github.com/grafana/grafana/pkg/setting"
legacydualwrite "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified"
"github.com/grafana/grafana/pkg/storage/unified/resource"
unifiedsearch "github.com/grafana/grafana/pkg/storage/unified/search"
@@ -215,6 +216,7 @@ var wireBasicSet = wire.NewSet(
mssql.ProvideService,
store.ProvideEntityEventsService,
unified.ProvideUnifiedStorageClient,
legacydualwrite.ProvideService,
httpclientprovider.New,
wire.Bind(new(httpclient.Provider), new(*sdkhttpclient.Provider)),
serverlock.ProvideService,
+7
View File
@@ -33,6 +33,7 @@ import (
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/apiserver/options"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
)
@@ -276,6 +277,7 @@ func InstallAPIs(
namespaceMapper request.NamespaceMapper,
kvStore grafanarest.NamespacedKVStore,
serverLock ServerLockService,
dualWriteService dualwrite.Service,
optsregister apistore.StorageOptionsRegister,
) error {
// dual writing is only enabled when the storage type is not legacy.
@@ -286,6 +288,11 @@ func InstallAPIs(
// nolint:staticcheck
if storageOpts.StorageType != options.StorageTypeLegacy {
dualWrite = func(gr schema.GroupResource, legacy grafanarest.LegacyStorage, storage grafanarest.Storage) (grafanarest.Storage, error) {
// Dashboards + Folders may be managed (depends on feature toggles and database state)
if dualWriteService.ShouldManage(gr) {
return dualWriteService.NewStorage(gr, legacy, storage) // eventually this can replace this whole function
}
key := gr.String() // ${resource}.{group} eg playlists.playlist.grafana.app
// Get the option from custom.ini/command line
+5
View File
@@ -47,6 +47,7 @@ import (
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
@@ -137,6 +138,7 @@ type service struct {
authorizer *authorizer.GrafanaAuthorizer
serverLockService builder.ServerLockService
storageStatus dualwrite.Service
kvStore kvstore.KVStore
pluginClient plugins.Client
@@ -159,6 +161,7 @@ func ProvideService(
datasources datasource.ScopedPluginDatasourceProvider,
contextProvider datasource.PluginContextWrapper,
pluginStore pluginstore.Store,
storageStatus dualwrite.Service,
unified resource.ResourceClient,
) (*service, error) {
s := &service{
@@ -178,6 +181,7 @@ func ProvideService(
contextProvider: contextProvider,
pluginStore: pluginStore,
serverLockService: serverLockService,
storageStatus: storageStatus,
unified: unified,
}
// This will be used when running as a dskit service
@@ -361,6 +365,7 @@ func (s *service) start(ctx context.Context) error {
// Required for the dual writer initialization
s.metrics, request.GetNamespaceMapper(s.cfg), kvstore.WithNamespace(s.kvStore, 0, "storage.dualwriting"),
s.serverLockService,
s.storageStatus,
optsregister,
)
if err != nil {
+8
View File
@@ -1483,6 +1483,14 @@ var (
HideFromDocs: true,
HideFromAdminPage: true,
},
{
Name: "managedDualWriter",
Description: "Pick the dual write mode from database configs",
Stage: FeatureStageExperimental,
Owner: grafanaSearchAndStorageSquad,
HideFromDocs: true,
HideFromAdminPage: true,
},
{
Name: "pluginsSriChecks",
Description: "Enables SRI checks for plugin assets",
+1
View File
@@ -195,6 +195,7 @@ rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false
unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false
unifiedStorageSearchPermissionFiltering,experimental,@grafana/search-and-storage,false,false,false
managedDualWriter,experimental,@grafana/search-and-storage,false,false,false
pluginsSriChecks,experimental,@grafana/plugins-platform-backend,false,false,false
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
195 unifiedStorageSearch experimental @grafana/search-and-storage false false false
196 unifiedStorageSearchSprinkles experimental @grafana/search-and-storage false false false
197 unifiedStorageSearchPermissionFiltering experimental @grafana/search-and-storage false false false
198 managedDualWriter experimental @grafana/search-and-storage false false false
199 pluginsSriChecks experimental @grafana/plugins-platform-backend false false false
200 unifiedStorageBigObjectsSupport experimental @grafana/search-and-storage false false false
201 timeRangeProvider experimental @grafana/grafana-frontend-platform false false false
+4
View File
@@ -791,6 +791,10 @@ const (
// Enable permission filtering on unified storage search
FlagUnifiedStorageSearchPermissionFiltering = "unifiedStorageSearchPermissionFiltering"
// FlagManagedDualWriter
// Pick the dual write mode from database configs
FlagManagedDualWriter = "managedDualWriter"
// FlagPluginsSriChecks
// Enables SRI checks for plugin assets
FlagPluginsSriChecks = "pluginsSriChecks"
+14
View File
@@ -2611,6 +2611,20 @@
"expression": "true"
}
},
{
"metadata": {
"name": "managedDualWriter",
"resourceVersion": "1739347092893",
"creationTimestamp": "2025-02-12T07:58:12Z"
},
"spec": {
"description": "Pick the dual write mode from database configs",
"stage": "experimental",
"codeowner": "@grafana/search-and-storage",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "managedPluginsInstall",
@@ -0,0 +1,134 @@
package dualwrite
import (
"context"
"net/http"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
)
func (m *service) NewStorage(gr schema.GroupResource,
legacy grafanarest.LegacyStorage,
storage grafanarest.Storage,
) (grafanarest.Storage, error) {
status, _ := m.Status(context.Background(), gr)
// Support startup modes without special
if !status.Runtime {
if status.ReadUnified {
if status.WriteLegacy {
// Write both, read unified
return grafanarest.NewDualWriter(grafanarest.Mode3, legacy, storage, m.reg, gr.String()), nil
}
return storage, nil
}
if status.WriteUnified {
// Write both, read legacy
return grafanarest.NewDualWriter(grafanarest.Mode2, legacy, storage, m.reg, gr.String()), nil
}
return legacy, nil
}
return &mangedMode3{
service: m,
legacy: legacy,
unified: storage,
target: grafanarest.NewDualWriter(grafanarest.Mode3, legacy, storage, m.reg, gr.String()),
gr: gr,
}, nil
}
type mangedMode3 struct {
service Service
legacy grafanarest.LegacyStorage
unified grafanarest.Storage
target grafanarest.Storage
gr schema.GroupResource
}
func (d *mangedMode3) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
if d.service.ReadFromUnified(ctx, d.gr) {
return d.unified.Get(ctx, name, options)
}
return d.legacy.Get(ctx, name, options)
}
func (d *mangedMode3) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) {
if d.service.ReadFromUnified(ctx, d.gr) {
return d.unified.List(ctx, options)
}
return d.legacy.List(ctx, options)
}
func (d *mangedMode3) isMigrating(ctx context.Context) error {
status, ok := d.service.Status(ctx, d.gr)
if ok && status.Migrating > 0 {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Code: http.StatusServiceUnavailable,
Message: "the system is migrating",
},
}
}
return nil
}
func (d *mangedMode3) Create(ctx context.Context, in runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
if err := d.isMigrating(ctx); err != nil {
return nil, err
}
return d.target.Create(ctx, in, createValidation, options)
}
func (d *mangedMode3) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
if err := d.isMigrating(ctx); err != nil {
return nil, false, err
}
return d.target.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
}
func (d *mangedMode3) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
if err := d.isMigrating(ctx); err != nil {
return nil, false, err
}
return d.target.Delete(ctx, name, deleteValidation, options)
}
// DeleteCollection overrides the behavior of the generic DualWriter and deletes from both LegacyStorage and Storage.
func (d *mangedMode3) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) {
if err := d.isMigrating(ctx); err != nil {
return nil, err
}
return d.target.DeleteCollection(ctx, deleteValidation, options, listOptions)
}
func (d *mangedMode3) Destroy() {
d.target.Destroy()
}
func (d *mangedMode3) GetSingularName() string {
return d.target.GetSingularName()
}
func (d *mangedMode3) NamespaceScoped() bool {
return d.target.NamespaceScoped()
}
func (d *mangedMode3) New() runtime.Object {
return d.target.New()
}
func (d *mangedMode3) NewList() runtime.Object {
return d.target.NewList()
}
func (d *mangedMode3) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return d.target.ConvertToTable(ctx, object, tableOptions)
}
+142
View File
@@ -0,0 +1,142 @@
package dualwrite
import (
"context"
"fmt"
"path/filepath"
"time"
"github.com/prometheus/client_golang/prometheus"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
func ProvideService(features featuremgmt.FeatureToggles, reg prometheus.Registerer, cfg *setting.Cfg) Service {
path := "" // storage path
if cfg != nil {
path = filepath.Join(cfg.DataPath, "dualwrite.json")
}
return &service{
db: newFileDB(path),
reg: reg,
enabled: features.IsEnabledGlobally(featuremgmt.FlagManagedDualWriter),
// TODO: when we can "export" from legacy, this can enabled along with provisioning
// || features.IsEnabledGlobally(featuremgmt.FlagProvisioning), // required for git provisioning
}
}
type service struct {
db statusStorage
reg prometheus.Registerer
enabled bool
}
// The storage interface has zero business logic and simply writes values to a database
type statusStorage interface {
Get(ctx context.Context, gr schema.GroupResource) (StorageStatus, bool)
Set(ctx context.Context, status StorageStatus) error
}
// Hardcoded list of resources that should be controlled by the database (eventually everything?)
func (m *service) ShouldManage(gr schema.GroupResource) bool {
if !m.enabled {
return false
}
switch gr.String() {
case "folders.folder.grafana.app":
return true
case "dashboards.dashboard.grafana.app":
return true
}
return false
}
func (m *service) ReadFromUnified(ctx context.Context, gr schema.GroupResource) bool {
v, ok := m.db.Get(ctx, gr)
return ok && v.ReadUnified
}
// Status implements Service.
func (m *service) Status(ctx context.Context, gr schema.GroupResource) (StorageStatus, bool) {
v, found := m.db.Get(ctx, gr)
if !found {
v = StorageStatus{
Group: gr.Group,
Resource: gr.Resource,
WriteLegacy: true,
WriteUnified: true,
ReadUnified: false,
Migrated: 0,
Migrating: 0,
Runtime: true, // need to explicitly ask for not runtime
UpdateKey: 1,
}
_ = m.db.Set(ctx, v) // write the value
return v, false
}
return v, found
}
// StartMigration implements Service.
func (m *service) StartMigration(ctx context.Context, gr schema.GroupResource, key int64) (StorageStatus, error) {
now := time.Now().UnixMilli()
v, ok := m.db.Get(ctx, gr)
if ok {
if v.Migrated > 0 {
return v, fmt.Errorf("already migrated")
}
if key != v.UpdateKey {
return v, fmt.Errorf("key mismatch")
}
if v.Migrating > 0 {
return v, fmt.Errorf("migration in progress")
}
v.Migrating = now
v.UpdateKey++
} else {
v = StorageStatus{
Group: gr.Group,
Resource: gr.Resource,
Runtime: true,
WriteLegacy: true,
WriteUnified: true,
ReadUnified: false,
Migrating: now,
Migrated: 0, // timestamp
UpdateKey: 1,
}
}
err := m.db.Set(ctx, v)
return v, err
}
// FinishMigration implements Service.
func (m *service) Update(ctx context.Context, status StorageStatus) (StorageStatus, error) {
v, ok := m.db.Get(ctx, schema.GroupResource{Group: status.Group, Resource: status.Resource})
if !ok {
return v, fmt.Errorf("no running status")
}
if status.UpdateKey != v.UpdateKey {
return v, fmt.Errorf("key mismatch")
}
if status.Migrating > 0 {
return v, fmt.Errorf("update can not change migrating status")
}
if status.ReadUnified {
if status.Migrated == 0 {
return v, fmt.Errorf("can not read from unified before a migration")
}
if !status.WriteUnified {
return v, fmt.Errorf("must write to unified when reading from unified")
}
}
if !status.WriteLegacy && !status.WriteUnified {
return v, fmt.Errorf("must write either legacy or unified")
}
status.UpdateKey++
return status, m.db.Set(ctx, status)
}
@@ -0,0 +1,46 @@
package dualwrite
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
func TestService(t *testing.T) {
ctx := context.Background()
mode := ProvideService(featuremgmt.WithFeatures(), nil, nil)
gr := schema.GroupResource{Group: "ggg", Resource: "rrr"}
status, found := mode.Status(ctx, gr)
require.False(t, found, "initially not found")
require.Equal(t, StorageStatus{
Group: "ggg",
Resource: "rrr",
WriteLegacy: true,
WriteUnified: true,
ReadUnified: false,
Migrated: 0,
Migrating: 0,
Runtime: true,
UpdateKey: 1,
}, status, "should start with the right defaults")
// Start migration
status, err := mode.StartMigration(ctx, gr, 1)
require.NoError(t, err)
require.Equal(t, status.UpdateKey, int64(2), "the key increased")
require.True(t, status.Migrating > 0, "migration is running")
status.Migrated = time.Now().UnixMilli()
status.Migrating = 0
status, err = mode.Update(ctx, status)
require.NoError(t, err)
require.Equal(t, status.UpdateKey, int64(3), "the key increased")
require.Equal(t, status.Migrating, int64(0), "done migrating")
require.True(t, status.Migrated > 0, "migration is running")
}
@@ -0,0 +1,75 @@
package dualwrite
import (
"context"
"encoding/json"
"os"
"sync"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana-app-sdk/logging"
)
// Simple file implementation -- useful while testing and not yet sure about the SQL structure!
// When a path exists, read/write it from disk
type fileDB struct {
path string
changed int64
db map[string]StorageStatus
mu sync.RWMutex
logger logging.Logger
}
// File implementation while testing -- values are saved in the data directory
func newFileDB(path string) *fileDB {
return &fileDB{
db: make(map[string]StorageStatus),
path: path,
logger: logging.DefaultLogger.With("logger", "fileDB"),
}
}
func (m *fileDB) Get(ctx context.Context, gr schema.GroupResource) (StorageStatus, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
info, err := os.Stat(m.path)
if err == nil && info.ModTime().UnixMilli() != m.changed {
v, err := os.ReadFile(m.path)
if err == nil {
err = json.Unmarshal(v, &m.db)
m.changed = info.ModTime().UnixMilli()
}
if err != nil {
m.logger.Warn("error reading filedb", "err", err)
}
}
v, ok := m.db[gr.String()]
return v, ok
}
func (m *fileDB) Set(ctx context.Context, status StorageStatus) error {
m.mu.Lock()
defer m.mu.Unlock()
gr := schema.GroupResource{
Group: status.Group,
Resource: status.Resource,
}
m.db[gr.String()] = status
if m.path != "" {
data, err := json.MarshalIndent(m.db, "", " ")
if err != nil {
return err
}
err = os.WriteFile(m.path, data, 0644)
if err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,20 @@
package dualwrite
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func AddUnifiedStatusMigrations(mg *migrator.Migrator) {
resourceStorageStatus := migrator.Table{
Name: "resource_storage_status",
Columns: []*migrator.Column{
{Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false},
{Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false},
{Name: "migrated", Type: migrator.DB_BigInt, Nullable: false}, // Timestamp when we can start trusting unified storage
{Name: "migrating", Type: migrator.DB_BigInt, Nullable: false}, // Actively running a migration (start timestamp)
{Name: "update_key", Type: migrator.DB_BigInt, Nullable: false}, // optimistic lock key -- required for update
},
Indices: []*migrator.Index{
{Cols: []string{"group", "resource", "namespace"}, Type: migrator.UniqueIndex},
},
}
mg.AddMigration("create resource_storage_status table", migrator.NewAddTableMigration(resourceStorageStatus))
}
+52
View File
@@ -0,0 +1,52 @@
package dualwrite
import (
"context"
"k8s.io/apimachinery/pkg/runtime/schema"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
)
// For *legacy* services, this will indicate if we have transitioned to Unified storage yet
type StorageStatus struct {
Group string `json:"group" xorm:"group"`
Resource string `json:"resource" xorm:"resource"`
WriteLegacy bool `json:"write_legacy" xorm:"write_legacy"`
WriteUnified bool `json:"write_unified" xorm:"write_unified"`
// Unified is the primary source (legacy may be secondary)
ReadUnified bool `json:"read_unified" xorm:"read_unified"`
// Timestamp when a migration finished
Migrated int64 `json:"migrated" xorm:"migrated"`
// Timestamp when a migration *started* this should be cleared when finished
// While migrating all write commands will be unavaliable
Migrating int64 `json:"migrating" xorm:"migrating"`
// When false, the behavior will not change at runtime
Runtime bool `json:"runtime" xorm:"runtime"`
// UpdateKey used for optimistic locking -- requests to change the status must match previous value
UpdateKey int64 `json:"update_key" xorm:"update_key"`
}
type Service interface {
ShouldManage(gr schema.GroupResource) bool
// Create a managed k8s storage instance
NewStorage(gr schema.GroupResource, legacy grafanarest.LegacyStorage, storage grafanarest.Storage) (grafanarest.Storage, error)
// Check if the dual writes is reading from unified storage (mode3++)
ReadFromUnified(ctx context.Context, gr schema.GroupResource) bool
// Get status details for a Group/Resource
Status(ctx context.Context, gr schema.GroupResource) (StorageStatus, bool)
// Start a migration process (writes will be locked)
StartMigration(ctx context.Context, gr schema.GroupResource, key int64) (StorageStatus, error)
// change the status (finish migration etc)
Update(ctx context.Context, status StorageStatus) (StorageStatus, error)
}