Skip to content

Commit 65643f2

Browse files
committed
fix(cdc-listener): subscribe to cdc_oversized and cdc_error channels for #1120 AC #3
Migration 0094 emits to two new pg_notify channels (cdc_oversized, cdc_error) when the primary cdc payload would have been silently dropped. Without consumers, those notifications go to /dev/null and AC Wire the consumer side end-to-end: - shared/database/src/cdc-listener.ts: define CdcOversizedEvent and CdcErrorEvent shapes matching the SQL payloads; add onCdcOversizedEvent and onCdcErrorEvent registrations; LISTEN on both new channels alongside the existing cdc subscription in startCdcListener; clear the new callback arrays in stopCdcListener. - apps/backend/services/cdc/dispatcher.ts: wire the dispatcher's fallback sinks to Sentry.captureMessage with stable fingerprints ('cdc-oversized-payload' / 'cdc-trigger-exception'). Module-level latch keeps the registration idempotent across stray startCdcDispatcher calls; shutdown drops the latch. - apps/enrichment-worker/worker.ts: mirror the Sentry wiring in the worker process so its independent LISTEN connection also surfaces the fallback channels (consumer='enrichment-worker' tag for disambiguation). Tests: - BS#1120 describe block in cdc-listener.test.ts pins channel subscription, callback dispatch, multi-callback fan-out, callback-error isolation, malformed-payload tolerance, and stopCdcListener teardown of the new callback arrays. - BS#1120 describe block in cdc-websocket.test.ts (alongside the existing dispatcher tests) pins the dispatcher's Sentry wiring, captureMessage tag/extra/fingerprint shape, double-start idempotency, and shutdown-resets-latch behavior. - Updated the BS#1014 enableLivenessProbe channel-order assertion to cover the new cdc_oversized + cdc_error subscriptions.
1 parent 9d9d6a4 commit 65643f2

5 files changed

Lines changed: 595 additions & 7 deletions

File tree

apps/backend/services/cdc/dispatcher.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,98 @@
99
*
1010
* Wire format and `onCdcEvent` API are unchanged — both still come from
1111
* `@wxyc/database` and remain the cross-consumer contract.
12+
*
13+
* BS#1120 fallback-channel sinks: `cdc_oversized` and `cdc_error` notifications
14+
* (emitted by migration 0094 when the primary `cdc` payload would have been
15+
* dropped) are wired here to `Sentry.captureMessage` so AC #3's "emit a metric
16+
* Sentry can alert on" is satisfied. Subscribing only — the cdc-listener owns
17+
* the LISTEN; this module owns the Sentry signal.
18+
*/
19+
20+
import * as Sentry from '@sentry/node';
21+
import { onCdcErrorEvent, onCdcOversizedEvent, startCdcListener, stopCdcListener } from '@wxyc/database';
22+
23+
/**
24+
* Stable Sentry fingerprints for the BS#1120 fallback channels. Each channel
25+
* gets a single issue group so alert thresholds count notifications, not
26+
* per-table churn. (The `table` is on `tags` for breakdown queries.)
27+
*/
28+
const OVERSIZED_FINGERPRINT = ['cdc-oversized-payload'];
29+
const ERROR_FINGERPRINT = ['cdc-trigger-exception'];
30+
31+
let fallbackSinksRegistered = false;
32+
33+
/**
34+
* Wires the BS#1120 fallback channels to Sentry. Idempotent: a second call is
35+
* a no-op so a stray `startCdcDispatcher()` (e.g. dev hot-reload) doesn't
36+
* stack duplicate captures. Exported so tests can drive the wiring without
37+
* coupling to module-init order. `__resetCdcFallbackSinksForTests` lets the
38+
* test harness drop the latch between cases.
1239
*/
40+
export function registerCdcFallbackSinks(): void {
41+
if (fallbackSinksRegistered) return;
42+
fallbackSinksRegistered = true;
1343

14-
import { startCdcListener, stopCdcListener } from '@wxyc/database';
44+
onCdcOversizedEvent((event) => {
45+
Sentry.captureMessage('cdc.oversized_payload', {
46+
level: 'warning',
47+
tags: {
48+
subsystem: 'cdc',
49+
table: event.table,
50+
action: event.action,
51+
reason: event.reason,
52+
},
53+
extra: {
54+
schema: event.schema,
55+
primary_key: event.primary_key,
56+
payload_bytes: event.payload_bytes,
57+
timestamp: event.timestamp,
58+
},
59+
fingerprint: OVERSIZED_FINGERPRINT,
60+
});
61+
});
62+
63+
onCdcErrorEvent((event) => {
64+
Sentry.captureMessage('cdc.trigger_exception', {
65+
level: 'error',
66+
tags: {
67+
subsystem: 'cdc',
68+
table: event.table,
69+
action: event.action,
70+
reason: event.reason,
71+
sqlstate: event.sqlstate,
72+
},
73+
extra: {
74+
schema: event.schema,
75+
sqlerrm: event.sqlerrm,
76+
timestamp: event.timestamp,
77+
},
78+
fingerprint: ERROR_FINGERPRINT,
79+
});
80+
});
81+
}
1582

