@rotorsoft/act-pg 1.13.0 → 1.13.2

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.
@@ -188,6 +188,34 @@ export declare class PostgresStore implements Store {
188
188
  * connection would re-fire the stale handler.
189
189
  */
190
190
  private _listen_handler;
191
+ /**
192
+ * Error listener attached to the active LISTEN client. node-postgres
193
+ * removes its idle-error guard on checkout, so a checked-out client
194
+ * that emits `error` (backend restart, failover, network drop) with no
195
+ * listener is an uncaught exception — a process crash (#1189). Tracked
196
+ * alongside `_listen_handler` so teardown detaches it in lockstep.
197
+ */
198
+ private _listen_error_handler;
199
+ /**
200
+ * The caller's notification handler for the active subscription, kept
201
+ * so the self-healing reconnect path (#1189) can re-establish LISTEN
202
+ * on a fresh client after the dedicated one emits `error`. Cleared by
203
+ * `_teardown_listen`, which is what makes disposal cancel any pending
204
+ * reconnect.
205
+ */
206
+ private _notify_handler;
207
+ /**
208
+ * Pending reconnect timer, if a LISTEN client error scheduled one.
209
+ * Tracked so `_teardown_listen` (and therefore `dispose()`) can cancel
210
+ * it — a reconnect must never fire after teardown.
211
+ */
212
+ private _reconnect_timer;
213
+ /**
214
+ * Consecutive reconnect attempts since the last healthy LISTEN, used to
215
+ * grow the capped exponential backoff. Reset to 0 once a re-LISTEN
216
+ * succeeds.
217
+ */
218
+ private _reconnect_attempts;
191
219
  /**
192
220
  * Cross-process commit subscription. **Present only when
193
221
  * `config.notify === true`** — the orchestrator's auto-wire path
@@ -228,11 +256,17 @@ export declare class PostgresStore implements Store {
228
256
  */
229
257
  dispose(): Promise<void>;
230
258
  /**
231
- * Tear down the active LISTEN subscription if any: detach the
232
- * notification listener, run UNLISTEN, and destroy the dedicated
233
- * client (do not return it to the pool its listener is removed but
234
- * destroying belt-and-braces guards against any future change in
235
- * pg-pool semantics that could re-issue a half-clean client).
259
+ * Tear down the active LISTEN subscription if any: cancel any pending
260
+ * reconnect, forget the caller's handler (so no reconnect can fire
261
+ * after teardown), detach the notification + error listeners, run
262
+ * UNLISTEN, and destroy the dedicated client (do not return it to the
263
+ * pool its listeners are removed but destroying belt-and-braces
264
+ * guards against any future change in pg-pool semantics that could
265
+ * re-issue a half-clean client).
266
+ *
267
+ * Clearing `_notify_handler` and the reconnect timer here is what makes
268
+ * `dispose()` safe during a pending reconnect (#1189): a scheduled
269
+ * `_reconnect` bails the moment it finds no handler.
236
270
  */
237
271
  private _teardown_listen;
238
272
  /**
@@ -437,10 +471,35 @@ export declare class PostgresStore implements Store {
437
471
  * client; pool disposal also tears the subscription down as a safety
438
472
  * net.
439
473
  *
474
+ * The subscription is **self-healing** (#1189): the dedicated client
475
+ * has an `error` listener that, on a connection blip (backend restart,
476
+ * failover, network drop), tears the dead client down and re-LISTENs
477
+ * on a fresh one with capped exponential backoff — degrading to the
478
+ * poll path in between. A pending reconnect is cancelled by disposal,
479
+ * so no reconnect fires after teardown.
480
+ *
440
481
  * @param handler Called for each cross-process commit notification.
441
482
  * @returns Disposer that releases the LISTEN client.
442
483
  */
443
484
  private _subscribe_notifications;
485
+ /**
486
+ * Check out a dedicated client, attach the notification + error
487
+ * listeners, and run `LISTEN`. Shared by the initial subscription and
488
+ * every reconnect (#1189). On any failure before `LISTEN` succeeds the
489
+ * client is detached and destroyed so nothing leaks — the caller
490
+ * decides whether to propagate (initial subscribe) or reschedule
491
+ * (reconnect).
492
+ */
493
+ private _open_listen;
494
+ /**
495
+ * Self-heal the LISTEN subscription after the dedicated client emitted
496
+ * `error` (#1189). Detaches and destroys the dead client, then
497
+ * reconnects on a fresh one with capped exponential backoff. Bails
498
+ * immediately if the subscription was disposed while a reconnect was
499
+ * pending (`_notify_handler` cleared by `_teardown_listen`), so no
500
+ * reconnect ever fires after teardown.
501
+ */
502
+ private _reconnect;
444
503
  /**
445
504
  * Atomically truncates streams and seeds each with a snapshot or tombstone.
446
505
  * Windowed targets (`before` set) prune the prefix below the closest safe
@@ -1 +1 @@
1
- {"version":3,"file":"postgres-store.d.ts","sourceRoot":"","sources":["../../src/postgres-store.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"postgres-store.d.ts","sourceRoot":"","sources":["../../src/postgres-store.ts"],"names":[],"mappings":"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"}
package/dist/index.cjs CHANGED
@@ -60,6 +60,8 @@ var SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
60
60
  var PG_UNIQUE_VIOLATION = "23505";
61
61
  var NOTIFY_CHANNEL_PREFIX = "act_commit";
62
62
  var NOTIFY_MAX_PAYLOAD_BYTES = 8e3;
63
+ var NOTIFY_RECONNECT_BASE_MS = 250;
64
+ var NOTIFY_RECONNECT_MAX_MS = 3e4;
63
65
  function notify_channel(schema, table) {
64
66
  return `${NOTIFY_CHANNEL_PREFIX}_${schema}_${table}`;
65
67
  }
@@ -132,6 +134,34 @@ var PostgresStore = class {
132
134
  * connection would re-fire the stale handler.
133
135
  */
134
136
  _listen_handler;
137
+ /**
138
+ * Error listener attached to the active LISTEN client. node-postgres
139
+ * removes its idle-error guard on checkout, so a checked-out client
140
+ * that emits `error` (backend restart, failover, network drop) with no
141
+ * listener is an uncaught exception — a process crash (#1189). Tracked
142
+ * alongside `_listen_handler` so teardown detaches it in lockstep.
143
+ */
144
+ _listen_error_handler;
145
+ /**
146
+ * The caller's notification handler for the active subscription, kept
147
+ * so the self-healing reconnect path (#1189) can re-establish LISTEN
148
+ * on a fresh client after the dedicated one emits `error`. Cleared by
149
+ * `_teardown_listen`, which is what makes disposal cancel any pending
150
+ * reconnect.
151
+ */
152
+ _notify_handler;
153
+ /**
154
+ * Pending reconnect timer, if a LISTEN client error scheduled one.
155
+ * Tracked so `_teardown_listen` (and therefore `dispose()`) can cancel
156
+ * it — a reconnect must never fire after teardown.
157
+ */
158
+ _reconnect_timer;
159
+ /**
160
+ * Consecutive reconnect attempts since the last healthy LISTEN, used to
161
+ * grow the capped exponential backoff. Reset to 0 once a re-LISTEN
162
+ * succeeds.
163
+ */
164
+ _reconnect_attempts = 0;
135
165
  /**
136
166
  * Cross-process commit subscription. **Present only when
137
167
  * `config.notify === true`** — the orchestrator's auto-wire path
@@ -199,16 +229,30 @@ var PostgresStore = class {
199
229
  await this._pool.end();
200
230
  }
201
231
  /**
202
- * Tear down the active LISTEN subscription if any: detach the
203
- * notification listener, run UNLISTEN, and destroy the dedicated
204
- * client (do not return it to the pool its listener is removed but
205
- * destroying belt-and-braces guards against any future change in
206
- * pg-pool semantics that could re-issue a half-clean client).
232
+ * Tear down the active LISTEN subscription if any: cancel any pending
233
+ * reconnect, forget the caller's handler (so no reconnect can fire
234
+ * after teardown), detach the notification + error listeners, run
235
+ * UNLISTEN, and destroy the dedicated client (do not return it to the
236
+ * pool its listeners are removed but destroying belt-and-braces
237
+ * guards against any future change in pg-pool semantics that could
238
+ * re-issue a half-clean client).
239
+ *
240
+ * Clearing `_notify_handler` and the reconnect timer here is what makes
241
+ * `dispose()` safe during a pending reconnect (#1189): a scheduled
242
+ * `_reconnect` bails the moment it finds no handler.
207
243
  */
208
244
  async _teardown_listen() {
245
+ if (this._reconnect_timer) {
246
+ clearTimeout(this._reconnect_timer);
247
+ this._reconnect_timer = void 0;
248
+ }
249
+ this._notify_handler = void 0;
250
+ this._reconnect_attempts = 0;
209
251
  if (!this._listen_client) return;
210
252
  this._listen_client.removeListener("notification", this._listen_handler);
253
+ this._listen_client.removeListener("error", this._listen_error_handler);
211
254
  this._listen_handler = void 0;
255
+ this._listen_error_handler = void 0;
212
256
  try {
213
257
  await this._listen_client.query(`UNLISTEN ${this._channel}`);
214
258
  } catch {
@@ -272,7 +316,7 @@ var PostgresStore = class {
272
316
  stream varchar(100) COLLATE pg_catalog."default" PRIMARY KEY,
273
317
  source varchar(100) COLLATE pg_catalog."default",
274
318
  at int NOT NULL DEFAULT -1,
275
- retry smallint NOT NULL DEFAULT -1,
319
+ retry int NOT NULL DEFAULT -1,
276
320
  blocked boolean NOT NULL DEFAULT false,
277
321
  error text,
278
322
  leased_by text,
@@ -294,6 +338,21 @@ var PostgresStore = class {
294
338
  `ALTER TABLE ${this._fqs}
295
339
  ADD COLUMN IF NOT EXISTS deferred_at timestamptz;`
296
340
  );
341
+ await client.query(
342
+ `DO $$
343
+ BEGIN
344
+ IF EXISTS (
345
+ SELECT 1 FROM information_schema.columns
346
+ WHERE table_schema = '${this.config.schema}'
347
+ AND table_name = '${this.config.table}_streams'
348
+ AND column_name = 'retry'
349
+ AND data_type = 'smallint'
350
+ ) THEN
351
+ EXECUTE 'ALTER TABLE ${this._fqs} ALTER COLUMN retry TYPE integer';
352
+ END IF;
353
+ END
354
+ $$;`
355
+ );
297
356
  await client.query(
298
357
  `DROP INDEX IF EXISTS "${this.config.schema}"."${this.config.table}_streams_fetch_ix"`
299
358
  );
@@ -439,16 +498,13 @@ var PostgresStore = class {
439
498
  async commit(stream, msgs, meta, expectedVersion) {
440
499
  if (msgs.length === 0) return [];
441
500
  const client = await this._client("commit");
442
- let version = -1;
443
501
  try {
444
- await client.query("BEGIN");
445
502
  const last = await client.query(
446
- `SELECT version
447
- FROM ${this._fqt}
448
- WHERE stream=$1 ORDER BY version DESC LIMIT 1`,
503
+ `SELECT version FROM ${this._fqt}
504
+ WHERE stream=$1 ORDER BY version DESC LIMIT 1`,
449
505
  [stream]
450
506
  );
451
- version = last.rowCount ? last.rows[0].version : -1;
507
+ let version = last.rows.at(0)?.version ?? -1;
452
508
  if (typeof expectedVersion === "number" && version !== expectedVersion)
453
509
  throw new import_act.ConcurrencyError(
454
510
  stream,
@@ -456,52 +512,69 @@ var PostgresStore = class {
456
512
  msgs,
457
513
  expectedVersion
458
514
  );
459
- const committed = [];
515
+ const base_version = version;
516
+ const names = [];
517
+ const datas = [];
518
+ const piis = [];
519
+ const versions = [];
460
520
  for (const { name, data, pii } of msgs) {
461
521
  version++;
462
- const sql = `
522
+ names.push(name);
523
+ datas.push(JSON.stringify(data));
524
+ piis.push(
525
+ this._resolve_pii_key && pii != null ? JSON.stringify(await (0, import_act_crypto.encrypt)(pii, this._resolve_pii_key)) : pii != null ? JSON.stringify(pii) : null
526
+ );
527
+ versions.push(version);
528
+ }
529
+ const insert_select = msgs.length === 1 ? `SELECT $1, $2::jsonb, $3::jsonb, $5, $4::int, $6 FROM l` : `SELECT u.name, u.data::jsonb, u.pii::jsonb, $5, u.version, $6
530
+ FROM l, unnest($1::text[], $2::text[], $3::text[], $4::int[])
531
+ WITH ORDINALITY AS u(name, data, pii, version, ord)
532
+ ORDER BY u.ord`;
533
+ const notify_ctes = this.config.notify ? `,
534
+ payload AS (
535
+ SELECT json_build_object(
536
+ 'stream', $5::text,
537
+ 'events', json_agg(json_build_object('id', ins.id, 'name', ins.name) ORDER BY ins.version),
538
+ 'by', $9::text
539
+ )::text AS p
540
+ FROM ins
541
+ ),
542
+ n AS (
543
+ SELECT pg_notify($8, payload.p) FROM payload
544
+ WHERE octet_length(payload.p) < $10
545
+ )` : "";
546
+ const final_select = this.config.notify ? "SELECT ins.* FROM ins LEFT JOIN n ON true ORDER BY ins.version" : "SELECT * FROM ins ORDER BY version";
547
+ const sql = `WITH l AS (SELECT pg_advisory_xact_lock(hashtext($7))),
548
+ ins AS (
463
549
  INSERT INTO ${this._fqt}(name, data, pii, stream, version, meta)
464
- VALUES($1, $2, $3, $4, $5, $6) RETURNING *`;
465
- const pii_for_write = this._resolve_pii_key && pii != null ? JSON.stringify(await (0, import_act_crypto.encrypt)(pii, this._resolve_pii_key)) : pii ?? null;
466
- const vals = [name, data, pii_for_write, stream, version, meta];
467
- try {
468
- const { rows } = await client.query(sql, vals);
469
- const row = rows.at(0);
470
- if (this._resolve_pii_key && typeof row.pii === "string") {
471
- const decrypted = await (0, import_act_crypto.decrypt)(row.pii, this._resolve_pii_key);
472
- row.pii = decrypted;
473
- }
474
- committed.push(row);
475
- } catch (error) {
476
- if (error?.code === PG_UNIQUE_VIOLATION) {
477
- throw new import_act.ConcurrencyError(
478
- stream,
479
- version - 1,
480
- msgs,
481
- expectedVersion ?? -1
482
- );
550
+ ${insert_select}
551
+ RETURNING *
552
+ )${notify_ctes}
553
+ ${final_select}`;
554
+ const base_params = msgs.length === 1 ? [names[0], datas[0], piis[0], versions[0], stream, meta, this._fqt] : [names, datas, piis, versions, stream, meta, this._fqt];
555
+ const params = this.config.notify ? [...base_params, this._channel, this._by, NOTIFY_MAX_PAYLOAD_BYTES] : base_params;
556
+ try {
557
+ const { rows } = await client.query(sql, params);
558
+ if (this._resolve_pii_key) {
559
+ for (const row of rows) {
560
+ if (typeof row.pii === "string") {
561
+ const decrypted = await (0, import_act_crypto.decrypt)(row.pii, this._resolve_pii_key);
562
+ row.pii = decrypted;
563
+ }
483
564
  }
484
- throw error;
485
565
  }
566
+ return rows;
567
+ } catch (error) {
568
+ if (error?.code === PG_UNIQUE_VIOLATION) {
569
+ throw new import_act.ConcurrencyError(
570
+ stream,
571
+ base_version,
572
+ msgs,
573
+ expectedVersion ?? -1
574
+ );
575
+ }
576
+ throw error;
486
577
  }
487
- if (this.config.notify) {
488
- const payload = JSON.stringify({
489
- stream,
490
- events: committed.map((c) => ({ id: c.id, name: c.name })),
491
- by: this._by
492
- });
493
- if (Buffer.byteLength(payload, "utf8") < NOTIFY_MAX_PAYLOAD_BYTES)
494
- await client.query(`SELECT pg_notify($1, $2)`, [
495
- this._channel,
496
- payload
497
- ]);
498
- }
499
- await client.query("COMMIT");
500
- return committed;
501
- } catch (error) {
502
- await client.query("ROLLBACK").catch(() => {
503
- });
504
- throw error;
505
578
  } finally {
506
579
  client.release();
507
580
  }
@@ -1166,11 +1239,39 @@ var PostgresStore = class {
1166
1239
  * client; pool disposal also tears the subscription down as a safety
1167
1240
  * net.
1168
1241
  *
1242
+ * The subscription is **self-healing** (#1189): the dedicated client
1243
+ * has an `error` listener that, on a connection blip (backend restart,
1244
+ * failover, network drop), tears the dead client down and re-LISTENs
1245
+ * on a fresh one with capped exponential backoff — degrading to the
1246
+ * poll path in between. A pending reconnect is cancelled by disposal,
1247
+ * so no reconnect fires after teardown.
1248
+ *
1169
1249
  * @param handler Called for each cross-process commit notification.
1170
1250
  * @returns Disposer that releases the LISTEN client.
1171
1251
  */
1172
1252
  async _subscribe_notifications(handler) {
1173
1253
  await this._teardown_listen();
1254
+ this._notify_handler = handler;
1255
+ try {
1256
+ await this._open_listen(handler);
1257
+ } catch (err) {
1258
+ this._notify_handler = void 0;
1259
+ throw err;
1260
+ }
1261
+ return async () => {
1262
+ if (this._notify_handler !== handler) return;
1263
+ await this._teardown_listen();
1264
+ };
1265
+ }
1266
+ /**
1267
+ * Check out a dedicated client, attach the notification + error
1268
+ * listeners, and run `LISTEN`. Shared by the initial subscription and
1269
+ * every reconnect (#1189). On any failure before `LISTEN` succeeds the
1270
+ * client is detached and destroyed so nothing leaks — the caller
1271
+ * decides whether to propagate (initial subscribe) or reschedule
1272
+ * (reconnect).
1273
+ */
1274
+ async _open_listen(handler) {
1174
1275
  const client = await this._client("notify");
1175
1276
  const on_notification = (msg) => {
1176
1277
  if (msg.channel !== this._channel) return;
@@ -1209,20 +1310,59 @@ var PostgresStore = class {
1209
1310
  logger.error(err, "act_commit: handler threw, listener preserved");
1210
1311
  }
1211
1312
  };
1313
+ const on_error = (err) => {
1314
+ logger.error(err, "act_commit: LISTEN client errored, reconnecting");
1315
+ this._reconnect();
1316
+ };
1212
1317
  client.on("notification", on_notification);
1318
+ client.on("error", on_error);
1213
1319
  try {
1214
1320
  await client.query(`LISTEN ${this._channel}`);
1215
1321
  } catch (err) {
1216
1322
  client.removeListener("notification", on_notification);
1323
+ client.removeListener("error", on_error);
1217
1324
  client.release(true);
1218
1325
  throw err;
1219
1326
  }
1220
1327
  this._listen_client = client;
1221
1328
  this._listen_handler = on_notification;
1222
- return async () => {
1223
- if (this._listen_client !== client) return;
1224
- await this._teardown_listen();
1225
- };
1329
+ this._listen_error_handler = on_error;
1330
+ this._reconnect_attempts = 0;
1331
+ }
1332
+ /**
1333
+ * Self-heal the LISTEN subscription after the dedicated client emitted
1334
+ * `error` (#1189). Detaches and destroys the dead client, then
1335
+ * reconnects on a fresh one with capped exponential backoff. Bails
1336
+ * immediately if the subscription was disposed while a reconnect was
1337
+ * pending (`_notify_handler` cleared by `_teardown_listen`), so no
1338
+ * reconnect ever fires after teardown.
1339
+ */
1340
+ _reconnect() {
1341
+ const handler = this._notify_handler;
1342
+ if (!handler) return;
1343
+ if (this._listen_client) {
1344
+ this._listen_client.removeListener("notification", this._listen_handler);
1345
+ this._listen_client.removeListener("error", this._listen_error_handler);
1346
+ this._listen_handler = void 0;
1347
+ this._listen_error_handler = void 0;
1348
+ this._listen_client.release(true);
1349
+ this._listen_client = void 0;
1350
+ }
1351
+ const delay = Math.min(
1352
+ NOTIFY_RECONNECT_MAX_MS,
1353
+ NOTIFY_RECONNECT_BASE_MS * 2 ** this._reconnect_attempts
1354
+ );
1355
+ this._reconnect_attempts++;
1356
+ this._reconnect_timer = setTimeout(() => {
1357
+ this._reconnect_timer = void 0;
1358
+ const current = this._notify_handler;
1359
+ if (!current) return;
1360
+ this._open_listen(current).catch((err) => {
1361
+ logger.error(err, "act_commit: LISTEN reconnect failed, retrying");
1362
+ this._reconnect();
1363
+ });
1364
+ }, delay);
1365
+ this._reconnect_timer.unref?.();
1226
1366
  }
1227
1367
  /**
1228
1368
  * Atomically truncates streams and seeds each with a snapshot or tombstone.
@@ -1240,6 +1380,9 @@ var PostgresStore = class {
1240
1380
  const client = await this._client("truncate");
1241
1381
  try {
1242
1382
  await client.query("BEGIN");
1383
+ await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [
1384
+ this._fqt
1385
+ ]);
1243
1386
  const result = /* @__PURE__ */ new Map();
1244
1387
  if (full.length) {
1245
1388
  await client.query(`DELETE FROM ${this._fqs} WHERE stream = ANY($1)`, [