Files
grafana/pkg/registry/apis/dashboard/legacy/storage.go
Rafael Bortolon Paulovic 5eae7d4f22 feat: legacy ListIterator with batches (#115038)
* feat: legacy ListIterator with batches

* chore: address code review

* chore: remove nil check in nextBatch

* chore: move close before count check

* chore: add err field to batchingIterator for its own errors

* chore: remove unused import
2025-12-10 16:12:08 +01:00

418 lines
12 KiB
Go

package legacy
import (
"context"
"encoding/json"
"errors"
"fmt"
"iter"
"net/http"
"time"
claims "github.com/grafana/authlib/types"
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
func getDashboardFromEvent(event resource.WriteEvent) (*dashboard.Dashboard, error) {
obj, ok := event.Object.GetRuntimeObject()
if ok && obj != nil {
dash, ok := obj.(*dashboard.Dashboard)
if ok {
return dash, nil
}
}
dash := &dashboard.Dashboard{}
err := json.Unmarshal(event.Value, dash)
return dash, err
}
func getProvisioningDataFromEvent(event resource.WriteEvent) (*dashboards.DashboardProvisioning, error) {
obj, ok := event.Object.GetRuntimeObject()
if !ok {
return nil, fmt.Errorf("object is not a runtime object")
}
meta, err := utils.MetaAccessor(obj)
if err != nil {
return nil, err
}
provisioningData, ok := meta.GetManagerProperties()
if !ok || (provisioningData.Kind != utils.ManagerKindClassicFP) { //nolint:staticcheck
return nil, nil
}
source, ok := meta.GetSourceProperties()
if !ok {
return nil, nil
}
provisioning := &dashboards.DashboardProvisioning{
Name: provisioningData.Identity,
ExternalID: source.Path,
CheckSum: source.Checksum,
}
if source.TimestampMillis > 0 {
provisioning.Updated = time.UnixMilli(source.TimestampMillis).Unix()
}
return provisioning, nil
}
func isDashboardKey(key *resourcepb.ResourceKey, requireName bool) error {
gr := dashboard.DashboardResourceInfo.GroupResource()
if key.Group != gr.Group {
return fmt.Errorf("expecting dashboard group (%s != %s)", key.Group, gr.Group)
}
if key.Resource != gr.Resource {
return fmt.Errorf("expecting dashboard resource (%s != %s)", key.Resource, gr.Resource)
}
if requireName && key.Name == "" {
return fmt.Errorf("expecting dashboard name (uid)")
}
return nil
}
func (a *dashboardSqlAccess) WriteEvent(ctx context.Context, event resource.WriteEvent) (rv int64, err error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.WriteEvent")
defer span.End()
info, err := claims.ParseNamespace(event.Key.Namespace)
if err == nil {
err = isDashboardKey(event.Key, true)
}
if err != nil {
return 0, err
}
switch event.Type {
case resourcepb.WatchEvent_DELETED:
{
_, _, err = a.DeleteDashboard(ctx, info.OrgID, event.Key.Name)
//rv = ???
}
// The difference depends on embedded internal ID
case resourcepb.WatchEvent_ADDED, resourcepb.WatchEvent_MODIFIED:
{
dash, err := getDashboardFromEvent(event)
if err != nil {
return 0, err
}
// In unistore, provisioning data is stored as annotations on the dashboard object. In legacy, it is stored in a separate
// database table. For the legacy fallback, we need to save the provisioning data in the same transaction - so we need to handle these separately.
// Without this, we can end up having dashboards created in legacy, unistore timing out, and then never saving the provisioning data, which
// results in duplicated dashboards on next startup.
provisioning, err := getProvisioningDataFromEvent(event)
if err != nil {
return 0, err
}
if provisioning != nil {
cmd, _, err := a.buildSaveDashboardCommand(ctx, info.OrgID, dash)
if err != nil {
return 0, err
}
after, err := a.dashStore.SaveProvisionedDashboard(ctx, *cmd, provisioning)
if err != nil {
return 0, err
}
// dashboard version is the RV in legacy storage
// and deprecatedInternalID must be set here (as SaveDashboard does below for non-provisioned dashboards)
if after != nil {
rv = int64(after.Version)
access := GetLegacyAccess(ctx)
if access != nil {
access.DashboardID = after.ID
}
}
} else {
failOnExisting := event.Type == resourcepb.WatchEvent_ADDED
sql, err := a.sql(ctx)
if err != nil {
return 0, err
}
var after *dashboard.Dashboard
if err := sql.DB.InTransaction(ctx, func(ctx context.Context) error {
var err error
after, _, err = a.SaveDashboard(ctx, info.OrgID, dash, failOnExisting)
return err
}); err != nil {
return 0, err
}
if after != nil {
meta, err := utils.MetaAccessor(after)
if err != nil {
return 0, err
}
rv, err = meta.GetResourceVersionInt64()
if err != nil {
return 0, err
}
}
}
}
default:
return 0, fmt.Errorf("unsupported event type: %v", event.Type)
}
// Async notify all subscribers (not HA!!!)
if a.subscribers != nil {
go func() {
write := &resource.WrittenEvent{
Type: event.Type,
Key: event.Key,
PreviousRV: event.PreviousRV,
Value: event.Value,
Timestamp: time.Now().UnixMilli(),
ResourceVersion: rv,
}
for _, sub := range a.subscribers {
sub <- write
}
}()
}
return rv, err
}
func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid string, v int64) (*dashboard.Dashboard, int64, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetDashboard")
defer span.End()
sql, err := a.sql(ctx)
if err != nil {
return nil, 0, err
}
rows, err := a.getRows(ctx, sql, &DashboardQuery{
OrgID: orgId,
UID: uid,
Limit: 2, // will only be one!
Version: v,
})
if err != nil {
return nil, 0, err
}
defer func() { _ = rows.Close() }()
if rows.Next() {
row := rows.row
if row != nil {
return row.Dash, row.RV, rows.err
}
}
return nil, 0, rows.err
}
// Read implements ResourceStoreServer.
func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resourcepb.ReadRequest) *resource.BackendReadResponse {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ReadResource")
defer span.End()
rsp := &resource.BackendReadResponse{}
info, err := claims.ParseNamespace(req.Key.Namespace)
if err == nil {
err = isDashboardKey(req.Key, true)
}
if err != nil {
rsp.Error = resource.AsErrorResult(err)
return rsp
}
version := int64(0)
if req.ResourceVersion > 0 {
version = req.ResourceVersion
}
dash, rv, err := a.GetDashboard(ctx, info.OrgID, req.Key.Name, version)
if err != nil {
rsp.Error = resource.AsErrorResult(err)
return rsp
}
if dash == nil {
rsp.Error = &resourcepb.ErrorResult{
Code: http.StatusNotFound,
}
} else {
meta, err := utils.MetaAccessor(dash)
if err != nil {
rsp.Error = resource.AsErrorResult(err)
}
rsp.Folder = meta.GetFolder()
rsp.Value, err = json.Marshal(dash)
if err != nil {
rsp.Error = resource.AsErrorResult(err)
}
}
rsp.ResourceVersion = rv
return rsp
}
// ListHistory implements StorageBackend.
func (a *dashboardSqlAccess) ListHistory(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListHistory")
defer span.End()
return a.ListIterator(ctx, req, cb)
}
func (a *dashboardSqlAccess) ListModifiedSince(ctx context.Context, key resource.NamespacedResource, sinceRv int64) (int64, iter.Seq2[*resource.ModifiedResource, error]) {
_, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListModifiedSince")
defer span.End()
return 0, func(yield func(*resource.ModifiedResource, error) bool) {
yield(nil, errors.New("not implemented"))
}
}
func (a *dashboardSqlAccess) GetResourceLastImportTimes(ctx context.Context) iter.Seq2[resource.ResourceLastImportTime, error] {
_, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetResourceLastImportTimes")
defer span.End()
return func(yield func(resource.ResourceLastImportTime, error) bool) {
yield(resource.ResourceLastImportTime{}, errors.New("not implemented"))
}
}
// List implements StorageBackend.
func (a *dashboardSqlAccess) ListIterator(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListIterator")
defer span.End()
if req.ResourceVersion != 0 {
return 0, apierrors.NewBadRequest("List with explicit resourceVersion is not supported with this storage backend")
}
opts := req.Options
info, err := claims.ParseNamespace(opts.Key.Namespace)
if err == nil {
err = isDashboardKey(opts.Key, false)
}
if err != nil {
return 0, err
}
token, err := readContinueToken(req.NextPageToken)
if err != nil {
return 0, err
}
if token.orgId > 0 && token.orgId != info.OrgID {
return 0, fmt.Errorf("token and orgID mismatch")
}
// Default batch size for iterator - fetch rows in batches to avoid slow queries
const defaultMaxRows = 500
maxRows := defaultMaxRows
if req.Limit > 0 && req.Limit < int64(defaultMaxRows) {
maxRows = int(req.Limit)
}
query := &DashboardQuery{
OrgID: info.OrgID,
Limit: int(req.Limit),
MaxRows: maxRows,
LastID: token.id,
Labels: req.Options.Labels,
}
sql, err := a.sql(ctx)
if err != nil {
return 0, err
}
switch req.Source {
case resourcepb.ListRequest_HISTORY:
query.GetHistory = true
query.UID = req.Options.Key.Name
case resourcepb.ListRequest_TRASH:
query.GetTrash = true
case resourcepb.ListRequest_STORE:
// normal
}
listRV, err := sql.GetResourceVersion(ctx, "dashboard", "updated")
if err != nil {
return 0, err
}
listRV *= 1000 // Convert to microseconds
iter, err := newBatchingIterator(ctx, a, sql, query)
if iter != nil {
defer func() {
_ = iter.Close()
}()
}
if err == nil {
err = cb(iter)
}
return listRV, err
}
// Watch implements AppendingStore.
func (a *dashboardSqlAccess) WatchWriteEvents(ctx context.Context) (<-chan *resource.WrittenEvent, error) {
stream := make(chan *resource.WrittenEvent, 10)
{
a.mutex.Lock()
defer a.mutex.Unlock()
// Add the event stream
a.subscribers = append(a.subscribers, stream)
}
// Wait for context done
go func() {
// Wait till the context is done
<-ctx.Done()
// Then remove the subscription
a.mutex.Lock()
defer a.mutex.Unlock()
// Copy all streams without our listener
subs := []chan *resource.WrittenEvent{}
for _, sub := range a.subscribers {
if sub != stream {
subs = append(subs, sub)
}
}
a.subscribers = subs
}()
return stream, nil
}
// Simple wrapper for index implementation
func (a *dashboardSqlAccess) Read(ctx context.Context, req *resourcepb.ReadRequest) (*resource.BackendReadResponse, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.Read")
defer span.End()
return a.ReadResource(ctx, req), nil
}
func (a *dashboardSqlAccess) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest) (*resourcepb.ResourceSearchResponse, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.Search")
defer span.End()
return a.dashboardSearchClient.Search(ctx, req)
}
func (a *dashboardSqlAccess) ListManagedObjects(ctx context.Context, req *resourcepb.ListManagedObjectsRequest) (*resourcepb.ListManagedObjectsResponse, error) {
return nil, fmt.Errorf("not implemented")
}
func (a *dashboardSqlAccess) CountManagedObjects(context.Context, *resourcepb.CountManagedObjectsRequest) (*resourcepb.CountManagedObjectsResponse, error) {
return nil, fmt.Errorf("not implemented")
}
// GetStats implements ResourceServer.
func (a *dashboardSqlAccess) GetStats(ctx context.Context, req *resourcepb.ResourceStatsRequest) (*resourcepb.ResourceStatsResponse, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetStats")
defer span.End()
return a.dashboardSearchClient.GetStats(ctx, req)
}