1683
/**
1784
* Starts the per-process CDC LISTEN connection. Idempotent at the listener
18-
* layer (`startCdcListener` warns and returns on a second call). Call once
19-
* at startup, before any consumer that registers via `onCdcEvent`.
85+
* layer (`startCdcListener` warns and returns on a second call) and at the
86+
* fallback-sink layer (`registerCdcFallbackSinks` no-ops on the second call
87+
* via its module-level latch). Call once at startup, before any consumer that
88+
* registers via `onCdcEvent`.
2089
*/
2190
export async function startCdcDispatcher(): Promise<void> {
91+
registerCdcFallbackSinks();
2292
await startCdcListener();
2393
}
2494

2595
/**
2696
* Stops the per-process CDC LISTEN connection and clears registered
2797
* callbacks. Safe to call unconditionally during shutdown.
98+
*
99+
* Drops the fallback-sink latch so a subsequent `startCdcDispatcher()` (test
100+
* harness, future hot-reload) re-wires the captures against the freshly
101+
* cleared `@wxyc/database` callback arrays.
28102
*/
29103
export async function shutdownCdcDispatcher(): Promise<void> {
30104
await stopCdcListener();
105+
fallbackSinksRegistered = false;
31106
}

apps/enrichment-worker/worker.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
closeDatabaseConnection,
1919
enableLivenessProbe,
2020
onCdcConnectionStateChange,
21+
onCdcErrorEvent,
2122
onCdcEvent,
23+
onCdcOversizedEvent,
2224
startCdcListener,
2325
stopCdcListener,
2426
} from '@wxyc/database';
@@ -214,6 +216,51 @@ const main = async (): Promise<void> => {
214216
});
215217

216218
onCdcEvent(makeEnrichmentHandler());
219+
220+
// BS#1120: wire the migration-0094 fallback channels to Sentry so a dropped
221+
// primary `cdc` payload (oversized row) or an unexpected trigger exception
222+
// produces a metric the alert can fire on. Both the worker and the
223+
// backend's CDC dispatcher subscribe; they're independent LISTEN
224+
// connections, so each process needs its own sink.
225+
onCdcOversizedEvent((event) => {
226+
Sentry.captureMessage('cdc.oversized_payload', {
227+
level: 'warning',
228+
tags: {
229+
subsystem: 'cdc',
230+
consumer: 'enrichment-worker',
231+
table: event.table,
232+
action: event.action,
233+
reason: event.reason,
234+
},
235+
extra: {
236+
schema: event.schema,
237+
primary_key: event.primary_key,
238+
payload_bytes: event.payload_bytes,
239+
timestamp: event.timestamp,
240+
},
241+
fingerprint: ['cdc-oversized-payload'],
242+
});
243+
});
244+
onCdcErrorEvent((event) => {
245+
Sentry.captureMessage('cdc.trigger_exception', {
246+
level: 'error',
247+
tags: {
248+
subsystem: 'cdc',
249+
consumer: 'enrichment-worker',
250+
table: event.table,
251+
action: event.action,
252+
reason: event.reason,
253+
sqlstate: event.sqlstate,
254+
},
255+
extra: {
256+
schema: event.schema,
257+
sqlerrm: event.sqlerrm,
258+
timestamp: event.timestamp,
259+
},
260+
fingerprint: ['cdc-trigger-exception'],
261+
});
262+
});
263+
217264
await startCdcListener();
218265
// Belt-and-suspenders: the onlisten hook should have already flipped this
219266
// to true; re-assert in case a future cdc-listener change skips dispatch.

