661 lines
22 KiB
Go
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
|
|
}
|