@rotorsoft/act 1.22.6 → 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;
@@ -2924,6 +2929,16 @@ var CorrelateCycle = class {
2924
2929
  _has_dynamic_resolvers;
2925
2930
  _cd;
2926
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;
2927
2942
  /**
2928
2943
  * Scope runner (#1191). The periodic `start_polling` timer fires
2929
2944
  * outside any caller frame, so its `correlate()` must be re-wrapped in
@@ -2932,7 +2947,7 @@ var CorrelateCycle = class {
2932
2947
  * `_scoped` (identity for a non-scoped Act), so it's required.
2933
2948
  */
2934
2949
  _run_scoped;
2935
- 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) {
2936
2951
  this._subscribed = new LruSet(maxSubscribedStreams);
2937
2952
  this._registry = registry;
2938
2953
  this._static_targets = static_targets;
@@ -2940,6 +2955,7 @@ var CorrelateCycle = class {
2940
2955
  this._cd = cd;
2941
2956
  this._on_init = on_init;
2942
2957
  this._run_scoped = run_scoped;
2958
+ this._on_init_async = on_init_async;
2943
2959
  }
2944
2960
  /** Last correlated event id. */
2945
2961
  get checkpoint() {
@@ -2961,6 +2977,7 @@ var CorrelateCycle = class {
2961
2977
  for (const { stream } of this._static_targets) {
2962
2978
  this._subscribed.add(stream);
2963
2979
  }
2980
+ await this._on_init_async?.();
2964
2981
  }
2965
2982
  /**
2966
2983
  * Discover dynamic-resolver targets in the events past the checkpoint
@@ -3834,6 +3851,22 @@ var DrainController = class {
3834
3851
  arm() {
3835
3852
  this._armed = true;
3836
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
+ }
3837
3870
  /** Read-only flag — true while a commit / reset is unprocessed. */
3838
3871
  get armed() {
3839
3872
  return this._armed;
@@ -4382,6 +4415,7 @@ var Act = class {
4382
4415
  this._drain = options.drain !== false;
4383
4416
  this._breaker = this._build_breaker(options);
4384
4417
  this._drain_controllers = this._build_drain_controllers(options, lanes);
4418
+ this._advise_orphaned_lanes(options, lanes);
4385
4419
  this._audit_deps = this._build_audit_deps();
4386
4420
  this._correlate = this._build_correlate(options, classification);
4387
4421
  this._settle = this._build_settle(options);
@@ -4474,6 +4508,31 @@ var Act = class {
4474
4508
  }
4475
4509
  return controllers;
4476
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
+ }
4477
4536
  /**
4478
4537
  * Audit deps bag. Snapshotted after registry classification and
4479
4538
  * drain-controller build so the audit module sees the finalized lane
@@ -4487,7 +4546,14 @@ var Act = class {
4487
4546
  event_to_state: this._event_to_state,
4488
4547
  states: this._states,
4489
4548
  known_events: new Set(this._event_to_state.keys()),
4490
- 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)]),
4491
4557
  routed_events: new Set(this._event_to_lanes.keys())
4492
4558
  };
4493
4559
  }
@@ -4508,9 +4574,36 @@ var Act = class {
4508
4574
  },
4509
4575
  // Re-scope the background `start_correlations` timer so its
4510
4576
  // correlate resolves the scoped ports, not the singleton (#1191).
4511
- 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
4512
4581
  );
4513
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
+ }
4514
4607
  /** Settle loop driving correlate + drain to quiescence. */
4515
4608
  _build_settle(options) {
4516
4609
  return new SettleLoop(