Merge remote-tracking branch 'origin/main' into resource-store
This commit is contained in:
@@ -1126,14 +1126,6 @@ var (
|
||||
HideFromDocs: true,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
Name: "betterPageScrolling",
|
||||
Description: "Removes CustomScrollbar from the UI, relying on native browser scrollbars",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
Expression: "true", // enabled by default
|
||||
},
|
||||
{
|
||||
Name: "authAPIAccessTokenAuth",
|
||||
Description: "Enables the use of Auth API access tokens for authentication",
|
||||
@@ -1331,6 +1323,13 @@ var (
|
||||
HideFromDocs: true,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
Name: "databaseReadReplica",
|
||||
Description: "Use a read replica for some database queries.",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaBackendServicesSquad,
|
||||
Expression: "false", // enabled by default
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -150,7 +150,6 @@ tlsMemcached,experimental,@grafana/grafana-operator-experience-squad,false,false
|
||||
kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
expressionParser,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
groupByVariable,experimental,@grafana/dashboards-squad,false,false,false
|
||||
betterPageScrolling,GA,@grafana/grafana-frontend-platform,false,false,true
|
||||
authAPIAccessTokenAuth,experimental,@grafana/identity-access-team,false,false,false
|
||||
scopeFilters,experimental,@grafana/dashboards-squad,false,false,false
|
||||
ssoSettingsSAML,preview,@grafana/identity-access-team,false,false,false
|
||||
@@ -177,3 +176,4 @@ pinNavItems,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false
|
||||
openSearchBackendFlowEnabled,preview,@grafana/aws-datasources,false,false,false
|
||||
ssoSettingsLDAP,experimental,@grafana/identity-access-team,false,false,false
|
||||
databaseReadReplica,experimental,@grafana/grafana-backend-services-squad,false,false,false
|
||||
|
||||
|
@@ -611,10 +611,6 @@ const (
|
||||
// Enable groupBy variable support in scenes dashboards
|
||||
FlagGroupByVariable = "groupByVariable"
|
||||
|
||||
// FlagBetterPageScrolling
|
||||
// Removes CustomScrollbar from the UI, relying on native browser scrollbars
|
||||
FlagBetterPageScrolling = "betterPageScrolling"
|
||||
|
||||
// FlagAuthAPIAccessTokenAuth
|
||||
// Enables the use of Auth API access tokens for authentication
|
||||
FlagAuthAPIAccessTokenAuth = "authAPIAccessTokenAuth"
|
||||
@@ -718,4 +714,8 @@ const (
|
||||
// FlagSsoSettingsLDAP
|
||||
// Use the new SSO Settings API to configure LDAP
|
||||
FlagSsoSettingsLDAP = "ssoSettingsLDAP"
|
||||
|
||||
// FlagDatabaseReadReplica
|
||||
// Use a read replica for some database queries.
|
||||
FlagDatabaseReadReplica = "databaseReadReplica"
|
||||
)
|
||||
|
||||
@@ -463,7 +463,8 @@
|
||||
"metadata": {
|
||||
"name": "betterPageScrolling",
|
||||
"resourceVersion": "1717578796182",
|
||||
"creationTimestamp": "2024-03-06T15:06:47Z"
|
||||
"creationTimestamp": "2024-03-06T15:06:47Z",
|
||||
"deletionTimestamp": "2024-06-18T09:25:56Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Removes CustomScrollbar from the UI, relying on native browser scrollbars",
|
||||
@@ -654,6 +655,18 @@
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "databaseReadReplica",
|
||||
"resourceVersion": "1718308641844",
|
||||
"creationTimestamp": "2024-06-13T19:57:21Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use a read replica for some database queries.",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/grafana-backend-services-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "dataplaneFrontendFallback",
|
||||
|
||||
@@ -358,8 +358,8 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
|
||||
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
|
||||
Text: "Recently Deleted",
|
||||
SubTitle: "Any items listed here for more than 30 days will be automatically deleted.",
|
||||
Id: "dashboards/recentlyDeleted",
|
||||
Url: s.cfg.AppSubURL + "/dashboard/recentlyDeleted",
|
||||
Id: "dashboards/recently-deleted",
|
||||
Url: s.cfg.AppSubURL + "/dashboard/recently-deleted",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,11 @@ func NewDatabaseConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*
|
||||
return dbCfg, nil
|
||||
}
|
||||
|
||||
func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error {
|
||||
sec := cfg.Raw.Section("database")
|
||||
|
||||
// readConfigSection reads the database configuration from the given block of
|
||||
// the configuration file. This method allows us to add a "database_replica"
|
||||
// section to the configuration file while using the same cfg struct.
|
||||
func (dbCfg *DatabaseConfig) readConfigSection(cfg *setting.Cfg, section string) error {
|
||||
sec := cfg.Raw.Section(section)
|
||||
cfgURL := sec.Key("url").String()
|
||||
if len(cfgURL) != 0 {
|
||||
dbURL, err := url.Parse(cfgURL)
|
||||
@@ -101,7 +103,6 @@ func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error {
|
||||
dbCfg.MaxOpenConn = sec.Key("max_open_conn").MustInt(0)
|
||||
dbCfg.MaxIdleConn = sec.Key("max_idle_conn").MustInt(2)
|
||||
dbCfg.ConnMaxLifetime = sec.Key("conn_max_lifetime").MustInt(14400)
|
||||
|
||||
dbCfg.SslMode = sec.Key("ssl_mode").String()
|
||||
dbCfg.SSLSNI = sec.Key("ssl_sni").String()
|
||||
dbCfg.CaCertPath = sec.Key("ca_cert_path").String()
|
||||
@@ -110,21 +111,22 @@ func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error {
|
||||
dbCfg.ServerCertName = sec.Key("server_cert_name").String()
|
||||
dbCfg.Path = sec.Key("path").MustString("data/grafana.db")
|
||||
dbCfg.IsolationLevel = sec.Key("isolation_level").String()
|
||||
|
||||
dbCfg.CacheMode = sec.Key("cache_mode").MustString("private")
|
||||
dbCfg.WALEnabled = sec.Key("wal").MustBool(false)
|
||||
dbCfg.SkipMigrations = sec.Key("skip_migrations").MustBool()
|
||||
dbCfg.MigrationLock = sec.Key("migration_locking").MustBool(true)
|
||||
dbCfg.MigrationLockAttemptTimeout = sec.Key("locking_attempt_timeout_sec").MustInt()
|
||||
|
||||
dbCfg.QueryRetries = sec.Key("query_retries").MustInt()
|
||||
dbCfg.TransactionRetries = sec.Key("transaction_retries").MustInt(5)
|
||||
|
||||
dbCfg.LogQueries = sec.Key("log_queries").MustBool(false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readConfig is a wrapper around readConfigSection that read the "database" configuration block.
|
||||
func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error {
|
||||
return dbCfg.readConfigSection(cfg, "database")
|
||||
}
|
||||
|
||||
func (dbCfg *DatabaseConfig) buildConnectionString(cfg *setting.Cfg, features featuremgmt.FeatureToggles) error {
|
||||
if dbCfg.ConnectionString != "" {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/dlmiddlecote/sqlstats"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrations"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// ReplStore is a wrapper around a main SQLStore and a read-only SQLStore. The
|
||||
// main SQLStore is anonymous, so the ReplStore may be used directly as a
|
||||
// SQLStore.
|
||||
type ReplStore struct {
|
||||
*SQLStore
|
||||
repl *SQLStore
|
||||
}
|
||||
|
||||
// DB returns the main SQLStore.
|
||||
func (rs ReplStore) DB() *SQLStore {
|
||||
return rs.SQLStore
|
||||
}
|
||||
|
||||
// ReadReplica returns the read-only SQLStore. If no read replica is configured,
|
||||
// it returns the main SQLStore.
|
||||
func (rs ReplStore) ReadReplica() *SQLStore {
|
||||
if rs.repl == nil {
|
||||
rs.log.Debug("ReadReplica not configured, using main SQLStore")
|
||||
return rs.SQLStore
|
||||
}
|
||||
rs.log.Debug("Using ReadReplica")
|
||||
return rs.repl
|
||||
}
|
||||
|
||||
// ProvideServiceWithReadReplica creates a new *SQLStore connection intended for
|
||||
// use as a ReadReplica of the main SQLStore. The primary SQLStore must already
|
||||
// be initialized.
|
||||
func ProvideServiceWithReadReplica(primary *SQLStore, cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator,
|
||||
bus bus.Bus, tracer tracing.Tracer) (*ReplStore, error) {
|
||||
// start with the initialized SQLStore
|
||||
replStore := &ReplStore{primary, nil}
|
||||
|
||||
// FeatureToggle fallback: If the FlagDatabaseReadReplica feature flag is not enabled, return a single SQLStore.
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagDatabaseReadReplica) {
|
||||
primary.log.Debug("ReadReplica feature flag not enabled, using main SQLStore")
|
||||
return replStore, nil
|
||||
}
|
||||
|
||||
// This change will make xorm use an empty default schema for postgres and
|
||||
// by that mimic the functionality of how it was functioning before
|
||||
// xorm's changes above.
|
||||
xorm.DefaultPostgresSchema = ""
|
||||
s, err := newReadOnlySQLStore(cfg, features, bus, tracer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.features = features
|
||||
s.tracer = tracer
|
||||
|
||||
// initialize and register metrics wrapper around the *sql.DB
|
||||
db := s.engine.DB().DB
|
||||
|
||||
// register the go_sql_stats_connections_* metrics
|
||||
if err := prometheus.Register(sqlstats.NewStatsCollector("grafana_repl", db)); err != nil {
|
||||
s.log.Warn("Failed to register sqlstore stats collector", "error", err)
|
||||
}
|
||||
|
||||
replStore.repl = s
|
||||
return replStore, nil
|
||||
}
|
||||
|
||||
// newReadOnlySQLStore creates a new *SQLStore intended for use with a
|
||||
// fully-populated read replica of the main Grafana Database. It provides no
|
||||
// write capabilities and does not run migrations, but other tracing and logging
|
||||
// features are enabled.
|
||||
func newReadOnlySQLStore(cfg *setting.Cfg, features featuremgmt.FeatureToggles, bus bus.Bus, tracer tracing.Tracer) (*SQLStore, error) {
|
||||
s := &SQLStore{
|
||||
cfg: cfg,
|
||||
log: log.New("replstore"),
|
||||
bus: bus,
|
||||
tracer: tracer,
|
||||
}
|
||||
|
||||
s.features = features
|
||||
s.tracer = tracer
|
||||
|
||||
err := s.initReadOnlyEngine(s.engine)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.dialect = migrator.NewDialect(s.engine.DriverName())
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// initReadOnlyEngine initializes ss.engine for read-only operations. The database must be a fully-populated read replica.
|
||||
func (ss *SQLStore) initReadOnlyEngine(engine *xorm.Engine) error {
|
||||
if ss.engine != nil {
|
||||
ss.log.Debug("Already connected to database replica")
|
||||
return nil
|
||||
}
|
||||
|
||||
dbCfg, err := NewRODatabaseConfig(ss.cfg, ss.features)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ss.dbCfg = dbCfg
|
||||
|
||||
if ss.cfg.DatabaseInstrumentQueries {
|
||||
ss.dbCfg.Type = WrapDatabaseDriverWithHooks(ss.dbCfg.Type, ss.tracer)
|
||||
}
|
||||
|
||||
if engine == nil {
|
||||
var err error
|
||||
engine, err = xorm.NewEngine(ss.dbCfg.Type, ss.dbCfg.ConnectionString)
|
||||
if err != nil {
|
||||
ss.log.Error("failed to connect to database replica", "error", err)
|
||||
return err
|
||||
}
|
||||
// Only for MySQL or MariaDB, verify we can connect with the current connection string's system var for transaction isolation.
|
||||
// If not, create a new engine with a compatible connection string.
|
||||
if ss.dbCfg.Type == migrator.MySQL {
|
||||
engine, err = ss.ensureTransactionIsolationCompatibility(engine, ss.dbCfg.ConnectionString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
engine.SetMaxOpenConns(ss.dbCfg.MaxOpenConn)
|
||||
engine.SetMaxIdleConns(ss.dbCfg.MaxIdleConn)
|
||||
engine.SetConnMaxLifetime(time.Second * time.Duration(ss.dbCfg.ConnMaxLifetime))
|
||||
|
||||
// configure sql logging
|
||||
debugSQL := ss.cfg.Raw.Section("database_replica").Key("log_queries").MustBool(false)
|
||||
if !debugSQL {
|
||||
engine.SetLogger(&xorm.DiscardLogger{})
|
||||
} else {
|
||||
// add stack to database calls to be able to see what repository initiated queries. Top 7 items from the stack as they are likely in the xorm library.
|
||||
engine.SetLogger(NewXormLogger(log.LvlInfo, log.WithSuffix(log.New("replsstore.xorm"), log.CallerContextKey, log.StackCaller(log.DefaultCallerDepth))))
|
||||
engine.ShowSQL(true)
|
||||
engine.ShowExecTime(true)
|
||||
}
|
||||
|
||||
ss.engine = engine
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRODatabaseConfig creates a new read-only database configuration.
|
||||
func NewRODatabaseConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*DatabaseConfig, error) {
|
||||
if cfg == nil {
|
||||
return nil, errors.New("cfg cannot be nil")
|
||||
}
|
||||
|
||||
dbCfg := &DatabaseConfig{}
|
||||
if err := dbCfg.readConfigSection(cfg, "database_replica"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := dbCfg.buildConnectionString(cfg, features); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dbCfg, nil
|
||||
}
|
||||
|
||||
// ProvideServiceWithReadReplicaForTests wraps the SQLStore in a ReplStore, with the main sqlstore as both the primary and read replica.
|
||||
// TODO: eventually this should be replaced with a more robust test setup which in
|
||||
func ProvideServiceWithReadReplicaForTests(testDB *SQLStore, t sqlutil.ITestDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator) (*ReplStore, error) {
|
||||
return &ReplStore{testDB, testDB}, nil
|
||||
}
|
||||
|
||||
// InitTestReplDB initializes a test DB and returns it wrapped in a ReplStore with the main SQLStore as both the primary and read replica.
|
||||
func InitTestReplDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*ReplStore, *setting.Cfg) {
|
||||
t.Helper()
|
||||
features := getFeaturesForTesting(opts...)
|
||||
cfg := getCfgForTesting(opts...)
|
||||
ss, err := initTestDB(t, cfg, features, migrations.ProvideOSSMigrations(features), opts...)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize sql repl store: %s", err)
|
||||
}
|
||||
return &ReplStore{ss, ss}, cfg
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/libraryelements/model"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/stats"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -17,12 +18,12 @@ import (
|
||||
const activeUserTimeLimit = time.Hour * 24 * 30
|
||||
const dailyActiveUserTimeLimit = time.Hour * 24
|
||||
|
||||
func ProvideService(cfg *setting.Cfg, db db.DB) stats.Service {
|
||||
func ProvideService(cfg *setting.Cfg, db *sqlstore.ReplStore) stats.Service {
|
||||
return &sqlStatsService{cfg: cfg, db: db}
|
||||
}
|
||||
|
||||
type sqlStatsService struct {
|
||||
db db.DB
|
||||
db *sqlstore.ReplStore
|
||||
cfg *setting.Cfg
|
||||
}
|
||||
|
||||
@@ -62,8 +63,8 @@ func notServiceAccount(dialect migrator.Dialect) string {
|
||||
}
|
||||
|
||||
func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetSystemStatsQuery) (result *stats.SystemStats, err error) {
|
||||
dialect := ss.db.GetDialect()
|
||||
err = ss.db.WithDbSession(ctx, func(dbSession *db.Session) error {
|
||||
dialect := ss.db.ReadReplica().GetDialect()
|
||||
err = ss.db.ReadReplica().WithDbSession(ctx, func(dbSession *db.Session) error {
|
||||
sb := &db.SQLBuilder{}
|
||||
sb.Write("SELECT ")
|
||||
sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` WHERE ` + notServiceAccount(dialect) + `) AS users,`)
|
||||
@@ -148,8 +149,8 @@ func (ss *sqlStatsService) roleCounterSQL(ctx context.Context) string {
|
||||
}
|
||||
|
||||
func (ss *sqlStatsService) GetAdminStats(ctx context.Context, query *stats.GetAdminStatsQuery) (result *stats.AdminStats, err error) {
|
||||
err = ss.db.WithDbSession(ctx, func(dbSession *db.Session) error {
|
||||
dialect := ss.db.GetDialect()
|
||||
err = ss.db.ReadReplica().WithDbSession(ctx, func(dbSession *db.Session) error {
|
||||
dialect := ss.db.ReadReplica().GetDialect()
|
||||
now := time.Now()
|
||||
activeEndDate := now.Add(-activeUserTimeLimit)
|
||||
dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit)
|
||||
|
||||
@@ -32,9 +32,9 @@ func TestIntegrationStatsDataAccess(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
db, cfg := db.InitTestDBWithCfg(t)
|
||||
statsService := &sqlStatsService{db: db}
|
||||
populateDB(t, db, cfg)
|
||||
store, cfg := db.InitTestReplDBWithCfg(t)
|
||||
statsService := &sqlStatsService{db: store}
|
||||
populateDB(t, store, cfg)
|
||||
|
||||
t.Run("Get system stats should not results in error", func(t *testing.T) {
|
||||
query := stats.GetSystemStatsQuery{}
|
||||
@@ -49,7 +49,7 @@ func TestIntegrationStatsDataAccess(t *testing.T) {
|
||||
assert.Equal(t, int64(0), result.APIKeys)
|
||||
assert.Equal(t, int64(2), result.Correlations)
|
||||
assert.NotNil(t, result.DatabaseCreatedTime)
|
||||
assert.Equal(t, db.GetDialect().DriverName(), result.DatabaseDriver)
|
||||
assert.Equal(t, store.GetDialect().DriverName(), result.DatabaseDriver)
|
||||
})
|
||||
|
||||
t.Run("Get system user count stats should not results in error", func(t *testing.T) {
|
||||
@@ -157,8 +157,8 @@ func TestIntegration_GetAdminStats(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
db, cfg := db.InitTestDBWithCfg(t)
|
||||
statsService := ProvideService(cfg, db)
|
||||
store, cfg := db.InitTestReplDBWithCfg(t)
|
||||
statsService := ProvideService(cfg, store)
|
||||
|
||||
query := stats.GetAdminStatsQuery{}
|
||||
_, err := statsService.GetAdminStats(context.Background(), &query)
|
||||
|
||||
Reference in New Issue
Block a user