@rotorsoft/act-pg 1.9.3 → 1.10.1

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
@@ -48,7 +48,35 @@ var DEFAULT_CONFIG = {
48
48
  password: "postgres",
49
49
  schema: "public",
50
50
  table: "events",
51
- notify: false
51
+ notify: false,
52
+ // Opinionated pool defaults (#1119). node-postgres ships `max: 10`
53
+ // with no acquisition timeout and no statement timeout — a saturated
54
+ // pool makes every caller hang indefinitely instead of failing with
55
+ // a diagnosable error. Nearly every store method holds a client for
56
+ // a multi-statement transaction, so multi-lane drains plus API
57
+ // traffic can exhaust a 10-client pool quickly. All four values are
58
+ // plain `pg.PoolConfig` fields — caller config overrides any of them
59
+ // via the constructor spread.
60
+ //
61
+ // - `max: 20` — floor for the default lane's parallel handler budget
62
+ // (streamLimit 10, each commit holds a client) plus API commits,
63
+ // the optional LISTEN client, and headroom. Sizing rule in the
64
+ // README: Σ per-lane streamLimit + API concurrency + notify + 2–4.
65
+ // - `connectionTimeoutMillis: 10_000` — fail acquisition fast (the
66
+ // pg default of 0 waits forever); surfaces as StoreError via
67
+ // `_client()` so operators see *which* operation starved.
68
+ // - `idleTimeoutMillis: 30_000` — keep idle clients warm across
69
+ // drain cycles (cycleMs can exceed the pg default of 10s in
70
+ // low-traffic deployments) without pinning connections for long.
71
+ // - `statement_timeout: 60_000` — per-statement (not per-transaction)
72
+ // ceiling; every statement the store issues (claim CTE, seed DDL,
73
+ // truncate, restore's per-event inserts) legitimately completes
74
+ // orders of magnitude faster, so 60s only fires on a wedged server
75
+ // or a lost lock instead of holding the client hostage.
76
+ max: 20,
77
+ connectionTimeoutMillis: 1e4,
78
+ idleTimeoutMillis: 3e4,
79
+ statement_timeout: 6e4
52
80
  };
53
81
  var PostgresStore = class {
54
82
  _pool;
@@ -119,6 +147,21 @@ var PostgresStore = class {
119
147
  }
120
148
  this._resolve_pii_key = this.config.pii_encryption ? makeKeyResolver(this.config.pii_encryption) : void 0;
121
149
  }
150
+ /**
151
+ * Acquire a pooled client, translating acquisition failures into
152
+ * {@link StoreError} with the calling operation as context. With the
153
+ * default `connectionTimeoutMillis`, a saturated pool fails here
154
+ * after 10s with `Store operation "<operation>" failed` (driver
155
+ * error preserved as `cause`) instead of hanging indefinitely.
156
+ * Every method that checks out a client routes through this helper.
157
+ */
158
+ async _client(operation) {
159
+ try {
160
+ return await this._pool.connect();
161
+ } catch (error) {
162
+ throw new StoreError(operation, { cause: error });
163
+ }
164
+ }
122
165
  /**
123
166
  * Dispose of the store and close all database connections.
124
167
  * Releases any active LISTEN client first so the pool can drain cleanly.
@@ -152,7 +195,7 @@ var PostgresStore = class {
152
195
  * @throws Error if seeding fails
153
196
  */
