feat: add reindex trigger as a migration validation (#114283)

* feat: add search validation

* chore: remove comment

* fix: remove local test

* fix: revert rebuild index portion

* chore: remove unnecessary comments

* refactor: validator

* fix: revert setting
This commit is contained in:
Mustafa Sencer Özcan
2025-11-21 12:21:19 +01:00
committed by GitHub
parent b17ba6677e
commit af684248b5
5 changed files with 129 additions and 146 deletions
+2 -2
View File
@@ -534,7 +534,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
}
migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl, featureToggles)
unifiedMigrator := migrations2.ProvideUnifiedMigrator(migrationDashboardAccessor, resourceClient)
unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore)
unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore, resourceClient)
dualwriteService, err := dualwrite.ProvideService(featureToggles, kvStore, cfg, unifiedStorageMigrationService)
if err != nil {
return nil, err
@@ -1181,7 +1181,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
}
migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl, featureToggles)
unifiedMigrator := migrations2.ProvideUnifiedMigrator(migrationDashboardAccessor, resourceClient)
unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore)
unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore, resourceClient)
dualwriteService, err := dualwrite.ProvideService(featureToggles, kvStore, cfg, unifiedStorageMigrationService)
if err != nil {
return nil, err
@@ -144,7 +144,6 @@ func newUnifiedMigrator(
}
}
// migrate function -- works for a single kind
type migratorFunc = func(ctx context.Context, orgId int64, opts legacy.MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*legacy.BlobStoreInfo, error)
func (m *unifiedMigration) Migrate(ctx context.Context, opts legacy.MigrateOptions) (*resourcepb.BulkResponse, error) {
@@ -16,65 +16,34 @@ import (
)
// ValidationFunc is a function that validates migration results.
// It receives the database session, migration response, and logger for reporting.
// Return an error if validation fails, nil if validation passes or is skipped.
type ValidationFunc func(sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error
type Validator interface {
Validate(ctx context.Context, sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error
}
// ResourceMigration handles migration of specific resource types from legacy to unified storage.
// It implements migrator.CodeMigration and provides a generic, extensible way to migrate any
// resource type by:
//
// 1. Iterating through all organizations
// 2. For each org, delegating to LegacyMigrator to read from legacy and write to unified storage
// 3. Validating migration results using the provided validation function (if any)
//
// To add a new resource type migration, simply create a new ResourceMigration instance in
// service.go with the appropriate schema.GroupResource specifications and optional validation function.
type ResourceMigration struct {
migrator.MigrationBase
migrator UnifiedMigrator
resources []schema.GroupResource
migrationID string
validationFunc ValidationFunc // Optional: custom validation logic for this migration
log log.Logger
migrator UnifiedMigrator
resources []schema.GroupResource
migrationID string
validator Validator // Optional: custom validation logic for this migration
log log.Logger
}
// NewResourceMigration creates a new migration for the specified resources.
// This is the primary way to register new resource migrations.
//
// Parameters:
// - legacyMigrator: handles reading from legacy storage and writing to unified storage
// - resources: list of GroupResource to migrate
// - migrationID: unique identifier for this migration
// - validationFunc: optional validation function to verify migration results.
// If nil, no validation will be performed.
//
// Example with legacy table count validation:
//
// NewResourceMigration(
// migrator,
// []schema.GroupResource{{Group: "playlist.grafana.app", Resource: "playlists"}},
// "playlists",
// NewLegacyTableCountValidator(map[string]LegacyTableInfo{
// "playlist.grafana.app/playlists": {Table: "playlist", WhereClause: "org_id = ?"},
// }),
// )
//
// Example without validation:
//
// NewResourceMigration(migrator, resources, "new-resource", nil)
func NewResourceMigration(
migrator UnifiedMigrator,
resources []schema.GroupResource,
migrationID string,
validationFunc ValidationFunc,
validator Validator,
) *ResourceMigration {
return &ResourceMigration{
migrator: migrator,
resources: resources,
migrationID: migrationID,
validationFunc: validationFunc,
log: log.New("storage.unified.resource_migration." + migrationID),
migrator: migrator,
resources: resources,
migrationID: migrationID,
validator: validator,
log: log.New("storage.unified.resource_migration." + migrationID),
}
}
@@ -139,7 +108,7 @@ func (m *ResourceMigration) migrateOrg(ctx context.Context, sess *xorm.Session,
}
// Validate the migration results
if err := m.validateMigration(sess, response); err != nil {
if err := m.validateMigration(migrationCtx, sess, response); err != nil {
m.log.Error("Migration validation failed", "org_id", org.ID, "error", err, "duration", time.Since(startTime))
return fmt.Errorf("migration validation failed for org %d (%s): %w", org.ID, org.Name, err)
}
@@ -155,13 +124,13 @@ func (m *ResourceMigration) migrateOrg(ctx context.Context, sess *xorm.Session,
}
// validateMigration calls the custom validation function if provided
func (m *ResourceMigration) validateMigration(sess *xorm.Session, response *resourcepb.BulkResponse) error {
if m.validationFunc == nil {
func (m *ResourceMigration) validateMigration(ctx context.Context, sess *xorm.Session, response *resourcepb.BulkResponse) error {
if m.validator == nil {
m.log.Debug("No validation function provided, skipping validation")
return nil
}
return m.validationFunc(sess, response, m.log)
return m.validator.Validate(ctx, sess, response, m.log)
}
// LegacyTableInfo defines how to map a unified storage resource to its legacy table
@@ -170,85 +139,6 @@ type LegacyTableInfo struct {
WhereClause string // WHERE clause template with org_id parameter (e.g., "org_id = ? and is_folder = false")
}
// NewLegacyTableCountValidator creates a ValidationFunc that validates migration by comparing
// counts between legacy tables and unified storage.
//
// This is a helper for the common case of validating that all items from legacy tables
// were successfully migrated to unified storage.
//
// Parameters:
// - legacyTableMap: maps "group/resource" keys to LegacyTableInfo for validation.
// Only resources with mappings will be validated.
//
// Example:
//
// validator := NewLegacyTableCountValidator(map[string]LegacyTableInfo{
// "dashboard.grafana.app/dashboards": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = false"},
// "folder.grafana.app/folders": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = true"},
// })
func NewLegacyTableCountValidator(legacyTableMap map[string]LegacyTableInfo) ValidationFunc {
return func(sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error {
// Check for rejected items
if len(response.Rejected) > 0 {
log.Warn("Migration had rejected items", "count", len(response.Rejected))
for i, rejected := range response.Rejected {
if i < 10 { // Log first 10 rejected items
log.Warn("Rejected item",
"namespace", rejected.Key.Namespace,
"group", rejected.Key.Group,
"resource", rejected.Key.Resource,
"name", rejected.Key.Name,
"reason", rejected.Error)
}
}
// Rejections are not fatal - they may be expected for invalid data
}
// Validate counts for each resource type
for _, summary := range response.Summary {
key := fmt.Sprintf("%s/%s", summary.Group, summary.Resource)
tableInfo, ok := legacyTableMap[key]
if !ok {
log.Debug("No legacy table mapping for resource, skipping count validation",
"resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group),
"namespace", summary.Namespace)
continue
}
// Get legacy count
orgID, err := ParseOrgIDFromNamespace(summary.Namespace)
if err != nil {
return fmt.Errorf("invalid namespace %s: %w", summary.Namespace, err)
}
legacyCount, err := sess.Table(tableInfo.Table).Where(tableInfo.WhereClause, orgID).Count()
if err != nil {
return fmt.Errorf("failed to count %s: %w", tableInfo.Table, err)
}
// Account for rejected items in validation
expectedCount := summary.Count + int64(len(response.Rejected))
log.Info("Count validation",
"resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group),
"namespace", summary.Namespace,
"legacy_count", legacyCount,
"unified_count", summary.Count,
"rejected", len(response.Rejected),
"history", summary.History)
// Validate that we migrated all items (allowing for rejected items)
if legacyCount > expectedCount {
return fmt.Errorf("count mismatch for %s.%s in namespace %s: legacy has %d, unified has %d, rejected %d",
summary.Resource, summary.Group, summary.Namespace,
legacyCount, summary.Count, len(response.Rejected))
}
}
return nil
}
}
func ParseOrgIDFromNamespace(namespace string) (int64, error) {
// Use authlib to properly parse all namespace formats including "default" for org 1
info, err := types.ParseNamespace(namespace)
+9 -14
View File
@@ -11,6 +11,7 @@ import (
sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/migrations/contract"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -24,29 +25,28 @@ type UnifiedStorageMigrationServiceImpl struct {
cfg *setting.Cfg
sqlStore db.DB
kv kvstore.KVStore
client resource.ResourceClient
}
var _ contract.UnifiedStorageMigrationService = (*UnifiedStorageMigrationServiceImpl)(nil)
// ProvideUnifiedStorageMigrationService is a Wire provider that creates the migration service.
// The service implements registry.BackgroundService and runs migrations during server startup.
func ProvideUnifiedStorageMigrationService(
migrator UnifiedMigrator,
cfg *setting.Cfg,
sqlStore db.DB,
kv kvstore.KVStore,
client resource.ResourceClient,
) contract.UnifiedStorageMigrationService {
return &UnifiedStorageMigrationServiceImpl{
migrator: migrator,
cfg: cfg,
sqlStore: sqlStore,
kv: kv,
client: client,
}
}
// Run executes unified storage migrations as a background service.
// This blocks until migrations complete. If migrations fail, an error is returned
// which will prevent Grafana from starting.
func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error {
// TODO: temporary skip migrations in test environments to prevent integration test timeouts.
if os.Getenv("GRAFANA_TEST_DB") != "" {
@@ -61,18 +61,15 @@ func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error {
// TODO: Re-enable once migrations are ready
// TODO: add guarantee that this only runs once
// return RegisterMigrations(p.migrator, p.cfg, p.sqlStore)
// return RegisterMigrations(p.migrator, p.cfg, p.sqlStore, p.client)
return nil
}
// RegisterMigrations initializes and registers all unified storage migrations.
// This function is the entry point for all data migrations from legacy storage
// to unified storage. It returns an error if migrations fail, preventing Grafana
// from starting with inconsistent data.
func RegisterMigrations(
migrator UnifiedMigrator,
cfg *setting.Cfg,
sqlStore db.DB,
client resource.ResourceClient,
) error {
ctx, span := tracer.Start(context.Background(), "storage.unified.RegisterMigrations")
defer span.End()
@@ -85,7 +82,7 @@ func RegisterMigrations(
// Register resource migrations
// To add a new resource type, simply add another migration here with the appropriate resources
registerResourceMigrations(mg, migrator)
registerResourceMigrations(mg, migrator, client)
// Run all registered migrations (blocking)
sec := cfg.Raw.Section("database")
@@ -99,9 +96,7 @@ func RegisterMigrations(
return nil
}
// registerResourceMigrations registers all unified storage resource migrations.
// Add new resource types here by creating additional ResourceMigration instances.
func registerResourceMigrations(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator) {
func registerResourceMigrations(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) {
dashboardsAndFolders := NewResourceMigration(
migrator,
[]schema.GroupResource{
@@ -109,7 +104,7 @@ func registerResourceMigrations(mg *sqlstoremigrator.Migrator, migrator UnifiedM
{Group: "dashboard.grafana.app", Resource: "dashboards"},
},
"folders-dashboards",
NewLegacyTableCountValidator(map[string]LegacyTableInfo{
NewCountValidator(client, map[string]LegacyTableInfo{
"folder.grafana.app/folders": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = true"},
"dashboard.grafana.app/dashboards": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = false"},
}),
@@ -0,0 +1,99 @@
package migrations
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/util/xorm"
)
type CountValidator struct {
client resourcepb.ResourceIndexClient
legacyTableMap map[string]LegacyTableInfo
}
func NewCountValidator(client resourcepb.ResourceIndexClient, legacyTableMap map[string]LegacyTableInfo) Validator {
return &CountValidator{client: client, legacyTableMap: legacyTableMap}
}
func (v *CountValidator) Validate(ctx context.Context, sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error {
if len(response.Rejected) > 0 {
log.Warn("Migration had rejected items", "count", len(response.Rejected))
for i, rejected := range response.Rejected {
if i < 10 { // Log first 10 rejected items
log.Warn("Rejected item",
"namespace", rejected.Key.Namespace,
"group", rejected.Key.Group,
"resource", rejected.Key.Resource,
"name", rejected.Key.Name,
"reason", rejected.Error)
}
}
// Rejections are not fatal - they may be expected for invalid data
}
// Validate counts for each resource type
for _, summary := range response.Summary {
key := fmt.Sprintf("%s/%s", summary.Group, summary.Resource)
tableInfo, ok := v.legacyTableMap[key]
if !ok {
log.Debug("No legacy table mapping for resource, skipping count validation",
"resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group),
"namespace", summary.Namespace)
continue
}
// Get legacy count from database
orgID, err := ParseOrgIDFromNamespace(summary.Namespace)
if err != nil {
return fmt.Errorf("invalid namespace %s: %w", summary.Namespace, err)
}
legacyCount, err := sess.Table(tableInfo.Table).Where(tableInfo.WhereClause, orgID).Count()
if err != nil {
return fmt.Errorf("failed to count %s: %w", tableInfo.Table, err)
}
// Get unified storage count using GetStats API
statsResp, err := v.client.GetStats(ctx, &resourcepb.ResourceStatsRequest{
Namespace: summary.Namespace,
Kinds: []string{fmt.Sprintf("%s/%s", summary.Group, summary.Resource)},
})
if err != nil {
return fmt.Errorf("failed to get stats for %s/%s in namespace %s: %w",
summary.Group, summary.Resource, summary.Namespace, err)
}
// Find the count for this specific resource type
var unifiedCount int64
for _, stat := range statsResp.Stats {
if stat.Group == summary.Group && stat.Resource == summary.Resource {
unifiedCount = stat.Count
break
}
}
// Account for rejected items in validation
expectedCount := unifiedCount + int64(len(response.Rejected))
log.Info("Count validation",
"resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group),
"namespace", summary.Namespace,
"legacy_count", legacyCount,
"unified_count", unifiedCount,
"migration_summary_count", summary.Count,
"rejected", len(response.Rejected),
"history", summary.History)
// Validate that we migrated all items (allowing for rejected items)
if legacyCount > expectedCount {
return fmt.Errorf("count mismatch for %s.%s in namespace %s: legacy has %d, unified has %d, rejected %d",
summary.Resource, summary.Group, summary.Namespace,
legacyCount, unifiedCount, len(response.Rejected))
}
}
return nil
}