@rotorsoft/act-pg 1.13.16 → 1.13.18

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
@@ -6,7 +6,8 @@ import {
6
6
  log,
7
7
  SNAP_EVENT,
8
8
  StoreError,
9
- TOMBSTONE_EVENT
9
+ TOMBSTONE_EVENT,
10
+ ValidationError
10
11
  } from "@rotorsoft/act";
11
12
  import {
12
13
  decrypt,
@@ -26,6 +27,7 @@ var scopedTypes = {
26
27
  };
27
28
  var SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
28
29
  var PG_UNIQUE_VIOLATION = "23505";
30
+ var PG_NUL_BYTE = /* @__PURE__ */ new Set(["22P05", "22021"]);
29
31
  var NOTIFY_CHANNEL_PREFIX = "act_commit";
30
32
  var NOTIFY_MAX_PAYLOAD_BYTES = 8e3;
31
33
  var NOTIFY_RECONNECT_BASE_MS = 250;
@@ -239,6 +241,9 @@ var PostgresStore = class {
239
241
  const client = await this._client("seed");
240
242
  try {
241
243
  await client.query("BEGIN");
244
+ await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [
245
+ this.config.schema
246
+ ]);
242
247
  await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [
243
248
  `${this.config.schema}.${this.config.table}`
244
249
  ]);
@@ -281,6 +286,11 @@ var PostgresStore = class {
281
286
  ON ${this._fqt} (stream COLLATE pg_catalog."default", id)
282
287
  WHERE name = '${SNAP_EVENT}';`
283
288
  );
289
+ await client.query(
290
+ `CREATE INDEX IF NOT EXISTS "${this.config.table}_stream_id_ix"
291
+ ON ${this._fqt} (stream COLLATE pg_catalog."default", id)
292
+ WHERE name <> '${SNAP_EVENT}';`
293
+ );
284
294
  await client.query(
285
295
  `CREATE TABLE IF NOT EXISTS ${this._fqs} (
286
296
  stream text COLLATE pg_catalog."default" PRIMARY KEY,
@@ -573,6 +583,13 @@ var PostgresStore = class {
573
583
  expectedVersion ?? -1
574
584
  );
575
585
  }
586
+ if (PG_NUL_BYTE.has(error?.code ?? "")) {
587
+ throw new ValidationError(
588
+ stream,
589
+ msgs,
590
+ `Postgres cannot store a NUL byte (\\u0000). One of the ${msgs.length} event(s) committed to "${stream}" (${msgs.map((m) => String(m.name)).join(", ")}) carries one in its name, data, meta or pii. InMemory and SQLite accept it, so this commit would have succeeded there \u2014 strip NUL bytes at the edge where the data enters. Driver: ${error.message}`
591
+ );
592
+ }
576
593
  throw error;
577
594
  }
578
595
  } finally {
@@ -608,29 +625,66 @@ var PostgresStore = class {
608
625
  -- and lock EVERY claimable stream for this transaction, starving
609
626
  -- overlapping competing consumers. We only lock the small
610
627
  -- lagging+leading candidate slice, down in the "locked" CTE.
611
- available AS (
628
+ -- Eligibility, split by SOURCE CLASS rather than expressed as one
629
+ -- disjunction (#1448). The three classes are mutually exclusive, so
630
+ -- the UNION ALL returns exactly what the old OR-chain did \u2014 but each
631
+ -- arm now carries a single, planner-legible predicate.
632
+ --
633
+ -- That matters for one arm in particular. Written as
634
+ -- "s.source IS NULL OR (... AND e.stream = s.source) OR (... ~ ...)",
635
+ -- the equality is buried inside a disjunction and is NOT sargable:
636
+ -- no index on (stream, id) can serve it, so every probe degenerated
637
+ -- into a forward scan of the pk from the stream's watermark. With
638
+ -- the classes separated, the literal arm \u2014 every per-aggregate
639
+ -- .to(e => ({target: e.stream})) reaction, i.e. the overwhelming
640
+ -- majority \u2014 becomes an index seek on (stream, id).
641
+ --
642
+ -- Measured at 10k subscribed streams with 10 pending: 5,792 ms
643
+ -- before, 10.3 ms after. See libs/act-pg/PERFORMANCE.md for #1448.
644
+ eligible AS (
612
645
  SELECT stream, source, at, priority, lane
613
646
  FROM ${this._fqs} s
614
647
  WHERE blocked = false
615
648
  ${lane_clause}
616
649
  AND (leased_by IS NULL OR leased_until <= NOW())
617
650
  AND (deferred_at IS NULL OR deferred_at <= NOW())
618
- AND (s.at < 0 OR EXISTS (
651
+ ),
652
+ available AS (
653
+ -- Never processed: claimable without probing the log at all.
654
+ SELECT stream, source, at, priority, lane FROM eligible WHERE at < 0
655
+ UNION ALL
656
+ -- Source-less subscription: any non-snapshot event past the
657
+ -- watermark counts, so the id index alone answers it.
658
+ SELECT stream, source, at, priority, lane FROM eligible s
659
+ WHERE s.at >= 0 AND s.source IS NULL
660
+ AND EXISTS (
619
661
  SELECT 1 FROM ${this._fqt} e
620
- WHERE e.id > s.at
621
- AND e.name <> '${SNAP_EVENT}'
622
- -- Literal source (no regex metacharacter) matches by
623
- -- equality \u2014 index-friendly, and exact so "s1" never
624
- -- claims "s12". A pattern source (e.g. '^(A|B)$') matches
625
- -- with the POSIX regex operator so the calculator's static
626
- -- regex reaction is claimed for every stream it anchors.
627
- AND (
628
- s.source IS NULL
629
- OR (s.source !~ '${SOURCE_METACHARACTER_CLASS}' AND e.stream = s.source)
630
- OR (s.source ~ '${SOURCE_METACHARACTER_CLASS}' AND e.stream ~ s.source)
631
- )
632
- LIMIT 1
633
- ))
662
+ WHERE e.id > s.at AND e.name <> '${SNAP_EVENT}' LIMIT 1
663
+ )
664
+ UNION ALL
665
+ -- Literal source (no regex metacharacter): exact equality, so
666
+ -- "s1" never claims "s12", and (stream, id) is usable.
667
+ SELECT stream, source, at, priority, lane FROM eligible s
668
+ WHERE s.at >= 0 AND s.source IS NOT NULL
669
+ AND s.source !~ '${SOURCE_METACHARACTER_CLASS}'
670
+ AND EXISTS (
671
+ SELECT 1 FROM ${this._fqt} e
672
+ WHERE e.stream = s.source AND e.id > s.at
673
+ AND e.name <> '${SNAP_EVENT}' LIMIT 1
674
+ )
675
+ UNION ALL
676
+ -- Pattern source (e.g. '^(A|B)$'): POSIX match, so the
677
+ -- calculator's static regex reaction is claimed for every stream
678
+ -- it anchors. Unavoidably a scan \u2014 but only for the subscriptions
679
+ -- that actually declare a pattern.
680
+ SELECT stream, source, at, priority, lane FROM eligible s
681
+ WHERE s.at >= 0 AND s.source IS NOT NULL
682
+ AND s.source ~ '${SOURCE_METACHARACTER_CLASS}'
683
+ AND EXISTS (
684
+ SELECT 1 FROM ${this._fqt} e
685
+ WHERE e.id > s.at AND e.name <> '${SNAP_EVENT}'
686
+ AND e.stream ~ s.source LIMIT 1
687
+ )
634
688
  ),
635
689
  -- Priority lanes (ACT-102): higher priority first, then
636
690
  -- lagging-watermark order. With everyone at priority=0 the