@rotorsoft/act 1.22.5 → 1.22.7

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.cjs CHANGED
@@ -703,6 +703,10 @@ var InMemoryStream = class {
703
703
  get leased_until() {
704
704
  return this._leased_until;
705
705
  }
706
+ /** Persisted next-visit time (#1090/#1221), or undefined when no active defer. */
707
+ get deferred_at() {
708
+ return this._deferred_at;
709
+ }
706
710
  /**
707
711
  * Attempt to lease this stream for processing.
708
712
  * @param lease - The lease request.
@@ -1297,7 +1301,8 @@ var InMemoryStore = class {
1297
1301
  priority: s.priority,
1298
1302
  leased_by: s.leased_by,
1299
1303
  leased_until: s.leased_until,
1300
- lane: s.lane
1304
+ lane: s.lane,
1305
+ deferred_at: s.deferred_at
1301
1306
  });
1302
1307
  count++;
1303
1308
  if (count >= limit) break;
@@ -1607,6 +1612,15 @@ function deprecated_event_names(names) {
1607
1612
  const deprecated = /* @__PURE__ */ new Set();
1608
1613
  for (const list of groups.values()) {
1609
1614
  if (list.length < 2) continue;
1615
+ const by_version = /* @__PURE__ */ new Map();
1616
+ for (const { version, name } of list) {
1617
+ const clash = by_version.get(version);
1618
+ if (clash)
1619
+ throw new Error(
1620
+ `duplicate event version: ${clash} and ${name} both map to version ${version} \u2014 leading zeros in a \`_v<n>\` suffix are not allowed; use a single canonical spelling`
1621
+ );
1622
+ by_version.set(version, name);
1623
+ }
1610
1624
  list.sort((a, b) => b.version - a.version);
1611
1625
  for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
1612
1626
  }
@@ -2052,18 +2066,33 @@ function hour_in_zone(now, timeZone) {
2052
2066
  }).formatToParts(now);
2053
2067
  return Number(parts.find((p) => p.type === "hour")?.value);
2054
2068
  }
2069
+ function hour_in_range(hour, start, end) {
2070
+ return start < end ? hour >= start && hour < end : hour >= start || hour < end;
2071
+ }
2072
+ function is_dst_gap_open(now, timeZone, start) {
2073
+ const hour = hour_in_zone(now, timeZone);
2074
+ if (hour <= start) return false;
2075
+ const prev = hour_in_zone(new Date(now.getTime() - 36e5), timeZone);
2076
+ return prev === start - 1 && hour === start + 1;
2077
+ }
2055
2078
  function in_autoclose_window(window, now) {
2056
2079
  if (!window) return true;
2057
2080
  const hour = hour_in_zone(now, window.timeZone);
2058
- return window.start < window.end ? hour >= window.start && hour < window.end : hour >= window.start || hour < window.end;
2081
+ return hour_in_range(hour, window.start, window.end) || is_dst_gap_open(now, window.timeZone, window.start);
2059
2082
  }
2060
2083
  function next_window_open(window, now) {
2084
+ const start_ms = now.getTime();
2061
2085
  for (let h = 0; h <= 48; h++) {
2062
- const candidate = new Date(now.getTime() + h * 36e5);
2086
+ const candidate = new Date(start_ms + h * 36e5);
2063
2087
  if (hour_in_zone(candidate, window.timeZone) === window.start)
2064
2088
  return candidate;
2089
+ const boundary = new Date(
2090
+ candidate.getTime() - candidate.getTime() % 36e5
2091
+ );
2092
+ if (is_dst_gap_open(boundary, window.timeZone, window.start))
2093
+ return boundary;
2065
2094
  }
2066
- return new Date(now.getTime() + 864e5);
2095
+ return new Date(start_ms + 864e5);
2067
2096
  }
2068
2097
 
2069
2098
  // src/internal/autoclose-policy.ts
