snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
83
internal/pgoutbox/doc.go
Normal file
83
internal/pgoutbox/doc.go
Normal 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
|
||||
136
internal/pgoutbox/listener.go
Normal file
136
internal/pgoutbox/listener.go
Normal 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
|
||||
}
|
||||
}
|
||||
300
internal/pgoutbox/lock_worker.go
Normal file
300
internal/pgoutbox/lock_worker.go
Normal 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
|
||||
}
|
||||
}
|
||||
178
internal/pgoutbox/lock_worker_test.go
Normal file
178
internal/pgoutbox/lock_worker_test.go
Normal 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
|
||||
}
|
||||
251
internal/pgoutbox/partitioner.go
Normal file
251
internal/pgoutbox/partitioner.go
Normal 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
|
||||
}
|
||||
253
internal/pgoutbox/partitioner_test.go
Normal file
253
internal/pgoutbox/partitioner_test.go
Normal 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
137
internal/pgoutbox/worker.go
Normal 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):
|
||||
}
|
||||
}
|
||||
}
|
||||
373
internal/pgoutbox/worker_test.go
Normal file
373
internal/pgoutbox/worker_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user