Files
upstream-centrifugo/internal/pgstreambroker/pgstreambroker.go
upstream-mirror b352a9a30d
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s
snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:17:21 +05:00

661 lines
22 KiB
Go

// Package pgstreambroker implements centrifuge.Broker using PostgreSQL as
// the backing store for stream subscriptions. It provides:
//
// - Transactional publishing: applications can publish in the same SQL
// transaction as their business writes, eliminating the dual-write problem.
// - Per-channel HistoryTTL and HistorySize honored at read time via the
// two-TTL meta model (matches Redis broker semantics).
// - At-most-once live delivery via the polling outbox; reliable recovery
// via History() reads.
// - Always-partitioned schema with daily partition rolling for vacuum-free
// bulk cleanup. PartitionRetentionDays defaults to 7 days; the partition
// retention worker (pgoutbox.Partitioner) drops old partitions whole.
//
// PG version requirement: PostgreSQL 13 or later (for partitioned table
// features and INSERT ... ON CONFLICT DO UPDATE on partitioned tables).
//
// See internal/pgstreambroker/internal/sql/schema.sql for the underlying
// schema and SQL functions.
package pgstreambroker
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/pgschema"
"github.com/centrifugal/centrifuge"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
//go:embed internal/sql/schema.sql
var postgresSchemaTemplate string
// schemaVersion is the current schema version. Bump when adding migrations.
var schemaVersion = 1
// schemaMigrations maps target version to a migration SQL TEMPLATE using the
// same placeholders as `schema.sql` (`__PREFIX__`, `__DATA_TYPE__`,
// `__STREAM_TABLE__`). EnsureSchema renders the template once per variant
// (jsonb + binary) and runs both rendered SQLs in a single transaction with
// atomic schema_version bumps across both prefix tables — see
// pgmapbroker.schemaMigrations for the full contract. Empty for v1.
var schemaMigrations = map[int]string{}
func init() {
pgschema.ValidateMigrationMap("pgstreambroker", schemaVersion, schemaMigrations)
}
// renderSchemaTemplate substitutes the placeholders this broker recognises
// in any template string. Reused by renderSchema (for the embedded baseline)
// and by migrationVariants (for each registered migration). Keeping this in
// one place means migration SQL works for any user-configured TablePrefix
// and for both jsonb/binary variants without the migration author writing
// the same statement twice.
//
// __PREFIX__ → e.g. "cf_stream_" (includes trailing underscore)
// __DATA_TYPE__ → "JSONB" or "BYTEA"
// __STREAM_TABLE__ → e.g. "cf_stream" (prefix without trailing underscore)
func renderSchemaTemplate(template, prefix string, binary bool) string {
dataType := "JSONB"
if binary {
dataType = "BYTEA"
}
streamTable := strings.TrimRight(prefix, "_")
return strings.NewReplacer(
"__STREAM_TABLE__", streamTable,
"__PREFIX__", prefix,
"__DATA_TYPE__", dataType,
).Replace(template)
}
func renderSchema(prefix string, binary bool) string {
return renderSchemaTemplate(postgresSchemaTemplate, prefix, binary)
}
// splitSchemaSQL splits the schema SQL into DDL (tables+indexes) and function
// definitions. They must be executed as separate transactions to avoid deadlocks
// with concurrent function executions during rolling deploys.
func splitSchemaSQL(sql string) (ddl, funcs string) {
const marker = "CREATE OR REPLACE FUNCTION"
i := strings.Index(sql, marker)
if i < 0 {
return sql, ""
}
return sql[:i], sql[i:]
}
// SchemaObject identifies a database object referenced in a SchemaError.
type SchemaObject struct {
Type string // "table", "function", "index", "schema"
Name string
}
// SchemaError wraps a schema-related error with object and operation info.
type SchemaError struct {
Object SchemaObject
Op string // "create", "verify", etc.
Err error
}
func (e *SchemaError) Error() string {
return fmt.Sprintf("postgres stream broker: %s %s %q: %v", e.Op, e.Object.Type, e.Object.Name, e.Err)
}
func (e *SchemaError) Unwrap() error {
return e.Err
}
// pgNames holds precomputed table/function/notify-channel names. The
// jsonbPrefix and binaryPrefix are kept so EnsureSchema can iterate both
// variants regardless of the active BinaryData mode.
//
// jsonbPrefix = userPrefix + "_stream_" (e.g. "cf_stream_")
// binaryPrefix = userPrefix + "_binary_stream_" (e.g. "cf_binary_stream_")
//
// The history/meta/idempotency/... fields are computed from the *active*
// prefix (jsonbPrefix when BinaryData is false, binaryPrefix when true).
type pgNames struct {
stream, meta, idempotency, shardLock, schemaVersion string // table names (active variant)
publish, publishStrict, publishJoin, publishLeave string // function names (active variant)
removeHistory string
notifyChannel string
jsonbPrefix, binaryPrefix string
}
func newPgNames(userPrefix string, binary bool) pgNames {
jsonbPrefix := userPrefix + "_stream_"
binaryPrefix := userPrefix + "_binary_stream_"
p := jsonbPrefix
if binary {
p = binaryPrefix
}
return pgNames{
stream: strings.TrimRight(p, "_"), // cf_stream (not cf_stream_history)
meta: p + "meta",
idempotency: p + "idempotency",
shardLock: p + "shard_lock",
schemaVersion: p + "schema_version",
publish: p + "publish",
publishStrict: p + "publish_strict",
publishJoin: p + "publish_join",
publishLeave: p + "publish_leave",
removeHistory: p + "remove_history",
notifyChannel: p + "notify",
jsonbPrefix: jsonbPrefix,
binaryPrefix: binaryPrefix,
}
}
// durationToIntervalString converts a time.Duration to a PostgreSQL interval string.
func durationToIntervalString(d time.Duration) string {
ms := d.Milliseconds()
if ms > 0 {
return strconv.FormatInt(ms, 10) + " milliseconds"
}
return "1 milliseconds"
}
// hashtext is a simple polynomial hash used only for in-process read-replica
// routing. It only needs consistency within a process, not cross-process
// compatibility with PostgreSQL's hashtext() (which uses Jenkins lookup3 and is
// used separately inside SQL for shard assignment). It returns uint32 so the
// result is always non-negative and can be taken modulo NumShards directly (no
// abs, and no math.MinInt32 overflow edge case).
func hashtext(s string) uint32 {
var h uint32
for i := 0; i < len(s); i++ {
h = h*31 + uint32(s[i])
}
return h
}
// OutboxConfig configures outbox-based delivery.
type OutboxConfig struct {
// PollInterval is how often to poll for new history rows when idle.
// Default: 100ms.
PollInterval time.Duration
// BatchSize is the maximum number of rows to process per batch.
// Default: 1000.
BatchSize int
// AdvisoryLockBaseID is the base ID for PostgreSQL advisory locks used to
// claim shards when Broker fan-out is enabled. Lock ID = AdvisoryLockBaseID + shardID.
// Default: 726966531 (one above the map broker base to avoid collision).
AdvisoryLockBaseID int64
// AdvisoryLockRetryInterval is how often to retry advisory lock acquisition
// when Broker fan-out is enabled. Default: 5s.
AdvisoryLockRetryInterval time.Duration
}
// PostgresStreamBrokerConfig configures the PostgreSQL stream broker.
type PostgresStreamBrokerConfig struct {
// Name is an observability label included in metrics and logs.
Name string
// DSN is the primary PostgreSQL connection string for writes.
DSN string
// TLS is an optional TLS configuration applied to all pools (primary,
// replicas, notify). Use instead of embedding TLS params in the DSN.
TLS configtypes.TLSConfig
// PoolSize sets the maximum number of connections in the primary pool.
// Default: 32.
PoolSize int
// NumShards is the total number of shards for parallel delivery workers.
// Channels are distributed via hash(channel) % NumShards.
// Default: 8.
NumShards int
// CleanupInterval is how often the cleanup worker and partition worker tick.
// Default: 1m.
CleanupInterval time.Duration
// IdempotentResultTTL is the default TTL for idempotency cache entries.
// Default: 5m.
IdempotentResultTTL time.Duration
// Outbox configures the outbox-based delivery mode.
Outbox OutboxConfig
// BinaryData uses BYTEA columns instead of JSONB for data fields.
// Set true if data payloads are not valid JSON (binary/protobuf).
BinaryData bool
// TablePrefix is the user-facing namespace prefix. Default "cf".
// The broker appends "_stream_" or "_binary_stream_" internally, so
// the default produces:
//
// cf_stream_history, cf_stream_meta, cf_stream_idempotency, cf_stream_publish, ...
//
// and their cf_binary_stream_* counterparts for the binary variant.
TablePrefix string
// StreamRetention is the safety floor for HistoryMetaTTL when neither
// PublishOptions nor node config sets it. Default: 24h. Guarantees that
// every channel meta row eventually expires (no NULL expires_at).
StreamRetention time.Duration
// UseNotify enables LISTEN/NOTIFY for low-latency outbox wakeup.
// When false (default), outbox worker uses PollInterval-based polling only.
UseNotify bool
// NotifyDSN is an optional separate connection string used exclusively for
// the LISTEN connection when UseNotify is true. Set this to a direct
// PostgreSQL URL (bypassing PGBouncer) when DSN points at a PGBouncer
// endpoint — PGBouncer transaction pooling mode is incompatible with
// LISTEN/NOTIFY. If empty, the primary DSN pool is used (fine for direct
// PostgreSQL connections).
NotifyDSN string
// ReplicaDSN is a list of read-replica connection strings. When set,
// outbox cursor bootstraps go to the replica routing instead of the
// primary. Default: empty (all reads go to primary).
ReplicaDSN []string
// ReplicaPoolSize sets max connections per replica pool.
// Default: same as PoolSize.
ReplicaPoolSize int
// Broker is an optional Broker (e.g. RedisBroker) for PUB/SUB fan-out.
// When set, outbox workers use advisory locks (one worker per shard
// across all nodes) and publish via the inner Broker instead of
// HandlePublication directly. PublishJoin/PublishLeave bypass PG
// entirely and go through the inner broker.
Broker centrifuge.Broker
// PartitionLookaheadDays controls how many future daily partitions to
// pre-create. Required > 0 so writes don't fail at the day rollover.
// Default: 2 (gives a 48-hour safety window).
PartitionLookaheadDays int
// PartitionRetentionDays controls how old a partition can be before
// it gets dropped whole by the partition retention worker.
//
// Set to 0 for unlimited retention (the pgoutbox.Partitioner guard
// treats RetentionDays <= 0 as "never drop"; old partitions accumulate).
//
// The OSS configtypes default is 7 (via the struct-tag default). Direct
// Go-level construction (e.g. tests) must set this explicitly — there
// is no implicit default in setDefaults so that 0 (unlimited) survives.
PartitionRetentionDays int
}
func (c *PostgresStreamBrokerConfig) setDefaults() {
if c.PoolSize <= 0 {
c.PoolSize = 16
}
if c.NumShards <= 0 {
c.NumShards = 8
}
if c.CleanupInterval <= 0 {
c.CleanupInterval = time.Minute
}
if c.IdempotentResultTTL <= 0 {
c.IdempotentResultTTL = 5 * time.Minute
}
if c.StreamRetention <= 0 {
c.StreamRetention = 24 * time.Hour
}
c.TablePrefix = strings.TrimRight(c.TablePrefix, "_")
if c.TablePrefix == "" {
c.TablePrefix = "cf"
}
if c.Outbox.PollInterval <= 0 {
c.Outbox.PollInterval = 100 * time.Millisecond
}
if c.Outbox.BatchSize <= 0 {
c.Outbox.BatchSize = 1000
}
if c.Outbox.AdvisoryLockBaseID == 0 {
// Stream broker occupies [5_067_067_000, 5_067_067_000+NumShards).
// Map broker uses [4_067_067_000, 4_067_067_000+NumShards).
// Non-round bases reduce the chance of collision with other software.
c.Outbox.AdvisoryLockBaseID = 5_067_067_000
}
if c.Outbox.AdvisoryLockRetryInterval <= 0 {
c.Outbox.AdvisoryLockRetryInterval = 5 * time.Second
}
if c.ReplicaPoolSize <= 0 {
c.ReplicaPoolSize = c.PoolSize
}
if c.PartitionLookaheadDays <= 0 {
c.PartitionLookaheadDays = 2
}
// PartitionRetentionDays is intentionally NOT defaulted here. The
// configtypes layer (centrifugo config tag default:"7") handles the
// production default. Direct Go-level construction must set this
// explicitly: positive = days of retention, 0 = unlimited (the
// pgoutbox.Partitioner guard treats 0 as a no-op DROP). Defaulting
// to 7 here would make it impossible to express "unlimited retention"
// without a sentinel like -1.
}
// PostgresStreamBroker is a centrifuge.Broker implementation backed by PostgreSQL.
type PostgresStreamBroker struct {
node *centrifuge.Node
conf PostgresStreamBrokerConfig
names pgNames
pool *pgxpool.Pool
readPools []*pgxpool.Pool
notifyPool *pgxpool.Pool // Dedicated single-conn pool for LISTEN; nil = use pool
eventHandler centrifuge.BrokerEventHandler
closeCh chan struct{}
closeOnce sync.Once
running atomic.Bool
cancelCtx context.Context
cancelFunc context.CancelFunc
notifyCh chan struct{}
sampler *metricsSampler
// notifyListenerReady flips true after the LISTEN command succeeds on
// the notification listener's connection. Used by tests that depend on
// NOTIFY-driven delivery (typically with a long PollInterval) so they
// publish only after LISTEN is bound. Production tolerates the race
// because PollInterval is the fallback.
notifyListenerReady atomic.Bool
// workersInitialized[i] flips true once shard i's outbox LockWorker
// completes its first InitCursor (i.e., has snapshotted MAX(id) and is
// actively polling). Used by tests to publish only after the cursor
// snapshot is taken — otherwise a publish committed between lock
// acquisition and the snapshot would be observed by InitCursor and
// skipped. Only the LockWorker path sets this; left as nil for the
// non-fan-out Worker path (which uses a pre-init cursor synchronously
// in RegisterBrokerEventHandler and so doesn't have this race).
workersInitialized []atomic.Bool
}
var _ centrifuge.Broker = (*PostgresStreamBroker)(nil)
// NewPostgresStreamBroker constructs a new PostgresStreamBroker. Call EnsureSchema
// after construction and then RegisterBrokerEventHandler.
func NewPostgresStreamBroker(n *centrifuge.Node, conf PostgresStreamBrokerConfig) (*PostgresStreamBroker, error) {
conf.setDefaults()
if conf.DSN == "" {
return nil, errors.New("postgres stream broker: DSN is required")
}
if err := pgschema.ValidateTablePrefix("postgres stream broker", conf.TablePrefix); err != nil {
return nil, err
}
ctx := context.Background()
poolConfig, err := pgxpool.ParseConfig(conf.DSN)
if err != nil {
return nil, fmt.Errorf("postgres stream broker: parse config: %w", err)
}
poolConfig.MaxConns = int32(conf.PoolSize)
if conf.TLS.Enabled {
tlsCfg, err := conf.TLS.ToGoTLSConfig("postgres-stream-broker")
if err != nil {
return nil, fmt.Errorf("postgres stream broker: TLS config: %w", err)
}
poolConfig.ConnConfig.TLSConfig = tlsCfg
}
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("postgres stream broker: create pool: %w", err)
}
var readPools []*pgxpool.Pool
for i, dsn := range conf.ReplicaDSN {
rcfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
pool.Close()
for _, rp := range readPools {
rp.Close()
}
return nil, fmt.Errorf("postgres stream broker: parse replica %d config: %w", i, err)
}
rcfg.MaxConns = int32(conf.ReplicaPoolSize)
if conf.TLS.Enabled {
tlsCfg, err := conf.TLS.ToGoTLSConfig("postgres-stream-broker")
if err != nil {
pool.Close()
for _, rp := range readPools {
rp.Close()
}
return nil, fmt.Errorf("postgres stream broker: replica TLS config: %w", err)
}
rcfg.ConnConfig.TLSConfig = tlsCfg
}
rp, err := pgxpool.NewWithConfig(ctx, rcfg)
if err != nil {
pool.Close()
for _, p := range readPools {
p.Close()
}
return nil, fmt.Errorf("postgres stream broker: create replica %d pool: %w", i, err)
}
readPools = append(readPools, rp)
}
cancelCtx, cancelFunc := context.WithCancel(context.Background())
b := &PostgresStreamBroker{
node: n,
conf: conf,
names: newPgNames(conf.TablePrefix, conf.BinaryData),
pool: pool,
readPools: readPools,
closeCh: make(chan struct{}),
cancelCtx: cancelCtx,
cancelFunc: cancelFunc,
workersInitialized: make([]atomic.Bool, conf.NumShards),
}
b.sampler = newMetricsSampler(b)
if conf.UseNotify {
b.notifyCh = make(chan struct{}, 1)
if conf.NotifyDSN != "" {
nCfg, err := pgxpool.ParseConfig(conf.NotifyDSN)
if err != nil {
pool.Close()
for _, rp := range readPools {
rp.Close()
}
cancelFunc()
return nil, fmt.Errorf("postgres stream broker: parse notify DSN: %w", err)
}
nCfg.MaxConns = 1
if conf.TLS.Enabled {
tlsCfg, err := conf.TLS.ToGoTLSConfig("postgres-stream-broker")
if err != nil {
pool.Close()
for _, rp := range readPools {
rp.Close()
}
cancelFunc()
return nil, fmt.Errorf("postgres stream broker: notify TLS config: %w", err)
}
nCfg.ConnConfig.TLSConfig = tlsCfg
}
nPool, err := pgxpool.NewWithConfig(ctx, nCfg)
if err != nil {
pool.Close()
for _, rp := range readPools {
rp.Close()
}
cancelFunc()
return nil, fmt.Errorf("postgres stream broker: connect notify: %w", err)
}
b.notifyPool = nPool
}
}
return b, nil
}
// ReliableDelivery reports whether this broker guarantees no-gaps delivery to
// local subscribers, so the centrifuge node can skip periodic position sync
// requests. True when polling the PG outbox directly (shard lock guarantees
// commit order = id order, cursor-based polling reads every row). False when
// broker fan-out is enabled — the Redis/Nats fan-out leg can drop messages.
func (e *PostgresStreamBroker) ReliableDelivery() bool {
return e.conf.Broker == nil
}
// Close shuts down the broker.
func (e *PostgresStreamBroker) Close(ctx context.Context) error {
e.closeOnce.Do(func() {
e.cancelFunc()
close(e.closeCh)
if e.conf.Broker != nil {
if c, ok := e.conf.Broker.(centrifuge.Closer); ok {
_ = c.Close(ctx)
}
}
for _, rp := range e.readPools {
rp.Close()
}
if e.notifyPool != nil {
e.notifyPool.Close()
}
e.pool.Close()
})
return nil
}
// RegisterBrokerEventHandler registers the event handler and starts background workers.
func (e *PostgresStreamBroker) RegisterBrokerEventHandler(h centrifuge.BrokerEventHandler) error {
e.eventHandler = h
if e.running.Swap(true) {
return errors.New("postgres stream broker: already running")
}
// Pre-initialize the outbox cursor before launching worker goroutines so
// that any message published after RegisterBrokerEventHandler returns is
// guaranteed to be delivered. Without this, a goroutine scheduled late
// could call initOutboxCursor after a message was already inserted, see
// that ID as MAX(id), and silently skip it.
initialCursor, err := e.initOutboxCursor(e.cancelCtx, e.pool)
if err != nil {
e.logErrorMsg("pre-init outbox cursor", err)
initialCursor = 0
}
if e.conf.Broker != nil {
if err := e.conf.Broker.RegisterBrokerEventHandler(h); err != nil {
return fmt.Errorf("postgres stream broker: register inner broker: %w", err)
}
for i := 0; i < e.conf.NumShards; i++ {
go e.runOutboxWorkerWithLock(i, initialCursor)
}
} else {
if e.conf.UseNotify {
go e.runNotificationListener()
}
for i := 0; i < e.conf.NumShards; i++ {
go e.runOutboxWorker(i, initialCursor)
}
}
go e.runCleanupWorker()
go e.runPartitionWorker() // unconditional — schema is always partitioned
return nil
}
// Subscribe is a no-op when running without an inner broker (every node polls
// the outbox). With fanout enabled, delegates to the inner Broker.
func (e *PostgresStreamBroker) Subscribe(channels ...string) error {
if e.conf.Broker != nil {
return e.conf.Broker.Subscribe(channels...)
}
return nil
}
// Unsubscribe mirrors Subscribe.
func (e *PostgresStreamBroker) Unsubscribe(channels ...string) error {
if e.conf.Broker != nil {
return e.conf.Broker.Unsubscribe(channels...)
}
return nil
}
// getReadPool returns the appropriate pool for read queries. If allowCached
// is true and replicas are configured, routes by shard hash to a replica.
func (e *PostgresStreamBroker) getReadPool(channel string, allowCached bool) *pgxpool.Pool {
if !allowCached || len(e.readPools) == 0 {
return e.pool
}
shardID := int(hashtext(channel) % uint32(e.conf.NumShards))
replicaIdx := shardID % len(e.readPools)
return e.readPools[replicaIdx]
}
// dataParam wraps a []byte for use as a SQL parameter. With JSONB columns,
// wrap as json.RawMessage so pgx encodes it as JSON text. With BYTEA, pass
// as plain []byte.
func (e *PostgresStreamBroker) dataParam(b []byte) any {
if b == nil {
return nil
}
if !e.conf.BinaryData {
return json.RawMessage(b)
}
return b
}
// rawDataBytes reads a data column (JSONB or BYTEA depending on BinaryData)
// using the correct format-aware parser.
func (e *PostgresStreamBroker) rawDataBytes(a *byteArena, b []byte, format int16) []byte {
if e.conf.BinaryData {
return pgRawBytes(a, b, format)
}
return pgRawJSONBBytes(a, b, format)
}
func (e *PostgresStreamBroker) logErrorMsg(msg string, err error) {
log.Error().Err(err).Str("broker", e.conf.Name).Msg("postgres stream broker: " + msg)
}
// pgColFormatsFromRows extracts per-column wire format codes from pgx rows.
func pgColFormatsFromRows(rows pgx.Rows) pgColFormats {
descs := rows.FieldDescriptions()
fmts := make(pgColFormats, len(descs))
for i, d := range descs {
fmts[i] = d.Format
}
return fmts
}
// execSchemaWithRetry executes idempotent schema SQL, retrying the batch on
// the conflicts several nodes running the same DDL at once produce — see
// pgschema.RetrySchemaExec for the policy and for the invariant the SQL has
// to keep (one multi-statement Exec with no arguments, so the server runs it
// in a single implicit transaction that rolls back completely on failure).
func (e *PostgresStreamBroker) execSchemaWithRetry(ctx context.Context, sql string) error {
err := pgschema.RetrySchemaExec(ctx, func(ctx context.Context) error {
_, err := e.pool.Exec(ctx, sql)
return err
})
if err != nil {
return &SchemaError{
Object: SchemaObject{Type: "schema", Name: ""},
Op: "create",
Err: err,
}
}
return nil
}