This commit is contained in:
107
pkg/pgkit/pgkit.go
Normal file
107
pkg/pgkit/pgkit.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Package pgkit is the small piece of internal/db/db.go that wasn't
|
||||
// specific to any one bounded context: connecting with a startup retry
|
||||
// loop (docker compose's "healthy" and "actually accepting
|
||||
// connections" can be a beat apart) and applying an idempotent
|
||||
// schema.sql-style migration under an advisory lock so two replicas of
|
||||
// the same service starting at once don't race each other.
|
||||
//
|
||||
// Each data-owning service (iam, billing, conversation, knowledge) gets
|
||||
// its own *pgkit.DB over its own database — see each service's
|
||||
// migrations/0001_init.sql — never a shared one. That split is the
|
||||
// difference between "microservices" and "one database with several
|
||||
// front ends" (see ARCHITECTURE.md, "Database-per-service").
|
||||
package pgkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Sentinel errors every repository package in this repo returns, so
|
||||
// application-layer use cases can errors.Is against one shared set
|
||||
// instead of each service inventing its own (matching internal/db's
|
||||
// original ErrNotFound/ErrEmailTaken/ErrInvalidToken).
|
||||
var (
|
||||
ErrNotFound = errors.New("pgkit: not found")
|
||||
ErrAlreadyTaken = errors.New("pgkit: unique constraint violated")
|
||||
ErrInvalid = errors.New("pgkit: invalid or expired")
|
||||
)
|
||||
|
||||
// DB wraps *sql.DB exactly as internal/db.DB did.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open connects to Postgres and retries pinging it for up to ~30s.
|
||||
func Open(dsn string) (*DB, error) {
|
||||
sqlDB, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgkit: open: %w", err)
|
||||
}
|
||||
|
||||
// Pool sizing: each microservice is still a single logical writer
|
||||
// per table, same reasoning as the monolith's own comment here —
|
||||
// a handful of connections covers concurrent gRPC calls without
|
||||
// exhausting Postgres' default max_connections (100) once you
|
||||
// multiply by four data-owning services.
|
||||
sqlDB.SetMaxOpenConns(20)
|
||||
sqlDB.SetMaxIdleConns(5)
|
||||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
var lastErr error
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
lastErr = sqlDB.PingContext(ctx)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
return &DB{sqlDB}, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("pgkit: ping (gave up after 30s): %w", lastErr)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// migrationLockID is an arbitrary constant used with pg_advisory_lock.
|
||||
// Shared across services since each connects to its own database (the
|
||||
// lock is scoped per-database by Postgres), so there's no cross-service
|
||||
// collision risk in reusing one constant.
|
||||
const migrationLockID = 7_402_113_915
|
||||
|
||||
// Migrate applies schema (expected to be idempotent — CREATE ... IF NOT
|
||||
// EXISTS throughout, as with the monolith's schema.sql) under an
|
||||
// advisory lock.
|
||||
func (d *DB) Migrate(ctx context.Context, schema string) error {
|
||||
conn, err := d.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pgkit: migrate: acquire connection: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, migrationLockID); err != nil {
|
||||
return fmt.Errorf("pgkit: migrate: lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock($1)`, migrationLockID)
|
||||
}()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("pgkit: migrate: apply schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUniqueViolation reports whether err is Postgres' unique_violation (23505).
|
||||
func IsUniqueViolation(err error) bool {
|
||||
var pqErr *pq.Error
|
||||
return errors.As(err, &pqErr) && pqErr.Code == "23505"
|
||||
}
|
||||
Reference in New Issue
Block a user