mirror of https://github.com/grafana/grafana
Storage: Add command line tool to migrate legacy dashboards (and folders) to unified storage (#99199)
parent
b6ea06f259
commit
a5355fd66c
@ -0,0 +1,70 @@ |
||||
package datamigrations |
||||
|
||||
import ( |
||||
"context" |
||||
"path/filepath" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/provisioning" |
||||
"github.com/grafana/grafana/pkg/services/provisioning/dashboards" |
||||
) |
||||
|
||||
var ( |
||||
_ provisioning.ProvisioningService = (*stubProvisioning)(nil) |
||||
) |
||||
|
||||
func newStubProvisioning(path string) (provisioning.ProvisioningService, error) { |
||||
cfgs, err := dashboards.ReadDashboardConfig(filepath.Join(path, "dashboards")) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
stub := &stubProvisioning{ |
||||
path: make(map[string]string), |
||||
} |
||||
for _, cfg := range cfgs { |
||||
stub.path[cfg.Name] = cfg.Options["path"].(string) |
||||
} |
||||
return &stubProvisioning{}, nil |
||||
} |
||||
|
||||
type stubProvisioning struct { |
||||
path map[string]string // name > options.path
|
||||
} |
||||
|
||||
// GetAllowUIUpdatesFromConfig implements provisioning.ProvisioningService.
|
||||
func (s *stubProvisioning) GetAllowUIUpdatesFromConfig(name string) bool { |
||||
return false |
||||
} |
||||
|
||||
func (s *stubProvisioning) GetDashboardProvisionerResolvedPath(name string) string { |
||||
return s.path[name] |
||||
} |
||||
|
||||
// ProvisionAlerting implements provisioning.ProvisioningService.
|
||||
func (s *stubProvisioning) ProvisionAlerting(ctx context.Context) error { |
||||
panic("unimplemented") |
||||
} |
||||
|
||||
// ProvisionDashboards implements provisioning.ProvisioningService.
|
||||
func (s *stubProvisioning) ProvisionDashboards(ctx context.Context) error { |
||||
panic("unimplemented") |
||||
} |
||||
|
||||
// ProvisionDatasources implements provisioning.ProvisioningService.
|
||||
func (s *stubProvisioning) ProvisionDatasources(ctx context.Context) error { |
||||
panic("unimplemented") |
||||
} |
||||
|
||||
// ProvisionPlugins implements provisioning.ProvisioningService.
|
||||
func (s *stubProvisioning) ProvisionPlugins(ctx context.Context) error { |
||||
panic("unimplemented") |
||||
} |
||||
|
||||
// Run implements provisioning.ProvisioningService.
|
||||
func (s *stubProvisioning) Run(ctx context.Context) error { |
||||
panic("unimplemented") |
||||
} |
||||
|
||||
// RunInitProvisioners implements provisioning.ProvisioningService.
|
||||
func (s *stubProvisioning) RunInitProvisioners(ctx context.Context) error { |
||||
panic("unimplemented") |
||||
} |
@ -0,0 +1,207 @@ |
||||
package datamigrations |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"os" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/prometheus/client_golang/prometheus" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
|
||||
dashboard "github.com/grafana/grafana/pkg/apis/dashboard" |
||||
folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" |
||||
|
||||
authlib "github.com/grafana/authlib/types" |
||||
"github.com/grafana/grafana/pkg/apimachinery/identity" |
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" |
||||
"github.com/grafana/grafana/pkg/infra/db" |
||||
"github.com/grafana/grafana/pkg/infra/tracing" |
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/setting" |
||||
"github.com/grafana/grafana/pkg/storage/legacysql" |
||||
"github.com/grafana/grafana/pkg/storage/unified" |
||||
"github.com/grafana/grafana/pkg/storage/unified/parquet" |
||||
"github.com/grafana/grafana/pkg/storage/unified/resource" |
||||
) |
||||
|
||||
// ToUnifiedStorage converts dashboards+folders into unified storage
|
||||
func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) error { |
||||
namespace := "default" // TODO... from command line
|
||||
ns, err := authlib.ParseNamespace(namespace) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
ctx := identity.WithServiceIdentityContext(context.Background(), ns.OrgID) |
||||
start := time.Now() |
||||
last := time.Now() |
||||
|
||||
opts := legacy.MigrateOptions{ |
||||
Namespace: namespace, |
||||
Resources: []schema.GroupResource{ |
||||
{Group: folders.GROUP, Resource: folders.RESOURCE}, |
||||
{Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE}, |
||||
{Group: dashboard.GROUP, Resource: dashboard.LIBRARY_PANEL_RESOURCE}, |
||||
}, |
||||
LargeObjects: nil, // TODO... from config
|
||||
Progress: func(count int, msg string) { |
||||
if count < 1 || time.Since(last) > time.Second { |
||||
fmt.Printf("[%4d] %s\n", count, msg) |
||||
last = time.Now() |
||||
} |
||||
}, |
||||
} |
||||
|
||||
provisioning, err := newStubProvisioning(cfg.ProvisioningPath) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
migrator := legacy.NewDashboardAccess( |
||||
legacysql.NewDatabaseProvider(sqlStore), |
||||
authlib.OrgNamespaceFormatter, |
||||
nil, provisioning, false, |
||||
) |
||||
|
||||
yes, err := promptYesNo(fmt.Sprintf("Count legacy resources for namespace: %s?", opts.Namespace)) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if yes { |
||||
opts.OnlyCount = true |
||||
rsp, err := migrator.Migrate(ctx, opts) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
fmt.Printf("Counting DONE: %s\n", time.Since(start)) |
||||
if rsp != nil { |
||||
jj, _ := json.MarshalIndent(rsp, "", " ") |
||||
fmt.Printf("%s\n", string(jj)) |
||||
} |
||||
} |
||||
|
||||
opts.OnlyCount = false |
||||
opts.WithHistory, err = promptYesNo("Include history in exports?") |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
yes, err = promptYesNo("Export legacy resources to parquet file?") |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if yes { |
||||
file, err := os.CreateTemp(cfg.DataPath, "grafana-export-*.parquet") |
||||
if err != nil { |
||||
return err |
||||
} |
||||
start = time.Now() |
||||
last = time.Now() |
||||
opts.Store, err = newParquetClient(file) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
rsp, err := migrator.Migrate(ctx, opts) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
fmt.Printf("Parquet export DONE: %s\n", time.Since(start)) |
||||
if rsp != nil { |
||||
jj, _ := json.MarshalIndent(rsp, "", " ") |
||||
fmt.Printf("%s\n", string(jj)) |
||||
} |
||||
fmt.Printf("File: %s\n", file.Name()) |
||||
} |
||||
|
||||
yes, err = promptYesNo("Export legacy resources to unified storage?") |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if yes { |
||||
client, err := newUnifiedClient(cfg, sqlStore) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Check the stats (eventually compare)
|
||||
req := &resource.ResourceStatsRequest{ |
||||
Namespace: opts.Namespace, |
||||
} |
||||
for _, r := range opts.Resources { |
||||
req.Kinds = append(req.Kinds, fmt.Sprintf("%s/%s", r.Group, r.Resource)) |
||||
} |
||||
|
||||
stats, err := client.GetStats(ctx, req) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if stats != nil { |
||||
fmt.Printf("Existing resources in unified storage:\n") |
||||
jj, _ := json.MarshalIndent(stats, "", " ") |
||||
fmt.Printf("%s\n", string(jj)) |
||||
} |
||||
|
||||
yes, err = promptYesNo("Would you like to continue? (existing resources will be replaced)") |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if yes { |
||||
start = time.Now() |
||||
last = time.Now() |
||||
opts.Store = client |
||||
opts.BlobStore = client |
||||
rsp, err := migrator.Migrate(ctx, opts) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
fmt.Printf("Unified storage export: %s\n", time.Since(start)) |
||||
if rsp != nil { |
||||
jj, _ := json.MarshalIndent(rsp, "", " ") |
||||
fmt.Printf("%s\n", string(jj)) |
||||
} |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func promptYesNo(prompt string) (bool, error) { |
||||
line := "" |
||||
for { |
||||
fmt.Printf("%s (Y/N) >", prompt) |
||||
_, err := fmt.Scanln(&line) |
||||
if err != nil && err.Error() != "unexpected newline" { |
||||
return false, err |
||||
} |
||||
switch strings.ToLower(line) { |
||||
case "y", "yes": |
||||
return true, nil |
||||
case "n", "no": |
||||
return false, nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
func newUnifiedClient(cfg *setting.Cfg, sqlStore db.DB) (resource.ResourceClient, error) { |
||||
return unified.ProvideUnifiedStorageClient(cfg, |
||||
featuremgmt.WithFeatures(), // none??
|
||||
sqlStore, |
||||
tracing.NewNoopTracerService(), |
||||
prometheus.NewPedanticRegistry(), |
||||
authlib.FixedAccessClient(true), // always true!
|
||||
nil, // document supplier
|
||||
) |
||||
} |
||||
|
||||
func newParquetClient(file *os.File) (resource.BatchStoreClient, error) { |
||||
writer, err := parquet.NewParquetWriter(file) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
client := parquet.NewBatchResourceWriterClient(writer) |
||||
return client, nil |
||||
} |
@ -0,0 +1,29 @@ |
||||
package datamigrations |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db" |
||||
) |
||||
|
||||
func TestUnifiedStorageCommand(t *testing.T) { |
||||
// setup datasources with password, basic_auth and none
|
||||
store := db.InitTestDB(t) |
||||
err := store.WithDbSession(context.Background(), func(sess *db.Session) error { |
||||
unistoreMigrationTest(t, sess, store) |
||||
return nil |
||||
}) |
||||
require.NoError(t, err) |
||||
} |
||||
|
||||
func unistoreMigrationTest(t *testing.T, session *db.Session, sqlstore db.DB) { |
||||
// empty stats
|
||||
|
||||
t.Run("get stats", func(t *testing.T) { |
||||
fmt.Printf("TODO... add folders and check that they migrate\n") |
||||
}) |
||||
} |
@ -0,0 +1,411 @@ |
||||
package legacy |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
|
||||
"google.golang.org/grpc/metadata" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
|
||||
authlib "github.com/grafana/authlib/types" |
||||
"github.com/grafana/grafana/pkg/apimachinery/utils" |
||||
dashboard "github.com/grafana/grafana/pkg/apis/dashboard" |
||||
folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/services/sqlstore" |
||||
"github.com/grafana/grafana/pkg/storage/unified/apistore" |
||||
"github.com/grafana/grafana/pkg/storage/unified/resource" |
||||
) |
||||
|
||||
type MigrateOptions struct { |
||||
Namespace string |
||||
Store resource.BatchStoreClient |
||||
Writer resource.BatchResourceWriter |
||||
LargeObjects apistore.LargeObjectSupport |
||||
BlobStore resource.BlobStoreClient |
||||
Resources []schema.GroupResource |
||||
WithHistory bool // only applies to dashboards
|
||||
OnlyCount bool // just count the values
|
||||
Progress func(count int, msg string) |
||||
} |
||||
|
||||
// Read from legacy and write into unified storage
|
||||
type LegacyMigrator interface { |
||||
Migrate(ctx context.Context, opts MigrateOptions) (*resource.BatchResponse, error) |
||||
} |
||||
|
||||
type BlobStoreInfo struct { |
||||
Count int64 |
||||
Size int64 |
||||
} |
||||
|
||||
// migrate function -- works for a single kind
|
||||
type migrator = func(ctx context.Context, orgId int64, opts MigrateOptions, stream resource.BatchStore_BatchProcessClient) (*BlobStoreInfo, error) |
||||
|
||||
func (a *dashboardSqlAccess) Migrate(ctx context.Context, opts MigrateOptions) (*resource.BatchResponse, error) { |
||||
info, err := authlib.ParseNamespace(opts.Namespace) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Migrate everything
|
||||
if len(opts.Resources) < 1 { |
||||
return nil, fmt.Errorf("missing resource selector") |
||||
} |
||||
|
||||
migrators := []migrator{} |
||||
settings := resource.BatchSettings{ |
||||
RebuildCollection: true, |
||||
SkipValidation: true, |
||||
} |
||||
|
||||
for _, res := range opts.Resources { |
||||
switch fmt.Sprintf("%s/%s", res.Group, res.Resource) { |
||||
case "folder.grafana.app/folders": |
||||
migrators = append(migrators, a.migrateFolders) |
||||
settings.Collection = append(settings.Collection, &resource.ResourceKey{ |
||||
Namespace: opts.Namespace, |
||||
Group: folders.GROUP, |
||||
Resource: folders.RESOURCE, |
||||
}) |
||||
|
||||
case "dashboard.grafana.app/librarypanels": |
||||
migrators = append(migrators, a.migratePanels) |
||||
settings.Collection = append(settings.Collection, &resource.ResourceKey{ |
||||
Namespace: opts.Namespace, |
||||
Group: dashboard.GROUP, |
||||
Resource: dashboard.LIBRARY_PANEL_RESOURCE, |
||||
}) |
||||
|
||||
case "dashboard.grafana.app/dashboards": |
||||
migrators = append(migrators, a.migrateDashboards) |
||||
settings.Collection = append(settings.Collection, &resource.ResourceKey{ |
||||
Namespace: opts.Namespace, |
||||
Group: dashboard.GROUP, |
||||
Resource: dashboard.DASHBOARD_RESOURCE, |
||||
}) |
||||
default: |
||||
return nil, fmt.Errorf("unsupported resource: %s", res) |
||||
} |
||||
} |
||||
|
||||
if opts.OnlyCount { |
||||
return a.countValues(ctx, opts) |
||||
} |
||||
|
||||
ctx = metadata.NewOutgoingContext(ctx, settings.ToMD()) |
||||
stream, err := opts.Store.BatchProcess(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Now run each migration
|
||||
blobStore := BlobStoreInfo{} |
||||
for _, m := range migrators { |
||||
blobs, err := m(ctx, info.OrgID, opts, stream) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if blobs != nil { |
||||
blobStore.Count += blobs.Count |
||||
blobStore.Size += blobs.Size |
||||
} |
||||
} |
||||
fmt.Printf("BLOBS: %+v\n", blobStore) |
||||
return stream.CloseAndRecv() |
||||
} |
||||
|
||||
func (a *dashboardSqlAccess) countValues(ctx context.Context, opts MigrateOptions) (*resource.BatchResponse, error) { |
||||
sql, err := a.sql(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
ns, err := authlib.ParseNamespace(opts.Namespace) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
orgId := ns.OrgID |
||||
rsp := &resource.BatchResponse{} |
||||
err = sql.DB.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { |
||||
for _, res := range opts.Resources { |
||||
switch fmt.Sprintf("%s/%s", res.Group, res.Resource) { |
||||
case "folder.grafana.app/folders": |
||||
summary := &resource.BatchResponse_Summary{} |
||||
summary.Group = folders.GROUP |
||||
summary.Group = folders.RESOURCE |
||||
_, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("dashboard")+ |
||||
" WHERE is_folder=FALSE AND org_id=?", orgId).Get(&summary.Count) |
||||
rsp.Summary = append(rsp.Summary, summary) |
||||
|
||||
case "dashboard.grafana.app/librarypanels": |
||||
summary := &resource.BatchResponse_Summary{} |
||||
summary.Group = dashboard.GROUP |
||||
summary.Resource = dashboard.LIBRARY_PANEL_RESOURCE |
||||
_, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("library_element")+ |
||||
" WHERE org_id=?", orgId).Get(&summary.Count) |
||||
rsp.Summary = append(rsp.Summary, summary) |
||||
|
||||
case "dashboard.grafana.app/dashboards": |
||||
summary := &resource.BatchResponse_Summary{} |
||||
summary.Group = dashboard.GROUP |
||||
summary.Resource = dashboard.DASHBOARD_RESOURCE |
||||
rsp.Summary = append(rsp.Summary, summary) |
||||
|
||||
_, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("dashboard")+ |
||||
" WHERE is_folder=FALSE AND org_id=?", orgId).Get(&summary.Count) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Also count history
|
||||
_, err = sess.SQL(`SELECT COUNT(*)
|
||||
FROM `+sql.Table("dashboard_version")+` as dv |
||||
JOIN `+sql.Table("dashboard")+` as dd |
||||
ON dd.id = dv.dashboard_id |
||||
WHERE org_id=?`, orgId).Get(&summary.History) |
||||
} |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
return rsp, nil |
||||
} |
||||
|
||||
func (a *dashboardSqlAccess) migrateDashboards(ctx context.Context, orgId int64, opts MigrateOptions, stream resource.BatchStore_BatchProcessClient) (*BlobStoreInfo, error) { |
||||
query := &DashboardQuery{ |
||||
OrgID: orgId, |
||||
Limit: 100000000, |
||||
GetHistory: opts.WithHistory, // include history
|
||||
} |
||||
|
||||
blobs := &BlobStoreInfo{} |
||||
sql, err := a.sql(ctx) |
||||
if err != nil { |
||||
return blobs, err |
||||
} |
||||
|
||||
opts.Progress(-1, "migrating dashboards...") |
||||
rows, err := a.getRows(ctx, sql, query) |
||||
if rows != nil { |
||||
defer func() { |
||||
_ = rows.Close() |
||||
}() |
||||
} |
||||
if err != nil { |
||||
return blobs, err |
||||
} |
||||
|
||||
large := opts.LargeObjects |
||||
|
||||
// Now send each dashboard
|
||||
for i := 1; rows.Next(); i++ { |
||||
dash := rows.row.Dash |
||||
dash.APIVersion = fmt.Sprintf("%s/v0alpha1", dashboard.GROUP) // << eventually v0
|
||||
dash.SetNamespace(opts.Namespace) |
||||
dash.SetResourceVersion("") // it will be filled in by the backend
|
||||
|
||||
body, err := json.Marshal(dash) |
||||
if err != nil { |
||||
err = fmt.Errorf("error reading json from: %s // %w", rows.row.Dash.Name, err) |
||||
return blobs, err |
||||
} |
||||
|
||||
req := &resource.BatchRequest{ |
||||
Key: &resource.ResourceKey{ |
||||
Namespace: opts.Namespace, |
||||
Group: dashboard.GROUP, |
||||
Resource: dashboard.DASHBOARD_RESOURCE, |
||||
Name: rows.Name(), |
||||
}, |
||||
Value: body, |
||||
Folder: rows.row.FolderUID, |
||||
Action: resource.BatchRequest_ADDED, |
||||
} |
||||
if dash.Generation > 1 { |
||||
req.Action = resource.BatchRequest_MODIFIED |
||||
} else if dash.Generation < 0 { |
||||
req.Action = resource.BatchRequest_DELETED |
||||
} |
||||
|
||||
// With large object support
|
||||
if large != nil && len(body) > large.Threshold() { |
||||
obj, err := utils.MetaAccessor(dash) |
||||
if err != nil { |
||||
return blobs, err |
||||
} |
||||
|
||||
opts.Progress(i, fmt.Sprintf("[v:%d] %s Large object (%d)", dash.Generation, dash.Name, len(body))) |
||||
err = large.Deconstruct(ctx, req.Key, opts.BlobStore, obj, req.Value) |
||||
if err != nil { |
||||
return blobs, err |
||||
} |
||||
|
||||
// The smaller version (most of spec removed)
|
||||
req.Value, err = json.Marshal(dash) |
||||
if err != nil { |
||||
return blobs, err |
||||
} |
||||
blobs.Count++ |
||||
blobs.Size += int64(len(body)) |
||||
} |
||||
|
||||
opts.Progress(i, fmt.Sprintf("[v:%2d] %s (size:%d / %d|%d)", dash.Generation, dash.Name, len(req.Value), i, rows.count)) |
||||
|
||||
err = stream.Send(req) |
||||
if err != nil { |
||||
if errors.Is(err, io.EOF) { |
||||
opts.Progress(i, fmt.Sprintf("stream EOF/cancelled. index=%d", i)) |
||||
err = nil |
||||
} |
||||
return blobs, err |
||||
} |
||||
} |
||||
|
||||
if len(rows.rejected) > 0 { |
||||
for _, row := range rows.rejected { |
||||
id := row.Dash.Labels[utils.LabelKeyDeprecatedInternalID] |
||||
fmt.Printf("REJECTED: %s / %s\n", id, row.Dash.Name) |
||||
opts.Progress(-2, fmt.Sprintf("rejected: id:%s, uid:%s", id, row.Dash.Name)) |
||||
} |
||||
} |
||||
|
||||
if rows.Error() != nil { |
||||
return blobs, rows.Error() |
||||
} |
||||
|
||||
opts.Progress(-2, fmt.Sprintf("finished dashboards... (%d)", rows.count)) |
||||
return blobs, err |
||||
} |
||||
|
||||
func (a *dashboardSqlAccess) migrateFolders(ctx context.Context, orgId int64, opts MigrateOptions, stream resource.BatchStore_BatchProcessClient) (*BlobStoreInfo, error) { |
||||
query := &DashboardQuery{ |
||||
OrgID: orgId, |
||||
Limit: 100000000, |
||||
GetFolders: true, |
||||
} |
||||
|
||||
sql, err := a.sql(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
opts.Progress(-1, "migrating folders...") |
||||
rows, err := a.getRows(ctx, sql, query) |
||||
if rows != nil { |
||||
defer func() { |
||||
_ = rows.Close() |
||||
}() |
||||
} |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Now send each dashboard
|
||||
for i := 1; rows.Next(); i++ { |
||||
dash := rows.row.Dash |
||||
dash.APIVersion = "folder.grafana.app/v0alpha1" |
||||
dash.Kind = "Folder" |
||||
dash.SetNamespace(opts.Namespace) |
||||
dash.SetResourceVersion("") // it will be filled in by the backend
|
||||
|
||||
spec := map[string]any{ |
||||
"title": dash.Spec.Object["title"], |
||||
} |
||||
description := dash.Spec.Object["description"] |
||||
if description != nil { |
||||
spec["description"] = description |
||||
} |
||||
dash.Spec.Object = spec |
||||
|
||||
body, err := json.Marshal(dash) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
req := &resource.BatchRequest{ |
||||
Key: &resource.ResourceKey{ |
||||
Namespace: opts.Namespace, |
||||
Group: "folder.grafana.app", |
||||
Resource: "folders", |
||||
Name: rows.Name(), |
||||
}, |
||||
Value: body, |
||||
Folder: rows.row.FolderUID, |
||||
Action: resource.BatchRequest_ADDED, |
||||
} |
||||
if dash.Generation > 1 { |
||||
req.Action = resource.BatchRequest_MODIFIED |
||||
} else if dash.Generation < 0 { |
||||
req.Action = resource.BatchRequest_DELETED |
||||
} |
||||
|
||||
opts.Progress(i, fmt.Sprintf("[v:%d] %s (%d)", dash.Generation, dash.Name, len(req.Value))) |
||||
|
||||
err = stream.Send(req) |
||||
if err != nil { |
||||
if errors.Is(err, io.EOF) { |
||||
err = nil |
||||
} |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
if rows.Error() != nil { |
||||
return nil, rows.Error() |
||||
} |
||||
|
||||
opts.Progress(-2, fmt.Sprintf("finished folders... (%d)", rows.count)) |
||||
return nil, err |
||||
} |
||||
|
||||
func (a *dashboardSqlAccess) migratePanels(ctx context.Context, orgId int64, opts MigrateOptions, stream resource.BatchStore_BatchProcessClient) (*BlobStoreInfo, error) { |
||||
opts.Progress(-1, "migrating library panels...") |
||||
panels, err := a.GetLibraryPanels(ctx, LibraryPanelQuery{ |
||||
OrgID: orgId, |
||||
Limit: 1000000, |
||||
}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
for i, panel := range panels.Items { |
||||
meta, err := utils.MetaAccessor(&panel) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
body, err := json.Marshal(panel) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
req := &resource.BatchRequest{ |
||||
Key: &resource.ResourceKey{ |
||||
Namespace: opts.Namespace, |
||||
Group: dashboard.GROUP, |
||||
Resource: dashboard.LIBRARY_PANEL_RESOURCE, |
||||
Name: panel.Name, |
||||
}, |
||||
Value: body, |
||||
Folder: meta.GetFolder(), |
||||
Action: resource.BatchRequest_ADDED, |
||||
} |
||||
if panel.Generation > 1 { |
||||
req.Action = resource.BatchRequest_MODIFIED |
||||
} |
||||
|
||||
opts.Progress(i, fmt.Sprintf("[v:%d] %s (%d)", i, meta.GetName(), len(req.Value))) |
||||
|
||||
err = stream.Send(req) |
||||
if err != nil { |
||||
if errors.Is(err, io.EOF) { |
||||
err = nil |
||||
} |
||||
return nil, err |
||||
} |
||||
} |
||||
opts.Progress(-2, fmt.Sprintf("finished panels... (%d)", len(panels.Items))) |
||||
return nil, nil |
||||
} |
@ -0,0 +1,6 @@ |
||||
# Parquet Support |
||||
|
||||
This package implements a limited parquet backend that is currently only useful |
||||
as a pass-though buffer while batch writing values. |
||||
|
||||
Eventually this package could evolve into a full storage backend. |
@ -0,0 +1,78 @@ |
||||
package parquet |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
|
||||
"google.golang.org/grpc" |
||||
"google.golang.org/grpc/metadata" |
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource" |
||||
) |
||||
|
||||
var ( |
||||
_ resource.BatchStoreClient = (*writerClient)(nil) |
||||
_ resource.BatchStore_BatchProcessClient = (*writerClient)(nil) |
||||
|
||||
errUnimplemented = errors.New("not implemented (BatchResourceWriter as BatchStoreClient shim)") |
||||
) |
||||
|
||||
type writerClient struct { |
||||
writer resource.BatchResourceWriter |
||||
ctx context.Context |
||||
} |
||||
|
||||
// NewBatchResourceWriterClient wraps a BatchResourceWriter so that it can be used as a ResourceStoreClient
|
||||
func NewBatchResourceWriterClient(writer resource.BatchResourceWriter) *writerClient { |
||||
return &writerClient{writer: writer} |
||||
} |
||||
|
||||
// Send implements resource.ResourceStore_BatchProcessClient.
|
||||
func (w *writerClient) Send(req *resource.BatchRequest) error { |
||||
return w.writer.Write(w.ctx, req.Key, req.Value) |
||||
} |
||||
|
||||
// BatchProcess implements resource.ResourceStoreClient.
|
||||
func (w *writerClient) BatchProcess(ctx context.Context, opts ...grpc.CallOption) (resource.BatchStore_BatchProcessClient, error) { |
||||
if w.ctx != nil { |
||||
return nil, fmt.Errorf("only one batch request supported") |
||||
} |
||||
w.ctx = ctx |
||||
return w, nil |
||||
} |
||||
|
||||
// CloseAndRecv implements resource.ResourceStore_BatchProcessClient.
|
||||
func (w *writerClient) CloseAndRecv() (*resource.BatchResponse, error) { |
||||
return w.writer.CloseWithResults() |
||||
} |
||||
|
||||
// CloseSend implements resource.ResourceStore_BatchProcessClient.
|
||||
func (w *writerClient) CloseSend() error { |
||||
return w.writer.Close() |
||||
} |
||||
|
||||
// Context implements resource.ResourceStore_BatchProcessClient.
|
||||
func (w *writerClient) Context() context.Context { |
||||
return w.ctx |
||||
} |
||||
|
||||
// Header implements resource.ResourceStore_BatchProcessClient.
|
||||
func (w *writerClient) Header() (metadata.MD, error) { |
||||
return nil, errUnimplemented |
||||
} |
||||
|
||||
// RecvMsg implements resource.ResourceStore_BatchProcessClient.
|
||||
func (w *writerClient) RecvMsg(m any) error { |
||||
return errUnimplemented |
||||
} |
||||
|
||||
// SendMsg implements resource.ResourceStore_BatchProcessClient.
|
||||
func (w *writerClient) SendMsg(m any) error { |
||||
return errUnimplemented |
||||
} |
||||
|
||||
// Trailer implements resource.ResourceStore_BatchProcessClient.
|
||||
func (w *writerClient) Trailer() metadata.MD { |
||||
return nil |
||||
} |
@ -0,0 +1,264 @@ |
||||
package parquet |
||||
|
||||
import ( |
||||
"fmt" |
||||
|
||||
"github.com/apache/arrow-go/v18/parquet" |
||||
"github.com/apache/arrow-go/v18/parquet/file" |
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource" |
||||
) |
||||
|
||||
var ( |
||||
_ resource.BatchRequestIterator = (*parquetReader)(nil) |
||||
) |
||||
|
||||
func NewParquetReader(inputPath string, batchSize int64) (resource.BatchRequestIterator, error) { |
||||
return newResourceReader(inputPath, batchSize) |
||||
} |
||||
|
||||
type parquetReader struct { |
||||
reader *file.Reader |
||||
|
||||
namespace *stringColumn |
||||
group *stringColumn |
||||
resource *stringColumn |
||||
name *stringColumn |
||||
value *stringColumn |
||||
folder *stringColumn |
||||
action *int32Column |
||||
columns []columnBuffer |
||||
|
||||
batchSize int64 |
||||
|
||||
defLevels []int16 |
||||
repLevels []int16 |
||||
|
||||
// how many we already read
|
||||
bufferSize int |
||||
bufferIndex int |
||||
rowGroupIDX int |
||||
|
||||
req *resource.BatchRequest |
||||
err error |
||||
} |
||||
|
||||
// Next implements resource.BatchRequestIterator.
|
||||
func (r *parquetReader) Next() bool { |
||||
r.req = nil |
||||
for r.err == nil && r.reader != nil { |
||||
if r.bufferIndex >= r.bufferSize && r.value.reader.HasNext() { |
||||
r.bufferIndex = 0 |
||||
r.err = r.readBatch() |
||||
if r.err != nil { |
||||
return false |
||||
} |
||||
r.bufferIndex = r.value.count |
||||
} |
||||
|
||||
if r.bufferSize > r.bufferIndex { |
||||
i := r.bufferIndex |
||||
r.bufferIndex++ |
||||
|
||||
r.req = &resource.BatchRequest{ |
||||
Key: &resource.ResourceKey{ |
||||
Group: r.group.buffer[i].String(), |
||||
Resource: r.resource.buffer[i].String(), |
||||
Namespace: r.namespace.buffer[i].String(), |
||||
Name: r.name.buffer[i].String(), |
||||
}, |
||||
Action: resource.BatchRequest_Action(r.action.buffer[i]), |
||||
Value: r.value.buffer[i].Bytes(), |
||||
Folder: r.folder.buffer[i].String(), |
||||
} |
||||
|
||||
return true |
||||
} |
||||
|
||||
r.rowGroupIDX++ |
||||
if r.rowGroupIDX >= r.reader.NumRowGroups() { |
||||
_ = r.reader.Close() |
||||
r.reader = nil |
||||
return false |
||||
} |
||||
r.err = r.open(r.reader.RowGroup(r.rowGroupIDX)) |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
// Request implements resource.BatchRequestIterator.
|
||||
func (r *parquetReader) Request() *resource.BatchRequest { |
||||
return r.req |
||||
} |
||||
|
||||
// RollbackRequested implements resource.BatchRequestIterator.
|
||||
func (r *parquetReader) RollbackRequested() bool { |
||||
return r.err != nil |
||||
} |
||||
|
||||
func newResourceReader(inputPath string, batchSize int64) (*parquetReader, error) { |
||||
rdr, err := file.OpenParquetFile(inputPath, true) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
schema := rdr.MetaData().Schema |
||||
makeColumn := func(name string) *stringColumn { |
||||
index := schema.ColumnIndexByName(name) |
||||
if index < 0 { |
||||
err = fmt.Errorf("missing column: %s", name) |
||||
} |
||||
return &stringColumn{ |
||||
index: index, |
||||
buffer: make([]parquet.ByteArray, batchSize), |
||||
} |
||||
} |
||||
|
||||
reader := &parquetReader{ |
||||
reader: rdr, |
||||
|
||||
namespace: makeColumn("namespace"), |
||||
group: makeColumn("group"), |
||||
resource: makeColumn("resource"), |
||||
name: makeColumn("name"), |
||||
value: makeColumn("value"), |
||||
folder: makeColumn("folder"), |
||||
|
||||
action: &int32Column{ |
||||
index: schema.ColumnIndexByName("action"), |
||||
buffer: make([]int32, batchSize), |
||||
}, |
||||
|
||||
batchSize: batchSize, |
||||
defLevels: make([]int16, batchSize), |
||||
repLevels: make([]int16, batchSize), |
||||
} |
||||
|
||||
if err != nil { |
||||
_ = rdr.Close() |
||||
return nil, err |
||||
} |
||||
|
||||
reader.columns = []columnBuffer{ |
||||
reader.namespace, |
||||
reader.group, |
||||
reader.resource, |
||||
reader.name, |
||||
reader.action, |
||||
reader.value, |
||||
} |
||||
|
||||
// Empty file, close and return
|
||||
if rdr.NumRowGroups() < 1 { |
||||
err = rdr.Close() |
||||
reader.reader = nil |
||||
return reader, err |
||||
} |
||||
|
||||
err = reader.open(rdr.RowGroup(0)) |
||||
if err != nil { |
||||
_ = rdr.Close() |
||||
return nil, err |
||||
} |
||||
|
||||
// get the first batch
|
||||
err = reader.readBatch() |
||||
if err != nil { |
||||
_ = rdr.Close() |
||||
return nil, err |
||||
} |
||||
|
||||
return reader, nil |
||||
} |
||||
|
||||
func (r *parquetReader) open(rgr *file.RowGroupReader) error { |
||||
for _, c := range r.columns { |
||||
err := c.open(rgr) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (r *parquetReader) readBatch() error { |
||||
r.bufferIndex = 0 |
||||
r.bufferSize = 0 |
||||
for i, c := range r.columns { |
||||
count, err := c.batch(r.batchSize, r.defLevels, r.repLevels) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if i > 0 && r.bufferSize != count { |
||||
return fmt.Errorf("expecting the same size for all columns") |
||||
} |
||||
r.bufferSize = count |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
//-------------------------------
|
||||
// Column support
|
||||
//-------------------------------
|
||||
|
||||
type columnBuffer interface { |
||||
open(rgr *file.RowGroupReader) error |
||||
batch(batchSize int64, defLevels []int16, repLevels []int16) (int, error) |
||||
} |
||||
|
||||
type stringColumn struct { |
||||
index int // within the schema
|
||||
reader *file.ByteArrayColumnChunkReader |
||||
buffer []parquet.ByteArray |
||||
count int // the active count
|
||||
} |
||||
|
||||
func (c *stringColumn) open(rgr *file.RowGroupReader) error { |
||||
tmp, err := rgr.Column(c.index) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
var ok bool |
||||
c.reader, ok = tmp.(*file.ByteArrayColumnChunkReader) |
||||
if !ok { |
||||
return fmt.Errorf("expected resource strings") |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (c *stringColumn) batch(batchSize int64, defLevels []int16, repLevels []int16) (int, error) { |
||||
_, count, err := c.reader.ReadBatch(batchSize, c.buffer, defLevels, repLevels) |
||||
c.count = count |
||||
return count, err |
||||
} |
||||
|
||||
type int32Column struct { |
||||
index int // within the schemna
|
||||
reader *file.Int32ColumnChunkReader |
||||
buffer []int32 |
||||
count int // the active count
|
||||
} |
||||
|
||||
func (c *int32Column) open(rgr *file.RowGroupReader) error { |
||||
tmp, err := rgr.Column(c.index) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
var ok bool |
||||
c.reader, ok = tmp.(*file.Int32ColumnChunkReader) |
||||
if !ok { |
||||
return fmt.Errorf("expected resource strings") |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (c *int32Column) batch(batchSize int64, defLevels []int16, repLevels []int16) (int, error) { |
||||
_, count, err := c.reader.ReadBatch(batchSize, c.buffer, defLevels, repLevels) |
||||
c.count = count |
||||
return count, err |
||||
} |
||||
|
||||
//-------------------------------
|
||||
// Column support
|
||||
//-------------------------------
|
@ -0,0 +1,125 @@ |
||||
package parquet |
||||
|
||||
import ( |
||||
"context" |
||||
"os" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils" |
||||
"github.com/grafana/grafana/pkg/storage/unified/resource" |
||||
) |
||||
|
||||
func TestParquetWriteThenRead(t *testing.T) { |
||||
t.Run("read-write-couple-rows", func(t *testing.T) { |
||||
file, err := os.CreateTemp(t.TempDir(), "temp-*.parquet") |
||||
require.NoError(t, err) |
||||
defer func() { _ = os.Remove(file.Name()) }() |
||||
|
||||
writer, err := NewParquetWriter(file) |
||||
require.NoError(t, err) |
||||
ctx := context.Background() |
||||
|
||||
require.NoError(t, writer.Write(toKeyAndBytes(ctx, "ggg", "rrr", &unstructured.Unstructured{ |
||||
Object: map[string]any{ |
||||
"metadata": map[string]any{ |
||||
"namespace": "ns", |
||||
"name": "aaa", |
||||
"resourceVersion": "1234", |
||||
"annotations": map[string]string{ |
||||
utils.AnnoKeyFolder: "xyz", |
||||
}, |
||||
}, |
||||
"spec": map[string]any{ |
||||
"hello": "first", |
||||
}, |
||||
}, |
||||
}))) |
||||
|
||||
require.NoError(t, writer.Write(toKeyAndBytes(ctx, "ggg", "rrr", &unstructured.Unstructured{ |
||||
Object: map[string]any{ |
||||
"metadata": map[string]any{ |
||||
"namespace": "ns", |
||||
"name": "bbb", |
||||
"resourceVersion": "5678", |
||||
"generation": -999, // deleted action
|
||||
}, |
||||
"spec": map[string]any{ |
||||
"hello": "second", |
||||
}, |
||||
}, |
||||
}))) |
||||
|
||||
require.NoError(t, writer.Write(toKeyAndBytes(ctx, "ggg", "rrr", &unstructured.Unstructured{ |
||||
Object: map[string]any{ |
||||
"metadata": map[string]any{ |
||||
"namespace": "ns", |
||||
"name": "ccc", |
||||
"resourceVersion": "789", |
||||
"generation": 3, // modified action
|
||||
}, |
||||
"spec": map[string]any{ |
||||
"hello": "thirt", |
||||
}, |
||||
}, |
||||
}))) |
||||
|
||||
res, err := writer.CloseWithResults() |
||||
require.NoError(t, err) |
||||
require.Equal(t, int64(3), res.Processed) |
||||
|
||||
var keys []string |
||||
reader, err := newResourceReader(file.Name(), 20) |
||||
require.NoError(t, err) |
||||
for reader.Next() { |
||||
req := reader.Request() |
||||
keys = append(keys, req.Key.SearchID()) |
||||
} |
||||
|
||||
// Verify that we read all values
|
||||
require.Equal(t, []string{ |
||||
"rrr/ns/ggg/aaa", |
||||
"rrr/ns/ggg/bbb", |
||||
"rrr/ns/ggg/ccc", |
||||
}, keys) |
||||
}) |
||||
|
||||
t.Run("read-write-empty-db", func(t *testing.T) { |
||||
file, err := os.CreateTemp(t.TempDir(), "temp-*.parquet") |
||||
require.NoError(t, err) |
||||
defer func() { _ = os.Remove(file.Name()) }() |
||||
|
||||
writer, err := NewParquetWriter(file) |
||||
require.NoError(t, err) |
||||
err = writer.Close() |
||||
require.NoError(t, err) |
||||
|
||||
var keys []string |
||||
reader, err := newResourceReader(file.Name(), 20) |
||||
require.NoError(t, err) |
||||
for reader.Next() { |
||||
req := reader.Request() |
||||
keys = append(keys, req.Key.SearchID()) |
||||
} |
||||
require.NoError(t, reader.err) |
||||
require.Empty(t, keys) |
||||
}) |
||||
} |
||||
|
||||
func toKeyAndBytes(ctx context.Context, group string, res string, obj *unstructured.Unstructured) (context.Context, *resource.ResourceKey, []byte) { |
||||
if obj.GetKind() == "" { |
||||
obj.SetKind(res) |
||||
} |
||||
if obj.GetAPIVersion() == "" { |
||||
obj.SetAPIVersion(group + "/vXyz") |
||||
} |
||||
data, _ := obj.MarshalJSON() |
||||
return ctx, &resource.ResourceKey{ |
||||
Namespace: obj.GetNamespace(), |
||||
Resource: res, |
||||
Group: group, |
||||
Name: obj.GetName(), |
||||
}, data |
||||
} |
@ -0,0 +1,209 @@ |
||||
package parquet |
||||
|
||||
import ( |
||||
"context" |
||||
"io" |
||||
|
||||
"github.com/apache/arrow-go/v18/arrow" |
||||
"github.com/apache/arrow-go/v18/arrow/array" |
||||
"github.com/apache/arrow-go/v18/arrow/memory" |
||||
"github.com/apache/arrow-go/v18/parquet" |
||||
"github.com/apache/arrow-go/v18/parquet/compress" |
||||
"github.com/apache/arrow-go/v18/parquet/pqarrow" |
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging" |
||||
"github.com/grafana/grafana/pkg/apimachinery/utils" |
||||
"github.com/grafana/grafana/pkg/storage/unified/resource" |
||||
) |
||||
|
||||
var ( |
||||
_ resource.BatchResourceWriter = (*parquetWriter)(nil) |
||||
) |
||||
|
||||
// Write resources into a parquet file
|
||||
func NewParquetWriter(f io.Writer) (*parquetWriter, error) { |
||||
w := &parquetWriter{ |
||||
pool: memory.DefaultAllocator, |
||||
schema: newSchema(nil), |
||||
buffer: 1024 * 10 * 100 * 10, // 10MB
|
||||
logger: logging.DefaultLogger.With("logger", "parquet.writer"), |
||||
rsp: &resource.BatchResponse{}, |
||||
summary: make(map[string]*resource.BatchResponse_Summary), |
||||
} |
||||
|
||||
props := parquet.NewWriterProperties( |
||||
parquet.WithCompression(compress.Codecs.Brotli), |
||||
) |
||||
writer, err := pqarrow.NewFileWriter(w.schema, f, props, pqarrow.DefaultWriterProps()) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
w.writer = writer |
||||
return w, w.init() |
||||
} |
||||
|
||||
// ProcessBatch implements resource.BatchProcessingBackend.
|
||||
func (w *parquetWriter) ProcessBatch(ctx context.Context, setting resource.BatchSettings, iter resource.BatchRequestIterator) *resource.BatchResponse { |
||||
defer func() { _ = w.Close() }() |
||||
|
||||
var err error |
||||
for iter.Next() { |
||||
if iter.RollbackRequested() { |
||||
break |
||||
} |
||||
|
||||
req := iter.Request() |
||||
|
||||
err = w.Write(ctx, req.Key, req.Value) |
||||
if err != nil { |
||||
break |
||||
} |
||||
} |
||||
|
||||
rsp, err := w.CloseWithResults() |
||||
if err != nil { |
||||
w.logger.Warn("error closing parquet file", "err", err) |
||||
} |
||||
if rsp == nil { |
||||
rsp = &resource.BatchResponse{} |
||||
} |
||||
if err != nil { |
||||
rsp.Error = resource.AsErrorResult(err) |
||||
} |
||||
return rsp |
||||
} |
||||
|
||||
type parquetWriter struct { |
||||
pool memory.Allocator |
||||
buffer int |
||||
wrote int |
||||
|
||||
schema *arrow.Schema |
||||
writer *pqarrow.FileWriter |
||||
logger logging.Logger |
||||
|
||||
rv *array.Int64Builder |
||||
namespace *array.StringBuilder |
||||
group *array.StringBuilder |
||||
resource *array.StringBuilder |
||||
name *array.StringBuilder |
||||
folder *array.StringBuilder |
||||
action *array.Int8Builder |
||||
value *array.StringBuilder |
||||
|
||||
rsp *resource.BatchResponse |
||||
summary map[string]*resource.BatchResponse_Summary |
||||
} |
||||
|
||||
func (w *parquetWriter) CloseWithResults() (*resource.BatchResponse, error) { |
||||
err := w.Close() |
||||
return w.rsp, err |
||||
} |
||||
|
||||
func (w *parquetWriter) Close() error { |
||||
if w.rv.Len() > 0 { |
||||
_ = w.flush() |
||||
} |
||||
w.logger.Info("close") |
||||
return w.writer.Close() |
||||
} |
||||
|
||||
// writes the current buffer to parquet and re-inits the arrow buffer
|
||||
func (w *parquetWriter) flush() error { |
||||
w.logger.Info("flush", "count", w.rv.Len()) |
||||
rec := array.NewRecord(w.schema, []arrow.Array{ |
||||
w.rv.NewArray(), |
||||
w.namespace.NewArray(), |
||||
w.group.NewArray(), |
||||
w.resource.NewArray(), |
||||
w.name.NewArray(), |
||||
w.folder.NewArray(), |
||||
w.action.NewArray(), |
||||
w.value.NewArray(), |
||||
}, int64(w.rv.Len())) |
||||
defer rec.Release() |
||||
err := w.writer.Write(rec) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
return w.init() |
||||
} |
||||
|
||||
func (w *parquetWriter) init() error { |
||||
w.rv = array.NewInt64Builder(w.pool) |
||||
w.namespace = array.NewStringBuilder(w.pool) |
||||
w.group = array.NewStringBuilder(w.pool) |
||||
w.resource = array.NewStringBuilder(w.pool) |
||||
w.name = array.NewStringBuilder(w.pool) |
||||
w.folder = array.NewStringBuilder(w.pool) |
||||
w.action = array.NewInt8Builder(w.pool) |
||||
w.value = array.NewStringBuilder(w.pool) |
||||
w.wrote = 0 |
||||
return nil |
||||
} |
||||
|
||||
func (w *parquetWriter) Write(ctx context.Context, key *resource.ResourceKey, value []byte) error { |
||||
w.rsp.Processed++ |
||||
obj := &unstructured.Unstructured{} |
||||
err := obj.UnmarshalJSON(value) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
meta, err := utils.MetaAccessor(obj) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
rv, _ := meta.GetResourceVersionInt64() // it can be empty
|
||||
|
||||
w.rv.Append(rv) |
||||
w.namespace.Append(key.Namespace) |
||||
w.group.Append(key.Group) |
||||
w.resource.Append(key.Resource) |
||||
w.name.Append(key.Name) |
||||
w.folder.Append(meta.GetFolder()) |
||||
w.value.Append(string(value)) |
||||
|
||||
var action resource.WatchEvent_Type |
||||
switch meta.GetGeneration() { |
||||
case 0, 1: |
||||
action = resource.WatchEvent_ADDED |
||||
case utils.DeletedGeneration: |
||||
action = resource.WatchEvent_DELETED |
||||
default: |
||||
action = resource.WatchEvent_MODIFIED |
||||
} |
||||
w.action.Append(int8(action)) |
||||
|
||||
w.wrote = w.wrote + len(value) |
||||
if w.wrote > w.buffer { |
||||
w.logger.Info("buffer full", "buffer", w.wrote, "max", w.buffer) |
||||
return w.flush() |
||||
} |
||||
|
||||
summary := w.summary[key.BatchID()] |
||||
if summary == nil { |
||||
summary = &resource.BatchResponse_Summary{ |
||||
Namespace: key.Namespace, |
||||
Group: key.Group, |
||||
Resource: key.Resource, |
||||
} |
||||
w.summary[key.BatchID()] = summary |
||||
w.rsp.Summary = append(w.rsp.Summary, summary) |
||||
} |
||||
summary.Count++ |
||||
return nil |
||||
} |
||||
|
||||
func newSchema(metadata *arrow.Metadata) *arrow.Schema { |
||||
return arrow.NewSchema([]arrow.Field{ |
||||
{Name: "resource_version", Type: &arrow.Int64Type{}, Nullable: false}, |
||||
{Name: "group", Type: &arrow.StringType{}, Nullable: false}, |
||||
{Name: "resource", Type: &arrow.StringType{}, Nullable: false}, |
||||
{Name: "namespace", Type: &arrow.StringType{}, Nullable: false}, |
||||
{Name: "name", Type: &arrow.StringType{}, Nullable: false}, |
||||
{Name: "folder", Type: &arrow.StringType{}, Nullable: false}, |
||||
{Name: "action", Type: &arrow.Int8Type{}, Nullable: false}, // 1,2,3
|
||||
{Name: "value", Type: &arrow.StringType{}, Nullable: false}, |
||||
}, metadata) |
||||
} |
@ -0,0 +1,297 @@ |
||||
package resource |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
|
||||
"google.golang.org/grpc/metadata" |
||||
|
||||
authlib "github.com/grafana/authlib/types" |
||||
"github.com/grafana/grafana/pkg/apimachinery/utils" |
||||
) |
||||
|
||||
const grpcMetaKeyCollection = "x-gf-batch-collection" |
||||
const grpcMetaKeyRebuildCollection = "x-gf-batch-rebuild-collection" |
||||
const grpcMetaKeySkipValidation = "x-gf-batch-skip-validation" |
||||
|
||||
func grpcMetaValueIsTrue(vals []string) bool { |
||||
return len(vals) == 1 && vals[0] == "true" |
||||
} |
||||
|
||||
type BatchRequestIterator interface { |
||||
Next() bool |
||||
|
||||
// The next event we should process
|
||||
Request() *BatchRequest |
||||
|
||||
// Rollback requested
|
||||
RollbackRequested() bool |
||||
} |
||||
|
||||
type BatchProcessingBackend interface { |
||||
ProcessBatch(ctx context.Context, setting BatchSettings, iter BatchRequestIterator) *BatchResponse |
||||
} |
||||
|
||||
type BatchResourceWriter interface { |
||||
io.Closer |
||||
|
||||
Write(ctx context.Context, key *ResourceKey, value []byte) error |
||||
|
||||
// Called when finished writing
|
||||
CloseWithResults() (*BatchResponse, error) |
||||
} |
||||
|
||||
type BatchSettings struct { |
||||
// All requests will be within this namespace/group/resource
|
||||
Collection []*ResourceKey |
||||
|
||||
// The batch will include everything from the collection
|
||||
// - all existing values will be removed/replaced if the batch completes successfully
|
||||
RebuildCollection bool |
||||
|
||||
// The byte[] payload and folder has already been validated - no need to decode and verify
|
||||
SkipValidation bool |
||||
} |
||||
|
||||
func (x *BatchSettings) ToMD() metadata.MD { |
||||
md := make(metadata.MD) |
||||
if len(x.Collection) > 0 { |
||||
for _, v := range x.Collection { |
||||
md[grpcMetaKeyCollection] = append(md[grpcMetaKeyCollection], v.SearchID()) |
||||
} |
||||
} |
||||
if x.RebuildCollection { |
||||
md[grpcMetaKeyRebuildCollection] = []string{"true"} |
||||
} |
||||
if x.SkipValidation { |
||||
md[grpcMetaKeySkipValidation] = []string{"true"} |
||||
} |
||||
return md |
||||
} |
||||
|
||||
func NewBatchSettings(md metadata.MD) (BatchSettings, error) { |
||||
settings := BatchSettings{} |
||||
for k, v := range md { |
||||
switch k { |
||||
case grpcMetaKeyCollection: |
||||
for _, c := range v { |
||||
key := &ResourceKey{} |
||||
err := key.ReadSearchID(c) |
||||
if err != nil { |
||||
return settings, fmt.Errorf("error reading collection metadata: %s / %w", c, err) |
||||
} |
||||
settings.Collection = append(settings.Collection, key) |
||||
} |
||||
case grpcMetaKeyRebuildCollection: |
||||
settings.RebuildCollection = grpcMetaValueIsTrue(v) |
||||
case grpcMetaKeySkipValidation: |
||||
settings.SkipValidation = grpcMetaValueIsTrue(v) |
||||
} |
||||
} |
||||
return settings, nil |
||||
} |
||||
|
||||
// BatchWrite implements ResourceServer.
|
||||
// All requests must be to the same NAMESPACE/GROUP/RESOURCE
|
||||
func (s *server) BatchProcess(stream BatchStore_BatchProcessServer) error { |
||||
ctx := stream.Context() |
||||
user, ok := authlib.AuthInfoFrom(ctx) |
||||
if !ok || user == nil { |
||||
return stream.SendAndClose(&BatchResponse{ |
||||
Error: &ErrorResult{ |
||||
Message: "no user found in context", |
||||
Code: http.StatusUnauthorized, |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
md, ok := metadata.FromIncomingContext(ctx) |
||||
if !ok { |
||||
return stream.SendAndClose(&BatchResponse{ |
||||
Error: &ErrorResult{ |
||||
Message: "unable to read metadata gRPC request", |
||||
Code: http.StatusPreconditionFailed, |
||||
}, |
||||
}) |
||||
} |
||||
runner := &batchRunner{ |
||||
checker: make(map[string]authlib.ItemChecker), // Can create
|
||||
stream: stream, |
||||
} |
||||
settings, err := NewBatchSettings(md) |
||||
if err != nil { |
||||
return stream.SendAndClose(&BatchResponse{ |
||||
Error: &ErrorResult{ |
||||
Message: "error reading settings", |
||||
Reason: err.Error(), |
||||
Code: http.StatusPreconditionFailed, |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
if len(settings.Collection) < 1 { |
||||
return stream.SendAndClose(&BatchResponse{ |
||||
Error: &ErrorResult{ |
||||
Message: "Missing target collection(s) in request header", |
||||
Code: http.StatusBadRequest, |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
// HACK!!! always allow everything!!!!!!
|
||||
access := authlib.FixedAccessClient(true) |
||||
|
||||
if settings.RebuildCollection { |
||||
for _, k := range settings.Collection { |
||||
// Can we delete the whole collection
|
||||
rsp, err := access.Check(ctx, user, authlib.CheckRequest{ |
||||
Namespace: k.Namespace, |
||||
Group: k.Group, |
||||
Resource: k.Resource, |
||||
Verb: utils.VerbDeleteCollection, |
||||
}) |
||||
if err != nil || !rsp.Allowed { |
||||
return stream.SendAndClose(&BatchResponse{ |
||||
Error: &ErrorResult{ |
||||
Message: fmt.Sprintf("Requester must be able to: %s", utils.VerbDeleteCollection), |
||||
Code: http.StatusForbidden, |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
// This will be called for each request -- with the folder ID
|
||||
runner.checker[k.BatchID()], err = access.Compile(ctx, user, authlib.ListRequest{ |
||||
Namespace: k.Namespace, |
||||
Group: k.Group, |
||||
Resource: k.Resource, |
||||
Verb: utils.VerbCreate, |
||||
}) |
||||
if err != nil { |
||||
return stream.SendAndClose(&BatchResponse{ |
||||
Error: &ErrorResult{ |
||||
Message: "Unable to check `create` permission", |
||||
Code: http.StatusForbidden, |
||||
}, |
||||
}) |
||||
} |
||||
} |
||||
} else { |
||||
return stream.SendAndClose(&BatchResponse{ |
||||
Error: &ErrorResult{ |
||||
Message: "Batch currently only supports RebuildCollection", |
||||
Code: http.StatusBadRequest, |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
backend, ok := s.backend.(BatchProcessingBackend) |
||||
if !ok { |
||||
return stream.SendAndClose(&BatchResponse{ |
||||
Error: &ErrorResult{ |
||||
Message: "The server backend does not support batch processing", |
||||
Code: http.StatusNotImplemented, |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
// BatchProcess requests
|
||||
rsp := backend.ProcessBatch(ctx, settings, runner) |
||||
if rsp == nil { |
||||
rsp = &BatchResponse{ |
||||
Error: &ErrorResult{ |
||||
Code: http.StatusInternalServerError, |
||||
Message: "Nothing returned from process batch", |
||||
}, |
||||
} |
||||
} |
||||
if runner.err != nil { |
||||
rsp.Error = AsErrorResult(runner.err) |
||||
} |
||||
|
||||
if rsp.Error == nil && s.search != nil { |
||||
// Rebuild any changed indexes
|
||||
for _, summary := range rsp.Summary { |
||||
_, _, err := s.search.build(ctx, NamespacedResource{ |
||||
Namespace: summary.Namespace, |
||||
Group: summary.Group, |
||||
Resource: summary.Resource, |
||||
}, summary.Count, summary.ResourceVersion) |
||||
if err != nil { |
||||
s.log.Warn("error building search index after batch load", "err", err) |
||||
rsp.Error = &ErrorResult{ |
||||
Code: http.StatusInternalServerError, |
||||
Message: "err building search index: " + summary.Resource, |
||||
Reason: err.Error(), |
||||
} |
||||
} |
||||
} |
||||
} |
||||
return stream.SendAndClose(rsp) |
||||
} |
||||
|
||||
var ( |
||||
_ BatchRequestIterator = (*batchRunner)(nil) |
||||
) |
||||
|
||||
type batchRunner struct { |
||||
stream BatchStore_BatchProcessServer |
||||
rollback bool |
||||
request *BatchRequest |
||||
err error |
||||
checker map[string]authlib.ItemChecker |
||||
} |
||||
|
||||
// Next implements BatchRequestIterator.
|
||||
func (b *batchRunner) Next() bool { |
||||
if b.rollback { |
||||
return true |
||||
} |
||||
|
||||
b.request, b.err = b.stream.Recv() |
||||
if errors.Is(b.err, io.EOF) { |
||||
b.err = nil |
||||
b.rollback = false |
||||
b.request = nil |
||||
return false |
||||
} |
||||
|
||||
if b.err != nil { |
||||
b.rollback = true |
||||
return true |
||||
} |
||||
|
||||
if b.request != nil { |
||||
key := b.request.Key |
||||
k := key.BatchID() |
||||
checker, ok := b.checker[k] |
||||
if !ok { |
||||
b.err = fmt.Errorf("missing access control for: %s", k) |
||||
b.rollback = true |
||||
} else if !checker(key.Namespace, key.Name, b.request.Folder) { |
||||
b.err = fmt.Errorf("not allowed to create resource") |
||||
b.rollback = true |
||||
} |
||||
return true |
||||
} |
||||
return false |
||||
} |
||||
|
||||
// Request implements BatchRequestIterator.
|
||||
func (b *batchRunner) Request() *BatchRequest { |
||||
if b.rollback { |
||||
return nil |
||||
} |
||||
return b.request |
||||
} |
||||
|
||||
// RollbackRequested implements BatchRequestIterator.
|
||||
func (b *batchRunner) RollbackRequested() bool { |
||||
if b.rollback { |
||||
b.rollback = false // break iterator
|
||||
return true |
||||
} |
||||
return false |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,338 @@ |
||||
package sql |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"net/http" |
||||
"os" |
||||
"sync" |
||||
"time" |
||||
|
||||
"github.com/google/uuid" |
||||
apierrors "k8s.io/apimachinery/pkg/api/errors" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging" |
||||
"github.com/grafana/grafana/pkg/apimachinery/utils" |
||||
"github.com/grafana/grafana/pkg/storage/unified/parquet" |
||||
"github.com/grafana/grafana/pkg/storage/unified/resource" |
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/db" |
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" |
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" |
||||
) |
||||
|
||||
var ( |
||||
_ resource.BatchProcessingBackend = (*backend)(nil) |
||||
) |
||||
|
||||
type batchRV struct { |
||||
max int64 |
||||
counter int64 |
||||
} |
||||
|
||||
func newBatchRV() *batchRV { |
||||
t := time.Now().Truncate(time.Second * 10) |
||||
return &batchRV{ |
||||
max: (t.UnixMicro() / 10000000) * 10000000, |
||||
counter: 0, |
||||
} |
||||
} |
||||
|
||||
func (x *batchRV) next(obj metav1.Object) int64 { |
||||
ts := obj.GetCreationTimestamp().UnixMicro() |
||||
anno := obj.GetAnnotations() |
||||
if anno != nil { |
||||
v := anno[utils.AnnoKeyUpdatedTimestamp] |
||||
t, err := time.Parse(time.RFC3339, v) |
||||
if err == nil { |
||||
ts = t.UnixMicro() |
||||
} |
||||
} |
||||
if ts > x.max || ts < 10000000 { |
||||
ts = x.max |
||||
} |
||||
x.counter++ |
||||
return (ts/10000000)*10000000 + x.counter |
||||
} |
||||
|
||||
type batchLock struct { |
||||
running map[string]bool |
||||
mu sync.Mutex |
||||
} |
||||
|
||||
func (x *batchLock) Start(keys []*resource.ResourceKey) error { |
||||
x.mu.Lock() |
||||
defer x.mu.Unlock() |
||||
|
||||
// First verify that it is not already running
|
||||
ids := make([]string, len(keys)) |
||||
for i, k := range keys { |
||||
id := k.BatchID() |
||||
if x.running[id] { |
||||
return &apierrors.StatusError{ErrStatus: metav1.Status{ |
||||
Code: http.StatusPreconditionFailed, |
||||
Message: "batch export is already running", |
||||
}} |
||||
} |
||||
ids[i] = id |
||||
} |
||||
|
||||
// Then add the keys to the lock
|
||||
for _, k := range ids { |
||||
x.running[k] = true |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (x *batchLock) Finish(keys []*resource.ResourceKey) { |
||||
x.mu.Lock() |
||||
defer x.mu.Unlock() |
||||
for _, k := range keys { |
||||
delete(x.running, k.BatchID()) |
||||
} |
||||
} |
||||
|
||||
func (x *batchLock) Active() bool { |
||||
x.mu.Lock() |
||||
defer x.mu.Unlock() |
||||
return len(x.running) > 0 |
||||
} |
||||
|
||||
func (b *backend) ProcessBatch(ctx context.Context, setting resource.BatchSettings, iter resource.BatchRequestIterator) *resource.BatchResponse { |
||||
err := b.batchLock.Start(setting.Collection) |
||||
if err != nil { |
||||
return &resource.BatchResponse{ |
||||
Error: resource.AsErrorResult(err), |
||||
} |
||||
} |
||||
defer b.batchLock.Finish(setting.Collection) |
||||
|
||||
// We may want to first write parquet, then read parquet
|
||||
if b.dialect.DialectName() == "sqlite" { |
||||
file, err := os.CreateTemp("", "grafana-batch-export-*.parquet") |
||||
if err != nil { |
||||
return &resource.BatchResponse{ |
||||
Error: resource.AsErrorResult(err), |
||||
} |
||||
} |
||||
|
||||
writer, err := parquet.NewParquetWriter(file) |
||||
if err != nil { |
||||
return &resource.BatchResponse{ |
||||
Error: resource.AsErrorResult(err), |
||||
} |
||||
} |
||||
|
||||
// write batch to parquet
|
||||
rsp := writer.ProcessBatch(ctx, setting, iter) |
||||
if rsp.Error != nil { |
||||
return rsp |
||||
} |
||||
|
||||
b.log.Info("using parquet buffer", "parquet", file) |
||||
|
||||
// Replace the iterator with one from parquet
|
||||
iter, err = parquet.NewParquetReader(file.Name(), 50) |
||||
if err != nil { |
||||
return &resource.BatchResponse{ |
||||
Error: resource.AsErrorResult(err), |
||||
} |
||||
} |
||||
} |
||||
|
||||
return b.processBatch(ctx, setting, iter) |
||||
} |
||||
|
||||
// internal batch process
|
||||
func (b *backend) processBatch(ctx context.Context, setting resource.BatchSettings, iter resource.BatchRequestIterator) *resource.BatchResponse { |
||||
rsp := &resource.BatchResponse{} |
||||
err := b.db.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error { |
||||
rollbackWithError := func(err error) error { |
||||
txerr := tx.Rollback() |
||||
if txerr != nil { |
||||
b.log.Warn("rollback", "error", txerr) |
||||
} else { |
||||
b.log.Info("rollback") |
||||
} |
||||
return err |
||||
} |
||||
batch := &batchWroker{ |
||||
ctx: ctx, |
||||
tx: tx, |
||||
dialect: b.dialect, |
||||
logger: logging.FromContext(ctx), |
||||
} |
||||
|
||||
// Calculate the RV based on incoming request timestamps
|
||||
rv := newBatchRV() |
||||
|
||||
summaries := make(map[string]*resource.BatchResponse_Summary, len(setting.Collection)*4) |
||||
|
||||
// First clear everything in the transaction
|
||||
if setting.RebuildCollection { |
||||
for _, key := range setting.Collection { |
||||
summary, err := batch.deleteCollection(key) |
||||
if err != nil { |
||||
return rollbackWithError(err) |
||||
} |
||||
summaries[key.BatchID()] = summary |
||||
rsp.Summary = append(rsp.Summary, summary) |
||||
} |
||||
} |
||||
|
||||
obj := &unstructured.Unstructured{} |
||||
|
||||
// Write each event into the history
|
||||
for iter.Next() { |
||||
if iter.RollbackRequested() { |
||||
return rollbackWithError(nil) |
||||
} |
||||
req := iter.Request() |
||||
if req == nil { |
||||
return rollbackWithError(fmt.Errorf("missing request")) |
||||
} |
||||
rsp.Processed++ |
||||
|
||||
if req.Action == resource.BatchRequest_UNKNOWN { |
||||
rsp.Rejected = append(rsp.Rejected, &resource.BatchResponse_Rejected{ |
||||
Key: req.Key, |
||||
Action: req.Action, |
||||
Error: "unknown action", |
||||
}) |
||||
continue |
||||
} |
||||
|
||||
err := obj.UnmarshalJSON(req.Value) |
||||
if err != nil { |
||||
rsp.Rejected = append(rsp.Rejected, &resource.BatchResponse_Rejected{ |
||||
Key: req.Key, |
||||
Action: req.Action, |
||||
Error: "unable to unmarshal json", |
||||
}) |
||||
continue |
||||
} |
||||
|
||||
// Write the event to history
|
||||
if _, err := dbutil.Exec(ctx, tx, sqlResourceHistoryInsert, sqlResourceRequest{ |
||||
SQLTemplate: sqltemplate.New(b.dialect), |
||||
WriteEvent: resource.WriteEvent{ |
||||
Key: req.Key, |
||||
Type: resource.WatchEvent_Type(req.Action), |
||||
Value: req.Value, |
||||
PreviousRV: -1, // Used for WATCH, but we want to skip watch events
|
||||
}, |
||||
Folder: req.Folder, |
||||
GUID: uuid.NewString(), |
||||
ResourceVersion: rv.next(obj), |
||||
}); err != nil { |
||||
return rollbackWithError(fmt.Errorf("insert into resource history: %w", err)) |
||||
} |
||||
} |
||||
|
||||
// Now update the resource table from history
|
||||
for _, key := range setting.Collection { |
||||
k := fmt.Sprintf("%s/%s/%s", key.Namespace, key.Group, key.Resource) |
||||
summary := summaries[k] |
||||
if summary == nil { |
||||
return rollbackWithError(fmt.Errorf("missing summary key for: %s", k)) |
||||
} |
||||
|
||||
err := batch.syncCollection(key, summary) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Make sure the collection RV is above our last written event
|
||||
_, err = b.resourceVersionAtomicInc(ctx, tx, key) |
||||
if err != nil { |
||||
b.log.Warn("error increasing RV", "error", err) |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
rsp.Error = resource.AsErrorResult(err) |
||||
} |
||||
return rsp |
||||
} |
||||
|
||||
type batchWroker struct { |
||||
ctx context.Context |
||||
tx db.ContextExecer |
||||
dialect sqltemplate.Dialect |
||||
logger logging.Logger |
||||
} |
||||
|
||||
// This will remove everything from the `resource` and `resource_history` table for a given namespace/group/resource
|
||||
func (w *batchWroker) deleteCollection(key *resource.ResourceKey) (*resource.BatchResponse_Summary, error) { |
||||
summary := &resource.BatchResponse_Summary{ |
||||
Namespace: key.Namespace, |
||||
Group: key.Group, |
||||
Resource: key.Resource, |
||||
} |
||||
|
||||
// First delete history
|
||||
res, err := dbutil.Exec(w.ctx, w.tx, sqlResourceHistoryDelete, &sqlResourceHistoryDeleteRequest{ |
||||
SQLTemplate: sqltemplate.New(w.dialect), |
||||
Namespace: key.Namespace, |
||||
Group: key.Group, |
||||
Resource: key.Resource, |
||||
}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
summary.PreviousHistory, err = res.RowsAffected() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Next delete the active resource table
|
||||
res, err = dbutil.Exec(w.ctx, w.tx, sqlResourceDelete, &sqlResourceRequest{ |
||||
SQLTemplate: sqltemplate.New(w.dialect), |
||||
WriteEvent: resource.WriteEvent{ |
||||
Key: key, |
||||
}, |
||||
}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
summary.PreviousCount, err = res.RowsAffected() |
||||
return summary, err |
||||
} |
||||
|
||||
// Copy the latest value from history into the active resource table
|
||||
func (w *batchWroker) syncCollection(key *resource.ResourceKey, summary *resource.BatchResponse_Summary) error { |
||||
w.logger.Info("synchronize collection", "key", key.BatchID()) |
||||
_, err := dbutil.Exec(w.ctx, w.tx, sqlResourceInsertFromHistory, &sqlResourceInsertFromHistoryRequest{ |
||||
SQLTemplate: sqltemplate.New(w.dialect), |
||||
Key: key, |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
w.logger.Info("get stats (still in transaction)", "key", key.BatchID()) |
||||
rows, err := dbutil.QueryRows(w.ctx, w.tx, sqlResourceStats, &sqlStatsRequest{ |
||||
SQLTemplate: sqltemplate.New(w.dialect), |
||||
Namespace: key.Namespace, |
||||
Group: key.Group, |
||||
Resource: key.Resource, |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if rows != nil { |
||||
defer func() { |
||||
_ = rows.Close() |
||||
}() |
||||
} |
||||
if rows.Next() { |
||||
row := resource.ResourceStats{} |
||||
return rows.Scan(&row.Namespace, &row.Group, &row.Resource, |
||||
&summary.Count, |
||||
&summary.ResourceVersion) |
||||
} |
||||
return err |
||||
} |
@ -0,0 +1,24 @@ |
||||
package sql |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
||||
) |
||||
|
||||
func TestBatch(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
t.Run("rv iterator", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
rv := newBatchRV() |
||||
v0 := rv.next(&unstructured.Unstructured{}) |
||||
v1 := rv.next(&unstructured.Unstructured{}) |
||||
v2 := rv.next(&unstructured.Unstructured{}) |
||||
require.True(t, v0 > 1000) |
||||
require.Equal(t, int64(1), v1-v0) |
||||
require.Equal(t, int64(1), v2-v1) |
||||
}) |
||||
} |
@ -0,0 +1,52 @@ |
||||
INSERT INTO {{ .Ident "resource" }} |
||||
SELECT |
||||
kv.{{ .Ident "guid" }}, |
||||
kv.{{ .Ident "resource_version" }}, |
||||
kv.{{ .Ident "group" }}, |
||||
kv.{{ .Ident "resource" }}, |
||||
kv.{{ .Ident "namespace" }}, |
||||
kv.{{ .Ident "name" }}, |
||||
kv.{{ .Ident "value" }}, |
||||
kv.{{ .Ident "action" }}, |
||||
kv.{{ .Ident "label_set" }}, |
||||
kv.{{ .Ident "previous_resource_version" }}, |
||||
kv.{{ .Ident "folder" }} |
||||
FROM {{ .Ident "resource_history" }} AS kv |
||||
INNER JOIN ( |
||||
SELECT {{ .Ident "namespace" }}, {{ .Ident "group" }}, {{ .Ident "resource" }}, {{ .Ident "name" }}, max({{ .Ident "resource_version" }}) AS {{ .Ident "resource_version" }} |
||||
FROM {{ .Ident "resource_history" }} AS mkv |
||||
WHERE 1 = 1 |
||||
{{ if .Key.Namespace }} |
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Key.Namespace }} |
||||
{{ end }} |
||||
{{ if .Key.Group }} |
||||
AND {{ .Ident "group" }} = {{ .Arg .Key.Group }} |
||||
{{ end }} |
||||
{{ if .Key.Resource }} |
||||
AND {{ .Ident "resource" }} = {{ .Arg .Key.Resource }} |
||||
{{ end }} |
||||
{{ if .Key.Name }} |
||||
AND {{ .Ident "name" }} = {{ .Arg .Key.Name }} |
||||
{{ end }} |
||||
GROUP BY mkv.{{ .Ident "namespace" }}, mkv.{{ .Ident "group" }}, mkv.{{ .Ident "resource" }}, mkv.{{ .Ident "name" }} |
||||
) AS maxkv |
||||
ON maxkv.{{ .Ident "resource_version" }} = kv.{{ .Ident "resource_version" }} |
||||
AND maxkv.{{ .Ident "namespace" }} = kv.{{ .Ident "namespace" }} |
||||
AND maxkv.{{ .Ident "group" }} = kv.{{ .Ident "group" }} |
||||
AND maxkv.{{ .Ident "resource" }} = kv.{{ .Ident "resource" }} |
||||
AND maxkv.{{ .Ident "name" }} = kv.{{ .Ident "name" }} |
||||
WHERE kv.{{ .Ident "action" }} != 3 |
||||
{{ if .Key.Namespace }} |
||||
AND kv.{{ .Ident "namespace" }} = {{ .Arg .Key.Namespace }} |
||||
{{ end }} |
||||
{{ if .Key.Group }} |
||||
AND kv.{{ .Ident "group" }} = {{ .Arg .Key.Group }} |
||||
{{ end }} |
||||
{{ if .Key.Resource }} |
||||
AND kv.{{ .Ident "resource" }} = {{ .Arg .Key.Resource }} |
||||
{{ end }} |
||||
{{ if .Key.Name }} |
||||
AND kv.{{ .Ident "name" }} = {{ .Arg .Key.Name }} |
||||
{{ end }} |
||||
ORDER BY kv.{{ .Ident "resource_version" }} ASC |
||||
; |
@ -0,0 +1,34 @@ |
||||
INSERT INTO `resource` |
||||
SELECT |
||||
kv.`guid`, |
||||
kv.`resource_version`, |
||||
kv.`group`, |
||||
kv.`resource`, |
||||
kv.`namespace`, |
||||
kv.`name`, |
||||
kv.`value`, |
||||
kv.`action`, |
||||
kv.`label_set`, |
||||
kv.`previous_resource_version`, |
||||
kv.`folder` |
||||
FROM `resource_history` AS kv |
||||
INNER JOIN ( |
||||
SELECT `namespace`, `group`, `resource`, `name`, max(`resource_version`) AS `resource_version` |
||||
FROM `resource_history` AS mkv |
||||
WHERE 1 = 1 |
||||
AND `namespace` = 'default' |
||||
AND `group` = 'dashboard.grafana.app' |
||||
AND `resource` = 'dashboards' |
||||
GROUP BY mkv.`namespace`, mkv.`group`, mkv.`resource`, mkv.`name` |
||||
) AS maxkv |
||||
ON maxkv.`resource_version` = kv.`resource_version` |
||||
AND maxkv.`namespace` = kv.`namespace` |
||||
AND maxkv.`group` = kv.`group` |
||||
AND maxkv.`resource` = kv.`resource` |
||||
AND maxkv.`name` = kv.`name` |
||||
WHERE kv.`action` != 3 |
||||
AND kv.`namespace` = 'default' |
||||
AND kv.`group` = 'dashboard.grafana.app' |
||||
AND kv.`resource` = 'dashboards' |
||||
ORDER BY kv.`resource_version` ASC |
||||
; |
@ -0,0 +1,34 @@ |
||||
INSERT INTO "resource" |
||||
SELECT |
||||
kv."guid", |
||||
kv."resource_version", |
||||
kv."group", |
||||
kv."resource", |
||||
kv."namespace", |
||||
kv."name", |
||||
kv."value", |
||||
kv."action", |
||||
kv."label_set", |
||||
kv."previous_resource_version", |
||||
kv."folder" |
||||
FROM "resource_history" AS kv |
||||
INNER JOIN ( |
||||
SELECT "namespace", "group", "resource", "name", max("resource_version") AS "resource_version" |
||||
FROM "resource_history" AS mkv |
||||
WHERE 1 = 1 |
||||
AND "namespace" = 'default' |
||||
AND "group" = 'dashboard.grafana.app' |
||||
AND "resource" = 'dashboards' |
||||
GROUP BY mkv."namespace", mkv."group", mkv."resource", mkv."name" |
||||
) AS maxkv |
||||
ON maxkv."resource_version" = kv."resource_version" |
||||
AND maxkv."namespace" = kv."namespace" |
||||
AND maxkv."group" = kv."group" |
||||
AND maxkv."resource" = kv."resource" |
||||
AND maxkv."name" = kv."name" |
||||
WHERE kv."action" != 3 |
||||
AND kv."namespace" = 'default' |
||||
AND kv."group" = 'dashboard.grafana.app' |
||||
AND kv."resource" = 'dashboards' |
||||
ORDER BY kv."resource_version" ASC |
||||
; |
@ -0,0 +1,34 @@ |
||||
INSERT INTO "resource" |
||||
SELECT |
||||
kv."guid", |
||||
kv."resource_version", |
||||
kv."group", |
||||
kv."resource", |
||||
kv."namespace", |
||||
kv."name", |
||||
kv."value", |
||||
kv."action", |
||||
kv."label_set", |
||||
kv."previous_resource_version", |
||||
kv."folder" |
||||
FROM "resource_history" AS kv |
||||
INNER JOIN ( |
||||
SELECT "namespace", "group", "resource", "name", max("resource_version") AS "resource_version" |
||||
FROM "resource_history" AS mkv |
||||
WHERE 1 = 1 |
||||
AND "namespace" = 'default' |
||||
AND "group" = 'dashboard.grafana.app' |
||||
AND "resource" = 'dashboards' |
||||
GROUP BY mkv."namespace", mkv."group", mkv."resource", mkv."name" |
||||
) AS maxkv |
||||
ON maxkv."resource_version" = kv."resource_version" |
||||
AND maxkv."namespace" = kv."namespace" |
||||
AND maxkv."group" = kv."group" |
||||
AND maxkv."resource" = kv."resource" |
||||
AND maxkv."name" = kv."name" |
||||
WHERE kv."action" != 3 |
||||
AND kv."namespace" = 'default' |
||||
AND kv."group" = 'dashboard.grafana.app' |
||||
AND kv."resource" = 'dashboards' |
||||
ORDER BY kv."resource_version" ASC |
||||
; |
Loading…
Reference in new issue