@rotorsoft/act-pg 1.15.1 → 1.17.0

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
@@ -16,7 +16,6 @@ import {
16
16
  } from "@rotorsoft/act-crypto";
17
17
  import pg from "pg";
18
18
  var logger = log();
19
- var SOURCE_METACHARACTER_CLASS = "[]^$.*+?()[{}|\\\\]";
20
19
  var { Pool, types } = pg;
21
20
  var JSONB_OID = types.builtins.JSONB;
22
21
  var scopedTypes = {
@@ -336,6 +335,11 @@ var PostgresStore = class {
336
335
  `ALTER TABLE ${this._fqs}
337
336
  ADD COLUMN IF NOT EXISTS correlated_at int;`
338
337
  );
338
+ await client.query(
339
+ `UPDATE ${this._fqs}
340
+ SET correlated_at = (SELECT COALESCE(MAX(id), -1) FROM ${this._fqt})
341
+ WHERE correlated_at IS NULL;`
342
+ );
339
343
  await client.query(
340
344
  `DO $$
341
345
  BEGIN
@@ -648,38 +652,32 @@ var PostgresStore = class {
648
652
  -- and lock EVERY claimable stream for this transaction, starving
649
653
  -- overlapping competing consumers. We only lock the small
650
654
  -- lagging+leading candidate slice, down in the "locked" CTE.
651
- -- Eligibility, split by SOURCE CLASS rather than expressed as one
652
- -- disjunction (#1448). The three classes are mutually exclusive, so
653
- -- the UNION ALL returns exactly what the old OR-chain did \u2014 but each
654
- -- arm now carries a single, planner-legible predicate.
655
655
  --
656
- -- That matters for one arm in particular. Written as
657
- -- "s.source IS NULL OR (... AND e.stream = s.source) OR (... ~ ...)",
658
- -- the equality is buried inside a disjunction and is NOT sargable:
659
- -- no index on (stream, id) can serve it, so every probe degenerated
660
- -- into a forward scan of the pk from the stream's watermark. With
661
- -- the classes separated, the literal arm \u2014 every per-aggregate
662
- -- .to(e => ({target: e.stream})) reaction, i.e. the overwhelming
663
- -- majority \u2014 becomes an index seek on (stream, id).
656
+ -- Eligibility is a pure subscription-table predicate (#1488).
657
+ -- claim does not read the event log at all: correlate marks the
658
+ -- highest event id that resolves to a target, and at <
659
+ -- correlated_at is the whole question. The probe this replaced ran
660
+ -- an EXISTS against the events table once per eligible row, which
661
+ -- cost O(subscribed streams) per claim per worker no matter how
662
+ -- little work was pending.
664
663
  --
665
- -- Measured at 10k subscribed streams with 10 pending: 5,792 ms
666
- -- before, 10.3 ms after. See libs/act-pg/PERFORMANCE.md for #1448.
667
- eligible AS (
668
- SELECT stream, source, at, priority, lane, correlated_at
669
- FROM ${this._fqs} s
670
- WHERE blocked = false
671
- ${lane_clause}
672
- AND (leased_by IS NULL OR leased_until <= NOW())
673
- AND (deferred_at IS NULL OR deferred_at <= NOW())
674
- ),
664
+ -- Read from the base table so the planner can use the partial index
665
+ -- built for exactly this predicate:
666
+ -- (lane, priority DESC, at) WHERE blocked = false
667
+ -- AND at < correlated_at
668
+ -- It contains only streams with work, so LIMIT pushes into it and a
669
+ -- claim scans at most lagging + leading rows. A stream leaves the
670
+ -- index when ack advances at to the mark, and re-enters when
671
+ -- correlate raises it.
672
+ --
673
+ -- The comparison is NULL-safe by SQL's own rules: an unmarked row
674
+ -- compares unknown and is excluded. That is definitional now
675
+ -- (#1446) \u2014 a subscription is claimable iff a mark says so \u2014 where
676
+ -- before #1488 it meant "unknown, fall through to the probe". An
677
+ -- install upgrading from before the column needs one correlate pass
678
+ -- from a rewound checkpoint to mark its rows; see the runbook in
679
+ -- docs/docs/guides/production-checklist.md.
675
680
  available AS (
676
- -- Fast arm (#1485): a marked stream answers from the subscription
677
- -- row alone. Read from the base table, NOT from the eligible CTE:
678
- -- it is referenced several times so PG materializes it, and a
679
- -- materialized scan cannot use the partial index this predicate was
680
- -- built for (WHERE blocked = false AND at < correlated_at).
681
- -- The comparison is NULL-safe: an unmarked row compares
682
- -- unknown and is excluded here, falling through to the legacy arms.
683
681
  SELECT stream, source, at, priority, lane
684
682
  FROM ${this._fqs} s
685
683
  WHERE s.blocked = false
@@ -687,53 +685,6 @@ var PostgresStore = class {
687
685
  ${lane_clause}
688
686
  AND (s.leased_by IS NULL OR s.leased_until <= NOW())
689
687
  AND (s.deferred_at IS NULL OR s.deferred_at <= NOW())
690
- UNION ALL
691
- -- Legacy arms, gated on an absent mark. NULL means "unknown", so
692
- -- an install that predates the column probes the event log exactly
693
- -- as it did before, and each row migrates to the fast arm the first
694
- -- time correlate marks it. Deleted once correlate marks universally.
695
- --
696
- -- Every arm below is watermark-agnostic (#1446): a fresh
697
- -- subscription sits at -1, and e.id > s.at already answers
698
- -- correctly there, since the first event has a greater id.
699
- -- There used to be a fourth arm claiming at < 0 unconditionally,
700
- -- which handed out an empty lease and a no-op ack for every
701
- -- subscription with no matching events yet.
702
- -- Source-less subscription: any non-snapshot event past the
703
- -- watermark counts, so the id index alone answers it.
704
- SELECT stream, source, at, priority, lane FROM eligible s
705
- WHERE s.correlated_at IS NULL
706
- AND s.source IS NULL
707
- AND EXISTS (
708
- SELECT 1 FROM ${this._fqt} e
709
- WHERE e.id > s.at AND e.name <> '${SNAP_EVENT}' LIMIT 1
710
- )
711
- UNION ALL
712
- -- Literal source (no regex metacharacter): exact equality, so
713
- -- "s1" never claims "s12", and (stream, id) is usable.
714
- SELECT stream, source, at, priority, lane FROM eligible s
715
- WHERE s.correlated_at IS NULL
716
- AND s.source IS NOT NULL
717
- AND s.source !~ '${SOURCE_METACHARACTER_CLASS}'
718
- AND EXISTS (
719
- SELECT 1 FROM ${this._fqt} e
720
- WHERE e.stream = s.source AND e.id > s.at
721
- AND e.name <> '${SNAP_EVENT}' LIMIT 1
722
- )
723
- UNION ALL
724
- -- Pattern source (e.g. '^(A|B)$'): POSIX match, so the
725
- -- calculator's static regex reaction is claimed for every stream
726
- -- it anchors. Unavoidably a scan \u2014 but only for the subscriptions
727
- -- that actually declare a pattern.
728
- SELECT stream, source, at, priority, lane FROM eligible s
729
- WHERE s.correlated_at IS NULL
730
- AND s.source IS NOT NULL
731
- AND s.source ~ '${SOURCE_METACHARACTER_CLASS}'
732
- AND EXISTS (
733
- SELECT 1 FROM ${this._fqt} e
734
- WHERE e.id > s.at AND e.name <> '${SNAP_EVENT}'
735
- AND e.stream ~ s.source LIMIT 1
736
- )
737
688
  ),
738
689
  -- Priority lanes (ACT-102): higher priority first, then
739
690
  -- lagging-watermark order. With everyone at priority=0 the
@@ -852,36 +803,19 @@ var PostgresStore = class {
852
803
  await client.query(
853
804
  `
854
805
  UPDATE ${this._fqs} t
855
- SET priority = COALESCE((s->>'priority')::int, 0)
806
+ SET priority = GREATEST(t.priority, COALESCE((s->>'priority')::int, 0)),
807
+ lane = COALESCE(s->>'lane', 'default'),
808
+ correlated_at = GREATEST(t.correlated_at, (s->>'correlated_at')::int)
856
809
  FROM jsonb_array_elements($1::jsonb) AS s
857
810
  WHERE t.stream = s->>'stream'
858
- AND COALESCE((s->>'priority')::int, 0) > t.priority
811
+ AND (COALESCE((s->>'priority')::int, 0) > t.priority
812
+ OR t.lane <> COALESCE(s->>'lane', 'default')
813
+ OR (s->>'correlated_at' IS NOT NULL
814
+ AND (t.correlated_at IS NULL
815
+ OR t.correlated_at < (s->>'correlated_at')::int)))
859
816
  `,
860
817
  [JSON.stringify(streams)]
861
818
  );
862
- await client.query(
863
- `
864
- UPDATE ${this._fqs} t
865
- SET lane = COALESCE(s->>'lane', 'default')
866
- FROM jsonb_array_elements($1::jsonb) AS s
867
- WHERE t.stream = s->>'stream'
868
- AND t.lane <> COALESCE(s->>'lane', 'default')
869
- `,
870
- [JSON.stringify(streams)]
871
- );
872
- if (streams.some((s) => s.correlated_at !== void 0))
873
- await client.query(
874
- `
875
- UPDATE ${this._fqs} t
876
- SET correlated_at = (s->>'correlated_at')::int
877
- FROM jsonb_array_elements($1::jsonb) AS s
878
- WHERE t.stream = s->>'stream'
879
- AND s->>'correlated_at' IS NOT NULL
880
- AND (t.correlated_at IS NULL
881
- OR t.correlated_at < (s->>'correlated_at')::int)
882
- `,
883
- [JSON.stringify(streams)]
884
- );
885
819
  }
886
820
  if (correlated_at !== void 0)
887
821
  await client.query(
@@ -1187,7 +1121,7 @@ var PostgresStore = class {
1187
1121
  values.push(query.after);
1188
1122
  conditions.push(`stream > $${values.length}`);
1189
1123
  }
1190
- let sql = `SELECT stream, source, at, retry, blocked, error, leased_by, leased_until, priority, lane, deferred_at FROM ${this._fqs}`;
1124
+ let sql = `SELECT stream, source, at, retry, blocked, error, leased_by, leased_until, priority, lane, deferred_at, correlated_at FROM ${this._fqs}`;
1191
1125
  if (conditions.length) sql += " WHERE " + conditions.join(" AND ");
1192
1126
  values.push(limit);
1193
1127
  sql += ` ORDER BY stream LIMIT $${values.length}`;
@@ -1214,7 +1148,9 @@ var PostgresStore = class {
1214
1148
  lane: row.lane,
1215
1149
  // Persisted as timestamptz; surface as ms since epoch (#1221) so
1216
1150
  // the cold-start re-seed can re-arm the drain at the due-time.
1217
- deferred_at: row.deferred_at ? row.deferred_at.getTime() : void 0
1151
+ deferred_at: row.deferred_at ? row.deferred_at.getTime() : void 0,
1152
+ // NULL means "no mark yet" — unknown, not "no work" (#1485).
1153
+ correlated_at: row.correlated_at ?? void 0
1218
1154
  });
1219
1155
  count++;
1220
1156
  }