154
197
  async seed() {
155
- const client = await this._pool.connect();
198
+ const client = await this._client("seed");
156
199
  try {
157
200
  await client.query("BEGIN");
158
201
  await client.query(
@@ -205,7 +248,8 @@ var PostgresStore = class {
205
248
  leased_by text,
206
249
  leased_until timestamptz,
207
250
  priority int NOT NULL DEFAULT 0,
208
- lane text NOT NULL DEFAULT 'default'
251
+ lane text NOT NULL DEFAULT 'default',
252
+ deferred_at timestamptz
209
253
  ) TABLESPACE pg_default;`
210
254
  );
211
255
  await client.query(
@@ -216,6 +260,10 @@ var PostgresStore = class {
216
260
  `ALTER TABLE ${this._fqs}
217
261
  ADD COLUMN IF NOT EXISTS lane text NOT NULL DEFAULT 'default';`
218
262
  );
263
+ await client.query(
264
+ `ALTER TABLE ${this._fqs}
265
+ ADD COLUMN IF NOT EXISTS deferred_at timestamptz;`
266
+ );
219
267
  await client.query(
220
268
  `DROP INDEX IF EXISTS "${this.config.schema}"."${this.config.table}_streams_fetch_ix"`
221
269
  );
@@ -360,7 +408,7 @@ var PostgresStore = class {
360
408
  */
361
409
  async commit(stream, msgs, meta, expectedVersion) {
362
410
  if (msgs.length === 0) return [];
363
- const client = await this._pool.connect();
411
+ const client = await this._client("commit");
364
412
  let version = -1;
365
413
  try {
366
414
  await client.query("BEGIN");
@@ -442,7 +490,7 @@ var PostgresStore = class {
442
490
  * @returns Leased streams with metadata
443
491
  */
444
492
  async claim(lagging, leading, by, millis, lane) {
445
- const client = await this._pool.connect();
493
+ const client = await this._client("claim");
446
494
  try {
447
495
  await client.query("BEGIN");
448
496
  const lane_clause = lane !== void 0 ? `AND s.lane = $5` : "";
@@ -456,6 +504,7 @@ var PostgresStore = class {
456
504
  WHERE blocked = false
457
505
  ${lane_clause}
458
506
  AND (leased_by IS NULL OR leased_until <= NOW())
507
+ AND (deferred_at IS NULL OR deferred_at <= NOW())
459
508
  AND (s.at < 0 OR EXISTS (
460
509
  SELECT 1 FROM ${this._fqt} e
461
510
  WHERE e.id > s.at
@@ -523,7 +572,7 @@ var PostgresStore = class {
523
572
  * @returns subscribed count and current max watermark.
524
573
  */
525
574
  async subscribe(streams) {
526
- const client = await this._pool.connect();
575
+ const client = await this._client("subscribe");
527
576
  try {
528
577
  await client.query("BEGIN");
529
578
  let subscribed = 0;
@@ -583,7 +632,7 @@ var PostgresStore = class {
583
632
  * @returns Acked leases.
584
633
  */
585
634
  async ack(leases) {
586
- const client = await this._pool.connect();
635
+ const client = await this._client("ack");
587
636
  try {
588
637
  await client.query("BEGIN");
589
638
  const { rows } = await client.query(
@@ -597,7 +646,8 @@ var PostgresStore = class {
597
646
  at = i.at,
598
647
  retry = -1,
599
648
  leased_by = NULL,
600
- leased_until = NULL
649
+ leased_until = NULL,
650
+ deferred_at = NULL
601
651
  FROM input i
602
652
  WHERE s.stream = i.stream AND s.leased_by = i.by
603
653
  RETURNING s.stream, s.source, s.at, i.by, s.retry, i.lagging, s.lane
@@ -628,7 +678,7 @@ var PostgresStore = class {
628
678
  * @returns Blocked leases.
629
679
  */
630
680
  async block(leases) {
631
- const client = await this._pool.connect();
681
+ const client = await this._client("block");
632
682
  try {
633
683
  await client.query("BEGIN");
634
684
  const { rows } = await client.query(
@@ -638,7 +688,7 @@ var PostgresStore = class {
638
688
  AS x(stream text, by text, error text, lagging boolean)
639
689
  )
640
690
  UPDATE ${this._fqs} AS s
641
- SET blocked = true, error = i.error
691
+ SET blocked = true, error = i.error, deferred_at = NULL
642
692
  FROM input i
643
693
  WHERE s.stream = i.stream AND s.leased_by = i.by AND s.blocked = false
644
694
  RETURNING s.stream, s.source, s.at, i.by, s.retry, s.error, i.lagging, s.lane
@@ -664,6 +714,33 @@ var PostgresStore = class {
664
714
  client.release();
665
715
  }
666
716
  }
717
+ /**
718
+ * Hold the matched streams out of {@link claim} until `deferred_at`
719
+ * (ms since epoch) — see {@link Store.defer}. Persists `deferred_at`
720
+ * (as a `timestamptz`) so the skip is honored by every competing
721
+ * worker; `claim` filters on `deferred_at <= NOW()`. Accepts an
722
+ * explicit list of names or a {@link StreamFilter}, mirroring
723
+ * {@link reset}/{@link prioritize}. Cleared by ack/block/reset/unblock.
724
+ *
725
+ * @returns Count of streams whose `deferred_at` was set.
726
+ */
727
+ async defer(input, deferred_at) {
728
+ const set_clause = `SET deferred_at = to_timestamp($1 / 1000.0), retry = -1`;
729
+ if (Array.isArray(input)) {
730
+ if (!input.length) return 0;
731
+ const { rowCount: rowCount2 } = await this._pool.query(
732
+ `UPDATE ${this._fqs} ${set_clause} WHERE stream = ANY($2)`,
733
+ [deferred_at, input]
734
+ );
735
+ return rowCount2 ?? 0;
736
+ }
737
+ const { clause, values } = this._filter_clause(input, 2);
738
+ const { rowCount } = await this._pool.query(
739
+ `UPDATE ${this._fqs} ${set_clause} WHERE ${clause}`,
740
+ [deferred_at, ...values]
741
+ );
742
+ return rowCount ?? 0;
743
+ }
667
744
  /**
668
745
  * Reset watermarks for the given streams to -1, clearing retry, blocked,
669
746
  * error, and lease state so they can be replayed from the beginning.
@@ -707,7 +784,7 @@ var PostgresStore = class {
707
784
  }
708
785
  async reset(input) {
709
786
  const set_clause = `SET at = -1, retry = -1, blocked = false, error = NULL,
710
- leased_by = NULL, leased_until = NULL`;
787
+ leased_by = NULL, leased_until = NULL, deferred_at = NULL`;
711
788
  if (Array.isArray(input)) {
712
789
  if (!input.length) return 0;
713
790
  const { rowCount: rowCount2 } = await this._pool.query(
@@ -740,7 +817,7 @@ var PostgresStore = class {
740
817
  */
741
818
  async unblock(input) {
742
819
  const set_clause = `SET retry = -1, blocked = false, error = NULL,
743
- leased_by = NULL, leased_until = NULL`;
820
+ leased_by = NULL, leased_until = NULL, deferred_at = NULL`;
744
821
  if (Array.isArray(input)) {
745
822
  if (!input.length) return 0;
746
823
  const { rowCount: rowCount2 } = await this._pool.query(
@@ -832,7 +909,7 @@ var PostgresStore = class {
832
909
  if (conditions.length) sql += " WHERE " + conditions.join(" AND ");
833
910
  values.push(limit);
834
911
  sql += ` ORDER BY stream LIMIT $${values.length}`;
835
- const client = await this._pool.connect();
912
+ const client = await this._client("query_streams");
836
913
  try {
837
914
  const [streamsResult, maxResult] = await Promise.all([
838
915
  client.query(sql, values),
@@ -1062,7 +1139,7 @@ var PostgresStore = class {
1062
1139
  */
1063
1140
  async _subscribe_notifications(handler) {
1064
1141
  await this._teardown_listen();
1065
- const client = await this._pool.connect();
1142
+ const client = await this._client("notify");
1066
1143
  const on_notification = (msg) => {
1067
1144
  if (msg.channel !== this._channel) return;
1068
1145
  if (!msg.payload) return;
@@ -1123,7 +1200,7 @@ var PostgresStore = class {
1123
1200
  async truncate(targets) {
1124
1201
  if (!targets.length) return /* @__PURE__ */ new Map();
1125
1202
  const streams = targets.map((t) => t.stream);
1126
- const client = await this._pool.connect();
1203
+ const client = await this._client("truncate");
1127
1204
  try {
1128
1205
  await client.query("BEGIN");
1129
1206
  await client.query(`DELETE FROM ${this._fqs} WHERE stream = ANY($1)`, [
@@ -1174,7 +1251,7 @@ var PostgresStore = class {
1174
1251
  * from 1. `created` is preserved verbatim from the source.
1175
1252
  */
1176
1253
  async restore(driver) {
1177
- const client = await this._pool.connect();
1254
+ const client = await this._client("restore");
1178
1255
  try {
1179
1256
  await client.query("BEGIN");
1180
1257
  await client.query(