@rotorsoft/act 1.27.0 → 1.28.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
@@ -22,7 +22,7 @@ import {
22
22
  sleep,
23
23
  store,
24
24
  validate
25
- } from "./chunk-66PTM4QB.js";
25
+ } from "./chunk-7WFKIQKY.js";
26
26
  import {
27
27
  ActorSchema,
28
28
  CausationEventSchema,
@@ -829,12 +829,10 @@ function classify_registry(registry, states) {
829
829
  const target_lanes = /* @__PURE__ */ new Map();
830
830
  const reactive_events = /* @__PURE__ */ new Set();
831
831
  const event_to_lanes = /* @__PURE__ */ new Map();
832
- let has_dynamic_resolvers = false;
833
832
  for (const [name, register] of Object.entries(registry.events)) {
834
833
  if (register.reactions.size > 0) reactive_events.add(name);
835
834
  for (const reaction of register.reactions.values()) {
836
835
  if (typeof reaction.resolver === "function") {
837
- has_dynamic_resolvers = true;
838
836
  event_to_lanes.set(name, ALL_LANES);
839
837
  } else {
840
838
  const { target, source, priority = 0, lane } = reaction.resolver;
@@ -870,7 +868,6 @@ function classify_registry(registry, states) {
870
868
  }
871
869
  return {
872
870
  static_targets: [...statics.values()],
873
- has_dynamic_resolvers,
874
871
  reactive_events,
875
872
  event_to_state,
876
873
  event_to_lanes
@@ -1354,7 +1351,8 @@ async function run_close_cycle(targets, deps) {
1354
1351
  stream_info,
1355
1352
  deps.reactive_events_size,
1356
1353
  skipped,
1357
- deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
1354
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE,
1355
+ deps.catch_up_correlation
1358
1356
  );
1359
1357
  if (!safe.length) return { truncated: windowed_result, skipped };
1360
1358
  const { guarded, guard_events } = await guard_with_tombstones(
@@ -1473,8 +1471,16 @@ async function scan_stream_heads(streams) {
1473
1471
  }
1474
1472
  return out;
1475
1473
  }
1476
- async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
1474
+ async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size, catch_up_correlation) {
1477
1475
  if (reactive_events_size === 0) return [...stream_info.keys()];
1476
+ let needed = -1;
1477
+ for (const info of stream_info.values())
1478
+ needed = Math.max(needed, info.max_id);
1479
+ const checkpoint = await catch_up_correlation(needed);
1480
+ const uncorrelated = /* @__PURE__ */ new Set();
1481
+ for (const [stream, info] of stream_info) {
1482
+ if (checkpoint < info.max_id) uncorrelated.add(stream);
1483
+ }
1478
1484
  const pending_set = /* @__PURE__ */ new Set();
1479
1485
  const source_regex = /* @__PURE__ */ new Map();
1480
1486
  const get_regex = (source) => {
@@ -1493,6 +1499,8 @@ async function partition_by_safety(stream_info, reactive_events_size, skipped, p
1493
1499
  (position) => {
1494
1500
  last = position.stream;
1495
1501
  const source_re = position.source ? get_regex(position.source) : void 0;
1502
+ const has_work = position.correlated_at === void 0 || position.at < position.correlated_at;
1503
+ if (!has_work) return;
1496
1504
  for (const [stream, info] of stream_info) {
1497
1505
  if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
1498
1506
  pending_set.add(stream);
@@ -1506,7 +1514,8 @@ async function partition_by_safety(stream_info, reactive_events_size, skipped, p
1506
1514
  }
1507
1515
  const safe = [];
1508
1516
  for (const [stream] of stream_info) {
1509
- if (pending_set.has(stream)) skipped.push(stream);
1517
+ if (pending_set.has(stream) || uncorrelated.has(stream))
1518
+ skipped.push(stream);
1510
1519
  else safe.push(stream);
1511
1520
  }
1512
1521
  return safe;
@@ -1596,21 +1605,24 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
1596
1605
 
1597
1606
  // src/internal/correlate-cycle.ts
1598
1607
  var DEFAULT_COLD_START_BACK_SCAN = 1e4;
1608
+ var PATTERN_CACHE_SIZE = 32;
1599
1609
  var CorrelateCycle = class {
1600
1610
  _checkpoint = -1;
1601
1611
  _initialized = false;
1602
1612
  /** In-flight init, memoized for single-flight and cleared on failure. */
1603
1613
  _init_promise;
1604
1614
  _timer = void 0;
1605
- // target → the priority it was last subscribed at. A dynamic target is
1606
- // re-subscribed (so the store's `GREATEST` upsert runs) only when a later
1607
- // scan resolves a *higher* priority for it — the runtime `max()` invariant
1608
- // (#1363). Static targets are recorded at +Infinity so a dynamic resolution
1609
- // never re-opens them (their priority is owned by the build-time subscribe).
1615
+ // target → what it was last subscribed at. Every scan re-subscribes the
1616
+ // targets it resolved (that is how the work mark lands), so this no longer
1617
+ // decides *whether* a target is sent — it decides *what* is sent with it:
1618
+ // a resolution raises priority/lane only when it beats the recorded floor,
1619
+ // and otherwise re-sends the row's own values so the mark changes nothing
1620
+ // else. See {@link Subscription}.
1610
1621
  _subscribed;
1622
+ /** Compiled pattern sources, bounded by {@link PATTERN_CACHE_SIZE}. */
1623
+ _patterns = new LruMap(PATTERN_CACHE_SIZE);
1611
1624
  _registry;
1612
1625
  _static_targets;
1613
- _has_dynamic_resolvers;
1614
1626
  _cd;
1615
1627
  _on_init;
1616
1628
  /**
@@ -1640,7 +1652,6 @@ var CorrelateCycle = class {
1640
1652
  constructor({
1641
1653
  registry,
1642
1654
  static_targets,
1643
- has_dynamic_resolvers,
1644
1655
  cd,
1645
1656
  max_subscribed_streams,
1646
1657
  run_scoped,
@@ -1651,7 +1662,6 @@ var CorrelateCycle = class {
1651
1662
  this._subscribed = new LruMap(max_subscribed_streams);
1652
1663
  this._registry = registry;
1653
1664
  this._static_targets = static_targets;
1654
- this._has_dynamic_resolvers = has_dynamic_resolvers;
1655
1665
  this._cd = cd;
1656
1666
  this._on_init = on_init;
1657
1667
  this._run_scoped = run_scoped;
@@ -1662,19 +1672,10 @@ var CorrelateCycle = class {
1662
1672
  get checkpoint() {
1663
1673
  return this._checkpoint;
1664
1674
  }
1665
- /**
1666
- * Whether any reaction uses a dynamic resolver — i.e. whether `correlate`
1667
- * performs a store scan rather than the static no-op early-return. The
1668
- * settle loop reads this to avoid recording a fictitious circuit-breaker
1669
- * success on a static app whose correlate never touches the store (#1329).
1670
- */
1671
- get has_dynamic_resolvers() {
1672
- return this._has_dynamic_resolvers;
1673
- }
1674
1675
  /**
1675
1676
  * Initialize correlation state on first call.
1676
- * - Reads max(at) from store, then floors the cold-start checkpoint at
1677
- * `watermark - back_scan` when dynamic resolvers exist, so an event
1677
+ * - Reads the durable correlate checkpoint (and max(at)) from the store,
1678
+ * flooring a first boot at `watermark - back_scan` so an event
1678
1679
  * committed-but-not-correlated before a crash is re-scanned on
1679
1680
  * restart instead of skipped (ACT-1207)
1680
1681
  * - Subscribes static resolver targets (idempotent upsert)
@@ -1696,10 +1697,14 @@ var CorrelateCycle = class {
1696
1697
  const { watermark, correlated_at } = await store().subscribe([
1697
1698
  ...this._static_targets
1698
1699
  ]);
1699
- this._checkpoint = !this._has_dynamic_resolvers ? watermark : correlated_at >= 0 ? correlated_at : Math.max(-1, watermark - this._cold_start_back_scan);
1700
+ this._checkpoint = correlated_at >= 0 ? correlated_at : Math.max(-1, watermark - this._cold_start_back_scan);
1700
1701
  this._on_init?.();
1701
- for (const { stream } of this._static_targets) {
1702
- this._subscribed.set(stream, Number.POSITIVE_INFINITY);
1702
+ for (const { stream, priority = 0, lane } of this._static_targets) {
1703
+ this._subscribed.set(stream, {
1704
+ floor: Number.POSITIVE_INFINITY,
1705
+ priority,
1706
+ lane
1707
+ });
1703
1708
  }
1704
1709
  await this._on_init_async?.();
1705
1710
  }
@@ -1724,14 +1729,34 @@ var CorrelateCycle = class {
1724
1729
  }
1725
1730
  }
1726
1731
  /**
1727
- * Discover dynamic-resolver targets in the events past the checkpoint
1728
- * and register any new streams via `cd.subscribe`. Static targets are
1729
- * subscribed at init time, so this only walks dynamic resolvers.
1732
+ * Would an event from `stream` be fetched for a target subscribed with
1733
+ * `source`? The subscription's source is the filter `fetch` queries with
1734
+ * — literal names by equality, patterns compiled as a `RegExp` — so an
1735
+ * event outside it is not work for that target and must not mark it.
1736
+ * No source means the target consumes every stream.
1737
+ */
1738
+ _in_fetch_window(source, stream) {
1739
+ if (source === void 0 || source === stream) return true;
1740
+ if (is_literal_source(source)) return false;
1741
+ let pattern = this._patterns.get(source);
1742
+ if (!pattern) {
1743
+ pattern = new RegExp(source);
1744
+ this._patterns.set(source, pattern);
1745
+ }
1746
+ return pattern.test(stream);
1747
+ }
1748
+ /**
1749
+ * Scan the events past the checkpoint, resolve each to its target
1750
+ * streams, and record what it found through `cd.subscribe` — new dynamic
1751
+ * targets get registered, and every target an event resolved to gets its
1752
+ * **work mark** raised to that event's id (#1487).
1753
+ *
1754
+ * Both resolver kinds are walked. A static target is already subscribed
1755
+ * at init, but marking it is what makes it claimable without probing the
1756
+ * event log, so the scan runs for every app.
1730
1757
  */
1731
1758
  async correlate(query = { after: -1, limit: 10 }) {
1732
1759
  await this.init();
1733
- if (!this._has_dynamic_resolvers)
1734
- return { subscribed: 0, last_id: this._checkpoint };
1735
1760
  const after = Math.max(this._checkpoint, query.after || -1);
1736
1761
  const correlated = /* @__PURE__ */ new Map();
1737
1762
  let last_id = after;
@@ -1741,47 +1766,53 @@ var CorrelateCycle = class {
1741
1766
  const register = this._registry.events[event.name];
1742
1767
  if (register) {
1743
1768
  for (const reaction of register.reactions.values()) {
1744
- if (typeof reaction.resolver !== "function") continue;
1745
- const resolved = reaction.resolver(event);
1746
- if (resolved && (resolved.priority ?? 0) > (this._subscribed.get(resolved.target) ?? Number.NEGATIVE_INFINITY)) {
1747
- const incoming_priority = resolved.priority ?? 0;
1748
- const entry = correlated.get(resolved.target) || {
1749
- source: resolved.source,
1750
- priority: incoming_priority,
1751
- lane: resolved.lane,
1752
- payloads: []
1753
- };
1754
- if (incoming_priority > entry.priority) {
1755
- entry.priority = incoming_priority;
1756
- entry.lane = resolved.lane;
1757
- }
1758
- entry.payloads.push({
1759
- ...reaction,
1760
- source: resolved.source,
1761
- event
1762
- });
1763
- correlated.set(resolved.target, entry);
1769
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
1770
+ if (!resolved) continue;
1771
+ const recorded = this._subscribed.get(resolved.target);
1772
+ const priority = resolved.priority ?? 0;
1773
+ const upgraded = !recorded || priority > recorded.floor;
1774
+ const carried = upgraded ? { priority, lane: resolved.lane } : { priority: recorded.priority, lane: recorded.lane };
1775
+ const entry = correlated.get(resolved.target) || {
1776
+ source: resolved.source,
1777
+ priority: carried.priority,
1778
+ lane: carried.lane,
1779
+ upgraded,
1780
+ correlated_at: void 0
1781
+ };
1782
+ if (carried.priority > entry.priority) {
1783
+ entry.priority = carried.priority;
1784
+ entry.lane = carried.lane;
1785
+ entry.upgraded = upgraded;
1764
1786
  }
1787
+ if (this._in_fetch_window(resolved.source, event.stream))
1788
+ entry.correlated_at = event.id;
1789
+ correlated.set(resolved.target, entry);
1765
1790
  }
1766
1791
  }
1767
1792
  },
1768
1793
  { ...query, after }
1769
1794
  );
1770
- if (correlated.size) {
1771
- const streams = [...correlated.entries()].map(
1772
- ([stream, { source, priority, lane }]) => ({
1795
+ const streams = [];
1796
+ for (const [stream, entry] of correlated) {
1797
+ if (entry.upgraded || entry.correlated_at !== void 0)
1798
+ streams.push({
1773
1799
  stream,
1774
- source,
1775
- priority,
1776
- lane
1777
- })
1778
- );
1800
+ source: entry.source,
1801
+ priority: entry.priority,
1802
+ lane: entry.lane,
1803
+ correlated_at: entry.correlated_at
1804
+ });
1805
+ }
1806
+ if (streams.length) {
1779
1807
  const { subscribed } = await this._cd.subscribe(streams, last_id);
1780
1808
  this._checkpoint = last_id;
1781
- if (subscribed) {
1782
- for (const { stream, priority } of streams) {
1783
- this._subscribed.set(stream, priority);
1784
- }
1809
+ for (const { stream, priority, lane } of streams) {
1810
+ if (correlated.get(stream)?.upgraded)
1811
+ this._subscribed.set(stream, {
1812
+ floor: priority,
1813
+ priority,
1814
+ lane
1815
+ });
1785
1816
  }
1786
1817
  return { subscribed, last_id };
1787
1818
  }
@@ -3089,7 +3120,7 @@ var SettleLoop = class {
3089
3120
  ...correlate_query,
3090
3121
  after: after_before
3091
3122
  });
3092
- if (this._deps.correlate_probes_store) this._deps.breaker.passed();
3123
+ this._deps.breaker.passed();
3093
3124
  const drain = await this._deps.drain(drain_options);
3094
3125
  settled_drain = settled_drain ? {
3095
3126
  fetched: [...settled_drain.fetched, ...drain.fetched],
@@ -3135,6 +3166,8 @@ var SettleLoop = class {
3135
3166
 
3136
3167
  // src/act.ts
3137
3168
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
3169
+ var CLOSE_CATCH_UP_LIMIT = 1e3;
3170
+ var CLOSE_CATCH_UP_PASSES = 20;
3138
3171
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
3139
3172
  function validate_only_lanes(options, lanes) {
3140
3173
  if (!options.onlyLanes || options.onlyLanes.length === 0) return;
@@ -3450,6 +3483,7 @@ var Act = class {
3450
3483
  const close_actor = { id: "$close", name: "close" };
3451
3484
  const result = await run_close_cycle(targets, {
3452
3485
  reactive_events_size: this._reactive_events.size,
3486
+ catch_up_correlation: (until) => this._catch_up_correlation(until),
3453
3487
  event_to_state: this._event_to_state,
3454
3488
  load: this._es.load,
3455
3489
  tombstone: this._es.tombstone,
@@ -3536,7 +3570,6 @@ var Act = class {
3536
3570
  return new CorrelateCycle({
3537
3571
  registry: this.registry,
3538
3572
  static_targets: classification.static_targets,
3539
- has_dynamic_resolvers: classification.has_dynamic_resolvers,
3540
3573
  cd: this._cd,
3541
3574
  max_subscribed_streams: options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
3542
3575
  on_init: () => {
@@ -3587,11 +3620,7 @@ var Act = class {
3587
3620
  correlate: (q) => this.correlate(q),
3588
3621
  drain: (o) => this.drain(o),
3589
3622
  on_settled: (drain) => this.emit("settled", drain),
3590
- breaker: this._breaker,
3591
- // Static-reaction apps' correlate is a no-op (no store call), so a
3592
- // settle pass carries no store-health signal — don't let it record a
3593
- // breaker success (#1329).
3594
- correlate_probes_store: this._correlate.has_dynamic_resolvers
3623
+ breaker: this._breaker
3595
3624
  },
3596
3625
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
3597
3626
  );
@@ -3964,8 +3993,13 @@ var Act = class {
3964
3993
  * Drain uses a dual-frontier strategy to balance processing of new streams (lagging)
3965
3994
  * vs active streams (leading). The ratio adapts based on event pressure.
3966
3995
  *
3967
- * Call `correlate()` before `drain()` to discover target streams. For a higher-level
3968
- * API that handles debouncing, correlation, and signaling automatically, use {@link settle}.
3996
+ * Call `correlate()` before `drain()`. It is not only how dynamic targets
3997
+ * are discovered: a stream is claimable while `at < correlated_at`, and
3998
+ * `correlate` is the only component that raises that mark (#1487), so a
3999
+ * commit no correlate has seen is not drainable — including for static
4000
+ * targets, which were served by a probe of the event log before. For a
4001
+ * higher-level API that handles debouncing, correlation, and signaling
4002
+ * automatically, use {@link settle}.
3969
4003
  *
3970
4004
  * @param options - Drain configuration — see {@link DrainOptions} for fields
3971
4005
  * (`streamLimit`, `eventLimit`, `leaseMillis`).
@@ -4515,6 +4549,26 @@ var Act = class {
4515
4549
  * removed, so a later correlate can re-subscribe it (#1398). Restart
4516
4550
  * targets keep their row, so only fully-retired streams are forgotten.
4517
4551
  */
4552
+ /**
4553
+ * Advance correlation until the read cursor reaches `until`, and report
4554
+ * where it landed (#1487). Used by the close cycle's safety probe, which
4555
+ * cannot judge a subscription's pending work over events correlate has
4556
+ * not resolved yet.
4557
+ *
4558
+ * Bounded on both ends: it stops as soon as a pass makes no progress (the
4559
+ * log has no more to give), and after {@link CLOSE_CATCH_UP_PASSES}
4560
+ * windows, so a close behind an enormous backlog degrades to skipping the
4561
+ * stream — the documented retryable outcome — instead of scanning the
4562
+ * whole log inside an operator call.
4563
+ */
4564
+ async _catch_up_correlation(until) {
4565
+ for (let pass = 0; pass < CLOSE_CATCH_UP_PASSES && this._correlate.checkpoint < until; pass++) {
4566
+ const before = this._correlate.checkpoint;
4567
+ await this.correlate({ limit: CLOSE_CATCH_UP_LIMIT });
4568
+ if (this._correlate.checkpoint <= before) break;
4569
+ }
4570
+ return this._correlate.checkpoint;
4571
+ }
4518
4572
  _forget_closed_subscriptions(result) {
4519
4573
  const retired = [...result.truncated.entries()].filter(([, r]) => r.committed.name === TOMBSTONE_EVENT).map(([stream]) => stream);
4520
4574
  if (retired.length) this._correlate.forget_subscribed(retired);
@@ -4526,6 +4580,7 @@ var Act = class {
4526
4580
  const close_actor = { id: "$close", name: "close" };
4527
4581
  const result = await run_close_cycle(targets, {
4528
4582
  reactive_events_size: this._reactive_events.size,
4583
+ catch_up_correlation: (until) => this._catch_up_correlation(until),
4529
4584
  event_to_state: this._event_to_state,
4530
4585
  load: this._es.load,
4531
4586
  tombstone: this._es.tombstone,