snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:17:21 +05:00
commit b352a9a30d
472 changed files with 108375 additions and 0 deletions

83
internal/pgoutbox/doc.go Normal file
View File

@@ -0,0 +1,83 @@
// Package pgoutbox provides reusable outbox-pattern machinery for
// PostgreSQL-backed brokers.
//
// The package is designed to be shared between the PG map broker
// (centrifugo/internal/pgmapbroker) and a future PG stream broker. It
// contains only the generic parts of the outbox pattern: polling loop,
// advisory-lock coordination for one-poller-per-shard, LISTEN/NOTIFY
// wakeup, and daily table partition maintenance. All SQL and row→publication
// conversion stays in the calling broker.
//
// # Outbox pattern overview
//
// The caller maintains a stream table backed by a BIGSERIAL id column and a
// per-shard lock that serializes inserts within a shard. This combination
// guarantees that committed ids within a shard form a contiguous sequence
// — no gaps — which makes cursor-based polling safe with a simple
// `id > cursor` filter.
//
// Workers poll the stream table in batches and deliver publications to
// subscribers. Two worker shapes are provided:
//
// - Worker — plain polling, one goroutine per shard on every node.
// Used when the caller is the sole consumer (no cross-node fan-out).
//
// - LockWorker — advisory-lock coordinated, one goroutine per shard with
// a session-scoped lock held on the primary pool. Only one node per
// shard actually polls; others wait to acquire the lock. Used when the
// caller fans out publications via an inner Broker (Redis/NATS) and
// must avoid duplicate delivery.
//
// # Concurrency model
//
// One goroutine per worker. Workers are goroutine-safe by construction
// because each owns its own cursor state and never shares memory with
// other workers. Error and info callbacks may be invoked from multiple
// workers concurrently and must be safe for concurrent use by the caller.
//
// # Lifecycle
//
// Each worker takes (ctx, closeCh) in Run(). Shutdown is signalled by
// cancelling ctx AND/OR closing closeCh. The caller is expected to cancel
// ctx first (to interrupt in-flight pgx calls and WaitForNotification)
// and then close closeCh (to signal the poll loop between batches).
//
// closeCh is a <-chan struct{} and is observed via a select case. It must
// be closed, never sent on. NotifyCh is a different story — see below.
//
// # NotifyCh invariants
//
// Worker's NotifyCh is a <-chan struct{} used as a wakeup signal from a
// LISTEN/NOTIFY loop. It must be a send-only channel from the poll loop's
// perspective. The caller owns the channel and its sender side.
//
// NotifyCh must NEVER be closed. A closed channel fires immediately in
// select statements and would cause the worker to spin. A nil channel is
// fine — a nil case in select is runtime-inert (blocks forever, never
// fires), so passing nil is equivalent to "no wakeup signal, rely on
// PollInterval".
//
// LockWorker does not take a NotifyCh because the current advisory-lock
// variant does not use LISTEN/NOTIFY. If this changes, the type should
// gain an explicit field rather than implicitly sharing Worker's.
//
// # Partitioning
//
// Partitioner manages daily time-based partition maintenance for tables
// declared as PARTITION BY RANGE (created_at). It does NOT create the
// parent partitioned table itself — the parent's column list is schema-
// specific and must stay with the calling broker. Partitioner only owns
// the ongoing maintenance: creating tomorrow's partition before it's
// needed, and dropping partitions older than retention.
//
// The partition naming convention is {ParentTable}_{YYYY}_{MM}_{DD}.
// Partitions that don't match this pattern are skipped by DropOldPartitions
// — they are assumed to be user-managed and not subject to automatic
// cleanup.
//
// # Dependencies
//
// This package imports only stdlib and github.com/jackc/pgx/v5/pgxpool.
// No centrifuge, no zerolog, no pgmapbroker. Logging is done via function-
// field callbacks (ErrorFn, InfoFn) provided by the caller.
package pgoutbox

View File

@@ -0,0 +1,136 @@
package pgoutbox
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// NotificationListener runs a PostgreSQL LISTEN loop, non-blocking-sending
// on NotifyCh each time a notification is received on Channel.
//
// Lifecycle:
// 1. Acquire a pinned connection from Pool.
// 2. LISTEN <Channel>.
// 3. WaitForNotification in a loop. On each notification, non-blocking
// send on NotifyCh (drops the signal if the channel is already full —
// the receiver only needs to know "wake up", not "how many times").
// 4. On connection error: release, wait with exponential backoff (1s → 30s),
// re-acquire.
// 5. On ctx cancel or closeCh close: release and return.
//
// NotifyCh is shared with one or more Worker instances. It must never be
// closed by the listener — a closed channel would cause Worker's idle
// wait select to spin. Workers use nil-safe select semantics so NotifyCh
// may also be nil if no listener is wired up.
type NotificationListener struct {
// Pool is the pgxpool used to acquire LISTEN connections.
Pool *pgxpool.Pool
// Channel is the PostgreSQL notification channel name (bare,
// unquoted). Typically set by the caller to match the pg_notify
// channel name emitted by its stream insert triggers.
Channel string
// NotifyCh is the buffered wakeup channel. Sends are non-blocking;
// if the channel is full the signal is dropped. Must not be closed.
NotifyCh chan<- struct{}
// ErrorFn is called on connection and LISTEN errors. Must be safe for
// concurrent use (in practice this listener runs in one goroutine).
ErrorFn func(msg string, err error)
// OnReady, if set, is invoked after each successful LISTEN — i.e., once
// the listener is bound and will deliver subsequent NOTIFY signals on
// the channel. Fires again after each reconnect. Used by tests that
// need to publish only after the listener is bound; the production
// path treats early NOTIFYs as benign (PollInterval is the fallback).
OnReady func()
}
// Run starts the listener loop. Returns when ctx is cancelled or closeCh
// is closed.
func (l *NotificationListener) Run(ctx context.Context, closeCh <-chan struct{}) {
backoff := time.Second
const maxBackoff = 30 * time.Second
for {
select {
case <-closeCh:
return
case <-ctx.Done():
return
default:
}
conn, err := l.Pool.Acquire(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
l.ErrorFn("notification listener: acquire connection", err)
if !waitWithBackoff(ctx, closeCh, &backoff, maxBackoff) {
return
}
continue
}
_, err = conn.Exec(ctx, "LISTEN "+l.Channel)
if err != nil {
conn.Release()
if ctx.Err() != nil {
return
}
l.ErrorFn("notification listener: LISTEN", err)
if !waitWithBackoff(ctx, closeCh, &backoff, maxBackoff) {
return
}
continue
}
backoff = time.Second
if l.OnReady != nil {
l.OnReady()
}
// Inner notification loop.
for {
_, err := conn.Conn().WaitForNotification(ctx)
if err != nil {
conn.Release()
if ctx.Err() != nil {
return
}
l.ErrorFn("notification listener: wait", err)
break // reconnect
}
// Non-blocking send to wake any waiting worker.
select {
case l.NotifyCh <- struct{}{}:
default:
}
}
if !waitWithBackoff(ctx, closeCh, &backoff, maxBackoff) {
return
}
}
}
// waitWithBackoff sleeps for *backoff, doubling it up to maxBackoff.
// Returns true if the sleep completed, false on shutdown.
func waitWithBackoff(ctx context.Context, closeCh <-chan struct{}, backoff *time.Duration, maxBackoff time.Duration) bool {
d := *backoff
*backoff = min(*backoff*2, maxBackoff)
select {
case <-closeCh:
return false
case <-ctx.Done():
return false
case <-time.After(d):
return true
}
}

