@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/.tsbuildinfo +1 -1
- package/dist/@types/postgres-store.d.ts +64 -5
- package/dist/@types/postgres-store.d.ts.map +1 -1
- package/dist/index.cjs +147 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +147 -11
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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:
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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.
|