github.com/authzed/spicedb@v1.32.1-0.20240520085336-ebda56537386/internal/datastore/mysql/revisions.go (about)

     1  package mysql
     2  
     3  import (
     4  	"context"
     5  	"database/sql"
     6  	"errors"
     7  	"fmt"
     8  	"time"
     9  
    10  	"github.com/authzed/spicedb/internal/datastore/revisions"
    11  	"github.com/authzed/spicedb/pkg/datastore"
    12  )
    13  
    14  var ParseRevisionString = revisions.RevisionParser(revisions.TransactionID)
    15  
    16  const (
    17  	errRevision      = "unable to find revision: %w"
    18  	errCheckRevision = "unable to check revision: %w"
    19  
    20  	// querySelectRevision will round the database's timestamp down to the nearest
    21  	// quantization period, and then find the first transaction after that. If there
    22  	// are no transactions newer than the quantization period, it just picks the latest
    23  	// transaction. It will also return the amount of nanoseconds until the next
    24  	// optimized revision would be selected server-side, for use with caching.
    25  	//
    26  	//   %[1] Name of id column
    27  	//   %[2] Relationship tuple transaction table
    28  	//   %[3] Name of timestamp column
    29  	//   %[4] Quantization period (in nanoseconds)
    30  	querySelectRevision = `SELECT COALESCE((
    31  			SELECT MIN(%[1]s)
    32  			FROM   %[2]s
    33  			WHERE  %[3]s >= FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(UTC_TIMESTAMP(6)) * 1000000000 / %[4]d) * %[4]d / 1000000000)
    34  		), (
    35  			SELECT MAX(%[1]s)
    36  			FROM   %[2]s
    37  		)) as revision,
    38  		%[4]d - CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(6)) * 1000000000 AS UNSIGNED INTEGER) %% %[4]d as validForNanos;`
    39  
    40  	// queryValidTransaction will return a single row with two values, one boolean
    41  	// for whether the specified transaction ID is newer than the garbage collection
    42  	// window, and one boolean for whether the transaction ID represents a transaction
    43  	// that will occur in the future.
    44  	// It treats the current head transaction as always valid even if it falls
    45  	// outside the GC window.
    46  	//
    47  	//   %[1] Name of id column
    48  	//   %[2] Relationship tuple transaction table
    49  	//   %[3] Name of timestamp column
    50  	//   %[4] Inverse of GC window (in seconds)
    51  	queryValidTransaction = `
    52  		SELECT ? >= COALESCE((
    53  			SELECT MIN(%[1]s)
    54  			FROM   %[2]s
    55  			WHERE  %[3]s >= TIMESTAMPADD(SECOND, %.6[4]f, UTC_TIMESTAMP(6))
    56  		),( 
    57  		    SELECT MAX(%[1]s)
    58  		    FROM %[2]s
    59  		    LIMIT 1
    60  		)) as fresh, ? > (
    61  			SELECT MAX(%[1]s)
    62  			FROM   %[2]s
    63  		) as unknown;`
    64  )
    65  
    66  func (mds *Datastore) optimizedRevisionFunc(ctx context.Context) (datastore.Revision, time.Duration, error) {
    67  	var rev uint64
    68  	var validForNanos time.Duration
    69  	if err := mds.db.QueryRowContext(ctx, mds.optimizedRevisionQuery).
    70  		Scan(&rev, &validForNanos); err != nil {
    71  		return datastore.NoRevision, 0, fmt.Errorf(errRevision, err)
    72  	}
    73  	return revisions.NewForTransactionID(rev), validForNanos, nil
    74  }
    75  
    76  func (mds *Datastore) HeadRevision(ctx context.Context) (datastore.Revision, error) {
    77  	// implementation deviates slightly from PSQL implementation in order to support
    78  	// database seeding in runtime, instead of through migrate command
    79  	revision, err := mds.loadRevision(ctx)
    80  	if err != nil {
    81  		return datastore.NoRevision, err
    82  	}
    83  	if revision == 0 {
    84  		return datastore.NoRevision, nil
    85  	}
    86  
    87  	return revisions.NewForTransactionID(revision), nil
    88  }
    89  
    90  func (mds *Datastore) CheckRevision(ctx context.Context, revision datastore.Revision) error {
    91  	if revision == datastore.NoRevision {
    92  		return datastore.NewInvalidRevisionErr(revision, datastore.CouldNotDetermineRevision)
    93  	}
    94  
    95  	rev, ok := revision.(revisions.TransactionIDRevision)
    96  	if !ok {
    97  		return fmt.Errorf("expected transaction revision, got %T", revision)
    98  	}
    99  
   100  	revisionTx := rev.TransactionID()
   101  	freshEnough, unknown, err := mds.checkValidTransaction(ctx, revisionTx)
   102  	if err != nil {
   103  		return fmt.Errorf(errCheckRevision, err)
   104  	}
   105  
   106  	if !freshEnough {
   107  		return datastore.NewInvalidRevisionErr(revision, datastore.RevisionStale)
   108  	}
   109  	if unknown {
   110  		return datastore.NewInvalidRevisionErr(revision, datastore.CouldNotDetermineRevision)
   111  	}
   112  
   113  	return nil
   114  }
   115  
   116  func (mds *Datastore) loadRevision(ctx context.Context) (uint64, error) {
   117  	// slightly changed to support no revisions at all, needed for runtime seeding of first transaction
   118  	ctx, span := tracer.Start(ctx, "loadRevision")
   119  	defer span.End()
   120  
   121  	query, args, err := mds.GetLastRevision.ToSql()
   122  	if err != nil {
   123  		return 0, fmt.Errorf(errRevision, err)
   124  	}
   125  
   126  	var revision *uint64
   127  	err = mds.db.QueryRowContext(ctx, query, args...).Scan(&revision)
   128  	if err != nil {
   129  		if errors.Is(err, sql.ErrNoRows) {
   130  			return 0, nil
   131  		}
   132  		return 0, fmt.Errorf(errRevision, err)
   133  	}
   134  
   135  	if revision == nil {
   136  		return 0, nil
   137  	}
   138  
   139  	return *revision, nil
   140  }
   141  
   142  func (mds *Datastore) checkValidTransaction(ctx context.Context, revisionTx uint64) (bool, bool, error) {
   143  	ctx, span := tracer.Start(ctx, "checkValidTransaction")
   144  	defer span.End()
   145  
   146  	var freshEnough, unknown sql.NullBool
   147  
   148  	err := mds.db.QueryRowContext(ctx, mds.validTransactionQuery, revisionTx, revisionTx).
   149  		Scan(&freshEnough, &unknown)
   150  	if err != nil {
   151  		return false, false, fmt.Errorf(errCheckRevision, err)
   152  	}
   153  
   154  	span.AddEvent("DB returned validTransaction checks")
   155  
   156  	return freshEnough.Bool, unknown.Bool, nil
   157  }
   158  
   159  func (mds *Datastore) createNewTransaction(ctx context.Context, tx *sql.Tx) (newTxnID uint64, err error) {
   160  	ctx, span := tracer.Start(ctx, "createNewTransaction")
   161  	defer span.End()
   162  
   163  	createQuery := mds.createTxn
   164  	if err != nil {
   165  		return 0, fmt.Errorf("createNewTransaction: %w", err)
   166  	}
   167  
   168  	result, err := tx.ExecContext(ctx, createQuery)
   169  	if err != nil {
   170  		return 0, fmt.Errorf("createNewTransaction: %w", err)
   171  	}
   172  
   173  	lastInsertID, err := result.LastInsertId()
   174  	if err != nil {
   175  		return 0, fmt.Errorf("createNewTransaction: failed to get last inserted id: %w", err)
   176  	}
   177  
   178  	return uint64(lastInsertID), nil
   179  }