snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:17:21 +05:00
commit b352a9a30d
472 changed files with 108375 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
package pgmapbroker
import (
"strings"
"testing"
"github.com/centrifugal/centrifuge"
)
// A table prefix reaches DDL unquoted, so one PostgreSQL can't parse has to be
// caught at construction — otherwise it surfaces much later as a syntax error
// naming a generated table rather than the setting behind it.
func TestNewPostgresMapBroker_RejectsBadTablePrefix(t *testing.T) {
node, err := centrifuge.New(centrifuge.Config{})
if err != nil {
t.Fatalf("centrifuge.New: %v", err)
}
t.Cleanup(func() { _ = node.Shutdown(t.Context()) })
// Rejected before any connection is attempted, so this needs no Postgres.
_, err = NewPostgresMapBroker(node, PostgresMapBrokerConfig{
DSN: "postgres://test:test@localhost:5432/test",
TablePrefix: "cf-prod",
})
if err == nil {
t.Fatal("expected an error for a prefix PostgreSQL cannot parse unquoted")
}
if !strings.Contains(err.Error(), "table_prefix") {
t.Fatalf("error should name the setting at fault, got: %v", err)
}
}

View File

@@ -0,0 +1,23 @@
# Schema Migrations
Migration files for PostgreSQL MapBroker schema upgrades.
The PostgreSQL MapBroker lives in Centrifugo at `internal/pgmapbroker/`.
## Conventions
- Files named `NNN.sql` where NNN is the target version (e.g., `002.sql`).
- Version 1 is the baseline (applied via full DDL in `schema.sql`).
- Each migration MUST be idempotent (use `IF NOT EXISTS`, `IF EXISTS`, etc.).
- Each migration MUST target BOTH prefixes (`cf_map_*` and `cf_binary_map_*`).
- No template placeholders -- use explicit table names.
## Example
```sql
-- Migration 002: Add foo column
ALTER TABLE cf_map_state ADD COLUMN IF NOT EXISTS foo TEXT;
ALTER TABLE cf_binary_map_state ADD COLUMN IF NOT EXISTS foo TEXT;
```
See `postgres_schema.md` for the full developer workflow.

View File

@@ -0,0 +1,69 @@
# PostgreSQL MapBroker Schema Management
## Overview
`EnsureSchema()` manages the PostgreSQL schema for MapBroker automatically. It creates **both** JSONB and BYTEA schema variants in a single call (regardless of `BinaryData` config), uses integer-based versioning, and supports forward migrations.
The PostgreSQL MapBroker lives in Centrifugo at `internal/pgmapbroker/`.
## What EnsureSchema handles automatically
| Change type | Mechanism | Migration needed? |
|---|---|---|
| New table | `CREATE TABLE IF NOT EXISTS` | No |
| New index | `CREATE INDEX IF NOT EXISTS` | No |
| Function body change | `CREATE OR REPLACE FUNCTION` | No |
| New function param with DEFAULT | `CREATE OR REPLACE FUNCTION` | No |
| New column on existing table | -- | **Yes** (`ALTER TABLE ADD COLUMN IF NOT EXISTS`) |
| Column type change | -- | **Yes** (`ALTER TABLE ALTER COLUMN TYPE`) |
| Function signature change (param/return types) | -- | **Yes** (`DROP FUNCTION` + `CREATE`) |
| Drop column | -- | **Yes** (two-phase: stop using, then drop) |
## Schema versioning
- Version is stored in `cf_map_schema_version` (and `cf_binary_map_schema_version`).
- On startup: if version matches and a probe query succeeds, all DDL is skipped (fast path).
- Fresh installs: DDL creates latest schema, version set to current. Migrations are skipped.
- Upgrades: DDL re-applied (idempotent), then migrations run in order from `dbVersion+1` to `schemaVersion`.
## Rolling deploy rules
1. **Additive changes are safe.** New columns with DEFAULT, new function params with DEFAULT -- old nodes ignore them, new nodes use them.
2. **Destructive changes need two phases.** Phase 1: Deploy new code that stops using the old column. Phase 2: Deploy migration that drops it.
3. **Function signature changes need coordination.** Options: accept brief errors during DROP+CREATE (<1s), or use a new function name (zero-downtime).
4. **EnsureSchema runs once per startup.** Concurrent runs are safe (idempotent DDL + retry on deadlock).
5. **NumShards changes require full restart** (not rolling). Concurrent nodes with different NumShards race on shard_lock population.
6. **Rollback (downgrade) is safe.** EnsureSchema overwrites functions to the older version. Extra columns from newer migrations remain but are ignored by older code. No data loss.
## Manual migration path
1. **Fresh install:** Apply `internal/pgmapbroker/internal/sql/schema_all.sql`.
2. **Check version:** `SELECT schema_version FROM cf_map_schema_version WHERE id = 1`.
3. **Apply migrations in order:** files in `internal/pgmapbroker/internal/sql/migrations/`.
4. **EnsureSchema optional:** Safe to keep (no-op when version matches), safe to disable.
## Adding a migration (developer workflow)
1. Bump `schemaVersion` in `internal/pgmapbroker/pgmapbroker.go`.
2. Create `internal/pgmapbroker/internal/sql/migrations/NNN.sql` -- explicit SQL for both prefixes, idempotent.
3. Add entry to `schemaMigrations` map (embed the file).
4. Update `schema.sql` template to reflect the final state (fresh installs get everything).
5. `make pg-schemas` to regenerate.
6. **Invariant:** fresh install via DDL + upgrade via migrations must produce identical schema.
## Migration file conventions
Migration files are plain SQL targeting both prefixes explicitly:
```sql
-- Migration 002: Add foo column
ALTER TABLE cf_map_state ADD COLUMN IF NOT EXISTS foo TEXT;
ALTER TABLE cf_binary_map_state ADD COLUMN IF NOT EXISTS foo TEXT;
```
No template placeholders in migration files -- explicit is safer for production migrations.
**Requirements on migration authors:**
- All migrations MUST be idempotent (`ADD COLUMN IF NOT EXISTS`, etc.)
- All migrations MUST be backwards-compatible (old code continues working after migration)
- Each migration targets BOTH `cf_map_*` and `cf_binary_map_*` prefixes

View File

