github.com/authzed/spicedb@v1.32.1-0.20240520085336-ebda56537386/internal/datastore/mysql/datastore.go (about) 1 package mysql 2 3 import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "math" 9 "strconv" 10 "sync/atomic" 11 "time" 12 13 mysqlCommon "github.com/authzed/spicedb/internal/datastore/mysql/common" 14 15 sq "github.com/Masterminds/squirrel" 16 "github.com/dlmiddlecote/sqlstats" 17 "github.com/go-sql-driver/mysql" 18 "github.com/google/uuid" 19 "github.com/prometheus/client_golang/prometheus" 20 "go.opentelemetry.io/otel" 21 "go.opentelemetry.io/otel/attribute" 22 "go.opentelemetry.io/otel/trace" 23 "golang.org/x/sync/errgroup" 24 25 datastoreinternal "github.com/authzed/spicedb/internal/datastore" 26 "github.com/authzed/spicedb/internal/datastore/common" 27 "github.com/authzed/spicedb/internal/datastore/mysql/migrations" 28 "github.com/authzed/spicedb/internal/datastore/revisions" 29 log "github.com/authzed/spicedb/internal/logging" 30 "github.com/authzed/spicedb/pkg/datastore" 31 "github.com/authzed/spicedb/pkg/datastore/options" 32 core "github.com/authzed/spicedb/pkg/proto/core/v1" 33 ) 34 35 const ( 36 Engine = "mysql" 37 38 colID = "id" 39 colTimestamp = "timestamp" 40 colNamespace = "namespace" 41 colConfig = "serialized_config" 42 colCreatedTxn = "created_transaction" 43 colDeletedTxn = "deleted_transaction" 44 colObjectID = "object_id" 45 colRelation = "relation" 46 colUsersetNamespace = "userset_namespace" 47 colUsersetObjectID = "userset_object_id" 48 colUsersetRelation = "userset_relation" 49 colName = "name" 50 colCaveatDefinition = "definition" 51 colCaveatName = "caveat_name" 52 colCaveatContext = "caveat_context" 53 54 errUnableToInstantiate = "unable to instantiate datastore: %w" 55 liveDeletedTxnID = uint64(math.MaxInt64) 56 batchDeleteSize = 1000 57 noLastInsertID = 0 58 seedingTimeout = 10 * time.Second 59 60 // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html#error_er_lock_wait_timeout 61 errMysqlLockWaitTimeout = 1205 62 63 // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html#error_er_lock_deadlock 64 errMysqlDeadlock = 1213 65 66 // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html#error_er_dup_entry 67 errMysqlDuplicateEntry = 1062 68 ) 69 70 var ( 71 tracer = otel.Tracer("spicedb/internal/datastore/mysql") 72 73 // Unless specified otherwise, Go's MySQL driver will assume 74 // the server sends datetime in UTC, 75 // see https://github.com/go-sql-driver/mysql#loc. This parameter 76 // is unrelated to the session's timezone. 77 // If the server's global timezone is set to something other than UTC, 78 // the driver will incorrectly convert SELECT NOW(), because 79 // the default session timezone is the one specified by the server. 80 getNow = sb.Select("UTC_TIMESTAMP(6)") 81 82 sb = sq.StatementBuilder.PlaceholderFormat(sq.Question) 83 ) 84 85 func init() { 86 datastore.Engines = append(datastore.Engines, Engine) 87 } 88 89 type sqlFilter interface { 90 ToSql() (string, []interface{}, error) 91 } 92 93 // NewMySQLDatastore creates a new mysql.Datastore value configured with the MySQL instance 94 // specified in through the URI parameter. Supports customization via the various options available 95 // in this package. 96 // 97 // URI: [scheme://][user[:[password]]@]host[:port][/schema][?attribute1=value1&attribute2=value2... 98 // See https://dev.mysql.com/doc/refman/8.0/en/connecting-using-uri-or-key-value-pairs.html 99 func NewMySQLDatastore(ctx context.Context, uri string, options ...Option) (datastore.Datastore, error) { 100 ds, err := newMySQLDatastore(ctx, uri, options...) 101 if err != nil { 102 return nil, err 103 } 104 105 return datastoreinternal.NewSeparatingContextDatastoreProxy(ds), nil 106 } 107 108 func newMySQLDatastore(ctx context.Context, uri string, options ...Option) (*Datastore, error) { 109 config, err := generateConfig(options) 110 if err != nil { 111 return nil, fmt.Errorf(errUnableToInstantiate, err) 112 } 113 114 parsedURI, err := mysql.ParseDSN(uri) 115 if err != nil { 116 return nil, common.RedactAndLogSensitiveConnString(ctx, "NewMySQLDatastore: could not parse connection URI", err, uri) 117 } 118 119 if !parsedURI.ParseTime { 120 return nil, errors.New("error in NewMySQLDatastore: connection URI for MySQL datastore must include `parseTime=true` as a query parameter; see https://spicedb.dev/d/parse-time-mysql for more details") 121 } 122 123 // Setup the credentials provider 124 var credentialsProvider datastore.CredentialsProvider 125 if config.credentialsProviderName != "" { 126 credentialsProvider, err = datastore.NewCredentialsProvider(ctx, config.credentialsProviderName) 127 if err != nil { 128 return nil, err 129 } 130 } 131 132 err = mysqlCommon.MaybeAddCredentialsProviderHook(parsedURI, credentialsProvider) 133 if err != nil { 134 return nil, err 135 } 136 137 // Call NewConnector with the existing parsed configuration to preserve the BeforeConnect added by the CredentialsProvider 138 connector, err := mysql.NewConnector(parsedURI) 139 if err != nil { 140 return nil, common.RedactAndLogSensitiveConnString(ctx, "NewMySQLDatastore: failed to create connector", err, uri) 141 } 142 143 if config.lockWaitTimeoutSeconds != nil { 144 log.Info().Uint8("timeout", *config.lockWaitTimeoutSeconds).Msg("overriding innodb_lock_wait_timeout") 145 connector, err = addSessionVariables(connector, map[string]string{ 146 "innodb_lock_wait_timeout": strconv.FormatUint(uint64(*config.lockWaitTimeoutSeconds), 10), 147 }) 148 if err != nil { 149 return nil, common.RedactAndLogSensitiveConnString(ctx, "NewMySQLDatastore: failed to add session variables to connector", err, uri) 150 } 151 } 152 153 var db *sql.DB 154 if config.enablePrometheusStats { 155 connector, err = instrumentConnector(connector) 156 if err != nil { 157 return nil, common.RedactAndLogSensitiveConnString(ctx, "NewMySQLDatastore: unable to instrument connector", err, uri) 158 } 159 160 db = sql.OpenDB(connector) 161 collector := sqlstats.NewStatsCollector("spicedb", db) 162 if err := prometheus.Register(collector); err != nil { 163 return nil, fmt.Errorf(errUnableToInstantiate, err) 164 } 165 166 if err := common.RegisterGCMetrics(); err != nil { 167 return nil, fmt.Errorf(errUnableToInstantiate, err) 168 } 169 } else { 170 db = sql.OpenDB(connector) 171 } 172 173 db.SetConnMaxLifetime(config.connMaxLifetime) 174 db.SetConnMaxIdleTime(config.connMaxIdleTime) 175 db.SetMaxOpenConns(config.maxOpenConns) 176 db.SetMaxIdleConns(config.maxOpenConns) 177 178 driver := migrations.NewMySQLDriverFromDB(db, config.tablePrefix) 179 queryBuilder := NewQueryBuilder(driver) 180 181 createTxn, _, err := sb.Insert(driver.RelationTupleTransaction()).Values().ToSql() 182 if err != nil { 183 return nil, fmt.Errorf("NewMySQLDatastore: %w", err) 184 } 185 186 // used for seeding the initial relation_tuple_transaction. using INSERT IGNORE on a known 187 // ID value makes this idempotent (i.e. safe to execute concurrently). 188 createBaseTxn := fmt.Sprintf("INSERT IGNORE INTO %s (id, timestamp) VALUES (1, FROM_UNIXTIME(1))", driver.RelationTupleTransaction()) 189 190 gcCtx, cancelGc := context.WithCancel(context.Background()) 191 192 maxRevisionStaleness := time.Duration(float64(config.revisionQuantization.Nanoseconds())* 193 config.maxRevisionStalenessPercent) * time.Nanosecond 194 195 quantizationPeriodNanos := config.revisionQuantization.Nanoseconds() 196 if quantizationPeriodNanos < 1 { 197 quantizationPeriodNanos = 1 198 } 199 200 revisionQuery := fmt.Sprintf( 201 querySelectRevision, 202 colID, 203 driver.RelationTupleTransaction(), 204 colTimestamp, 205 quantizationPeriodNanos, 206 ) 207 208 validTransactionQuery := fmt.Sprintf( 209 queryValidTransaction, 210 colID, 211 driver.RelationTupleTransaction(), 212 colTimestamp, 213 -1*config.gcWindow.Seconds(), 214 ) 215 216 store := &Datastore{ 217 db: db, 218 driver: driver, 219 url: uri, 220 revisionQuantization: config.revisionQuantization, 221 gcWindow: config.gcWindow, 222 gcInterval: config.gcInterval, 223 gcTimeout: config.gcMaxOperationTime, 224 gcCtx: gcCtx, 225 cancelGc: cancelGc, 226 watchBufferLength: config.watchBufferLength, 227 watchBufferWriteTimeout: config.watchBufferWriteTimeout, 228 optimizedRevisionQuery: revisionQuery, 229 validTransactionQuery: validTransactionQuery, 230 createTxn: createTxn, 231 createBaseTxn: createBaseTxn, 232 QueryBuilder: queryBuilder, 233 readTxOptions: &sql.TxOptions{Isolation: sql.LevelSerializable, ReadOnly: true}, 234 maxRetries: config.maxRetries, 235 analyzeBeforeStats: config.analyzeBeforeStats, 236 CachedOptimizedRevisions: revisions.NewCachedOptimizedRevisions( 237 maxRevisionStaleness, 238 ), 239 CommonDecoder: revisions.CommonDecoder{ 240 Kind: revisions.TransactionID, 241 }, 242 } 243 244 store.SetOptimizedRevisionFunc(store.optimizedRevisionFunc) 245 246 ctx, cancel := context.WithTimeout(context.Background(), seedingTimeout) 247 defer cancel() 248 err = store.seedDatabase(ctx) 249 if err != nil { 250 return nil, err 251 } 252 253 // Start a goroutine for garbage collection. 254 if store.gcInterval > 0*time.Minute && config.gcEnabled { 255 store.gcGroup, store.gcCtx = errgroup.WithContext(store.gcCtx) 256 store.gcGroup.Go(func() error { 257 return common.StartGarbageCollector( 258 store.gcCtx, 259 store, 260 store.gcInterval, 261 store.gcWindow, 262 store.gcTimeout, 263 ) 264 }) 265 } else { 266 log.Warn().Msg("datastore background garbage collection disabled") 267 } 268 269 return store, nil 270 } 271 272 func (mds *Datastore) SnapshotReader(rev datastore.Revision) datastore.Reader { 273 createTxFunc := func(ctx context.Context) (*sql.Tx, txCleanupFunc, error) { 274 tx, err := mds.db.BeginTx(ctx, mds.readTxOptions) 275 if err != nil { 276 return nil, nil, err 277 } 278 279 return tx, tx.Rollback, nil 280 } 281 282 executor := common.QueryExecutor{ 283 Executor: newMySQLExecutor(mds.db), 284 } 285 286 return &mysqlReader{ 287 mds.QueryBuilder, 288 createTxFunc, 289 executor, 290 buildLivingObjectFilterForRevision(rev), 291 } 292 } 293 294 func noCleanup() error { return nil } 295 296 // ReadWriteTx starts a read/write transaction, which will be committed if no error is 297 // returned and rolled back if an error is returned. 298 func (mds *Datastore) ReadWriteTx( 299 ctx context.Context, 300 fn datastore.TxUserFunc, 301 opts ...options.RWTOptionsOption, 302 ) (datastore.Revision, error) { 303 config := options.NewRWTOptionsWithOptions(opts...) 304 305 var err error 306 for i := uint8(0); i <= mds.maxRetries; i++ { 307 var newTxnID uint64 308 if err = migrations.BeginTxFunc(ctx, mds.db, &sql.TxOptions{Isolation: sql.LevelSerializable}, func(tx *sql.Tx) error { 309 newTxnID, err = mds.createNewTransaction(ctx, tx) 310 if err != nil { 311 return fmt.Errorf("unable to create new txn ID: %w", err) 312 } 313 314 longLivedTx := func(context.Context) (*sql.Tx, txCleanupFunc, error) { 315 return tx, noCleanup, nil 316 } 317 318 executor := common.QueryExecutor{ 319 Executor: newMySQLExecutor(tx), 320 } 321 322 rwt := &mysqlReadWriteTXN{ 323 &mysqlReader{ 324 mds.QueryBuilder, 325 longLivedTx, 326 executor, 327 currentlyLivingObjects, 328 }, 329 mds.driver.RelationTuple(), 330 tx, 331 newTxnID, 332 } 333 334 return fn(ctx, rwt) 335 }); err != nil { 336 if !config.DisableRetries && isErrorRetryable(err) { 337 continue 338 } 339 340 return datastore.NoRevision, wrapError(err) 341 } 342 343 return revisions.NewForTransactionID(newTxnID), nil 344 } 345 if !config.DisableRetries { 346 err = fmt.Errorf("max retries exceeded: %w", err) 347 } 348 349 return datastore.NoRevision, wrapError(err) 350 } 351 352 // wrapError maps any mysql internal error into a SpiceDB typed error or an error 353 // that implements GRPCStatus(). 354 func wrapError(err error) error { 355 if cerr := convertToWriteConstraintError(err); cerr != nil { 356 return cerr 357 } 358 359 return err 360 } 361 362 func isErrorRetryable(err error) bool { 363 var mysqlerr *mysql.MySQLError 364 if !errors.As(err, &mysqlerr) { 365 log.Debug().Err(err).Msg("couldn't determine a sqlstate error code") 366 return false 367 } 368 369 return mysqlerr.Number == errMysqlDeadlock || mysqlerr.Number == errMysqlLockWaitTimeout 370 } 371 372 type querier interface { 373 QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) 374 } 375 376 func newMySQLExecutor(tx querier) common.ExecuteQueryFunc { 377 // This implementation does not create a transaction because it's redundant for single statements, and it avoids 378 // the network overhead and reduce contention on the connection pool. From MySQL docs: 379 // 380 // https://dev.mysql.com/doc/refman/5.7/en/commit.html 381 // "By default, MySQL runs with autocommit mode enabled. This means that, when not otherwise inside a transaction, 382 // each statement is atomic, as if it were surrounded by START TRANSACTION and COMMIT." 383 // 384 // https://dev.mysql.com/doc/refman/5.7/en/innodb-consistent-read.html 385 // "Consistent read is the default mode in which InnoDB processes SELECT statements in READ COMMITTED and 386 // REPEATABLE READ isolation levels. A consistent read does not set any locks on the tables it accesses, 387 // and therefore other sessions are free to modify those tables at the same time a consistent read 388 // is being performed on the table." 389 // 390 // Prepared statements are also not used given they perform poorly on environments where connections have 391 // short lifetime (e.g. to gracefully handle load-balancer connection drain) 392 return func(ctx context.Context, sqlQuery string, args []interface{}) ([]*core.RelationTuple, error) { 393 span := trace.SpanFromContext(ctx) 394 395 rows, err := tx.QueryContext(ctx, sqlQuery, args...) 396 if err != nil { 397 return nil, fmt.Errorf(errUnableToQueryTuples, err) 398 } 399 defer common.LogOnError(ctx, rows.Close) 400 401 span.AddEvent("Query issued to database") 402 403 var tuples []*core.RelationTuple 404 for rows.Next() { 405 nextTuple := &core.RelationTuple{ 406 ResourceAndRelation: &core.ObjectAndRelation{}, 407 Subject: &core.ObjectAndRelation{}, 408 } 409 410 var caveatName string 411 var caveatContext caveatContextWrapper 412 err := rows.Scan( 413 &nextTuple.ResourceAndRelation.Namespace, 414 &nextTuple.ResourceAndRelation.ObjectId, 415 &nextTuple.ResourceAndRelation.Relation, 416 &nextTuple.Subject.Namespace, 417 &nextTuple.Subject.ObjectId, 418 &nextTuple.Subject.Relation, 419 &caveatName, 420 &caveatContext, 421 ) 422 if err != nil { 423 return nil, fmt.Errorf(errUnableToQueryTuples, err) 424 } 425 426 nextTuple.Caveat, err = common.ContextualizedCaveatFrom(caveatName, caveatContext) 427 if err != nil { 428 return nil, fmt.Errorf(errUnableToQueryTuples, err) 429 } 430 431 tuples = append(tuples, nextTuple) 432 } 433 if err := rows.Err(); err != nil { 434 return nil, fmt.Errorf(errUnableToQueryTuples, err) 435 } 436 span.AddEvent("Tuples loaded", trace.WithAttributes(attribute.Int("tupleCount", len(tuples)))) 437 return tuples, nil 438 } 439 } 440 441 // Datastore is a MySQL-based implementation of the datastore.Datastore interface 442 type Datastore struct { 443 db *sql.DB 444 driver *migrations.MySQLDriver 445 readTxOptions *sql.TxOptions 446 url string 447 analyzeBeforeStats bool 448 449 revisionQuantization time.Duration 450 gcWindow time.Duration 451 gcInterval time.Duration 452 gcTimeout time.Duration 453 watchBufferLength uint16 454 watchBufferWriteTimeout time.Duration 455 maxRetries uint8 456 457 optimizedRevisionQuery string 458 validTransactionQuery string 459 460 gcGroup *errgroup.Group 461 gcCtx context.Context 462 cancelGc context.CancelFunc 463 gcHasRun atomic.Bool 464 465 createTxn string 466 createBaseTxn string 467 468 *QueryBuilder 469 *revisions.CachedOptimizedRevisions 470 revisions.CommonDecoder 471 } 472 473 // Close closes the data store. 474 func (mds *Datastore) Close() error { 475 mds.cancelGc() 476 if mds.gcGroup != nil { 477 if err := mds.gcGroup.Wait(); err != nil && !errors.Is(err, context.Canceled) { 478 log.Error().Err(err).Msg("error waiting for garbage collector to shutdown") 479 } 480 } 481 return mds.db.Close() 482 } 483 484 // ReadyState returns whether the datastore is ready to accept data. Datastores that require 485 // database schema creation will return false until the migrations have been run to create 486 // the necessary tables. 487 // 488 // fundamentally different from PSQL implementation: 489 // - checking if the current migration version is compatible is implemented with IsHeadCompatible 490 // - Database seeding is handled here, so that we can decouple schema migration from data migration 491 // and support skeema-based migrations. 492 func (mds *Datastore) ReadyState(ctx context.Context) (datastore.ReadyState, error) { 493 if err := mds.db.PingContext(ctx); err != nil { 494 return datastore.ReadyState{}, err 495 } 496 497 currentMigrationRevision, err := mds.driver.Version(ctx) 498 if err != nil { 499 return datastore.ReadyState{}, err 500 } 501 502 compatible, err := migrations.Manager.IsHeadCompatible(currentMigrationRevision) 503 if err != nil { 504 return datastore.ReadyState{}, err 505 } 506 if !compatible { 507 return datastore.ReadyState{ 508 Message: "datastore is not at a currently compatible revision", 509 IsReady: false, 510 }, nil 511 } 512 513 isSeeded, err := mds.isSeeded(ctx) 514 if err != nil { 515 return datastore.ReadyState{}, err 516 } 517 if !isSeeded { 518 return datastore.ReadyState{ 519 Message: "datastore is not properly seeded", 520 IsReady: false, 521 }, nil 522 } 523 524 return datastore.ReadyState{ 525 Message: "", 526 IsReady: true, 527 }, nil 528 } 529 530 func (mds *Datastore) Features(_ context.Context) (*datastore.Features, error) { 531 return &datastore.Features{Watch: datastore.Feature{Enabled: true}}, nil 532 } 533 534 // isSeeded determines if the backing database has been seeded 535 func (mds *Datastore) isSeeded(ctx context.Context) (bool, error) { 536 headRevision, err := mds.HeadRevision(ctx) 537 if err != nil { 538 return false, err 539 } 540 if headRevision == datastore.NoRevision { 541 return false, nil 542 } 543 544 _, err = mds.getUniqueID(ctx) 545 if err != nil { 546 return false, nil 547 } 548 549 return true, nil 550 } 551 552 // seedDatabase initializes the first transaction revision if necessary. 553 func (mds *Datastore) seedDatabase(ctx context.Context) error { 554 ctx, span := tracer.Start(ctx, "seedDatabase") 555 defer span.End() 556 557 isSeeded, err := mds.isSeeded(ctx) 558 if err != nil { 559 return err 560 } 561 if isSeeded { 562 return nil 563 } 564 565 // this seeds the transaction table with the first transaction, in a way that is idempotent 566 return migrations.BeginTxFunc(ctx, mds.db, nil, func(tx *sql.Tx) error { 567 // idempotent INSERT IGNORE transaction id=1. safe to be executed concurrently. 568 result, err := tx.ExecContext(ctx, mds.createBaseTxn) 569 if err != nil { 570 return fmt.Errorf("seedDatabase: %w", err) 571 } 572 573 lastInsertID, err := result.LastInsertId() 574 if err != nil { 575 return fmt.Errorf("seedDatabase: failed to get last inserted id: %w", err) 576 } 577 578 if lastInsertID != noLastInsertID { 579 // If there was no error and `lastInsertID` is 0, the insert was ignored. This indicates the transaction 580 // was already seeded by another processes (i.e. race condition). 581 log.Ctx(ctx).Info().Int64("headRevision", lastInsertID).Msg("seeded base datastore headRevision") 582 } 583 584 uuidSQL, uuidArgs, err := sb. 585 Insert(mds.driver.Metadata()). 586 Options("IGNORE"). 587 Columns(metadataIDColumn, metadataUniqueIDColumn). 588 Values(0, uuid.NewString()). 589 ToSql() 590 if err != nil { 591 return fmt.Errorf("seedDatabase: failed to prepare SQL: %w", err) 592 } 593 594 insertUniqueResult, err := tx.ExecContext(ctx, uuidSQL, uuidArgs...) 595 if err != nil { 596 return fmt.Errorf("seedDatabase: failed to insert unique ID: %w", err) 597 } 598 599 lastInsertID, err = insertUniqueResult.LastInsertId() 600 if err != nil { 601 return fmt.Errorf("seedDatabase: failed to get last inserted unique id: %w", err) 602 } 603 604 if lastInsertID != noLastInsertID { 605 // If there was no error and `lastInsertID` is 0, the insert was ignored. This indicates the transaction 606 // was already seeded by another processes (i.e. race condition). 607 log.Ctx(ctx).Info().Int64("headRevision", lastInsertID).Msg("seeded base datastore unique ID") 608 } 609 610 return nil 611 }) 612 } 613 614 func buildLivingObjectFilterForRevision(revision datastore.Revision) queryFilterer { 615 return func(original sq.SelectBuilder) sq.SelectBuilder { 616 return original.Where(sq.LtOrEq{colCreatedTxn: revision.(revisions.TransactionIDRevision).TransactionID()}). 617 Where(sq.Or{ 618 sq.Eq{colDeletedTxn: liveDeletedTxnID}, 619 sq.Gt{colDeletedTxn: revision}, 620 }) 621 } 622 } 623 624 func currentlyLivingObjects(original sq.SelectBuilder) sq.SelectBuilder { 625 return original.Where(sq.Eq{colDeletedTxn: liveDeletedTxnID}) 626 }