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