View File

@@ -0,0 +1,300 @@
package pgoutbox
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// lockPingMinTimeout is the floor for the lock-connection ping timeout.
// PollInterval can be set very small (sub-second) to make idle polling
// responsive, but a healthy ping must still tolerate transient latency
// spikes — otherwise we spuriously release and re-acquire the lock.
const lockPingMinTimeout = 2 * time.Second
// lockLivenessInterval bounds how often the lock-holding connection is pinged
// for liveness, whether the worker is busy or idle. It caps how long a silently
// dropped lock connection — and therefore a dual-leader window — can go
// undetected under a sustained backlog (where the worker never goes idle).
const lockLivenessInterval = 5 * time.Second
// LockWorker runs the advisory-lock variant of the outbox poll loop.
//
// Unlike Worker, LockWorker holds a session-scoped PostgreSQL advisory
// lock for the lifetime of each poll session. This guarantees at most one
// active poller per shard across a cluster of Centrifugo nodes, which is
// required when the caller fans publications out via an inner Broker
// (Redis/NATS) — otherwise multiple nodes would deliver duplicates.
//
// Lifecycle per session:
// 1. Acquire a pinned connection from LockPool (typically the primary).
// 2. pg_try_advisory_lock(LockID) — non-blocking. On failure, release and
// retry after RetryInterval.
// 3. InitCursor on PollPool (typically a read-replica) — each session
// re-initializes from the current MAX(id) because the stream may have
// advanced while another node held the lock.
// 4. Drain ProcessBatch until idle, then wait PollInterval, repeat.
// 5. On any ProcessBatch error: log via ErrorFn, release lock, retry from
// step 1 after RetryInterval. (This is a harder recovery than Worker's
// log-and-continue — a query error may indicate the lock-holding
// connection is broken.)
// 6. On ctx cancel or closeCh close: release lock and return.
//
// LockWorker does NOT take a NotifyCh — the advisory-lock variant does
// not use LISTEN/NOTIFY today.
type LockWorker struct {
// LockPool is the pool used to acquire the advisory lock. The lock is
// session-scoped, so the connection is pinned for the lock's lifetime.
// Typically the primary pool.
LockPool *pgxpool.Pool
// PollPool is the pool used for cursor bootstrap and batch polling
// while the lock is held. Typically a read-replica pool.
PollPool *pgxpool.Pool
// ShardIDs is the set of shard ids this worker owns. Passed through
// to ProcessBatch so the callback can scope its SELECT WHERE clause.
ShardIDs []int
// LockID is the advisory lock id. Must be globally unique per shard
// across all LockWorker instances; callers typically derive it as
// BaseID + shardIndex.
LockID int64
// PollInterval is the idle wait between drain passes.
PollInterval time.Duration
// RetryInterval is the wait between failed lock acquisition attempts
// and between full poll-session retries after a ProcessBatch error.
RetryInterval time.Duration
// InitCursor is called once per lock acquisition (NOT once per
// LockWorker lifetime) to bootstrap the cursor value from PollPool.
// Typically implemented as SELECT COALESCE(MAX(id), 0) FROM <stream_table>.
InitCursor func(ctx context.Context, pool *pgxpool.Pool) (int64, error)
// ProcessBatch fetches and delivers the next batch. See Worker.ProcessBatch
// for semantics. In LockWorker, a non-ctx error triggers lock release.
ProcessBatch func(ctx context.Context, pool *pgxpool.Pool, cursor int64, shardIDs []int) (processed int, maxID int64, err error)
// ErrorFn is called for non-ctx errors. Must be safe for concurrent use.
ErrorFn func(msg string, err error)
// InfoFn is called on successful lock acquisition. Optional.
InfoFn func(msg string)
}
// Run starts the advisory-lock poll loop. Returns when ctx is cancelled
// or closeCh is closed.
func (lw *LockWorker) Run(ctx context.Context, closeCh <-chan struct{}) {
for {
select {
case <-closeCh:
return
case <-ctx.Done():
return
default:
}
// Acquire a connection from LockPool for the session-scoped lock.
conn, err := lw.LockPool.Acquire(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
lw.ErrorFn("outbox worker: acquire lock connection", err)
if !sleep(ctx, closeCh, lw.RetryInterval) {
return
}
continue
}
// Non-blocking lock acquisition.
var locked bool
err = conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", lw.LockID).Scan(&locked)
if err != nil {
conn.Release()
if ctx.Err() != nil {
return
}
lw.ErrorFn("outbox worker: try advisory lock", err)
if !sleep(ctx, closeCh, lw.RetryInterval) {
return
}
continue
}
if !locked {
conn.Release()
if !sleep(ctx, closeCh, lw.RetryInterval) {
return
}
continue
}
if lw.InfoFn != nil {
lw.InfoFn("outbox worker: acquired advisory lock")
}
// Lock acquired — init cursor and run the inner poll loop.
shouldReturn := lw.runLockedSession(ctx, closeCh, conn)
releaseAdvisoryLock(conn, lw.LockID, lw.ErrorFn)
conn.Release()
if shouldReturn {
return
}
// Back off before retrying lock acquisition.
if !sleep(ctx, closeCh, lw.RetryInterval) {
return
}
}
}
// runLockedSession runs one locked poll session: initializes the cursor
// and drains batches until shutdown, error, or lock loss. Returns true if
// the caller should exit Run (ctx cancelled or closeCh closed); false if
// the caller should retry lock acquisition.
//
// The lock-holding conn is owned by the caller and released AFTER this
// method returns (via releaseAdvisoryLock + conn.Release); this method
// never releases it. We DO ping it during idle waits — see comment on the
// idle-wait branch below for why.
func (lw *LockWorker) runLockedSession(ctx context.Context, closeCh <-chan struct{}, lockConn *pgxpool.Conn) bool {
// Initialize cursor from PollPool. Cursor is re-initialized on every
// acquisition because the stream may have advanced while another node
// held the lock.
cursor, err := lw.InitCursor(ctx, lw.PollPool)
if err != nil {
if ctx.Err() != nil {
return true
}
lw.ErrorFn("outbox worker: init cursor", err)
return false
}
lastLockPing := time.Now()
for {
select {
case <-closeCh:
return true
case <-ctx.Done():
return true
default:
}
// Drain batches until idle or error.
idle := true
lockLost := false
for {
processed, maxID, err := lw.ProcessBatch(ctx, lw.PollPool, cursor, lw.ShardIDs)
if err != nil {
if ctx.Err() != nil {
return true
}
lw.ErrorFn("outbox worker: process batch", err)
lockLost = true
break
}
if processed == 0 {
break
}
if maxID > cursor {
cursor = maxID
}
idle = false
}
if lockLost {
// Drop the session and re-acquire the lock; a query error may
// indicate the lock-holding connection is broken.
return false
}
// Ping the lock-holding connection on a time bound, whether the worker is
// busy or idle. Postgres releases the advisory lock when this session dies,
// so a silently reaped lock conn (pgbouncer reaping it, kernel TCP timeout,
// a network partition affecting only this conn) lets another node acquire
// the lock and dual-process. The lock conn is TCP-idle while queries run on
// PollPool, and under a sustained backlog the worker never reaches the idle
// wait below — so this liveness ping must run here, not only when idle. On
// failure, drop the session and re-acquire, restoring single-writer
// semantics.
if time.Since(lastLockPing) >= lockLivenessInterval {
alive, ctxDone := lw.pingLockConn(ctx, lockConn)
if ctxDone {
return true
}
if !alive {
return false
}
lastLockPing = time.Now()
}
if !idle {
continue
}
// Idle wait.
select {
case <-closeCh:
return true
case <-ctx.Done():
return true
case <-time.After(lw.PollInterval):
}
}
}
// pingLockConn checks the lock-holding connection is still alive. Returns
// (alive, ctxDone): alive=false means the connection is dead — Postgres has (or
// will) release the advisory lock, so the caller must drop the session and
// re-acquire; ctxDone=true means the worker is shutting down. The ping timeout
// is decoupled from PollInterval (which can be sub-second) with a floor so a
// transient latency spike doesn't spuriously drop the lock.
func (lw *LockWorker) pingLockConn(ctx context.Context, lockConn *pgxpool.Conn) (alive bool, ctxDone bool) {
pingTimeout := lw.PollInterval
if pingTimeout < lockPingMinTimeout {
pingTimeout = lockPingMinTimeout
}
pingCtx, cancel := context.WithTimeout(ctx, pingTimeout)
err := lockConn.Conn().Ping(pingCtx)
cancel()
if err != nil {
if ctx.Err() != nil {
return false, true
}
lw.ErrorFn("outbox worker: lock connection ping failed — releasing lock", err)
return false, false
}
return true, false
}
// releaseAdvisoryLock explicitly releases an advisory lock held on conn.
// Uses a fresh context.Background()-based timeout so it can still execute
// after the worker's context has been cancelled during shutdown. This is
// best-effort — the lock is also auto-released when the connection is
// returned to the pool and dropped.
func releaseAdvisoryLock(conn *pgxpool.Conn, lockID int64, errorFn func(msg string, err error)) {
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := conn.Exec(releaseCtx, "SELECT pg_advisory_unlock($1)", lockID); err != nil {
errorFn("outbox worker: release advisory lock", err)
}
}
// sleep waits for the given duration or returns early on shutdown.
// Returns true if the sleep completed, false if ctx/closeCh triggered exit.
func sleep(ctx context.Context, closeCh <-chan struct{}, d time.Duration) bool {
select {
case <-closeCh:
return false
case <-ctx.Done():
return false
case <-time.After(d):
return true
}
}

