db

package
v1.1.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 23, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

README

Transaction Manager

The TransactionManager provides a generic, type-safe way to execute database operations within a transaction. It ensures that all repository operations within a callback use the same transaction-bound query executor.

Core Concepts

TxQuerier Interface

Any sqlc-generated Queries struct must implement TxQuerier to be compatible with the transaction manager:

type TxQuerier[Q any] interface {
    WithTx(tx *sql.Tx) Q
}

sqlc generates this method automatically when configured with emit_methods_with_db_argument: false.

TransactionManager Interface
type TransactionManager[Q TxQuerier[Q], F any] interface {
    WithTx(ctx context.Context, fn func(ctx context.Context, f F) *apierror.APIError) *apierror.APIError
    WithTxSavepoint(ctx context.Context, fn func(ctx context.Context, f F, sp SavepointRunner) *apierror.APIError) *apierror.APIError
}
  • Q is the sqlc Queries type
  • F is the factory type (typically RepoFactory) that gets created with transaction-bound queries
Deadlock Retry

A transaction the database rolls back as a deadlock victim is re-run (up to 3 attempts, with a jittered millisecond backoff). The callback therefore runs more than once, and only its database writes are undone by the rollback — so a callback may write to the database and nothing else.

make tx-audit enforces this across the codebase. It reports four escaping effects inside a WithTx / withTx / WithTxSavepoint closure:

  • appending to a variable declared outside the callback
  • calling a client that leaves the database (Stripe, S3, RabbitMQ, another service's gRPC client)
  • starting a goroutine
  • sending on a channel

This is why domain events go to the outbox rather than being published inline.

Setup

1. Define a RepoFactory

Create a factory that produces repositories from a queries instance:

type repoFactoryImpl struct {
    queries *sqlc.Queries
}

func NewRepoFactory(queries *sqlc.Queries) domain.RepoFactory {
    return &repoFactoryImpl{queries: queries}
}

func (r *repoFactoryImpl) NewUserRepo() domain.UserRepo {
    return NewUserRepo(r.queries)
}

func (r *repoFactoryImpl) NewOrderRepo() domain.OrderRepo {
    return NewOrderRepo(r.queries)
}
2. Create the TransactionManager
type TransactionManager = db.TransactionManager[*sqlc.Queries, domain.RepoFactory]

func NewTransactionManager(sqlDB *sql.DB, queries *sqlc.Queries) TransactionManager {
    return db.NewTransactionManager(sqlDB, queries, repository.NewRepoFactory)
}
3. Inject into Your Service
type serviceSvcImpl struct {
    repos           domain.RepoFactory
    mediatorFactory domain.MediatorFactory
    txManager       TransactionManager
}

Usage Pattern

Service Layer

The service layer wraps operations in transactions using a withTx helper:

func (s *serviceSvcImpl) withTx(ctx context.Context, fn func(context.Context, *serviceSvcImpl) *apierror.APIError) *apierror.APIError {
    return s.txManager.WithTx(ctx, func(txCtx context.Context, f domain.RepoFactory) *apierror.APIError {
        // Create a NEW service instance with transaction-bound repos
        txSvc := &serviceSvcImpl{
            repos:           f,  // Transaction-bound factory
            mediatorFactory: s.mediatorFactory,
            txManager:       s.txManager,
        }
        return fn(txCtx, txSvc)
    })
}

Then use it in service methods:

func (s *serviceSvcImpl) CreateOrder(ctx context.Context, input CreateOrderInput) (*Order, *apierror.APIError) {
    var result *Order

    apiErr := s.withTx(ctx, func(txCtx context.Context, svc *serviceSvcImpl) *apierror.APIError {
        // All operations here use the same transaction
        order, err := svc.mediators().Order.Create(txCtx, input)
        if err != nil {
            return err
        }

        err = svc.mediators().Inventory.Reserve(txCtx, order.Items)
        if err != nil {
            return err // Transaction will rollback
        }

        result = order
        return nil
    })

    return result, apiErr
}
Mediator Layer

Mediators receive their RepoFactory at construction time. When built within a transaction context, they automatically use transaction-bound repositories:

type orderMedImpl struct {
    repos domain.RepoFactory  // Injected at build time
}

func (m *orderMedImpl) Create(ctx context.Context, input CreateOrderInput) (*Order, *apierror.APIError) {
    orderRepo := m.repos.NewOrderRepo()      // Uses tx-bound queries
    itemRepo := m.repos.NewOrderItemRepo()   // Same transaction

    order, err := orderRepo.Create(ctx, input)
    if err != nil {
        return nil, err
    }

    for _, item := range input.Items {
        _, err := itemRepo.Create(ctx, order.ID, item)
        if err != nil {
            return nil, err
        }
    }

    return order, nil
}
Mediator Factory

The mediator factory builds mediators with a specific RepoFactory:

func (f *mediatorFactoryImpl) Build(repoFactory domain.RepoFactory) domain.Mediators {
    return domain.Mediators{
        Order:     NewOrderMed(OrderMedConfig{Repos: repoFactory}),
        Inventory: NewInventoryMed(InventoryMedConfig{Repos: repoFactory}),
    }
}

The service calls mediators() which builds with the current repos:

func (s *serviceSvcImpl) mediators() domain.Mediators {
    return s.mediatorFactory.Build(s.repos)
}

Transaction Flow

Service.CreateOrder()
    │
    ▼
withTx() starts transaction
    │
    ├── Creates tx-bound RepoFactory
    │
    ├── Creates new service instance with tx-bound repos
    │
    ▼
txSvc.mediators().Order.Create()
    │
    ├── mediators() calls factory.Build(txSvc.repos)
    │   └── repos is tx-bound
    │
    ├── Mediator uses m.repos.NewOrderRepo()
    │   └── Returns repo with tx-bound queries
    │
    ▼
All repo operations use same transaction
    │
    ▼
withTx() commits or rolls back

Rules

  1. Always create repos fresh: Call m.repos.NewXxxRepo() when you need a repo. Don't cache repo instances.

  2. Pass the txCtx: Always use the context provided by the transaction callback, not the outer context.

  3. Return errors to trigger rollback: The transaction commits only if the callback returns nil. Any error causes a rollback.

  4. Don't nest transactions: The transaction manager doesn't support nested transactions. When you need one unit of work to fail without discarding the rest, use WithTxSavepoint instead of a second transaction.

  5. Keep transactions short: Don't do external API calls or long-running operations inside a transaction — beyond holding locks, they are not undone by a rollback and would run twice on a deadlock retry. make tx-audit fails the build on them.

Non-Transactional Operations

For read-only or single-write operations that don't need transactions, use the service's default repos directly:

func (s *serviceSvcImpl) GetOrder(ctx context.Context, id string) (*Order, *apierror.APIError) {
    // No transaction needed for simple reads
    return s.mediators().Order.GetByID(ctx, id)
}

Partial-Success Batches

WithTxSavepoint is WithTx plus a SavepointRunner over the same transaction. Each Run brackets a unit of work in a SAVEPOINT: it releases the savepoint on success and rolls back to it on error, undoing only that unit's writes while the surrounding transaction stays open. Use it when one item in a batch may fail without discarding the rest — everything that did succeed still commits together at the end.

apiErr := s.txManager.WithTxSavepoint(ctx, func(txCtx context.Context, f domain.RepoFactory, sp db.SavepointRunner) *apierror.APIError {
    for _, item := range items {
        if err := sp.Run(txCtx, func(spCtx context.Context) *apierror.APIError {
            return importItem(spCtx, f, item)
        }); err != nil {
            failures = append(failures, err) // recorded, not fatal
        }
    }
    return nil
})

Testing

Mock the RepoFactory interface to test mediators in isolation. Mocks are generated with mockgen by make mocks [service] and live under internal/domain/mock/:

func TestOrderMed_Create(t *testing.T) {
    ctrl := gomock.NewController(t)

    orderRepo := repositorymock.NewMockOrderRepo(ctrl)
    orderRepo.EXPECT().
        Create(gomock.Any(), gomock.Any()).
        Return(&domain.Order{ID: "so_j8cz0b79pwdb"}, nil)

    repos := factorymock.NewMockRepoFactory(ctrl)
    repos.EXPECT().NewOrderRepo().Return(orderRepo).AnyTimes()

    med := NewOrderMed(OrderMedConfig{Repos: repos})
    // ... test
}

Documentation

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func CatalogSearchRank

func CatalogSearchRank(sku string, cs CatalogSearch) int32

CatalogSearchRank returns the SKU tier used for search ordering (0 exact, 1 token, 2 prefix, 3 substring-only). It mirrors the CASE expression in catalog list SQL.

func ConnRetryConfig

func ConnRetryConfig() *retry.Config

ConnRetryConfig returns the short retry policy used by WithConnRetry. The waits are deliberately small: a dropped database connection (e.g. a Vitess tablet failover) is either recovered by the next pooled connection almost immediately or not at all, and callers sit on hot request paths.

func EscapeLike

func EscapeLike(s string) string

EscapeLike escapes MySQL LIKE metacharacters in user-provided search terms.

func Int64FromInterface

func Int64FromInterface(v any) (int64, bool)

Int64FromInterface extracts an int64 from an interface{} value. MySQL CASE expressions are typed as interface{} by sqlc and may arrive as int64, int, or []byte depending on the driver. Returns (0, false) for nil.

func IsDeadlock

func IsDeadlock(err error) bool

IsDeadlock reports whether err is a MySQL 1213 (deadlock) or PostgreSQL 40P01 (deadlock_detected) / 40001 (serialization_failure) error.

func IsDuplicateEntry

func IsDuplicateEntry(err error) bool

IsDuplicateEntry reports whether err is a MySQL 1062 (duplicate entry) error.

func IsRetryableConnectionError

func IsRetryableConnectionError(err error) bool

IsRetryableConnectionError reports whether err is a transient connection-level failure (connection refused, server gone away, connection killed, or lost mid-query — e.g. a Vitess tablet failover) that is safe to retry for an idempotent operation. It returns false when the caller's own context is canceled or past its deadline, since retrying then is pointless.

func IsRetryableLockConflict

func IsRetryableLockConflict(err error) bool

IsRetryableLockConflict reports whether err is a transient database lock conflict that is safe to retry around a small, idempotent database operation.

func MapSQLError

func MapSQLError(err error) *apierror.APIError

MapSQLError converts common SQL/driver errors into an APIError so callers can differentiate expected cases (e.g. not found) from infrastructural failures (timeouts, connection issues, unknown errors).

func MapSQLErrorWithDuplicateKeys

func MapSQLErrorWithDuplicateKeys(err error, mapping DuplicateKeyMapping) *apierror.APIError

MapSQLErrorWithDuplicateKeys works like MapSQLError but, for MySQL 1062 errors, looks up the violated constraint name in the provided mapping to return a domain-specific error. If no mapping matches, it falls through to the generic ResourceExistsError from MapSQLError.

func NewDbPool

func NewDbPool(config *Config) (*sql.DB, error)

NewDbPool creates a new instrumented SQL database connection pool for MySQL with default parameters and tracing.

Example

ExampleNewDbPool shows the minimal configuration for creating a connection pool: only DBURI is required; all other fields receive production defaults.

package main

import (
	"github.com/open-mrp/api/shared/db"
)

func main() {
	pool, err := db.NewDbPool(&db.Config{
		DBURI: "user:pass@tcp(localhost:3306)/app",
	})
	if err != nil {
		panic(err)
	}
	defer pool.Close()
}

func NullInt64Ptr

func NullInt64Ptr(i *int64) sql.NullInt64

func NullString

func NullString(s string) sql.NullString

func NullStringFulltextPtr

func NullStringFulltextPtr(s *string) sql.NullString

NullStringFulltextPtr returns a NullString formatted for MySQL FULLTEXT BOOLEAN MODE search. It appends a wildcard (*) so the term matches any word that starts with the given value (e.g. "kilo" → "kilo*").

func NullStringLikePtr

func NullStringLikePtr(s *string) sql.NullString

NullStringLikePtr returns a NullString with the value wrapped in % wildcards for LIKE queries.

func NullStringPtr

func NullStringPtr(s *string) sql.NullString

func NullTierInt64Param

func NullTierInt64Param(t *int) sql.NullInt64

NullTierInt64Param binds cursor_match_tier for sqlc (nullable integer tier 0–3).

func NullTime

func NullTime(t time.Time) sql.NullTime

func NullTimePtr

func NullTimePtr(t *time.Time) sql.NullTime

func SanitizeFulltextBoolean

func SanitizeFulltextBoolean(s string) string

SanitizeFulltextBoolean strips MySQL BOOLEAN MODE operators from user input.

func StringFromInterface

func StringFromInterface(v any) string

StringFromInterface extracts a string from an interface{} value. MySQL CASE expressions are typed as interface{} by sqlc and may arrive as []byte or string depending on the driver. Returns "" for nil.

func StringFromNullString

func StringFromNullString(ns sql.NullString) *string

func TimeFromNullTime

func TimeFromNullTime(nt sql.NullTime) *time.Time

func TrimDecimal

func TrimDecimal(s string) string

TrimDecimal removes unnecessary trailing zeros from a MySQL DECIMAL string. "1.000000000000000000000000000000" → "1", "10.500000..." → "10.5".

func WithConnRetry

func WithConnRetry(ctx context.Context, cfg *retry.Config, operation string, fn func() error) error

WithConnRetry retries operation only for transient connection failures (see IsRetryableConnectionError). Callers must only use it for idempotent operations — typically pure reads — because a connection lost mid-write leaves the write's outcome unknown. A nil cfg uses ConnRetryConfig.

Types

type CatalogSearch

type CatalogSearch struct {
	// Contains is a LIKE pattern "%escaped_query%" for substring matches.
	Contains sql.NullString
	// Exact is the raw query string for SKU equality (tier 0) and token / MATCH expressions.
	Exact sql.NullString
	// Prefix is a LIKE pattern "escaped_query%" for prefix SKU matches (tier 2).
	Prefix sql.NullString
}

CatalogSearch binds parameters for catalog list queries that filter by item SKU and description and rank exact / token / prefix SKU matches ahead of plain substring matches.

func NewCatalogSearch

func NewCatalogSearch(q *string) CatalogSearch

NewCatalogSearch builds bind args for catalog search. If q is nil or empty, all fields are invalid (no search).

type Config

type Config struct {
	// DBURI (required) is the database connection URI.
	DBURI string

	// TracingEnabled (optional; default: true) specifies whether tracing is enabled. The zero value (false) is treated as "unset" by WithDefaults and replaced with true, so tracing cannot be disabled via this config.
	TracingEnabled bool

	// ConnectionMaxLifetime (optional; default: 30m) is the maximum lifetime of a connection.
	ConnectionMaxLifetime time.Duration

	// ConnectionMaxIdleTime (optional; default: 10m) is the maximum idle time of a connection.
	ConnectionMaxIdleTime time.Duration

	// MaxOpenConnections (optional; default: 50) is the maximum number of open connections.
	MaxOpenConnections int

	// MaxIdleConnections (optional; default: 50) is the maximum number of idle connections.
	MaxIdleConnections int
}

Config represents the configuration for the database connection pool.

func (*Config) WithDefaults

func (c *Config) WithDefaults() *Config

WithDefaults returns a new Config with all zero-value optional fields replaced by production defaults. It is safe to call on a nil receiver. The original Config is not mutated; a copy is always returned.

type DuplicateKeyMapping

type DuplicateKeyMapping map[string]func() *apierror.APIError

DuplicateKeyMapping maps MySQL unique constraint names to custom APIError constructors.

type FulltextSearch

type FulltextSearch struct {
	// Fulltext is the value for the FULLTEXT IS NULL guard and AGAINST clause.
	Fulltext sql.NullString
	// Fulltext2 is a duplicate of Fulltext required by a sqlc dedup bug.
	Fulltext2 sql.NullString
	// Like is the value for the LIKE fallback (set for short queries).
	Like sql.NullString
}

FulltextSearch holds parameters for a SQL clause that supports both FULLTEXT (MATCH/AGAINST) and LIKE search. Queries with at least innoDBMinTokenSize characters use FULLTEXT; shorter queries fall back to LIKE so that short abbreviations (e.g. "pr") are still matched.

The SQL clause should be structured as:

AND (
    (sqlc.narg('search_query') IS NULL AND sqlc.narg('like_query') IS NULL)
    OR MATCH(...) AGAINST(sqlc.narg('search_query') IN BOOLEAN MODE)
    OR col LIKE sqlc.narg('like_query')
)

Due to a sqlc bug, MATCH/AGAINST generates a duplicate parameter (SearchQuery_2). This helper populates both so callers don't need to know about the dedup issue.

Usage:

ft := db.NewFulltextSearch(params.Query)
sqlc.ListFooParams{ SearchQuery: ft.Fulltext, SearchQuery_2: ft.Fulltext2, LikeQuery: ft.Like, ... }

func NewFulltextSearch

func NewFulltextSearch(s *string) FulltextSearch

type NullableRawMessage

type NullableRawMessage []byte

func (*NullableRawMessage) Scan

func (n *NullableRawMessage) Scan(value any) error

func (NullableRawMessage) Value

func (n NullableRawMessage) Value() (driver.Value, error)

type SavepointRunner

type SavepointRunner interface {
	Run(ctx context.Context, fn func(ctx context.Context) *apierror.APIError) *apierror.APIError
}

SavepointRunner brackets a unit of work in a SAVEPOINT within an open transaction. Run releases the savepoint on success and rolls back to it on error — undoing only that unit's writes while the surrounding transaction stays alive — so a batch can let one item fail without discarding the rest. Obtain one from WithTxSavepoint.

type TransactionManager

type TransactionManager[Q TxQuerier[Q], F any] interface {
	WithTx(ctx context.Context, fn func(ctx context.Context, f F) *apierror.APIError) *apierror.APIError
	// WithTxSavepoint is WithTx plus a SavepointRunner over the same transaction, for
	// partial-success batches: successful items and whatever the callback commits still
	// commit together at the end, and a mid-batch crash rolls the whole thing back.
	WithTxSavepoint(ctx context.Context, fn func(ctx context.Context, f F, sp SavepointRunner) *apierror.APIError) *apierror.APIError
}

TransactionManager runs a unit of work in a database transaction.

A transaction that InnoDB picks as a deadlock victim is re-run, so callbacks must be safe to execute more than once. In practice that means a callback may only write to the database: its writes are rolled back with the transaction, so a second run starts from the same state the first one did. Anything that escapes the database does not get undone — an HTTP call to a payment provider, a message published straight to the broker, a value appended to a slice declared outside the callback — and would happen twice.

That is why events are written to the outbox rather than published inline, and why results are assembled inside the callback and handed out at the end. `make tx-audit` checks these rules across the codebase.

func NewTransactionManager

func NewTransactionManager[Q TxQuerier[Q], F any](
	db *sql.DB,
	queries Q,
	factoryCreate func(Q) F,
) TransactionManager[Q, F]

type TxQuerier

type TxQuerier[Q any] interface {
	WithTx(tx *sql.Tx) Q
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL