@rotorsoft/act-pg 0.23.0 → 0.25.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/README.md +91 -119
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/postgres-store.d.ts +43 -2
- package/dist/@types/postgres-store.d.ts.map +1 -1
- package/dist/index.cjs +215 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +215 -18
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -175,13 +175,18 @@ var PostgresStore = class {
|
|
|
175
175
|
error text,
|
|
176
176
|
leased_by text,
|
|
177
177
|
leased_until timestamptz,
|
|
178
|
-
priority int NOT NULL DEFAULT 0
|
|
178
|
+
priority int NOT NULL DEFAULT 0,
|
|
179
|
+
lane text NOT NULL DEFAULT 'default'
|
|
179
180
|
) TABLESPACE pg_default;`
|
|
180
181
|
);
|
|
181
182
|
await client.query(
|
|
182
183
|
`ALTER TABLE ${this._fqs}
|
|
183
184
|
ADD COLUMN IF NOT EXISTS priority int NOT NULL DEFAULT 0;`
|
|
184
185
|
);
|
|
186
|
+
await client.query(
|
|
187
|
+
`ALTER TABLE ${this._fqs}
|
|
188
|
+
ADD COLUMN IF NOT EXISTS lane text NOT NULL DEFAULT 'default';`
|
|
189
|
+
);
|
|
185
190
|
await client.query(
|
|
186
191
|
`DROP INDEX IF EXISTS "${this.config.schema}"."${this.config.table}_streams_fetch_ix"`
|
|
187
192
|
);
|
|
@@ -189,6 +194,10 @@ var PostgresStore = class {
|
|
|
189
194
|
`CREATE INDEX IF NOT EXISTS "${this.config.table}_streams_claim_ix"
|
|
190
195
|
ON ${this._fqs} (blocked, priority DESC, at);`
|
|
191
196
|
);
|
|
197
|
+
await client.query(
|
|
198
|
+
`CREATE INDEX IF NOT EXISTS "${this.config.table}_streams_lane_ix"
|
|
199
|
+
ON ${this._fqs} (lane);`
|
|
200
|
+
);
|
|
192
201
|
await client.query("COMMIT");
|
|
193
202
|
logger.info(
|
|
194
203
|
`Seeded schema "${this.config.schema}" with table "${this.config.table}"`
|
|
@@ -386,17 +395,20 @@ var PostgresStore = class {
|
|
|
386
395
|
* @param millis - Lease duration in milliseconds
|
|
387
396
|
* @returns Leased streams with metadata
|
|
388
397
|
*/
|
|
389
|
-
async claim(lagging, leading, by, millis) {
|
|
398
|
+
async claim(lagging, leading, by, millis, lane) {
|
|
390
399
|
const client = await this._pool.connect();
|
|
391
400
|
try {
|
|
392
401
|
await client.query("BEGIN");
|
|
402
|
+
const laneClause = lane !== void 0 ? `AND s.lane = $5` : "";
|
|
403
|
+
const params = lane !== void 0 ? [lagging, leading, by, millis, lane] : [lagging, leading, by, millis];
|
|
393
404
|
const { rows } = await client.query(
|
|
394
405
|
`
|
|
395
406
|
WITH
|
|
396
407
|
available AS (
|
|
397
|
-
SELECT stream, source, at, priority
|
|
408
|
+
SELECT stream, source, at, priority, lane
|
|
398
409
|
FROM ${this._fqs} s
|
|
399
410
|
WHERE blocked = false
|
|
411
|
+
${laneClause}
|
|
400
412
|
AND (leased_by IS NULL OR leased_until <= NOW())
|
|
401
413
|
AND (s.at < 0 OR EXISTS (
|
|
402
414
|
SELECT 1 FROM ${this._fqt} e
|
|
@@ -412,19 +424,19 @@ var PostgresStore = class {
|
|
|
412
424
|
-- ORDER BY collapses to plain at ASC so existing workloads
|
|
413
425
|
-- see no behavior change.
|
|
414
426
|
lag AS (
|
|
415
|
-
SELECT stream, source, at, TRUE AS lagging
|
|
427
|
+
SELECT stream, source, at, lane, TRUE AS lagging
|
|
416
428
|
FROM available
|
|
417
429
|
ORDER BY priority DESC, at ASC
|
|
418
430
|
LIMIT $1
|
|
419
431
|
),
|
|
420
432
|
lead AS (
|
|
421
|
-
SELECT stream, source, at, FALSE AS lagging
|
|
433
|
+
SELECT stream, source, at, lane, FALSE AS lagging
|
|
422
434
|
FROM available
|
|
423
435
|
ORDER BY at DESC
|
|
424
436
|
LIMIT $2
|
|
425
437
|
),
|
|
426
438
|
combined AS (
|
|
427
|
-
SELECT DISTINCT ON (stream) stream, source, at, lagging
|
|
439
|
+
SELECT DISTINCT ON (stream) stream, source, at, lane, lagging
|
|
428
440
|
FROM (SELECT * FROM lag UNION ALL SELECT * FROM lead) t
|
|
429
441
|
ORDER BY stream, at
|
|
430
442
|
)
|
|
@@ -435,18 +447,19 @@ var PostgresStore = class {
|
|
|
435
447
|
retry = s.retry + 1
|
|
436
448
|
FROM combined c
|
|
437
449
|
WHERE s.stream = c.stream
|
|
438
|
-
RETURNING s.stream, s.source, s.at, s.retry, c.lagging
|
|
450
|
+
RETURNING s.stream, s.source, s.at, s.retry, c.lagging, s.lane
|
|
439
451
|
`,
|
|
440
|
-
|
|
452
|
+
params
|
|
441
453
|
);
|
|
442
454
|
await client.query("COMMIT");
|
|
443
|
-
return rows.map(({ stream, source, at, retry, lagging: lagging2 }) => ({
|
|
455
|
+
return rows.map(({ stream, source, at, retry, lagging: lagging2, lane: lane2 }) => ({
|
|
444
456
|
stream,
|
|
445
457
|
source: source ?? void 0,
|
|
446
458
|
at,
|
|
447
459
|
by,
|
|
448
460
|
retry,
|
|
449
|
-
lagging: lagging2
|
|
461
|
+
lagging: lagging2,
|
|
462
|
+
lane: lane2
|
|
450
463
|
}));
|
|
451
464
|
} catch (error) {
|
|
452
465
|
await client.query("ROLLBACK").catch(() => {
|
|
@@ -472,10 +485,11 @@ var PostgresStore = class {
|
|
|
472
485
|
if (streams.length) {
|
|
473
486
|
const { rowCount: inserted } = await client.query(
|
|
474
487
|
`
|
|
475
|
-
INSERT INTO ${this._fqs} (stream, source, priority)
|
|
488
|
+
INSERT INTO ${this._fqs} (stream, source, priority, lane)
|
|
476
489
|
SELECT s->>'stream',
|
|
477
490
|
s->>'source',
|
|
478
|
-
COALESCE((s->>'priority')::int, 0)
|
|
491
|
+
COALESCE((s->>'priority')::int, 0),
|
|
492
|
+
COALESCE(s->>'lane', 'default')
|
|
479
493
|
FROM jsonb_array_elements($1::jsonb) AS s
|
|
480
494
|
ON CONFLICT (stream) DO NOTHING
|
|
481
495
|
`,
|
|
@@ -492,6 +506,16 @@ var PostgresStore = class {
|
|
|
492
506
|
`,
|
|
493
507
|
[JSON.stringify(streams)]
|
|
494
508
|
);
|
|
509
|
+
await client.query(
|
|
510
|
+
`
|
|
511
|
+
UPDATE ${this._fqs} t
|
|
512
|
+
SET lane = COALESCE(s->>'lane', 'default')
|
|
513
|
+
FROM jsonb_array_elements($1::jsonb) AS s
|
|
514
|
+
WHERE t.stream = s->>'stream'
|
|
515
|
+
AND t.lane <> COALESCE(s->>'lane', 'default')
|
|
516
|
+
`,
|
|
517
|
+
[JSON.stringify(streams)]
|
|
518
|
+
);
|
|
495
519
|
}
|
|
496
520
|
const { rows } = await client.query(
|
|
497
521
|
`SELECT COALESCE(MAX(at), -1) AS max FROM ${this._fqs}`
|
|
@@ -531,7 +555,7 @@ var PostgresStore = class {
|
|
|
531
555
|
leased_until = NULL
|
|
532
556
|
FROM input i
|
|
533
557
|
WHERE s.stream = i.stream AND s.leased_by = i.by
|
|
534
|
-
RETURNING s.stream, s.source, s.at, i.by, s.retry, i.lagging
|
|
558
|
+
RETURNING s.stream, s.source, s.at, i.by, s.retry, i.lagging, s.lane
|
|
535
559
|
`,
|
|
536
560
|
[JSON.stringify(leases)]
|
|
537
561
|
);
|
|
@@ -542,7 +566,8 @@ var PostgresStore = class {
|
|
|
542
566
|
at: row.at,
|
|
543
567
|
by: row.by,
|
|
544
568
|
retry: row.retry,
|
|
545
|
-
lagging: row.lagging
|
|
569
|
+
lagging: row.lagging,
|
|
570
|
+
lane: row.lane
|
|
546
571
|
}));
|
|
547
572
|
} catch (error) {
|
|
548
573
|
await client.query("ROLLBACK").catch(() => {
|
|
@@ -572,7 +597,7 @@ var PostgresStore = class {
|
|
|
572
597
|
SET blocked = true, error = i.error
|
|
573
598
|
FROM input i
|
|
574
599
|
WHERE s.stream = i.stream AND s.leased_by = i.by AND s.blocked = false
|
|
575
|
-
RETURNING s.stream, s.source, s.at, i.by, s.retry, s.error, i.lagging
|
|
600
|
+
RETURNING s.stream, s.source, s.at, i.by, s.retry, s.error, i.lagging, s.lane
|
|
576
601
|
`,
|
|
577
602
|
[JSON.stringify(leases)]
|
|
578
603
|
);
|
|
@@ -584,7 +609,8 @@ var PostgresStore = class {
|
|
|
584
609
|
by: row.by,
|
|
585
610
|
retry: row.retry,
|
|
586
611
|
lagging: row.lagging,
|
|
587
|
-
error: row.error
|
|
612
|
+
error: row.error,
|
|
613
|
+
lane: row.lane
|
|
588
614
|
}));
|
|
589
615
|
} catch (error) {
|
|
590
616
|
await client.query("ROLLBACK").catch(() => {
|
|
@@ -627,6 +653,10 @@ var PostgresStore = class {
|
|
|
627
653
|
values.push(filter.blocked);
|
|
628
654
|
conditions.push(`blocked = $${start + values.length - 1}`);
|
|
629
655
|
}
|
|
656
|
+
if (filter.lane !== void 0) {
|
|
657
|
+
values.push(filter.lane);
|
|
658
|
+
conditions.push(`lane = $${start + values.length - 1}`);
|
|
659
|
+
}
|
|
630
660
|
return {
|
|
631
661
|
clause: conditions.length ? conditions.join(" AND ") : "TRUE",
|
|
632
662
|
values
|
|
@@ -739,11 +769,15 @@ var PostgresStore = class {
|
|
|
739
769
|
values.push(query.blocked);
|
|
740
770
|
conditions.push(`blocked = $${values.length}`);
|
|
741
771
|
}
|
|
772
|
+
if (query?.lane !== void 0) {
|
|
773
|
+
values.push(query.lane);
|
|
774
|
+
conditions.push(`lane = $${values.length}`);
|
|
775
|
+
}
|
|
742
776
|
if (query?.after !== void 0) {
|
|
743
777
|
values.push(query.after);
|
|
744
778
|
conditions.push(`stream > $${values.length}`);
|
|
745
779
|
}
|
|
746
|
-
let sql = `SELECT stream, source, at, retry, blocked, error, leased_by, leased_until, priority FROM ${this._fqs}`;
|
|
780
|
+
let sql = `SELECT stream, source, at, retry, blocked, error, leased_by, leased_until, priority, lane FROM ${this._fqs}`;
|
|
747
781
|
if (conditions.length) sql += " WHERE " + conditions.join(" AND ");
|
|
748
782
|
values.push(limit);
|
|
749
783
|
sql += ` ORDER BY stream LIMIT $${values.length}`;
|
|
@@ -766,7 +800,8 @@ var PostgresStore = class {
|
|
|
766
800
|
error: row.error ?? "",
|
|
767
801
|
priority: row.priority,
|
|
768
802
|
leased_by: row.leased_by ?? void 0,
|
|
769
|
-
leased_until: row.leased_until ?? void 0
|
|
803
|
+
leased_until: row.leased_until ?? void 0,
|
|
804
|
+
lane: row.lane
|
|
770
805
|
});
|
|
771
806
|
count++;
|
|
772
807
|
}
|
|
@@ -775,6 +810,168 @@ var PostgresStore = class {
|
|
|
775
810
|
client.release();
|
|
776
811
|
}
|
|
777
812
|
}
|
|
813
|
+
/**
|
|
814
|
+
* Per-stream aggregated stats — see {@link Store.query_stats}.
|
|
815
|
+
*
|
|
816
|
+
* Two code paths chosen by the requested stats:
|
|
817
|
+
*
|
|
818
|
+
* - **Heads-only path** (no `count`, no `names`): one or two
|
|
819
|
+
* `SELECT DISTINCT ON (stream) ... ORDER BY stream, version DESC|ASC`
|
|
820
|
+
* queries, executed in parallel when `tail: true`. The
|
|
821
|
+
* `(stream, version)` unique index gives index-only access — K rows
|
|
822
|
+
* touched per query (K = matched streams), not N (events).
|
|
823
|
+
* Ordering by `version` (not `id`) is equivalent within a stream
|
|
824
|
+
* (versions are monotonic per stream and events are committed
|
|
825
|
+
* sequentially) and is the column actually indexed.
|
|
826
|
+
*
|
|
827
|
+
* - **Full-scan path** (`count` or `names` set): one CTE materializes
|
|
828
|
+
* the filtered events, then `GROUP BY stream, name` →
|
|
829
|
+
* `jsonb_object_agg(name, n)` for the `names` map plus per-stream
|
|
830
|
+
* `COUNT(*)` for `count`. Heads (and `tails` when requested) come
|
|
831
|
+
* from `DISTINCT ON` over the same CTE — they ride free on the
|
|
832
|
+
* already-paid scan.
|
|
833
|
+
*
|
|
834
|
+
* The stream universe is derived from the events table: filter form
|
|
835
|
+
* matches event-bearing streams (not subscription rows). When the
|
|
836
|
+
* filter sets `source` or `blocked`, the events table is joined
|
|
837
|
+
* against the streams subscription table since those concepts only
|
|
838
|
+
* exist for subscribed streams.
|
|
839
|
+
*/
|
|
840
|
+
async query_stats(input, options) {
|
|
841
|
+
const exclude = options?.exclude ?? [];
|
|
842
|
+
const wantTail = options?.tail ?? false;
|
|
843
|
+
const wantCount = options?.count ?? false;
|
|
844
|
+
const wantNames = options?.names ?? false;
|
|
845
|
+
const before = options?.before;
|
|
846
|
+
const fullScan = wantCount || wantNames;
|
|
847
|
+
if (Array.isArray(input) && input.length === 0) {
|
|
848
|
+
return /* @__PURE__ */ new Map();
|
|
849
|
+
}
|
|
850
|
+
const where = [];
|
|
851
|
+
const params = [];
|
|
852
|
+
if (Array.isArray(input)) {
|
|
853
|
+
params.push(input);
|
|
854
|
+
where.push(`e.stream = ANY($${params.length})`);
|
|
855
|
+
} else if (input.stream !== void 0) {
|
|
856
|
+
params.push(input.stream);
|
|
857
|
+
where.push(
|
|
858
|
+
input.stream_exact ? `e.stream = $${params.length}` : `e.stream ~ $${params.length}`
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
if (exclude.length) {
|
|
862
|
+
params.push(exclude);
|
|
863
|
+
where.push(`e.name <> ALL($${params.length})`);
|
|
864
|
+
}
|
|
865
|
+
if (before !== void 0) {
|
|
866
|
+
params.push(before);
|
|
867
|
+
where.push(`e.id < $${params.length}`);
|
|
868
|
+
}
|
|
869
|
+
const fromClause = `${this._fqt} e`;
|
|
870
|
+
const whereClause = `WHERE ${where.length ? where.join(" AND ") : "TRUE"}`;
|
|
871
|
+
return fullScan ? this._queryStatsFullScan(
|
|
872
|
+
fromClause,
|
|
873
|
+
whereClause,
|
|
874
|
+
params,
|
|
875
|
+
wantTail,
|
|
876
|
+
wantCount,
|
|
877
|
+
wantNames
|
|
878
|
+
) : this._queryStatsHeadsOnly(fromClause, whereClause, params, wantTail);
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Cheap path: index-only DISTINCT ON for the head per stream, plus an
|
|
882
|
+
* optional second query (in parallel) for the tail. K rows touched
|
|
883
|
+
* per query, not N events.
|
|
884
|
+
*/
|
|
885
|
+
async _queryStatsHeadsOnly(fromClause, whereClause, params, wantTail) {
|
|
886
|
+
const cols = `e.id, e.stream, e.version, e.name, e.data, e.created, e.meta`;
|
|
887
|
+
const headSql = `SELECT DISTINCT ON (e.stream) ${cols} FROM ${fromClause} ${whereClause} ORDER BY e.stream, e.version DESC`;
|
|
888
|
+
const tailSql = wantTail ? `SELECT DISTINCT ON (e.stream) ${cols} FROM ${fromClause} ${whereClause} ORDER BY e.stream, e.version ASC` : null;
|
|
889
|
+
const [headRes, tailRes] = await Promise.all([
|
|
890
|
+
this._pool.query(headSql, params),
|
|
891
|
+
tailSql ? this._pool.query(tailSql, params) : Promise.resolve(null)
|
|
892
|
+
]);
|
|
893
|
+
const out = /* @__PURE__ */ new Map();
|
|
894
|
+
for (const row of headRes.rows) {
|
|
895
|
+
out.set(row.stream, { head: row });
|
|
896
|
+
}
|
|
897
|
+
if (tailRes) {
|
|
898
|
+
for (const row of tailRes.rows) {
|
|
899
|
+
out.get(row.stream).tail = row;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return out;
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Full-scan path: one CTE-based query computes the per-stream
|
|
906
|
+
* `COUNT(*)` and `jsonb_object_agg(name, n)` map alongside the head
|
|
907
|
+
* (and tail when requested). All extras share the single events scan.
|
|
908
|
+
*/
|
|
909
|
+
async _queryStatsFullScan(fromClause, whereClause, params, wantTail, wantCount, wantNames) {
|
|
910
|
+
const tailCte = wantTail ? `, tails AS (SELECT DISTINCT ON (stream) * FROM ef ORDER BY stream, version ASC)` : "";
|
|
911
|
+
const tailJoin = wantTail ? `LEFT JOIN tails t ON t.stream = h.stream` : "";
|
|
912
|
+
const tailCols = wantTail ? `, t.id AS t_id, t.stream AS t_stream, t.version AS t_version,
|
|
913
|
+
t.name AS t_name, t.data AS t_data, t.created AS t_created, t.meta AS t_meta` : "";
|
|
914
|
+
const sql = `
|
|
915
|
+
WITH ef AS (
|
|
916
|
+
SELECT e.id, e.stream, e.version, e.name, e.data, e.created, e.meta
|
|
917
|
+
FROM ${fromClause}
|
|
918
|
+
${whereClause}
|
|
919
|
+
),
|
|
920
|
+
agg AS (
|
|
921
|
+
SELECT stream,
|
|
922
|
+
SUM(n)::int AS cnt,
|
|
923
|
+
jsonb_object_agg(name, n) AS names
|
|
924
|
+
FROM (
|
|
925
|
+
SELECT stream, name, COUNT(*)::int AS n
|
|
926
|
+
FROM ef
|
|
927
|
+
GROUP BY stream, name
|
|
928
|
+
) t
|
|
929
|
+
GROUP BY stream
|
|
930
|
+
),
|
|
931
|
+
heads AS (
|
|
932
|
+
SELECT DISTINCT ON (stream) * FROM ef ORDER BY stream, version DESC
|
|
933
|
+
)
|
|
934
|
+
${tailCte}
|
|
935
|
+
SELECT
|
|
936
|
+
h.id, h.stream, h.version, h.name, h.data, h.created, h.meta,
|
|
937
|
+
a.cnt AS agg_count,
|
|
938
|
+
a.names AS agg_names
|
|
939
|
+
${tailCols}
|
|
940
|
+
FROM heads h
|
|
941
|
+
LEFT JOIN agg a ON a.stream = h.stream
|
|
942
|
+
${tailJoin}
|
|
943
|
+
`;
|
|
944
|
+
const res = await this._pool.query(sql, params);
|
|
945
|
+
const out = /* @__PURE__ */ new Map();
|
|
946
|
+
for (const row of res.rows) {
|
|
947
|
+
const stats = {
|
|
948
|
+
head: {
|
|
949
|
+
id: row.id,
|
|
950
|
+
stream: row.stream,
|
|
951
|
+
version: row.version,
|
|
952
|
+
name: row.name,
|
|
953
|
+
data: row.data,
|
|
954
|
+
created: row.created,
|
|
955
|
+
meta: row.meta
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
if (wantTail && row.t_id !== void 0 && row.t_id !== null) {
|
|
959
|
+
stats.tail = {
|
|
960
|
+
id: row.t_id,
|
|
961
|
+
stream: row.t_stream,
|
|
962
|
+
version: row.t_version,
|
|
963
|
+
name: row.t_name,
|
|
964
|
+
data: row.t_data,
|
|
965
|
+
created: row.t_created,
|
|
966
|
+
meta: row.t_meta
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
if (wantCount) stats.count = row.agg_count;
|
|
970
|
+
if (wantNames) stats.names = row.agg_names;
|
|
971
|
+
out.set(row.stream, stats);
|
|
972
|
+
}
|
|
973
|
+
return out;
|
|
974
|
+
}
|
|
778
975
|
/**
|
|
779
976
|
* Implementation of the optional `Store.notify` hook. Bound onto
|
|
780
977
|
* `this.notify` in the constructor when `config.notify === true`,
|