snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
31
internal/pgmapbroker/config_test.go
Normal file
31
internal/pgmapbroker/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
23
internal/pgmapbroker/internal/sql/migrations/README.md
Normal file
23
internal/pgmapbroker/internal/sql/migrations/README.md
Normal 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.
|
||||
@@ -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
|
||||
703
internal/pgmapbroker/internal/sql/schema.sql
Normal file
703
internal/pgmapbroker/internal/sql/schema.sql
Normal 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;
|
||||
|
||||
2379
internal/pgmapbroker/pgmapbroker.go
Normal file
2379
internal/pgmapbroker/pgmapbroker.go
Normal file
File diff suppressed because it is too large
Load Diff
570
internal/pgmapbroker/pgmapbroker_bench_test.go
Normal file
570
internal/pgmapbroker/pgmapbroker_bench_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
4751
internal/pgmapbroker/pgmapbroker_test.go
Normal file
4751
internal/pgmapbroker/pgmapbroker_test.go
Normal file
File diff suppressed because it is too large
Load Diff
208
internal/pgmapbroker/pgx_helpers.go
Normal file
208
internal/pgmapbroker/pgx_helpers.go
Normal 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
|
||||
}
|
||||
196
internal/pgmapbroker/pgx_helpers_test.go
Normal file
196
internal/pgmapbroker/pgx_helpers_test.go
Normal 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")))
|
||||
})
|
||||
}
|
||||
73
internal/pgmapbroker/sampler.go
Normal file
73
internal/pgmapbroker/sampler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
40
internal/pgmapbroker/schema_template_test.go
Normal file
40
internal/pgmapbroker/schema_template_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user