unified-storage: implement sqlkv Save method (#115458)

* unified-storage: sqlkv save method
This commit is contained in:
Will Assis
2025-12-19 14:27:06 -05:00
committed by GitHub
parent 14c595f206
commit 4164239f56
15 changed files with 534 additions and 47 deletions
@@ -0,0 +1,5 @@
DELETE FROM {{ .Ident "resource" }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }};
@@ -0,0 +1,21 @@
INSERT INTO {{ .Ident .TableName }}
(
{{ .Ident "guid" }},
{{ .Ident "key_path" }},
{{ .Ident "value" }},
{{ .Ident "group" }},
{{ .Ident "resource" }},
{{ .Ident "namespace" }},
{{ .Ident "name" }},
{{ .Ident "action" }}
)
VALUES (
{{ .Arg .GUID }},
{{ .Arg .KeyPath }},
COALESCE({{ .Arg .Value }}, ""),
{{ .Arg .Group }},
{{ .Arg .Resource }},
{{ .Arg .Namespace }},
{{ .Arg .Name }},
{{ .Arg .Action }}
);
@@ -0,0 +1,31 @@
INSERT INTO {{ .Ident "resource" }}
(
{{ .Ident "value" }},
{{ .Ident "guid" }},
{{ .Ident "group" }},
{{ .Ident "resource" }},
{{ .Ident "namespace" }},
{{ .Ident "name" }},
{{ .Ident "action" }},
{{ .Ident "folder" }},
{{ .Ident "previous_resource_version" }}
)
VALUES (
COALESCE({{ .Arg .Value }}, ""),
{{ .Arg .GUID }},
{{ .Arg .Group }},
{{ .Arg .Resource }},
{{ .Arg .Namespace }},
{{ .Arg .Name }},
{{ .Arg .Action }},
{{ .Arg .Folder }},
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
SELECT {{ .Ident "resource_version" }}
FROM {{ .Ident "resource" }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }}
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
) END
);
@@ -0,0 +1,44 @@
INSERT INTO {{ .Ident "resource_history" }}
(
{{ .Ident "value" }},
{{ .Ident "guid" }},
{{ .Ident "group" }},
{{ .Ident "resource" }},
{{ .Ident "namespace" }},
{{ .Ident "name" }},
{{ .Ident "action" }},
{{ .Ident "folder" }},
{{ .Ident "previous_resource_version" }},
{{ .Ident "generation" }}
)
VALUES (
COALESCE({{ .Arg .Value }}, ""),
{{ .Arg .GUID }},
{{ .Arg .Group }},
{{ .Arg .Resource }},
{{ .Arg .Namespace }},
{{ .Arg .Name }},
{{ .Arg .Action }},
{{ .Arg .Folder }},
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
SELECT {{ .Ident "resource_version" }}
FROM {{ .Ident "resource_history" }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }}
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
) END,
CASE
WHEN {{ .Arg .Action }} = 1 THEN 1
WHEN {{ .Arg .Action }} = 3 THEN 0
ELSE 1 + (
SELECT COUNT(1)
FROM {{ .Ident "resource_history" }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }}
)
END
);
@@ -0,0 +1,15 @@
INSERT INTO {{ .Ident .TableName }}
(
{{ .Ident "key_path" }},
{{ .Ident "value" }}
)
VALUES (
{{ .Arg .KeyPath }},
COALESCE({{ .Arg .Value }}, "")
)
{{- if eq .DialectName "mysql" }}
ON DUPLICATE KEY UPDATE {{ .Ident "value" }} = {{ .Arg .Value }}
{{- else }}
ON CONFLICT ({{ .Ident "key_path" }}) DO UPDATE SET {{ .Ident "value" }} = {{ .Arg .Value }}
{{- end }}
;
@@ -0,0 +1,3 @@
UPDATE {{ .Ident .TableName }}
SET {{ .Ident "value" }} = {{ .Arg .Value }}
WHERE {{ .Ident "key_path" }} = {{ .Arg .KeyPath }};
@@ -0,0 +1,9 @@
UPDATE {{ .Ident "resource" }}
SET
{{ .Ident "value" }} = {{ .Arg .Value }},
{{ .Ident "action" }} = {{ .Arg .Action }},
{{ .Ident "folder" }} = {{ .Arg .Folder }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }};
+43 -1
View File
@@ -49,6 +49,9 @@ type DataKey struct {
ResourceVersion int64
Action DataAction
Folder string
// needed to maintain backwards compatibility with unified/sql
GUID string
}
// GroupResource represents a unique group/resource combination
@@ -61,6 +64,12 @@ func (k DataKey) String() string {
return fmt.Sprintf("%s/%s/%s/%s/%d~%s~%s", k.Group, k.Resource, k.Namespace, k.Name, k.ResourceVersion, k.Action, k.Folder)
}
// Temporary while we need to support unified/sql/backend compatibility
// Remove once we stop using RvManager in storage_backend.go
func (k DataKey) StringWithGUID() string {
return fmt.Sprintf("%s/%s/%s/%s/%d~%s~%s~%s", k.Group, k.Resource, k.Namespace, k.Name, k.ResourceVersion, k.Action, k.Folder, k.GUID)
}
func (k DataKey) Equals(other DataKey) bool {
return k.Group == other.Group && k.Resource == other.Resource && k.Namespace == other.Namespace && k.Name == other.Name && k.ResourceVersion == other.ResourceVersion && k.Action == other.Action && k.Folder == other.Folder
}
@@ -516,7 +525,13 @@ func (d *dataStore) Save(ctx context.Context, key DataKey, value io.Reader) erro
return fmt.Errorf("invalid data key: %w", err)
}
writer, err := d.kv.Save(ctx, dataSection, key.String())
var writer io.WriteCloser
var err error
if key.GUID != "" {
writer, err = d.kv.Save(ctx, dataSection, key.StringWithGUID())
} else {
writer, err = d.kv.Save(ctx, dataSection, key.String())
}
if err != nil {
return err
}
@@ -583,6 +598,33 @@ func ParseKey(key string) (DataKey, error) {
}, nil
}
// Temporary while we need to support unified/sql/backend compatibility
// Remove once we stop using RvManager in storage_backend.go
func ParseKeyWithGUID(key string) (DataKey, error) {
parts := strings.Split(key, "/")
if len(parts) != 5 {
return DataKey{}, fmt.Errorf("invalid key: %s", key)
}
rvActionFolderGUIDParts := strings.Split(parts[4], "~")
if len(rvActionFolderGUIDParts) != 4 {
return DataKey{}, fmt.Errorf("invalid key: %s", key)
}
rv, err := strconv.ParseInt(rvActionFolderGUIDParts[0], 10, 64)
if err != nil {
return DataKey{}, fmt.Errorf("invalid resource version '%s' in key %s: %w", rvActionFolderGUIDParts[0], key, err)
}
return DataKey{
Group: parts[0],
Resource: parts[1],
Namespace: parts[2],
Name: parts[3],
ResourceVersion: rv,
Action: DataAction(rvActionFolderGUIDParts[1]),
Folder: rvActionFolderGUIDParts[2],
GUID: rvActionFolderGUIDParts[3],
}, nil
}
// SameResource checks if this key represents the same resource as another key.
// It compares the identifying fields: Group, Resource, Namespace, and Name.
// ResourceVersion, Action, and Folder are ignored as they don't identify the resource itself.
@@ -32,6 +32,7 @@ type EventKey struct {
ResourceVersion int64
Action DataAction
Folder string
GUID string
}
func (k EventKey) String() string {
+233 -16
View File
@@ -12,8 +12,10 @@ import (
"strings"
"text/template"
"github.com/google/uuid"
"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/rvmanager"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
@@ -34,11 +36,18 @@ func mustTemplate(filename string) *template.Template {
// Templates.
var (
sqlKVKeys = mustTemplate("sqlkv_keys.sql")
sqlKVGet = mustTemplate("sqlkv_get.sql")
sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql")
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
sqlKVKeys = mustTemplate("sqlkv_keys.sql")
sqlKVGet = mustTemplate("sqlkv_get.sql")
sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql")
sqlKVSaveEvent = mustTemplate("sqlkv_save_event.sql")
sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql")
sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql")
sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql")
sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql")
sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql")
sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql")
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
)
// sqlKVSection can be embedded in structs used when rendering query templates
@@ -128,6 +137,45 @@ func (req sqlKVBatchRequest) KeyPaths() []string {
return result
}
type sqlKVSaveRequest struct {
sqltemplate.SQLTemplate
sqlKVSectionKey
Value []byte
// old fields that can be removed once we prune resource_history
GUID string
Group string
Resource string
Namespace string
Name string
Action int64
Folder string
}
func (req sqlKVSaveRequest) Validate() error {
return req.sqlKVSectionKey.Validate()
}
type sqlKVLegacySaveRequest struct {
sqltemplate.SQLTemplate
Value []byte
GUID string
Group string
Resource string
Namespace string
Name string
Action int64
Folder string
}
func (req sqlKVLegacySaveRequest) Validate() error {
return nil
}
func (req sqlKVLegacySaveRequest) Results() ([]byte, error) {
return req.Value, nil
}
type sqlKVKeysRequest struct {
sqltemplate.SQLTemplate
sqlKVSection
@@ -285,20 +333,187 @@ func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) ite
}
}
// TODO: this function only exists to support the testing of the sqlkv implementation before
// we have a proper implementation of `Save`.
func (k *sqlKV) TestingSave(ctx context.Context, key string, value []byte) error {
stmt := fmt.Sprintf(
`INSERT INTO resource_events (key_path, value) VALUES (%s, %s)`,
k.dialect.ArgPlaceholder(1), k.dialect.ArgPlaceholder(2),
)
func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) {
sectionKey := sqlKVSectionKey{sqlKVSection{section}, key}
if err := sectionKey.Validate(); err != nil {
return nil, err
}
_, err := k.db.ExecContext(ctx, stmt, eventsSection+"/"+key, value)
return err
return &sqlWriteCloser{
kv: k,
ctx: ctx,
sectionKey: sectionKey,
buf: &bytes.Buffer{},
closed: false,
}, nil
}
func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) {
panic("not implemented!")
type sqlWriteCloser struct {
kv *sqlKV
ctx context.Context
sectionKey sqlKVSectionKey
buf *bytes.Buffer
closed bool
}
func (w *sqlWriteCloser) Write(value []byte) (int, error) {
if w.closed {
return 0, errors.New("write to closed writer")
}
return w.buf.Write(value)
}
func (w *sqlWriteCloser) Close() error {
if w.closed {
return nil
}
w.closed = true
// do regular kv save: simple key_path + value insert with conflict check.
// can only do this on resource_events for now, until we drop the columns in resource_history
if w.sectionKey.Section == eventsSection {
_, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVSaveEvent, sqlKVSaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
sqlKVSectionKey: w.sectionKey,
Value: w.buf.Bytes(),
})
if err != nil {
return fmt.Errorf("failed to save: %w", err)
}
return nil
}
// if storage_backend is running with an RvManager, it will inject a transaction into the context
// used to keep backwards compatibility between sql-based kvstore and unified/sql/backend
tx, ok := rvmanager.TxFromCtx(w.ctx)
if !ok {
// temporary save for dataStore without rvmanager
// we can use the same template as the event one after we:
// - move PK from GUID to key_path
// - remove all unnecessary columns (or at least their NOT NULL constraints)
_, err := w.kv.Get(w.ctx, w.sectionKey.Section, w.sectionKey.Key)
if errors.Is(err, ErrNotFound) {
_, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVInsertData, sqlKVSaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
sqlKVSectionKey: w.sectionKey,
GUID: uuid.New().String(),
Value: w.buf.Bytes(),
})
if err != nil {
return fmt.Errorf("failed to insert to datastore: %w", err)
}
return nil
}
if err != nil {
return fmt.Errorf("failed to get for save: %w", err)
}
_, err = dbutil.Exec(w.ctx, w.kv.db, sqlKVUpdateData, sqlKVSaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
sqlKVSectionKey: w.sectionKey,
Value: w.buf.Bytes(),
})
if err != nil {
return fmt.Errorf("failed to update to datastore: %w", err)
}
return nil
}
// special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore,
// as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
// component will be responsible for populating the resource_version and key_path columns
// note that we are not touching resource_version table, neither the resource_version columns or the key_path column
// as the RvManager will be responsible for this
dataKey, err := ParseKeyWithGUID(w.sectionKey.Key)
if err != nil {
return fmt.Errorf("failed to parse key: %w", err)
}
var action int64
switch dataKey.Action {
case DataActionCreated:
action = 1
case DataActionUpdated:
action = 2
case DataActionDeleted:
action = 3
default:
return fmt.Errorf("failed to parse key: %w", err)
}
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
sqlKVSectionKey: w.sectionKey,
Value: w.buf.Bytes(),
GUID: dataKey.GUID,
Group: dataKey.Group,
Resource: dataKey.Resource,
Namespace: dataKey.Namespace,
Name: dataKey.Name,
Action: action,
Folder: dataKey.Folder,
})
if err != nil {
return fmt.Errorf("failed to save to resource_history: %w", err)
}
switch dataKey.Action {
case DataActionCreated:
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
Value: w.buf.Bytes(),
GUID: dataKey.GUID,
Group: dataKey.Group,
Resource: dataKey.Resource,
Namespace: dataKey.Namespace,
Name: dataKey.Name,
Action: action,
Folder: dataKey.Folder,
})
if err != nil {
return fmt.Errorf("failed to insert to resource: %w", err)
}
case DataActionUpdated:
_, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
Value: w.buf.Bytes(),
Group: dataKey.Group,
Resource: dataKey.Resource,
Namespace: dataKey.Namespace,
Name: dataKey.Name,
Action: action,
Folder: dataKey.Folder,
})
if err != nil {
return fmt.Errorf("failed to update resource: %w", err)
}
case DataActionDeleted:
_, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
Group: dataKey.Group,
Resource: dataKey.Resource,
Namespace: dataKey.Namespace,
Name: dataKey.Name,
})
if err != nil {
return fmt.Errorf("failed to delete from resource: %w", err)
}
}
return nil
}
func (k *sqlKV) Delete(ctx context.Context, section string, key string) error {
@@ -319,6 +534,8 @@ func (k *sqlKV) Delete(ctx context.Context, section string, key string) error {
return ErrNotFound
}
// TODO reflect change to resource table
return nil
}
@@ -14,6 +14,7 @@ import (
"time"
"github.com/bwmarrin/snowflake"
"github.com/google/uuid"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/trace"
@@ -22,6 +23,8 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
"github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager"
"github.com/grafana/grafana/pkg/util/debouncer"
)
@@ -68,6 +71,8 @@ type kvStorageBackend struct {
withExperimentalClusterScope bool
//tracer trace.Tracer
//reg prometheus.Registerer
rvManager *rvmanager.ResourceVersionManager
}
var _ KVBackend = &kvStorageBackend{}
@@ -85,6 +90,10 @@ type KVBackendOptions struct {
EventPruningInterval time.Duration // How often to run the event pruning (default: 5 minutes)
Tracer trace.Tracer // TODO add tracing
Reg prometheus.Registerer // TODO add metrics
// Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with
// unified/sql
RvManager *rvmanager.ResourceVersionManager
}
func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) {
@@ -119,6 +128,7 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) {
eventRetentionPeriod: eventRetentionPeriod,
eventPruningInterval: eventPruningInterval,
withExperimentalClusterScope: opts.WithExperimentalClusterScope,
rvManager: opts.RvManager,
}
err = backend.initPruner(ctx)
if err != nil {
@@ -317,9 +327,28 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
Action: action,
Folder: obj.GetFolder(),
}
err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value))
if err != nil {
return 0, fmt.Errorf("failed to write data: %w", err)
if k.rvManager != nil {
dataKey.GUID = uuid.New().String()
var err error
rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) {
err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value))
if err != nil {
return "", fmt.Errorf("failed to write data: %w", err)
}
return dataKey.GUID, nil
})
if err != nil {
return 0, fmt.Errorf("failed to write data: %w", err)
}
dataKey.ResourceVersion = rv
} else {
err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value))
if err != nil {
return 0, fmt.Errorf("failed to write data: %w", err)
}
}
// Optimistic concurrency control to verify our write is the latest version
@@ -340,14 +369,22 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
}
// Check if the RV we just wrote is the latest. If not, a concurrent write with higher RV happened
if latestKey.ResourceVersion != rv {
if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) {
// Delete the data we just wrote since it's not the latest
// if we're running with rvManager, convert the ResourceVersion back to snowflake to delete
if k.rvManager != nil {
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion)
}
_ = k.dataStore.Delete(ctx, dataKey)
return 0, fmt.Errorf("optimistic locking failed: concurrent modification detected")
}
if prevKey.ResourceVersion != event.PreviousRV {
if !rvmanager.IsRvEqual(prevKey.ResourceVersion, event.PreviousRV) {
// Another concurrent write happened between our read and write
// if we're running with rvManager, convert the ResourceVersion back to snowflake to delete
if k.rvManager != nil {
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion)
}
_ = k.dataStore.Delete(ctx, dataKey)
return 0, fmt.Errorf("optimistic locking failed: resource was modified concurrently (expected previous RV %d, found %d)", event.PreviousRV, prevKey.ResourceVersion)
}
@@ -366,8 +403,12 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
}
// Check if the RV we just wrote is the latest. If not, a concurrent create with higher RV happened
if latestKey.ResourceVersion != rv {
if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) {
// Delete the data we just wrote since it's not the latest
// if we're running with rvManager, convert the ResourceVersion back to snowflake to delete
if k.rvManager != nil {
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion)
}
_ = k.dataStore.Delete(ctx, dataKey)
return 0, fmt.Errorf("optimistic locking failed: concurrent create detected")
}
@@ -375,6 +416,10 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
// Verify that the immediate predecessor is not a create
if prevKey.Action == DataActionCreated {
// Another concurrent create happened - delete our write and return error
// if we're running with rvManager, convert the ResourceVersion back to snowflake to delete
if k.rvManager != nil {
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion)
}
_ = k.dataStore.Delete(ctx, dataKey)
return 0, fmt.Errorf("optimistic locking failed: concurrent create detected")
}
@@ -391,7 +436,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
Folder: obj.GetFolder(),
PreviousRV: event.PreviousRV,
}
err = k.eventStore.Save(ctx, eventData)
err := k.eventStore.Save(ctx, eventData)
if err != nil {
// Clean up the data we wrote since event save failed
_ = k.dataStore.Delete(ctx, dataKey)