shared/database/src/cdc-listener.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@
1010
* worker's /healthcheck within ~one probe-cycle + echo-timeout. Postgres-js's
1111
* `onlisten` callback (third arg of `listen()`) also dispatches connected=true
1212
* on the initial subscribe and on every auto-reconnect.
13+
*
14+
* Oversized + error visibility (BS#1120): migration 0094 routes payloads that
15+
* exceed Postgres's 8000-byte `pg_notify` cap to a `cdc_oversized` channel and
16+
* unexpected trigger exceptions to a `cdc_error` channel. Both have distinct
17+
* payload shapes from the main `cdc` channel — see `CdcOversizedEvent` and
18+
* `CdcErrorEvent` — and consumers register via `onCdcOversizedEvent` /
19+
* `onCdcErrorEvent`. Wiring a Sentry sink in the process entry point (the
20+
* dispatcher in the backend, `worker.ts` in the enrichment worker) gives the
21+
* BS#1120 AC #3 metric the alert hook can drive off.
1322
*/
1423

1524
import postgres from 'postgres';
@@ -22,15 +31,58 @@ export interface CdcEvent {
2231
timestamp: number;
2332
}
2433

34+
/**
35+
* Payload shape emitted by `pg_notify('cdc_oversized', ...)` in migration 0094
36+
* when the would-be `cdc` payload exceeds the 7800-byte safety threshold.
37+
*
38+
* The originating mutation still committed — only the live notification was
39+
* dropped. Consumers that need the row's new state must refetch it (by
40+
* `primary_key` when present, otherwise by a source-of-truth scan).
41+
*/
42+
export interface CdcOversizedEvent {
43+
table: string;
44+
schema: string;
45+
action: 'INSERT' | 'UPDATE' | 'DELETE';
46+
/** `data->>'id'` from the row, when the table has an `id` column. Null otherwise. */
47+
primary_key: string | null;
48+
/** `octet_length(payload::text)` of the would-be `cdc` payload, in bytes. */
49+
payload_bytes: number;
50+
timestamp: number;
51+
reason: 'payload_too_large';
52+
}
53+
54+
/**
55+
* Payload shape emitted by `pg_notify('cdc_error', ...)` in migration 0094 when
56+
* the trigger body raised an unexpected exception. Paired with a `RAISE
57+
* WARNING` so PG logs still record the failure for forensics.
58+
*/
59+
export interface CdcErrorEvent {
60+
table: string;
61+
schema: string;
62+
action: 'INSERT' | 'UPDATE' | 'DELETE';
63+
/** SQLSTATE of the underlying PL/pgSQL exception. */
64+
sqlstate: string;
65+
/** SQLERRM of the underlying PL/pgSQL exception. */
66+
sqlerrm: string;
67+
timestamp: number;
68+
reason: 'trigger_exception';
69+
}
70+
2571
export type CdcEventCallback = (event: CdcEvent) => void;
2672
export type CdcConnectionStateCallback = (connected: boolean) => void;
73+
export type CdcOversizedEventCallback = (event: CdcOversizedEvent) => void;
74+
export type CdcErrorEventCallback = (event: CdcErrorEvent) => void;
2775

2876
const CDC_CHANNEL = 'cdc';
2977
const HEALTH_CHANNEL = 'cdc_health';
78+
const CDC_OVERSIZED_CHANNEL = 'cdc_oversized';
79+
const CDC_ERROR_CHANNEL = 'cdc_error';
3080

3181
let listenConnection: ReturnType<typeof postgres> | null = null;
3282
let callbacks: CdcEventCallback[] = [];
3383
let stateCallbacks: CdcConnectionStateCallback[] = [];
84+
let oversizedCallbacks: CdcOversizedEventCallback[] = [];
85+
let errorCallbacks: CdcErrorEventCallback[] = [];
3486

3587
let livenessTimer: ReturnType<typeof setInterval> | null = null;
3688
let outstandingProbeToken: string | null = null;
@@ -45,6 +97,40 @@ export function onCdcEvent(callback: CdcEventCallback): void {
4597
callbacks.push(callback);
4698
}
4799

100+
/**
101+
* Registers a callback to receive `cdc_oversized` events (BS#1120).
102+
*
103+
* Fired when a row's would-be `cdc` payload would exceed Postgres's 8000-byte
104+
* `pg_notify` cap (migration 0094 cuts over at 7800 bytes to leave wire
105+
* headroom). The originating mutation already committed; the live notification
106+
* was dropped. Sinks are typically:
107+
*
108+
* - Sentry signal so an alert can fire on AC #3 (see `dispatcher.ts`,
109+
* `worker.ts` for the wiring).
110+
* - A refetch path keyed off `primary_key` (when non-null) for downstream
111+
* consumers (SSE, enrichment, reconciliation) that need the row state.
112+
*
113+
* Multiple callbacks can be registered; all are invoked for each event.
114+
*/
115+
export function onCdcOversizedEvent(callback: CdcOversizedEventCallback): void {
116+
oversizedCallbacks.push(callback);
117+
}
118+
119+
/**
120+
* Registers a callback to receive `cdc_error` events (BS#1120).
121+
*
122+
* Fired when the `cdc_notify()` trigger body raised an unexpected exception
123+
* (anything other than the oversized branch, which has its own channel). The
124+
* trigger also emits `RAISE WARNING` so PG logs still record it for forensics;
125+
* this callback is the listener-side visibility path so the failure isn't
126+
* confined to PG logs the application servers don't tail.
127+
*
128+
* Multiple callbacks can be registered; all are invoked for each event.
129+
*/
130+
export function onCdcErrorEvent(callback: CdcErrorEventCallback): void {
131+
errorCallbacks.push(callback);
132+
}
133+
48134
/**
49135
* Registers a callback fired on CDC connection-state transitions.
50136
*
@@ -118,7 +204,42 @@ export async function startCdcListener(): Promise<void> {
118204
}
119205
);
120206

121-
console.log('[cdc-listener] Listening on channel:', CDC_CHANNEL);
207+
// BS#1120: fallback channels carry oversized/error notifications when the
208+
// primary `cdc` payload would have been dropped. Subscribed alongside `cdc`
209+
// so a single LISTEN connection covers all three. State callback intentionally
210+
// omitted — the `cdc` re-LISTEN above already covers reconnect signaling,
211+
// and these channels reuse the same socket.
212+
await listenConnection.listen(CDC_OVERSIZED_CHANNEL, (payload: string) => {
213+
try {
214+
const event = JSON.parse(payload) as CdcOversizedEvent;
215+
for (const cb of oversizedCallbacks) {
216+
try {
217+
cb(event);
218+
} catch (err) {
219+
console.error('[cdc-listener] Oversized callback error:', err);
220+
}
221+
}
222+
} catch (err) {
223+
console.error('[cdc-listener] Failed to parse cdc_oversized payload:', err);
224+
}
225+
});
226+
227+
await listenConnection.listen(CDC_ERROR_CHANNEL, (payload: string) => {
228+
try {
229+
const event = JSON.parse(payload) as CdcErrorEvent;
230+
for (const cb of errorCallbacks) {
231+
try {
232+
cb(event);
233+
} catch (err) {
234+
console.error('[cdc-listener] Error callback error:', err);
235+
}
236+
}
237+
} catch (err) {
238+
console.error('[cdc-listener] Failed to parse cdc_error payload:', err);
239+
}
240+
});
241+
242+
console.log('[cdc-listener] Listening on channels:', CDC_CHANNEL, CDC_OVERSIZED_CHANNEL, CDC_ERROR_CHANNEL);
122243
}
123244

124245
export interface LivenessProbeOptions {
@@ -227,6 +348,8 @@ export async function stopCdcListener(): Promise<void> {
227348
listenConnection = null;
228349
callbacks = [];
229350
stateCallbacks = [];
351+
oversizedCallbacks = [];
352+
errorCallbacks = [];
230353
console.log('[cdc-listener] Stopped');
231354
}
232355
}

0 commit comments

Comments
 (0)