Files
ntfy/db/db.go
2026-03-10 22:17:40 -04:00

157 lines
4.1 KiB
Go

package db
import (
"database/sql"
"sync/atomic"
"time"
)
const (
replicaHealthCheckInterval = 5 * time.Second
)
// Beginner is an interface for types that can begin a database transaction.
// Both *sql.DB and *DB implement this.
type Beginner interface {
Begin() (*sql.Tx, error)
}
// DB wraps a primary *sql.DB and optional read replicas. All standard query/exec methods
// delegate to the primary. The ReadOnly() method returns a *sql.DB from a healthy replica
// (round-robin), falling back to the primary if no replicas are configured or all are unhealthy.
type DB struct {
primary *sql.DB
replicas []*replica
counter atomic.Uint64
}
type replica struct {
db *sql.DB
healthy atomic.Bool
lastChecked atomic.Int64
}
// NewDB creates a new DB that wraps the given primary and optional replica connections.
// If replicas is nil or empty, ReadOnly() simply returns the primary.
func NewDB(primary *sql.DB, replicas []*sql.DB) *DB {
d := &DB{
primary: primary,
replicas: make([]*replica, len(replicas)),
}
for i, r := range replicas {
rep := &replica{db: r}
rep.healthy.Store(true)
d.replicas[i] = rep
}
return d
}
// Query delegates to the primary database.
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
return d.primary.Query(query, args...)
}
// QueryRow delegates to the primary database.
func (d *DB) QueryRow(query string, args ...any) *sql.Row {
return d.primary.QueryRow(query, args...)
}
// Exec delegates to the primary database.
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
return d.primary.Exec(query, args...)
}
// Begin delegates to the primary database.
func (d *DB) Begin() (*sql.Tx, error) {
return d.primary.Begin()
}
// Ping delegates to the primary database.
func (d *DB) Ping() error {
return d.primary.Ping()
}
// Close closes the primary database and all replicas.
func (d *DB) Close() error {
for _, r := range d.replicas {
r.db.Close()
}
return d.primary.Close()
}
// SetupPrimary returns the underlying primary *sql.DB. This is only intended for
// one-time schema setup during store initialization, not for regular queries.
func (d *DB) SetupPrimary() *sql.DB {
return d.primary
}
// ReadOnly returns a *sql.DB suitable for read-only queries. It round-robins across healthy
// replicas. If a replica's health status is stale (older than replicaHealthCheckInterval), it
// is re-checked with a ping. If all replicas are unhealthy or none are configured, the primary
// is returned.
func (d *DB) ReadOnly() *sql.DB {
if len(d.replicas) == 0 {
return d.primary
}
n := len(d.replicas)
start := int(d.counter.Add(1) - 1)
for i := 0; i < n; i++ {
r := d.replicas[(start+i)%n]
if d.isHealthy(r) {
return r.db
}
}
return d.primary
}
// isHealthy returns whether the replica is healthy. If the cached health status is stale,
// it pings the replica and updates the cache.
func (d *DB) isHealthy(r *replica) bool {
now := time.Now().Unix()
lastChecked := r.lastChecked.Load()
if now-lastChecked >= int64(replicaHealthCheckInterval.Seconds()) {
if r.lastChecked.CompareAndSwap(lastChecked, now) {
if err := r.db.Ping(); err != nil {
r.healthy.Store(false)
return false
}
r.healthy.Store(true)
return true
}
}
return r.healthy.Load()
}
// ExecTx executes a function within a database transaction. If the function returns an error,
// the transaction is rolled back. Otherwise, the transaction is committed.
func ExecTx(db Beginner, f func(tx *sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := f(tx); err != nil {
return err
}
return tx.Commit()
}
// QueryTx executes a function within a database transaction and returns the result. If the function
// returns an error, the transaction is rolled back. Otherwise, the transaction is committed.
func QueryTx[T any](db Beginner, f func(tx *sql.Tx) (T, error)) (T, error) {
tx, err := db.Begin()
if err != nil {
var zero T
return zero, err
}
defer tx.Rollback()
t, err := f(tx)
if err != nil {
return t, err
}
if err := tx.Commit(); err != nil {
return t, err
}
return t, nil
}