snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
660
internal/pgstreambroker/pgstreambroker.go
Normal file
660
internal/pgstreambroker/pgstreambroker.go
Normal file
@@ -0,0 +1,660 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user