@everystack/cli 0.4.33 → 0.4.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,407 @@
1
+ /**
2
+ * swap-heartbeat — liveness for db:swap's restore leg, read from the OTHER side of the connection.
3
+ *
4
+ * The problem: `restoreIntoIncoming` spawns `psql -f <file>` and prints nothing until it exits.
5
+ * Both stderr streams are accumulated into strings and surfaced only on a non-zero exit, so for the
6
+ * whole multi-GB push a wedged restore and a healthy one produce the identical observation: silence.
7
+ *
8
+ * The probe: db:swap already holds an operator connection (`createUrlRunner`, max: 1) that sits
9
+ * COMPLETELY IDLE for the duration of the restore — `applyIncoming` ignores the runner it is handed
10
+ * and spawns psql on its own connection. So liveness costs no new connection and no new credential.
11
+ *
12
+ * ============================================================================================
13
+ * THE PROBE MUST NEVER TOUCH A RELATION. Learned the hard way, 2026-07-27.
14
+ * ============================================================================================
15
+ * The first version summed `pg_total_relation_size()` over the incoming schema. That function calls
16
+ * `relation_open(relid, AccessShareLock)` — it LOCKS every relation it measures, and holds those
17
+ * locks until the whole `sum()` completes. Against a restore doing DDL, two things follow:
18
+ *
19
+ * 1. The probe BLOCKS behind the restore's AccessExclusiveLock. Measured: it hung until killed by
20
+ * `statement_timeout`; unbounded without one. A liveness probe that hangs is worse than none —
21
+ * it reports "no backend connected" about a restore that is fine.
22
+ * 2. Worse, it blocks the RESTORE. PostgreSQL queues lock requests fairly, so once the probe's
23
+ * pending AccessShare is in line, the restore's next AccessExclusive queues behind IT. Polling
24
+ * every 10s injected a lock barrier across every table in the schema.
25
+ *
26
+ * Everything below reads ONLY statistics and catalog views — `pg_stat_all_tables`,
27
+ * `pg_stat_progress_copy`, `pg_stat_activity`, `pg_namespace`. All verified lock-free against a held
28
+ * AccessExclusiveLock (0.03s vs. blocking-until-killed). Do not reintroduce a relation-size call,
29
+ * `relpages` arithmetic aside — if you need bytes, `pg_stat_progress_copy.bytes_processed` is the
30
+ * lock-free source.
31
+ *
32
+ * Two signals, and the pair is what discriminates:
33
+ * - rows landed (committed inserts + the in-flight COPY's tuples) — is data actually arriving
34
+ * - the loader's wait state — what is it waiting ON
35
+ *
36
+ * Neither alone is enough. Over a high-latency link a HEALTHY COPY sits in `Client/ClientRead`
37
+ * constantly, between chunks — treating that as a stall cries wolf on every normal restore. The
38
+ * deadlock signature is `ClientRead` with FLAT progress across consecutive polls: the server is
39
+ * waiting for a client that has stopped sending. `Lock` is never healthy here and reports at once.
40
+ */
41
+
42
+ import type { QueryRunner } from './authz-contract.js';
43
+
44
+ /** One poll: what has landed, and what the loading backend is doing. */
45
+ export interface HeartbeatSample {
46
+ /** ms since the restore phase started. */
47
+ elapsedMs: number;
48
+ /**
49
+ * Rows landed, from `n_tup_ins` ALONE. Null before the schema exists.
50
+ *
51
+ * Do NOT add the in-flight COPY's tuples to this. The first version did, on the assumption that
52
+ * a COPY's tuples only enter `n_tup_ins` at commit. They do not: the backend flushes stats while
53
+ * the COPY is still running, so the two overlap. Measured on a real run — it reported 767,883
54
+ * rows landed for an artifact whose entire SQL file contains at most 345,663. In-flight tuples
55
+ * are carried separately as `copyRows` and reported, never summed.
56
+ */
57
+ rows: number | null;
58
+ /** Tuples processed by in-flight COPYs. Separate from `rows` — see above. */
59
+ copyRows: number;
60
+ /** Bytes processed by in-flight COPYs; 0 between them. The finer-grained signal inside one COPY. */
61
+ copyBytes: number;
62
+ /** How many COPY commands are running right now. */
63
+ copies: number;
64
+ /** Tables present in the incoming schema. */
65
+ tables: number;
66
+ /**
67
+ * ALL relations in the schema — tables, indexes, sequences, matviews. Its growth is the progress
68
+ * signal for the DDL and index phases, when no row has landed yet and nothing is COPYing.
69
+ */
70
+ relations: number;
71
+ /** Whether the incoming schema exists yet — keeps "empty" and "absent" distinguishable. */
72
+ schemaExists: boolean;
73
+ /** The loading backend's state, or null when no psql backend is connected. */
74
+ state: string | null;
75
+ waitEventType: string | null;
76
+ waitEvent: string | null;
77
+ /** Who holds the lock we are waiting on: `pid N [app] query...`. Null unless blocked. */
78
+ blockedBy: string | null;
79
+ }
80
+
81
+ export type Liveness =
82
+ /** Rows or COPY bytes grew since the last poll. */
83
+ | 'progressing'
84
+ /** Waiting on a lock — never healthy on this path. */
85
+ | 'blocked'
86
+ /** Flat progress AND the server is waiting on the client: the deadlock signature. */
87
+ | 'client-stall'
88
+ /** Connected and working, but nothing measurable moved this poll (DDL, index build). */
89
+ | 'busy'
90
+ /** No psql backend connected — not started yet, or already gone. */
91
+ | 'absent';
92
+
93
+ /** Consecutive stalled polls before the heartbeat escalates from info to warn. */
94
+ export const STALL_POLLS = 3;
95
+
96
+ /**
97
+ * Minimum quiet time since the last observed progress before a stall is called out.
98
+ *
99
+ * STALL_POLLS alone fires at the TAIL of a healthy run: a real restore ended with three flat polls
100
+ * and the warning printed one second before it completed successfully. Requiring a grace window
101
+ * since progress was last SEEN — not merely N polls in a row — keeps the alarm for genuine stalls.
102
+ */
103
+ export const STALL_GRACE_MS = 60_000;
104
+
105
+ /** Default ms between polls. Long enough to be quiet, short enough to localize a stall. */
106
+ const DEFAULT_INTERVAL_MS = 10_000;
107
+
108
+ /**
109
+ * Client-side cap on one poll. The query is provably lock-free, but a hung CONNECTION (network,
110
+ * server-side termination) could still park it forever, and a probe that never returns is exactly
111
+ * the failure this module exists to report. Racing in JS also covers hangs no `statement_timeout`
112
+ * would catch, because it does not depend on the server being responsive at all.
113
+ */
114
+ export const PROBE_TIMEOUT_MS = 5_000;
115
+
116
+ /** Single-quote a string literal for inlining into the probe SQL. */
117
+ function quoteLiteral(v: string): string {
118
+ return v.replace(/'/g, "''");
119
+ }
120
+
121
+ /**
122
+ * The probe, as ONE round trip. Statistics and catalog views ONLY — see the module header. Every
123
+ * CTE here was verified to return in ~0.03s while another session held an AccessExclusiveLock on
124
+ * the schema's tables.
125
+ */
126
+ export function heartbeatQuery(incoming: string): string {
127
+ const ns = quoteLiteral(incoming);
128
+ return `WITH ns AS (
129
+ SELECT oid FROM pg_namespace WHERE nspname = '${ns}'
130
+ ), tbl AS (
131
+ SELECT coalesce(sum(n_tup_ins), 0)::bigint AS rows_in,
132
+ count(*)::int AS tables
133
+ FROM pg_stat_all_tables WHERE schemaname = '${ns}'
134
+ ), rel AS (
135
+ -- EVERY relation, not just tables: indexes, sequences and matviews are relations, so this keeps
136
+ -- producing a progress signal through the CREATE INDEX phase after the data has landed.
137
+ SELECT count(*)::int AS relations
138
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '${ns}'
139
+ ), cp AS (
140
+ -- Scoped to OUR load: COPY FROM only (never a concurrent pg_dump's COPY TO), and only into
141
+ -- relations in the incoming schema. Unscoped, this counted every COPY in the database — on a
142
+ -- real run it reported 2,269,567 rows in flight for an artifact containing 341,160, because a
143
+ -- concurrent dump was streaming out. Worse than noise: "COPY advancing" reads as progress, so
144
+ -- another session's work could mask our restore being genuinely stuck.
145
+ SELECT count(*)::int AS copies,
146
+ coalesce(sum(p.bytes_processed), 0)::bigint AS copy_bytes,
147
+ coalesce(sum(p.tuples_processed), 0)::bigint AS copy_tuples
148
+ FROM pg_stat_progress_copy p
149
+ JOIN pg_class c ON c.oid = p.relid
150
+ JOIN pg_namespace n ON n.oid = c.relnamespace
151
+ WHERE p.datname = current_database()
152
+ AND p.command = 'COPY FROM'
153
+ AND n.nspname = '${ns}'
154
+ ), be AS (
155
+ -- pg_blocking_pids answers the question a bare wait_event cannot: WHO is holding the lock.
156
+ -- Without it "Lock/relation" is unactionable and invites guessing at the culprit.
157
+ SELECT a.state, a.wait_event_type, a.wait_event,
158
+ (SELECT string_agg(
159
+ format('pid %s [%s] %s', b.pid,
160
+ coalesce(nullif(b.application_name, ''), '?'),
161
+ left(regexp_replace(coalesce(b.query, ''), '\\s+', ' ', 'g'), 80)),
162
+ '; ')
163
+ FROM pg_stat_activity b
164
+ WHERE b.pid = ANY (pg_blocking_pids(a.pid))) AS blocked_by
165
+ FROM pg_stat_activity a
166
+ WHERE a.datname = current_database()
167
+ AND a.pid <> pg_backend_pid()
168
+ AND a.application_name = 'psql'
169
+ ORDER BY a.backend_start DESC
170
+ LIMIT 1
171
+ )
172
+ SELECT (SELECT count(*) FROM ns) > 0 AS schema_exists,
173
+ tbl.rows_in, tbl.tables, rel.relations,
174
+ cp.copies, cp.copy_bytes, cp.copy_tuples,
175
+ be.state, be.wait_event_type, be.wait_event, be.blocked_by
176
+ FROM tbl CROSS JOIN rel CROSS JOIN cp LEFT JOIN be ON true`;
177
+ }
178
+
179
+ /** Map a probe row to a sample. A missing row, or a schema that does not exist, means null rows. */
180
+ export function toSample(row: any, elapsedMs: number): HeartbeatSample {
181
+ const schemaExists = row?.schema_exists === true;
182
+ const committed = row?.rows_in == null ? null : Number(row.rows_in);
183
+ return {
184
+ elapsedMs,
185
+ rows: schemaExists && committed !== null ? committed : null,
186
+ copyRows: Number(row?.copy_tuples ?? 0),
187
+ copyBytes: Number(row?.copy_bytes ?? 0),
188
+ copies: Number(row?.copies ?? 0),
189
+ tables: Number(row?.tables ?? 0),
190
+ relations: Number(row?.relations ?? 0),
191
+ schemaExists,
192
+ state: row?.state ?? null,
193
+ waitEventType: row?.wait_event_type ?? null,
194
+ waitEvent: row?.wait_event ?? null,
195
+ blockedBy: row?.blocked_by ?? null,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Did anything measurable move between two samples? Any of the three independent signals counts.
201
+ * `rows` is monotonic but coarse; the COPY counters are live but reset to 0 when a COPY finishes,
202
+ * so a drop in them is never evidence of a stall — only a rise is evidence of progress.
203
+ */
204
+ function movedForward(prev: HeartbeatSample, cur: HeartbeatSample): boolean {
205
+ if (prev.rows !== null && cur.rows !== null && cur.rows > prev.rows) return true;
206
+ if (cur.copyBytes > prev.copyBytes) return true;
207
+ if (cur.copyRows > prev.copyRows) return true;
208
+ // Schema growth. Without this a restore visibly creating sixteen tables every poll read as a
209
+ // stall and tripped the deadlock alarm, because no ROW had landed yet — the data phase had not
210
+ // started. Every phase of a restore must have a signal, or the quiet ones look like hangs.
211
+ return cur.relations > prev.relations;
212
+ }
213
+
214
+ /**
215
+ * The discrimination. Order is the whole point:
216
+ * 1. no backend → absent (nothing else can be said)
217
+ * 2. Lock → blocked, even mid-progress, and even with no baseline
218
+ * 3. no predecessor → busy (cannot claim progress OR a stall on the first poll)
219
+ * 4. rows/bytes grew → progressing, EVEN in ClientRead (the healthy high-latency case)
220
+ * 5. schema not created → busy (DDL phase, nothing to measure)
221
+ * 6. flat + ClientRead → client-stall (the deadlock signature)
222
+ */
223
+ export function classify(prev: HeartbeatSample | null, cur: HeartbeatSample): Liveness {
224
+ if (cur.state === null) return 'absent';
225
+ if (!prev) return 'busy';
226
+ // PROGRESS OUTRANKS EVERY SCARY WAIT STATE, including Lock. A restore takes relation locks
227
+ // constantly while building 84 indexes and 37 constraints, so a 10s sample catches it mid-wait
228
+ // routinely. The first version returned 'blocked' on sight of a Lock and printed "nothing
229
+ // landing" over a run whose row count was climbing 25k every poll. Same rule as ClientRead: the
230
+ // wait state only means something when nothing is moving.
231
+ if (movedForward(prev, cur)) return 'progressing';
232
+ if (cur.waitEventType === 'Lock') return 'blocked';
233
+ if (!cur.schemaExists) return 'busy';
234
+ if (cur.waitEvent === 'ClientRead') return 'client-stall';
235
+ return 'busy';
236
+ }
237
+
238
+ /** `12s`, `1m30s`, `2h05m01s`. */
239
+ export function humanElapsed(ms: number): string {
240
+ const total = Math.max(0, Math.floor(ms / 1000));
241
+ const h = Math.floor(total / 3600);
242
+ const m = Math.floor((total % 3600) / 60);
243
+ const s = total % 60;
244
+ if (h) return `${h}h${String(m).padStart(2, '0')}m${String(s).padStart(2, '0')}s`;
245
+ if (m) return `${m}m${String(s).padStart(2, '0')}s`;
246
+ return `${s}s`;
247
+ }
248
+
249
+ /** `1,234,567` — rows read better grouped. */
250
+ function groupNum(n: number): string {
251
+ return n.toLocaleString('en-US');
252
+ }
253
+
254
+ /** One operator-readable line per poll. Says what moved, what it is waiting on, and how long in. */
255
+ export function formatSample(prev: HeartbeatSample | null, cur: HeartbeatSample, liveness: Liveness): string {
256
+ const at = `t+${humanElapsed(cur.elapsedMs)}`;
257
+ const wait = cur.waitEventType ? `${cur.waitEventType}/${cur.waitEvent ?? '?'}` : (cur.state ?? 'idle');
258
+
259
+ if (liveness === 'absent') {
260
+ return `restore: no psql backend connected to the target (${at}) — the loader has not started yet, or has already exited.`;
261
+ }
262
+ if (!cur.schemaExists) {
263
+ return `restore: schema not created yet, no tables yet — DDL phase, ${wait} (${at}).`;
264
+ }
265
+
266
+ const rows = cur.rows === null ? 'unknown' : groupNum(cur.rows);
267
+ // In-flight COPY tuples are reported alongside the landed count, never folded into it.
268
+ const copying = cur.copies > 0
269
+ ? `, ${cur.copies} COPY running (${groupNum(cur.copyRows)} rows in flight)`
270
+ : '';
271
+ const scope = `${rows} row(s) landed across ${cur.tables} table(s)${copying}`;
272
+
273
+ // Every branch below states only what it actually read. The first version hardcoded "nothing
274
+ // landing" into the blocked branch, which then printed over a run whose row count was climbing.
275
+ if (liveness === 'blocked') {
276
+ // Naming the holder is the difference between an actionable report and a shrug.
277
+ const by = cur.blockedBy ? ` HELD BY ${cur.blockedBy}` : ' (holder not visible — it may belong to another role)';
278
+ return `restore: waiting on ${wait} with no progress this poll${by} — ${scope} (${at}).`;
279
+ }
280
+ if (liveness === 'client-stall') {
281
+ return `restore: nothing landed since the last poll and the server is waiting on the client (${wait}) — ${scope} (${at}).`;
282
+ }
283
+ if (liveness === 'progressing' && prev) {
284
+ const dRows = (cur.rows ?? 0) - (prev.rows ?? 0);
285
+ const secs = Math.max(1, (cur.elapsedMs - prev.elapsedMs) / 1000);
286
+ const moved = dRows > 0
287
+ ? `+${groupNum(dRows)} row(s) (${groupNum(Math.round(dRows / secs))} rows/s)`
288
+ : 'COPY advancing';
289
+ return `restore: ${scope}, ${moved} — ${wait} (${at}).`;
290
+ }
291
+ return `restore: ${scope}, nothing new this poll — ${wait} (${at}).`;
292
+ }
293
+
294
+ /**
295
+ * Run one probe. Never throws and never hangs: a probe that fails must not kill the restore it is
296
+ * watching, and a probe that parks forever is the failure it exists to report.
297
+ */
298
+ export async function pollOnce(
299
+ runner: QueryRunner,
300
+ incoming: string,
301
+ elapsedMs: number,
302
+ timeoutMs: number = PROBE_TIMEOUT_MS,
303
+ ): Promise<HeartbeatSample> {
304
+ let timer: any;
305
+ const timeout = new Promise<undefined>((resolve) => {
306
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
307
+ timer.unref?.();
308
+ });
309
+ try {
310
+ const rows = await Promise.race([
311
+ runner(heartbeatQuery(incoming)).catch(() => undefined),
312
+ timeout,
313
+ ]);
314
+ return toSample(Array.isArray(rows) ? rows[0] : undefined, elapsedMs);
315
+ } finally {
316
+ clearTimeout(timer);
317
+ }
318
+ }
319
+
320
+ export interface HeartbeatOptions {
321
+ /** The schema the restore is landing into. */
322
+ incoming: string;
323
+ /** Per-poll line sink. */
324
+ log: (msg: string) => void;
325
+ /** Escalation sink for a sustained stall or a lock wait. Defaults to `log`. */
326
+ warn?: (msg: string) => void;
327
+ intervalMs?: number;
328
+ /** Injectable clock, for tests. */
329
+ now?: () => number;
330
+ /** Per-poll cap; exposed for tests. */
331
+ timeoutMs?: number;
332
+ /**
333
+ * Every sample, raw, before any classification. This is how a caller acts on what the probe sees
334
+ * rather than only reading about it — db:swap uses it to kill a psql that the server has no
335
+ * backend for. Must not throw; a sink that does is ignored so it cannot kill the heartbeat.
336
+ */
337
+ onSample?: (sample: HeartbeatSample) => void;
338
+ }
339
+
340
+ /**
341
+ * Poll the idle operator connection until stopped. Returns the stopper, which clears the timer and
342
+ * awaits any in-flight poll so nothing logs after the caller has moved on.
343
+ *
344
+ * Ticks are SKIPPED while one is in flight, never queued. Chaining them (`inFlight.then(tick)`)
345
+ * serializes but does not drop: one slow poll lets `setInterval` stack dozens behind it, and they
346
+ * all drain at once the moment it resolves — a wall of identical lines that buries the real signal.
347
+ * Observed for real: a 7-minute poll produced ~40 stacked lines in a 5-second burst.
348
+ */
349
+ export function startHeartbeat(runner: QueryRunner, opts: HeartbeatOptions): () => Promise<void> {
350
+ const now = opts.now ?? (() => Date.now());
351
+ const warn = opts.warn ?? opts.log;
352
+ const startedAt = now();
353
+ let prev: HeartbeatSample | null = null;
354
+ let consecutiveStalls = 0;
355
+ let lastProgressMs = 0;
356
+ let inFlight: Promise<void> = Promise.resolve();
357
+ let running = false;
358
+ let stopped = false;
359
+
360
+ const tick = async () => {
361
+ const cur = await pollOnce(runner, opts.incoming, now() - startedAt, opts.timeoutMs);
362
+ if (stopped) return;
363
+ // Raw sample first: a caller acting on the probe (the dead-backend watchdog) must see every
364
+ // sample, and must never be able to break the heartbeat by throwing.
365
+ try { opts.onSample?.(cur); } catch { /* a sink's failure is not the probe's problem */ }
366
+ const liveness = classify(prev, cur);
367
+ const line = formatSample(prev, cur, liveness);
368
+
369
+ if (liveness === 'blocked') {
370
+ warn(line);
371
+ } else if (liveness === 'client-stall') {
372
+ consecutiveStalls += 1;
373
+ // Two conditions, not one. A stall must SUSTAIN (one flat poll is normal between COPY
374
+ // chunks) AND enough quiet must have passed since progress was last seen — otherwise the
375
+ // warning fires on the tail of a healthy run that is about to finish.
376
+ const quietMs = cur.elapsedMs - lastProgressMs;
377
+ if (consecutiveStalls >= STALL_POLLS && quietMs >= STALL_GRACE_MS) {
378
+ warn(
379
+ `${line} STALLED for ${consecutiveStalls} consecutive polls and ${humanElapsed(quietMs)} with no progress — this is the deadlock signature (the server is waiting for data psql is not sending).`,
380
+ );
381
+ } else {
382
+ opts.log(line);
383
+ }
384
+ } else {
385
+ consecutiveStalls = 0;
386
+ if (liveness === 'progressing') lastProgressMs = cur.elapsedMs;
387
+ opts.log(line);
388
+ }
389
+ prev = cur;
390
+ };
391
+
392
+ const timer = setInterval(() => {
393
+ if (running || stopped) return; // SKIP, never queue — see the doc comment.
394
+ running = true;
395
+ inFlight = tick()
396
+ .catch(() => {})
397
+ .finally(() => { running = false; });
398
+ }, opts.intervalMs ?? DEFAULT_INTERVAL_MS);
399
+ // Never hold the process open on the heartbeat alone.
400
+ (timer as any).unref?.();
401
+
402
+ return async () => {
403
+ stopped = true;
404
+ clearInterval(timer);
405
+ await inFlight.catch(() => {});
406
+ };
407
+ }