Unified Storage: Fix Create, Update and Delete wrt Resource Versions (#88183)

* add sqltemplate utilities, improve tests and documentation

* bunch of things

* remove unnecessary message

* add queries

* add queries

* add queries

* add folders support

* fix diff

* fix linters

* fix diff

* fix linters

* fix linters

* fix typo

* fix linters

* fix linters

* fix linters

* several fixes

* several fixes

* temporarily disable k8s integration tests for Entity Server

* postpone some tests

* postpone documentation changes

* Fix bug in create

* improve error reporting

* fix PostgeSQL parameters

* fix MySQL sqlmode

* fix MySQL-5.7

* reduce but document the number of database connection options

* remove unused code and improve docs
This commit is contained in:
Diego Augusto Molina 2024-06-05 14:23:32 -03:00 committed by GitHub
parent cbe7521a56
commit 6fcd7d9e03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1869 additions and 1300 deletions

View File

@ -99,6 +99,7 @@ type service struct {
cfg *setting.Cfg cfg *setting.Cfg
features featuremgmt.FeatureToggles features featuremgmt.FeatureToggles
startedCh chan struct{}
stopCh chan struct{} stopCh chan struct{}
stoppedCh chan error stoppedCh chan error
@ -124,6 +125,7 @@ func ProvideService(
cfg: cfg, cfg: cfg,
features: features, features: features,
rr: rr, rr: rr,
startedCh: make(chan struct{}),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
builders: []builder.APIGroupBuilder{}, builders: []builder.APIGroupBuilder{},
authorizer: authorizer.NewGrafanaAuthorizer(cfg, orgService), authorizer: authorizer.NewGrafanaAuthorizer(cfg, orgService),
@ -139,6 +141,7 @@ func ProvideService(
// the routes are registered before the Grafana HTTP server starts. // the routes are registered before the Grafana HTTP server starts.
proxyHandler := func(k8sRoute routing.RouteRegister) { proxyHandler := func(k8sRoute routing.RouteRegister) {
handler := func(c *contextmodel.ReqContext) { handler := func(c *contextmodel.ReqContext) {
<-s.startedCh
if s.handler == nil { if s.handler == nil {
c.Resp.WriteHeader(404) c.Resp.WriteHeader(404)
_, _ = c.Resp.Write([]byte("Not found")) _, _ = c.Resp.Write([]byte("Not found"))
@ -188,6 +191,8 @@ func (s *service) RegisterAPI(b builder.APIGroupBuilder) {
} }
func (s *service) start(ctx context.Context) error { func (s *service) start(ctx context.Context) error {
defer close(s.startedCh)
// Get the list of groups the server will support // Get the list of groups the server will support
builders := s.builders builders := s.builders
@ -405,6 +410,7 @@ func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Co
return &clientrest.Config{ return &clientrest.Config{
Transport: &roundTripperFunc{ Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) { fn: func(req *http.Request) (*http.Response, error) {
<-s.startedCh
ctx := appcontext.WithUser(req.Context(), c.SignedInUser) ctx := appcontext.WithUser(req.Context(), c.SignedInUser)
wrapped := grafanaresponsewriter.WrapHandler(s.handler) wrapped := grafanaresponsewriter.WrapHandler(s.handler)
return wrapped(req.WithContext(ctx)) return wrapped(req.WithContext(ctx))
@ -414,6 +420,7 @@ func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Co
} }
func (s *service) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *service) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {
<-s.startedCh
s.handler.ServeHTTP(w, r) s.handler.ServeHTTP(w, r)
} }

View File

@ -153,6 +153,7 @@ func TestIntegrationWatch(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
t.Skip("In maintenance")
ctx, store, destroyFunc, err := testSetup(t) ctx, store, destroyFunc, err := testSetup(t)
defer destroyFunc() defer destroyFunc()
@ -164,6 +165,7 @@ func TestIntegrationClusterScopedWatch(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
t.Skip("In maintenance")
ctx, store, destroyFunc, err := testSetup(t) ctx, store, destroyFunc, err := testSetup(t)
defer destroyFunc() defer destroyFunc()
@ -175,6 +177,7 @@ func TestIntegrationNamespaceScopedWatch(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
t.Skip("In maintenance")
ctx, store, destroyFunc, err := testSetup(t) ctx, store, destroyFunc, err := testSetup(t)
defer destroyFunc() defer destroyFunc()
@ -186,6 +189,7 @@ func TestIntegrationDeleteTriggerWatch(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
t.Skip("In maintenance")
ctx, store, destroyFunc, err := testSetup(t) ctx, store, destroyFunc, err := testSetup(t)
defer destroyFunc() defer destroyFunc()
@ -197,6 +201,7 @@ func TestIntegrationWatchFromZero(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
t.Skip("In maintenance")
ctx, store, destroyFunc, err := testSetup(t) ctx, store, destroyFunc, err := testSetup(t)
defer destroyFunc() defer destroyFunc()
@ -210,6 +215,7 @@ func TestIntegrationWatchFromNonZero(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
t.Skip("In maintenance")
ctx, store, destroyFunc, err := testSetup(t) ctx, store, destroyFunc, err := testSetup(t)
defer destroyFunc() defer destroyFunc()
@ -255,6 +261,7 @@ func TestIntegrationWatcherTimeout(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
t.Skip("In maintenance")
ctx, store, destroyFunc, err := testSetup(t) ctx, store, destroyFunc, err := testSetup(t)
defer destroyFunc() defer destroyFunc()
@ -266,6 +273,7 @@ func TestIntegrationWatchDeleteEventObjectHaveLatestRV(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
t.Skip("In maintenance")
ctx, store, destroyFunc, err := testSetup(t) ctx, store, destroyFunc, err := testSetup(t)
defer destroyFunc() defer destroyFunc()
@ -303,6 +311,7 @@ func TestIntegrationWatchDispatchBookmarkEvents(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
t.Skip("In maintenance")
ctx, store, destroyFunc, err := testSetup(t) ctx, store, destroyFunc, err := testSetup(t)
defer destroyFunc() defer destroyFunc()
@ -326,6 +335,7 @@ func TestIntegrationEtcdWatchSemantics(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
t.Skip("In maintenance")
ctx, store, destroyFunc, err := testSetup(t) ctx, store, destroyFunc, err := testSetup(t)
defer destroyFunc() defer destroyFunc()

View File

@ -121,6 +121,7 @@ func (m *grafanaResourceMetaAccessor) GetUpdatedTimestamp() (*time.Time, error)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid updated timestamp: %s", err.Error()) return nil, fmt.Errorf("invalid updated timestamp: %s", err.Error())
} }
t = t.UTC()
return &t, nil return &t, nil
} }
@ -195,7 +196,7 @@ func (m *grafanaResourceMetaAccessor) SetOriginInfo(info *ResourceOriginInfo) {
anno[AnnoKeyOriginKey] = info.Key anno[AnnoKeyOriginKey] = info.Key
} }
if info.Timestamp != nil { if info.Timestamp != nil {
anno[AnnoKeyOriginTimestamp] = info.Timestamp.Format(time.RFC3339) anno[AnnoKeyOriginTimestamp] = info.Timestamp.UTC().Format(time.RFC3339)
} }
} }
m.obj.SetAnnotations(anno) m.obj.SetAnnotations(anno)

View File

@ -8,6 +8,9 @@ import (
// Really just spitballing here :) this should hook into a system that can give better display info // Really just spitballing here :) this should hook into a system that can give better display info
func GetUserIDString(user *user.SignedInUser) string { func GetUserIDString(user *user.SignedInUser) string {
// TODO: should we check IsDisabled?
// TODO: could we use the NamespacedID.ID() as prefix instead of manually
// setting "anon", "key", etc.?
if user == nil { if user == nil {
return "" return ""
} }

View File

@ -1,75 +1,105 @@
package dbimpl package dbimpl
import ( import (
"cmp"
"fmt" "fmt"
"strings" "strings"
"time" "time"
"github.com/grafana/grafana/pkg/infra/tracing" "github.com/go-sql-driver/mysql"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"xorm.io/xorm" "xorm.io/xorm"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/store/entity/db"
) )
func getEngineMySQL(cfgSection *setting.DynamicSection, tracer tracing.Tracer) (*xorm.Engine, error) { func getEngineMySQL(getter *sectionGetter, _ tracing.Tracer) (*xorm.Engine, error) {
dbHost := cfgSection.Key("db_host").MustString("") config := mysql.NewConfig()
dbName := cfgSection.Key("db_name").MustString("") config.User = getter.String("db_user")
dbUser := cfgSection.Key("db_user").MustString("") config.Passwd = getter.String("db_pass")
dbPass := cfgSection.Key("db_pass").MustString("") config.Net = "tcp"
config.Addr = getter.String("db_host")
config.DBName = getter.String("db_name")
config.Params = map[string]string{
// See: https://dev.mysql.com/doc/refman/en/sql-mode.html
"@@SESSION.sql_mode": "ANSI",
}
config.Collation = "utf8mb4_unicode_ci"
config.Loc = time.UTC
config.AllowNativePasswords = true
config.ClientFoundRows = true
// TODO: support all mysql connection options // TODO: do we want to support these?
protocol := "tcp" // config.ServerPubKey = getter.String("db_server_pub_key")
if strings.HasPrefix(dbHost, "/") { // config.TLSConfig = getter.String("db_tls_config_name")
protocol = "unix"
if err := getter.Err(); err != nil {
return nil, fmt.Errorf("config error: %w", err)
} }
connectionString := connectionStringMySQL(dbUser, dbPass, protocol, dbHost, dbName) if strings.HasPrefix(config.Addr, "/") {
config.Net = "unix"
}
driverName := sqlstore.WrapDatabaseDriverWithHooks("mysql", tracer) // FIXME: get rid of xorm
engine, err := xorm.NewEngine(driverName, connectionString) engine, err := xorm.NewEngine(db.DriverMySQL, config.FormatDSN())
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("open database: %w", err)
} }
engine.SetMaxOpenConns(0) engine.SetMaxOpenConns(0)
engine.SetMaxIdleConns(2) engine.SetMaxIdleConns(2)
engine.SetConnMaxLifetime(time.Second * time.Duration(14400)) engine.SetConnMaxLifetime(4 * time.Hour)
return engine, nil return engine, nil
} }
func getEnginePostgres(cfgSection *setting.DynamicSection, tracer tracing.Tracer) (*xorm.Engine, error) { func getEnginePostgres(getter *sectionGetter, _ tracing.Tracer) (*xorm.Engine, error) {
dbHost := cfgSection.Key("db_host").MustString("") dsnKV := map[string]string{
dbName := cfgSection.Key("db_name").MustString("") "user": getter.String("db_user"),
dbUser := cfgSection.Key("db_user").MustString("") "password": getter.String("db_pass"),
dbPass := cfgSection.Key("db_pass").MustString("") "dbname": getter.String("db_name"),
"sslmode": cmp.Or(getter.String("db_sslmode"), "disable"),
// TODO: support all postgres connection options
dbSslMode := cfgSection.Key("db_sslmode").MustString("disable")
addr, err := util.SplitHostPortDefault(dbHost, "127.0.0.1", "5432")
if err != nil {
return nil, fmt.Errorf("invalid host specifier '%s': %w", dbHost, err)
} }
connectionString := connectionStringPostgres(dbUser, dbPass, addr.Host, addr.Port, dbName, dbSslMode) // TODO: probably interesting:
// "passfile", "statement_timeout", "lock_timeout", "connect_timeout"
driverName := sqlstore.WrapDatabaseDriverWithHooks("postgres", tracer) // TODO: for CockroachDB, we probably need to use the following:
engine, err := xorm.NewEngine(driverName, connectionString) // dsnKV["options"] = "-c enable_experimental_alter_column_type_general=true"
if err != nil { // Or otherwise specify it as:
return nil, err // dsnKV["enable_experimental_alter_column_type_general"] = "true"
// TODO: do we want to support these options in the DSN as well?
// "sslkey", "sslcert", "sslrootcert", "sslpassword", "sslsni", "krbspn",
// "krbsrvname", "target_session_attrs", "service", "servicefile"
// More on Postgres connection string parameters:
// https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
hostport := getter.String("db_host")
if err := getter.Err(); err != nil {
return nil, fmt.Errorf("config error: %w", err)
} }
host, port, err := splitHostPortDefault(hostport, "127.0.0.1", "5432")
if err != nil {
return nil, fmt.Errorf("invalid db_host: %w", err)
}
dsnKV["host"] = host
dsnKV["port"] = port
dsn, err := MakeDSN(dsnKV)
if err != nil {
return nil, fmt.Errorf("error building DSN: %w", err)
}
// FIXME: get rid of xorm
engine, err := xorm.NewEngine(db.DriverPostgres, dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
return engine, nil return engine, nil
} }
func connectionStringMySQL(user, password, protocol, host, dbName string) string {
return fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", user, password, protocol, host, dbName)
}
func connectionStringPostgres(user, password, host, port, dbName, sslMode string) string {
return fmt.Sprintf(
"user=%s password=%s host=%s port=%s dbname=%s sslmode=%s", // sslcert='%s' sslkey='%s' sslrootcert='%s'",
user, password, host, port, dbName, sslMode, // ss.dbCfg.ClientCertPath, ss.dbCfg.ClientKeyPath, ss.dbCfg.CaCertPath
)
}

View File

@ -19,7 +19,10 @@ func TestGetEnginePostgresFromConfig(t *testing.T) {
s.Key("db_user").SetValue("user") s.Key("db_user").SetValue("user")
s.Key("db_password").SetValue("password") s.Key("db_password").SetValue("password")
engine, err := getEnginePostgres(cfg.SectionWithEnvOverrides("entity_api"), nil) getter := &sectionGetter{
DynamicSection: cfg.SectionWithEnvOverrides("entity_api"),
}
engine, err := getEnginePostgres(getter, nil)
assert.NotNil(t, engine) assert.NotNil(t, engine)
assert.NoError(t, err) assert.NoError(t, err)
@ -36,19 +39,11 @@ func TestGetEngineMySQLFromConfig(t *testing.T) {
s.Key("db_user").SetValue("user") s.Key("db_user").SetValue("user")
s.Key("db_password").SetValue("password") s.Key("db_password").SetValue("password")
engine, err := getEngineMySQL(cfg.SectionWithEnvOverrides("entity_api"), nil) getter := &sectionGetter{
DynamicSection: cfg.SectionWithEnvOverrides("entity_api"),
}
engine, err := getEngineMySQL(getter, nil)
assert.NotNil(t, engine) assert.NotNil(t, engine)
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestGetConnectionStrings(t *testing.T) {
t.Run("generate mysql connection string", func(t *testing.T) {
expected := "user:password@tcp(localhost)/grafana?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true"
assert.Equal(t, expected, connectionStringMySQL("user", "password", "tcp", "localhost", "grafana"))
})
t.Run("generate postgres connection string", func(t *testing.T) {
expected := "user=user password=password host=localhost port=5432 dbname=grafana sslmode=disable"
assert.Equal(t, expected, connectionStringPostgres("user", "password", "localhost", "5432", "grafana", "disable"))
})
}

View File

@ -2,6 +2,7 @@ package dbimpl
import ( import (
"fmt" "fmt"
"sync"
"github.com/dlmiddlecote/sqlstats" "github.com/dlmiddlecote/sqlstats"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@ -31,6 +32,9 @@ func ProvideEntityDB(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureTog
} }
type EntityDB struct { type EntityDB struct {
once sync.Once
onceErr error
db db.DB db db.DB
features featuremgmt.FeatureToggles features featuremgmt.FeatureToggles
engine *xorm.Engine engine *xorm.Engine
@ -45,42 +49,57 @@ func (db *EntityDB) Init() error {
} }
func (db *EntityDB) GetEngine() (*xorm.Engine, error) { func (db *EntityDB) GetEngine() (*xorm.Engine, error) {
db.once.Do(func() {
db.onceErr = db.init()
})
return db.engine, db.onceErr
}
func (db *EntityDB) init() error {
if db.engine != nil { if db.engine != nil {
return db.engine, nil return nil
} }
var engine *xorm.Engine var engine *xorm.Engine
var err error var err error
cfgSection := db.cfg.SectionWithEnvOverrides("entity_api") getter := &sectionGetter{
dbType := cfgSection.Key("db_type").MustString("") DynamicSection: db.cfg.SectionWithEnvOverrides("entity_api"),
}
dbType := getter.Key("db_type").MustString("")
// if explicit connection settings are provided, use them // if explicit connection settings are provided, use them
if dbType != "" { if dbType != "" {
if dbType == "postgres" { if dbType == "postgres" {
engine, err = getEnginePostgres(cfgSection, db.tracer) engine, err = getEnginePostgres(getter, db.tracer)
if err != nil { if err != nil {
return nil, err return err
} }
// FIXME: this config option is cockroachdb-specific, it's not supported by postgres // FIXME: this config option is cockroachdb-specific, it's not supported by postgres
// FIXME: this only sets this option for the session that we get
// from the pool right now. A *sql.DB is a pool of connections,
// there is no guarantee that the session where this is run will be
// the same where we need to change the type of a column
_, err = engine.Exec("SET SESSION enable_experimental_alter_column_type_general=true") _, err = engine.Exec("SET SESSION enable_experimental_alter_column_type_general=true")
if err != nil { if err != nil {
db.log.Error("error connecting to postgres", "msg", err.Error()) db.log.Error("error connecting to postgres", "msg", err.Error())
// FIXME: return nil, err // FIXME: return nil, err
} }
} else if dbType == "mysql" { } else if dbType == "mysql" {
engine, err = getEngineMySQL(cfgSection, db.tracer) engine, err = getEngineMySQL(getter, db.tracer)
if err != nil { if err != nil {
return nil, err return err
} }
_, err = engine.Exec("SELECT 1")
if err != nil { if err = engine.Ping(); err != nil {
return nil, err return err
} }
} else { } else {
// TODO: sqlite support // TODO: sqlite support
return nil, fmt.Errorf("invalid db type specified: %s", dbType) return fmt.Errorf("invalid db type specified: %s", dbType)
} }
// register sql stat metrics // register sql stat metrics
@ -89,7 +108,7 @@ func (db *EntityDB) GetEngine() (*xorm.Engine, error) {
} }
// configure sql logging // configure sql logging
debugSQL := cfgSection.Key("log_queries").MustBool(false) debugSQL := getter.Key("log_queries").MustBool(false)
if !debugSQL { if !debugSQL {
engine.SetLogger(&xorm.DiscardLogger{}) engine.SetLogger(&xorm.DiscardLogger{})
} else { } else {
@ -98,10 +117,11 @@ func (db *EntityDB) GetEngine() (*xorm.Engine, error) {
engine.ShowSQL(true) engine.ShowSQL(true)
engine.ShowExecTime(true) engine.ShowExecTime(true)
} }
// otherwise, try to use the grafana db connection // otherwise, try to use the grafana db connection
} else { } else {
if db.db == nil { if db.db == nil {
return nil, fmt.Errorf("no db connection provided") return fmt.Errorf("no db connection provided")
} }
engine = db.db.GetEngine() engine = db.db.GetEngine()
@ -109,12 +129,12 @@ func (db *EntityDB) GetEngine() (*xorm.Engine, error) {
db.engine = engine db.engine = engine
if err := migrations.MigrateEntityStore(db, db.features); err != nil { if err := migrations.MigrateEntityStore(engine, db.cfg, db.features); err != nil {
db.engine = nil db.engine = nil
return nil, err return fmt.Errorf("run migrations: %w", err)
} }
return db.engine, nil return nil
} }
func (db *EntityDB) GetSession() (*session.SessionDB, error) { func (db *EntityDB) GetSession() (*session.SessionDB, error) {

View File

@ -0,0 +1,99 @@
package dbimpl
import (
"cmp"
"errors"
"fmt"
"net"
"strings"
"unicode/utf8"
"github.com/grafana/grafana/pkg/setting"
)
var (
ErrInvalidUTF8Sequence = errors.New("invalid UTF-8 sequence")
)
type sectionGetter struct {
*setting.DynamicSection
err error
}
func (g *sectionGetter) Err() error {
return g.err
}
func (g *sectionGetter) String(key string) string {
if g.err != nil {
return ""
}
v := g.DynamicSection.Key(key).MustString("")
if !utf8.ValidString(v) {
g.err = fmt.Errorf("value for key %q: %w", key, ErrInvalidUTF8Sequence)
return ""
}
return v
}
// MakeDSN creates a DSN from the given key/value pair. It validates the strings
// form valid UTF-8 sequences and escapes values if needed.
func MakeDSN(m map[string]string) (string, error) {
b := new(strings.Builder)
for k, v := range m {
if !utf8.ValidString(v) {
return "", fmt.Errorf("value for DSN key %q: %w", k,
ErrInvalidUTF8Sequence)
}
if v == "" {
continue
}
if b.Len() > 0 {
_ = b.WriteByte(' ')
}
_, _ = b.WriteString(k)
_ = b.WriteByte('=')
writeDSNValue(b, v)
}
return b.String(), nil
}
func writeDSNValue(b *strings.Builder, v string) {
numq := strings.Count(v, `'`)
numb := strings.Count(v, `\`)
if numq+numb == 0 && v != "" {
b.WriteString(v)
return
}
b.Grow(2 + numq + numb + len(v))
_ = b.WriteByte('\'')
for _, r := range v {
if r == '\\' || r == '\'' {
_ = b.WriteByte('\\')
}
_, _ = b.WriteRune(r)
}
_ = b.WriteByte('\'')
}
func splitHostPortDefault(hostport, defaultHost, defaultPort string) (string, string, error) {
host, port, err := net.SplitHostPort(hostport)
if err != nil {
// try appending the port
host, port, err = net.SplitHostPort(hostport + ":" + defaultPort)
if err != nil {
return "", "", fmt.Errorf("invalid hostport: %q", hostport)
}
}
host = cmp.Or(host, defaultHost)
port = cmp.Or(port, defaultPort)
return host, port, nil
}

View File

@ -1,23 +1,20 @@
package migrations package migrations
import ( import (
"xorm.io/xorm"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/store/entity/db" "github.com/grafana/grafana/pkg/setting"
) )
func MigrateEntityStore(db db.EntityDBInterface, features featuremgmt.FeatureToggles) error { func MigrateEntityStore(engine *xorm.Engine, cfg *setting.Cfg, features featuremgmt.FeatureToggles) error {
// Skip if feature flag is not enabled // Skip if feature flag is not enabled
if !features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorage) { if !features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorage) {
return nil return nil
} }
engine, err := db.GetEngine() mg := migrator.NewScopedMigrator(engine, cfg, "entity")
if err != nil {
return err
}
mg := migrator.NewScopedMigrator(engine, db.GetCfg(), "entity")
mg.AddCreateMigration() mg.AddCreateMigration()
initEntityTables(mg) initEntityTables(mg)

View File

@ -13,6 +13,7 @@ import (
const ( const (
DriverPostgres = "postgres" DriverPostgres = "postgres"
DriverMySQL = "mysql" DriverMySQL = "mysql"
DriverSQLite = "sqlite"
DriverSQLite3 = "sqlite3" DriverSQLite3 = "sqlite3"
) )
@ -40,17 +41,29 @@ type DB interface {
DriverName() string DriverName() string
} }
// TxFunc is a function that executes with access to a transaction. The context
// it receives is the same context used to create the transaction, and is
// provided so that a general prupose TxFunc is able to retrieve information
// from that context, and derive other contexts that may be used to run database
// operation methods accepting a context. A derived context can be used to
// request a specific database operation to take no more than a specific
// fraction of the remaining timeout of the transaction context, or to enrich
// the downstream observability layer with relevant information regarding the
// specific operation being carried out.
type TxFunc = func(context.Context, Tx) error type TxFunc = func(context.Context, Tx) error
// Tx is a thin abstraction on *sql.Tx to allow mocking to provide better unit
// testing. We allow database operation methods that do not take a
// context.Context here since a Tx can only be obtained with DB.BeginTx, which
// already takes a context.Context.
type Tx interface { type Tx interface {
ContextExecer ContextExecer
Exec(query string, args ...any) (sql.Result, error)
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
Commit() error Commit() error
Rollback() error Rollback() error
} }
// ContextExecer is a set of database operation methods that take
// context.Context.
type ContextExecer interface { type ContextExecer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)

View File

@ -3,6 +3,7 @@ package entity
import ( import (
"context" "context"
"errors" "errors"
sync "sync"
"testing" "testing"
"time" "time"
@ -120,17 +121,22 @@ func (s *entityStoreStub) FindReferences(ctx context.Context, r *ReferenceReques
} }
type fakeHealthWatchServer struct { type fakeHealthWatchServer struct {
mu sync.Mutex
grpc.ServerStream grpc.ServerStream
healthChecks []*grpc_health_v1.HealthCheckResponse healthChecks []*grpc_health_v1.HealthCheckResponse
context context.Context context context.Context
} }
func (f *fakeHealthWatchServer) Send(resp *grpc_health_v1.HealthCheckResponse) error { func (f *fakeHealthWatchServer) Send(resp *grpc_health_v1.HealthCheckResponse) error {
f.mu.Lock()
defer f.mu.Unlock()
f.healthChecks = append(f.healthChecks, resp) f.healthChecks = append(f.healthChecks, resp)
return nil return nil
} }
func (f *fakeHealthWatchServer) RecvMsg(m interface{}) error { func (f *fakeHealthWatchServer) RecvMsg(m interface{}) error {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.healthChecks) == 0 { if len(f.healthChecks) == 0 {
return errors.New("no health checks received") return errors.New("no health checks received")
} }

View File

@ -23,7 +23,7 @@ func NewBroadcaster[T any](ctx context.Context, connect ConnectFunc[T]) (Broadca
} }
type broadcaster[T any] struct { type broadcaster[T any] struct {
running bool running bool // FIXME: race condition between `Subscribe`/`Unsubscribe` and `start`
ctx context.Context ctx context.Context
subs map[chan T]struct{} subs map[chan T]struct{}
cache Cache[T] cache Cache[T]

View File

@ -0,0 +1,129 @@
package sqlstash
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/services/store/entity/db"
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
)
func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequest) (*entity.CreateEntityResponse, error) {
ctx, span := s.tracer.Start(ctx, "storage_server.Create")
defer span.End()
key, err := entity.ParseKey(r.Entity.Key)
if err != nil {
return nil, fmt.Errorf("create entity: parse entity key: %w", err)
}
// validate and process the request to get the information we need to run
// the query
newEntity, err := entityForCreate(ctx, r, key)
if err != nil {
return nil, fmt.Errorf("create entity: entity from create entity request: %w", err)
}
err = s.sqlDB.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
if len(newEntity.Entity.Labels) > 0 {
// Pre-locking: register this entity's labels
insLabels := sqlEntityLabelsInsertRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
GUID: newEntity.Guid,
Labels: newEntity.Entity.Labels,
}
if _, err = exec(ctx, tx, sqlEntityLabelsInsert, insLabels); err != nil {
return fmt.Errorf("insert into entity_labels: %w", err)
}
}
// up to this point, we have done all the work possible before having to
// lock kind_version
// 1. Atomically increpement resource version for this kind
newVersion, err := kindVersionAtomicInc(ctx, tx, s.sqlDialect, key.Group, key.Resource)
if err != nil {
return err
}
newEntity.ResourceVersion = newVersion
// 2. Insert into entity
insEntity := sqlEntityInsertRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
Entity: newEntity,
TableEntity: true,
}
if _, err = exec(ctx, tx, sqlEntityInsert, insEntity); err != nil {
return fmt.Errorf("insert into entity: %w", err)
}
// 3. Insert into entity history
insEntityHistory := sqlEntityInsertRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
Entity: newEntity,
}
if _, err = exec(ctx, tx, sqlEntityInsert, insEntityHistory); err != nil {
return fmt.Errorf("insert into entity_history: %w", err)
}
// 4. Rebuild the whole folder tree structure if we're creating a folder
if newEntity.Group == folder.GROUP && newEntity.Resource == folder.RESOURCE {
if err = s.updateFolderTree(ctx, tx, key.Namespace); err != nil {
return fmt.Errorf("rebuild folder tree structure: %w", err)
}
}
return nil
})
if err != nil {
// TODO: should we define the "Error" field here and how? (i.e. how
// to determine what information can be disclosed to the user?)
return nil, fmt.Errorf("create entity: %w", err)
}
return &entity.CreateEntityResponse{
Entity: newEntity.Entity,
Status: entity.CreateEntityResponse_CREATED,
}, nil
}
// entityForCreate validates the given request and returns a *returnsEntity
// populated accordingly.
func entityForCreate(ctx context.Context, r *entity.CreateEntityRequest, key *entity.Key) (*returnsEntity, error) {
newEntity := &returnsEntity{
Entity: cloneEntity(r.Entity),
}
if err := newEntity.marshal(); err != nil {
return nil, fmt.Errorf("serialize entity data for db: %w", err)
}
createdAt := time.Now().UnixMilli()
createdBy, err := getCurrentUser(ctx)
if err != nil {
return nil, err
}
newEntity.Guid = uuid.New().String()
newEntity.Group = key.Group
newEntity.Resource = key.Resource
newEntity.Namespace = key.Namespace
newEntity.Name = key.Name
newEntity.Size = int64(len(r.Entity.Body))
newEntity.ETag = createETag(r.Entity.Body, r.Entity.Meta, r.Entity.Status)
newEntity.CreatedAt = createdAt
newEntity.CreatedBy = createdBy
newEntity.UpdatedAt = createdAt
newEntity.UpdatedBy = createdBy
newEntity.Action = entity.Entity_CREATED
return newEntity, nil
}

View File

@ -0,0 +1,61 @@
{{/*
This is the list of all the fields in *entity.Entity, in a way that is
suitable to be imported by other templates that need to select these fields
from either the "entity" or the "entity_history" tables.
Example usage:
SELECT {{ template "common_entity_select_into" . }}
FROM {{ .Ident "entity" }} AS e
*/}}
{{ define "common_entity_select_into" }}
e.{{ .Ident "guid" | .Into .Entity.Guid }},
e.{{ .Ident "resource_version" | .Into .Entity.ResourceVersion }},
e.{{ .Ident "key" | .Into .Entity.Key }},
e.{{ .Ident "group" | .Into .Entity.Group }},
e.{{ .Ident "group_version" | .Into .Entity.GroupVersion }},
e.{{ .Ident "resource" | .Into .Entity.Resource }},
e.{{ .Ident "namespace" | .Into .Entity.Namespace }},
e.{{ .Ident "name" | .Into .Entity.Name }},
e.{{ .Ident "folder" | .Into .Entity.Folder }},
e.{{ .Ident "meta" | .Into .Entity.Meta }},
e.{{ .Ident "body" | .Into .Entity.Body }},
e.{{ .Ident "status" | .Into .Entity.Status }},
e.{{ .Ident "size" | .Into .Entity.Size }},
e.{{ .Ident "etag" | .Into .Entity.ETag }},
e.{{ .Ident "created_at" | .Into .Entity.CreatedAt }},
e.{{ .Ident "created_by" | .Into .Entity.CreatedBy }},
e.{{ .Ident "updated_at" | .Into .Entity.UpdatedAt }},
e.{{ .Ident "updated_by" | .Into .Entity.UpdatedBy }},
e.{{ .Ident "origin" | .Into .Entity.Origin.Source }},
e.{{ .Ident "origin_key" | .Into .Entity.Origin.Key }},
e.{{ .Ident "origin_ts" | .Into .Entity.Origin.Time }},
e.{{ .Ident "title" | .Into .Entity.Title }},
e.{{ .Ident "slug" | .Into .Entity.Slug }},
e.{{ .Ident "description" | .Into .Entity.Description }},
e.{{ .Ident "message" | .Into .Entity.Message }},
e.{{ .Ident "labels" | .Into .Entity.Labels }},
e.{{ .Ident "fields" | .Into .Entity.Fields }},
e.{{ .Ident "errors" | .Into .Entity.Errors }},
e.{{ .Ident "action" | .Into .Entity.Action }}
{{ end }}
{{/* Build an ORDER BY clause from a []SortBy contained in a .Sort field */}}
{{ define "common_order_by" }}
{{ $comma := listSep ", " }}
{{ range .Sort }}
{{- call $comma -}} {{ $.Ident .Field }} {{ .Direction.String }}
{{ end }}
{{ end }}

View File

@ -12,24 +12,18 @@ INSERT INTO {{ .Ident "entity_folder" }}
) )
VALUES VALUES
{{ $this := . }} {{ $comma := listSep ", " }}
{{ $addComma := false }}
{{ range .Items }} {{ range .Items }}
{{ if $addComma }} {{- call $comma -}} (
, {{ $.Arg .GUID }},
{{ end }} {{ $.Arg .Namespace }},
{{ $addComma = true }} {{ $.Arg .UID }},
{{ $.Arg .SlugPath }},
( {{ $.Arg .JS }},
{{ $this.Arg .GUID }}, {{ $.Arg .Depth }},
{{ $this.Arg .Namespace }}, {{ $.Arg .Left }},
{{ $this.Arg .UID }}, {{ $.Arg .Right }},
{{ $this.Arg .SlugPath }}, {{ $.Arg .Detached }}
{{ $this.Arg .JS }},
{{ $this.Arg .Depth }},
{{ $this.Arg .Left }},
{{ $this.Arg .Right }},
{{ $this.Arg .Detached }}
) )
{{ end }} {{ end }}
; ;

View File

@ -0,0 +1,30 @@
SELECT {{ template "common_entity_select_into" . }}
FROM {{ .Ident "entity_history" }} AS e
WHERE 1 = 1
{{ if gt .Before 0 }}
AND {{ .Ident "resource_version" }} < {{ .Arg .Before }}
{{ end }}
{{/* There are two mutually exclusive search modes: by GUID and by Key */}}
{{ if ne .Query.GUID "" }}
AND {{ .Ident "guid" }} = {{ .Arg .Query.GUID }}
{{ else }}
AND {{ .Ident "group" }} = {{ .Arg .Query.Key.Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Query.Key.Resource }}
AND {{ .Ident "name" }} = {{ .Arg .Query.Key.Name }}
{{ if ne .Query.Key.Namespace "" }}
AND {{ .Ident "namespace" }} = {{ .Arg .Query.Key.Namespace }}
{{ end }}
{{ end }}
ORDER BY {{ template "common_order_by" . }}
LIMIT {{ .Limit }}
OFFSET {{ .Offset }}
;

View File

@ -2,17 +2,6 @@ DELETE FROM {{ .Ident "entity_labels" }}
WHERE 1 = 1 WHERE 1 = 1
AND {{ .Ident "guid" }} = {{ .Arg .GUID }} AND {{ .Ident "guid" }} = {{ .Arg .GUID }}
{{ if gt (len .KeepLabels) 0 }} {{ if gt (len .KeepLabels) 0 }}
AND {{ .Ident "label" }} NOT IN ( AND {{ .Ident "label" }} NOT IN ( {{ .ArgList .KeepLabels }} )
{{ $this := . }}
{{ $addComma := false }}
{{ range .KeepLabels }}
{{ if $addComma }}
,
{{ end }}
{{ $addComma = true }}
{{ $this.Arg . }}
{{ end }}
)
{{ end }} {{ end }}
; ;

View File

@ -6,24 +6,12 @@ INSERT INTO {{ .Ident "entity_labels" }}
) )
VALUES VALUES
{{/* {{ $comma := listSep ", " }}
When we enter the "range" loop the "." will be changed, so we need to
store the current ".GUID" in a variable to be able to use its value
*/}}
{{ $guid := .GUID }}
{{ $this := . }}
{{ $addComma := false }}
{{ range $name, $value := .Labels }} {{ range $name, $value := .Labels }}
{{ if $addComma }} {{- call $comma -}} (
, {{ $.Arg $.GUID }},
{{ end }} {{ $.Arg $name }},
{{ $addComma = true }} {{ $.Arg $value }}
(
{{ $this.Arg $guid }},
{{ $this.Arg $name }},
{{ $this.Arg $value }}
) )
{{ end }} {{ end }}
; ;

View File

@ -1,49 +1,10 @@
SELECT SELECT {{ template "common_entity_select_into" . }}
{{ .Ident "guid" | .Into .Entity.Guid }},
{{ .Ident "resource_version" | .Into .Entity.ResourceVersion }},
{{ .Ident "key" | .Into .Entity.Key }},
{{ .Ident "group" | .Into .Entity.Group }},
{{ .Ident "group_version" | .Into .Entity.GroupVersion }},
{{ .Ident "resource" | .Into .Entity.Resource }},
{{ .Ident "namespace" | .Into .Entity.Namespace }},
{{ .Ident "name" | .Into .Entity.Name }},
{{ .Ident "folder" | .Into .Entity.Folder }},
{{ .Ident "meta" | .Into .Entity.Meta }},
{{ .Ident "body" | .Into .Entity.Body }},
{{ .Ident "status" | .Into .Entity.Status }},
{{ .Ident "size" | .Into .Entity.Size }},
{{ .Ident "etag" | .Into .Entity.ETag }},
{{ .Ident "created_at" | .Into .Entity.CreatedAt }},
{{ .Ident "created_by" | .Into .Entity.CreatedBy }},
{{ .Ident "updated_at" | .Into .Entity.UpdatedAt }},
{{ .Ident "updated_by" | .Into .Entity.UpdatedBy }},
{{ .Ident "origin" | .Into .Entity.Origin.Source }},
{{ .Ident "origin_key" | .Into .Entity.Origin.Key }},
{{ .Ident "origin_ts" | .Into .Entity.Origin.Time }},
{{ .Ident "title" | .Into .Entity.Title }},
{{ .Ident "slug" | .Into .Entity.Slug }},
{{ .Ident "description" | .Into .Entity.Description }},
{{ .Ident "message" | .Into .Entity.Message }},
{{ .Ident "labels" | .Into .Entity.Labels }},
{{ .Ident "fields" | .Into .Entity.Fields }},
{{ .Ident "errors" | .Into .Entity.Errors }},
{{ .Ident "action" | .Into .Entity.Action }}
FROM FROM
{{ if gt .ResourceVersion 0 }} {{ if gt .ResourceVersion 0 }}
{{ .Ident "entity_history" }} {{ .Ident "entity_history" }} AS e
{{ else }} {{ else }}
{{ .Ident "entity" }} {{ .Ident "entity" }} AS e
{{ end }} {{ end }}
WHERE 1 = 1 WHERE 1 = 1
@ -73,6 +34,6 @@ SELECT
{{ end }} {{ end }}
{{ if .SelectForUpdate }} {{ if .SelectForUpdate }}
{{ .SelectFor "UPDATE" }} {{ .SelectFor "UPDATE NOWAIT" }}
{{ end }} {{ end }}
; ;

View File

@ -1,43 +1,4 @@
SELECT SELECT {{ template "common_entity_select_into" . }}
e.{{ .Ident "guid" | .Into .Entity.Guid }},
e.{{ .Ident "resource_version" | .Into .Entity.ResourceVersion }},
e.{{ .Ident "key" | .Into .Entity.Key }},
e.{{ .Ident "group" | .Into .Entity.Group }},
e.{{ .Ident "group_version" | .Into .Entity.GroupVersion }},
e.{{ .Ident "resource" | .Into .Entity.Resource }},
e.{{ .Ident "namespace" | .Into .Entity.Namespace }},
e.{{ .Ident "name" | .Into .Entity.Name }},
e.{{ .Ident "folder" | .Into .Entity.Folder }},
e.{{ .Ident "meta" | .Into .Entity.Meta }},
e.{{ .Ident "body" | .Into .Entity.Body }},
e.{{ .Ident "status" | .Into .Entity.Status }},
e.{{ .Ident "size" | .Into .Entity.Size }},
e.{{ .Ident "etag" | .Into .Entity.ETag }},
e.{{ .Ident "created_at" | .Into .Entity.CreatedAt }},
e.{{ .Ident "created_by" | .Into .Entity.CreatedBy }},
e.{{ .Ident "updated_at" | .Into .Entity.UpdatedAt }},
e.{{ .Ident "updated_by" | .Into .Entity.UpdatedBy }},
e.{{ .Ident "origin" | .Into .Entity.Origin.Source }},
e.{{ .Ident "origin_key" | .Into .Entity.Origin.Key }},
e.{{ .Ident "origin_ts" | .Into .Entity.Origin.Time }},
e.{{ .Ident "title" | .Into .Entity.Title }},
e.{{ .Ident "slug" | .Into .Entity.Slug }},
e.{{ .Ident "description" | .Into .Entity.Description }},
e.{{ .Ident "message" | .Into .Entity.Message }},
e.{{ .Ident "labels" | .Into .Entity.Labels }},
e.{{ .Ident "fields" | .Into .Entity.Fields }},
e.{{ .Ident "errors" | .Into .Entity.Errors }},
e.{{ .Ident "action" | .Into .Entity.Action }}
FROM FROM
{{ .Ident "entity_ref" }} AS r {{ .Ident "entity_ref" }} AS r

View File

@ -0,0 +1,10 @@
SELECT
{{ .Ident "resource_version" | .Into .ResourceVersion }},
{{ .Ident "created_at" | .Into .ResourceVersion }},
{{ .Ident "updated_at" | .Into .ResourceVersion }}
FROM {{ .Ident "kind_version" }}
WHERE 1 = 1
AND {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
;

View File

@ -1,5 +1,8 @@
UPDATE {{ .Ident "kind_version" }} UPDATE {{ .Ident "kind_version" }}
SET {{ .Ident "resource_version" }} = {{ .Arg .ResourceVersion }} + 1 SET
{{ .Ident "resource_version" }} = {{ .Arg .ResourceVersion }} + 1,
{{ .Ident "updated_at" }} = {{ .Arg .UpdatedAt }}
WHERE 1 = 1 WHERE 1 = 1
AND {{ .Ident "group" }} = {{ .Arg .Group }} AND {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }} AND {{ .Ident "resource" }} = {{ .Arg .Resource }}

View File

@ -2,12 +2,16 @@ INSERT INTO {{ .Ident "kind_version" }}
( (
{{ .Ident "group" }}, {{ .Ident "group" }},
{{ .Ident "resource" }}, {{ .Ident "resource" }},
{{ .Ident "resource_version" }} {{ .Ident "resource_version" }},
{{ .Ident "created_at" }},
{{ .Ident "updated_at" }}
) )
VALUES ( VALUES (
{{ .Arg .Group }}, {{ .Arg .Group }},
{{ .Arg .Resource }}, {{ .Arg .Resource }},
1 1,
{{ .Arg .CreatedAt }},
{{ .Arg .UpdatedAt }}
) )
; ;

View File

@ -0,0 +1,113 @@
package sqlstash
import (
"context"
"errors"
"fmt"
"time"
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/services/store/entity/db"
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
)
func (s *sqlEntityServer) Delete(ctx context.Context, r *entity.DeleteEntityRequest) (*entity.DeleteEntityResponse, error) {
ctx, span := s.tracer.Start(ctx, "storage_server.Delete")
defer span.End()
key, err := entity.ParseKey(r.Key)
if err != nil {
return nil, fmt.Errorf("delete entity: parse entity key: %w", err)
}
updatedBy, err := getCurrentUser(ctx)
if err != nil {
return nil, fmt.Errorf("delete entity: %w", err)
}
ret := new(entity.DeleteEntityResponse)
err = s.sqlDB.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
// Pre-locking: get the latest version of the entity
previous, err := readEntity(ctx, tx, s.sqlDialect, key, r.PreviousVersion, true, false)
if errors.Is(err, ErrNotFound) {
ret.Status = entity.DeleteEntityResponse_NOTFOUND
return nil
}
if err != nil {
return err
}
// Pre-locking: remove this entity's labels
delLabelsReq := sqlEntityLabelsDeleteRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
GUID: previous.Guid,
}
if _, err = exec(ctx, tx, sqlEntityLabelsDelete, delLabelsReq); err != nil {
return fmt.Errorf("delete all labels of entity with guid %q: %w",
previous.Guid, err)
}
// TODO: Pre-locking: remove this entity's refs from `entity_ref`
// Pre-locking: delete from "entity"
delEntityReq := sqlEntityDeleteRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
Key: key,
}
if _, err = exec(ctx, tx, sqlEntityDelete, delEntityReq); err != nil {
return fmt.Errorf("delete entity with key %#v: %w", key, err)
}
// Pre-locking: rebuild the whole folder tree structure if we're
// deleting a folder
if previous.Group == folder.GROUP && previous.Resource == folder.RESOURCE {
if err = s.updateFolderTree(ctx, tx, key.Namespace); err != nil {
return fmt.Errorf("rebuild folder tree structure: %w", err)
}
}
// up to this point, we have done all the work possible before having to
// lock kind_version
// 1. Atomically increpement resource version for this kind
newVersion, err := kindVersionAtomicInc(ctx, tx, s.sqlDialect, key.Group, key.Resource)
if err != nil {
return err
}
// k8s expects us to return the entity as it was before the deletion,
// but with the updated RV
previous.ResourceVersion = newVersion
// build the new row to be inserted
deletedVersion := *previous // copy marshaled data since it won't change
deletedVersion.Entity = cloneEntity(previous.Entity) // clone entity
deletedVersion.Action = entity.Entity_DELETED
deletedVersion.UpdatedAt = time.Now().UnixMilli()
deletedVersion.UpdatedBy = updatedBy
// 2. Insert into entity history
insEntity := sqlEntityInsertRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
Entity: &deletedVersion,
}
if _, err = exec(ctx, tx, sqlEntityInsert, insEntity); err != nil {
return fmt.Errorf("insert into entity_history: %w", err)
}
// success
ret.Status = entity.DeleteEntityResponse_DELETED
ret.Entity = previous.Entity
return nil
})
if err != nil {
// TODO: should we populate the Error field and how? (i.e. how to
// determine what information can be disclosed to the user?)
return nil, fmt.Errorf("delete entity: %w", err)
}
return ret, nil
}

View File

@ -3,13 +3,16 @@ package sqlstash
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"strings"
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/services/store/entity/db"
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
) )
type folderInfo struct { type folderInfo struct {
Guid string `json:"guid"` GUID string `json:"guid"`
UID string `json:"uid"` UID string `json:"uid"`
Name string `json:"name"` // original display name Name string `json:"name"` // original display name
@ -23,7 +26,7 @@ type folderInfo struct {
right int32 right int32
// Build the tree // Build the tree
parentUID string ParentUID string
// Calculated after query // Calculated after query
parent *folderInfo parent *folderInfo
@ -31,56 +34,101 @@ type folderInfo struct {
stack []*folderInfo stack []*folderInfo
} }
// This will replace all entries in `entity_folder` func (fi *folderInfo) buildInsertItems(items *[]*sqlEntityFolderInsertRequestItem, namespace string, isLost bool) error {
// This is pretty heavy weight, but it does give us a sorted folder list var js strings.Builder
// NOTE: this could be done async with a mutex/lock? reconciler pattern if err := json.NewEncoder(&js).Encode(fi.stack); err != nil {
func (s *sqlEntityServer) updateFolderTree(ctx context.Context, tx *session.SessionTx, namespace string) error { return fmt.Errorf("marshal stack of folder %q to JSON: %w", fi.SlugPath, err)
_, err := tx.Exec(ctx, "DELETE FROM entity_folder WHERE namespace=?", namespace)
if err != nil {
return err
} }
query := "SELECT guid,name,folder,name,slug" + *items = append(*items, &sqlEntityFolderInsertRequestItem{
" FROM entity" + GUID: fi.GUID,
" WHERE " + s.dialect.Quote("group") + "=? AND resource=? AND namespace=?" + Namespace: namespace,
" ORDER BY slug asc" UID: fi.UID,
args := []interface{}{folder.GROUP, folder.RESOURCE, namespace} SlugPath: fi.SlugPath,
JS: js.String(),
Depth: fi.depth,
Left: fi.left,
Right: fi.right,
Detached: isLost,
})
all := []*folderInfo{} for _, sub := range fi.children {
rows, err := tx.Query(ctx, query, args...) if err := sub.buildInsertItems(items, namespace, isLost); err != nil {
if err != nil { return nil
return err
} }
defer func() { _ = rows.Close() }()
for rows.Next() {
folder := folderInfo{
children: []*folderInfo{},
}
err = rows.Scan(&folder.Guid, &folder.UID, &folder.parentUID, &folder.Name, &folder.Slug)
if err != nil {
return err
}
all = append(all, &folder)
} }
root, lost, err := buildFolderTree(all) return nil
if err != nil {
return err
} }
err = insertFolderInfo(ctx, tx, namespace, root, false) // This rebuilds the whole folders structure for a given namespace. This has to
// be done each time an entity is created or deleted.
// FIXME: This is very inefficient and time consuming. This could be implemented
// with a different approach instead of MPTT, or at least mitigated by an async
// job?
// FIXME: This algorithm apparently allows lost trees which are called
// "detached"? We should probably migrate to something safer.
func (s *sqlEntityServer) updateFolderTree(ctx context.Context, x db.ContextExecer, namespace string) error {
_, err := x.ExecContext(ctx, "DELETE FROM entity_folder WHERE namespace=?", namespace)
if err != nil { if err != nil {
return err return fmt.Errorf("clear entity_folder for namespace %q: %w", namespace, err)
} }
for _, folder := range lost { listReq := sqlEntityListFolderElementsRequest{
err = insertFolderInfo(ctx, tx, namespace, folder, true) SQLTemplate: sqltemplate.New(s.sqlDialect),
Group: folder.GROUP,
Resource: folder.RESOURCE,
Namespace: namespace,
FolderInfo: new(folderInfo),
}
query, err := sqltemplate.Execute(sqlEntityListFolderElements, listReq)
if err != nil { if err != nil {
return err return fmt.Errorf("execute SQL template to list folder items in namespace %q: %w", namespace, err)
}
rows, err := x.QueryContext(ctx, query, listReq.GetArgs()...)
if err != nil {
return fmt.Errorf("list folder items in namespace %q: %w", namespace, err)
}
var itemList []*folderInfo
for i := 1; rows.Next(); i++ {
if err := rows.Scan(listReq.GetScanDest()...); err != nil {
return fmt.Errorf("scan row #%d listing folder items in namespace %q: %w", i, namespace, err)
}
fi := *listReq.FolderInfo
itemList = append(itemList, &fi)
}
if err := rows.Close(); err != nil {
return fmt.Errorf("close rows after listing folder items in namespace %q: %w", namespace, err)
}
root, lost, err := buildFolderTree(itemList)
if err != nil {
return fmt.Errorf("build folder tree for namespace %q: %w", namespace, err)
}
var insertItems []*sqlEntityFolderInsertRequestItem
if err = root.buildInsertItems(&insertItems, namespace, false); err != nil {
return fmt.Errorf("build insert items for root tree in namespace %q: %w", namespace, err)
}
for i, lostItem := range lost {
if err = lostItem.buildInsertItems(&insertItems, namespace, false); err != nil {
return fmt.Errorf("build insert items for lost folder #%d tree in namespace %q: %w", i, namespace, err)
} }
} }
return err
insReq := sqlEntityFolderInsertRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
Items: insertItems,
}
if _, err = exec(ctx, x, sqlEntityFolderInsert, insReq); err != nil {
return fmt.Errorf("insert rebuilt tree for namespace %q: %w", namespace, err)
}
return nil
} }
func buildFolderTree(all []*folderInfo) (*folderInfo, []*folderInfo, error) { func buildFolderTree(all []*folderInfo) (*folderInfo, []*folderInfo, error) {
@ -100,7 +148,7 @@ func buildFolderTree(all []*folderInfo) (*folderInfo, []*folderInfo, error) {
// already sorted by slug // already sorted by slug
for _, folder := range all { for _, folder := range all {
parent, ok := lookup[folder.parentUID] parent, ok := lookup[folder.ParentUID]
if ok { if ok {
folder.parent = parent folder.parent = parent
parent.children = append(parent.children, folder) parent.children = append(parent.children, folder)
@ -136,32 +184,3 @@ func setMPTTOrder(folder *folderInfo, stack []*folderInfo, idx int32) (int32, er
folder.right = idx + 1 folder.right = idx + 1
return folder.right, nil return folder.right, nil
} }
func insertFolderInfo(ctx context.Context, tx *session.SessionTx, namespace string, folder *folderInfo, isDetached bool) error {
js, _ := json.Marshal(folder.stack)
_, err := tx.Exec(ctx,
`INSERT INTO entity_folder `+
"(guid, namespace, name, slug_path, tree, depth, lft, rgt, detached) "+
`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
folder.Guid,
namespace,
folder.UID,
folder.SlugPath,
string(js),
folder.depth,
folder.left,
folder.right,
isDetached,
)
if err != nil {
return err
}
for _, sub := range folder.children {
err := insertFolderInfo(ctx, tx, namespace, sub, isDetached)
if err != nil {
return err
}
}
return nil
}

View File

@ -5,16 +5,17 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana-plugin-sdk-go/experimental" "github.com/grafana/grafana-plugin-sdk-go/experimental"
"github.com/stretchr/testify/require"
) )
func TestFolderSupport(t *testing.T) { func TestFolderSupport(t *testing.T) {
root, lost, err := buildFolderTree([]*folderInfo{ root, lost, err := buildFolderTree([]*folderInfo{
{Guid: "GA", UID: "A", parentUID: "", Name: "A", Slug: "a"}, {GUID: "GA", UID: "A", ParentUID: "", Name: "A", Slug: "a"},
{Guid: "GAA", UID: "AA", parentUID: "A", Name: "AA", Slug: "aa"}, {GUID: "GAA", UID: "AA", ParentUID: "A", Name: "AA", Slug: "aa"},
{Guid: "GB", UID: "B", parentUID: "", Name: "B", Slug: "b"}, {GUID: "GB", UID: "B", ParentUID: "", Name: "B", Slug: "b"},
}) })
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, root) require.NotNil(t, root)

View File

@ -1,47 +1,111 @@
package sqlstash package sqlstash
import ( import (
"context"
"database/sql"
"embed" "embed"
"encoding/json"
"errors"
"fmt" "fmt"
"strings"
"text/template" "text/template"
"time"
"google.golang.org/protobuf/proto"
"github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/services/store/entity/db"
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate" "github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
) )
// Templates. // Templates setup.
var ( var (
//go:embed data //go:embed data/*.sql
templatesFs embed.FS sqlTemplatesFS embed.FS
// all templates // all templates
templates = template.Must(template.ParseFS(templatesFs, `data/*.sql`)) helpers = template.FuncMap{
"listSep": helperListSep,
sqlEntityDelete = getTemplate("entity_delete.sql") "join": helperJoin,
sqlEntityInsert = getTemplate("entity_insert.sql") }
sqlEntityListFolderElements = getTemplate("entity_list_folder_elements.sql") sqlTemplates = template.Must(template.New("sql").Funcs(helpers).ParseFS(sqlTemplatesFS, `data/*.sql`))
sqlEntityUpdate = getTemplate("entity_update.sql")
sqlEntityRead = getTemplate("entity_read.sql")
sqlEntityFolderInsert = getTemplate("entity_folder_insert.sql")
sqlEntityRefFind = getTemplate("entity_ref_find.sql")
sqlEntityLabelsDelete = getTemplate("entity_labels_delete.sql")
sqlEntityLabelsInsert = getTemplate("entity_labels_insert.sql")
sqlKindVersionInc = getTemplate("kind_version_inc.sql")
sqlKindVersionInsert = getTemplate("kind_version_insert.sql")
sqlKindVersionLock = getTemplate("kind_version_lock.sql")
) )
func getTemplate(filename string) *template.Template { func mustTemplate(filename string) *template.Template {
if t := templates.Lookup(filename); t != nil { if t := sqlTemplates.Lookup(filename); t != nil {
return t return t
} }
panic(fmt.Sprintf("template file not found: %s", filename)) panic(fmt.Sprintf("template file not found: %s", filename))
} }
// Templates.
var (
sqlEntityDelete = mustTemplate("entity_delete.sql")
sqlEntityHistory = mustTemplate("entity_history.sql")
//sqlEntityHistoryList = mustTemplate("entity_history_list.sql") // TODO: in upcoming PRs
sqlEntityInsert = mustTemplate("entity_insert.sql")
sqlEntityListFolderElements = mustTemplate("entity_list_folder_elements.sql")
sqlEntityUpdate = mustTemplate("entity_update.sql")
sqlEntityRead = mustTemplate("entity_read.sql")
sqlEntityFolderInsert = mustTemplate("entity_folder_insert.sql")
sqlEntityRefFind = mustTemplate("entity_ref_find.sql")
sqlEntityLabelsDelete = mustTemplate("entity_labels_delete.sql")
sqlEntityLabelsInsert = mustTemplate("entity_labels_insert.sql")
sqlKindVersionGet = mustTemplate("kind_version_get.sql")
sqlKindVersionInc = mustTemplate("kind_version_inc.sql")
sqlKindVersionInsert = mustTemplate("kind_version_insert.sql")
sqlKindVersionLock = mustTemplate("kind_version_lock.sql")
)
// TxOptions.
var (
ReadCommitted = &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
}
ReadCommittedRO = &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
ReadOnly: true,
}
)
// SQLError is an error returned by the database, which includes additionally
// debugging information about what was sent to the database.
type SQLError struct {
Err error
CallType string // either Query, QueryRow or Exec
Arguments []any
ScanDest []any
Query string
RawQuery string
}
func (e SQLError) Unwrap() error {
return e.Err
}
func (e SQLError) Error() string {
return fmt.Sprintf("calling %s in database: %v", e.CallType, e.Err)
}
func (e SQLError) Debug() string {
scanDestStr := "(none)"
if len(e.ScanDest) > 0 {
format := "[%T" + strings.Repeat(", %T", len(e.ScanDest)-1) + "]"
scanDestStr = fmt.Sprintf(format, e.ScanDest...)
}
return fmt.Sprintf("call %s in database: %v\n\tArguments (%d): %#v\n\t"+
"Return Value Types (%d): %s\n\tExecuted Query: %s\n\tRaw SQL "+
"Template Output: %s", e.CallType, e.Err, len(e.Arguments), e.Arguments,
len(e.ScanDest), scanDestStr, e.Query, e.RawQuery)
}
// entity_folder table requests.
type sqlEntityFolderInsertRequest struct { type sqlEntityFolderInsertRequest struct {
*sqltemplate.SQLTemplate *sqltemplate.SQLTemplate
Items []*sqlEntityFolderInsertRequestItem Items []*sqlEntityFolderInsertRequestItem
@ -59,12 +123,16 @@ type sqlEntityFolderInsertRequestItem struct {
Detached bool Detached bool
} }
// entity_ref table requests.
type sqlEntityRefFindRequest struct { type sqlEntityRefFindRequest struct {
*sqltemplate.SQLTemplate *sqltemplate.SQLTemplate
Request *entity.ReferenceRequest Request *entity.ReferenceRequest
Entity *withSerialized returnsEntitySet
} }
// entity_labels table requests.
type sqlEntityLabelsInsertRequest struct { type sqlEntityLabelsInsertRequest struct {
*sqltemplate.SQLTemplate *sqltemplate.SQLTemplate
GUID string GUID string
@ -77,29 +145,50 @@ type sqlEntityLabelsDeleteRequest struct {
KeepLabels []string KeepLabels []string
} }
// entity_kind table requests.
type returnsKindVersion struct {
ResourceVersion int64
CreatedAt int64
UpdatedAt int64
}
func (r *returnsKindVersion) Results() (*returnsKindVersion, error) {
return r, nil
}
type sqlKindVersionGetRequest struct {
*sqltemplate.SQLTemplate
Group string
Resource string
*returnsKindVersion
}
type sqlKindVersionLockRequest struct { type sqlKindVersionLockRequest struct {
*sqltemplate.SQLTemplate *sqltemplate.SQLTemplate
Group string Group string
GroupVersion string
Resource string Resource string
ResourceVersion int64 *returnsKindVersion
} }
type sqlKindVersionIncRequest struct { type sqlKindVersionIncRequest struct {
*sqltemplate.SQLTemplate *sqltemplate.SQLTemplate
Group string Group string
GroupVersion string
Resource string Resource string
ResourceVersion int64 ResourceVersion int64
UpdatedAt int64
} }
type sqlKindVersionInsertRequest struct { type sqlKindVersionInsertRequest struct {
*sqltemplate.SQLTemplate *sqltemplate.SQLTemplate
Group string Group string
GroupVersion string
Resource string Resource string
CreatedAt int64
UpdatedAt int64
} }
// entity and entity_history tables requests.
type sqlEntityListFolderElementsRequest struct { type sqlEntityListFolderElementsRequest struct {
*sqltemplate.SQLTemplate *sqltemplate.SQLTemplate
Group string Group string
@ -108,12 +197,16 @@ type sqlEntityListFolderElementsRequest struct {
FolderInfo *folderInfo FolderInfo *folderInfo
} }
// sqlEntityReadRequest can be used to retrieve a row from either the "entity"
// or the "entity_history" tables. In particular, don't use this template
// directly. Instead, use the readEntity function, which provides all common use
// cases and proper database deserialization.
type sqlEntityReadRequest struct { type sqlEntityReadRequest struct {
*sqltemplate.SQLTemplate *sqltemplate.SQLTemplate
Key *entity.Key Key *entity.Key
ResourceVersion int64 ResourceVersion int64
SelectForUpdate bool SelectForUpdate bool
Entity *withSerialized returnsEntitySet
} }
type sqlEntityDeleteRequest struct { type sqlEntityDeleteRequest struct {
@ -121,9 +214,21 @@ type sqlEntityDeleteRequest struct {
Key *entity.Key Key *entity.Key
} }
type sqlEntityHistoryRequest struct {
*sqltemplate.SQLTemplate
//historyToken // TODO: coming in another PR
returnsEntitySet
}
type sqlEntityHistoryListRequest struct {
*sqltemplate.SQLTemplate
//hitoryListToken // TODO: coming in another PR
returnsEntitySet
}
type sqlEntityInsertRequest struct { type sqlEntityInsertRequest struct {
*sqltemplate.SQLTemplate *sqltemplate.SQLTemplate
Entity *withSerialized Entity *returnsEntity
// TableEntity, when true, means we will insert into table "entity", and // TableEntity, when true, means we will insert into table "entity", and
// into table "entity_history" otherwise. // into table "entity_history" otherwise.
@ -132,33 +237,269 @@ type sqlEntityInsertRequest struct {
type sqlEntityUpdateRequest struct { type sqlEntityUpdateRequest struct {
*sqltemplate.SQLTemplate *sqltemplate.SQLTemplate
Entity *withSerialized Entity *returnsEntity
} }
// withSerialized provides access to the wire Entiity DTO as well as the func newEmptyEntity() *entity.Entity {
// serialized version of some of its fields suitable to be read from or written return &entity.Entity{
// to the database. // we need to allocate all internal pointer types so that they
type withSerialized struct { // are readily available to be populated in the template
*entity.Entity Origin: new(entity.EntityOriginInfo),
}
}
func cloneEntity(src *entity.Entity) *entity.Entity {
ret := newEmptyEntity()
proto.Merge(ret, src)
return ret
}
// returnsEntitySet can be embedded in a request struct to provide automatic set
// returning of []*entity.Entity from the database, deserializing as needed. It
// should be embedded as a value type.
type returnsEntitySet struct {
Entity *returnsEntity
}
// newWithResults returns a new newWithResults.
func newReturnsEntitySet() returnsEntitySet {
return returnsEntitySet{
Entity: newReturnsEntity(),
}
}
// Results is part of the implementation of sqltemplate.WithResults that
// deserializes the database data into an internal *entity.Entity, and then
// returns a deep copy of it.
func (e returnsEntitySet) Results() (*entity.Entity, error) {
ent, err := e.Entity.Results()
if err != nil {
return nil, err
}
return proto.Clone(ent).(*entity.Entity), nil
}
// returnsEntity is a wrapper that aids with database (de)serialization. It
// embeds a *entity.Entity to provide transparent access to all its fields, but
// overrides the ones that need database (de)serialization. It should be a named
// field in your request struct, with pointer type.
type returnsEntity struct {
*entity.Entity
Labels []byte Labels []byte
Fields []byte Fields []byte
Errors []byte Errors []byte
} }
// TODO: remove once we start using these symbols. Prevents `unused` linter func newReturnsEntity() *returnsEntity {
// until the next PR. return &returnsEntity{
var ( Entity: newEmptyEntity(),
_, _, _ = sqlEntityDelete, sqlEntityInsert, sqlEntityListFolderElements }
_, _, _ = sqlEntityUpdate, sqlEntityRead, sqlEntityFolderInsert }
_, _, _ = sqlEntityRefFind, sqlEntityLabelsDelete, sqlEntityLabelsInsert
_, _, _ = sqlKindVersionInc, sqlKindVersionInsert, sqlKindVersionLock func (e *returnsEntity) Results() (*entity.Entity, error) {
_, _ = sqlEntityFolderInsertRequest{}, sqlEntityFolderInsertRequestItem{} if err := e.unmarshal(); err != nil {
_, _ = sqlEntityRefFindRequest{}, sqlEntityLabelsInsertRequest{} return nil, err
_, _ = sqlEntityLabelsInsertRequest{}, sqlEntityLabelsDeleteRequest{} }
_, _ = sqlKindVersionLockRequest{}, sqlKindVersionIncRequest{}
_, _ = sqlKindVersionInsertRequest{}, sqlEntityListFolderElementsRequest{} return e.Entity, nil
_, _ = sqlEntityReadRequest{}, sqlEntityDeleteRequest{} }
_, _ = sqlEntityInsertRequest{}, sqlEntityUpdateRequest{}
_ = withSerialized{} // marshal serializes the fields from the wire protocol representation so they
) // can be written to the database.
func (e *returnsEntity) marshal() error {
var err error
if len(e.Entity.Labels) == 0 {
e.Labels = []byte{'{', '}'}
} else {
e.Labels, err = json.Marshal(e.Entity.Labels)
if err != nil {
return fmt.Errorf("serialize entity \"labels\" field: %w", err)
}
}
if len(e.Entity.Fields) == 0 {
e.Fields = []byte{'{', '}'}
} else {
e.Fields, err = json.Marshal(e.Entity.Fields)
if err != nil {
return fmt.Errorf("serialize entity \"fields\" field: %w", err)
}
}
if len(e.Entity.Errors) == 0 {
e.Errors = []byte{'[', ']'}
} else {
e.Errors, err = json.Marshal(e.Entity.Errors)
if err != nil {
return fmt.Errorf("serialize entity \"errors\" field: %w", err)
}
}
return nil
}
// unmarshal deserializes the fields in the database representation so they can
// be written to the wire protocol.
func (e *returnsEntity) unmarshal() error {
if len(e.Labels) > 0 {
if err := json.Unmarshal(e.Labels, &e.Entity.Labels); err != nil {
return fmt.Errorf("deserialize entity \"labels\" field: %w", err)
}
}
if len(e.Fields) > 0 {
if err := json.Unmarshal(e.Fields, &e.Entity.Fields); err != nil {
return fmt.Errorf("deserialize entity \"fields\" field: %w", err)
}
}
if len(e.Errors) > 0 {
if err := json.Unmarshal(e.Errors, &e.Entity.Errors); err != nil {
return fmt.Errorf("deserialize entity \"errors\" field: %w", err)
}
}
return nil
}
func readEntity(
ctx context.Context,
x db.ContextExecer,
d sqltemplate.Dialect,
k *entity.Key,
asOfVersion int64,
optimisticLocking bool,
selectForUpdate bool,
) (*returnsEntity, error) {
if asOfVersion < 0 {
asOfVersion = 0
}
if asOfVersion == 0 {
optimisticLocking = false
}
v := asOfVersion
if optimisticLocking {
// for optimistic locking, we will not ask for a specific version, but
// instead retrieve the latest version from the table "entity" and
// manually compare if it matches the given value of "asOfVersion".
v = 0
}
readReq := sqlEntityReadRequest{
SQLTemplate: sqltemplate.New(d),
Key: k,
ResourceVersion: v,
SelectForUpdate: selectForUpdate,
returnsEntitySet: newReturnsEntitySet(),
}
ent, err := queryRow(ctx, x, sqlEntityRead, readReq)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("read entity: %w", err)
}
if ent.Action == entity.Entity_DELETED {
return nil, ErrNotFound
}
if optimisticLocking && asOfVersion != 0 && ent.ResourceVersion != asOfVersion {
return nil, ErrOptimisticLockingFailed
}
return readReq.Entity, nil
}
// kindVersionAtomicInc atomically increases the version of a kind within a
// transaction.
func kindVersionAtomicInc(ctx context.Context, x db.ContextExecer, d sqltemplate.Dialect, group, resource string) (newVersion int64, err error) {
now := time.Now().UnixMilli()
// 1. Lock the kind and get the latest version
lockReq := sqlKindVersionLockRequest{
SQLTemplate: sqltemplate.New(d),
Group: group,
Resource: resource,
returnsKindVersion: new(returnsKindVersion),
}
kindv, err := queryRow(ctx, x, sqlKindVersionLock, lockReq)
// if there wasn't a row associated with the given kind, we create one with
// version 1
if errors.Is(err, sql.ErrNoRows) {
// NOTE: there is a marginal chance that we race with another writer
// trying to create the same row. This is only possible when onboarding
// a new (Group, Resource) to the cell, which should be very unlikely,
// and the workaround is simply retrying. The alternative would be to
// use INSERT ... ON CONFLICT DO UPDATE ..., but that creates a
// requirement for support in Dialect only for this marginal case, but
// we would rather keep Dialect as small as possible. Another
// alternative is to simply check if the INSERT returns a DUPLICATE KEY
// error and then retry the original SELECT, but that also adds some
// complexity to the code. That would be preferrable to changing
// Dialect, though. The current alternative, just retrying, seems to be
// enough for now.
insReq := sqlKindVersionInsertRequest{
SQLTemplate: sqltemplate.New(d),
Group: group,
Resource: resource,
CreatedAt: now,
UpdatedAt: now,
}
if _, err = exec(ctx, x, sqlKindVersionInsert, insReq); err != nil {
return 0, fmt.Errorf("insert into kind_version: %w", err)
}
return 1, nil
}
if err != nil {
return 0, fmt.Errorf("lock kind: %w", err)
}
incReq := sqlKindVersionIncRequest{
SQLTemplate: sqltemplate.New(d),
Group: group,
Resource: resource,
ResourceVersion: kindv.ResourceVersion,
UpdatedAt: now,
}
if _, err = exec(ctx, x, sqlKindVersionInc, incReq); err != nil {
return 0, fmt.Errorf("increase kind version: %w", err)
}
return kindv.ResourceVersion + 1, nil
}
// Template helpers.
// helperListSep is a helper that helps writing simpler loops in SQL templates.
// Example usage:
//
// {{ $comma := listSep ", " }}
// {{ range .Values }}
// {{/* here we put "-" on each end to remove extra white space */}}
// {{- call $comma -}}
// {{ .Value }}
// {{ end }}
func helperListSep(sep string) func() string {
var addSep bool
return func() string {
if addSep {
return sep
}
addSep = true
return ""
}
}
func helperJoin(sep string, elems ...string) string {
return strings.Join(elems, sep)
}

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db" oldDB "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl"
@ -25,113 +25,11 @@ func TestIsHealthy(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func TestCreate(t *testing.T) {
s := setUpTestServer(t)
tests := []struct {
name string
ent *entity.Entity
errIsExpected bool
statusIsExpected bool
}{
{
"request with key and entity creator",
&entity.Entity{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: "default",
Name: "set-minimum-uid",
Key: "/playlist.grafana.app/playlists/namespaces/default/set-minimum-uid",
CreatedBy: "set-minimum-creator",
Origin: &entity.EntityOriginInfo{},
},
false,
true,
},
{
"request with no entity creator",
&entity.Entity{
Key: "/playlist.grafana.app/playlists/namespaces/default/set-only-key",
},
true,
false,
},
{
"request with no key",
&entity.Entity{
CreatedBy: "entity-creator",
},
true,
true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := entity.CreateEntityRequest{
Entity: &entity.Entity{
Key: tc.ent.Key,
CreatedBy: tc.ent.CreatedBy,
},
}
resp, err := s.Create(context.Background(), &req)
if tc.errIsExpected {
require.Error(t, err)
if tc.statusIsExpected {
require.Equal(t, entity.CreateEntityResponse_ERROR, resp.Status)
}
return
}
require.Nil(t, err)
require.Equal(t, entity.CreateEntityResponse_CREATED, resp.Status)
require.NotNil(t, resp)
require.Nil(t, resp.Error)
read, err := s.Read(context.Background(), &entity.ReadEntityRequest{
Key: tc.ent.Key,
})
require.NoError(t, err)
require.NotNil(t, read)
require.Greater(t, len(read.Guid), 0)
require.Greater(t, read.ResourceVersion, int64(0))
expectedETag := createContentsHash(tc.ent.Body, tc.ent.Meta, tc.ent.Status)
require.Equal(t, expectedETag, read.ETag)
require.Equal(t, tc.ent.Origin, read.Origin)
require.Equal(t, tc.ent.Group, read.Group)
require.Equal(t, tc.ent.Resource, read.Resource)
require.Equal(t, tc.ent.Namespace, read.Namespace)
require.Equal(t, tc.ent.Name, read.Name)
require.Equal(t, tc.ent.Subresource, read.Subresource)
require.Equal(t, tc.ent.GroupVersion, read.GroupVersion)
require.Equal(t, tc.ent.Key, read.Key)
require.Equal(t, tc.ent.Folder, read.Folder)
require.Equal(t, tc.ent.Meta, read.Meta)
require.Equal(t, tc.ent.Body, read.Body)
require.Equal(t, tc.ent.Status, read.Status)
require.Equal(t, tc.ent.Title, read.Title)
require.Equal(t, tc.ent.Size, read.Size)
require.Greater(t, read.CreatedAt, int64(0))
require.Equal(t, tc.ent.CreatedBy, read.CreatedBy)
require.Equal(t, tc.ent.UpdatedAt, read.UpdatedAt)
require.Equal(t, tc.ent.UpdatedBy, read.UpdatedBy)
require.Equal(t, tc.ent.Description, read.Description)
require.Equal(t, tc.ent.Slug, read.Slug)
require.Equal(t, tc.ent.Message, read.Message)
require.Equal(t, tc.ent.Labels, read.Labels)
require.Equal(t, tc.ent.Fields, read.Fields)
require.Equal(t, tc.ent.Errors, read.Errors)
})
}
}
func setUpTestServer(t *testing.T) entity.EntityStoreServer { func setUpTestServer(t *testing.T) entity.EntityStoreServer {
sqlStore, cfg := db.InitTestDBWithCfg(t) if testing.Short() {
t.Skip("skipping integration test in short mode")
}
sqlStore, cfg := oldDB.InitTestDBWithCfg(t)
entityDB, err := dbimpl.ProvideEntityDB( entityDB, err := dbimpl.ProvideEntityDB(
sqlStore, sqlStore,
@ -150,3 +48,18 @@ func setUpTestServer(t *testing.T) entity.EntityStoreServer {
require.NoError(t, err) require.NoError(t, err)
return s return s
} }
// TODO: remove all the following once the Proposal 1 for Consistent Resource
// Version is finished.
var (
_ = parseAllSortBy
_ = countTrue
_ = query[any]
_ = sqlEntityHistory
_ = sqlEntityRefFind
_ = sqlKindVersionGet
_ = sqlEntityRefFindRequest{}
_ = sqlKindVersionGetRequest{}
_ = sqlEntityHistoryRequest{}
_ = sqlEntityHistoryListRequest{}
)

View File

@ -1,18 +1,80 @@
package sqltemplate package sqltemplate
import (
"errors"
"reflect"
"strings"
)
// Args errors.
var (
ErrInvalidArgList = errors.New("invalid arglist")
)
// Args keeps the data that needs to be passed to the engine for execution in // Args keeps the data that needs to be passed to the engine for execution in
// the right order. Add it to your data types passed to SQLTemplate, either by // the right order. Add it to your data types passed to SQLTemplate, either by
// embedding or with a named struct field if its Arg method would clash with // embedding or with a named struct field if its Arg method would clash with
// another struct field. // another struct field.
type Args []any type Args struct {
d Dialect
values []any
}
func NewArgs(d Dialect) *Args {
return &Args{
d: d,
}
}
// Arg can be called from within templates to pass arguments to the SQL driver // Arg can be called from within templates to pass arguments to the SQL driver
// to use in the execution of the query. // to use in the execution of the query.
func (a *Args) Arg(x any) string { func (a *Args) Arg(x any) string {
*a = append(*a, x) a.values = append(a.values, x)
return "?"
return a.d.ArgPlaceholder(len(a.values))
} }
func (a *Args) GetArgs() Args { // ArgList returns a comma separated list of `?` placeholders for each element
return *a // in the provided slice argument, calling Arg for each of them.
// Example struct:
//
// type sqlMyRequest struct {
// *sqltemplate.SQLTemplate
// IDs []int64
// }
//
// Example usage in a SQL template:
//
// DELETE FROM {{ .Ident "mytab" }}
// WHERE id IN ( {{ argList . .IDs }} )
// ;
func (a *Args) ArgList(slice reflect.Value) (string, error) {
if !slice.IsValid() || slice.Kind() != reflect.Slice {
return "", ErrInvalidArgList
}
sliceLen := slice.Len()
if sliceLen == 0 {
return "", nil
}
var b strings.Builder
b.Grow(3*sliceLen - 2) // the list will be ?, ?, ?
for i, l := 0, slice.Len(); i < l; i++ {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(a.Arg(slice.Index(i).Interface()))
}
return b.String(), nil
}
func (a *Args) GetArgs() []any {
return a.values
}
type ArgsIface interface {
Arg(x any) string
ArgList(slice reflect.Value) (string, error)
GetArgs() []any
} }

View File

@ -12,7 +12,7 @@ func TestArgs_Arg(t *testing.T) {
} }
} }
a := new(Args) a := NewArgs(MySQL)
shouldBeQuestionMark(t, a.Arg(0)) shouldBeQuestionMark(t, a.Arg(0))
shouldBeQuestionMark(t, a.Arg(1)) shouldBeQuestionMark(t, a.Arg(1))

View File

@ -2,6 +2,7 @@ package sqltemplate
import ( import (
"errors" "errors"
"strconv"
"strings" "strings"
) )
@ -21,6 +22,12 @@ type Dialect interface {
// names are all examples of identifiers. // names are all examples of identifiers.
Ident(string) (string, error) Ident(string) (string, error)
// ArgPlaceholder returns a safe argument suitable to be used in a SQL
// prepared statement for the argNum-eth argument passed in execution. The
// SQL92 Standard specifies the question mark ('?') should be used in all
// cases, but some implementations differ.
ArgPlaceholder(argNum int) string
// SelectFor parses and returns the given row-locking clause for a SELECT // SelectFor parses and returns the given row-locking clause for a SELECT
// statement. If the clause is invalid it returns an error. Implementations // statement. If the clause is invalid it returns an error. Implementations
// of this method should use ParseRowLockingClause. // of this method should use ParseRowLockingClause.
@ -97,3 +104,18 @@ func (standardIdent) Ident(s string) (string, error) {
} }
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`, nil return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`, nil
} }
type argPlaceholderFunc func(int) string
func (f argPlaceholderFunc) ArgPlaceholder(argNum int) string {
return f(argNum)
}
var (
argFmtSQL92 = argPlaceholderFunc(func(int) string {
return "?"
})
argFmtPositional = argPlaceholderFunc(func(argNum int) string {
return "$" + strconv.Itoa(argNum)
})
)

View File

@ -7,6 +7,7 @@ package sqltemplate
// https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes // https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes
var MySQL = mysql{ var MySQL = mysql{
rowLockingClauseAll: true, rowLockingClauseAll: true,
argPlaceholderFunc: argFmtSQL92,
} }
var _ Dialect = MySQL var _ Dialect = MySQL
@ -14,4 +15,5 @@ var _ Dialect = MySQL
type mysql struct { type mysql struct {
standardIdent standardIdent
rowLockingClauseAll rowLockingClauseAll
argPlaceholderFunc
} }

View File

@ -8,6 +8,7 @@ import (
// PostgreSQL is an implementation of Dialect for the PostgreSQL DMBS. // PostgreSQL is an implementation of Dialect for the PostgreSQL DMBS.
var PostgreSQL = postgresql{ var PostgreSQL = postgresql{
rowLockingClauseAll: true, rowLockingClauseAll: true,
argPlaceholderFunc: argFmtPositional,
} }
var _ Dialect = PostgreSQL var _ Dialect = PostgreSQL
@ -20,6 +21,7 @@ var (
type postgresql struct { type postgresql struct {
standardIdent standardIdent
rowLockingClauseAll rowLockingClauseAll
argPlaceholderFunc
} }
func (p postgresql) Ident(s string) (string, error) { func (p postgresql) Ident(s string) (string, error) {

View File

@ -3,6 +3,7 @@ package sqltemplate
// SQLite is an implementation of Dialect for the SQLite DMBS. // SQLite is an implementation of Dialect for the SQLite DMBS.
var SQLite = sqlite{ var SQLite = sqlite{
rowLockingClauseAll: false, rowLockingClauseAll: false,
argPlaceholderFunc: argFmtSQL92,
} }
var _ Dialect = SQLite var _ Dialect = SQLite
@ -12,4 +13,5 @@ type sqlite struct {
// https://www.sqlite.org/lang_keywords.html // https://www.sqlite.org/lang_keywords.html
standardIdent standardIdent
rowLockingClauseAll rowLockingClauseAll
argPlaceholderFunc
} }

View File

@ -103,15 +103,15 @@ func Example() {
// Assuming that we have a *sql.DB object named "db", we could now make our // Assuming that we have a *sql.DB object named "db", we could now make our
// query with: // query with:
// row := db.QueryRowContext(ctx, query, queryData.Args...) // row := db.QueryRowContext(ctx, query, queryData.GetArgs()...)
// // and check row.Err() here // // and check row.Err() here
// As we're not actually running a database in this example, let's verify // As we're not actually running a database in this example, let's verify
// that we find our arguments populated as expected instead: // that we find our arguments populated as expected instead:
if len(queryData.Args) != 1 { if len(queryData.GetArgs()) != 1 {
panic(fmt.Sprintf("unexpected number of args: %#v", queryData.Args)) panic(fmt.Sprintf("unexpected number of args: %#v", queryData.Args))
} }
id, ok := queryData.Args[0].(int) id, ok := queryData.GetArgs()[0].(int)
if !ok || id != queryData.Request.ID { if !ok || id != queryData.Request.ID {
panic(fmt.Sprintf("unexpected args: %#v", queryData.Args)) panic(fmt.Sprintf("unexpected args: %#v", queryData.Args))
} }
@ -119,25 +119,25 @@ func Example() {
// In your code you would now have "row" populated with the row data, // In your code you would now have "row" populated with the row data,
// assuming that the operation succeeded, so you would now scan the row data // assuming that the operation succeeded, so you would now scan the row data
// abd populate the values of our response: // abd populate the values of our response:
// err := row.Scan(queryData.ScanDest...) // err := row.Scan(queryData.GetScanDest()...)
// // and check err here // // and check err here
// Again, as we're not actually running a database in this example, we will // Again, as we're not actually running a database in this example, we will
// instead run the code to assert that queryData.ScanDest was populated with // instead run the code to assert that queryData.ScanDest was populated with
// the expected data, which should be pointers to each of the fields of // the expected data, which should be pointers to each of the fields of
// Response so that the Scan method can write to them: // Response so that the Scan method can write to them:
if len(queryData.ScanDest) != 3 { if len(queryData.GetScanDest()) != 3 {
panic(fmt.Sprintf("unexpected number of scan dest: %#v", queryData.ScanDest)) panic(fmt.Sprintf("unexpected number of scan dest: %#v", queryData.ScanDest))
} }
idPtr, ok := queryData.ScanDest[0].(*int) idPtr, ok := queryData.GetScanDest()[0].(*int)
if !ok || idPtr != &queryData.Response.ID { if !ok || idPtr != &queryData.Response.ID {
panic(fmt.Sprintf("unexpected response 'id' pointer: %#v", queryData.ScanDest)) panic(fmt.Sprintf("unexpected response 'id' pointer: %#v", queryData.ScanDest))
} }
typePtr, ok := queryData.ScanDest[1].(*string) typePtr, ok := queryData.GetScanDest()[1].(*string)
if !ok || typePtr != &queryData.Response.Type { if !ok || typePtr != &queryData.Response.Type {
panic(fmt.Sprintf("unexpected response 'type' pointer: %#v", queryData.ScanDest)) panic(fmt.Sprintf("unexpected response 'type' pointer: %#v", queryData.ScanDest))
} }
namePtr, ok := queryData.ScanDest[2].(*string) namePtr, ok := queryData.GetScanDest()[2].(*string)
if !ok || namePtr != &queryData.Response.Name { if !ok || namePtr != &queryData.Response.Name {
panic(fmt.Sprintf("unexpected response 'name' pointer: %#v", queryData.ScanDest)) panic(fmt.Sprintf("unexpected response 'name' pointer: %#v", queryData.ScanDest))
} }

View File

@ -5,18 +5,25 @@ import (
"reflect" "reflect"
) )
type ScanDest []any type ScanDest struct {
values []any
}
func (i *ScanDest) Into(v reflect.Value, colName string) (string, error) { func (i *ScanDest) Into(v reflect.Value, colName string) (string, error) {
if !v.IsValid() || !v.CanAddr() || !v.Addr().CanInterface() { if !v.IsValid() || !v.CanAddr() || !v.Addr().CanInterface() {
return "", fmt.Errorf("invalid or unaddressable value: %v", colName) return "", fmt.Errorf("invalid or unaddressable value: %v", colName)
} }
*i = append(*i, v.Addr().Interface()) i.values = append(i.values, v.Addr().Interface())
return colName, nil return colName, nil
} }
func (i *ScanDest) GetScanDest() ScanDest { func (i *ScanDest) GetScanDest() []any {
return *i return i.values
}
type ScanDestIface interface {
Into(v reflect.Value, colName string) (string, error)
GetScanDest() []any
} }

View File

@ -23,13 +23,15 @@ func TestScanDest_Into(t *testing.T) {
dataVal := reflect.ValueOf(&data).Elem() dataVal := reflect.ValueOf(&data).Elem()
colName, err = d.Into(dataVal.FieldByName("X"), "some int") colName, err = d.Into(dataVal.FieldByName("X"), "some int")
if err != nil || colName != "some int" || len(d) != 1 || d[0] != &data.X { v := d.GetScanDest()
if err != nil || colName != "some int" || len(v) != 1 || v[0] != &data.X {
t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v", t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v",
colName, err, d) colName, err, d)
} }
colName, err = d.Into(dataVal.FieldByName("Y"), "some byte") colName, err = d.Into(dataVal.FieldByName("Y"), "some byte")
if err != nil || colName != "some byte" || len(d) != 2 || d[1] != &data.Y { v = d.GetScanDest()
if err != nil || colName != "some byte" || len(v) != 2 || v[1] != &data.Y {
t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v", t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v",
colName, err, d) colName, err, d)
} }

View File

@ -1,26 +1,47 @@
package sqltemplate package sqltemplate
import ( import (
"regexp"
"strings" "strings"
"text/template" "text/template"
) )
// SQLTemplate provides comprehensive support for SQL templating, handling
// dialect traits, execution arguments and scanning arguments.
type SQLTemplate struct { type SQLTemplate struct {
Dialect Dialect
Args Args
ScanDest ScanDest
} }
// New returns a nee *SQLTemplate that will use the given dialect.
func New(d Dialect) *SQLTemplate { func New(d Dialect) *SQLTemplate {
return &SQLTemplate{ return &SQLTemplate{
Args: Args{
d: d,
},
Dialect: d, Dialect: d,
} }
} }
// SQLTemplateIface can be used as argument in general purpose utilities
// expecting a struct embedding *SQLTemplate.
type SQLTemplateIface interface { type SQLTemplateIface interface {
Dialect Dialect
GetArgs() Args ArgsIface
GetScanDest() ScanDest ScanDestIface
}
// WithResults has an additional method suited for structs embedding
// *SQLTemplate and returning a set of rows.
type WithResults[T any] interface {
SQLTemplateIface
// Results returns the results of the query. If the query is expected to
// return a set of rows, then it should be a deep copy of the internal
// results, so that it can be called multiple times to get the different
// values.
Results() (T, error)
} }
// Execute is a trivial utility to execute and return the results of any // Execute is a trivial utility to execute and return the results of any
@ -33,3 +54,38 @@ func Execute(t *template.Template, data any) (string, error) {
return b.String(), nil return b.String(), nil
} }
// FormatSQL is an opinionated formatter for SQL template output that returns
// the code as a oneliner. It can be used to reduce the final code length, for
// debugging, and testing. It is not a propoer and full-fledged SQL parser, so
// it makes the following assumptions, which are also good practices for writing
// your SQL templates:
// 1. There are no SQL comments. Consider adding your comments as template
// comments instead (i.e. "{{/* this is a template comment */}}").
// 2. There are no multiline strings, and strings do not contain consecutive
// spaces. Code looking like this is already a smell. Avoid string literals,
// pass them as arguments so they can be appropriately escaped by the
// corresponding driver. And identifiers with white space should be avoided
// in all cases as well.
func FormatSQL(q string) string {
q = strings.TrimSpace(q)
for _, f := range formatREs {
q = f.re.ReplaceAllString(q, f.replacement)
}
return q
}
type reFormatting struct {
re *regexp.Regexp
replacement string
}
var formatREs = []reFormatting{
{re: regexp.MustCompile(`\s+`), replacement: " "},
{re: regexp.MustCompile(` ?([+-/*=<>%!~]+) ?`), replacement: " $1 "},
{re: regexp.MustCompile(`([([{]) `), replacement: "$1"},
{re: regexp.MustCompile(` ([)\]}])`), replacement: "$1"},
{re: regexp.MustCompile(` ?, ?`), replacement: ", "},
{re: regexp.MustCompile(` ?([;.:]) ?`), replacement: "$1"},
}

View File

@ -3,16 +3,16 @@
// Frame[0] // Frame[0]
// Name: // Name:
// Dimensions: 7 Fields by 4 Rows // Dimensions: 7 Fields by 4 Rows
// +----------------+----------------+----------------+---------------+---------------+---------------+---------------------------------------------------------------------------------------------------------+ // +----------------+----------------+----------------+---------------+---------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------+
// | Name: UID | Name: name | Name: slug | Name: depth | Name: left | Name: right | Name: tree | // | Name: UID | Name: name | Name: slug | Name: depth | Name: left | Name: right | Name: tree |
// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: |
// | Type: []string | Type: []string | Type: []string | Type: []int32 | Type: []int32 | Type: []int32 | Type: []json.RawMessage | // | Type: []string | Type: []string | Type: []string | Type: []int32 | Type: []int32 | Type: []int32 | Type: []json.RawMessage |
// +----------------+----------------+----------------+---------------+---------------+---------------+---------------------------------------------------------------------------------------------------------+ // +----------------+----------------+----------------+---------------+---------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------+
// | | Root | | 0 | 1 | 8 | [] | // | | Root | | 0 | 1 | 8 | [] |
// | A | A | /a/ | 1 | 2 | 5 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/"}] | // | A | A | /a/ | 1 | 2 | 5 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/","ParentUID":""}] |
// | AA | AA | /a/aa/ | 2 | 3 | 4 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/"},{"guid":"GAA","uid":"AA","name":"AA","slug":"/a/aa/"}] | // | AA | AA | /a/aa/ | 2 | 3 | 4 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/","ParentUID":""},{"guid":"GAA","uid":"AA","name":"AA","slug":"/a/aa/","ParentUID":"A"}] |
// | B | B | /b/ | 1 | 6 | 7 | [{"guid":"GB","uid":"B","name":"B","slug":"/b/"}] | // | B | B | /b/ | 1 | 6 | 7 | [{"guid":"GB","uid":"B","name":"B","slug":"/b/","ParentUID":""}] |
// +----------------+----------------+----------------+---------------+---------------+---------------+---------------------------------------------------------------------------------------------------------+ // +----------------+----------------+----------------+---------------+---------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------+
// //
// //
// 🌟 This was machine generated. Do not edit. 🌟 // 🌟 This was machine generated. Do not edit. 🌟
@ -118,7 +118,8 @@
"guid": "GA", "guid": "GA",
"uid": "A", "uid": "A",
"name": "A", "name": "A",
"slug": "/a/" "slug": "/a/",
"ParentUID": ""
} }
], ],
[ [
@ -126,13 +127,15 @@
"guid": "GA", "guid": "GA",
"uid": "A", "uid": "A",
"name": "A", "name": "A",
"slug": "/a/" "slug": "/a/",
"ParentUID": ""
}, },
{ {
"guid": "GAA", "guid": "GAA",
"uid": "AA", "uid": "AA",
"name": "AA", "name": "AA",
"slug": "/a/aa/" "slug": "/a/aa/",
"ParentUID": "A"
} }
], ],
[ [
@ -140,7 +143,8 @@
"guid": "GB", "guid": "GB",
"uid": "B", "uid": "B",
"name": "B", "name": "B",
"slug": "/b/" "slug": "/b/",
"ParentUID": ""
} }
] ]
] ]

View File

@ -0,0 +1,198 @@
package sqlstash
import (
"cmp"
"context"
"fmt"
"maps"
"time"
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/services/store/entity/db"
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
)
func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequest) (*entity.UpdateEntityResponse, error) {
ctx, span := s.tracer.Start(ctx, "storage_server.Update")
defer span.End()
key, err := entity.ParseKey(r.Entity.Key)
if err != nil {
return nil, fmt.Errorf("update entity: parse entity key: %w", err)
}
updatedBy, err := getCurrentUser(ctx)
if err != nil {
return nil, fmt.Errorf("update entity: get user from context: %w", err)
}
ret := new(entity.UpdateEntityResponse)
err = s.sqlDB.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
// Pre-locking: get the latest version of the entity
oldEntity, err := readEntity(ctx, tx, s.sqlDialect, key, r.PreviousVersion, true, false)
if err != nil {
return err
}
// build the entity from the request and the old data
newEntity, err := entityForUpdate(updatedBy, oldEntity.Entity, r.Entity)
if err != nil {
return fmt.Errorf("")
}
keepLabels, insertLabels := diffLabels(oldEntity.Entity.Labels, r.Entity.Labels)
// Pre-locking: delete old labels
if len(keepLabels) > 0 {
delLabelsReq := sqlEntityLabelsDeleteRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
GUID: oldEntity.Guid,
KeepLabels: keepLabels,
}
_, err = exec(ctx, tx, sqlEntityLabelsDelete, delLabelsReq)
if err != nil {
return fmt.Errorf("delete old labels: %w", err)
}
}
// Pre-locking: insert new labels
if len(insertLabels) > 0 {
insLabelsReq := sqlEntityLabelsInsertRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
GUID: oldEntity.Guid,
Labels: insertLabels,
}
_, err = exec(ctx, tx, sqlEntityLabelsInsert, insLabelsReq)
if err != nil {
return fmt.Errorf("insert new labels: %w", err)
}
}
// up to this point, we have done all the work possible before having to
// lock kind_version
// 1. Atomically increpement resource version for this kind
newVersion, err := kindVersionAtomicInc(ctx, tx, s.sqlDialect, key.Group, key.Resource)
if err != nil {
return err
}
newEntity.ResourceVersion = newVersion
// 2. Update entity
updEntityReq := sqlEntityUpdateRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
Entity: newEntity,
}
if _, err = exec(ctx, tx, sqlEntityUpdate, updEntityReq); err != nil {
return fmt.Errorf("update entity: %w", err)
}
// 3. Insert into entity history
insEntity := sqlEntityInsertRequest{
SQLTemplate: sqltemplate.New(s.sqlDialect),
Entity: newEntity,
}
if _, err = exec(ctx, tx, sqlEntityInsert, insEntity); err != nil {
return fmt.Errorf("insert into entity_history: %w", err)
}
// 4. Rebuild the whole folder tree structure if we're updating a folder
if newEntity.Group == folder.GROUP && newEntity.Resource == folder.RESOURCE {
if err = s.updateFolderTree(ctx, tx, key.Namespace); err != nil {
return fmt.Errorf("rebuild folder tree structure: %w", err)
}
}
// success
ret.Entity = newEntity.Entity
ret.Status = entity.UpdateEntityResponse_UPDATED
return nil
})
if err != nil {
// TODO: should we define the "Error" field here and how? (i.e. how
// to determine what information can be disclosed to the user?)
return nil, fmt.Errorf("update entity: %w", err)
}
return ret, nil
}
func diffLabels(oldLabels, newLabels map[string]string) (keepLabels []string, insertLabels map[string]string) {
insertLabels = maps.Clone(newLabels)
for oldk, oldv := range oldLabels {
newv, ok := insertLabels[oldk]
if ok && oldv == newv {
keepLabels = append(keepLabels, oldk)
delete(insertLabels, oldk)
}
}
return keepLabels, insertLabels
}
// entityForUpdate populates a *returnsEntity taking the relevant parts from
// the requested update and keeping the necessary values from the old one.
func entityForUpdate(updatedBy string, oldEntity, newEntity *entity.Entity) (*returnsEntity, error) {
newOrigin := ptrOr(newEntity.Origin)
oldOrigin := ptrOr(oldEntity.Origin)
ret := &returnsEntity{
Entity: &entity.Entity{
Guid: oldEntity.Guid, // read-only
// ResourceVersion is later set after reading `kind_version` table
Key: oldEntity.Key, // read-only
Group: oldEntity.Group, // read-only
GroupVersion: cmp.Or(newEntity.GroupVersion, oldEntity.GroupVersion),
Resource: oldEntity.Resource, // read-only
Namespace: oldEntity.Namespace, // read-only
Name: oldEntity.Name, // read-only
Folder: cmp.Or(newEntity.Folder, oldEntity.Folder),
Meta: sliceOr(newEntity.Meta, oldEntity.Meta),
Body: sliceOr(newEntity.Body, oldEntity.Body),
Status: sliceOr(newEntity.Status, oldEntity.Status),
Size: int64(cmp.Or(len(newEntity.Body), len(oldEntity.Body))),
ETag: cmp.Or(newEntity.ETag, oldEntity.ETag),
CreatedAt: oldEntity.CreatedAt, // read-only
CreatedBy: oldEntity.CreatedBy, // read-only
UpdatedAt: time.Now().UnixMilli(),
UpdatedBy: updatedBy,
Origin: &entity.EntityOriginInfo{
Source: cmp.Or(newOrigin.Source, oldOrigin.Source),
Key: cmp.Or(newOrigin.Key, oldOrigin.Key),
Time: cmp.Or(newOrigin.Time, oldOrigin.Time),
},
Title: cmp.Or(newEntity.Title, oldEntity.Title),
Slug: cmp.Or(newEntity.Slug, oldEntity.Slug),
Description: cmp.Or(newEntity.Description, oldEntity.Description),
Message: cmp.Or(newEntity.Message, oldEntity.Message),
Labels: mapOr(newEntity.Labels, oldEntity.Labels),
Fields: mapOr(newEntity.Fields, oldEntity.Fields),
Errors: newEntity.Errors,
Action: entity.Entity_UPDATED,
},
}
if len(newEntity.Body) != 0 ||
len(newEntity.Meta) != 0 ||
len(newEntity.Status) != 0 {
ret.ETag = createETag(ret.Body, ret.Meta, ret.Status)
}
if err := ret.marshal(); err != nil {
return nil, fmt.Errorf("serialize entity data for db: %w", err)
}
return ret, nil
}

View File

@ -1,11 +1,20 @@
package sqlstash package sqlstash
import ( import (
"context"
"crypto/md5" "crypto/md5"
"database/sql"
"encoding/hex" "encoding/hex"
"fmt"
"text/template"
"github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/services/store"
"github.com/grafana/grafana/pkg/services/store/entity/db"
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
) )
func createContentsHash(body []byte, meta []byte, status []byte) string { func createETag(body []byte, meta []byte, status []byte) string {
h := md5.New() h := md5.New()
_, _ = h.Write(meta) _, _ = h.Write(meta)
_, _ = h.Write(body) _, _ = h.Write(body)
@ -13,3 +22,168 @@ func createContentsHash(body []byte, meta []byte, status []byte) string {
hash := h.Sum(nil) hash := h.Sum(nil)
return hex.EncodeToString(hash[:]) return hex.EncodeToString(hash[:])
} }
// getCurrentUser returns a string identifying the user making a request with
// the given context.
func getCurrentUser(ctx context.Context) (string, error) {
user, err := appcontext.User(ctx)
if err != nil || user == nil {
return "", fmt.Errorf("%w: %w", ErrUserNotFoundInContext, err)
}
return store.GetUserIDString(user), nil
}
// ptrOr returns the first non-nil pointer in the least or a new non-nil
// pointer.
func ptrOr[P ~*E, E any](ps ...P) P {
for _, p := range ps {
if p != nil {
return p
}
}
return P(new(E))
}
// sliceOr returns the first slice that has at least one element, or a non-nil
// empty slice.
func sliceOr[S ~[]E, E comparable](vals ...S) S {
for _, s := range vals {
if len(s) > 0 {
return s
}
}
return S{}
}
// mapOr returns the first map that has at least one element, or a non-nil empty
// map.
func mapOr[M ~map[K]V, K comparable, V any](vals ...M) M {
for _, m := range vals {
if len(m) > 0 {
return m
}
}
return M{}
}
// countTrue returns the number of true values in its arguments.
func countTrue(bools ...bool) uint64 {
var ret uint64
for _, b := range bools {
if b {
ret++
}
}
return ret
}
// query uses `req` as input and output for a zero or more row-returning query
// generated with `tmpl`, and executed in `x`.
func query[T any](ctx context.Context, x db.ContextExecer, tmpl *template.Template, req sqltemplate.WithResults[T]) ([]T, error) {
rawQuery, err := sqltemplate.Execute(tmpl, req)
if err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}
query := sqltemplate.FormatSQL(rawQuery)
rows, err := x.QueryContext(ctx, query, req.GetArgs()...)
if err != nil {
return nil, SQLError{
Err: err,
CallType: "Query",
Arguments: req.GetArgs(),
ScanDest: req.GetScanDest(),
Query: query,
RawQuery: rawQuery,
}
}
defer rows.Close() //nolint:errcheck
var ret []T
for rows.Next() {
res, err := scanRow(rows, req)
if err != nil {
return nil, err
}
ret = append(ret, res)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows err: %w", err)
}
return ret, nil
}
// queryRow uses `req` as input and output for a single-row returning query
// generated with `tmpl`, and executed in `x`.
func queryRow[T any](ctx context.Context, x db.ContextExecer, tmpl *template.Template, req sqltemplate.WithResults[T]) (T, error) {
var zero T
rawQuery, err := sqltemplate.Execute(tmpl, req)
if err != nil {
return zero, fmt.Errorf("execute template: %w", err)
}
query := sqltemplate.FormatSQL(rawQuery)
row := x.QueryRowContext(ctx, query, req.GetArgs()...)
if err := row.Err(); err != nil {
return zero, SQLError{
Err: err,
CallType: "QueryRow",
Arguments: req.GetArgs(),
ScanDest: req.GetScanDest(),
Query: query,
RawQuery: rawQuery,
}
}
return scanRow(row, req)
}
type scanner interface {
Scan(dest ...any) error
}
// scanRow is used on *sql.Row and *sql.Rows, and is factored out here not to
// improving code reuse, but rather for ease of testing.
func scanRow[T any](sc scanner, req sqltemplate.WithResults[T]) (zero T, err error) {
if err = sc.Scan(req.GetScanDest()...); err != nil {
return zero, fmt.Errorf("row scan: %w", err)
}
res, err := req.Results()
if err != nil {
return zero, fmt.Errorf("row results: %w", err)
}
return res, nil
}
// exec uses `req` as input for a non-data returning query generated with
// `tmpl`, and executed in `x`.
func exec(ctx context.Context, x db.ContextExecer, tmpl *template.Template, req sqltemplate.SQLTemplateIface) (sql.Result, error) {
rawQuery, err := sqltemplate.Execute(tmpl, req)
if err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}
query := sqltemplate.FormatSQL(rawQuery)
res, err := x.ExecContext(ctx, query, req.GetArgs()...)
if err != nil {
return nil, SQLError{
Err: err,
CallType: "Exec",
Arguments: req.GetArgs(),
Query: query,
RawQuery: rawQuery,
}
}
return res, nil
}