@@ -0,0 +1,703 @@
-- ============================================================================
-- PostgreSQL MapBroker Schema and Functions (template).
-- Auto-created by EnsureSchema(). All statements are idempotent.
-- Placeholders are replaced at runtime by PostgresMapBroker.renderSchema:
-- __DATA_TYPE__ → JSONB (default) or BYTEA (when BinaryData is true)
-- __PREFIX__ → <TablePrefix>_map_ or <TablePrefix>_binary_map_
-- where TablePrefix is user-configurable (default "cf").
-- EnsureSchema renders this template once per variant (JSONB + binary) and
-- executes both, so a broker always creates both sets of tables regardless
-- of the BinaryData runtime flag.
-- ============================================================================
-- Stream Table (Change History + Fan-out)
-- Stream table is always partitioned by created_at (daily). The composite
-- primary key (id, created_at) is required because Postgres mandates that
-- the partition key be part of every unique constraint. Initial partitions
-- (today + lookahead) are created via pgoutbox.Partitioner.EnsureLookaheadPartitions
-- after this DDL runs; the partition retention worker drops old partitions.
CREATE TABLE IF NOT EXISTS __PREFIX__stream (
id BIGSERIAL,
channel TEXT NOT NULL,
channel_offset BIGINT NOT NULL,
epoch TEXT NOT NULL DEFAULT '',
key TEXT NOT NULL,
data __DATA_TYPE__,
tags JSONB,
client_id TEXT,
user_id TEXT,
conn_info __DATA_TYPE__,
chan_info __DATA_TYPE__,
removed BOOLEAN DEFAULT FALSE,
prev_data __DATA_TYPE__,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
shard_id SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- (channel, epoch, channel_offset) lets ReadStream seek directly past dead-epoch
-- rows that linger between meta TTL expiry and the next partition retention
-- drop, instead of fetching them and discarding by filter.
CREATE INDEX IF NOT EXISTS __PREFIX__stream_channel_epoch_offset_idx ON __PREFIX__stream (channel, epoch, channel_offset);
CREATE INDEX IF NOT EXISTS __PREFIX__stream_channel_id_idx ON __PREFIX__stream (channel, id DESC);
CREATE INDEX IF NOT EXISTS __PREFIX__stream_shard_id_idx ON __PREFIX__stream (shard_id, id);
CREATE INDEX IF NOT EXISTS __PREFIX__stream_created_at_idx ON __PREFIX__stream (created_at);
-- ============================================================================
-- Snapshot Table (Current State)
-- ============================================================================
CREATE TABLE IF NOT EXISTS __PREFIX__state (
channel TEXT NOT NULL,
key TEXT NOT NULL,
data __DATA_TYPE__,
tags JSONB,
client_id TEXT,
user_id TEXT,
conn_info __DATA_TYPE__,
chan_info __DATA_TYPE__,
key_version BIGINT DEFAULT 0,
key_version_epoch TEXT,
key_offset BIGINT NOT NULL,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (channel, key)
);
CREATE INDEX IF NOT EXISTS __PREFIX__state_expires_idx
ON __PREFIX__state (expires_at)
WHERE expires_at IS NOT NULL;
-- ============================================================================
-- Stream Metadata Table
-- ============================================================================
CREATE TABLE IF NOT EXISTS __PREFIX__meta (
channel TEXT PRIMARY KEY,
top_offset BIGINT NOT NULL DEFAULT 0,
epoch TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS __PREFIX__meta_expires_idx
ON __PREFIX__meta (expires_at)
WHERE expires_at IS NOT NULL;
-- ============================================================================
-- Idempotency Table
-- ============================================================================
CREATE TABLE IF NOT EXISTS __PREFIX__idempotency (
channel TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
result_offset BIGINT NOT NULL,
-- result_epoch captures the meta epoch at the time the original publish
-- committed. Returned on idempotent replay so callers reading history from
-- the replied position can find it — otherwise, if the meta row had been
-- TTL-evicted and recreated under a fresh random epoch by the time of the
-- replay, the suppressed reply would return the live (new) epoch and the
-- caller's recovery would fail to locate the publication.
result_epoch TEXT NOT NULL,
result_id BIGINT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (channel, idempotency_key)
);
CREATE INDEX IF NOT EXISTS __PREFIX__idempotency_expires_idx ON __PREFIX__idempotency (expires_at);
-- ============================================================================
-- Shard Lock Table
-- ============================================================================
CREATE TABLE IF NOT EXISTS __PREFIX__shard_lock (
shard_id SMALLINT PRIMARY KEY
);
-- ============================================================================
-- Schema Version
-- ============================================================================
CREATE TABLE IF NOT EXISTS __PREFIX__schema_version (
id INTEGER PRIMARY KEY,
schema_version INTEGER NOT NULL
);
INSERT INTO __PREFIX__schema_version (id, schema_version) VALUES (1, 1)
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- Functions
-- ============================================================================
CREATE OR REPLACE FUNCTION __PREFIX__publish(
p_channel TEXT,
p_key TEXT,
p_data __DATA_TYPE__,
p_tags JSONB DEFAULT NULL,
p_key_ttl INTERVAL DEFAULT NULL,
p_meta_ttl INTERVAL DEFAULT NULL,
p_key_mode TEXT DEFAULT NULL,
p_expected_offset BIGINT DEFAULT NULL,
p_expected_epoch TEXT DEFAULT NULL,
p_key_version BIGINT DEFAULT NULL,
p_key_version_epoch TEXT DEFAULT NULL,
p_idempotency_key TEXT DEFAULT NULL,
p_idempotency_ttl INTERVAL DEFAULT NULL,
p_use_delta BOOLEAN DEFAULT FALSE,
p_client_id TEXT DEFAULT NULL,
p_user_id TEXT DEFAULT NULL,
p_conn_info __DATA_TYPE__ DEFAULT NULL,
p_chan_info __DATA_TYPE__ DEFAULT NULL,
p_refresh_ttl_on_suppress BOOLEAN DEFAULT FALSE,
p_num_shards INTEGER DEFAULT NULL
) RETURNS TABLE(
result_id BIGINT,
channel_offset BIGINT,
epoch TEXT,
suppressed BOOLEAN,
suppress_reason TEXT,
current_data __DATA_TYPE__,
current_offset BIGINT
) AS $$
DECLARE
v_offset BIGINT;
v_id BIGINT;
v_epoch TEXT;
v_exists BOOLEAN;
v_current_offset BIGINT;
v_current_data __DATA_TYPE__;
v_previous_data __DATA_TYPE__;
v_current_version BIGINT;
v_current_version_epoch TEXT;
v_idempotent_epoch TEXT;
v_shard_id INTEGER;
v_meta_inserted BOOLEAN;
BEGIN
-- Validate: meta_ttl must outlive key_ttl. When keys are permanent
-- (p_key_ttl IS NULL), meta must also be permanent (p_meta_ttl IS NULL).
IF p_meta_ttl IS NOT NULL AND p_key_ttl IS NULL THEN
RAISE EXCEPTION 'meta_ttl must be NULL (permanent) when key_ttl is NULL (permanent keys)';
END IF;
IF p_meta_ttl IS NOT NULL AND p_key_ttl IS NOT NULL AND p_meta_ttl < p_key_ttl THEN
RAISE EXCEPTION 'meta_ttl must be >= key_ttl (metadata must outlive keys)';
END IF;
-- Auto-derive num_shards from shard_lock table when not provided.
IF p_num_shards IS NULL THEN
SELECT COUNT(*)::INTEGER INTO p_num_shards FROM __PREFIX__shard_lock;
END IF;
IF p_num_shards <= 0 THEN
RAISE EXCEPTION 'shard_lock table is empty — run EnsureSchema first';
END IF;
-- Calculate shard_id from channel hash
v_shard_id := abs(hashtext(p_channel)::bigint) % p_num_shards;
-- 0. Per-shard serialization lock (lock order: shard → meta → state)
PERFORM 1 FROM __PREFIX__shard_lock WHERE shard_id = v_shard_id FOR UPDATE;
-- 1. Get or create stream metadata. Atomic UPSERT with no-op write on
-- conflict acquires the row lock as part of the same statement, avoiding
-- the race where DO NOTHING + SELECT FOR UPDATE could return zero rows
-- if a concurrent cleanup deleted the meta between the two statements.
-- Qualified column references (m.top_offset, m.epoch) disambiguate from
-- the function's OUT parameter named "epoch".
--
-- (xmax = 0) distinguishes a fresh INSERT from a CONFLICT-DO-UPDATE: when
-- the meta row was just created (e.g. cleanupEntries dropped the prior
-- one), we wipe the channel's state below — otherwise zombie state rows
-- from the dead epoch would be returned by ReadState/Stats with stale
-- key_offset values until expire_keys catches up. Mirrors the Redis
-- broker's `if top_offset == 1 then del stream_key` safety net.
INSERT INTO __PREFIX__meta AS m (channel, top_offset, epoch, updated_at)
VALUES (p_channel, 0, substr(md5(random()::text || random()::text), 1, 8), NOW())
ON CONFLICT (channel) DO UPDATE
SET updated_at = m.updated_at
RETURNING m.top_offset, m.epoch, (xmax = 0) INTO v_offset, v_epoch, v_meta_inserted;
IF v_meta_inserted THEN
DELETE FROM __PREFIX__state WHERE channel = p_channel;
END IF;
-- 2. Check idempotency. Return the epoch the publication was committed
-- under (stored in the idempotency row), NOT the current meta epoch —
-- those can differ if meta was TTL-evicted and recreated between the
-- original publish and this replay.
IF p_idempotency_key IS NOT NULL THEN
SELECT result_offset, result_epoch INTO v_current_offset, v_idempotent_epoch
FROM __PREFIX__idempotency
WHERE channel = p_channel AND idempotency_key = p_idempotency_key
AND expires_at > NOW();
IF FOUND THEN
RETURN QUERY SELECT NULL::BIGINT, v_current_offset, v_idempotent_epoch, TRUE,
'idempotency'::TEXT, NULL::__DATA_TYPE__, NULL::BIGINT;
RETURN;
END IF;
END IF;
-- Canonical check order across brokers: Idempotency → Version → KeyMode → CAS.
-- Dedup checks (idempotency, version) drop duplicates first; constraint checks
-- (KeyMode, CAS) report meaningful intent failures last.
-- 3. Per-key version check (dedup)
IF p_key_version IS NOT NULL AND p_key IS NOT NULL AND p_key != '' THEN
SELECT sn.key_version, sn.key_version_epoch
INTO v_current_version, v_current_version_epoch
FROM __PREFIX__state sn WHERE sn.channel = p_channel AND sn.key = p_key;
IF FOUND AND v_current_version IS NOT NULL AND v_current_version > 0 THEN
-- Empty p_key_version_epoch matches any stored epoch (matches the
-- stream broker contract: "I don't care about epoch boundaries,
-- just compare versions"). A non-empty value that differs resets
-- the comparison so a new epoch isn't suppressed by a stale
-- higher version under a previous epoch.
IF (p_key_version_epoch IS NULL OR p_key_version_epoch = '' OR p_key_version_epoch = v_current_version_epoch)
AND p_key_version <= v_current_version THEN
RETURN QUERY SELECT NULL::BIGINT, v_offset, v_epoch, TRUE, 'version'::TEXT, NULL::__DATA_TYPE__, NULL::BIGINT;
RETURN;
END IF;
END IF;
END IF;
-- 4. KeyMode check — physical existence in state table, matching Redis HEXISTS.
-- Zombie entries (TTL passed but cleanup worker hasn't emitted LEAVE yet) still
-- count as existing: from the channel's perspective, the key is still "live" until
-- the removal publication is written to the stream.
IF p_key_mode IS NOT NULL THEN
SELECT EXISTS(SELECT 1 FROM __PREFIX__state WHERE channel = p_channel AND key = p_key) INTO v_exists;
IF p_key_mode = 'if_new' AND v_exists THEN
IF p_refresh_ttl_on_suppress AND p_key_ttl IS NOT NULL THEN
UPDATE __PREFIX__state SET expires_at = NOW() + p_key_ttl, updated_at = NOW()
WHERE channel = p_channel AND key = p_key;
-- Keepalive must extend meta TTL too, otherwise meta expires while
-- keys are being refreshed and the next publish creates a new epoch
-- (channel "blinks" — clients see ErrorUnrecoverablePosition).
IF p_meta_ttl IS NOT NULL THEN
UPDATE __PREFIX__meta SET expires_at = NOW() + p_meta_ttl, updated_at = NOW()
WHERE channel = p_channel;
END IF;
END IF;
RETURN QUERY SELECT NULL::BIGINT, v_offset, v_epoch, TRUE, 'key_exists'::TEXT, NULL::__DATA_TYPE__, NULL::BIGINT;
RETURN;
END IF;
IF p_key_mode = 'if_exists' AND NOT v_exists THEN
RETURN QUERY SELECT NULL::BIGINT, v_offset, v_epoch, TRUE, 'key_not_found'::TEXT, NULL::__DATA_TYPE__, NULL::BIGINT;
RETURN;
END IF;
END IF;
-- 5. CAS check (ExpectedPosition). Compare BOTH offset and epoch (when
-- the caller supplies an expected epoch) so that a CAS using a position
-- from a dead epoch — e.g. after a Clear+republish that flips the channel
-- epoch but produces matching offsets — is correctly rejected. Memory and
-- Redis brokers compare both; PostgreSQL must match for cross-broker
-- consistency.
IF p_expected_offset IS NOT NULL THEN
SELECT key_offset, sn.data INTO v_current_offset, v_current_data
FROM __PREFIX__state sn WHERE sn.channel = p_channel AND sn.key = p_key;
IF NOT FOUND
OR v_current_offset != p_expected_offset
OR (p_expected_epoch IS NOT NULL AND v_epoch != p_expected_epoch) THEN
RETURN QUERY SELECT NULL::BIGINT, v_offset, v_epoch, TRUE,
'position_mismatch'::TEXT, v_current_data, v_current_offset;
RETURN;
END IF;
END IF;
-- 6. All checks passed - increment offset
UPDATE __PREFIX__meta SET
top_offset = top_offset + 1,
expires_at = COALESCE(CASE WHEN p_meta_ttl IS NOT NULL THEN NOW() + p_meta_ttl ELSE NULL END, expires_at),
updated_at = NOW()
WHERE channel = p_channel
RETURNING top_offset INTO v_offset;
-- 6b. Fetch previous data for key-based delta (before UPSERT overwrites it)
IF p_use_delta AND p_key IS NOT NULL AND p_key != '' THEN
SELECT sn.data INTO v_previous_data
FROM __PREFIX__state sn WHERE sn.channel = p_channel AND sn.key = p_key;
END IF;
-- 7. Update snapshot
INSERT INTO __PREFIX__state (
channel, key, data, tags, client_id, user_id, conn_info, chan_info,
key_version, key_version_epoch, key_offset, expires_at, updated_at
) VALUES (
p_channel, p_key, p_data, p_tags, p_client_id, p_user_id, p_conn_info, p_chan_info,
p_key_version, p_key_version_epoch, v_offset,
CASE WHEN p_key_ttl IS NOT NULL THEN NOW() + p_key_ttl ELSE NULL END, NOW()
)
ON CONFLICT (channel, key) DO UPDATE SET
data = EXCLUDED.data, tags = EXCLUDED.tags,
client_id = EXCLUDED.client_id, user_id = EXCLUDED.user_id,
conn_info = EXCLUDED.conn_info, chan_info = EXCLUDED.chan_info,
-- Preserve stored version when caller publishes without one (matches Redis).
-- Overwriting with NULL would erase dedup protection against late-arriving
-- older versions from a concurrent producer.
key_version = COALESCE(EXCLUDED.key_version, __PREFIX__state.key_version),
key_version_epoch = COALESCE(EXCLUDED.key_version_epoch, __PREFIX__state.key_version_epoch),
key_offset = EXCLUDED.key_offset, expires_at = EXCLUDED.expires_at, updated_at = NOW();
-- 8. Insert into stream (include epoch, shard_id, and prev_data for delta)
INSERT INTO __PREFIX__stream (
channel, channel_offset, epoch, key, data, tags, client_id, user_id, conn_info, chan_info, prev_data, shard_id
) VALUES (
p_channel, v_offset, v_epoch, p_key, p_data, p_tags, p_client_id, p_user_id, p_conn_info, p_chan_info, v_previous_data,
v_shard_id
) RETURNING __PREFIX__stream.id INTO v_id;
-- 9. Save idempotency key. Persist the epoch alongside the offset so
-- replays return the (offset, epoch) tuple this publish committed under,
-- not whatever epoch happens to live in meta at replay time.
IF p_idempotency_key IS NOT NULL THEN
INSERT INTO __PREFIX__idempotency (channel, idempotency_key, result_offset, result_epoch, result_id, expires_at)
VALUES (p_channel, p_idempotency_key, v_offset, v_epoch, v_id, NOW() + COALESCE(p_idempotency_ttl, INTERVAL '5 minutes'))
ON CONFLICT DO NOTHING;
END IF;
-- 10. Notify outbox workers
PERFORM pg_notify('__PREFIX__stream_notify', '');
RETURN QUERY SELECT v_id, v_offset, v_epoch, FALSE, NULL::TEXT, NULL::__DATA_TYPE__, NULL::BIGINT;
END;
$$ LANGUAGE plpgsql;
-- __PREFIX__publish_strict: Auto-rollback on suppression
CREATE OR REPLACE FUNCTION __PREFIX__publish_strict(
p_channel TEXT,
p_key TEXT,
p_data __DATA_TYPE__,
p_tags JSONB DEFAULT NULL,
p_key_ttl INTERVAL DEFAULT NULL,
p_meta_ttl INTERVAL DEFAULT NULL,
p_key_mode TEXT DEFAULT NULL,
p_expected_offset BIGINT DEFAULT NULL,
p_expected_epoch TEXT DEFAULT NULL,
p_key_version BIGINT DEFAULT NULL,
p_key_version_epoch TEXT DEFAULT NULL,
p_idempotency_key TEXT DEFAULT NULL,
p_idempotency_ttl INTERVAL DEFAULT NULL,
p_use_delta BOOLEAN DEFAULT FALSE,
p_client_id TEXT DEFAULT NULL,
p_user_id TEXT DEFAULT NULL,
p_conn_info __DATA_TYPE__ DEFAULT NULL,
p_chan_info __DATA_TYPE__ DEFAULT NULL,
p_refresh_ttl_on_suppress BOOLEAN DEFAULT FALSE,
p_num_shards INTEGER DEFAULT NULL
) RETURNS TABLE(
result_id BIGINT,
channel_offset BIGINT,
epoch TEXT
) AS $$
DECLARE
v_result RECORD;
BEGIN
SELECT * INTO v_result
FROM __PREFIX__publish(
p_channel, p_key, p_data, p_tags,
p_key_ttl, p_meta_ttl, p_key_mode,
p_expected_offset, p_expected_epoch, p_key_version, p_key_version_epoch,
p_idempotency_key, p_idempotency_ttl, p_use_delta,
p_client_id, p_user_id, p_conn_info, p_chan_info,
p_refresh_ttl_on_suppress, p_num_shards
);
IF v_result.suppressed THEN
CASE v_result.suppress_reason
WHEN 'key_exists' THEN
RAISE EXCEPTION '__PREFIX__publish: key already exists: %.%', p_channel, p_key
USING ERRCODE = 'unique_violation';
WHEN 'key_not_found' THEN
RAISE EXCEPTION '__PREFIX__publish: key not found: %.%', p_channel, p_key
USING ERRCODE = 'no_data_found';
WHEN 'position_mismatch' THEN
RAISE EXCEPTION '__PREFIX__publish: CAS conflict on %.%', p_channel, p_key
USING ERRCODE = 'serialization_failure',
DETAIL = v_result.current_data::TEXT;
WHEN 'version' THEN
RAISE EXCEPTION '__PREFIX__publish: version conflict on %.%', p_channel, p_key
USING ERRCODE = 'serialization_failure';
WHEN 'idempotency' THEN
RAISE EXCEPTION '__PREFIX__publish: duplicate idempotency key: %.%', p_channel, p_key
USING ERRCODE = 'unique_violation';
ELSE
RAISE EXCEPTION '__PREFIX__publish: suppressed: %', v_result.suppress_reason
USING ERRCODE = 'raise_exception';
END CASE;
END IF;
RETURN QUERY SELECT v_result.result_id, v_result.channel_offset, v_result.epoch;
END;
$$ LANGUAGE plpgsql;
-- __PREFIX__remove: Remove a key
CREATE OR REPLACE FUNCTION __PREFIX__remove(
p_channel TEXT,
p_key TEXT,
p_meta_ttl INTERVAL DEFAULT NULL,
p_expected_offset BIGINT DEFAULT NULL,
p_expected_epoch TEXT DEFAULT NULL,
p_idempotency_key TEXT DEFAULT NULL,
p_idempotency_ttl INTERVAL DEFAULT NULL,
p_client_id TEXT DEFAULT NULL,
p_user_id TEXT DEFAULT NULL,
p_num_shards INTEGER DEFAULT NULL
) RETURNS TABLE(
result_id BIGINT,
channel_offset BIGINT,
epoch TEXT,
suppressed BOOLEAN,
suppress_reason TEXT,
current_data __DATA_TYPE__,
current_offset BIGINT
) AS $$
DECLARE
v_offset BIGINT;
v_id BIGINT;
v_epoch TEXT;
v_exists BOOLEAN;
v_shard_id INTEGER;
v_current_offset BIGINT;
v_current_data __DATA_TYPE__;
v_tags JSONB;
v_idempotent_epoch TEXT;
BEGIN
-- Auto-derive num_shards from shard_lock table when not provided.
IF p_num_shards IS NULL THEN
SELECT COUNT(*)::INTEGER INTO p_num_shards FROM __PREFIX__shard_lock;
END IF;
IF p_num_shards <= 0 THEN
RAISE EXCEPTION 'shard_lock table is empty — run EnsureSchema first';
END IF;
-- Calculate shard_id from channel hash
v_shard_id := abs(hashtext(p_channel)::bigint) % p_num_shards;
-- 0. Per-shard serialization lock (lock order: shard → meta → state)
PERFORM 1 FROM __PREFIX__shard_lock WHERE shard_id = v_shard_id FOR UPDATE;
-- 1. Get stream metadata under FOR UPDATE so cleanupEntries (which doesn't
-- take the shard lock) can't delete the meta row between this read and
-- the UPDATE in step 5. Without the lock, a concurrent cleanup could leave
-- this function inserting a stream row under a dead epoch with a stale
-- v_offset (the UPDATE would match 0 rows but RETURNING ... INTO doesn't
-- reset v_offset on no-match).
SELECT top_offset, m.epoch INTO v_offset, v_epoch
FROM __PREFIX__meta m WHERE m.channel = p_channel
FOR UPDATE;
IF NOT FOUND THEN
RETURN QUERY SELECT NULL::BIGINT, 0::BIGINT, ''::TEXT, TRUE, 'key_not_found'::TEXT, NULL::__DATA_TYPE__, NULL::BIGINT;
RETURN;
END IF;
-- 2. Check idempotency. Return the epoch the removal was committed under
-- (stored in the idempotency row), not the live meta epoch — see publish
-- for the same rationale.
IF p_idempotency_key IS NOT NULL THEN
SELECT result_offset, result_epoch INTO v_offset, v_idempotent_epoch
FROM __PREFIX__idempotency
WHERE channel = p_channel AND idempotency_key = p_idempotency_key
AND expires_at > NOW();
IF FOUND THEN
RETURN QUERY SELECT NULL::BIGINT, v_offset, v_idempotent_epoch, TRUE, 'idempotency'::TEXT, NULL::__DATA_TYPE__, NULL::BIGINT;
RETURN;
END IF;
END IF;
-- 3. CAS check (ExpectedPosition). Same epoch-aware compare as publish.
IF p_expected_offset IS NOT NULL THEN
SELECT key_offset, sn.data INTO v_current_offset, v_current_data
FROM __PREFIX__state sn WHERE sn.channel = p_channel AND sn.key = p_key;
IF NOT FOUND
OR v_current_offset != p_expected_offset
OR (p_expected_epoch IS NOT NULL AND v_epoch != p_expected_epoch) THEN
RETURN QUERY SELECT NULL::BIGINT, v_offset, v_epoch, TRUE,
'position_mismatch'::TEXT, v_current_data, v_current_offset;
RETURN;
END IF;
END IF;
-- 4. Check if key exists (exclude expired entries — they are logically deleted)
SELECT EXISTS(SELECT 1 FROM __PREFIX__state WHERE channel = p_channel AND key = p_key AND (expires_at IS NULL OR expires_at > NOW())) INTO v_exists;
IF NOT v_exists THEN
RETURN QUERY SELECT NULL::BIGINT, v_offset, v_epoch, TRUE, 'key_not_found'::TEXT, NULL::__DATA_TYPE__, NULL::BIGINT;
RETURN;
END IF;
-- 5. Increment offset
UPDATE __PREFIX__meta SET
top_offset = top_offset + 1,
expires_at = COALESCE(CASE WHEN p_meta_ttl IS NOT NULL THEN NOW() + p_meta_ttl ELSE NULL END, expires_at),
updated_at = NOW()
WHERE channel = p_channel
RETURNING top_offset INTO v_offset;
-- 6. Read tags before deletion (for server-side filtering on removal events).
SELECT tags INTO v_tags FROM __PREFIX__state WHERE channel = p_channel AND key = p_key;
-- 7. Delete from snapshot
DELETE FROM __PREFIX__state WHERE channel = p_channel AND key = p_key;
-- 8. Insert removal into stream with tags from the deleted entry.
INSERT INTO __PREFIX__stream (channel, channel_offset, epoch, key, removed, client_id, user_id, shard_id, tags)
VALUES (
p_channel, v_offset, v_epoch, p_key, TRUE, p_client_id, p_user_id,
v_shard_id, v_tags
) RETURNING __PREFIX__stream.id INTO v_id;
-- 8. Save idempotency key. Persist the epoch alongside the offset — see
-- publish for rationale.
IF p_idempotency_key IS NOT NULL THEN
INSERT INTO __PREFIX__idempotency (channel, idempotency_key, result_offset, result_epoch, result_id, expires_at)
VALUES (p_channel, p_idempotency_key, v_offset, v_epoch, v_id, NOW() + COALESCE(p_idempotency_ttl, INTERVAL '5 minutes'))
ON CONFLICT DO NOTHING;
END IF;
-- 9. Notify outbox workers
PERFORM pg_notify('__PREFIX__stream_notify', '');
RETURN QUERY SELECT v_id, v_offset, v_epoch, FALSE, NULL::TEXT, NULL::__DATA_TYPE__, NULL::BIGINT;
END;
$$ LANGUAGE plpgsql;
-- __PREFIX__remove_strict: Auto-rollback if key not found
CREATE OR REPLACE FUNCTION __PREFIX__remove_strict(
p_channel TEXT,
p_key TEXT,
p_meta_ttl INTERVAL DEFAULT NULL,
p_expected_offset BIGINT DEFAULT NULL,
p_expected_epoch TEXT DEFAULT NULL,
p_idempotency_key TEXT DEFAULT NULL,
p_idempotency_ttl INTERVAL DEFAULT NULL,
p_client_id TEXT DEFAULT NULL,
p_user_id TEXT DEFAULT NULL,
p_num_shards INTEGER DEFAULT NULL
) RETURNS TABLE(
result_id BIGINT,
channel_offset BIGINT,
epoch TEXT
) AS $$
DECLARE
v_result RECORD;
BEGIN
SELECT * INTO v_result FROM __PREFIX__remove(
p_channel, p_key, p_meta_ttl,
p_expected_offset, p_expected_epoch, p_idempotency_key, p_idempotency_ttl,
p_client_id, p_user_id, p_num_shards
);
IF v_result.suppressed THEN
RAISE EXCEPTION '__PREFIX__remove: key not found: %.%', p_channel, p_key
USING ERRCODE = 'no_data_found';
END IF;
RETURN QUERY SELECT v_result.result_id, v_result.channel_offset, v_result.epoch;
END;
$$ LANGUAGE plpgsql;
-- __PREFIX__expire_keys: Atomically expire keys that have passed their TTL.
-- Lock ordering: shard_lock → meta FOR UPDATE → state FOR UPDATE SKIP LOCKED.
-- This matches publish's lock order (shard → meta → state), preventing deadlocks.
CREATE OR REPLACE FUNCTION __PREFIX__expire_keys(
p_batch_size INT DEFAULT 1000,
p_num_shards INTEGER DEFAULT NULL,
p_meta_ttl INTERVAL DEFAULT NULL,
p_channel TEXT DEFAULT NULL
) RETURNS TABLE(
out_channel TEXT,
out_key TEXT,
out_offset BIGINT,
out_epoch TEXT
) AS $$
DECLARE
v_channel TEXT;
v_keys TEXT[];
v_tags_arr JSONB[];
v_count INT;
v_base_offset BIGINT;
v_epoch TEXT;
v_shard_id INTEGER;
v_processed INT := 0;
v_notified BOOLEAN := FALSE;
BEGIN
-- Auto-derive num_shards from shard_lock table when not provided.
IF p_num_shards IS NULL THEN
SELECT COUNT(*)::INTEGER INTO p_num_shards FROM __PREFIX__shard_lock;
END IF;
IF p_num_shards <= 0 THEN
RAISE EXCEPTION 'shard_lock table is empty — run EnsureSchema first';
END IF;
-- Process one channel at a time to maintain meta→state lock ordering.
FOR v_channel IN
SELECT DISTINCT s.channel FROM __PREFIX__state s
WHERE s.expires_at IS NOT NULL AND s.expires_at <= NOW()
AND (p_channel IS NULL OR s.channel = p_channel)
LOOP
-- 0. Calculate shard_id and acquire per-shard serialization lock
-- (lock order: shard → meta → state).
v_shard_id := abs(hashtext(v_channel)::bigint) % p_num_shards;
PERFORM 1 FROM __PREFIX__shard_lock WHERE shard_id = v_shard_id FOR UPDATE;
-- 1. Lock meta (same order as publish: shard → meta → state).
SELECT top_offset, m.epoch INTO v_base_offset, v_epoch
FROM __PREFIX__meta m WHERE m.channel = v_channel FOR UPDATE;
IF NOT FOUND THEN
DELETE FROM __PREFIX__state WHERE channel = v_channel
AND expires_at IS NOT NULL AND expires_at <= NOW();
CONTINUE;
END IF;
-- 2. Collect expired keys and their tags in one query (already under shard + meta locks).
SELECT array_agg(key), array_agg(COALESCE(tags, '{}'::jsonb)), count(*)
INTO v_keys, v_tags_arr, v_count FROM (
SELECT key, tags FROM __PREFIX__state
WHERE channel = v_channel
AND expires_at IS NOT NULL AND expires_at <= NOW()
LIMIT p_batch_size - v_processed
FOR UPDATE SKIP LOCKED
) t;
IF v_count IS NULL OR v_count = 0 THEN
CONTINUE;
END IF;
-- Bump offset by count (1 UPDATE instead of N).
UPDATE __PREFIX__meta SET
top_offset = top_offset + v_count,
expires_at = COALESCE(CASE WHEN p_meta_ttl IS NOT NULL THEN NOW() + p_meta_ttl ELSE NULL END, expires_at),
updated_at = NOW()
WHERE channel = v_channel
RETURNING top_offset - v_count INTO v_base_offset;
-- Batch delete (1 DELETE instead of N).
DELETE FROM __PREFIX__state WHERE channel = v_channel AND key = ANY(v_keys);
-- Batch insert removal events with tags (1 INSERT instead of N).
INSERT INTO __PREFIX__stream (channel, channel_offset, epoch, key, removed, shard_id, tags)
SELECT v_channel, v_base_offset + rn, v_epoch, k, TRUE, v_shard_id, tg
FROM unnest(v_keys, v_tags_arr) WITH ORDINALITY AS t(k, tg, rn);
-- Return results.
RETURN QUERY
SELECT v_channel, k, (v_base_offset + rn)::BIGINT, v_epoch
FROM unnest(v_keys) WITH ORDINALITY AS t(k, rn);
v_notified := TRUE;
v_processed := v_processed + v_count;
IF v_processed >= p_batch_size THEN
PERFORM pg_notify('__PREFIX__stream_notify', '');
RETURN;
END IF;
END LOOP;
-- Notify outbox workers if any entries were expired.
IF v_notified THEN
PERFORM pg_notify('__PREFIX__stream_notify', '');
END IF;
END;
$$ LANGUAGE plpgsql;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,570 @@
//go:build integration
package pgmapbroker
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/centrifugal/centrifuge"
)
// setupBench creates a broker with Persistent mode for standard benchmarks.
func setupBench(b *testing.B) (*PostgresMapBroker, func()) {
b.Helper()
connString := getPostgresConnString(b)
node, _ := centrifuge.New(centrifuge.Config{
Map: centrifuge.MapConfig{
GetMapChannelOptions: func(channel string) centrifuge.MapChannelOptions {
return centrifuge.MapChannelOptions{
Mode: centrifuge.MapModePersistent,
}
},
},
})
broker, err := NewPostgresMapBroker(node, PostgresMapBrokerConfig{
DSN: connString,
BinaryData: true,
PartitionRetentionDays: 7,
})
if err != nil {
b.Fatal(err)
}
ctx := context.Background()
if err := broker.EnsureSchema(ctx); err != nil {
b.Fatal(err)
}
_ = broker.RegisterEventHandler(nil)
cleanupTestTables(ctx, broker)
return broker, func() {
_ = broker.Close(context.Background())
_ = node.Shutdown(context.Background())
}
}
// setupBenchOutbox creates a broker with outbox workers running.
func setupBenchOutbox(b *testing.B, handler centrifuge.BrokerEventHandler) (*PostgresMapBroker, func()) {
b.Helper()
connString := getPostgresConnString(b)
node, _ := centrifuge.New(centrifuge.Config{
Map: centrifuge.MapConfig{
GetMapChannelOptions: func(channel string) centrifuge.MapChannelOptions {
return centrifuge.MapChannelOptions{
Mode: centrifuge.MapModePersistent,
}
},
},
})
if err := node.Run(); err != nil {
b.Fatal(err)
}
broker, err := NewPostgresMapBroker(node, PostgresMapBrokerConfig{
DSN: connString,
BinaryData: true,
PoolSize: 32,
NumShards: 8,
PartitionRetentionDays: 7,
Outbox: OutboxConfig{
PollInterval: 5 * time.Millisecond,
BatchSize: 1000,
},
})
if err != nil {
b.Fatal(err)
}
ctx := context.Background()
if err := broker.EnsureSchema(ctx); err != nil {
b.Fatal(err)
}
cleanupTestTables(ctx, broker)
_ = broker.RegisterEventHandler(handler)
return broker, func() {
_ = broker.Close(context.Background())
_ = node.Shutdown(context.Background())
}
}
// ============================================================================
// Publish benchmarks
// ============================================================================
// BenchmarkPostgresMapBroker_Publish measures the core map publish path:
// shard lock → meta UPSERT → state UPSERT → stream INSERT → NOTIFY.
// All publish benchmarks go through the same cf_map_publish SQL function.
func BenchmarkPostgresMapBroker_Publish(b *testing.B) {
broker, cleanup := setupBench(b)
defer cleanup()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
i := atomic.AddInt64(&counter, 1)
ch := fmt.Sprintf("bench_publish_%d", i%1000)
key := fmt.Sprintf("key%d", i%1000)
_, err := broker.Publish(ctx, ch, key, centrifuge.MapPublishOptions{
Data: []byte("x"),
})
if err != nil {
b.Fatal(err)
}
}
})
}
// BenchmarkPostgresMapBroker_PublishIdempotent measures publish with
// idempotency key check (extra lookup + conditional insert).
func BenchmarkPostgresMapBroker_PublishIdempotent(b *testing.B) {
broker, cleanup := setupBench(b)
defer cleanup()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
i := atomic.AddInt64(&counter, 1)
ch := fmt.Sprintf("bench_idempotent_%d", i%1000)
key := fmt.Sprintf("key_%d", i)
_, err := broker.Publish(ctx, ch, "", centrifuge.MapPublishOptions{
Data: []byte(key),
IdempotencyKey: key,
IdempotentResultTTL: 60 * time.Second,
})
if err != nil {
b.Fatal(err)
}
}
})
}
// BenchmarkPostgresMapBroker_PublishCAS measures Compare-And-Swap: read
// current state + conditional publish. Includes both the read and write
// round-trips.
func BenchmarkPostgresMapBroker_PublishCAS(b *testing.B) {
broker, cleanup := setupBench(b)
defer cleanup()
ctx := context.Background()
channel := "bench_cas"
// Seed the key.
_, err := broker.Publish(ctx, channel, "counter", centrifuge.MapPublishOptions{
Data: []byte("0"),
})
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
stateRes, err := broker.ReadState(ctx, channel, centrifuge.MapReadStateOptions{
Key: "counter",
})
if err != nil || len(stateRes.Publications) == 0 {
continue
}
pos := centrifuge.StreamPosition{
Offset: stateRes.Publications[0].Offset,
Epoch: stateRes.Position.Epoch,
}
_, _ = broker.Publish(ctx, channel, "counter", centrifuge.MapPublishOptions{
Data: []byte("updated"),
ExpectedPosition: &pos,
})
}
})
}
// ============================================================================
// Read benchmarks
// ============================================================================
// BenchmarkPostgresMapBroker_ReadStream measures reading publications from
// the stream table (the change log used for recovery).
func BenchmarkPostgresMapBroker_ReadStream(b *testing.B) {
broker, cleanup := setupBench(b)
defer cleanup()
ctx := context.Background()
channel := "bench_read_stream"
// Seed 100 publications.
for i := 0; i < 100; i++ {
_, err := broker.Publish(ctx, channel, fmt.Sprintf("k%d", i), centrifuge.MapPublishOptions{
Data: []byte("data"),
})
if err != nil {
b.Fatal(err)
}
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := broker.ReadStream(ctx, channel, centrifuge.MapReadStreamOptions{
Filter: centrifuge.StreamFilter{Limit: 50},
})
if err != nil {
b.Fatal(err)
}
}
}
// BenchmarkPostgresMapBroker_ReadState measures reading the full key-value
// snapshot of a channel.
func BenchmarkPostgresMapBroker_ReadState(b *testing.B) {
broker, cleanup := setupBench(b)
defer cleanup()
ctx := context.Background()
channel := "bench_read_state"
for i := 0; i < 100; i++ {
_, err := broker.Publish(ctx, channel, fmt.Sprintf("k%d", i), centrifuge.MapPublishOptions{
Data: []byte("data"),
})
if err != nil {
b.Fatal(err)
}
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := broker.ReadState(ctx, channel, centrifuge.MapReadStateOptions{
Limit: 100,
})
if err != nil {
b.Fatal(err)
}
}
}
// BenchmarkPostgresMapBroker_ReadStatePaginated measures paginated state
// reads (typical for large channels with cursor-based iteration).
func BenchmarkPostgresMapBroker_ReadStatePaginated(b *testing.B) {
broker, cleanup := setupBench(b)
defer cleanup()
ctx := context.Background()
channel := "bench_read_state_paged"
for i := 0; i < 100; i++ {
_, err := broker.Publish(ctx, channel, fmt.Sprintf("k%d", i), centrifuge.MapPublishOptions{
Data: []byte("data"),
})
if err != nil {
b.Fatal(err)
}
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := broker.ReadState(ctx, channel, centrifuge.MapReadStateOptions{
Limit: 10,
})
if err != nil {
b.Fatal(err)
}
}
}
// BenchmarkPostgresMapBroker_Stats measures the channel statistics query.
func BenchmarkPostgresMapBroker_Stats(b *testing.B) {
broker, cleanup := setupBench(b)
defer cleanup()
ctx := context.Background()
channel := "bench_stats"
for i := 0; i < 50; i++ {
_, _ = broker.Publish(ctx, channel, fmt.Sprintf("k%d", i), centrifuge.MapPublishOptions{
Data: []byte("data"),
})
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := broker.Stats(ctx, channel)
if err != nil {
b.Fatal(err)
}
}
}
// BenchmarkPostgresMapBroker_Remove measures key removal.
func BenchmarkPostgresMapBroker_Remove(b *testing.B) {
broker, cleanup := setupBench(b)
defer cleanup()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
i := atomic.AddInt64(&counter, 1)
ch := fmt.Sprintf("bench_remove_%d", i%1000)
key := fmt.Sprintf("key%d", i%1000)
// Ensure key exists, then remove it.
_, _ = broker.Publish(ctx, ch, key, centrifuge.MapPublishOptions{
Data: []byte("x"),
})
_, err := broker.Remove(ctx, ch, key, centrifuge.MapRemoveOptions{})
if err != nil {
b.Fatal(err)
}
}
})
}
// ============================================================================
// Outbox delivery benchmarks
// ============================================================================
// BenchmarkPostgresMapBroker_OutboxPublish measures publish latency with
// the outbox worker running (parallel publishers across many channels).
func BenchmarkPostgresMapBroker_OutboxPublish(b *testing.B) {
broker, cleanup := setupBenchOutbox(b, nil)
defer cleanup()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
i := atomic.AddInt64(&counter, 1)
ch := fmt.Sprintf("bench_outbox_%d", i%1000)
key := fmt.Sprintf("key%d", i%1000)
_, err := broker.Publish(ctx, ch, key, centrifuge.MapPublishOptions{
Data: []byte("x"),
})
if err != nil {
b.Fatal(err)
}
}
})
}
// BenchmarkPostgresMapBroker_OutboxThroughput measures end-to-end outbox
// throughput: 8 publisher goroutines + outbox workers delivering to a handler.
// Verifies per-channel offset ordering as a side effect.
func BenchmarkPostgresMapBroker_OutboxThroughput(b *testing.B) {
ctx := context.Background()
prefix := fmt.Sprintf("bench_tp_%d_", time.Now().UnixNano())
var delivered int64
broker, cleanup := setupBenchOutbox(b, &testBrokerEventHandler{
HandlePublicationFunc: func(ch string, pub *centrifuge.Publication, sp centrifuge.StreamPosition, delta bool, prevPub *centrifuge.Publication) error {
// Only count publications from this bench run's channels.
if len(ch) < len(prefix) || ch[:len(prefix)] != prefix {
return nil
}
atomic.AddInt64(&delivered, 1)
return nil
},
})
defer cleanup()
time.Sleep(100 * time.Millisecond) // let workers start
b.ReportAllocs()
b.ResetTimer()
var published int64
var counter int64
var wg sync.WaitGroup
const numPublishers = 8
perPublisher := b.N / numPublishers
remainder := b.N % numPublishers
for p := 0; p < numPublishers; p++ {
n := perPublisher
if p < remainder {
n++
}
if n == 0 {
continue
}
wg.Add(1)
go func(count int) {
defer wg.Done()
for j := 0; j < count; j++ {
i := atomic.AddInt64(&counter, 1)
ch := fmt.Sprintf("%sch%d", prefix, i%100)
key := fmt.Sprintf("key%d", i)
_, err := broker.Publish(ctx, ch, key, centrifuge.MapPublishOptions{
Data: []byte("x"),
})
if err != nil {
b.Error(err)
return
}
atomic.AddInt64(&published, 1)
}
}(n)
}
wg.Wait()
want := atomic.LoadInt64(&published)
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
for {
if atomic.LoadInt64(&delivered) >= want {
break
}
select {
case <-deadline.C:
b.Fatalf("timeout: delivered %d/%d", atomic.LoadInt64(&delivered), want)
default:
time.Sleep(time.Millisecond)
}
}
b.StopTimer()
}
// BenchmarkPostgresMapBroker_OutboxLatency measures publish-to-delivery
// latency via the outbox. Reports average latency as a custom metric.
func BenchmarkPostgresMapBroker_OutboxLatency(b *testing.B) {
ctx := context.Background()
channel := fmt.Sprintf("bench_latency_%d", time.Now().UnixNano())
publishTimes := make([]time.Time, b.N+2)
delivered := make(chan time.Duration, b.N)
var mu sync.Mutex
broker, cleanup := setupBenchOutbox(b, &testBrokerEventHandler{
HandlePublicationFunc: func(ch string, pub *centrifuge.Publication, sp centrifuge.StreamPosition, delta bool, prevPub *centrifuge.Publication) error {
if ch != channel {
return nil
}
mu.Lock()
var t time.Time
if int(sp.Offset) < len(publishTimes) {
t = publishTimes[sp.Offset]
}
mu.Unlock()
if !t.IsZero() {
delivered <- time.Since(t)
}
return nil
},
})
defer cleanup()
time.Sleep(100 * time.Millisecond) // let workers start
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
mu.Lock()
now := time.Now()
res, err := broker.Publish(ctx, channel, fmt.Sprintf("key%d", i), centrifuge.MapPublishOptions{
Data: []byte("x"),
})
if err != nil {
mu.Unlock()
b.Fatal(err)
}
if int(res.Position.Offset) < len(publishTimes) {
publishTimes[res.Position.Offset] = now
}
mu.Unlock()
}
deadline := time.NewTimer(30 * time.Second)
defer deadline.Stop()
var totalLatency time.Duration
received := 0
for received < b.N {
select {
case d := <-delivered:
totalLatency += d
received++
case <-deadline.C:
b.Fatalf("timeout: received %d/%d", received, b.N)
}
}
b.StopTimer()
if b.N > 0 {
avg := totalLatency / time.Duration(b.N)
b.ReportMetric(float64(avg.Microseconds()), "µs/delivery")
}
}
// ============================================================================
// Cleanup benchmark
// ============================================================================
// BenchmarkPostgresMapBroker_Cleanup measures TTL-based key expiration
// throughput via the expire_keys SQL function. Sub-benchmarks cover
// different key counts.
func BenchmarkPostgresMapBroker_Cleanup(b *testing.B) {
for _, numKeys := range []int{100, 1000} {
b.Run(fmt.Sprintf("keys_%d", numKeys), func(b *testing.B) {
connString := getPostgresConnString(b)
node, _ := centrifuge.New(centrifuge.Config{
Map: centrifuge.MapConfig{
GetMapChannelOptions: func(channel string) centrifuge.MapChannelOptions {
return centrifuge.MapChannelOptions{
Mode: centrifuge.MapModeRecoverable,
KeyTTL: time.Millisecond,
}
},
},
})
broker, err := NewPostgresMapBroker(node, PostgresMapBrokerConfig{
DSN: connString,
BinaryData: true,
PartitionRetentionDays: 7,
})
if err != nil {
b.Fatal(err)
}
ctx := context.Background()
if err := broker.EnsureSchema(ctx); err != nil {
b.Fatal(err)
}
_ = broker.RegisterEventHandler(nil)
defer func() {
_ = broker.Close(context.Background())
_ = node.Shutdown(context.Background())
}()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
channel := fmt.Sprintf("bench_cleanup_%d_%d", time.Now().UnixNano(), i)
for k := 0; k < numKeys; k++ {
_, err := broker.Publish(ctx, channel, fmt.Sprintf("key_%d", k), centrifuge.MapPublishOptions{
Data: []byte("data"),
})
if err != nil {
b.Fatal(err)
}
}
time.Sleep(5 * time.Millisecond)
b.StartTimer()
numShards := broker.conf.NumShards
var metaTTL *string
_, err := broker.pool.Exec(ctx, fmt.Sprintf(`
SELECT %s($1, $2, $3::interval, $4)
`, broker.names.expireKeys), 1000, numShards, metaTTL, channel)
if err != nil {
b.Fatal(err)
}
}
b.ReportMetric(float64(numKeys), "keys/op")
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
//go:build !appengine
package pgmapbroker
import (
"encoding/binary"
"encoding/hex"
"encoding/json"
"strconv"
"time"
"github.com/centrifugal/centrifugo/v6/internal/convert"
)
// byteArena is a chunked arena allocator for byte data. It copies incoming
// slices into a contiguous buffer and hands back sub-slices (or strings via
// unsafe conversion). When the current chunk is full a new one is allocated;
// old chunks stay alive via the slices/strings already referencing them.
type byteArena struct {
buf []byte
}
// copyBytes copies src into the arena and returns a sub-slice of the arena buffer.
// The returned slice has cap==len (three-index slice) so that append on it
// cannot silently overwrite adjacent arena data.
func (a *byteArena) copyBytes(src []byte) []byte {
n := len(src)
if n == 0 {
return nil
}
if cap(a.buf)-len(a.buf) < n {
newCap := 2 * cap(a.buf)
if newCap < n {
newCap = n
}
if newCap < 4096 {
newCap = 4096
}
a.buf = make([]byte, 0, newCap)
}
start := len(a.buf)
a.buf = append(a.buf, src...)
return a.buf[start : start+n : start+n]
}
// copyString copies src into the arena and returns a string backed by arena memory.
func (a *byteArena) copyString(src []byte) string {
if len(src) == 0 {
return ""
}
b := a.copyBytes(src)
return convert.BytesToString(b)
}
// pgTextFormat is the pgx text wire format code.
const pgTextFormat int16 = 0
// pgRawInt64 parses a pgx int8 value as int64.
// Handles both binary wire format (8-byte big-endian) and text (decimal string).
// Returns 0 for nil input.
func pgRawInt64(b []byte, format int16) int64 {
if b == nil {
return 0
}
if format == pgTextFormat {
v, _ := strconv.ParseInt(convert.BytesToString(b), 10, 64)
return v
}
return int64(binary.BigEndian.Uint64(b))
}
// pgRawUint64 parses a pgx int8 value as uint64.
// Handles both binary wire format (8-byte big-endian) and text (decimal string).
// Returns 0 for nil input.
func pgRawUint64(b []byte, format int16) uint64 {
if b == nil {
return 0
}
if format == pgTextFormat {
v, _ := strconv.ParseUint(convert.BytesToString(b), 10, 64)
return v
}
return binary.BigEndian.Uint64(b)
}
// pgRawInt32 parses a pgx int4 value as int32.
// Handles both binary wire format (4-byte big-endian) and text (decimal string).
// Returns 0 for nil input.
func pgRawInt32(b []byte, format int16) int32 {
if b == nil {
return 0
}
if format == pgTextFormat {
v, _ := strconv.ParseInt(convert.BytesToString(b), 10, 32)
return int32(v)
}
return int32(binary.BigEndian.Uint32(b))
}
// pgRawBool parses a pgx boolean value.
// Handles both binary wire format (0x00/0x01) and text ("t"/"f").
// Returns false for nil input.
func pgRawBool(b []byte, format int16) bool {
if b == nil {
return false
}
if format == pgTextFormat {
return len(b) > 0 && b[0] == 't'
}
return b[0] == 1
}
// pgRawString copies a pgx text value into the arena and returns the resulting
// string. TEXT/VARCHAR have identical representation in both binary and text wire
// formats, so no format parameter is needed. Returns "" for nil input.
func pgRawString(a *byteArena, b []byte) string {
if b == nil {
return ""
}
return a.copyString(b)
}
// pgRawBytes copies a pgx BYTEA value into the arena.
// Handles both binary wire format (raw bytes) and text (hex-encoded \xDEAD...).
// Returns nil for nil input.
func pgRawBytes(a *byteArena, b []byte, format int16) []byte {
if b == nil {
return nil
}
if format == pgTextFormat && len(b) >= 2 && b[0] == '\\' && b[1] == 'x' {
decoded := make([]byte, hex.DecodedLen(len(b)-2))
n, err := hex.Decode(decoded, b[2:])
if err != nil {
return a.copyBytes(b) // fallback: copy as-is
}
return a.copyBytes(decoded[:n])
}
return a.copyBytes(b)
}
// pgRawJSONBBytes extracts the JSON body from a pgx JSONB value into the arena.
// Binary wire format has a 1-byte version header that must be stripped.
// Text wire format is plain JSON (no header). Returns nil for nil input.
func pgRawJSONBBytes(a *byteArena, b []byte, format int16) []byte {
if b == nil {
return nil
}
if format != pgTextFormat && len(b) > 1 {
return a.copyBytes(b[1:]) // strip version byte
}
return a.copyBytes(b)
}
// pgColFormats holds per-column wire format codes extracted from pgx rows.
// Format values: 0 = text, 1 = binary. Call pgColFormatsFromRows once per
// query result, before iterating rows.
type pgColFormats []int16
// pgEpochDeltaUsec is the difference in microseconds between the Unix epoch
// (1970-01-01) and the PostgreSQL epoch (2000-01-01).
const pgEpochDeltaUsec = 946684800_000_000
// pgRawTimestampMillis parses a pgx TIMESTAMPTZ value as Unix milliseconds.
// Binary wire format: 8-byte big-endian int64 = microseconds since PG epoch (2000-01-01 UTC).
// Text wire format: parsed with time.Parse. Returns 0 for nil input.
func pgRawTimestampMillis(b []byte, format int16) int64 {
if b == nil {
return 0
}
if format == pgTextFormat {
// PostgreSQL renders timestamptz with a variable-width zone offset - hour
// only ("+00"), hour:minute ("+05:30"), or the rare hour:minute:second - so
// no single Go layout matches all of them (the previous "Z07:00:00" layout
// parsed none of PostgreSQL's actual output, zeroing every timestamp under
// the simple-query protocol). Try the three offset widths.
s := convert.BytesToString(b)
for _, layout := range []string{
"2006-01-02 15:04:05.999999-07",
"2006-01-02 15:04:05.999999-07:00",
"2006-01-02 15:04:05.999999-07:00:00",
} {
if t, err := time.Parse(layout, s); err == nil {
return t.UnixMilli()
}
}
return 0
}
usec := int64(binary.BigEndian.Uint64(b))
return (usec + pgEpochDeltaUsec) / 1000
}
// pgRawJSONBMap parses a JSONB value into a map[string]string.
// Handles both binary wire format (1-byte version header + JSON body)
// and text wire format (plain JSON). Returns nil for nil or empty input.
func pgRawJSONBMap(b []byte) map[string]string {
if len(b) == 0 {
return nil
}
// Binary format has a version prefix byte (typically 1).
// Text format starts directly with '{'.
data := b
if len(b) > 1 && b[0] != '{' && b[0] != '[' && b[0] != 'n' {
data = b[1:] // skip version byte
}
var m map[string]string
_ = json.Unmarshal(data, &m)
return m
}

View File

@@ -0,0 +1,196 @@
package pgmapbroker
import (
"encoding/binary"
"math"
"testing"
"github.com/stretchr/testify/require"
)
const (
fmtText int16 = 0
fmtBinary int16 = 1
)
func TestByteArena_CopyBytes(t *testing.T) {
a := byteArena{}
t.Run("nil_input", func(t *testing.T) {
require.Nil(t, a.copyBytes(nil))
})
t.Run("empty_input", func(t *testing.T) {
require.Nil(t, a.copyBytes([]byte{}))
})
t.Run("copy_is_independent", func(t *testing.T) {
src := []byte("hello")
got := a.copyBytes(src)
require.Equal(t, []byte("hello"), got)
// Mutating source must not affect copy.
src[0] = 'H'
require.Equal(t, byte('h'), got[0])
})
t.Run("cap_equals_len", func(t *testing.T) {
got := a.copyBytes([]byte("abc"))
require.Equal(t, len(got), cap(got))
})
t.Run("growth", func(t *testing.T) {
a2 := byteArena{}
// Fill beyond initial chunk.
big := make([]byte, 5000)
for i := range big {
big[i] = byte(i % 256)
}
got := a2.copyBytes(big)
require.Equal(t, big, got)
require.Equal(t, len(got), cap(got))
})
}
func TestByteArena_CopyString(t *testing.T) {
a := byteArena{}
t.Run("nil_input", func(t *testing.T) {
require.Equal(t, "", a.copyString(nil))
})
t.Run("empty_input", func(t *testing.T) {
require.Equal(t, "", a.copyString([]byte{}))
})
t.Run("copies_content", func(t *testing.T) {
src := []byte("world")
got := a.copyString(src)
require.Equal(t, "world", got)
src[0] = 'W'
require.Equal(t, "world", got)
})
}
func putUint64(v uint64) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, v)
return b
}
func putUint32(v uint32) []byte {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, v)
return b
}
func TestPgRawInt64(t *testing.T) {
// Binary format.
require.Equal(t, int64(0), pgRawInt64(nil, fmtBinary))
require.Equal(t, int64(0), pgRawInt64(putUint64(0), fmtBinary))
require.Equal(t, int64(42), pgRawInt64(putUint64(42), fmtBinary))
require.Equal(t, int64(-1), pgRawInt64(putUint64(math.MaxUint64), fmtBinary))
require.Equal(t, int64(math.MinInt64), pgRawInt64(putUint64(1<<63), fmtBinary))
// Text format.
require.Equal(t, int64(0), pgRawInt64(nil, fmtText))
require.Equal(t, int64(0), pgRawInt64([]byte("0"), fmtText))
require.Equal(t, int64(42), pgRawInt64([]byte("42"), fmtText))
require.Equal(t, int64(-1), pgRawInt64([]byte("-1"), fmtText))
require.Equal(t, int64(12345678), pgRawInt64([]byte("12345678"), fmtText))
require.Equal(t, int64(math.MaxInt64), pgRawInt64([]byte("9223372036854775807"), fmtText))
}
func TestPgRawUint64(t *testing.T) {
// Binary format.
require.Equal(t, uint64(0), pgRawUint64(nil, fmtBinary))
require.Equal(t, uint64(0), pgRawUint64(putUint64(0), fmtBinary))
require.Equal(t, uint64(123456789), pgRawUint64(putUint64(123456789), fmtBinary))
require.Equal(t, uint64(math.MaxUint64), pgRawUint64(putUint64(math.MaxUint64), fmtBinary))
// Text format.
require.Equal(t, uint64(0), pgRawUint64(nil, fmtText))
require.Equal(t, uint64(0), pgRawUint64([]byte("0"), fmtText))
require.Equal(t, uint64(123456789), pgRawUint64([]byte("123456789"), fmtText))
}
func TestPgRawInt32(t *testing.T) {
// Binary format.
require.Equal(t, int32(0), pgRawInt32(nil, fmtBinary))
require.Equal(t, int32(0), pgRawInt32(putUint32(0), fmtBinary))
require.Equal(t, int32(100), pgRawInt32(putUint32(100), fmtBinary))
require.Equal(t, int32(-1), pgRawInt32(putUint32(math.MaxUint32), fmtBinary))
// Text format.
require.Equal(t, int32(0), pgRawInt32(nil, fmtText))
require.Equal(t, int32(100), pgRawInt32([]byte("100"), fmtText))
require.Equal(t, int32(-1), pgRawInt32([]byte("-1"), fmtText))
}
func TestPgRawBool(t *testing.T) {
// Binary format.
require.False(t, pgRawBool(nil, fmtBinary))
require.True(t, pgRawBool([]byte{1}, fmtBinary))
require.False(t, pgRawBool([]byte{0}, fmtBinary))
// Text format.
require.False(t, pgRawBool(nil, fmtText))
require.True(t, pgRawBool([]byte("t"), fmtText))
require.False(t, pgRawBool([]byte("f"), fmtText))
}
func TestPgRawString(t *testing.T) {
a := byteArena{}
require.Equal(t, "", pgRawString(&a, nil))
require.Equal(t, "hello", pgRawString(&a, []byte("hello")))
}
func TestPgRawBytes(t *testing.T) {
// Binary format: raw bytes.
a := byteArena{}
require.Nil(t, pgRawBytes(&a, nil, fmtBinary))
require.Equal(t, []byte{0xde, 0xad}, pgRawBytes(&a, []byte{0xde, 0xad}, fmtBinary))
// Text format: hex-encoded \xDEAD.
a2 := byteArena{}
require.Equal(t, []byte{0xde, 0xad}, pgRawBytes(&a2, []byte(`\xdead`), fmtText))
// Text format: non-hex passthrough.
a3 := byteArena{}
require.Equal(t, []byte("hello"), pgRawBytes(&a3, []byte("hello"), fmtText))
}
func TestPgRawJSONBBytes(t *testing.T) {
// Binary format: strip version byte.
a := byteArena{}
require.Nil(t, pgRawJSONBBytes(&a, nil, fmtBinary))
b := append([]byte{1}, []byte(`{"foo":"bar"}`)...)
require.Equal(t, []byte(`{"foo":"bar"}`), pgRawJSONBBytes(&a, b, fmtBinary))
// Text format: plain JSON.
a2 := byteArena{}
require.Equal(t, []byte(`{"foo":"bar"}`), pgRawJSONBBytes(&a2, []byte(`{"foo":"bar"}`), fmtText))
}
func TestPgRawJSONBMap(t *testing.T) {
t.Run("nil", func(t *testing.T) {
require.Nil(t, pgRawJSONBMap(nil))
})
t.Run("empty", func(t *testing.T) {
require.Nil(t, pgRawJSONBMap([]byte{}))
})
t.Run("binary_empty_object", func(t *testing.T) {
// Binary wire format: version byte (1) + empty JSON object.
b := append([]byte{1}, []byte("{}")...)
got := pgRawJSONBMap(b)
require.NotNil(t, got)
require.Empty(t, got)
})
t.Run("binary_valid_map", func(t *testing.T) {
// Binary wire format: version byte (1) + JSON.
b := append([]byte{1}, []byte(`{"a":"1","b":"2"}`)...)
got := pgRawJSONBMap(b)
require.Equal(t, map[string]string{"a": "1", "b": "2"}, got)
})
t.Run("text_empty_object", func(t *testing.T) {
// Text wire format: plain JSON (no version byte).
got := pgRawJSONBMap([]byte("{}"))
require.NotNil(t, got)
require.Empty(t, got)
})
t.Run("text_valid_map", func(t *testing.T) {
// Text wire format: plain JSON (no version byte).
got := pgRawJSONBMap([]byte(`{"sector":"tech"}`))
require.Equal(t, map[string]string{"sector": "tech"}, got)
})
t.Run("text_null", func(t *testing.T) {
// Text wire format: JSON null.
require.Nil(t, pgRawJSONBMap([]byte("null")))
})
}

