Files
upstream-centrifugo/internal/pgmapbroker/pgmapbroker.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

2380 lines
81 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pgmapbroker
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/pgoutbox"
"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"
"github.com/rs/zerolog/log"
)
//go:embed internal/sql/schema.sql
var postgresSchemaTemplate string
// schemaVersion is the current schema version. Bump when adding migrations
// or when the schema.sql template changes in a way that requires re-running
// the CREATE OR REPLACE on existing installs.
var schemaVersion = 1
// schemaMigrations maps target version to a migration SQL TEMPLATE. Each
// template uses the same placeholders as `schema.sql`:
//
// __PREFIX__ → e.g. "cf_map_" or a user-configured custom prefix
// __DATA_TYPE__ → "JSONB" or "BYTEA" depending on broker variant
//
// EnsureSchema renders the template once per variant (jsonb + binary) and
// executes both rendered SQLs inside a SINGLE transaction that also bumps
// schema_version atomically across both prefix tables. Authors therefore
// write the migration once — neither variant nor the user's `table_prefix`
// option needs to be spelled out manually.
//
// Idempotent constructs (`ALTER ... IF NOT EXISTS`, `CREATE OR REPLACE`) are
// still preferred so that a partial deploy or operator-side rerun is safe.
// Version 1 is the baseline (applied via full DDL). Migrations start at 2.
//
// When adding a migration you MUST ALSO update `internal/sql/schema.sql` to
// reflect the latest shape — fresh installs run only the DDL, not the
// migration chain. Migrations and `schema.sql` must converge on the same
// end state; CI tests pin this invariant per release.
var schemaMigrations = map[int]string{}
func init() {
pgschema.ValidateMigrationMap("pgmapbroker", schemaVersion, schemaMigrations)
}
// renderSchemaTemplate substitutes the placeholders this broker recognises
// (__PREFIX__, __DATA_TYPE__) 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 having to write the same statement twice.
func renderSchemaTemplate(template, prefix string, binary bool) string {
dataType := "JSONB"
if binary {
dataType = "BYTEA"
}
return strings.NewReplacer(
"__PREFIX__", prefix,
"__DATA_TYPE__", dataType,
).Replace(template)
}
// renderSchema substitutes the __PREFIX__ and __DATA_TYPE__ placeholders in
// the embedded schema template with the caller-supplied values. Used both by
// EnsureSchema (to create tables+functions for JSONB and binary variants) and
// by ensurePartitionedStream (to recreate functions after dropping and
// recreating the stream table as partitioned).
func renderSchema(prefix string, binary bool) string {
return renderSchemaTemplate(postgresSchemaTemplate, prefix, binary)
}
// 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 *PostgresMapBroker) 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 nil
}
// No object name: the batch spans every table of one variant, and a server
// error's TableName is not the one that failed to be created — on the
// catalog unique violation a lost DDL race produces it reads "pg_class",
// which points the reader at PostgreSQL's own catalog. The wrapped error
// carries the statement's real detail.
return &SchemaError{
Object: SchemaObject{Type: "schema", Name: ""},
Op: "create",
Err: err,
}
}
// 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:]
}
// pgNames holds precomputed table/function names based on the user-configured
// TablePrefix and the BinaryData mode. For a given userPrefix P:
//
// jsonbPrefix = P + "_map_" (e.g. "cf_map_")
// binaryPrefix = P + "_binary_map_" (e.g. "cf_binary_map_")
//
// The stream/state/meta/… fields are computed from the *active* prefix,
// which is jsonbPrefix when BinaryData is false and binaryPrefix when true.
// EnsureSchema iterates over both jsonbPrefix and binaryPrefix to manage
// both table sets regardless of the active mode.
type pgNames struct {
stream, state, meta, idempotency, shardLock, schemaVersion string // table names (active variant)
publish, remove, expireKeys string // function names (active variant)
notifyChannel string // pg_notify channel name (active variant)
// jsonbPrefix and binaryPrefix are the two full prefix strings for this
// broker's user TablePrefix, used by EnsureSchema to manage both variants.
jsonbPrefix, binaryPrefix string
}
// newPgNames constructs the full set of table/function/channel names from a
// user-supplied TablePrefix (e.g. "cf") and the BinaryData mode flag. The
// userPrefix must be pre-normalized (no trailing underscore) — setDefaults
// on PostgresMapBrokerConfig handles that.
func newPgNames(userPrefix string, binary bool) pgNames {
jsonbPrefix := userPrefix + "_map_"
binaryPrefix := userPrefix + "_binary_map_"
p := jsonbPrefix
if binary {
p = binaryPrefix
}
return pgNames{
stream: p + "stream",
state: p + "state",
meta: p + "meta",
idempotency: p + "idempotency",
shardLock: p + "shard_lock",
schemaVersion: p + "schema_version",
publish: p + "publish",
remove: p + "remove",
expireKeys: p + "expire_keys",
notifyChannel: p + "stream_notify",
jsonbPrefix: jsonbPrefix,
binaryPrefix: binaryPrefix,
}
}
// PostgresMapBroker is MapBroker implementation using PostgreSQL for persistent
// map subscriptions. It provides durability, CAS operations, and transactional
// publishing from SQL.
//
// Key features:
// - Dual ID system: global `id` for polling, per-channel `offset` for Centrifuge
// - All nodes independently poll the stream table (SQL SELECT is reliable)
// - Optional LISTEN/NOTIFY for low-latency outbox wakeup
// - Full ACID transactions for atomic CAS operations
// - Optional read replica support for scaling reads
//
// Use cases: collaborative boards, document editing, inventory/booking systems,
// game lobbies with persistent state.
// durationToIntervalString converts a time.Duration to a PostgreSQL interval string.
// Uses milliseconds for precision.
func durationToIntervalString(d time.Duration) string {
ms := d.Milliseconds()
if ms > 0 {
return strconv.FormatInt(ms, 10) + " milliseconds"
}
// Sub-millisecond duration — round up to 1 millisecond minimum.
return "1 milliseconds"
}
type PostgresMapBroker struct {
node *centrifuge.Node
conf PostgresMapBrokerConfig
names pgNames
pool *pgxpool.Pool // Primary pool for writes
readPools []*pgxpool.Pool // One per replica; empty = use primary
notifyPool *pgxpool.Pool // Dedicated single-conn pool for LISTEN; nil = use pool
sampler *mapMetricsSampler
eventHandler centrifuge.BrokerEventHandler
closeCh chan struct{}
closeOnce sync.Once
running atomic.Bool
cancelCtx context.Context
cancelFunc context.CancelFunc
notifyCh chan struct{} // nil when UseNotify is false
// workersInitialized[i] flips true once shard i's outbox worker completes
// its first InitCursor (i.e., has snapshotted MAX(id) and is actively
// polling). Used by tests to publish only after the InitCursor 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 path.
workersInitialized []atomic.Bool
// notifyListenerReady flips true after the LISTEN command succeeds on
// the notification listener's connection. Used by tests that depend on
// NOTIFY-driven delivery so they publish only after LISTEN is bound.
// Production tolerates the race because PollInterval is the fallback.
notifyListenerReady atomic.Bool
}
var _ centrifuge.MapBroker = (*PostgresMapBroker)(nil)
// OutboxConfig configures the outbox-based delivery mode.
// Every node independently polls cf_map_stream — no advisory locks needed.
type OutboxConfig struct {
// PollInterval is how often to poll for new stream entries 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: 726966530.
AdvisoryLockBaseID int64
// AdvisoryLockRetryInterval is how often to retry advisory lock acquisition
// when Broker fan-out is enabled. Default: 5s.
AdvisoryLockRetryInterval time.Duration
}
// PostgresMapBrokerConfig configures the PostgreSQL map broker.
type PostgresMapBrokerConfig struct {
// Name of broker, for observability purposes – i.e. becomes part of metrics/logs labels.
// By default, empty string is used.
Name string
// DSN is the primary PostgreSQL connection string for writes.
// Example: "postgres://user:pass@localhost:5432/dbname?sslmode=disable"
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 pool.
// Default: 32
PoolSize int
// NumShards is the total number of shards for parallel delivery workers.
// Channels are distributed across shards using hash(channel) % NumShards.
// Default: 8
NumShards int
// TTLCheckInterval is how often to check for expired keys.
// Default: 1s
TTLCheckInterval time.Duration
// CleanupInterval is how often to clean up expired stream/meta/idempotency entries.
// Default: 1m
CleanupInterval time.Duration
// IdempotentResultTTL is the default TTL for idempotency keys.
// Default: 5m
IdempotentResultTTL time.Duration
// Outbox configures the outbox-based delivery mode.
Outbox OutboxConfig
// BinaryData uses BYTEA columns instead of JSONB for data fields.
// Default: false (JSONB — suitable for JSON payloads, enables JSONB queries).
// Set to true if data payloads are not valid JSON (binary/protobuf).
BinaryData bool
// TablePrefix is the user-facing namespace prefix for all tables,
// functions, and NOTIFY channels created by this broker. Default "cf".
// The broker appends its role-specific component internally, so the
// default produces:
//
// cf_map_state, cf_map_stream, cf_map_publish, cf_map_stream_notify, ...
//
// and their cf_binary_map_* counterparts for the binary data variant.
//
// Multi-tenant deployments sharing one PostgreSQL instance use distinct
// prefixes per Centrifugo cluster (e.g. "prod_us_cf", "prod_eu_cf").
// A trailing underscore is allowed but will be trimmed.
//
// Note: a future PostgreSQL stream broker uses the same default prefix
// "cf" with its own role component ("_stream_"), so it does not collide
// with the map broker even when both point at the same database.
TablePrefix string
// StreamRetention controls how long stream entries are kept.
// Cleanup worker deletes entries older than this. Default: 24h.
StreamRetention time.Duration
// UseNotify enables LISTEN/NOTIFY for low-latency outbox wakeup.
// When false (default), outbox worker uses PollInterval-based polling only.
// When true, a listener goroutine wakes the worker immediately on new entries.
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 an optional list of read replica connection strings.
// When set, ReadState queries with AllowCached=true are distributed
// across replicas using shard-based routing for consistency:
// hash(channel) % NumShards → shard_id % len(replicas) → replica
// 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 Broker instead of HandlePublication.
// Subscribe/Unsubscribe are delegated to this Broker.
// When nil, every node polls independently (current behavior).
Broker centrifuge.Broker
// PartitionRetentionDays controls how old a partition can be before it
// gets dropped whole by the partition retention worker. Default: 7.
// Set to 0 for unlimited retention (partitions accumulate; the pgoutbox
// guard skips the drop step). The stream table is always partitioned —
// there is no on/off toggle, partitioning is structural.
PartitionRetentionDays int
// 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 if the lookahead worker stalls).
PartitionLookaheadDays int
// CleanupBatchSize bounds each support-table (meta + idempotency) DELETE
// to this many rows per transaction. Default: 1000.
CleanupBatchSize int
// CleanupChunkPause is the pause between cleanup batches, giving
// autovacuum room between chunked deletes. Default: 100ms.
CleanupChunkPause time.Duration
}
func (c *PostgresMapBrokerConfig) setDefaults() {
if c.PoolSize <= 0 {
c.PoolSize = 16
}
if c.NumShards <= 0 {
c.NumShards = 8
}
if c.TTLCheckInterval <= 0 {
c.TTLCheckInterval = time.Second
}
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
}
if c.CleanupBatchSize <= 0 {
c.CleanupBatchSize = 1000
}
if c.CleanupChunkPause <= 0 {
c.CleanupChunkPause = 100 * time.Millisecond
}
// TablePrefix: default "cf"; allow/normalize trailing underscore.
c.TablePrefix = strings.TrimRight(c.TablePrefix, "_")
if c.TablePrefix == "" {
c.TablePrefix = "cf"
}
// Outbox config defaults
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 {
// Map broker occupies [4_067_067_000, 4_067_067_000+NumShards).
// Stream broker uses [5_067_067_000, 5_067_067_000+NumShards).
// Non-round bases reduce the chance of collision with other software.
c.Outbox.AdvisoryLockBaseID = 4_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.
}
// NewPostgresMapBroker creates a new PostgreSQL map broker.
func NewPostgresMapBroker(n *centrifuge.Node, conf PostgresMapBrokerConfig) (*PostgresMapBroker, error) {
conf.setDefaults()
if conf.DSN == "" {
return nil, errors.New("postgres map broker: DSN is required")
}
if err := pgschema.ValidateTablePrefix("postgres map broker", conf.TablePrefix); err != nil {
return nil, err
}
ctx := context.Background()
// Configure primary pool
poolConfig, err := pgxpool.ParseConfig(conf.DSN)
if err != nil {
return nil, fmt.Errorf("postgres map broker: parse config: %w", err)
}
poolConfig.MaxConns = int32(conf.PoolSize)
if conf.TLS.Enabled {
tlsCfg, err := conf.TLS.ToGoTLSConfig("postgres-map-broker")
if err != nil {
return nil, fmt.Errorf("postgres map broker: TLS config: %w", err)
}
poolConfig.ConnConfig.TLSConfig = tlsCfg
}
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("postgres map broker: connect primary: %w", err)
}
// Test connection
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("postgres map broker: ping primary: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
e := &PostgresMapBroker{
node: n,
conf: conf,
names: newPgNames(conf.TablePrefix, conf.BinaryData),
pool: pool,
closeCh: make(chan struct{}),
cancelCtx: ctx,
cancelFunc: cancel,
workersInitialized: make([]atomic.Bool, conf.NumShards),
}
e.sampler = newMapMetricsSampler(e)
if conf.UseNotify {
e.notifyCh = make(chan struct{}, 1)
if conf.NotifyDSN != "" {
nCfg, err := pgxpool.ParseConfig(conf.NotifyDSN)
if err != nil {
pool.Close()
cancel()
return nil, fmt.Errorf("postgres map broker: parse notify DSN: %w", err)
}
nCfg.MaxConns = 1
if conf.TLS.Enabled {
tlsCfg, err := conf.TLS.ToGoTLSConfig("postgres-map-broker")
if err != nil {
pool.Close()
cancel()
return nil, fmt.Errorf("postgres map broker: notify TLS config: %w", err)
}
nCfg.ConnConfig.TLSConfig = tlsCfg
}
nPool, err := pgxpool.NewWithConfig(ctx, nCfg)
if err != nil {
pool.Close()
cancel()
return nil, fmt.Errorf("postgres map broker: connect notify: %w", err)
}
e.notifyPool = nPool
}
}
// Create replica pools if configured
if len(conf.ReplicaDSN) > 0 {
for _, connStr := range conf.ReplicaDSN {
replicaConfig, err := pgxpool.ParseConfig(connStr)
if err != nil {
pool.Close()
for _, rp := range e.readPools {
rp.Close()
}
cancel()
return nil, fmt.Errorf("postgres map broker: parse replica config: %w", err)
}
replicaConfig.MaxConns = int32(conf.ReplicaPoolSize)
if conf.TLS.Enabled {
tlsCfg, err := conf.TLS.ToGoTLSConfig("postgres-map-broker")
if err != nil {
pool.Close()
for _, rp := range e.readPools {
rp.Close()
}
cancel()
return nil, fmt.Errorf("postgres map broker: replica TLS config: %w", err)
}
replicaConfig.ConnConfig.TLSConfig = tlsCfg
}
rp, err := pgxpool.NewWithConfig(context.Background(), replicaConfig)
if err != nil {
pool.Close()
for _, rp := range e.readPools {
rp.Close()
}
cancel()
return nil, fmt.Errorf("postgres map broker: connect replica: %w", err)
}
e.readPools = append(e.readPools, rp)
}
}
return e, nil
}
// getReadPool returns the pool for reading the given channel.
// Only routes to replica when allowCached is true AND replicas are configured.
// Routes by shard: hash(channel) % NumShards % len(readPools) → replica index.
func (e *PostgresMapBroker) 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]
}
// hashtext is a simple polynomial hash used only for in-process read-replica
// routing. It does NOT match PostgreSQL's hashtext() (which uses Jenkins
// lookup3) — values differ for the same input, which is fine: replicas are
// interchangeable, and the PostgreSQL function 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
}
// RegisterEventHandler registers the event handler and starts background workers.
func (e *PostgresMapBroker) RegisterEventHandler(h centrifuge.BrokerEventHandler) error {
e.eventHandler = h
if e.running.Swap(true) {
return errors.New("postgres map broker: already running")
}
// Pre-initialize the outbox cursor before launching worker goroutines so
// that any message published after RegisterEventHandler 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 {
// When inner Broker is configured, register it for PUB/SUB fan-out
// and use advisory lock workers to ensure only one node per shard polls.
if err := e.conf.Broker.RegisterBrokerEventHandler(h); err != nil {
return fmt.Errorf("postgres map 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()
}
// Start outbox workers: one per shard. Per-shard serialization (FOR UPDATE
// on shard_lock) + one-shard-per-worker eliminates BIGSERIAL gaps.
for i := 0; i < e.conf.NumShards; i++ {
go e.runOutboxWorker(i, initialCursor)
}
}
go e.runTTLExpirationWorker()
go e.runCleanupLagWorker()
go e.runCleanupWorker()
go e.runPartitionWorker() // unconditional — schema is always partitioned
return 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 *PostgresMapBroker) ReliableDelivery() bool {
return e.conf.Broker == nil
}
// Close shuts down the broker.
func (e *PostgresMapBroker) Close(ctx context.Context) error {
e.closeOnce.Do(func() {
e.cancelFunc() // Cancel context to unblock WaitForNotification
close(e.closeCh)
if e.conf.Broker != nil {
if closer, ok := e.conf.Broker.(centrifuge.Closer); ok {
_ = closer.Close(ctx)
}
}
for _, rp := range e.readPools {
rp.Close()
}
if e.notifyPool != nil {
e.notifyPool.Close()
}
e.pool.Close()
})
return nil
}
// SchemaObject identifies a database object involved in a schema error.
type SchemaObject struct {
Type string // "table", "index", "function"
Name string
}
// SchemaError wraps a schema-related error with object and operation info.
type SchemaError struct {
Object SchemaObject
Op string // "create", "verify"
Err error
}
func (e *SchemaError) Error() string {
return fmt.Sprintf("schema %s %s %q: %v", e.Op, e.Object.Type, e.Object.Name, e.Err)
}
func (e *SchemaError) Unwrap() error {
return e.Err
}
// EnsureSchema creates all required database objects idempotently.
// It creates BOTH JSONB and BYTEA schema variants in a single call,
// regardless of the BinaryData config (which only controls which variant
// is used at runtime). This ensures both schemas are always available.
//
// Schema versioning uses an integer version stored in the active variant's
// schema_version table. On startup, if the version matches and a probe
// query succeeds, all DDL is skipped (fast path). Otherwise, full DDL is
// re-applied (idempotent) and any pending migrations are executed in order.
//
// This method is safe to call concurrently from multiple nodes — all DDL
// uses CREATE IF NOT EXISTS / CREATE OR REPLACE, and all migrations must
// be idempotent (e.g. ADD COLUMN IF NOT EXISTS).
// reconcileShardLock ensures the shard_lock table contains exactly one row for
// each shard in [0, NumShards). Called on every EnsureSchema (including the
// fast path) so a NumShards change between runs is picked up. Running with a
// stale shard_lock is not merely a perf issue — missing rows silently break
// per-shard publish serialization, which in turn lets stream IDs commit out
// of order and lets the outbox cursor skip rows.
// bothVariantsPresent reports whether the primary table exists for both the
// jsonb and binary variants, so EnsureSchema's fast path doesn't trust a partial
// install where only one variant was created.
func (e *PostgresMapBroker) bothVariantsPresent(ctx context.Context) bool {
for _, prefix := range []string{e.names.jsonbPrefix, e.names.binaryPrefix} {
// <prefix>stream, not the trimmed prefix: this broker's tables are all
// suffixed (cf_map_stream), unlike pgstreambroker where the trimmed
// prefix IS the table (cf_stream). Probing "cf_map" — a relation that
// never exists — made this return false every time, so the fast path
// below was dead and every start re-ran the whole DDL batch.
table := prefix + "stream"
if _, err := e.pool.Exec(ctx, fmt.Sprintf(`SELECT 1 FROM %s LIMIT 0`, table)); err != nil {
return false
}
}
return true
}
func (e *PostgresMapBroker) reconcileShardLock(ctx context.Context) error {
for _, prefix := range []string{e.names.jsonbPrefix, e.names.binaryPrefix} {
shardLock := prefix + "shard_lock"
if _, err := e.pool.Exec(ctx, fmt.Sprintf(
`INSERT INTO %s (shard_id) SELECT generate_series(0, $1 - 1) ON CONFLICT DO NOTHING`,
shardLock), e.conf.NumShards); err != nil {
return &SchemaError{
Object: SchemaObject{Type: "table", Name: shardLock},
Op: "create",
Err: fmt.Errorf("populate shard_lock: %w", err),
}
}
if _, err := e.pool.Exec(ctx, fmt.Sprintf(
`DELETE FROM %s WHERE shard_id >= $1`,
shardLock), e.conf.NumShards); err != nil {
return &SchemaError{
Object: SchemaObject{Type: "table", Name: shardLock},
Op: "create",
Err: fmt.Errorf("trim shard_lock: %w", err),
}
}
}
return nil
}
// versionTables returns the fully-qualified `<prefix>schema_version` table
// names for both broker variants (jsonb + binary). Used as the targets for
// every UPDATE schema_version performed by the schema lifecycle code that
// doesn't carry SQL (the final SetSchemaVersion after DDL).
func (e *PostgresMapBroker) versionTables() []string {
return []string{
e.names.jsonbPrefix + "schema_version",
e.names.binaryPrefix + "schema_version",
}
}
// migrationVariants renders `template` once per variant (jsonb + binary) and
// pairs each rendered SQL with its variant's schema_version table, ready to
// hand to pgschema.ApplyMigrationInTx. All variants run inside ONE
// transaction so a migration is atomic across both variants.
func (e *PostgresMapBroker) migrationVariants(template string) []pgschema.MigrationVariant {
return []pgschema.MigrationVariant{
{
SQL: renderSchemaTemplate(template, e.names.jsonbPrefix, false),
VersionTable: e.names.jsonbPrefix + "schema_version",
},
{
SQL: renderSchemaTemplate(template, e.names.binaryPrefix, true),
VersionTable: e.names.binaryPrefix + "schema_version",
},
}
}
// runMigrationsUnderLock acquires the migration advisory lock, re-reads
// schema_version inside the lock (in case another node finished the upgrade
// while we were waiting), and applies pending migrations sequentially. Each
// migration runs in its own transaction with an atomic version bump; failure
// of any migration rolls back its tx and returns the error, leaving the DB
// at the last successfully-committed version.
func (e *PostgresMapBroker) runMigrationsUnderLock(ctx context.Context, label string) error {
release, err := pgschema.AcquireMigrationLock(ctx, e.pool, label)
if err != nil {
return err
}
defer release()
dbVersion, isFresh, err := pgschema.ReadSchemaVersion(ctx, e.pool, e.names.schemaVersion)
if err != nil {
return err
}
if isFresh {
// Another node dropped the schema while we were waiting (operator
// intervention). Bail out — the outer EnsureSchema's DDL path will
// rebuild from scratch.
return nil
}
// Recheck downgrade inside the lock: the dbVersion may have advanced past
// schemaVersion while we waited (the other migrator was running a newer
// binary).
if err := pgschema.CheckDowngrade(label, dbVersion, schemaVersion); err != nil {
return err
}
for v := dbVersion + 1; v <= schemaVersion; v++ {
sql, ok := schemaMigrations[v]
if !ok {
// init() already validated contiguity — reaching here means a
// concurrent map mutation in a test forgot cleanup. Fail loud.
return fmt.Errorf("%s: missing schemaMigrations[%d] at runtime (schemaVersion=%d)", label, v, schemaVersion)
}
if err := pgschema.ApplyMigrationInTx(ctx, e.pool, label, v, e.migrationVariants(sql)); err != nil {
return err
}
}
return nil
}
func (e *PostgresMapBroker) EnsureSchema(ctx context.Context) error {
const label = "pgmapbroker"
// 1. Read schema_version with error discrimination. A transient/permission
// failure must propagate — silently treating it as "fresh install"
// would skip migrations and force schema_version forward, leaving the
// DB at the old shape while the row claims the new version.
dbVersion, isFresh, err := pgschema.ReadSchemaVersion(ctx, e.pool, e.names.schemaVersion)
if err != nil {
return err
}
// 2. Fast path: version matches AND the primary table exists. Reconcile
// shard_lock (load-bearing, see comment in reconcileShardLock), refresh
// the partition lookahead, and return without touching DDL.
if !isFresh && dbVersion == schemaVersion {
// Probe BOTH variants' primary tables, not just the active one. The two
// variants (jsonb + binary) are created by separate Exec calls, so a
// partial fresh install can commit one variant's DDL (including its
// schema_version row) and fail the other's on a transient error. Probing
// only the active variant would then take the fast path and fail in
// reconcileShardLock (which touches both variants) on the missing tables,
// before ever reaching the idempotent DDL that recreates them — wedging
// the broker on every subsequent restart. If a variant is missing, fall
// through and re-apply DDL (idempotent) to self-heal.
if e.bothVariantsPresent(ctx) {
if err := e.reconcileShardLock(ctx); err != nil {
return err
}
// Partitions are the one part of the schema that expires: the
// lookahead window advances with the calendar, so a node coming
// back after the cluster was down longer than
// PartitionLookaheadDays finds today's partition missing. Without
// this the gap stays open until the partition worker's first tick
// — a full CleanupInterval of publishes failing with "no partition
// for value". pgstreambroker and the Postgres controller already
// close it on their fast paths.
return e.ensurePartitionedStream(ctx)
}
}
// 3. Reject downgrade. A binary running schemaVersion=N against a DB at
// M>N has no way to roll back the M-only migrations that may have
// altered table shapes.
if !isFresh {
if err := pgschema.CheckDowngrade(label, dbVersion, schemaVersion); err != nil {
return err
}
}
// 4. Run pending migrations BEFORE DDL, under a Postgres advisory lock so
// a rolling deploy can't race two nodes through the same migration
// chain. Within the lock we RE-READ schema_version — another node may
// have completed the upgrade while we were waiting on the lock, in
// which case the migration loop is a no-op for us. The lock is
// released before DDL, which is idempotent and safely concurrent.
//
// (a) Migrations before DDL because the DDL template (`schema.sql`)
// reflects the LATEST shape and may contain
// `CREATE INDEX IF NOT EXISTS idx ON tbl(newcol)` — parsing
// requires `newcol` to already exist on the table. Migrations
// run first guarantees columns referenced by DDL are present.
// (b) Each migration is wrapped in a transaction together with its
// own `UPDATE schema_version = v`, so a failed migration rolls
// back to the previous version on retry — never partial.
// Fresh-install case (isFresh=true) skips this loop: there's nothing
// to upgrade from, and DDL produces the baseline at the latest shape.
if !isFresh {
if err := e.runMigrationsUnderLock(ctx, label); err != nil {
return err
}
}
// 5. Render and run DDL for both variants. Fresh install creates
// everything at the latest shape; upgrade is idempotent and picks up
// new functions / indexes from schema.sql.
jsonbSQL := renderSchema(e.names.jsonbPrefix, false)
binarySQL := renderSchema(e.names.binaryPrefix, true)
jsonbDDL, jsonbFuncs := splitSchemaSQL(jsonbSQL)
binaryDDL, binaryFuncs := splitSchemaSQL(binarySQL)
for _, sql := range []string{jsonbDDL, binaryDDL, jsonbFuncs, binaryFuncs} {
if sql == "" {
continue
}
if err := e.execSchemaWithRetry(ctx, sql); err != nil {
return err
}
}
// 6. Populate/trim shard_lock and ensure the partitioned stream is set up.
if err := e.reconcileShardLock(ctx); err != nil {
return err
}
if err := e.ensurePartitionedStream(ctx); err != nil {
return err
}
// 7. Final schema_version write. For an upgrade, the migration txs have
// already set it; this UPDATE is a no-op. For a fresh install the
// schema.sql DDL inserted (1, 1), and this UPDATE bumps it to the
// current schemaVersion. Fatal on failure — leaving a fresh install
// stuck at version=1 would re-run the migration chain on next start.
return pgschema.SetSchemaVersion(ctx, e.pool, label, schemaVersion, e.versionTables())
}
// Subscribe delegates to inner Broker when configured, otherwise no-op.
func (e *PostgresMapBroker) Subscribe(channels ...string) error {
if e.conf.Broker != nil {
return e.conf.Broker.Subscribe(channels...)
}
return nil
}
// Unsubscribe delegates to inner Broker when configured, otherwise no-op.
func (e *PostgresMapBroker) Unsubscribe(channels ...string) error {
if e.conf.Broker != nil {
return e.conf.Broker.Unsubscribe(channels...)
}
return nil
}
// parseSuppressReason converts SQL suppress_reason string to SuppressReason type.
func parseSuppressReason(reason *string) centrifuge.SuppressReason {
if reason == nil {
return centrifuge.SuppressReasonNone
}
switch *reason {
case "idempotency":
return centrifuge.SuppressReasonIdempotency
case "position_mismatch":
return centrifuge.SuppressReasonPositionMismatch
case "key_exists":
return centrifuge.SuppressReasonKeyExists
case "key_not_found":
return centrifuge.SuppressReasonKeyNotFound
case "version":
return centrifuge.SuppressReasonVersion
default:
return centrifuge.SuppressReasonNone
}
}
// Publish publishes data to a map channel using the cf_map_publish SQL function.
func (e *PostgresMapBroker) Publish(ctx context.Context, ch string, key string, opts centrifuge.MapPublishOptions) (centrifuge.MapUpdateResult, error) {
// Resolve and validate channel options.
chOpts, err := centrifuge.ResolveAndValidateMapChannelOptions(e.node.Config().Map.GetMapChannelOptions, ch)
if err != nil {
return centrifuge.MapUpdateResult{}, err
}
// Reject CAS and Version in ephemeral mode.
if chOpts.Mode.IsEphemeral() {
if opts.ExpectedPosition != nil {
return centrifuge.MapUpdateResult{}, errors.New("CAS (ExpectedPosition) requires recoverable or persistent mode")
}
if opts.Version > 0 {
return centrifuge.MapUpdateResult{}, errors.New("version-based dedup requires recoverable or persistent mode")
}
}
// Prepare client info fields
var clientID, userID *string
var connInfo, chanInfo []byte
if opts.ClientInfo != nil {
if opts.ClientInfo.ClientID != "" {
clientID = &opts.ClientInfo.ClientID
}
if opts.ClientInfo.UserID != "" {
userID = &opts.ClientInfo.UserID
}
connInfo = opts.ClientInfo.ConnInfo
chanInfo = opts.ClientInfo.ChanInfo
}
// Prepare tags as json.RawMessage so pgx encodes it as JSON (not hex bytea)
// in both extended and simple protocol modes.
var tagsJSON json.RawMessage
if opts.Tags != nil {
tagsJSON, _ = json.Marshal(opts.Tags)
}
// Prepare key mode
var keyMode *string
if opts.KeyMode != centrifuge.KeyModeReplace {
km := string(opts.KeyMode)
keyMode = &km
}
// Prepare TTLs as interval strings.
var keyTTL, metaTTL, idempotencyTTL *string
if chOpts.KeyTTL > 0 {
s := durationToIntervalString(chOpts.KeyTTL)
keyTTL = &s
}
if chOpts.MetaTTL > 0 {
s := durationToIntervalString(chOpts.MetaTTL)
metaTTL = &s
}
idempotentResultTTL := opts.IdempotentResultTTL
if idempotentResultTTL == 0 {
idempotentResultTTL = e.conf.IdempotentResultTTL
}
if opts.IdempotencyKey != "" && idempotentResultTTL > 0 {
s := durationToIntervalString(idempotentResultTTL)
idempotencyTTL = &s
}
// Prepare expected offset and epoch for CAS. Compare epoch too so a stale
// position from a dead epoch (e.g. after Clear+republish) can't match.
var expectedOffset *int64
var expectedEpoch *string
if opts.ExpectedPosition != nil {
eo := int64(opts.ExpectedPosition.Offset)
expectedOffset = &eo
if opts.ExpectedPosition.Epoch != "" {
ee := opts.ExpectedPosition.Epoch
expectedEpoch = &ee
}
}
// Prepare per-key version (stored in state, used for per-key version check)
var keyVersion *int64
var keyVersionEpoch *string
if opts.Version > 0 && key != "" {
v := int64(opts.Version)
keyVersion = &v
if opts.VersionEpoch != "" {
keyVersionEpoch = &opts.VersionEpoch
}
}
// Prepare idempotency key
var idempotencyKey *string
if opts.IdempotencyKey != "" {
idempotencyKey = &opts.IdempotencyKey
}
// Call cf_map_publish function
numShards := e.conf.NumShards
var id *int64
var channelOffset int64
var epoch string
var suppressed bool
var suppressReason *string
var currentData []byte
var currentOffset *int64
err = e.pool.QueryRow(ctx, fmt.Sprintf(`
SELECT result_id, channel_offset, epoch, suppressed, suppress_reason, current_data, current_offset
FROM %s($1, $2, $3, $4, $5::interval, $6::interval, $7, $8, $9, $10, $11, $12, $13::interval, $14, $15, $16, $17, $18, $19, $20)
`, e.names.publish),
ch, key, e.dataParam(opts.Data), tagsJSON,
keyTTL, metaTTL, keyMode,
expectedOffset, expectedEpoch, keyVersion, keyVersionEpoch,
idempotencyKey, idempotencyTTL, opts.UseDelta,
clientID, userID, e.dataParam(connInfo), e.dataParam(chanInfo),
opts.RefreshTTLOnSuppress, numShards,
).Scan(&id, &channelOffset, &epoch, &suppressed, &suppressReason, &currentData, &currentOffset)
if err != nil {
return centrifuge.MapUpdateResult{}, err
}
newPos := centrifuge.StreamPosition{Offset: uint64(channelOffset), Epoch: epoch}
if suppressed {
result := centrifuge.MapUpdateResult{
Position: newPos,
Suppressed: true,
SuppressReason: parseSuppressReason(suppressReason),
}
// For position_mismatch, include current key state for immediate retry.
if suppressReason != nil && *suppressReason == "position_mismatch" && currentOffset != nil {
result.CurrentEntry = &centrifuge.MapCurrentEntry{
Offset: uint64(*currentOffset),
Data: currentData,
}
}
return result, nil
}
return centrifuge.MapUpdateResult{Position: newPos}, nil
}
// Remove removes a key from keyed state using the cf_map_remove SQL function.
func (e *PostgresMapBroker) Remove(ctx context.Context, ch string, key string, opts centrifuge.MapRemoveOptions) (centrifuge.MapUpdateResult, error) {
// Resolve and validate channel options.
chOpts, err := centrifuge.ResolveAndValidateMapChannelOptions(e.node.Config().Map.GetMapChannelOptions, ch)
if err != nil {
return centrifuge.MapUpdateResult{}, err
}
// Reject CAS in ephemeral mode.
if chOpts.Mode.IsEphemeral() {
if opts.ExpectedPosition != nil {
return centrifuge.MapUpdateResult{}, errors.New("CAS (ExpectedPosition) requires recoverable or persistent mode")
}
}
// Prepare TTLs as interval strings.
var metaTTL, idempotencyTTL *string
if chOpts.MetaTTL > 0 {
s := durationToIntervalString(chOpts.MetaTTL)
metaTTL = &s
}
idempotentResultTTL := opts.IdempotentResultTTL
if idempotentResultTTL == 0 {
idempotentResultTTL = e.conf.IdempotentResultTTL
}
if opts.IdempotencyKey != "" && idempotentResultTTL > 0 {
s := durationToIntervalString(idempotentResultTTL)
idempotencyTTL = &s
}
// Prepare idempotency key
var idempotencyKey *string
if opts.IdempotencyKey != "" {
idempotencyKey = &opts.IdempotencyKey
}
// Prepare expected position for CAS. Compare epoch too — see Publish for
// the rationale.
var expectedOffset *int64
var expectedEpoch *string
if opts.ExpectedPosition != nil {
eo := int64(opts.ExpectedPosition.Offset)
expectedOffset = &eo
if opts.ExpectedPosition.Epoch != "" {
ee := opts.ExpectedPosition.Epoch
expectedEpoch = &ee
}
}
// Call cf_map_remove function
numShards := e.conf.NumShards
var id *int64
var channelOffset int64
var epoch string
var suppressed bool
var suppressReason *string
var currentData []byte
var currentOffset *int64
var clientID, userID *string
err = e.pool.QueryRow(ctx, fmt.Sprintf(`
SELECT result_id, channel_offset, epoch, suppressed, suppress_reason, current_data, current_offset
FROM %s($1, $2, $3::interval, $4, $5, $6, $7::interval, $8, $9, $10)
`, e.names.remove),
ch, key, metaTTL, expectedOffset, expectedEpoch,
idempotencyKey, idempotencyTTL,
clientID, userID,
numShards,
).Scan(&id, &channelOffset, &epoch, &suppressed, &suppressReason, &currentData, &currentOffset)
if err != nil {
return centrifuge.MapUpdateResult{}, err
}
newPos := centrifuge.StreamPosition{Offset: uint64(channelOffset), Epoch: epoch}
if suppressed {
result := centrifuge.MapUpdateResult{
Position: newPos,
Suppressed: true,
SuppressReason: parseSuppressReason(suppressReason),
}
if suppressReason != nil && *suppressReason == "position_mismatch" && currentOffset != nil {
result.CurrentEntry = &centrifuge.MapCurrentEntry{
Offset: uint64(*currentOffset),
Data: currentData,
}
}
return result, nil
}
return centrifuge.MapUpdateResult{Position: newPos}, nil
}
// ReadState retrieves keyed state with revisions.
func (e *PostgresMapBroker) ReadState(ctx context.Context, ch string, opts centrifuge.MapReadStateOptions) (centrifuge.MapStateResult, error) {
pool := e.getReadPool(ch, opts.AllowCached)
_, err := centrifuge.ResolveAndValidateMapChannelOptions(e.node.Config().Map.GetMapChannelOptions, ch)
if err != nil {
return centrifuge.MapStateResult{}, err
}
// Limit=0 with no key: return just stream position, no transaction needed.
if opts.Limit == 0 && opts.Key == "" {
return e.readStatePosition(ctx, pool, ch, opts)
}
// Single key lookup (CAS read): batch meta + key query.
if opts.Key != "" {
return e.readStateKey(ctx, pool, ch, opts)
}
// Full/paginated state read (chOpts already resolved above).
limit := opts.Limit
if limit < 0 {
limit = 100000
}
// Build state query with key-based cursor pagination.
// NOTE: Score-based ordered pagination was removed before initial release.
// To restore ordered mode: add score column back to state table, add
// __PREFIX__state_ordered_idx (channel, score DESC, key), re-export Ordered
// on MapChannelOptions, and branch here on chOpts.IsOrdered() with
// ORDER BY score ASC/DESC, key ASC/DESC and composite cursor via
// centrifuge.MakeOrderedCursor / ParseOrderedCursor.
stateTable := e.names.state
var stateQuery string
var stateArgs []any
if opts.Cursor == "" {
stateQuery = fmt.Sprintf(`
SELECT key, data, tags, key_offset, client_id, user_id, conn_info, chan_info
FROM %s
WHERE channel = $1
ORDER BY key
LIMIT $2
`, stateTable)
stateArgs = []any{ch, limit + 1}
} else {
stateQuery = fmt.Sprintf(`
SELECT key, data, tags, key_offset, client_id, user_id, conn_info, chan_info
FROM %s
WHERE channel = $1 AND key > $3
ORDER BY key
LIMIT $2
`, stateTable)
stateArgs = []any{ch, limit + 1, opts.Cursor}
}
// Pipelined batch: meta + state in a single round trip with REPEATABLE READ.
metaQuery := fmt.Sprintf(`SELECT top_offset, epoch FROM %s WHERE channel = $1`, e.names.meta)
batch := &pgx.Batch{}
batch.Queue("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
batch.Queue(metaQuery, ch)
batch.Queue(stateQuery, stateArgs...)
batch.Queue("COMMIT")
br := pool.SendBatch(ctx, batch)
// Consume BEGIN.
if _, err := br.Exec(); err != nil {
_ = br.Close()
return centrifuge.MapStateResult{}, err
}
// Read meta.
var topOffset int64
var epoch string
err = br.QueryRow().Scan(&topOffset, &epoch)
if errors.Is(err, pgx.ErrNoRows) {
_ = br.Close()
if opts.Revision != nil && opts.Revision.Epoch != "" {
return centrifuge.MapStateResult{}, centrifuge.ErrorUnrecoverablePosition
}
return centrifuge.MapStateResult{}, nil
}
if err != nil {
_ = br.Close()
return centrifuge.MapStateResult{}, err
}
streamPos := centrifuge.StreamPosition{Offset: uint64(topOffset), Epoch: epoch}
if opts.Revision != nil && opts.Revision.Epoch != "" && opts.Revision.Epoch != epoch {
_ = br.Close()
return centrifuge.MapStateResult{Position: streamPos}, centrifuge.ErrorUnrecoverablePosition
}
// Read state rows.
rows, err := br.Query()
if err != nil {
_ = br.Close()
return centrifuge.MapStateResult{}, err
}
allocHint := limit + 1 // +1 for next-page detection row.
if allocHint > 1001 {
allocHint = 1001
}
arena := byteArena{buf: make([]byte, 0, allocHint*64)}
backing := make([]centrifuge.Publication, 0, allocHint)
pubs := make([]*centrifuge.Publication, 0, allocHint)
// Use RawValues + arena to avoid per-row allocations.
// Column order: key(0), data(1), tags(2), key_offset(3),
// client_id(4), user_id(5), conn_info(6), chan_info(7).
var fmts pgColFormats
for rows.Next() {
if fmts == nil {
fmts = pgColFormatsFromRows(rows)
}
raw := rows.RawValues()
backing = append(backing, centrifuge.Publication{})
p := &backing[len(backing)-1]
p.Key = pgRawString(&arena, raw[0])
p.Data = e.rawDataBytes(&arena, raw[1], fmts[1])
p.Tags = pgRawJSONBMap(raw[2])
p.Offset = pgRawUint64(raw[3], fmts[3])
if raw[4] != nil {
p.Info = &centrifuge.ClientInfo{
ClientID: pgRawString(&arena, raw[4]),
UserID: pgRawString(&arena, raw[5]),
ConnInfo: e.rawDataBytes(&arena, raw[6], fmts[6]),
ChanInfo: e.rawDataBytes(&arena, raw[7], fmts[7]),
}
}
pubs = append(pubs, p)
}
rows.Close()
// Consume COMMIT.
_, _ = br.Exec()
_ = br.Close()
if err := rows.Err(); err != nil {
return centrifuge.MapStateResult{}, err
}
var nextCursor string
if len(pubs) > limit {
pubs = pubs[:limit]
nextCursor = pubs[limit-1].Key
}
return centrifuge.MapStateResult{Publications: pubs, Position: streamPos, Cursor: nextCursor}, nil
}
// readStatePosition returns just the stream position for a channel (no state entries).
// Used when Limit=0 with no key filter — a single meta query, no transaction needed.
func (e *PostgresMapBroker) readStatePosition(ctx context.Context, pool *pgxpool.Pool, ch string, opts centrifuge.MapReadStateOptions) (centrifuge.MapStateResult, error) {
var topOffset int64
var epoch string
err := pool.QueryRow(ctx, fmt.Sprintf(
`SELECT top_offset, epoch FROM %s WHERE channel = $1`, e.names.meta), ch,
).Scan(&topOffset, &epoch)
if errors.Is(err, pgx.ErrNoRows) {
if opts.Revision != nil && opts.Revision.Epoch != "" {
return centrifuge.MapStateResult{}, centrifuge.ErrorUnrecoverablePosition
}
return centrifuge.MapStateResult{}, nil
}
if err != nil {
return centrifuge.MapStateResult{}, err
}
streamPos := centrifuge.StreamPosition{Offset: uint64(topOffset), Epoch: epoch}
if opts.Revision != nil && opts.Revision.Epoch != "" && opts.Revision.Epoch != epoch {
return centrifuge.MapStateResult{Position: streamPos}, centrifuge.ErrorUnrecoverablePosition
}
return centrifuge.MapStateResult{Position: streamPos}, nil
}
// readStateKey reads a single key from state (CAS read path).
// Uses pipelined batch: meta + key query in one round trip with REPEATABLE READ.
func (e *PostgresMapBroker) readStateKey(ctx context.Context, pool *pgxpool.Pool, ch string, opts centrifuge.MapReadStateOptions) (centrifuge.MapStateResult, error) {
metaQuery := fmt.Sprintf(`SELECT top_offset, epoch FROM %s WHERE channel = $1`, e.names.meta)
keyQuery := fmt.Sprintf(`
SELECT key, data, tags, key_offset, client_id, user_id, conn_info, chan_info
FROM %s
WHERE channel = $1 AND key = $2
`, e.names.state)
batch := &pgx.Batch{}
batch.Queue("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
batch.Queue(metaQuery, ch)
batch.Queue(keyQuery, ch, opts.Key)
batch.Queue("COMMIT")
br := pool.SendBatch(ctx, batch)
// Consume BEGIN.
if _, err := br.Exec(); err != nil {
_ = br.Close()
return centrifuge.MapStateResult{}, err
}
// Read meta.
var topOffset int64
var epoch string
err := br.QueryRow().Scan(&topOffset, &epoch)
if errors.Is(err, pgx.ErrNoRows) {
_ = br.Close()
if opts.Revision != nil && opts.Revision.Epoch != "" {
return centrifuge.MapStateResult{}, centrifuge.ErrorUnrecoverablePosition
}
return centrifuge.MapStateResult{}, nil
}
if err != nil {
_ = br.Close()
return centrifuge.MapStateResult{}, err
}
streamPos := centrifuge.StreamPosition{Offset: uint64(topOffset), Epoch: epoch}
if opts.Revision != nil && opts.Revision.Epoch != "" && opts.Revision.Epoch != epoch {
_ = br.Close()
return centrifuge.MapStateResult{Position: streamPos}, centrifuge.ErrorUnrecoverablePosition
}
// Read key.
var p centrifuge.Publication
var tagsJSON []byte
var clientID, userID *string
var connInfo, chanInfo []byte
err = br.QueryRow().Scan(&p.Key, &p.Data, &tagsJSON, &p.Offset, &clientID, &userID, &connInfo, &chanInfo)
// Consume COMMIT and close batch before processing results.
_, _ = br.Exec()
_ = br.Close()
if errors.Is(err, pgx.ErrNoRows) {
return centrifuge.MapStateResult{Position: streamPos}, nil
}
if err != nil {
return centrifuge.MapStateResult{}, err
}
if len(tagsJSON) > 0 {
_ = json.Unmarshal(tagsJSON, &p.Tags)
}
if clientID != nil {
p.Info = &centrifuge.ClientInfo{
ClientID: *clientID,
ConnInfo: connInfo,
ChanInfo: chanInfo,
}
if userID != nil {
p.Info.UserID = *userID
}
}
return centrifuge.MapStateResult{Publications: []*centrifuge.Publication{&p}, Position: streamPos}, nil
}
// ReadStream retrieves publications from stream.
func (e *PostgresMapBroker) ReadStream(ctx context.Context, ch string, opts centrifuge.MapReadStreamOptions) (centrifuge.MapStreamResult, error) {
pool := e.getReadPool(ch, opts.AllowCached)
if opts.Filter.Limit == 0 {
// Position check only — single meta query, no stream read needed.
return e.readStreamPosition(ctx, pool, ch)
}
sinceOffset := int64(0)
if opts.Filter.Since != nil {
sinceOffset = int64(opts.Filter.Since.Offset)
}
limit := opts.Filter.Limit
unlimited := limit < 0
// Build stream query. The `epoch = (SELECT epoch FROM meta ...)` correlation
// rejects rows from prior epochs that linger between meta TTL expiry and the
// next partition retention drop. Inside REPEATABLE READ both reads see the
// same snapshot, and the new (channel, epoch, channel_offset) index resolves
// the predicate without scanning dead-epoch rows.
streamTable := e.names.stream
metaTable := e.names.meta
var streamQuery string
if opts.Filter.Reverse {
if opts.Filter.Since == nil {
// For reverse without explicit Since, we need topOffset from meta.
// We handle this after reading meta from the batch result.
sinceOffset = 0 // placeholder, will be overridden
}
if unlimited {
streamQuery = fmt.Sprintf(`
SELECT key, data, tags, channel_offset, removed, client_id, user_id, conn_info, chan_info
FROM %s
WHERE channel = $1 AND channel_offset < $2
AND epoch = (SELECT epoch FROM %s WHERE channel = $1)
ORDER BY channel_offset DESC
`, streamTable, metaTable)
} else {
streamQuery = fmt.Sprintf(`
SELECT key, data, tags, channel_offset, removed, client_id, user_id, conn_info, chan_info
FROM %s
WHERE channel = $1 AND channel_offset < $2
AND epoch = (SELECT epoch FROM %s WHERE channel = $1)
ORDER BY channel_offset DESC
LIMIT $3
`, streamTable, metaTable)
}
} else {
if unlimited {
streamQuery = fmt.Sprintf(`
SELECT key, data, tags, channel_offset, removed, client_id, user_id, conn_info, chan_info
FROM %s
WHERE channel = $1 AND channel_offset > $2
AND epoch = (SELECT epoch FROM %s WHERE channel = $1)
ORDER BY channel_offset ASC
`, streamTable, metaTable)
} else {
streamQuery = fmt.Sprintf(`
SELECT key, data, tags, channel_offset, removed, client_id, user_id, conn_info, chan_info
FROM %s
WHERE channel = $1 AND channel_offset > $2
AND epoch = (SELECT epoch FROM %s WHERE channel = $1)
ORDER BY channel_offset ASC
LIMIT $3
`, streamTable, metaTable)
}
}
// For reverse without Since, we need topOffset to set sinceOffset.
// Fall back to transactional path for this case.
if opts.Filter.Reverse && opts.Filter.Since == nil {
return e.readStreamTx(ctx, pool, ch, opts, streamQuery, unlimited, limit)
}
// Pipelined batch: meta + stream in a single round trip, with REPEATABLE READ for consistency.
metaQuery := fmt.Sprintf(`SELECT top_offset, epoch FROM %s WHERE channel = $1`, e.names.meta)
batch := &pgx.Batch{}
batch.Queue("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
batch.Queue(metaQuery, ch)
if unlimited {
batch.Queue(streamQuery, ch, sinceOffset)
} else {
batch.Queue(streamQuery, ch, sinceOffset, limit)
}
batch.Queue("COMMIT")
br := pool.SendBatch(ctx, batch)
// Consume BEGIN.
if _, err := br.Exec(); err != nil {
_ = br.Close()
return centrifuge.MapStreamResult{}, err
}
// Read meta.
var topOffset int64
var epoch string
err := br.QueryRow().Scan(&topOffset, &epoch)
if errors.Is(err, pgx.ErrNoRows) {
_ = br.Close()
return centrifuge.MapStreamResult{}, nil
}
if err != nil {
_ = br.Close()
return centrifuge.MapStreamResult{}, err
}
streamPos := centrifuge.StreamPosition{Offset: uint64(topOffset), Epoch: epoch}
// Validate epoch if provided.
if opts.Filter.Since != nil && opts.Filter.Since.Epoch != "" && opts.Filter.Since.Epoch != epoch {
_ = br.Close()
return centrifuge.MapStreamResult{}, centrifuge.ErrorUnrecoverablePosition
}
// Read stream rows.
rows, err := br.Query()
if err != nil {
_ = br.Close()
return centrifuge.MapStreamResult{}, err
}
allocHint := limit
if unlimited {
allocHint = 64
}
arena := byteArena{buf: make([]byte, 0, allocHint*64)}
backing := make([]centrifuge.Publication, 0, allocHint)
pubs := make([]*centrifuge.Publication, 0, allocHint)
// Column order: key(0), data(1), tags(2), channel_offset(3), removed(4),
// client_id(5), user_id(6), conn_info(7), chan_info(8).
var fmts pgColFormats
for rows.Next() {
if fmts == nil {
fmts = pgColFormatsFromRows(rows)
}
raw := rows.RawValues()
backing = append(backing, centrifuge.Publication{})
p := &backing[len(backing)-1]
p.Key = pgRawString(&arena, raw[0])
p.Data = e.rawDataBytes(&arena, raw[1], fmts[1])
p.Tags = pgRawJSONBMap(raw[2])
p.Offset = pgRawUint64(raw[3], fmts[3])
p.Removed = pgRawBool(raw[4], fmts[4])
if raw[5] != nil {
p.Info = &centrifuge.ClientInfo{
ClientID: pgRawString(&arena, raw[5]),
UserID: pgRawString(&arena, raw[6]),
ConnInfo: e.rawDataBytes(&arena, raw[7], fmts[7]),
ChanInfo: e.rawDataBytes(&arena, raw[8], fmts[8]),
}
}
pubs = append(pubs, p)
}
rows.Close()
// Consume COMMIT.
_, _ = br.Exec()
_ = br.Close()
if err := rows.Err(); err != nil {
return centrifuge.MapStreamResult{}, err
}
return centrifuge.MapStreamResult{Publications: pubs, Position: streamPos}, nil
}
// readStreamTx is a fallback for ReadStream when we need meta before building the query
// (e.g., reverse without explicit Since needs topOffset). Uses REPEATABLE READ transaction.
func (e *PostgresMapBroker) readStreamTx(ctx context.Context, pool *pgxpool.Pool, ch string, _ centrifuge.MapReadStreamOptions, streamQuery string, unlimited bool, limit int) (centrifuge.MapStreamResult, error) {
tx, err := pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return centrifuge.MapStreamResult{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
var topOffset int64
var epoch string
err = tx.QueryRow(ctx, fmt.Sprintf(`
SELECT top_offset, epoch FROM %s WHERE channel = $1
`, e.names.meta), ch).Scan(&topOffset, &epoch)
if errors.Is(err, pgx.ErrNoRows) {
_ = tx.Rollback(ctx)
return centrifuge.MapStreamResult{}, nil
}
if err != nil {
return centrifuge.MapStreamResult{}, err
}
sinceOffset := topOffset + 1
var rows pgx.Rows
if unlimited {
rows, err = tx.Query(ctx, streamQuery, ch, sinceOffset)
} else {
rows, err = tx.Query(ctx, streamQuery, ch, sinceOffset, limit)
}
if err != nil {
return centrifuge.MapStreamResult{}, err
}
defer rows.Close()
allocHint := limit
if unlimited {
allocHint = 64
}
arena := byteArena{buf: make([]byte, 0, allocHint*64)}
backing := make([]centrifuge.Publication, 0, allocHint)
pubs := make([]*centrifuge.Publication, 0, allocHint)
var fmts pgColFormats
for rows.Next() {
if fmts == nil {
fmts = pgColFormatsFromRows(rows)
}
raw := rows.RawValues()
backing = append(backing, centrifuge.Publication{})
p := &backing[len(backing)-1]
p.Key = pgRawString(&arena, raw[0])
p.Data = e.rawDataBytes(&arena, raw[1], fmts[1])
p.Tags = pgRawJSONBMap(raw[2])
p.Offset = pgRawUint64(raw[3], fmts[3])
p.Removed = pgRawBool(raw[4], fmts[4])
if raw[5] != nil {
p.Info = &centrifuge.ClientInfo{
ClientID: pgRawString(&arena, raw[5]),
UserID: pgRawString(&arena, raw[6]),
ConnInfo: e.rawDataBytes(&arena, raw[7], fmts[7]),
ChanInfo: e.rawDataBytes(&arena, raw[8], fmts[8]),
}
}
pubs = append(pubs, p)
}
if err := rows.Err(); err != nil {
return centrifuge.MapStreamResult{}, err
}
return centrifuge.MapStreamResult{Publications: pubs, Position: centrifuge.StreamPosition{Offset: uint64(topOffset), Epoch: epoch}}, nil
}
// readStreamPosition returns just the stream position for a channel (Limit=0 case).
func (e *PostgresMapBroker) readStreamPosition(ctx context.Context, pool *pgxpool.Pool, ch string) (centrifuge.MapStreamResult, error) {
var topOffset int64
var epoch string
err := pool.QueryRow(ctx, fmt.Sprintf(`
SELECT top_offset, epoch FROM %s WHERE channel = $1
`, e.names.meta), ch).Scan(&topOffset, &epoch)
if errors.Is(err, pgx.ErrNoRows) {
return centrifuge.MapStreamResult{}, nil
}
if err != nil {
return centrifuge.MapStreamResult{}, err
}
return centrifuge.MapStreamResult{Position: centrifuge.StreamPosition{Offset: uint64(topOffset), Epoch: epoch}}, nil
}
// Stats returns state statistics.
func (e *PostgresMapBroker) Stats(ctx context.Context, ch string) (centrifuge.MapStats, error) {
pool := e.getReadPool(ch, false)
var count int
err := pool.QueryRow(ctx, fmt.Sprintf(`
SELECT COUNT(*) FROM %s
WHERE channel = $1
`, e.names.state), ch).Scan(&count)
if err != nil {
return centrifuge.MapStats{}, err
}
return centrifuge.MapStats{NumKeys: count}, nil
}
// Clear deletes all data for a channel.
func (e *PostgresMapBroker) Clear(ctx context.Context, ch string, _ centrifuge.MapClearOptions) error {
tx, err := e.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
_, err = tx.Exec(ctx, fmt.Sprintf(`DELETE FROM %s WHERE channel = $1`, e.names.stream), ch)
if err != nil {
return err
}
_, err = tx.Exec(ctx, fmt.Sprintf(`DELETE FROM %s WHERE channel = $1`, e.names.state), ch)
if err != nil {
return err
}
_, err = tx.Exec(ctx, fmt.Sprintf(`DELETE FROM %s WHERE channel = $1`, e.names.meta), ch)
if err != nil {
return err
}
_, err = tx.Exec(ctx, fmt.Sprintf(`DELETE FROM %s WHERE channel = $1`, e.names.idempotency), ch)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// ============================================================================
// Notification Listener (optional, only started when UseNotify=true)
// ============================================================================
// runNotificationListener listens for pg_notify and wakes the outbox worker.
// Thin wrapper around pgoutbox.NotificationListener.
func (e *PostgresMapBroker) runNotificationListener() {
pool := e.pool
if e.notifyPool != nil {
pool = e.notifyPool
}
l := &pgoutbox.NotificationListener{
Pool: pool,
Channel: e.names.notifyChannel,
NotifyCh: e.notifyCh,
ErrorFn: e.logErrorMsg,
OnReady: func() { e.notifyListenerReady.Store(true) },
}
l.Run(e.cancelCtx, e.closeCh)
}
// ============================================================================
// Outbox Worker Implementation
// ============================================================================
// outboxWorkerConfig returns (pool, shardIDs) for outbox worker #workerIdx.
// Each worker handles exactly one shard. With replicas, the worker reads from
// readPools[workerIdx % len(readPools)], matching getReadPool's routing.
func (e *PostgresMapBroker) outboxWorkerConfig(workerIdx int) (*pgxpool.Pool, []int) {
pool := e.pool
if len(e.readPools) > 0 {
pool = e.readPools[workerIdx%len(e.readPools)]
}
return pool, []int{workerIdx}
}
// initOutboxCursor bootstraps an outbox worker cursor from the current
// MAX(id) of the stream table. Used as the InitCursor callback for both
// pgoutbox.Worker and pgoutbox.LockWorker.
func (e *PostgresMapBroker) initOutboxCursor(ctx context.Context, pool *pgxpool.Pool) (int64, error) {
var cursor int64
err := pool.QueryRow(ctx, fmt.Sprintf(
`SELECT COALESCE(MAX(id), 0) FROM %s`, e.names.stream)).Scan(&cursor)
return cursor, err
}
// runOutboxWorker polls the stream table for new entries and delivers them.
//
// Per-shard serialization (FOR UPDATE on shard_lock) combined with
// one-shard-per-worker guarantees that BIGSERIAL IDs within a shard are
// committed in order — no gaps possible. This allows a simple maxID cursor.
//
// Thin wrapper around pgoutbox.Worker. All map-specific logic (SQL,
// row scanning, delivery dispatch) stays in processOutboxBatch, which is
// called via a closure that captures a per-worker outboxBatchBuf.
func (e *PostgresMapBroker) runOutboxWorker(workerIdx int, initialCursor int64) {
pool, shards := e.outboxWorkerConfig(workerIdx)
// Pre-allocate reusable batch buffer (captured by the ProcessBatch closure).
allocHint := e.conf.Outbox.BatchSize
if allocHint > 1001 {
allocHint = 1001
}
buf := &outboxBatchBuf{
metas: make([]outboxMeta, 0, allocHint),
}
w := &pgoutbox.Worker{
Pool: pool,
ShardIDs: shards,
PollInterval: e.conf.Outbox.PollInterval,
NotifyCh: e.notifyCh,
InitCursor: func(ctx context.Context, p *pgxpool.Pool) (int64, error) {
return initialCursor, nil
},
ProcessBatch: func(ctx context.Context, pool *pgxpool.Pool, cursor int64, shardIDs []int) (int, int64, error) {
return e.processOutboxBatch(ctx, pool, cursor, shardIDs, buf)
},
ErrorFn: e.logErrorMsg,
}
w.Run(e.cancelCtx, e.closeCh)
}
// runOutboxWorkerWithLock uses PostgreSQL advisory locks to ensure only one node
// per shard polls the stream table. Used when Broker fan-out is enabled.
// The worker acquires a session-level advisory lock on the primary pool.
// If the lock is held by another node, it retries after AdvisoryLockRetryInterval.
// Once acquired, it runs the normal outbox poll loop. The lock is automatically
// released when the connection is returned to the pool or dropped.
//
// Thin wrapper around pgoutbox.LockWorker. All map-specific logic stays in
// processOutboxBatch, called via a closure that captures a per-worker buf.
func (e *PostgresMapBroker) runOutboxWorkerWithLock(workerIdx int, initialCursor int64) {
pollPool, shards := e.outboxWorkerConfig(workerIdx)
allocHint := e.conf.Outbox.BatchSize
if allocHint > 1001 {
allocHint = 1001
}
buf := &outboxBatchBuf{
metas: make([]outboxMeta, 0, allocHint),
}
lw := &pgoutbox.LockWorker{
LockPool: e.pool,
PollPool: pollPool,
ShardIDs: shards,
LockID: e.conf.Outbox.AdvisoryLockBaseID + int64(workerIdx),
PollInterval: e.conf.Outbox.PollInterval,
RetryInterval: e.conf.Outbox.AdvisoryLockRetryInterval,
// InitCursor is called on every lock acquisition (see lock_worker.go)
// so we re-query MAX(id) each time. Returning a fixed startup value
// is not a correctness bug (subscribers dedup by offset) but would
// re-fanout every row already forwarded by a previous lock holder,
// growing linearly with broker uptime. The race window between lock
// acquisition and this snapshot is recovered subscriber-side via
// insufficient_state.
InitCursor: func(ctx context.Context, p *pgxpool.Pool) (int64, error) {
cursor, err := e.initOutboxCursor(ctx, p)
if err == nil {
e.workersInitialized[workerIdx].Store(true)
}
return cursor, err
},
ProcessBatch: func(ctx context.Context, pool *pgxpool.Pool, cursor int64, shardIDs []int) (int, int64, error) {
return e.processOutboxBatch(ctx, pool, cursor, shardIDs, buf)
},
ErrorFn: func(msg string, err error) {
e.logError(msg, err, workerIdx)
},
InfoFn: func(msg string) {
e.logInfo(msg, workerIdx)
},
}
lw.Run(e.cancelCtx, e.closeCh)
}
// outboxMeta holds per-row metadata not captured in Publication.
// String/byte fields reference arena memory — no per-field heap allocation.
type outboxMeta struct {
id int64
channel string
epoch string
previousData []byte
}
// outboxBatchBuf holds reusable buffers for processOutboxBatch. Only metas
// (internal metadata) is reused across batches. pubBacking and infoBacking are
// allocated fresh each batch because handlers may retain *Publication pointers
// asynchronously (e.g. cachedEventHandler.BufferPublication, channelMedium queue).
type outboxBatchBuf struct {
metas []outboxMeta
}
// reset clears metas while retaining capacity.
func (b *outboxBatchBuf) reset() {
clear(b.metas[:cap(b.metas)])
b.metas = b.metas[:0]
}
// processOutboxBatch fetches and processes a batch of stream entries for the
// given shards. Per-shard serialization (FOR UPDATE on shard_lock) guarantees
// IDs within a shard are committed in order, so a simple cursor is safe.
// Uses RawValues + byteArena to avoid per-row heap allocations from pgx Scan.
func (e *PostgresMapBroker) processOutboxBatch(ctx context.Context, pool *pgxpool.Pool, cursor int64, shardIDs []int, buf *outboxBatchBuf) (int, int64, error) {
batchSize := e.conf.Outbox.BatchSize
rows, err := pool.Query(ctx, fmt.Sprintf(`
SELECT id, shard_id, channel, channel_offset, epoch, key, data, tags, removed,
client_id, user_id, conn_info, chan_info, prev_data, created_at
FROM %s
WHERE id > $1 AND shard_id = ANY($2)
ORDER BY id
LIMIT $3
`, e.names.stream), cursor, shardIDs, batchSize)
if err != nil {
return 0, cursor, fmt.Errorf("query stream: %w", err)
}
buf.reset()
arena := byteArena{}
allocHint := batchSize
if allocHint > 1001 {
allocHint = 1001
}
pubBacking := make([]centrifuge.Publication, 0, allocHint)
infoBacking := make([]centrifuge.ClientInfo, 0, allocHint/4+1)
var maxID int64
// Use RawValues + arena to avoid per-row allocations.
// Column order: id(0), shard_id(1), channel(2), channel_offset(3), epoch(4),
// key(5), data(6), tags(7), removed(8),
// client_id(9), user_id(10), conn_info(11), chan_info(12),
// prev_data(13), created_at(14).
var fmts pgColFormats
for rows.Next() {
if fmts == nil {
fmts = pgColFormatsFromRows(rows)
}
raw := rows.RawValues()
id := pgRawInt64(raw[0], fmts[0])
if id > maxID {
maxID = id
}
pubBacking = append(pubBacking, centrifuge.Publication{})
p := &pubBacking[len(pubBacking)-1]
p.Offset = pgRawUint64(raw[3], fmts[3])
p.Key = pgRawString(&arena, raw[5])
p.Data = e.rawDataBytes(&arena, raw[6], fmts[6])
p.Tags = pgRawJSONBMap(raw[7])
p.Removed = pgRawBool(raw[8], fmts[8])
p.Time = pgRawTimestampMillis(raw[14], fmts[14])
if raw[9] != nil {
infoBacking = append(infoBacking, centrifuge.ClientInfo{
ClientID: pgRawString(&arena, raw[9]),
UserID: pgRawString(&arena, raw[10]),
ConnInfo: e.rawDataBytes(&arena, raw[11], fmts[11]),
ChanInfo: e.rawDataBytes(&arena, raw[12], fmts[12]),
})
p.Info = &infoBacking[len(infoBacking)-1]
}
m := outboxMeta{
id: id,
channel: pgRawString(&arena, raw[2]),
epoch: pgRawString(&arena, raw[4]),
previousData: e.rawDataBytes(&arena, raw[13], fmts[13]),
}
buf.metas = append(buf.metas, m)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, cursor, fmt.Errorf("iterate rows: %w", err)
}
if len(buf.metas) == 0 {
return 0, cursor, nil
}
if e.sampler != nil && len(shardIDs) == 1 {
e.sampler.storeCursor(shardIDs[0], maxID)
}
// Deliver each entry.
for i := range buf.metas {
m := &buf.metas[i]
pub := &pubBacking[i]
streamPos := centrifuge.StreamPosition{Offset: pub.Offset, Epoch: m.epoch}
var prevPub *centrifuge.Publication
useDelta := len(m.previousData) > 0
if useDelta {
prevPub = &centrifuge.Publication{Data: m.previousData}
}
if e.conf.Broker != nil {
// Fan-out via inner Broker (e.g. Redis PUB/SUB).
pubOpts := centrifuge.PublishOptions{
ClientInfo: pub.Info,
Key: pub.Key,
Removed: pub.Removed,
Tags: pub.Tags,
Offset: pub.Offset,
Epoch: m.epoch,
UseDelta: useDelta,
}
if useDelta {
pubOpts.PrevData = m.previousData
}
if _, err := e.conf.Broker.Publish(m.channel, pub.Data, pubOpts); err != nil {
e.logErrorMsg("outbox worker: broker publish", err)
}
} else if e.eventHandler != nil {
_ = e.eventHandler.HandlePublication(m.channel, pub, streamPos, useDelta, prevPub)
}
}
return len(buf.metas), maxID, nil
}
func (e *PostgresMapBroker) logEvent() *zerolog.Event {
ev := log.Error()
if e.conf.Name != "" {
ev = ev.Str("broker_name", e.conf.Name)
}
return ev
}
func (e *PostgresMapBroker) logErrorMsg(msg string, err error) {
e.logEvent().Err(err).Msg(msg)
}
func (e *PostgresMapBroker) logError(msg string, err error, shardID int) {
e.logEvent().Err(err).Int("shard", shardID).Msg(msg)
}
func (e *PostgresMapBroker) logInfo(msg string, shardID int) {
ev := log.Info()
if e.conf.Name != "" {
ev = ev.Str("broker_name", e.conf.Name)
}
ev.Int("shard", shardID).Msg(msg)
}
// ============================================================================
// TTL Expiration Worker
// ============================================================================
// runTTLExpirationWorker expires keys with TTL and emits removal events.
func (e *PostgresMapBroker) runTTLExpirationWorker() {
ticker := time.NewTicker(e.conf.TTLCheckInterval)
defer ticker.Stop()
ctx := e.cancelCtx
for {
select {
case <-e.closeCh:
return
case <-ctx.Done():
return
case <-ticker.C:
e.expireKeys(ctx)
}
}
}
func (e *PostgresMapBroker) expireKeys(ctx context.Context) {
numShards := e.conf.NumShards
// Find distinct channels with expired keys to resolve per-channel options.
channelRows, err := e.pool.Query(ctx, fmt.Sprintf(`
SELECT DISTINCT channel FROM %s
WHERE expires_at IS NOT NULL AND expires_at <= NOW()
LIMIT 100
`, e.names.state))
if err != nil {
e.logErrorMsg("error querying channels for key expiration", err)
e.node.IncMapBrokerCleanupErrors(e.conf.Name)
return
}
var channels []string
for channelRows.Next() {
var ch string
if err := channelRows.Scan(&ch); err != nil {
continue
}
channels = append(channels, ch)
}
if err := channelRows.Err(); err != nil {
e.logErrorMsg("error scanning channels for key expiration", err)
e.node.IncMapBrokerCleanupErrors(e.conf.Name)
channelRows.Close()
return
}
channelRows.Close()
// Process each channel with its own resolved options.
for _, ch := range channels {
chOpts, err := centrifuge.ResolveAndValidateMapChannelOptions(e.node.Config().Map.GetMapChannelOptions, ch)
if err != nil {
e.logEvent().Err(err).Str("channel", ch).Msg("error resolving channel options for key expiration")
e.node.IncMapBrokerCleanupErrors(e.conf.Name)
continue
}
var metaTTL *string
if chOpts.MetaTTL > 0 {
s := durationToIntervalString(chOpts.MetaTTL)
metaTTL = &s
}
// Call expire_keys SQL function which atomically:
// 1. Deletes expired keys from state
// 2. Inserts removal events into the stream
// The outbox worker will pick up the stream entries and deliver them
// via HandlePublication — we must NOT call HandlePublication here to avoid
// duplicate delivery.
rows, err := e.pool.Query(ctx, fmt.Sprintf(`
SELECT out_channel, out_key, out_offset, out_epoch
FROM %s($1, $2, $3::interval, $4)
`, e.names.expireKeys), 1000, numShards, metaTTL, ch)
if err != nil {
e.logEvent().Err(err).Str("channel", ch).Msg("error in batch key expiration")
e.node.IncMapBrokerCleanupErrors(e.conf.Name)
continue
}
// Count rows to track keys removed.
var removedCount int64
for rows.Next() {
removedCount++
}
if err := rows.Err(); err != nil {
e.logEvent().Err(err).Str("channel", ch).Msg("error reading key expiration results")
e.node.IncMapBrokerCleanupErrors(e.conf.Name)
}
rows.Close()
if removedCount > 0 {
e.node.AddMapBrokerCleanupRemoved(e.conf.Name, removedCount)
}
}
}
// runCleanupLagWorker periodically checks the oldest expired entry in the state
// table and reports it as a lag metric. This runs in a separate goroutine to
// avoid adding extra queries to the hot cleanup path.
func (e *PostgresMapBroker) runCleanupLagWorker() {
interval := e.conf.TTLCheckInterval
if interval <= 0 {
interval = time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
ctx := e.cancelCtx
for {
select {
case <-e.closeCh:
return
case <-ctx.Done():
return
case <-ticker.C:
e.updateCleanupLag(ctx)
}
}
}
func (e *PostgresMapBroker) updateCleanupLag(ctx context.Context) {
var expiresAt *time.Time
err := e.pool.QueryRow(ctx, fmt.Sprintf(`
SELECT MIN(expires_at) FROM %s
WHERE expires_at IS NOT NULL AND expires_at <= NOW()
`, e.names.state)).Scan(&expiresAt)
if err != nil || expiresAt == nil {
e.node.SetMapBrokerCleanupLag(e.conf.Name, 0)
return
}
lagSeconds := time.Since(*expiresAt).Seconds()
if lagSeconds < 0 {
lagSeconds = 0
}
e.node.SetMapBrokerCleanupLag(e.conf.Name, lagSeconds)
}
// ============================================================================
// Cleanup Worker
// ============================================================================
// runCleanupWorker cleans up old stream entries and expired meta/idempotency entries.
func (e *PostgresMapBroker) runCleanupWorker() {
ticker := time.NewTicker(e.conf.CleanupInterval)
defer ticker.Stop()
ctx := e.cancelCtx
for {
select {
case <-e.closeCh:
return
case <-ctx.Done():
return
case <-ticker.C:
e.cleanupEntries(ctx)
if e.sampler != nil {
e.sampler.sample(ctx)
}
}
}
}
// cleanupEntries deletes expired meta and idempotency rows in bounded batches
// to avoid long-running DELETE locks on busy tables. History rows on the stream
// table are cleaned up by partition retention (DROP TABLE, vacuum-free) — no
// chunked DELETE is needed there.
func (e *PostgresMapBroker) cleanupEntries(ctx context.Context) {
e.cleanupExpiredBatched(ctx, fmt.Sprintf(
`DELETE FROM %[1]s WHERE channel IN (
SELECT channel FROM %[1]s
WHERE expires_at IS NOT NULL AND expires_at < NOW()
LIMIT $1
)`, e.names.meta))
e.cleanupExpiredBatched(ctx, fmt.Sprintf(
`DELETE FROM %[1]s WHERE (channel, idempotency_key) IN (
SELECT channel, idempotency_key FROM %[1]s
WHERE expires_at < NOW()
LIMIT $1
)`, e.names.idempotency))
}
// cleanupExpiredBatched runs a chunked DELETE loop, pausing between batches
// to give autovacuum room. Stops when a batch is under the batch size or ctx is done.
func (e *PostgresMapBroker) cleanupExpiredBatched(ctx context.Context, query string) {
for {
res, err := e.pool.Exec(ctx, query, e.conf.CleanupBatchSize)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
e.logErrorMsg("error cleaning up expired rows", err)
}
return
}
if res.RowsAffected() < int64(e.conf.CleanupBatchSize) {
return
}
select {
case <-ctx.Done():
return
case <-e.closeCh:
return
case <-time.After(e.conf.CleanupChunkPause):
}
}
}
// ============================================================================
// Partitioning Support (optional optimization)
// ============================================================================
// ensurePartitionedStream verifies the stream table is partitioned and
// pre-creates the lookahead partitions. The schema.sql template now creates
// the table as PARTITION BY RANGE from the start, so this method's job is
// reduced to:
//
// 1. Probe that the existing table is actually partitioned (if a previous
// unreleased build of pgmapbroker created it as a plain table, the
// CREATE TABLE IF NOT EXISTS in the schema template silently skipped
// re-creation, leaving an incompatible schema). Fail loudly with a
// clear "drop the legacy tables manually" error.
// 2. Pre-create today's + lookahead partitions via the pgoutbox helper.
//
// The stream table has been PARTITION BY RANGE since the broker shipped, so
// no released version can hold the legacy shape and no automatic migration is
// provided — an operator carrying it over from a pre-release build drops the
// legacy tables and starts fresh.
func (e *PostgresMapBroker) ensurePartitionedStream(ctx context.Context) error {
// Probe: verify the stream table is actually partitioned. The CREATE TABLE
// IF NOT EXISTS in the schema template skips creation if a non-partitioned
// table already exists, which would leave the broker running on an
// incompatible schema. Fail loudly.
var isPartitioned bool
err := e.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM pg_partitioned_table
WHERE partrelid = $1::regclass
)
`, e.names.stream).Scan(&isPartitioned)
if err != nil {
// Table doesn't exist (or other lookup error). The schema template
// CREATE TABLE will run after this check, so missing-table is fine.
// Fall through to lookahead-partition creation, which will fail
// noisily if the table really doesn't exist.
isPartitioned = true // skip the probe-failure error path
}
if !isPartitioned {
return &SchemaError{
Object: SchemaObject{Type: "table", Name: e.names.stream},
Op: "verify",
Err: fmt.Errorf(
"%s exists but is not partitioned. pgmapbroker schema has changed "+
"and this build does not include migration logic. Drop the "+
"existing tables manually with: DROP TABLE IF EXISTS %s, %s, %s, %s, %s CASCADE",
e.names.stream,
e.names.stream, e.names.state, e.names.meta, e.names.idempotency, e.names.shardLock,
),
}
}
// Pre-create today's + lookahead partitions so the first publish doesn't
// fail with "no partition for value".
p := e.newPartitioner()
if err := p.EnsureLookaheadPartitions(ctx); err != nil {
return &SchemaError{
Object: SchemaObject{Type: "table", Name: e.names.stream},
Op: "create",
Err: err,
}
}
return nil
}
// newPartitioner constructs a pgoutbox.Partitioner configured for the
// map broker's stream table. Used by ensurePartitionedStream (one-shot)
// and runPartitionWorker (periodic maintenance).
func (e *PostgresMapBroker) newPartitioner() *pgoutbox.Partitioner {
return &pgoutbox.Partitioner{
Pool: e.pool,
ParentTable: e.names.stream,
CleanupInterval: e.conf.CleanupInterval,
LookaheadDays: e.conf.PartitionLookaheadDays,
RetentionDays: e.conf.PartitionRetentionDays,
ErrorFn: e.logErrorMsg,
}
}
// 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
}
// rawDataBytes reads a data column (JSONB or BYTEA depending on BinaryData config)
// using the correct format-aware parser.
func (e *PostgresMapBroker) rawDataBytes(a *byteArena, b []byte, format int16) []byte {
if e.conf.BinaryData {
return pgRawBytes(a, b, format)
}
return pgRawJSONBBytes(a, b, format)
}
// dataParam wraps a []byte for use as a SQL parameter.
// When BinaryData is false, data columns are JSONB — wrap as json.RawMessage
// so pgx encodes it as JSON text (not hex bytea) in simple-protocol mode.
// When BinaryData is true, data columns are BYTEA — pass as plain []byte.
func (e *PostgresMapBroker) dataParam(b []byte) any {
if b == nil {
return nil
}
if !e.conf.BinaryData {
return json.RawMessage(b)
}
return b
}
// runPartitionWorker manages partition creation and cleanup. Thin wrapper
// around pgoutbox.Partitioner.Run.
func (e *PostgresMapBroker) runPartitionWorker() {
p := e.newPartitioner()
p.Run(e.cancelCtx, e.closeCh)
}