@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.
- 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 +199 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +199 -56
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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:
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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
|
|
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
|
);
|
|
@@ -413,16 +472,13 @@ var PostgresStore = class {
|
|
|
413
472
|
async commit(stream, msgs, meta, expectedVersion) {
|
|
414
473
|
if (msgs.length === 0) return [];
|
|
415
474
|
const client = await this._client("commit");
|
|
416
|
-
let version = -1;
|
|
417
475
|
try {
|
|
418
|
-
await client.query("BEGIN");
|
|
419
476
|
const last = await client.query(
|
|
420
|
-
`SELECT version
|
|
421
|
-
|
|
422
|
-
WHERE stream=$1 ORDER BY version DESC LIMIT 1`,
|
|
477
|
+
`SELECT version FROM ${this._fqt}
|
|
478
|
+
WHERE stream=$1 ORDER BY version DESC LIMIT 1`,
|
|
423
479
|
[stream]
|
|
424
480
|
);
|
|
425
|
-
version = last.
|
|
481
|
+
let version = last.rows.at(0)?.version ?? -1;
|
|
426
482
|
if (typeof expectedVersion === "number" && version !== expectedVersion)
|
|
427
483
|
throw new ConcurrencyError(
|
|
428
484
|
stream,
|
|
@@ -430,52 +486,69 @@ var PostgresStore = class {
|
|
|
430
486
|
msgs,
|
|
431
487
|
expectedVersion
|
|
432
488
|
);
|
|
433
|
-
const
|
|
489
|
+
const base_version = version;
|
|
490
|
+
const names = [];
|
|
491
|
+
const datas = [];
|
|
492
|
+
const piis = [];
|
|
493
|
+
const versions = [];
|
|
434
494
|
for (const { name, data, pii } of msgs) {
|
|
435
495
|
version++;
|
|
436
|
-
|
|
496
|
+
names.push(name);
|
|
497
|
+
datas.push(JSON.stringify(data));
|
|
498
|
+
piis.push(
|
|
499
|
+
this._resolve_pii_key && pii != null ? JSON.stringify(await encrypt(pii, this._resolve_pii_key)) : pii != null ? JSON.stringify(pii) : null
|
|
500
|
+
);
|
|
501
|
+
versions.push(version);
|
|
502
|
+
}
|
|
503
|
+
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
|
|
504
|
+
FROM l, unnest($1::text[], $2::text[], $3::text[], $4::int[])
|
|
505
|
+
WITH ORDINALITY AS u(name, data, pii, version, ord)
|
|
506
|
+
ORDER BY u.ord`;
|
|
507
|
+
const notify_ctes = this.config.notify ? `,
|
|
508
|
+
payload AS (
|
|
509
|
+
SELECT json_build_object(
|
|
510
|
+
'stream', $5::text,
|
|
511
|
+
'events', json_agg(json_build_object('id', ins.id, 'name', ins.name) ORDER BY ins.version),
|
|
512
|
+
'by', $9::text
|
|
513
|
+
)::text AS p
|
|
514
|
+
FROM ins
|
|
515
|
+
),
|
|
516
|
+
n AS (
|
|
517
|
+
SELECT pg_notify($8, payload.p) FROM payload
|
|
518
|
+
WHERE octet_length(payload.p) < $10
|
|
519
|
+
)` : "";
|
|
520
|
+
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";
|
|
521
|
+
const sql = `WITH l AS (SELECT pg_advisory_xact_lock(hashtext($7))),
|
|
522
|
+
ins AS (
|
|
437
523
|
INSERT INTO ${this._fqt}(name, data, pii, stream, version, meta)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
stream,
|
|
453
|
-
version - 1,
|
|
454
|
-
msgs,
|
|
455
|
-
expectedVersion ?? -1
|
|
456
|
-
);
|
|
524
|
+
${insert_select}
|
|
525
|
+
RETURNING *
|
|
526
|
+
)${notify_ctes}
|
|
527
|
+
${final_select}`;
|
|
528
|
+
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];
|
|
529
|
+
const params = this.config.notify ? [...base_params, this._channel, this._by, NOTIFY_MAX_PAYLOAD_BYTES] : base_params;
|
|
530
|
+
try {
|
|
531
|
+
const { rows } = await client.query(sql, params);
|
|
532
|
+
if (this._resolve_pii_key) {
|
|
533
|
+
for (const row of rows) {
|
|
534
|
+
if (typeof row.pii === "string") {
|
|
535
|
+
const decrypted = await decrypt(row.pii, this._resolve_pii_key);
|
|
536
|
+
row.pii = decrypted;
|
|
537
|
+
}
|
|
457
538
|
}
|
|
458
|
-
throw error;
|
|
459
539
|
}
|
|
540
|
+
return rows;
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (error?.code === PG_UNIQUE_VIOLATION) {
|
|
543
|
+
throw new ConcurrencyError(
|
|
544
|
+
stream,
|
|
545
|
+
base_version,
|
|
546
|
+
msgs,
|
|
547
|
+
expectedVersion ?? -1
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
throw error;
|
|
460
551
|
}
|
|
461
|
-
if (this.config.notify) {
|
|
462
|
-
const payload = JSON.stringify({
|
|
463
|
-
stream,
|
|
464
|
-
events: committed.map((c) => ({ id: c.id, name: c.name })),
|
|
465
|
-
by: this._by
|
|
466
|
-
});
|
|
467
|
-
if (Buffer.byteLength(payload, "utf8") < NOTIFY_MAX_PAYLOAD_BYTES)
|
|
468
|
-
await client.query(`SELECT pg_notify($1, $2)`, [
|
|
469
|
-
this._channel,
|
|
470
|
-
payload
|
|
471
|
-
]);
|
|
472
|
-
}
|
|
473
|
-
await client.query("COMMIT");
|
|
474
|
-
return committed;
|
|
475
|
-
} catch (error) {
|
|
476
|
-
await client.query("ROLLBACK").catch(() => {
|
|
477
|
-
});
|
|
478
|
-
throw error;
|
|
479
552
|
} finally {
|
|
480
553
|
client.release();
|
|
481
554
|
}
|
|
@@ -1140,11 +1213,39 @@ var PostgresStore = class {
|
|
|
1140
1213
|
* client; pool disposal also tears the subscription down as a safety
|
|
1141
1214
|
* net.
|
|
1142
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
|
+
*
|
|
1143
1223
|
* @param handler Called for each cross-process commit notification.
|
|
1144
1224
|
* @returns Disposer that releases the LISTEN client.
|
|
1145
1225
|
*/
|
|
1146
1226
|
async _subscribe_notifications(handler) {
|
|
1147
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) {
|
|
1148
1249
|
const client = await this._client("notify");
|
|
1149
1250
|
const on_notification = (msg) => {
|
|
1150
1251
|
if (msg.channel !== this._channel) return;
|
|
@@ -1183,20 +1284,59 @@ var PostgresStore = class {
|
|
|
1183
1284
|
logger.error(err, "act_commit: handler threw, listener preserved");
|
|
1184
1285
|
}
|
|
1185
1286
|
};
|
|
1287
|
+
const on_error = (err) => {
|
|
1288
|
+
logger.error(err, "act_commit: LISTEN client errored, reconnecting");
|
|
1289
|
+
this._reconnect();
|
|
1290
|
+
};
|
|
1186
1291
|
client.on("notification", on_notification);
|
|
1292
|
+
client.on("error", on_error);
|
|
1187
1293
|
try {
|
|
1188
1294
|
await client.query(`LISTEN ${this._channel}`);
|
|
1189
1295
|
} catch (err) {
|
|
1190
1296
|
client.removeListener("notification", on_notification);
|
|
1297
|
+
client.removeListener("error", on_error);
|
|
1191
1298
|
client.release(true);
|
|
1192
1299
|
throw err;
|
|
1193
1300
|
}
|
|
1194
1301
|
this._listen_client = client;
|
|
1195
1302
|
this._listen_handler = on_notification;
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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?.();
|
|
1200
1340
|
}
|
|
1201
1341
|
/**
|
|
1202
1342
|
* Atomically truncates streams and seeds each with a snapshot or tombstone.
|
|
@@ -1214,6 +1354,9 @@ var PostgresStore = class {
|
|
|
1214
1354
|
const client = await this._client("truncate");
|
|
1215
1355
|
try {
|
|
1216
1356
|
await client.query("BEGIN");
|
|
1357
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [
|
|
1358
|
+
this._fqt
|
|
1359
|
+
]);
|
|
1217
1360
|
const result = /* @__PURE__ */ new Map();
|
|
1218
1361
|
if (full.length) {
|
|
1219
1362
|
await client.query(`DELETE FROM ${this._fqs} WHERE stream = ANY($1)`, [
|