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  }