Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
256 lines
8.5 KiB
Go
256 lines
8.5 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package popx
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cockroachdb/cockroach-go/v2/crdb"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
"github.com/ory/x/prometheusx"
|
|
|
|
"github.com/ory/pop/v6"
|
|
"github.com/ory/x/sqlcon"
|
|
)
|
|
|
|
type transactionContextKey int
|
|
|
|
const transactionKey transactionContextKey = 0
|
|
|
|
func WithTransaction(ctx context.Context, tx *pop.Connection) context.Context {
|
|
return context.WithValue(ctx, transactionKey, tx)
|
|
}
|
|
|
|
func InTransaction(ctx context.Context) bool {
|
|
return ctx.Value(transactionKey) != nil
|
|
}
|
|
|
|
func Transaction(ctx context.Context, connection *pop.Connection, callback func(context.Context, *pop.Connection) error) error {
|
|
return TransactionWithOptions(ctx, connection, nil, callback)
|
|
}
|
|
|
|
// TransactionWithOptions opens the transaction with given sql.TxOptions, allowing isolation level to be set.
|
|
func TransactionWithOptions(ctx context.Context, connection *pop.Connection, opts *sql.TxOptions, callback func(context.Context, *pop.Connection) error) error {
|
|
c := ctx.Value(transactionKey)
|
|
if c != nil {
|
|
if conn, ok := c.(*pop.Connection); ok {
|
|
return errors.WithStack(callback(ctx, conn.WithContext(ctx)))
|
|
}
|
|
}
|
|
|
|
conn := connection.WithContext(ctx)
|
|
|
|
switch conn.Dialect.Name() {
|
|
case "cockroach":
|
|
return conn.Dialect.Lock(func() error {
|
|
tx, err := conn.NewTransactionContextOptions(ctx, opts)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
attempt := 0
|
|
// A transaction's retries are recorded in a single increment so
|
|
// that the trace exemplar's value equals the transaction's retry
|
|
// count, which Grafana renders as the exemplar marker's position.
|
|
// Deferred so that retries are recorded even when the callback
|
|
// panics.
|
|
defer func() {
|
|
if retries := attempt - 1; retries > 0 {
|
|
recordRetries(ctx, retries)
|
|
}
|
|
}()
|
|
return errors.WithStack(crdb.ExecuteInTx(ctx, sqlxTxAdapter{tx.TX.Tx}, func() error {
|
|
attempt++
|
|
return errors.WithStack(callback(WithTransaction(ctx, tx), tx))
|
|
}))
|
|
})
|
|
case "postgres", "mysql", "yugabyte":
|
|
// YugabyteDB speaks the Postgres wire protocol and, like Postgres, may
|
|
// surface serialization failures (SQLSTATE 40001) that the retry loop
|
|
// below handles through sqlcon.ErrConcurrentUpdate.
|
|
//
|
|
// Mirrors pop's Connection#Transaction with opts passed to NewTransactionContextOptions.
|
|
// https://github.com/ory/pop/blob/89126558d36911217a1cc70172ba94ee32692cad/connection.go#L148
|
|
return conn.Dialect.Lock(func() error {
|
|
var err error
|
|
for range MaxTransactionRetries {
|
|
err = func() error {
|
|
cn, err := conn.NewTransactionContextOptions(ctx, opts)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
defer func() {
|
|
if ex := recover(); ex != nil {
|
|
_ = cn.TX.Rollback()
|
|
panic(ex)
|
|
}
|
|
}()
|
|
err = callback(WithTransaction(ctx, cn), cn)
|
|
var dberr error
|
|
if err != nil {
|
|
dberr = cn.TX.Rollback()
|
|
if errors.Is(dberr, sql.ErrTxDone) {
|
|
// Already rolled back by the database (e.g. context cancelled).
|
|
return err
|
|
}
|
|
if dberr != nil && dberr.Error() == "conn closed" {
|
|
// pgx closes the connection on context cancellation before
|
|
// database/sql gets a chance to roll back.
|
|
// See https://github.com/jackc/pgx/issues/2551
|
|
return err
|
|
}
|
|
} else {
|
|
dberr = cn.TX.Commit()
|
|
}
|
|
if dberr != nil {
|
|
return fmt.Errorf("database error on committing or rolling back transaction: %w", dberr)
|
|
}
|
|
return err
|
|
}()
|
|
if err == nil || !errors.Is(sqlcon.HandleError(err), sqlcon.ErrConcurrentUpdate()) {
|
|
return err
|
|
}
|
|
}
|
|
return err
|
|
})
|
|
}
|
|
|
|
// SQLite and unknown dialects: opts are ignored; use pop's default
|
|
// transaction path with concurrent-update retry handling.
|
|
var err error
|
|
for attempt := range MaxTransactionRetries {
|
|
err = conn.Transaction(func(tx *pop.Connection) error {
|
|
return callback(WithTransaction(ctx, tx), tx)
|
|
})
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(sqlcon.HandleError(err), sqlcon.ErrConcurrentUpdate()) {
|
|
return err
|
|
}
|
|
// Back off with jitter before retrying. SQLite in WAL mode fails a
|
|
// deferred transaction's read-to-write upgrade with
|
|
// SQLITE_BUSY_SNAPSHOT immediately (busy_timeout cannot help a stale
|
|
// snapshot), so an immediate retry under write contention can livelock
|
|
// through the whole retry budget.
|
|
select {
|
|
case <-ctx.Done():
|
|
return err
|
|
case <-time.After(time.Duration(attempt+1) * time.Duration(1+rand.IntN(3)) * time.Millisecond):
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func GetConnection(ctx context.Context, connection *pop.Connection) *pop.Connection {
|
|
c := ctx.Value(transactionKey)
|
|
if c != nil {
|
|
if conn, ok := c.(*pop.Connection); ok {
|
|
return conn.WithContext(ctx)
|
|
}
|
|
}
|
|
return connection.WithContext(ctx)
|
|
}
|
|
|
|
type sqlxTxAdapter struct {
|
|
*sqlx.Tx
|
|
}
|
|
|
|
var _ crdb.Tx = sqlxTxAdapter{}
|
|
|
|
func (s sqlxTxAdapter) Exec(ctx context.Context, query string, args ...any) error {
|
|
_, err := s.Tx.ExecContext(ctx, query, args...)
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
func (s sqlxTxAdapter) Commit(ctx context.Context) error {
|
|
return errors.WithStack(s.Tx.Commit())
|
|
}
|
|
|
|
func (s sqlxTxAdapter) Rollback(ctx context.Context) error {
|
|
return errors.WithStack(s.Tx.Rollback())
|
|
}
|
|
|
|
// MaxTransactionRetries is the number of times a transaction is retried on
|
|
// a concurrent-update conflict before the error is returned to the caller.
|
|
const MaxTransactionRetries = 10
|
|
|
|
var (
|
|
transactionRetries = prometheus.NewCounterVec(prometheus.CounterOpts{
|
|
Name: "ory_x_popx_cockroach_transaction_retries_total",
|
|
Help: "Counts the number of automatic CockroachDB transaction retries",
|
|
}, []string{"caller"})
|
|
TransactionRetries prometheus.Collector = transactionRetries
|
|
_ = transactionRetries.WithLabelValues(unknownCaller) // make sure the metric is always present
|
|
unknownCaller = "unknown"
|
|
)
|
|
|
|
// recordRetries counts a transaction's automatic CockroachDB restarts. When
|
|
// the surrounding request is traced, it also attaches the trace to the span
|
|
// (as an event) and to the counter (as an exemplar whose value is the retry
|
|
// count) so that retry spikes can be linked to concrete requests.
|
|
func recordRetries(ctx context.Context, retries int) {
|
|
c := caller()
|
|
counter := transactionRetries.WithLabelValues(c)
|
|
span := trace.SpanFromContext(ctx)
|
|
span.AddEvent("db.transaction.retry", trace.WithAttributes(
|
|
attribute.String("caller", c),
|
|
attribute.Int("retries", retries),
|
|
))
|
|
prometheusx.AddWithExemplar(ctx, counter, float64(retries))
|
|
}
|
|
|
|
// caller returns the function that opened the transaction: the innermost
|
|
// frame that is neither transaction plumbing (this package, pop, and
|
|
// crdb.ExecuteInTx) nor one of the thin wrapper methods that services layer
|
|
// over popx, so that a retry is attributed to the business operation rather
|
|
// than to a generic Transaction wrapper.
|
|
func caller() string {
|
|
pc := make([]uintptr, 32)
|
|
n := runtime.Callers(2, pc)
|
|
frames := runtime.CallersFrames(pc[:n])
|
|
for {
|
|
frame, more := frames.Next()
|
|
if frame.Function != "" && !skipCallerFrame(frame.Function) {
|
|
return frame.Function
|
|
}
|
|
if !more {
|
|
return unknownCaller
|
|
}
|
|
}
|
|
}
|
|
|
|
// skipCallerFrame reports whether a stack frame identifies transaction
|
|
// plumbing rather than the transaction's origin: this package and the
|
|
// libraries under it, and the thin wrapper methods services layer over popx,
|
|
// e.g. hydra's BasePersister.Transaction and RegistrySQL.Transaction
|
|
// (fosite's Transactional interface), keto's WithLatestSnapshot, and
|
|
// backoffice's WithFollowerReads.
|
|
func skipCallerFrame(fn string) bool {
|
|
return strings.HasPrefix(fn, "github.com/ory/x/popx.") ||
|
|
strings.HasPrefix(fn, "github.com/ory/pop/") ||
|
|
strings.HasPrefix(fn, "github.com/cockroachdb/cockroach-go/v2/crdb.ExecuteInTx") ||
|
|
// Deferred recording runs with runtime frames (e.g. runtime.gopanic)
|
|
// on the stack when the transaction callback panicked.
|
|
strings.HasPrefix(fn, "runtime.") ||
|
|
// Wrapper matching is deliberately coarse: a frame that merely
|
|
// mentions one of these names is skipped and the retry is attributed
|
|
// one frame further up, which is still a useful label. "Transaction"
|
|
// also covers TransactionWithOptions and method-value "-fm" symbols.
|
|
strings.Contains(fn, "Transaction") ||
|
|
strings.Contains(fn, "WithLatestSnapshot") ||
|
|
strings.Contains(fn, "WithFollowerReads")
|
|
}
|