feat: enable auto migration based on resource count (#115619)

* feat(unified): migration at startup based on resource count

-- draft

* feat: introduce auto migration enablement for dashboards & folders

* feat: enable auto migration based on threshold

* fix: improve

* fix: pass in the auto migrate per migration definition

* fix: minor

* fix: only use one options

* fix: test

* fix: test

* fix: tests

* fix: simplify configs

* chore: rename

* fix: add integration test

* fix: add integration test

* fix: integration tests

* chore: add comments

* fix: address comment

* fix: address comments

* fix: test and auto migration flow

* fix: test

---------

Co-authored-by: Rafael Paulovic <rafael.paulovic@grafana.com>
This commit is contained in:
Mustafa Sencer Özcan
2026-01-08 13:30:40 +01:00
committed by GitHub
co-authored by Rafael Paulovic
parent 9e8bdee283
commit 088bab8b38
11 changed files with 759 additions and 114 deletions
+222 -36
View File
@@ -1,11 +1,13 @@
package migrations
import (
"context"
"fmt"
v1beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
playlists "github.com/grafana/grafana/apps/playlist/pkg/apis/playlist/v0alpha1"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
@@ -14,69 +16,70 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
)
type ResourceDefinition struct {
GroupResource schema.GroupResource
MigratorFunc string // Name of the method: "MigrateFolders", "MigrateDashboards", etc.
type resourceDefinition struct {
groupResource schema.GroupResource
migratorFunc string // Name of the method: "MigrateFolders", "MigrateDashboards", etc.
}
type migrationDefinition struct {
name string
migrationID string // The ID stored in the migration log table (e.g., "playlists migration")
resources []string
registerFunc func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient)
registerFunc func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient, opts ...ResourceMigrationOption)
}
var resourceRegistry = []ResourceDefinition{
var resourceRegistry = []resourceDefinition{
{
GroupResource: schema.GroupResource{Group: folders.GROUP, Resource: folders.RESOURCE},
MigratorFunc: "MigrateFolders",
groupResource: schema.GroupResource{Group: folders.GROUP, Resource: folders.RESOURCE},
migratorFunc: "MigrateFolders",
},
{
GroupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.LIBRARY_PANEL_RESOURCE},
MigratorFunc: "MigrateLibraryPanels",
groupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.LIBRARY_PANEL_RESOURCE},
migratorFunc: "MigrateLibraryPanels",
},
{
GroupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.DASHBOARD_RESOURCE},
MigratorFunc: "MigrateDashboards",
groupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.DASHBOARD_RESOURCE},
migratorFunc: "MigrateDashboards",
},
{
GroupResource: schema.GroupResource{Group: playlists.APIGroup, Resource: "playlists"},
MigratorFunc: "MigratePlaylists",
groupResource: schema.GroupResource{Group: playlists.APIGroup, Resource: "playlists"},
migratorFunc: "MigratePlaylists",
},
}
var migrationRegistry = []migrationDefinition{
{
name: "playlists",
migrationID: "playlists migration",
resources: []string{setting.PlaylistResource},
registerFunc: registerPlaylistMigration,
},
{
name: "folders and dashboards",
migrationID: "folders and dashboards migration",
resources: []string{setting.FolderResource, setting.DashboardResource},
registerFunc: registerDashboardAndFolderMigration,
},
}
func registerMigrations(cfg *setting.Cfg, mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) error {
func registerMigrations(ctx context.Context,
cfg *setting.Cfg,
mg *sqlstoremigrator.Migrator,
migrator UnifiedMigrator,
client resource.ResourceClient,
sqlStore db.DB,
) error {
for _, migration := range migrationRegistry {
var (
hasValue bool
allEnabled bool
)
for _, res := range migration.resources {
enabled := cfg.UnifiedStorage[res].EnableMigration
if !hasValue {
allEnabled = enabled
hasValue = true
continue
}
if enabled != allEnabled {
return fmt.Errorf("cannot migrate resources separately: %v migration must be either all enabled or all disabled", migration.resources)
}
if shouldAutoMigrate(ctx, migration, cfg, sqlStore) {
migration.registerFunc(mg, migrator, client, WithAutoMigrate(cfg))
continue
}
if !allEnabled {
enabled, err := isMigrationEnabled(migration, cfg)
if err != nil {
return err
}
if !enabled {
logger.Info("Migration is disabled in config, skipping", "migration", migration.name)
continue
}
@@ -85,10 +88,193 @@ func registerMigrations(cfg *setting.Cfg, mg *sqlstoremigrator.Migrator, migrato
return nil
}
func getResourceDefinition(group, resource string) *ResourceDefinition {
func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator,
migrator UnifiedMigrator,
client resource.ResourceClient,
opts ...ResourceMigrationOption,
) {
foldersDef := getResourceDefinition("folder.grafana.app", "folders")
dashboardsDef := getResourceDefinition("dashboard.grafana.app", "dashboards")
driverName := mg.Dialect.DriverName()
folderCountValidator := NewCountValidator(
client,
foldersDef.groupResource,
"dashboard",
"org_id = ? and is_folder = true",
driverName,
)
dashboardCountValidator := NewCountValidator(
client,
dashboardsDef.groupResource,
"dashboard",
"org_id = ? and is_folder = false",
driverName,
)
folderTreeValidator := NewFolderTreeValidator(client, foldersDef.groupResource, driverName)
dashboardsAndFolders := NewResourceMigration(
migrator,
[]schema.GroupResource{foldersDef.groupResource, dashboardsDef.groupResource},
"folders-dashboards",
[]Validator{folderCountValidator, dashboardCountValidator, folderTreeValidator},
opts...,
)
mg.AddMigration("folders and dashboards migration", dashboardsAndFolders)
}
func registerPlaylistMigration(mg *sqlstoremigrator.Migrator,
migrator UnifiedMigrator,
client resource.ResourceClient,
opts ...ResourceMigrationOption,
) {
playlistsDef := getResourceDefinition("playlist.grafana.app", "playlists")
driverName := mg.Dialect.DriverName()
playlistCountValidator := NewCountValidator(
client,
playlistsDef.groupResource,
"playlist",
"org_id = ?",
driverName,
)
playlistsMigration := NewResourceMigration(
migrator,
[]schema.GroupResource{playlistsDef.groupResource},
"playlists",
[]Validator{playlistCountValidator},
opts...,
)
mg.AddMigration("playlists migration", playlistsMigration)
}
// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613
func shouldAutoMigrate(ctx context.Context, migration migrationDefinition, cfg *setting.Cfg, sqlStore db.DB) bool {
autoMigrate := false
for _, res := range migration.resources {
config := cfg.UnifiedStorageConfig(res)
if config.DualWriterMode == 5 {
return false
}
if !setting.AutoMigratedUnifiedResources[res] {
continue
}
if checkIfAlreadyMigrated(ctx, migration, sqlStore) {
for _, res := range migration.resources {
cfg.EnableMode5(res)
}
logger.Info("Auto-migration already completed, enabling mode 5 for resources", "migration", migration.name)
return true
}
autoMigrate = true
threshold := int64(setting.DefaultAutoMigrationThreshold)
if config.AutoMigrationThreshold > 0 {
threshold = int64(config.AutoMigrationThreshold)
}
count, err := countResource(ctx, sqlStore, res)
if err != nil {
logger.Warn("Failed to count resource for auto migration check", "resource", res, "error", err)
return false
}
logger.Info("Resource count for auto migration check", "resource", res, "count", count, "threshold", threshold)
if count > threshold {
return false
}
}
if !autoMigrate {
return false
}
logger.Info("Auto-migration enabled for migration", "migration", migration.name)
return true
}
func checkIfAlreadyMigrated(ctx context.Context, migration migrationDefinition, sqlStore db.DB) bool {
if migration.migrationID == "" {
return false
}
exists, err := migrationExists(ctx, sqlStore, migration.migrationID)
if err != nil {
logger.Warn("Failed to check if migration exists", "migration", migration.name, "error", err)
return false
}
return exists
}
func isMigrationEnabled(migration migrationDefinition, cfg *setting.Cfg) (bool, error) {
var (
hasValue bool
allEnabled bool
)
for _, res := range migration.resources {
enabled := cfg.UnifiedStorage[res].EnableMigration
if !hasValue {
allEnabled = enabled
hasValue = true
continue
}
if enabled != allEnabled {
return false, fmt.Errorf("cannot migrate resources separately: %v migration must be either all enabled or all disabled", migration.resources)
}
}
return allEnabled, nil
}
// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613
func countResource(ctx context.Context, sqlStore db.DB, resourceName string) (int64, error) {
var count int64
err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
switch resourceName {
case setting.DashboardResource:
var err error
count, err = sess.Table("dashboard").Where("is_folder = ?", false).Count()
return err
case setting.FolderResource:
var err error
count, err = sess.Table("dashboard").Where("is_folder = ?", true).Count()
return err
default:
return fmt.Errorf("unknown resource: %s", resourceName)
}
})
return count, err
}
const migrationLogTableName = "unifiedstorage_migration_log"
func migrationExists(ctx context.Context, sqlStore db.DB, migrationID string) (bool, error) {
var count int64
err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
var err error
count, err = sess.Table(migrationLogTableName).Where("migration_id = ?", migrationID).Count()
return err
})
if err != nil {
return false, fmt.Errorf("failed to check migration existence: %w", err)
}
return count > 0, nil
}
func getResourceDefinition(group, resource string) *resourceDefinition {
for i := range resourceRegistry {
r := &resourceRegistry[i]
if r.GroupResource.Group == group && r.GroupResource.Resource == resource {
if r.groupResource.Group == group && r.groupResource.Resource == resource {
return r
}
}
@@ -102,8 +288,8 @@ func buildResourceKey(group, resource, namespace string) *resourcepb.ResourceKey
}
return &resourcepb.ResourceKey{
Namespace: namespace,
Group: def.GroupResource.Group,
Resource: def.GroupResource.Resource,
Group: def.groupResource.Group,
Resource: def.groupResource.Resource,
}
}
@@ -113,7 +299,7 @@ func getMigratorFunc(accessor legacy.MigrationDashboardAccessor, group, resource
return nil
}
switch def.MigratorFunc {
switch def.migratorFunc {
case "MigrateFolders":
return accessor.MigrateFolders
case "MigrateLibraryPanels":
@@ -130,7 +316,7 @@ func getMigratorFunc(accessor legacy.MigrationDashboardAccessor, group, resource
func validateRegisteredResources() error {
registeredMap := make(map[string]bool)
for _, gr := range resourceRegistry {
key := fmt.Sprintf("%s.%s", gr.GroupResource.Resource, gr.GroupResource.Group)
key := fmt.Sprintf("%s.%s", gr.groupResource.Resource, gr.groupResource.Group)
registeredMap[key] = true
}