@rotorsoft/act 1.29.2 → 1.31.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
@@ -22,7 +22,7 @@ import {
22
22
  sleep,
23
23
  store,
24
24
  validate
25
- } from "./chunk-PX3IBVLW.js";
25
+ } from "./chunk-YUJ7ACSP.js";
26
26
  import {
27
27
  ActorSchema,
28
28
  CausationEventSchema,
@@ -1607,11 +1607,46 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
1607
1607
  }
1608
1608
 
1609
1609
  // src/internal/correlate-cycle.ts
1610
+ import { createHash, randomUUID } from "crypto";
1610
1611
  var DEFAULT_COLD_START_BACK_SCAN = 1e4;
1612
+ var DEFAULT_CORRELATION_LEASE_MS = 5e3;
1613
+ var registry_key = (events) => {
1614
+ const shape = Object.keys(events).filter((name) => events[name].reactions.size > 0).sort().map((name) => [name, [...events[name].reactions.keys()].sort()]);
1615
+ return createHash("sha256").update(JSON.stringify(shape)).digest("hex").slice(0, 32);
1616
+ };
1611
1617
  var PATTERN_CACHE_SIZE = 32;
1612
1618
  var CorrelateCycle = class {
1613
1619
  _checkpoint = -1;
1614
1620
  _initialized = false;
1621
+ /**
1622
+ * This worker's identity for the correlation lease (#1532). A per-instance
1623
+ * UUID, matching the drain's convention, so a renewal is recognised as the
1624
+ * same holder and a restarted process never inherits a stale claim.
1625
+ */
1626
+ _by = randomUUID();
1627
+ /** How long the correlation lease is taken for. */
1628
+ _lease_millis;
1629
+ /**
1630
+ * Which correlator this is. Computed once from the registry so identical
1631
+ * workers share a lease and unrelated applications never do.
1632
+ */
1633
+ _key;
1634
+ /**
1635
+ * When this worker's correlation lease runs out, as a local clock reading,
1636
+ * or 0 when it holds none.
1637
+ *
1638
+ * Asking the store on every pass costs a round trip that the holder — which
1639
+ * is the *only* worker in a single-node deployment, and the steady-state
1640
+ * one everywhere else — gains nothing from: it already knows the answer.
1641
+ * The act-sqlite perf gate caught that as a 1.5x regression on
1642
+ * correlate+drain, which is the shape an embedded app runs constantly.
1643
+ *
1644
+ * Believing this while the store disagrees is safe in the one direction it
1645
+ * can fail. A worker that scans without really holding the lease produces
1646
+ * the duplicate scan that existed before the lease, and the marks are
1647
+ * idempotent — so a stale belief costs work, never correctness.
1648
+ */
1649
+ _lease_until = 0;
1615
1650
  /**
1616
1651
  * Whether a scan might find anything. The drain has carried the same flag
1617
1652
  * since it was written — a commit raises it, an empty claim lowers it, and
@@ -1671,8 +1706,11 @@ var CorrelateCycle = class {
1671
1706
  run_scoped,
1672
1707
  on_init,
1673
1708
  on_init_async,
1674
- cold_start_back_scan = DEFAULT_COLD_START_BACK_SCAN
1709
+ cold_start_back_scan = DEFAULT_COLD_START_BACK_SCAN,
1710
+ lease_millis = DEFAULT_CORRELATION_LEASE_MS
1675
1711
  }) {
1712
+ this._lease_millis = lease_millis;
1713
+ this._key = registry_key(registry.events);
1676
1714
  this._subscribed = new LruMap(max_subscribed_streams);
1677
1715
  this._registry = registry;
1678
1716
  this._static_targets = static_targets;
@@ -1777,7 +1815,7 @@ var CorrelateCycle = class {
1777
1815
  * at init, but marking it is what makes it claimable without probing the
1778
1816
  * event log, so the scan runs for every app.
1779
1817
  */
1780
- async correlate(query = { after: -1, limit: 10 }) {
1818
+ async correlate(query = { after: -1, limit: 10 }, lease = false) {
1781
1819
  await this.init();
1782
1820
  if (!this._armed)
1783
1821
  return {
@@ -1786,6 +1824,22 @@ var CorrelateCycle = class {
1786
1824
  marked: 0,
1787
1825
  scanned: false
1788
1826
  };
1827
+ const now = Date.now();
1828
+ if (lease && now >= this._lease_until - this._lease_millis / 2) {
1829
+ const { correlating } = await this._cd.subscribe([], void 0, {
1830
+ key: this._key,
1831
+ by: this._by,
1832
+ millis: this._lease_millis
1833
+ });
1834
+ this._lease_until = correlating ? now + this._lease_millis : 0;
1835
+ if (correlating === false)
1836
+ return {
1837
+ subscribed: 0,
1838
+ last_id: this._checkpoint,
1839
+ marked: 0,
1840
+ scanned: false
1841
+ };
1842
+ }
1789
1843
  const after = Math.max(this._checkpoint, query.after || -1);
1790
1844
  const correlated = /* @__PURE__ */ new Map();
1791
1845
  let last_id = after;
@@ -1833,7 +1887,14 @@ var CorrelateCycle = class {
1833
1887
  });
1834
1888
  }
1835
1889
  if (streams.length) {
1836
- const { subscribed } = await this._cd.subscribe(streams, last_id);
1890
+ const renewed_at = Date.now();
1891
+ const { subscribed, correlating } = await this._cd.subscribe(
1892
+ streams,
1893
+ last_id,
1894
+ lease ? { key: this._key, by: this._by, millis: this._lease_millis } : void 0
1895
+ );
1896
+ if (lease && correlating !== false)
1897
+ this._lease_until = renewed_at + this._lease_millis;
1837
1898
  const marked = streams.filter(
1838
1899
  (entry) => entry.correlated_at !== void 0
1839
1900
  ).length;
@@ -1863,11 +1924,14 @@ var CorrelateCycle = class {
1863
1924
  this._timer = setInterval(
1864
1925
  () => this._run_scoped(() => {
1865
1926
  this.arm();
1866
- return this.correlate({
1867
- ...query,
1868
- after: this._checkpoint,
1869
- limit
1870
- });
1927
+ return this.correlate(
1928
+ {
1929
+ ...query,
1930
+ after: this._checkpoint,
1931
+ limit
1932
+ },
1933
+ true
1934
+ );
1871
1935
  }).then((result) => {
1872
1936
  if (callback && result.subscribed) callback(result.subscribed);
1873
1937
  }).catch((err) => log().error(err)),
@@ -1876,6 +1940,34 @@ var CorrelateCycle = class {
1876
1940
  return true;
1877
1941
  }
1878
1942
  /** Stop the periodic correlation worker. Idempotent. */
1943
+ /**
1944
+ * Hand the correlation lease back early.
1945
+ *
1946
+ * There is no release verb on the port, deliberately — expiry is the only
1947
+ * path, which keeps a crash and a clean stop on the same code path. A
1948
+ * holder can still shorten its own lease, because re-acquiring as the same
1949
+ * holder renews, and renewing to a millisecond is a release in all but
1950
+ * name.
1951
+ *
1952
+ * Without this a worker that stops cleanly still blocks discovery for the
1953
+ * rest of its lease — invisible in a long-lived deployment, very visible
1954
+ * anywhere Acts are created and dropped inside one process.
1955
+ *
1956
+ * Best-effort: failing here costs the lease's remaining lifetime, which is
1957
+ * what would have happened had the process died instead.
1958
+ */
1959
+ async release_correlation() {
1960
+ try {
1961
+ this._lease_until = 0;
1962
+ await this._cd.subscribe([], void 0, {
1963
+ key: this._key,
1964
+ by: this._by,
1965
+ millis: 0
1966
+ });
1967
+ } catch (error) {
1968
+ log().error(error);
1969
+ }
1970
+ }
1879
1971
  stop_polling() {
1880
1972
  if (this._timer) {
1881
1973
  clearInterval(this._timer);
@@ -1909,7 +2001,7 @@ function close_correlation(correlator, actor) {
1909
2001
  }
1910
2002
 
1911
2003
  // src/internal/drain-cycle.ts
1912
- import { randomUUID } from "crypto";
2004
+ import { randomUUID as randomUUID2 } from "crypto";
1913
2005
 
1914
2006
  // src/internal/defer-timer.ts
1915
2007
  var MAX_TIMER_DELAY_MS = 2147483647;
@@ -2031,7 +2123,7 @@ async function fetch(leased, eventLimit) {
2031
2123
  }
2032
2124
  var ack = (leases) => store().ack(leases);
2033
2125
  var block = (leases) => store().block(leases);
2034
- var subscribe = (streams, correlated_at) => store().subscribe(streams, correlated_at);
2126
+ var subscribe = (streams, correlated_at, correlator) => store().subscribe(streams, correlated_at, correlator);
2035
2127
 
2036
2128
  // src/internal/event-sourcing.ts
2037
2129
  import { patch } from "@rotorsoft/act-patch";
@@ -2639,7 +2731,7 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
2639
2731
  const leased = await ops.claim(
2640
2732
  lagging,
2641
2733
  leading,
2642
- randomUUID(),
2734
+ randomUUID2(),
2643
2735
  leaseMillis,
2644
2736
  lane
2645
2737
  );
@@ -3655,7 +3747,7 @@ var Act = class {
3655
3747
  // the scoped store and `_initialized` then blocks a retry (#1191).
3656
3748
  init: () => this._scoped(() => this._correlate.init()),
3657
3749
  checkpoint: () => this._correlate.checkpoint,
3658
- correlate: (q) => this._correlate_scanned(q),
3750
+ correlate: (q) => this._correlate_scanned(q, true),
3659
3751
  drain: (o) => this.drain(o),
3660
3752
  on_settled: (drain) => this.emit("settled", drain),
3661
3753
  breaker: this._breaker
@@ -3698,6 +3790,7 @@ var Act = class {
3698
3790
  resolveShutdownConfig(options);
3699
3791
  this._shutdown_promise = (async () => {
3700
3792
  this.stop_correlations();
3793
+ await this._scoped(() => this._correlate.release_correlation());
3701
3794
  this.stop_settling();
3702
3795
  this._breaker.stop();
3703
3796
  for (const c of this._drain_controllers.values()) c.stop();
@@ -4177,10 +4270,10 @@ var Act = class {
4177
4270
  * internal rather than widening the public `correlate` return, which is
4178
4271
  * charter-covered and has no use for it.
4179
4272
  */
4180
- async _correlate_scanned(query) {
4273
+ async _correlate_scanned(query, lease = false) {
4181
4274
  if (!this._drain) return { subscribed: 0, last_id: -1, scanned: false };
4182
4275
  return this._scoped(async () => {
4183
- const { subscribed, last_id, marked, scanned } = await this._correlate.correlate(query);
4276
+ const { subscribed, last_id, marked, scanned } = await this._correlate.correlate(query, lease);
4184
4277
  if ((subscribed > 0 || marked > 0) && this._reactive_events.size > 0)
4185
4278
  this._arm_all();
4186
4279
  return { subscribed, last_id, scanned };
@@ -4263,6 +4356,7 @@ var Act = class {
4263
4356
  */
4264
4357
  stop_correlations() {
4265
4358
  this._correlate.stop_polling();
4359
+ void this._scoped(() => this._correlate.release_correlation());
4266
4360
  }
4267
4361
  /**
4268
4362
  * Cancels any pending or active settle cycle.
@@ -4622,6 +4716,22 @@ var Act = class {
4622
4716
  }
4623
4717
  return this._correlate.checkpoint;
4624
4718
  }
4719
+ /**
4720
+ * Drop retired streams from correlate's in-process "already subscribed"
4721
+ * set, so a later scan re-issues `subscribe()` for them.
4722
+ *
4723
+ * A tombstone seed means the stream was retired; a snapshot seed means it
4724
+ * was restarted and is still consuming.
4725
+ *
4726
+ * `truncate` no longer removes the subscription row (#1527), so this is no
4727
+ * longer repairing damage the close itself did. It still matters, because
4728
+ * the row can disappear later: reclaiming retired subscriptions is an
4729
+ * operator job now, and that `DELETE` can land while this process is
4730
+ * running. Forgetting here keeps the in-process view from outliving a row
4731
+ * an operator removed, which is the same silent-no-delivery failure #1398
4732
+ * described — a reaction whose target is named after the stream would never
4733
+ * be re-registered.
4734
+ */
4625
4735
  _forget_closed_subscriptions(result) {
4626
4736
  const retired = [...result.truncated.entries()].filter(([, r]) => r.committed.name === TOMBSTONE_EVENT).map(([stream]) => stream);
4627
4737
  if (retired.length) this._correlate.forget_subscribed(retired);