View File

@@ -0,0 +1,178 @@
//go:build integration
package pgoutbox
import (
"context"
"fmt"
"math/rand/v2"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
)
// These tests exercise the LockWorker against a real Postgres. They are
// gated by the `integration` build tag and the CENTRIFUGE_POSTGRES_URL env
// var (matching the other pg-integration suites in this repo).
func getPostgresConnString(tb testing.TB) string {
tb.Helper()
dsn := os.Getenv("CENTRIFUGE_POSTGRES_URL")
if dsn == "" {
dsn = "postgres://test:test@localhost:5432/test?sslmode=disable"
}
return dsn
}
// newLockTestPool returns a pool sized for the lock-conn-ping test:
// 4 max conns so there's room for the lock conn + the polling pool's needs
// + a test-helper conn used to kill the lock conn from outside.
func newLockTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
cfg, err := pgxpool.ParseConfig(getPostgresConnString(t))
require.NoError(t, err)
cfg.MaxConns = 4
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
require.NoError(t, err)
t.Cleanup(pool.Close)
return pool
}
// findLockHolderPID returns the backend pid of the session holding our
// advisory lock, or 0 if not found. Used to identify the lock conn so we
// can terminate it from a different session.
func findLockHolderPID(t *testing.T, pool *pgxpool.Pool, lockID int64) int32 {
t.Helper()
var pid int32
err := pool.QueryRow(context.Background(), `
SELECT pid FROM pg_locks
WHERE locktype = 'advisory'
AND ((classid::bigint << 32) | objid::bigint::int8) = $1
LIMIT 1
`, lockID).Scan(&pid)
if err != nil {
return 0
}
return pid
}
// TestLockWorker_LockConnTerminationDetected is the regression test for the
// advisory-lock health-check ping. Once the worker has acquired the advisory
// lock, an external session terminates the lock-holding backend; Postgres
// then auto-releases the advisory lock. The time-gated liveness ping (fires
// every lockLivenessInterval, whether the worker is idle or busy) must detect
// the dead session and surface it via ErrorFn so the worker releases the
// (already-gone) lock and re-acquires it via the outer loop.
//
// Without the ping, the worker would happily continue polling on a healthy
// PollPool connection while another node could have already acquired the
// advisory lock — dual-leader processing of the same outbox region.
func TestLockWorker_LockConnTerminationDetected(t *testing.T) {
pool := newLockTestPool(t)
// Randomise the lock ID per test run so concurrent test packages don't
// collide on the same advisory lock.
lockID := rand.Int64()
var (
processCalls atomic.Int32
errs []error
errsMu sync.Mutex
)
lw := &LockWorker{
PollPool: pool,
LockPool: pool,
ShardIDs: []int{0},
LockID: lockID,
PollInterval: 100 * time.Millisecond,
RetryInterval: 100 * time.Millisecond,
InitCursor: func(ctx context.Context, _ *pgxpool.Pool) (int64, error) {
return 0, nil
},
ProcessBatch: func(ctx context.Context, _ *pgxpool.Pool, _ int64, _ []int) (int, int64, error) {
processCalls.Add(1)
return 0, 0, nil // always idle
},
ErrorFn: func(msg string, err error) {
errsMu.Lock()
defer errsMu.Unlock()
errs = append(errs, fmt.Errorf("%s: %w", msg, err))
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
closeCh := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
lw.Run(ctx, closeCh)
}()
t.Cleanup(func() {
close(closeCh)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("worker did not exit on close")
}
})
// Wait until the worker has acquired the lock.
var pid int32
require.Eventually(t, func() bool {
pid = findLockHolderPID(t, pool, lockID)
return pid != 0
}, 5*time.Second, 50*time.Millisecond, "worker never acquired the advisory lock")
// Externally terminate the lock-holding session — Postgres auto-releases
// the advisory lock on session end.
_, err := pool.Exec(context.Background(), `SELECT pg_terminate_backend($1)`, pid)
require.NoError(t, err)
// Within a few PollInterval ticks the ping must fail and the worker must
// log it via ErrorFn. The exact error from pgx may vary by version, so
// we only require some error to be reported referencing the ping path
// or session loss.
require.Eventually(t, func() bool {
errsMu.Lock()
defer errsMu.Unlock()
for _, e := range errs {
s := e.Error()
if containsAny(s, "ping failed", "release advisory lock") {
return true
}
}
return false
}, lockLivenessInterval+3*time.Second, 100*time.Millisecond, "ping failure was not surfaced via ErrorFn")
// And the worker should be able to re-acquire the lock under a fresh
// connection on its next cycle. We don't assert specific PIDs because
// the new lock holder may use any conn from the pool.
require.Eventually(t, func() bool {
newPID := findLockHolderPID(t, pool, lockID)
return newPID != 0 && newPID != pid
}, 5*time.Second, 100*time.Millisecond, "worker did not re-acquire the advisory lock after lock-conn loss")
}
func containsAny(s string, substrs ...string) bool {
for _, sub := range substrs {
if idx := indexOfStr(s, sub); idx >= 0 {
return true
}
}
return false
}
func indexOfStr(s, substr string) int {
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

View File

@@ -0,0 +1,251 @@
package pgoutbox
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/centrifugal/centrifugo/v6/internal/pgschema"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// isShutdownErr reports whether err is a normal-shutdown signal — context
// cancellation surfacing because the broker is being closed mid-tick. Those
// aren't real failures and shouldn't be logged at error level, since they
// would otherwise pollute CI logs and mask genuine cleanup errors.
func isShutdownErr(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
// isDuplicateTableErr reports whether err is PostgreSQL's 42P07, raised when the
// relation already exists. CREATE TABLE IF NOT EXISTS checks the catalog before
// it takes the lock, so two nodes creating the same partition at the same moment
// both pass the check and the loser gets a hard error rather than the notice a
// sequential re-run produces. The partition exists either way, which is the
// outcome the statement was asking for.
func isDuplicateTableErr(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.DuplicateTable
}
// Partitioner manages daily time-based partition maintenance for an
// existing PARTITION BY RANGE (created_at) parent table.
//
// It does NOT create the parent partitioned table itself — the parent's
// column list is schema-specific and must be created by the calling
// broker before any Partitioner methods are called. Partitioner only
// handles the ongoing maintenance:
//
// - EnsureLookaheadPartitions creates today's partition and up to
// LookaheadDays future daily partitions via CREATE TABLE IF NOT EXISTS
// ... PARTITION OF. Safe to call repeatedly. Used both by the caller
// at schema init time and by Run on a periodic ticker.
//
// - DropOldPartitions lists child partitions via pg_inherits, parses
// the date from the partition name suffix, and drops any partition
// older than (now - RetentionDays). Errors on individual drops are
// logged via ErrorFn rather than returned, so a failed drop does not
// prevent the cleanup pass from attempting the remaining partitions.
//
// - Run starts a ticker that calls both methods on each tick.
//
// Partition naming convention: {ParentTable}_{YYYY}_{MM}_{DD}. Partitions
// that don't match this convention are silently skipped by DropOldPartitions
// — they are assumed to be user-managed and are not subject to automatic
// cleanup.
type Partitioner struct {
// Pool is the pgxpool used for all partition maintenance SQL.
// Typically, the primary pool (DDL must target the primary).
Pool *pgxpool.Pool
// ParentTable is the unquoted name of the parent partitioned table
// (e.g. "cf_map_stream").
ParentTable string
// CleanupInterval is the ticker interval for Run.
CleanupInterval time.Duration
// LookaheadDays controls how many future daily partitions to pre-create.
// 0 means only today's partition.
LookaheadDays int
// RetentionDays controls how old a partition must be before
// DropOldPartitions removes it. Partitions dated strictly before
// (now - RetentionDays) are dropped.
RetentionDays int
// ErrorFn is called for non-ctx errors that do not abort the pass
// (e.g. per-partition drop failures, listing failures in the
// cleanup path). Must be safe for concurrent use.
ErrorFn func(msg string, err error)
}
// EnsureLookaheadPartitions creates today's daily partition and up to
// LookaheadDays future daily partitions as PARTITION OF ParentTable.
// The CREATE TABLE IF NOT EXISTS form makes this safe to call repeatedly, and
// tolerating 42P07 (plus retrying the other concurrent-DDL conflicts) makes it
// safe to call concurrently: every node runs the partitioner, so the same
// partition is routinely created by two of them at once.
//
// Boundaries are written as fully-qualified UTC timestamps (e.g.
// '2026-04-23 00:00:00+00'). Using a bare DATE/date-text literal would
// bind the boundary to the PostgreSQL session's timezone: a server
// configured for Asia/Nicosia would treat '2026-04-23' as
// 2026-04-22 21:00 UTC, producing partitions that are offset from the
// UTC calendar day and don't cover rows inserted in the last hours of a
// UTC day.
func (p *Partitioner) EnsureLookaheadPartitions(ctx context.Context) error {
now := time.Now().UTC()
for d := 0; d <= p.LookaheadDays; d++ {
day := now.AddDate(0, 0, d)
nextDay := day.AddDate(0, 0, 1)
partName := fmt.Sprintf("%s_%s", p.ParentTable, day.Format("2006_01_02"))
stmt := fmt.Sprintf(
`CREATE TABLE IF NOT EXISTS %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')`,
partName, p.ParentTable,
day.Format("2006-01-02 00:00:00+00"),
nextDay.Format("2006-01-02 00:00:00+00"),
)
// Nodes that start together run this at the same moment, so the same
// concurrent-DDL conflicts the schema batch retries apply here too —
// and this runs on the startup path (EnsureSchema pre-creates the
// lookahead), where a bare failure means the node refuses to start.
// 42P07 is the one conflict that needs no retry: the partition exists,
// which is the outcome the statement was asking for.
err := pgschema.RetrySchemaExec(ctx, func(ctx context.Context) error {
_, err := p.Pool.Exec(ctx, stmt)
if isDuplicateTableErr(err) {
return nil
}
return err
})
if err != nil {
return fmt.Errorf("create partition %s: %w", partName, err)
}
}
return nil
}
// DropOldPartitions lists child partitions of ParentTable via pg_inherits,
// parses the date from each partition name, and drops partitions dated
// before (now - RetentionDays). Errors on individual drops (or the list
// query itself) are logged via ErrorFn rather than returned.
//
// When RetentionDays is 0 or negative, this is a no-op: the caller wants
// "lookahead only, never drop" semantics. Without this guard, RetentionDays=0
// would compute cutoff = now, dropping every partition dated strictly before
// today (i.e. yesterday and older) — equivalent to a 1-day retention, not
// "no retention".
func (p *Partitioner) DropOldPartitions(ctx context.Context) {
if p.RetentionDays <= 0 {
return
}
cutoff := time.Now().UTC().AddDate(0, 0, -p.RetentionDays)
// Filter by current_schema() so that two Centrifugo clusters (or other
// software) sharing the same PostgreSQL instance but using different
// search_path schemas don't accidentally drop each other's partitions.
rows, err := p.Pool.Query(ctx, `
SELECT n.nspname, c.relname
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class pp ON pp.oid = i.inhparent
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE pp.relname = $1 AND n.nspname = current_schema()
`, p.ParentTable)
if err != nil {
// Context cancellation (shutdown mid-cleanup) is normal; only genuine
// query failures should surface as errors.
if !isShutdownErr(err) {
p.ErrorFn("error listing partitions for cleanup", err)
}
return
}
defer rows.Close()
type partition struct{ schema, name string }
var partitions []partition
for rows.Next() {
var pt partition
if err := rows.Scan(&pt.schema, &pt.name); err != nil {
continue
}
partitions = append(partitions, pt)
}
// Close rows before issuing further queries on the same pool.
rows.Close()
for _, pt := range partitions {
partDate, ok := parsePartitionDate(pt.name, p.ParentTable)
if !ok {
continue
}
if partDate.Before(cutoff) {
qualified := quoteIdent(pt.schema) + "." + quoteIdent(pt.name)
if _, err := p.Pool.Exec(ctx, "DROP TABLE IF EXISTS "+qualified); err != nil {
if !isShutdownErr(err) {
p.ErrorFn("error dropping old partition "+qualified, err)
}
}
}
}
}
// quoteIdent returns a double-quoted PostgreSQL identifier with any embedded
// double-quotes escaped by doubling them, per the SQL standard.
func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// Run starts a ticker that calls EnsureLookaheadPartitions and
// DropOldPartitions on each CleanupInterval tick. Returns when ctx is
// cancelled or closeCh is closed.
func (p *Partitioner) Run(ctx context.Context, closeCh <-chan struct{}) {
ticker := time.NewTicker(p.CleanupInterval)
defer ticker.Stop()
for {
select {
case <-closeCh:
return
case <-ctx.Done():
return
case <-ticker.C:
if err := p.EnsureLookaheadPartitions(ctx); err != nil {
if !isShutdownErr(err) {
p.ErrorFn("error ensuring lookahead partitions", err)
}
}
p.DropOldPartitions(ctx)
}
}
}
// parsePartitionDate parses the date suffix from a child partition name
// of the form {ParentTable}_{YYYY}_{MM}_{DD}. Returns (time, true) on
// success; (zero, false) on any parse failure (too-short name, non-date
// suffix, invalid calendar date, etc.). Failures are silent — they
// indicate a partition that doesn't follow the expected naming convention
// and should be left alone by automatic cleanup.
//
// The parentTable argument is currently unused by the parser (the date
// suffix is interpreted positionally as the last three underscore-separated
// components). It is retained in the signature so future refinements can
// validate the prefix without a breaking change.
func parsePartitionDate(partName, _ string) (time.Time, bool) {
parts := strings.Split(partName, "_")
if len(parts) < 3 {
return time.Time{}, false
}
dateStr := strings.Join(parts[len(parts)-3:], "-")
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return time.Time{}, false
}
return t, true
}

View File

@@ -0,0 +1,253 @@
package pgoutbox
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5/pgconn"
)
func TestParsePartitionDate_Valid(t *testing.T) {
cases := []struct {
name string
partName string
parent string
wantYear int
wantMonth time.Month
wantDay int
}{
{
name: "map stream parent",
partName: "cf_map_stream_2025_12_31",
parent: "cf_map_stream",
wantYear: 2025,
wantMonth: time.December,
wantDay: 31,
},
{
name: "binary map stream parent",
partName: "cf_binary_map_stream_2026_01_01",
parent: "cf_binary_map_stream",
wantYear: 2026,
wantMonth: time.January,
wantDay: 1,
},
{
name: "future stream broker parent",
partName: "cf_stream_history_2024_06_15",
parent: "cf_stream_history",
wantYear: 2024,
wantMonth: time.June,
wantDay: 15,
},
{
name: "single-word parent",
partName: "events_2025_03_07",
parent: "events",
wantYear: 2025,
wantMonth: time.March,
wantDay: 7,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := parsePartitionDate(tc.partName, tc.parent)
if !ok {
t.Fatalf("parsePartitionDate(%q, %q) = _, false; want ok=true", tc.partName, tc.parent)
}
if got.Year() != tc.wantYear || got.Month() != tc.wantMonth || got.Day() != tc.wantDay {
t.Errorf("parsePartitionDate(%q, %q) = %v, want %d-%02d-%02d",
tc.partName, tc.parent, got, tc.wantYear, int(tc.wantMonth), tc.wantDay)
}
})
}
}
func TestParsePartitionDate_Invalid(t *testing.T) {
cases := []struct {
name string
partName string
parent string
}{
{"empty name", "", "cf_map_stream"},
{"no date suffix", "cf_map_stream", "cf_map_stream"},
{"only one component", "stream", "stream"},
{"short suffix", "cf_map_stream_abc", "cf_map_stream"},
{"non-date suffix", "cf_map_stream_foo_bar_baz", "cf_map_stream"},
{"invalid month", "cf_map_stream_2025_13_01", "cf_map_stream"},
{"invalid day in february", "cf_map_stream_2025_02_30", "cf_map_stream"},
{"letters in date", "cf_map_stream_abcd_ef_gh", "cf_map_stream"},
{"too few date components", "cf_map_stream_2025_01", "cf_map_stream"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := parsePartitionDate(tc.partName, tc.parent)
if ok {
t.Errorf("parsePartitionDate(%q, %q) = %v, true; want ok=false", tc.partName, tc.parent, got)
}
if !got.IsZero() {
t.Errorf("parsePartitionDate(%q, %q) returned non-zero time on failure: %v", tc.partName, tc.parent, got)
}
})
}
}
func TestPartitioner_ShutdownViaCloseCh(t *testing.T) {
p := &Partitioner{
Pool: nil, // never dereferenced because closeCh is already closed when Run starts
ParentTable: "test",
CleanupInterval: time.Hour,
LookaheadDays: 1,
RetentionDays: 1,
ErrorFn: func(msg string, err error) {},
}
closeCh := make(chan struct{})
close(closeCh)
done := make(chan struct{})
go func() {
defer close(done)
p.Run(context.Background(), closeCh)
}()
select {
case <-done:
case <-time.After(testRunTimeout):
t.Fatal("Partitioner.Run did not return within timeout")
}
}
func TestPartitioner_ShutdownViaContextCancel(t *testing.T) {
p := &Partitioner{
Pool: nil,
ParentTable: "test",
CleanupInterval: time.Hour,
LookaheadDays: 1,
RetentionDays: 1,
ErrorFn: func(msg string, err error) {},
}
ctx, cancel := context.WithCancel(context.Background())
closeCh := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
p.Run(ctx, closeCh)
}()
// Give Run a moment to enter the select, then cancel.
time.Sleep(10 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(testRunTimeout):
t.Fatal("Partitioner.Run did not return within timeout")
}
}
// TestPartitioner_DropOldPartitions_RetentionZero_NoOp verifies that when
// RetentionDays is 0, DropOldPartitions returns immediately without touching
// the pool. Without the early-return guard, the method would compute
// cutoff = now and try to query/drop everything before today — equivalent
// to a 1-day retention, not the intended "never drop" semantic.
//
// This test passes a nil Pool: if the guard is removed, the test would
// panic on Pool.Query.
func TestPartitioner_DropOldPartitions_RetentionZero_NoOp(t *testing.T) {
cases := []struct {
name string
retentionDays int
}{
{"zero", 0},
{"negative", -1},
{"large negative", -365},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
errFnCalled := false
p := &Partitioner{
Pool: nil, // would NPE if guard is missing
ParentTable: "test",
RetentionDays: tc.retentionDays,
ErrorFn: func(msg string, err error) { errFnCalled = true },
}
// Should be a no-op.
p.DropOldPartitions(context.Background())
if errFnCalled {
t.Errorf("ErrorFn unexpectedly called for RetentionDays=%d", tc.retentionDays)
}
})
}
}
// TestPartitioner_DropOldPartitions_RetentionPositive_AttemptsQuery verifies
// that with a positive RetentionDays, the method does try to query the pool
// (and panics on nil) — i.e. the early-return guard only fires for <= 0.
// This is the negative-case companion to TestPartitioner_DropOldPartitions_RetentionZero_NoOp.
func TestPartitioner_DropOldPartitions_RetentionPositive_AttemptsQuery(t *testing.T) {
p := &Partitioner{
Pool: nil, // intentional — we expect the panic
ParentTable: "test",
RetentionDays: 1,
ErrorFn: func(msg string, err error) {},
}
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic on nil Pool with positive RetentionDays, got none — guard may be too aggressive")
}
}()
p.DropOldPartitions(context.Background())
}
func TestIsDuplicateTableErr(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{
name: "duplicate table from a concurrent creator",
err: &pgconn.PgError{Code: pgerrcode.DuplicateTable, Message: `relation "cf_stream_history_2026_09_11" already exists`},
want: true,
},
{
name: "wrapped duplicate table",
err: fmt.Errorf("create partition: %w", &pgconn.PgError{Code: pgerrcode.DuplicateTable}),
want: true,
},
{
name: "a different server error is still a failure",
err: &pgconn.PgError{Code: pgerrcode.InsufficientPrivilege, Message: "permission denied"},
want: false,
},
{
name: "a non-server error is still a failure",
err: errors.New("connection reset"),
want: false,
},
{
name: "no error",
err: nil,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isDuplicateTableErr(tc.err); got != tc.want {
t.Fatalf("isDuplicateTableErr(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}

137
internal/pgoutbox/worker.go Normal file
View File

@@ -0,0 +1,137 @@
package pgoutbox
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Worker runs the plain outbox poll loop for a single shard.
//
// At startup Worker calls InitCursor once (retrying in-place on error
// until success or shutdown). Then it drains ProcessBatch until processed
// reaches zero, and waits on NotifyCh / PollInterval / shutdown signals
// before draining again.
//
// Error policy: a ProcessBatch error is logged via ErrorFn and the worker
// falls through to the idle wait, then resumes on the next iteration. This
// is log-and-continue semantics — a transient query error should not cause
// the worker to stop. For fail-fast semantics that drops an advisory lock,
// use LockWorker instead.
type Worker struct {
// Pool is the pgxpool used for cursor bootstrap and batch polling.
// Typically a read-replica pool when replicas are configured.
Pool *pgxpool.Pool
// ShardIDs is the set of shard ids this worker owns. Passed through
// to ProcessBatch so the callback can scope its SELECT WHERE clause.
// Typically a single-element slice equal to this worker's index.
ShardIDs []int
// PollInterval is the idle wait between drain passes when NotifyCh
// is not set or does not fire.
PollInterval time.Duration
// NotifyCh is an optional wakeup channel driven by a LISTEN/NOTIFY
// loop. A non-blocking send on this channel wakes the idle wait.
// nil is fine — a nil channel in a select case is runtime-inert
// (blocks forever, never fires). NotifyCh must never be closed
// by the sender; see package doc for the invariant.
NotifyCh <-chan struct{}
// InitCursor is called once at startup (with in-place retry on error)
// to bootstrap the cursor value. Typically implemented as
// SELECT COALESCE(MAX(id), 0) FROM <stream_table> on the Pool.
InitCursor func(ctx context.Context, pool *pgxpool.Pool) (int64, error)
// ProcessBatch fetches and delivers the next batch of rows whose id
// is greater than cursor, filtered to the given shards. Returns the
// number of rows processed and the maximum id seen. Returning
// processed == 0 signals idle (the worker goes to wait).
ProcessBatch func(ctx context.Context, pool *pgxpool.Pool, cursor int64, shardIDs []int) (processed int, maxID int64, err error)
// ErrorFn is called for non-ctx errors. Must be safe for concurrent use.
ErrorFn func(msg string, err error)
}
// Run starts the poll loop and returns when ctx is cancelled or closeCh is
// closed. Run is intended to be called in its own goroutine.
func (w *Worker) Run(ctx context.Context, closeCh <-chan struct{}) {
cursor, ok := w.initCursorWithRetry(ctx, closeCh)
if !ok {
return
}
for {
select {
case <-closeCh:
return
case <-ctx.Done():
return
default:
}
// Drain batches until idle.
idle := true
for {
processed, maxID, err := w.ProcessBatch(ctx, w.Pool, cursor, w.ShardIDs)
if err != nil {
if ctx.Err() != nil {
return
}
w.ErrorFn("outbox worker: process batch", err)
break
}
if processed == 0 {
break
}
if maxID > cursor {
cursor = maxID
}
idle = false
}
// If we made progress, loop immediately to try for more.
if !idle {
continue
}
// Idle wait — NotifyCh may be nil (inert), time.After provides the
// fallback poll interval, and closeCh/ctx.Done exit the worker.
select {
case <-closeCh:
return
case <-ctx.Done():
return
case <-w.NotifyCh:
case <-time.After(w.PollInterval):
}
}
}
// initCursorWithRetry calls InitCursor in a loop, retrying with a 1s delay
// on any non-ctx error. Returns (cursor, true) on success, or (0, false)
// if the context was cancelled or closeCh was closed before a successful
// init. Unlike the previous self-respawn pattern in pgmapbroker, this
// retries in-place in the current goroutine.
func (w *Worker) initCursorWithRetry(ctx context.Context, closeCh <-chan struct{}) (int64, bool) {
const retryDelay = time.Second
for {
cursor, err := w.InitCursor(ctx, w.Pool)
if err == nil {
return cursor, true
}
if ctx.Err() != nil {
return 0, false
}
w.ErrorFn("outbox worker: init cursor", err)
select {
case <-closeCh:
return 0, false
case <-ctx.Done():
return 0, false
case <-time.After(retryDelay):
}
}
}

View File

@@ -0,0 +1,373 @@
package pgoutbox
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// These tests exercise Worker's lifecycle, error handling, and cursor
// advancement using callback stubs. They do not require a running
// Postgres — Pool is passed as nil because the stub callbacks never
// dereference it.
const testRunTimeout = 2 * time.Second
// runWorkerAsync starts w.Run in a goroutine and returns a channel that
// closes when Run returns.
func runWorkerAsync(ctx context.Context, closeCh <-chan struct{}, w *Worker) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
w.Run(ctx, closeCh)
}()
return done
}
func expectReturn(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(testRunTimeout):
t.Fatal("worker did not return within timeout")
}
}
func newTestWorker(process func(context.Context, *pgxpool.Pool, int64, []int) (int, int64, error)) *Worker {
return &Worker{
Pool: nil,
ShardIDs: []int{0},
PollInterval: 10 * time.Millisecond,
NotifyCh: nil,
InitCursor: func(ctx context.Context, _ *pgxpool.Pool) (int64, error) {
return 0, nil
},
ProcessBatch: process,
ErrorFn: func(msg string, err error) {},
}
}
func TestWorker_ShutdownViaCloseCh(t *testing.T) {
w := newTestWorker(func(ctx context.Context, _ *pgxpool.Pool, _ int64, _ []int) (int, int64, error) {
return 0, 0, nil
})
closeCh := make(chan struct{})
done := runWorkerAsync(context.Background(), closeCh, w)
close(closeCh)
expectReturn(t, done)
}
func TestWorker_ShutdownViaContext(t *testing.T) {
w := newTestWorker(func(ctx context.Context, _ *pgxpool.Pool, _ int64, _ []int) (int, int64, error) {
return 0, 0, nil
})
ctx, cancel := context.WithCancel(context.Background())
closeCh := make(chan struct{})
done := runWorkerAsync(ctx, closeCh, w)
cancel()
expectReturn(t, done)
}
func TestWorker_DrainsUntilIdle(t *testing.T) {
type call struct {
cursor int64
}
var (
mu sync.Mutex
calls []call
)
// Return (5, 42, nil), then (3, 50, nil), then (0, 0, nil) forever.
var callIdx int32
w := newTestWorker(func(ctx context.Context, _ *pgxpool.Pool, cursor int64, _ []int) (int, int64, error) {
mu.Lock()
calls = append(calls, call{cursor: cursor})
mu.Unlock()
idx := atomic.AddInt32(&callIdx, 1)
switch idx {
case 1:
return 5, 42, nil
case 2:
return 3, 50, nil
default:
return 0, 0, nil
}
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
closeCh := make(chan struct{})
done := runWorkerAsync(ctx, closeCh, w)
// Wait for at least 3 calls to have happened.
deadline := time.Now().Add(testRunTimeout)
for time.Now().Before(deadline) {
mu.Lock()
n := len(calls)
mu.Unlock()
if n >= 3 {
break
}
time.Sleep(5 * time.Millisecond)
}
close(closeCh)
expectReturn(t, done)
mu.Lock()
defer mu.Unlock()
if len(calls) < 3 {
t.Fatalf("expected at least 3 ProcessBatch calls, got %d", len(calls))
}
if calls[0].cursor != 0 {
t.Errorf("first call cursor = %d, want 0", calls[0].cursor)
}
if calls[1].cursor != 42 {
t.Errorf("second call cursor = %d, want 42", calls[1].cursor)
}
if calls[2].cursor != 50 {
t.Errorf("third call cursor = %d, want 50", calls[2].cursor)
}
}
func TestWorker_IdleWaitWakesFromNotifyCh(t *testing.T) {
var callCount int32
var first sync.Once
firstCallDone := make(chan struct{})
w := newTestWorker(func(ctx context.Context, _ *pgxpool.Pool, _ int64, _ []int) (int, int64, error) {
n := atomic.AddInt32(&callCount, 1)
if n == 1 {
first.Do(func() { close(firstCallDone) })
}
return 0, 0, nil
})
w.PollInterval = 10 * time.Second // long enough that only NotifyCh can wake it
notifyCh := make(chan struct{}, 1)
w.NotifyCh = notifyCh
closeCh := make(chan struct{})
done := runWorkerAsync(context.Background(), closeCh, w)
// Wait for the first ProcessBatch call to complete.
select {
case <-firstCallDone:
case <-time.After(testRunTimeout):
t.Fatal("first ProcessBatch call did not happen")
}
// Send a notification — should wake the idle wait and cause another call.
notifyCh <- struct{}{}
// Poll for callCount >= 2.
deadline := time.Now().Add(testRunTimeout)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&callCount) >= 2 {
break
}
time.Sleep(5 * time.Millisecond)
}
if got := atomic.LoadInt32(&callCount); got < 2 {
t.Fatalf("expected at least 2 ProcessBatch calls after notify, got %d", got)
}
close(closeCh)
expectReturn(t, done)
}
func TestWorker_IdleWaitWakesFromPollInterval(t *testing.T) {
var callCount int32
w := newTestWorker(func(ctx context.Context, _ *pgxpool.Pool, _ int64, _ []int) (int, int64, error) {
atomic.AddInt32(&callCount, 1)
return 0, 0, nil
})
w.PollInterval = 5 * time.Millisecond
w.NotifyCh = nil // rely entirely on the timer
closeCh := make(chan struct{})
done := runWorkerAsync(context.Background(), closeCh, w)
time.Sleep(100 * time.Millisecond)
close(closeCh)
expectReturn(t, done)
if got := atomic.LoadInt32(&callCount); got < 5 {
t.Fatalf("expected at least 5 ProcessBatch calls in 100ms with 5ms poll interval, got %d", got)
}
}
func TestWorker_ProcessBatchErrorLoggedAndRetried(t *testing.T) {
var callCount int32
var errorCalls int32
wantErr := errors.New("boom")
var (
errMu sync.Mutex
loggedMsgs []string
)
w := newTestWorker(func(ctx context.Context, _ *pgxpool.Pool, _ int64, _ []int) (int, int64, error) {
n := atomic.AddInt32(&callCount, 1)
if n == 1 {
return 0, 0, wantErr
}
return 0, 0, nil
})
w.ErrorFn = func(msg string, err error) {
atomic.AddInt32(&errorCalls, 1)
errMu.Lock()
loggedMsgs = append(loggedMsgs, msg)
errMu.Unlock()
}
closeCh := make(chan struct{})
done := runWorkerAsync(context.Background(), closeCh, w)
// Wait for at least 2 calls (error + retry) and 1 error log.
deadline := time.Now().Add(testRunTimeout)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&callCount) >= 2 && atomic.LoadInt32(&errorCalls) >= 1 {
break
}
time.Sleep(5 * time.Millisecond)
}
close(closeCh)
expectReturn(t, done)
if got := atomic.LoadInt32(&callCount); got < 2 {
t.Errorf("expected ProcessBatch retried after error, got %d calls", got)
}
if got := atomic.LoadInt32(&errorCalls); got < 1 {
t.Errorf("expected ErrorFn called at least once, got %d", got)
}
errMu.Lock()
defer errMu.Unlock()
if len(loggedMsgs) == 0 || loggedMsgs[0] != "outbox worker: process batch" {
t.Errorf("unexpected error log message: %v", loggedMsgs)
}
}
func TestWorker_ProcessBatchErrorDuringCtxCancel(t *testing.T) {
var errorCalls int32
ctx, cancel := context.WithCancel(context.Background())
w := newTestWorker(func(ctx context.Context, _ *pgxpool.Pool, _ int64, _ []int) (int, int64, error) {
// Cancel on first call, then return a ctx-derived error.
cancel()
return 0, 0, ctx.Err()
})
w.ErrorFn = func(msg string, err error) {
atomic.AddInt32(&errorCalls, 1)
}
closeCh := make(chan struct{})
done := runWorkerAsync(ctx, closeCh, w)
expectReturn(t, done)
if got := atomic.LoadInt32(&errorCalls); got != 0 {
t.Errorf("expected ErrorFn NOT called on ctx-derived error, got %d calls", got)
}
}
func TestWorker_InitCursorError_RetriesInPlace(t *testing.T) {
var initCalls int32
var processCalled int32
var seenCursor atomic.Int64
wantErr := errors.New("init boom")
const successCursor int64 = 12345
var errorCalls int32
w := newTestWorker(func(ctx context.Context, _ *pgxpool.Pool, cursor int64, _ []int) (int, int64, error) {
if atomic.CompareAndSwapInt32(&processCalled, 0, 1) {
seenCursor.Store(cursor)
}
return 0, 0, nil
})
w.InitCursor = func(ctx context.Context, _ *pgxpool.Pool) (int64, error) {
n := atomic.AddInt32(&initCalls, 1)
if n == 1 {
return 0, wantErr
}
return successCursor, nil
}
w.ErrorFn = func(msg string, err error) {
atomic.AddInt32(&errorCalls, 1)
}
// Use a very short "retry delay" by cheating: since the constant is 1s
// internally, we give the test a longer budget to observe retry.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
closeCh := make(chan struct{})
done := runWorkerAsync(ctx, closeCh, w)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&processCalled) == 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
close(closeCh)
expectReturn(t, done)
if got := atomic.LoadInt32(&initCalls); got < 2 {
t.Errorf("expected InitCursor called at least twice (retry), got %d", got)
}
if got := atomic.LoadInt32(&errorCalls); got < 1 {
t.Errorf("expected ErrorFn called at least once for init cursor error, got %d", got)
}
if got := atomic.LoadInt32(&processCalled); got != 1 {
t.Errorf("expected ProcessBatch called after successful init, got processCalled=%d", got)
}
if got := seenCursor.Load(); got != successCursor {
t.Errorf("expected ProcessBatch to see cursor=%d from successful init, got %d", successCursor, got)
}
}
func TestWorker_InitCursorSetsInitialCursor(t *testing.T) {
const initialCursor int64 = 9876
var seen atomic.Int64
var observed int32
w := newTestWorker(func(ctx context.Context, _ *pgxpool.Pool, cursor int64, _ []int) (int, int64, error) {
if atomic.CompareAndSwapInt32(&observed, 0, 1) {
seen.Store(cursor)
}
return 0, 0, nil
})
w.InitCursor = func(ctx context.Context, _ *pgxpool.Pool) (int64, error) {
return initialCursor, nil
}
closeCh := make(chan struct{})
done := runWorkerAsync(context.Background(), closeCh, w)
deadline := time.Now().Add(testRunTimeout)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&observed) == 1 {
break
}
time.Sleep(5 * time.Millisecond)
}
close(closeCh)
expectReturn(t, done)
if got := seen.Load(); got != initialCursor {
t.Errorf("first ProcessBatch call cursor = %d, want %d", got, initialCursor)
}
}