Stage 1: backend foundation (Postgres, sessions, accounts, OTel)
- internal/postgres: pgx-over-database/sql pool (otelsql), embedded goose
migrations into schema 'backend', committed go-jet code + cmd/jetgen tool.
- internal/account: durable accounts + unified telegram/email identities
(UUIDv7 keys), find-or-create provisioning with unique-conflict handling.
- internal/session: opaque 256-bit tokens stored as a SHA-256 hash, revoke-only
(no TTL); write-through cache gating /readyz; store + service.
- internal/telemetry: OTel tracer/meter providers (none/stdout) + request-timing
middleware; internal/config gains Postgres + OTel env loading.
- internal/server: /api/v1 {public,user,internal,admin} skeleton + X-User-ID
middleware; /readyz checks DB ping + cache; main wires
telemetry -> db+migrate -> warm cache -> server.
- Tests: unit + integration (build tag 'integration', testcontainers
postgres:17) for migrations, accounts, sessions, readyz; new integration.yaml.
- Docs: ARCHITECTURE, TESTING, PLAN refinements, root + backend READMEs.
Session/account REST handlers deferred to Stage 6 (gateway); OTLP + dashboards
to Stage 11.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
"scrabble/backend/internal/postgres/migrations"
|
||||
)
|
||||
|
||||
// schemaName is the Postgres schema owned by the backend service. Every backend
|
||||
// table lives here, and the DSN pins search_path to it.
|
||||
const schemaName = "backend"
|
||||
|
||||
// migrationRetryAttempts and migrationRetryBackoff bound the transient-error
|
||||
// retry around ApplyMigrations. A freshly started Postgres — notably a test
|
||||
// container — can reset a pooled connection moments after it reports ready,
|
||||
// which surfaces as "bad connection" mid-migration; a handful of quick retries
|
||||
// ride over that without masking real failures.
|
||||
const (
|
||||
migrationRetryAttempts = 5
|
||||
migrationRetryBackoff = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// gooseMu serialises access to goose's package-level filesystem state so a
|
||||
// second caller in the same process cannot race on goose.SetBaseFS.
|
||||
var gooseMu sync.Mutex
|
||||
|
||||
// ApplyMigrations runs every pending Up migration embedded in the backend
|
||||
// binary against db. The schema is created upfront so goose's bookkeeping table
|
||||
// (`goose_db_version`, scoped to the DSN search_path) has somewhere to land
|
||||
// before the first migration runs; migration 00001_init.sql re-asserts the
|
||||
// schema with IF NOT EXISTS, so the double-create is idempotent.
|
||||
//
|
||||
// The apply is retried on transient connection errors. Both steps are
|
||||
// idempotent, so a retry after a dropped connection resumes from the last
|
||||
// committed migration.
|
||||
func ApplyMigrations(ctx context.Context, db *sql.DB) error {
|
||||
return retryOnTransient(ctx, migrationRetryAttempts, migrationRetryBackoff, func() error {
|
||||
if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaName); err != nil {
|
||||
return fmt.Errorf("ensure backend schema: %w", err)
|
||||
}
|
||||
if err := runMigrations(ctx, db, migrations.Migrations(), "."); err != nil {
|
||||
return fmt.Errorf("apply backend migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// runMigrations applies every pending Up migration found under dir inside fsys
|
||||
// against db. The PostgreSQL dialect is forced; goose's package-level base FS is
|
||||
// restored on the way out so a second caller in the same process is safe. dir
|
||||
// is "." when the migration files sit at the embed root.
|
||||
func runMigrations(ctx context.Context, db *sql.DB, fsys fs.FS, dir string) error {
|
||||
if db == nil {
|
||||
return errors.New("run migrations: nil db")
|
||||
}
|
||||
if fsys == nil {
|
||||
return errors.New("run migrations: nil fs")
|
||||
}
|
||||
|
||||
gooseMu.Lock()
|
||||
defer gooseMu.Unlock()
|
||||
|
||||
goose.SetBaseFS(fsys)
|
||||
defer goose.SetBaseFS(nil)
|
||||
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return fmt.Errorf("run migrations: set dialect: %w", err)
|
||||
}
|
||||
if err := goose.UpContext(ctx, db, dir); err != nil {
|
||||
return fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// retryOnTransient runs op up to attempts times, retrying only when op fails
|
||||
// with a transient connection error — a dropped, reset, or refused connection,
|
||||
// as opposed to a deterministic SQL error. It waits backoff between attempts and
|
||||
// stops early if ctx is cancelled.
|
||||
func retryOnTransient(ctx context.Context, attempts int, backoff time.Duration, op func() error) error {
|
||||
var err error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if err = op(); err == nil {
|
||||
return nil
|
||||
}
|
||||
if attempt == attempts || !isTransientConnError(err) {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errors.Join(err, ctx.Err())
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// isTransientConnError reports whether err is a transient connection-level
|
||||
// failure worth retrying, leaving deterministic SQL errors (syntax, constraint
|
||||
// violations) to fail fast.
|
||||
func isTransientConnError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, driver.ErrBadConn) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, s := range []string{
|
||||
"bad connection",
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"broken pipe",
|
||||
"server closed the connection",
|
||||
} {
|
||||
if strings.Contains(msg, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user