@@ -2900,6 +2929,16 @@ var CorrelateCycle = class {
2900
2929
  _has_dynamic_resolvers;
2901
2930
  _cd;
2902
2931
  _on_init;
2932
+ /**
2933
+ * Async cold-start hook (#1221). Runs once, after the sync `on_init`,
2934
+ * inside the same `init()` await. The orchestrator uses it to re-seed the
2935
+ * process-local defer timers from the store's persisted `deferred_at` so
2936
+ * an idle deferred stream re-arms its drain across a restart. Kept
2937
+ * separate from `on_init` because seeding is an async store read; `init`
2938
+ * already awaits, so folding it in here preserves the "runs exactly once"
2939
+ * guarantee without a second gate on the Act side.
2940
+ */
2941
+ _on_init_async;
2903
2942
  /**
2904
2943
  * Scope runner (#1191). The periodic `start_polling` timer fires
2905
2944
  * outside any caller frame, so its `correlate()` must be re-wrapped in
@@ -2908,7 +2947,7 @@ var CorrelateCycle = class {
2908
2947
  * `_scoped` (identity for a non-scoped Act), so it's required.
2909
2948
  */
2910
2949
  _run_scoped;
2911
- constructor(registry, static_targets, has_dynamic_resolvers, cd, maxSubscribedStreams, on_init, run_scoped) {
2950
+ constructor(registry, static_targets, has_dynamic_resolvers, cd, maxSubscribedStreams, on_init, run_scoped, on_init_async) {
2912
2951
  this._subscribed = new LruSet(maxSubscribedStreams);
2913
2952
  this._registry = registry;
2914
2953
  this._static_targets = static_targets;
@@ -2916,6 +2955,7 @@ var CorrelateCycle = class {
2916
2955
  this._cd = cd;
2917
2956
  this._on_init = on_init;
2918
2957
  this._run_scoped = run_scoped;
2958
+ this._on_init_async = on_init_async;
2919
2959
  }
2920
2960
  /** Last correlated event id. */
2921
2961
  get checkpoint() {
@@ -2937,6 +2977,7 @@ var CorrelateCycle = class {
2937
2977
  for (const { stream } of this._static_targets) {
2938
2978
  this._subscribed.add(stream);
2939
2979
  }
2980
+ await this._on_init_async?.();
2940
2981
  }
2941
2982
  /**
2942
2983
  * Discover dynamic-resolver targets in the events past the checkpoint
@@ -3810,6 +3851,22 @@ var DrainController = class {
3810
3851
  arm() {
3811
3852
  this._armed = true;
3812
3853
  }
3854
+ /**
3855
+ * Re-seed a persisted defer schedule into the process-local timer at cold
3856
+ * start (#1221). The `_defer` map lives in worker memory and is empty
3857
+ * after a restart; a stream deferred to a future due-time (e.g. an idle
3858
+ * autoclose aggregate) is durable in the store's `deferred_at` but has
3859
+ * nothing in memory to re-arm the drain at the due-time. The orchestrator
3860
+ * reads the persisted `deferred_at` for this controller's lane and calls
3861
+ * this to park the stream + (re)schedule the shared wake — so the drain
3862
+ * re-arms at the due-time with no intervening commit. `schedule()`
3863
+ * collapses many seeds into one timer, so callers may seed in a loop and
3864
+ * let the earliest due-time win.
3865
+ */
3866
+ seed_defer(stream, at) {
3867
+ this._defer.set(stream, at);
3868
+ this._defer.schedule();
3869
+ }
3813
3870
  /** Read-only flag — true while a commit / reset is unprocessed. */
3814
3871
  get armed() {
3815
3872
  return this._armed;
@@ -4358,6 +4415,7 @@ var Act = class {
4358
4415
  this._drain = options.drain !== false;
4359
4416
  this._breaker = this._build_breaker(options);
4360
4417
  this._drain_controllers = this._build_drain_controllers(options, lanes);
4418
+ this._advise_orphaned_lanes(options, lanes);
4361
4419
  this._audit_deps = this._build_audit_deps();
4362
4420
  this._correlate = this._build_correlate(options, classification);
4363
4421
  this._settle = this._build_settle(options);
@@ -4450,6 +4508,31 @@ var Act = class {
4450
4508
  }
4451
4509
  return controllers;
4452
4510
  }
4511
+ /**
4512
+ * Orphaned-lane startup advisory (#1220). When `onlyLanes` is set, this
4513
+ * instance builds a controller only for its slice of the declared lane
4514
+ * universe — every OTHER declared lane's stream is persisted (correlate
4515
+ * subscribes all static targets regardless of `onlyLanes`) but never
4516
+ * claimed here. If no peer worker deploys with those lanes in ITS
4517
+ * `onlyLanes`, their reactions accumulate forever, silently. A single
4518
+ * process can't verify the cluster invariant `∪ onlyLanes ⊇ declared
4519
+ * lanes`, so we surface the per-instance signal — "these declared lanes
4520
+ * have no controller here" — the same way the deprecated-event advisory
4521
+ * surfaces legacy events. No advisory when `onlyLanes` is unset (every
4522
+ * lane gets a controller) or covers every declared lane.
4523
+ */
4524
+ _advise_orphaned_lanes(options, lanes) {
4525
+ if (!options.onlyLanes || options.onlyLanes.length === 0) return;
4526
+ const active = new Set(this._drain_controllers.keys());
4527
+ const orphaned = ["default", ...lanes.map((l) => l.name)].filter(
4528
+ (name) => !active.has(name)
4529
+ );
4530
+ if (orphaned.length === 0) return;
4531
+ const list = orphaned.map((name) => `"${name}"`).join(", ");
4532
+ this._logger.info(
4533
+ `Act declared ${orphaned.length} orphaned lane(s) on this instance: ${list}. onlyLanes excludes them, so no DrainController claims their streams here \u2014 their reactions accumulate un-drained unless a peer worker deploys with these lanes in its onlyLanes. Ensure the cluster invariant holds: the union of every worker's onlyLanes must cover every declared lane. See docs/docs/guides/production-checklist.md \xA7 Sizing lanes.`
4534
+ );
4535
+ }
4453
4536
  /**
4454
4537
  * Audit deps bag. Snapshotted after registry classification and
4455
4538
  * drain-controller build so the audit module sees the finalized lane
@@ -4463,7 +4546,14 @@ var Act = class {
4463
4546
  event_to_state: this._event_to_state,
4464
4547
  states: this._states,
4465
4548
  known_events: new Set(this._event_to_state.keys()),
4466
- declared_lanes: new Set(this._drain_controllers.keys()),
4549
+ // The DECLARED lane universe — the implicit "default" plus every
4550
+ // `.withLane(...)` name — NOT `_drain_controllers.keys()` (#1224).
4551
+ // An `onlyLanes`-filtered instance builds a controller only for its
4552
+ // slice of lanes, so keying off the active controller set would flag
4553
+ // a stream correctly assigned to an excluded-but-declared lane (one
4554
+ // another worker drains) as `unknown-lane`. The audit reports what's
4555
+ // structurally routable across the cluster, not what this process runs.
4556
+ declared_lanes: /* @__PURE__ */ new Set(["default", ...this._lanes.map((l) => l.name)]),
4467
4557
  routed_events: new Set(this._event_to_lanes.keys())
4468
4558
  };
4469
4559
  }
@@ -4484,9 +4574,36 @@ var Act = class {
4484
4574
  },
4485
4575
  // Re-scope the background `start_correlations` timer so its
4486
4576
  // correlate resolves the scoped ports, not the singleton (#1191).
4487
- this._scoped
4577
+ this._scoped,
4578
+ // Cold-start defer re-seed (#1221). Skipped on writer-only instances
4579
+ // (`drain: false`) — they run no local controllers to re-arm.
4580
+ this._drain ? () => this._seed_persisted_defers() : void 0
4488
4581
  );
4489
4582
  }
4583
+ /**
4584
+ * Re-seed every active lane controller's process-local defer timer from
4585
+ * the store's persisted `deferred_at` (#1221). Runs once at cold start,
4586
+ * inside `CorrelateCycle.init`, after static targets are subscribed.
4587
+ *
4588
+ * The defer timer is worker memory: empty after a restart. A stream
4589
+ * deferred to a future due-time (the classic case: an idle autoclose
4590
+ * aggregate that deferred its terminal close) is durable in the store but
4591
+ * has nothing in memory to re-arm the drain — the controller disarms on
4592
+ * the first empty claim and, since the aggregate is idle, no commit ever
4593
+ * re-arms it. Reading the persisted schedule and seeding the owning lane's
4594
+ * timer restores the wake, so the close fires at the due-time.
4595
+ *
4596
+ * Streams whose lane has no controller on this instance (excluded by
4597
+ * `onlyLanes`) are skipped — a peer worker owns that lane's timer.
4598
+ */
4599
+ async _seed_persisted_defers() {
4600
+ const now = Date.now();
4601
+ await store2().query_streams((pos) => {
4602
+ if (pos.deferred_at === void 0 || pos.deferred_at <= now) return;
4603
+ const controller = this._drain_controllers.get(pos.lane ?? "default");
4604
+ controller?.seed_defer(pos.stream, pos.deferred_at);
4605
+ });
4606
+ }
4490
4607
  /** Settle loop driving correlate + drain to quiescence. */
4491
4608
  _build_settle(options) {
4492
4609
  return new SettleLoop(