2380 lines
81 KiB
Go
2380 lines
81 KiB
Go
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, ¤tData, ¤tOffset)
|
||
|
||
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 = ¢rifuge.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, ¤tData, ¤tOffset)
|
||
|
||
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 = ¢rifuge.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 = ¢rifuge.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 = ¢rifuge.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 = ¢rifuge.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 = ¢rifuge.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 = ¢rifuge.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)
|
||
}
|