Skip to content

Commit 2cfaa21

Browse files
committed
fix(cdc): emit fallback notification for oversized payloads + visible errors (#1120)
Postgres `pg_notify` enforces an 8000-byte payload cap. The previous `cdc_notify()` trigger function wrapped the call in a broad `EXCEPTION WHEN OTHERS ... RAISE WARNING ... RETURN NULL`, so when a row's `to_jsonb(NEW)` overflowed the cap (e.g. an enrichment-worker `flowsheet` UPDATE with `artist_bio` + seven streaming URLs) the notification was silently dropped — the originating mutation committed, but no consumer ever saw the event. The only signal was a buried PG-log WARNING line. Migration 0094 replaces the trigger function so it: - Detects the oversized case up-front (`octet_length(payload_text) > 7800`) and emits a minimal `cdc_oversized` fallback notification carrying `(table, schema, action, primary_key, payload_bytes, reason='payload_too_large')` so downstream consumers can refetch the row from the source of truth. - Routes any other unexpected exception through a dedicated `cdc_error` channel (`reason='trigger_exception'`) in addition to the existing `RAISE WARNING`, restoring listener visibility into trigger failures. - Returns `NEW`/`OLD` instead of bare `NULL` so the trigger contract is unambiguous if a future maintainer rewires it as `BEFORE`. Tests in `tests/integration/cdc-oversized-fallback.spec.js` verify both paths against real PG: normal INSERTs still fire `cdc`, oversized INSERTs fire `cdc_oversized` (and NOT `cdc`), and the row commits regardless. Also pins the function body so an accidental rollback of 0094 fails CI before redeploy. The migration header comment documents the new notification channels for listener-side consumers.
1 parent c22a9f6 commit 2cfaa21

5 files changed

Lines changed: 4978 additions & 1 deletion

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
-- Make CDC trigger visibility safe: detect oversized payloads before pg_notify
2+
-- and emit a minimal fallback notification instead of silently dropping the event,
3+
-- and surface any other trigger failure through a dedicated error channel.
4+
--
5+
-- Background: WXYC/Backend-Service#1120
6+
-- PostgreSQL's pg_notify enforces an 8000-byte payload limit. The previous
7+
-- cdc_notify() (migration 0046) wrapped the call in a broad `EXCEPTION WHEN
8+
-- OTHERS ... RAISE WARNING ... RETURN NULL` block. Because cdc_notify is wired
9+
-- as an AFTER trigger, the returned NULL is ignored by Postgres — the
10+
-- originating INSERT/UPDATE commits, but the notification never fires. The
11+
-- only operator signal was a PG-log WARNING line that the application servers
12+
-- don't tail. Risk hot-spot: the enrichment worker's flowsheet UPDATE writes
13+
-- artist_bio (free text) plus seven streaming-URL columns in a single row;
14+
-- `to_jsonb(NEW)` can plausibly cross 8000 bytes, dropping the `liveFs:update`
15+
-- event that dj-site SSE clients depend on.
16+
--
17+
-- Notification channels emitted by the updated function:
18+
--
19+
-- cdc — primary channel, unchanged shape:
20+
-- { table, schema, action, data, timestamp }
21+
-- Consumers: cdc-listener.ts, cdc-websocket.ts,
22+
-- metadata-broadcast, enrichment-worker.
23+
--
24+
-- cdc_oversized — fired in place of `cdc` when the primary payload would
25+
-- exceed the 7800-byte safety threshold (200 bytes of
26+
-- headroom below the 8000-byte pg_notify cap to cover
27+
-- JSON escaping in extremely rare edge cases). Carries:
28+
-- { table, schema, action, primary_key, payload_bytes,
29+
-- timestamp, reason: 'payload_too_large' }
30+
-- `primary_key` is best-effort: `data->>'id'` if the row
31+
-- has an `id` column, otherwise NULL. Consumers must
32+
-- refetch the row from the source of truth (e.g. REST
33+
-- by primary key, or a full table scan). New channel —
34+
-- see cdc-listener.ts subscription update.
35+
--
36+
-- cdc_error — fired when the trigger body raised an unexpected
37+
-- exception (anything other than the oversized branch).
38+
-- Carries:
39+
-- { table, schema, action, sqlstate, sqlerrm, timestamp,
40+
-- reason: 'trigger_exception' }
41+
-- Backed by a `RAISE WARNING` so the PG log still records
42+
-- the failure for forensics. New channel.
43+
--
44+
-- Trigger contract: still AFTER INSERT/UPDATE/DELETE on the same 22+ tables
45+
-- as 0046. AFTER triggers' return value is ignored by Postgres, but to keep
46+
-- the function's behavior easy to reason about (and safe if a future
47+
-- maintainer rewires it as BEFORE), we return NEW for INSERT/UPDATE and OLD
48+
-- for DELETE.
49+
50+
-- @no-precondition-needed: trigger-function replacement is idempotent; no
51+
-- data-shape invariant required.
52+
53+
CREATE OR REPLACE FUNCTION cdc_notify() RETURNS trigger AS $$
54+
DECLARE
55+
payload jsonb;
56+
payload_text text;
57+
row_data jsonb;
58+
pk_value text;
59+
return_row record;
60+
BEGIN
61+
IF TG_OP = 'DELETE' THEN
62+
row_data := to_jsonb(OLD);
63+
return_row := OLD;
64+
ELSE
65+
row_data := to_jsonb(NEW);
66+
return_row := NEW;
67+
END IF;
68+
69+
BEGIN
70+
payload := jsonb_build_object(
71+
'table', TG_TABLE_NAME,
72+
'schema', TG_TABLE_SCHEMA,
73+
'action', TG_OP,
74+
'data', row_data,
75+
'timestamp', (extract(epoch from clock_timestamp()) * 1000)::bigint
76+
);
77+
payload_text := payload::text;
78+
79+
-- pg_notify hard-caps each payload at 8000 bytes. We check below 7800
80+
-- to leave headroom: the LISTEN-side JSON re-encode is identity, but
81+
-- pathological Unicode in the row could grow the wire form by a few
82+
-- bytes during transit.
83+
IF octet_length(payload_text) > 7800 THEN
84+
pk_value := row_data->>'id';
85+
PERFORM pg_notify(
86+
'cdc_oversized',
87+
jsonb_build_object(
88+
'table', TG_TABLE_NAME,
89+
'schema', TG_TABLE_SCHEMA,
90+
'action', TG_OP,
91+
'primary_key', pk_value,
92+
'payload_bytes', octet_length(payload_text),
93+
'timestamp', (extract(epoch from clock_timestamp()) * 1000)::bigint,
94+
'reason', 'payload_too_large'
95+
)::text
96+
);
97+
RAISE WARNING 'cdc_notify oversized payload: table=% action=% bytes=% pk=%',
98+
TG_TABLE_NAME, TG_OP, octet_length(payload_text), COALESCE(pk_value, '<none>');
99+
ELSE
100+
PERFORM pg_notify('cdc', payload_text);
101+
END IF;
102+
EXCEPTION WHEN OTHERS THEN
103+
-- Last-resort visibility path. The previous behavior silently swallowed
104+
-- the exception here and returned NULL. We keep returning the row (the
105+
-- originating mutation must not roll back on a notify failure) but emit
106+
-- a dedicated cdc_error notification *and* a WARNING so the failure is
107+
-- visible to the listener fan-out, not just the PG log.
108+
RAISE WARNING 'cdc_notify failed: table=% action=% sqlstate=% sqlerrm=%',
109+
TG_TABLE_NAME, TG_OP, SQLSTATE, SQLERRM;
110+
BEGIN
111+
PERFORM pg_notify(
112+
'cdc_error',
113+
jsonb_build_object(
114+
'table', TG_TABLE_NAME,
115+
'schema', TG_TABLE_SCHEMA,
116+
'action', TG_OP,
117+
'sqlstate', SQLSTATE,
118+
'sqlerrm', SQLERRM,
119+
'timestamp', (extract(epoch from clock_timestamp()) * 1000)::bigint,
120+
'reason', 'trigger_exception'
121+
)::text
122+
);
123+
EXCEPTION WHEN OTHERS THEN
124+
-- pg_notify on cdc_error itself failed (e.g. its own payload too
125+
-- large, which would be extraordinary given the fixed shape).
126+
-- Nothing else to do; the outer WARNING above is the last signal.
127+
NULL;
128+
END;
129+
END;
130+
131+
RETURN return_row;
132+
END;
133+
$$ LANGUAGE plpgsql;

0 commit comments

Comments
 (0)