Phase 2b — durable Postgres queue + worker (gated on DATABASE_URL)
Some checks failed
Docker Build / build-and-push-image (push) Has been cancelled
Some checks failed
Docker Build / build-and-push-image (push) Has been cancelled
Adds the async dispatch infrastructure : - Postgres pool + embedded migration (CREATE TABLE/INDEX IF NOT EXISTS gateway_jobs). Auto-applied at boot. lib/pq driver (matches webapp convention). - queue.go : Enqueue (idempotent on UNIQUE(bot_slug, update_id) — handles Telegram redelivery), Pop with FOR UPDATE SKIP LOCKED, MarkDone, MarkFailed with exponential backoff (30s → 2m → 10m → 1h → dead at 5). - worker.go : goroutine that drains the queue, dispatches via the same Handler interface as sync, schedules retries on failure, notifies the user once when a job goes to dead. - BotConfig gains `async: bool`. Registry refuses bots with async=true if DATABASE_URL is unset (queue=nil). - Server : when bot.Async, the webhook ack is immediate ; the update payload is enqueued for the worker. When DATABASE_URL is unset (current default), queue/worker stay disabled and only sync handlers (echo, http, auth) work — no breaking change to the running cluster. Refs ~/.claude/plans/pour-les-notifications-on-inherited-seal.md § Phase 2.
This commit is contained in:
58
db.go
Normal file
58
db.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Phase 2b — Postgres pool + schema migration.
|
||||
// Voir ~/.claude/plans/pour-les-notifications-on-inherited-seal.md § Phase 2.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
//go:embed migrations/001_init.sql
|
||||
var initSQL string
|
||||
|
||||
// OpenDB opens a Postgres pool from a DSN, pings, and runs the embedded
|
||||
// migration (idempotent CREATE TABLE / CREATE INDEX IF NOT EXISTS).
|
||||
// Caller is responsible for closing the returned *sql.DB.
|
||||
func OpenDB(ctx context.Context, dsn string) (*sql.DB, error) {
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sql.Open: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(pingCtx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("postgres ping: %w", err)
|
||||
}
|
||||
|
||||
migCtx, cancelMig := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancelMig()
|
||||
if _, err := db.ExecContext(migCtx, initSQL); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("apply migration: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// RedactDSN strips the password from a Postgres URL for safe logging.
|
||||
func RedactDSN(dsn string) string {
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return "(invalid dsn)"
|
||||
}
|
||||
if u.User != nil {
|
||||
username := u.User.Username()
|
||||
u.User = url.User(username)
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
Reference in New Issue
Block a user