@rotorsoft/act-pg 1.13.1 → 1.13.3

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.
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ var dateReviver = (_key, value) => {
25
25
 
26
26
  // src/postgres-store.ts
27
27
  var logger = log();
28
+ var SOURCE_METACHARACTER_CLASS = "[]^$.*+?()[{}|\\\\]";
28
29
  var { Pool, types } = pg;
29
30
  types.setTypeParser(
30
31
  types.builtins.JSONB,
@@ -34,6 +35,8 @@ var SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
34
35
  var PG_UNIQUE_VIOLATION = "23505";
35
36
  var NOTIFY_CHANNEL_PREFIX = "act_commit";
36
37
  var NOTIFY_MAX_PAYLOAD_BYTES = 8e3;
38
+ var NOTIFY_RECONNECT_BASE_MS = 250;
39
+ var NOTIFY_RECONNECT_MAX_MS = 3e4;
37
40
  function notify_channel(schema, table) {
38
41
  return `${NOTIFY_CHANNEL_PREFIX}_${schema}_${table}`;
39
42
  }
@@ -106,6 +109,34 @@ var PostgresStore = class {
106
109
  * connection would re-fire the stale handler.
107
110
  */
108
111
  _listen_handler;
112
+ /**
113
+ * Error listener attached to the active LISTEN client. node-postgres
114
+ * removes its idle-error guard on checkout, so a checked-out client
115
+ * that emits `error` (backend restart, failover, network drop) with no
116
+ * listener is an uncaught exception — a process crash (#1189). Tracked
117
+ * alongside `_listen_handler` so teardown detaches it in lockstep.
118
+ */
119
+ _listen_error_handler;
120
+ /**
121
+ * The caller's notification handler for the active subscription, kept
122
+ * so the self-healing reconnect path (#1189) can re-establish LISTEN
123
+ * on a fresh client after the dedicated one emits `error`. Cleared by
124
+ * `_teardown_listen`, which is what makes disposal cancel any pending
125
+ * reconnect.
126
+ */
127
+ _notify_handler;
128
+ /**
129
+ * Pending reconnect timer, if a LISTEN client error scheduled one.
130
+ * Tracked so `_teardown_listen` (and therefore `dispose()`) can cancel
131
+ * it — a reconnect must never fire after teardown.
132
+ */
133
+ _reconnect_timer;
134
+ /**
135
+ * Consecutive reconnect attempts since the last healthy LISTEN, used to
136
+ * grow the capped exponential backoff. Reset to 0 once a re-LISTEN
137
+ * succeeds.
138
+ */
139
+ _reconnect_attempts = 0;
109
140
  /**
110
141
  * Cross-process commit subscription. **Present only when
111
142
  * `config.notify === true`** — the orchestrator's auto-wire path
@@ -173,16 +204,30 @@ var PostgresStore = class {
173
204
  await this._pool.end();
174
205
  }
175
206
  /**
176
- * Tear down the active LISTEN subscription if any: detach the
177
- * notification listener, run UNLISTEN, and destroy the dedicated
178
- * client (do not return it to the pool its listener is removed but
179
- * destroying belt-and-braces guards against any future change in
180
- * pg-pool semantics that could re-issue a half-clean client).
207
+ * Tear down the active LISTEN subscription if any: cancel any pending
208
+ * reconnect, forget the caller's handler (so no reconnect can fire
209
+ * after teardown), detach the notification + error listeners, run
210
+ * UNLISTEN, and destroy the dedicated client (do not return it to the
211
+ * pool its listeners are removed but destroying belt-and-braces
212
+ * guards against any future change in pg-pool semantics that could
213
+ * re-issue a half-clean client).
214
+ *
215
+ * Clearing `_notify_handler` and the reconnect timer here is what makes
216
+ * `dispose()` safe during a pending reconnect (#1189): a scheduled
217
+ * `_reconnect` bails the moment it finds no handler.
181
218
  */
182
219
  async _teardown_listen() {
220
+ if (this._reconnect_timer) {
221
+ clearTimeout(this._reconnect_timer);
222
+ this._reconnect_timer = void 0;
223
+ }
224
+ this._notify_handler = void 0;
225
+ this._reconnect_attempts = 0;
183
226
  if (!this._listen_client) return;
184
227
  this._listen_client.removeListener("notification", this._listen_handler);
228
+ this._listen_client.removeListener("error", this._listen_error_handler);
185
229
  this._listen_handler = void 0;
230
+ this._listen_error_handler = void 0;
186
231
  try {
187
232
  await this._listen_client.query(`UNLISTEN ${this._channel}`);
188
233
  } catch {
@@ -246,7 +291,7 @@ var PostgresStore = class {
246
291
  stream varchar(100) COLLATE pg_catalog."default" PRIMARY KEY,
247
292
  source varchar(100) COLLATE pg_catalog."default",
248
293
  at int NOT NULL DEFAULT -1,
249
- retry smallint NOT NULL DEFAULT -1,
294
+ retry int NOT NULL DEFAULT -1,
250
295
  blocked boolean NOT NULL DEFAULT false,
251
296
  error text,
252
297
  leased_by text,
@@ -268,6 +313,21 @@ var PostgresStore = class {
268
313
  `ALTER TABLE ${this._fqs}
269
314
  ADD COLUMN IF NOT EXISTS deferred_at timestamptz;`
270
315
  );
316
+ await client.query(
317
+ `DO $$
318
+ BEGIN
319
+ IF EXISTS (
320
+ SELECT 1 FROM information_schema.columns
321
+ WHERE table_schema = '${this.config.schema}'
322
+ AND table_name = '${this.config.table}_streams'
323
+ AND column_name = 'retry'
324
+ AND data_type = 'smallint'
325
+ ) THEN
326
+ EXECUTE 'ALTER TABLE ${this._fqs} ALTER COLUMN retry TYPE integer';
327
+ END IF;
328
+ END
329
+ $$;`
330
+ );
271
331
  await client.query(
272
332
  `DROP INDEX IF EXISTS "${this.config.schema}"."${this.config.table}_streams_fetch_ix"`
273
333
  );
@@ -528,7 +588,16 @@ var PostgresStore = class {
528
588
  SELECT 1 FROM ${this._fqt} e
529
589
  WHERE e.id > s.at
530
590
  AND e.name <> '${SNAP_EVENT}'
531
- AND (s.source IS NULL OR e.stream = COALESCE(s.source, s.stream))
591
+ -- Literal source (no regex metacharacter) matches by
592
+ -- equality \u2014 index-friendly, and exact so "s1" never
593
+ -- claims "s12". A pattern source (e.g. '^(A|B)$') matches
594
+ -- with the POSIX regex operator so the calculator's static
595
+ -- regex reaction is claimed for every stream it anchors.
596
+ AND (
597
+ s.source IS NULL
598
+ OR (s.source !~ '${SOURCE_METACHARACTER_CLASS}' AND e.stream = s.source)
599
+ OR (s.source ~ '${SOURCE_METACHARACTER_CLASS}' AND e.stream ~ s.source)
600
+ )
532
601
  LIMIT 1
533
602
  ))
534
603
  FOR UPDATE SKIP LOCKED
@@ -1154,11 +1223,39 @@ var PostgresStore = class {
1154
1223
  * client; pool disposal also tears the subscription down as a safety
1155
1224
  * net.
1156
1225
  *
1226
+ * The subscription is **self-healing** (#1189): the dedicated client
1227
+ * has an `error` listener that, on a connection blip (backend restart,
1228
+ * failover, network drop), tears the dead client down and re-LISTENs
1229
+ * on a fresh one with capped exponential backoff — degrading to the
1230
+ * poll path in between. A pending reconnect is cancelled by disposal,
1231
+ * so no reconnect fires after teardown.
1232
+ *
1157
1233
  * @param handler Called for each cross-process commit notification.
1158
1234
  * @returns Disposer that releases the LISTEN client.
1159
1235
  */
1160
1236
  async _subscribe_notifications(handler) {
1161
1237
  await this._teardown_listen();
1238
+ this._notify_handler = handler;
1239
+ try {
1240
+ await this._open_listen(handler);
1241
+ } catch (err) {
1242
+ this._notify_handler = void 0;
1243
+ throw err;
1244
+ }
1245
+ return async () => {
1246
+ if (this._notify_handler !== handler) return;
1247
+ await this._teardown_listen();
1248
+ };
1249
+ }
1250
+ /**
1251
+ * Check out a dedicated client, attach the notification + error
1252
+ * listeners, and run `LISTEN`. Shared by the initial subscription and
1253
+ * every reconnect (#1189). On any failure before `LISTEN` succeeds the
1254
+ * client is detached and destroyed so nothing leaks — the caller
1255
+ * decides whether to propagate (initial subscribe) or reschedule
1256
+ * (reconnect).
1257
+ */
1258
+ async _open_listen(handler) {
1162
1259
  const client = await this._client("notify");
1163
1260
  const on_notification = (msg) => {
1164
1261
  if (msg.channel !== this._channel) return;
@@ -1197,20 +1294,59 @@ var PostgresStore = class {
1197
1294
  logger.error(err, "act_commit: handler threw, listener preserved");
1198
1295
  }
1199
1296
  };
1297
+ const on_error = (err) => {
1298
+ logger.error(err, "act_commit: LISTEN client errored, reconnecting");
1299
+ this._reconnect();
1300
+ };
1200
1301
  client.on("notification", on_notification);
1302
+ client.on("error", on_error);
1201
1303
  try {
1202
1304
  await client.query(`LISTEN ${this._channel}`);
1203
1305
  } catch (err) {
1204
1306
  client.removeListener("notification", on_notification);
1307
+ client.removeListener("error", on_error);
1205
1308
  client.release(true);
1206
1309
  throw err;
1207
1310
  }
1208
1311
  this._listen_client = client;
1209
1312
  this._listen_handler = on_notification;
1210
- return async () => {
1211
- if (this._listen_client !== client) return;
1212
- await this._teardown_listen();
1213
- };
1313
+ this._listen_error_handler = on_error;
1314
+ this._reconnect_attempts = 0;
1315
+ }
1316
+ /**
1317
+ * Self-heal the LISTEN subscription after the dedicated client emitted
1318
+ * `error` (#1189). Detaches and destroys the dead client, then
1319
+ * reconnects on a fresh one with capped exponential backoff. Bails
1320
+ * immediately if the subscription was disposed while a reconnect was
1321
+ * pending (`_notify_handler` cleared by `_teardown_listen`), so no
1322
+ * reconnect ever fires after teardown.
1323
+ */
1324
+ _reconnect() {
1325
+ const handler = this._notify_handler;
1326
+ if (!handler) return;
1327
+ if (this._listen_client) {
1328
+ this._listen_client.removeListener("notification", this._listen_handler);
1329
+ this._listen_client.removeListener("error", this._listen_error_handler);
1330
+ this._listen_handler = void 0;
1331
+ this._listen_error_handler = void 0;
1332
+ this._listen_client.release(true);
1333
+ this._listen_client = void 0;
1334
+ }
1335
+ const delay = Math.min(
1336
+ NOTIFY_RECONNECT_MAX_MS,
1337
+ NOTIFY_RECONNECT_BASE_MS * 2 ** this._reconnect_attempts
1338
+ );
1339
+ this._reconnect_attempts++;
1340
+ this._reconnect_timer = setTimeout(() => {
1341
+ this._reconnect_timer = void 0;
1342
+ const current = this._notify_handler;
1343
+ if (!current) return;
1344
+ this._open_listen(current).catch((err) => {
1345
+ logger.error(err, "act_commit: LISTEN reconnect failed, retrying");
1346
+ this._reconnect();
1347
+ });
1348
+ }, delay);
1349
+ this._reconnect_timer.unref?.();
1214
1350
  }
1215
1351
  /**
1216
1352
  * Atomically truncates streams and seeds each with a snapshot or tombstone.