View File

@@ -0,0 +1,73 @@
package pgmapbroker
import (
"context"
"fmt"
"sync"
"time"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
)
// mapMetricsSampler periodically samples PG-specific gauge metrics: outbox
// cursor lag per shard and partition count on the stream table.
type mapMetricsSampler struct {
broker *PostgresMapBroker
mu sync.Mutex
cursorByShard map[int]int64
}
func newMapMetricsSampler(b *PostgresMapBroker) *mapMetricsSampler {
return &mapMetricsSampler{
broker: b,
cursorByShard: make(map[int]int64),
}
}
func (s *mapMetricsSampler) storeCursor(shard int, cursor int64) {
s.mu.Lock()
s.cursorByShard[shard] = cursor
s.mu.Unlock()
}
func (s *mapMetricsSampler) sample(ctx context.Context) {
if metrics.MapBrokerPostgresPartitions == nil {
return
}
brokerName := s.broker.conf.Name
// Sample partition count on the stream table.
var partitionCount int
err := s.broker.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhparent
WHERE c.relname = $1
`, s.broker.names.stream).Scan(&partitionCount)
if err == nil {
metrics.MapBrokerPostgresPartitions.WithLabelValues(brokerName).Set(float64(partitionCount))
}
// Sample cursor lag per shard.
s.mu.Lock()
cursors := make(map[int]int64, len(s.cursorByShard))
for k, v := range s.cursorByShard {
cursors[k] = v
}
s.mu.Unlock()
for shard, cursor := range cursors {
var createdAt time.Time
err := s.broker.pool.QueryRow(ctx, fmt.Sprintf(
`SELECT created_at FROM %s WHERE id = $1 LIMIT 1`,
s.broker.names.stream,
), cursor).Scan(&createdAt)
if err != nil {
metrics.MapBrokerPostgresOutboxCursorLagSeconds.WithLabelValues(brokerName, fmt.Sprintf("%d", shard)).Set(0)
continue
}
lag := time.Since(createdAt).Seconds()
if lag < 0 {
lag = 0
}
metrics.MapBrokerPostgresOutboxCursorLagSeconds.WithLabelValues(brokerName, fmt.Sprintf("%d", shard)).Set(lag)
}
}

View File

@@ -0,0 +1,40 @@
package pgmapbroker
import (
"regexp"
"testing"
)
// execSchemaWithRetry re-runs the whole batch on a concurrent-DDL conflict,
// which is only safe while the batch stays one implicit transaction: no
// statement of its own opening or closing a transaction, and nothing that
// cannot run inside a transaction block at all (CREATE INDEX CONCURRENTLY
// would fail outright with 25001, and a retry could not undo the half of the
// batch that already committed). The invariant lives in the SQL template, so
// pin it here rather than in a comment.
func TestSchemaTemplate_StaysOneImplicitTransaction(t *testing.T) {
concurrently := regexp.MustCompile(`(?i)\bCONCURRENTLY\b`)
txControl := regexp.MustCompile(`(?im)^\s*(BEGIN|COMMIT|ROLLBACK|START\s+TRANSACTION)\b`)
for _, tc := range []struct {
name string
prefix string
binary bool
}{
{"jsonb", "cf_map_", false},
{"binary", "cf_binary_map_", true},
} {
t.Run(tc.name, func(t *testing.T) {
sql := renderSchema(tc.prefix, tc.binary)
if loc := concurrently.FindString(sql); loc != "" {
t.Errorf("schema template uses %q — it cannot run inside the implicit transaction execSchemaWithRetry relies on", loc)
}
// Only the DDL half is checked for transaction control: the funcs
// half is dollar-quoted PL/pgSQL, where BEGIN opens a block.
ddl, _ := splitSchemaSQL(sql)
if loc := txControl.FindString(ddl); loc != "" {
t.Errorf("schema DDL contains explicit transaction control %q — the batch must be left to the server's implicit transaction", loc)
}
})
}
}