@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.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  sleep,
21
21
  store,
22
22
  validate
23
- } from "./chunk-CWBI4NQD.js";
23
+ } from "./chunk-QVHLKVS7.js";
24
24
  import {
25
25
  ActorSchema,
26
26
  CausationEventSchema,
@@ -1406,6 +1406,16 @@ var CorrelateCycle = class {
1406
1406
  _has_dynamic_resolvers;
1407
1407
  _cd;
1408
1408
  _on_init;
1409
+ /**
1410
+ * Async cold-start hook (#1221). Runs once, after the sync `on_init`,
1411
+ * inside the same `init()` await. The orchestrator uses it to re-seed the
1412
+ * process-local defer timers from the store's persisted `deferred_at` so
1413
+ * an idle deferred stream re-arms its drain across a restart. Kept
1414
+ * separate from `on_init` because seeding is an async store read; `init`
1415
+ * already awaits, so folding it in here preserves the "runs exactly once"
1416
+ * guarantee without a second gate on the Act side.
1417
+ */
1418
+ _on_init_async;
1409
1419
  /**
1410
1420
  * Scope runner (#1191). The periodic `start_polling` timer fires
1411
1421
  * outside any caller frame, so its `correlate()` must be re-wrapped in
@@ -1414,7 +1424,7 @@ var CorrelateCycle = class {
1414
1424
  * `_scoped` (identity for a non-scoped Act), so it's required.
1415
1425
  */
1416
1426
  _run_scoped;
1417
- constructor(registry, static_targets, has_dynamic_resolvers, cd, maxSubscribedStreams, on_init, run_scoped) {
1427
+ constructor(registry, static_targets, has_dynamic_resolvers, cd, maxSubscribedStreams, on_init, run_scoped, on_init_async) {
1418
1428
  this._subscribed = new LruSet(maxSubscribedStreams);
1419
1429
  this._registry = registry;
1420
1430
  this._static_targets = static_targets;
@@ -1422,6 +1432,7 @@ var CorrelateCycle = class {
1422
1432
  this._cd = cd;
1423
1433
  this._on_init = on_init;
1424
1434
  this._run_scoped = run_scoped;
1435
+ this._on_init_async = on_init_async;
1425
1436
  }
1426
1437
  /** Last correlated event id. */
1427
1438
  get checkpoint() {
@@ -1443,6 +1454,7 @@ var CorrelateCycle = class {
1443
1454
  for (const { stream } of this._static_targets) {
1444
1455
  this._subscribed.add(stream);
1445
1456
  }
1457
+ await this._on_init_async?.();
1446
1458
  }
1447
1459
  /**
1448
1460
  * Discover dynamic-resolver targets in the events past the checkpoint
@@ -2316,6 +2328,22 @@ var DrainController = class {
2316
2328
  arm() {
2317
2329
  this._armed = true;
2318
2330
  }
2331
+ /**
2332
+ * Re-seed a persisted defer schedule into the process-local timer at cold
2333
+ * start (#1221). The `_defer` map lives in worker memory and is empty
2334
+ * after a restart; a stream deferred to a future due-time (e.g. an idle
2335
+ * autoclose aggregate) is durable in the store's `deferred_at` but has
2336
+ * nothing in memory to re-arm the drain at the due-time. The orchestrator
2337
+ * reads the persisted `deferred_at` for this controller's lane and calls
2338
+ * this to park the stream + (re)schedule the shared wake — so the drain
2339
+ * re-arms at the due-time with no intervening commit. `schedule()`
2340
+ * collapses many seeds into one timer, so callers may seed in a loop and
2341
+ * let the earliest due-time win.
2342
+ */
2343
+ seed_defer(stream, at) {
2344
+ this._defer.set(stream, at);
2345
+ this._defer.schedule();
2346
+ }
2319
2347
  /** Read-only flag — true while a commit / reset is unprocessed. */
2320
2348
  get armed() {
2321
2349
  return this._armed;
@@ -2864,6 +2892,7 @@ var Act = class {
2864
2892
  this._drain = options.drain !== false;
2865
2893
  this._breaker = this._build_breaker(options);
2866
2894
  this._drain_controllers = this._build_drain_controllers(options, lanes);
2895
+ this._advise_orphaned_lanes(options, lanes);
2867
2896
  this._audit_deps = this._build_audit_deps();
2868
2897
  this._correlate = this._build_correlate(options, classification);
2869
2898
  this._settle = this._build_settle(options);
@@ -2956,6 +2985,31 @@ var Act = class {
2956
2985
  }
2957
2986
  return controllers;
2958
2987
  }
2988
+ /**
2989
+ * Orphaned-lane startup advisory (#1220). When `onlyLanes` is set, this
2990
+ * instance builds a controller only for its slice of the declared lane
2991
+ * universe — every OTHER declared lane's stream is persisted (correlate
2992
+ * subscribes all static targets regardless of `onlyLanes`) but never
2993
+ * claimed here. If no peer worker deploys with those lanes in ITS
2994
+ * `onlyLanes`, their reactions accumulate forever, silently. A single
2995
+ * process can't verify the cluster invariant `∪ onlyLanes ⊇ declared
2996
+ * lanes`, so we surface the per-instance signal — "these declared lanes
2997
+ * have no controller here" — the same way the deprecated-event advisory
2998
+ * surfaces legacy events. No advisory when `onlyLanes` is unset (every
2999
+ * lane gets a controller) or covers every declared lane.
3000
+ */
3001
+ _advise_orphaned_lanes(options, lanes) {
3002
+ if (!options.onlyLanes || options.onlyLanes.length === 0) return;
3003
+ const active = new Set(this._drain_controllers.keys());
3004
+ const orphaned = ["default", ...lanes.map((l) => l.name)].filter(
3005
+ (name) => !active.has(name)
3006
+ );
3007
+ if (orphaned.length === 0) return;
3008
+ const list = orphaned.map((name) => `"${name}"`).join(", ");
3009
+ this._logger.info(
3010
+ `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.`
3011
+ );
3012
+ }
2959
3013
  /**
2960
3014
  * Audit deps bag. Snapshotted after registry classification and
2961
3015
  * drain-controller build so the audit module sees the finalized lane
@@ -2969,7 +3023,14 @@ var Act = class {
2969
3023
  event_to_state: this._event_to_state,
2970
3024
  states: this._states,
2971
3025
  known_events: new Set(this._event_to_state.keys()),
2972
- declared_lanes: new Set(this._drain_controllers.keys()),
3026
+ // The DECLARED lane universe — the implicit "default" plus every
3027
+ // `.withLane(...)` name — NOT `_drain_controllers.keys()` (#1224).
3028
+ // An `onlyLanes`-filtered instance builds a controller only for its
3029
+ // slice of lanes, so keying off the active controller set would flag
3030
+ // a stream correctly assigned to an excluded-but-declared lane (one
3031
+ // another worker drains) as `unknown-lane`. The audit reports what's
3032
+ // structurally routable across the cluster, not what this process runs.
3033
+ declared_lanes: /* @__PURE__ */ new Set(["default", ...this._lanes.map((l) => l.name)]),
2973
3034
  routed_events: new Set(this._event_to_lanes.keys())
2974
3035
  };
2975
3036
  }
@@ -2990,9 +3051,36 @@ var Act = class {
2990
3051
  },
2991
3052
  // Re-scope the background `start_correlations` timer so its
2992
3053
  // correlate resolves the scoped ports, not the singleton (#1191).
2993
- this._scoped
3054
+ this._scoped,
3055
+ // Cold-start defer re-seed (#1221). Skipped on writer-only instances
3056
+ // (`drain: false`) — they run no local controllers to re-arm.
3057
+ this._drain ? () => this._seed_persisted_defers() : void 0
2994
3058
  );
2995
3059
  }
3060
+ /**
3061
+ * Re-seed every active lane controller's process-local defer timer from
3062
+ * the store's persisted `deferred_at` (#1221). Runs once at cold start,
3063
+ * inside `CorrelateCycle.init`, after static targets are subscribed.
3064
+ *
3065
+ * The defer timer is worker memory: empty after a restart. A stream
3066
+ * deferred to a future due-time (the classic case: an idle autoclose
3067
+ * aggregate that deferred its terminal close) is durable in the store but
3068
+ * has nothing in memory to re-arm the drain — the controller disarms on
3069
+ * the first empty claim and, since the aggregate is idle, no commit ever
3070
+ * re-arms it. Reading the persisted schedule and seeding the owning lane's
3071
+ * timer restores the wake, so the close fires at the due-time.
3072
+ *
3073
+ * Streams whose lane has no controller on this instance (excluded by
3074
+ * `onlyLanes`) are skipped — a peer worker owns that lane's timer.
3075
+ */
3076
+ async _seed_persisted_defers() {
3077
+ const now = Date.now();
3078
+ await store().query_streams((pos) => {
3079
+ if (pos.deferred_at === void 0 || pos.deferred_at <= now) return;
3080
+ const controller = this._drain_controllers.get(pos.lane ?? "default");
3081
+ controller?.seed_defer(pos.stream, pos.deferred_at);
3082
+ });
3083
+ }
2996
3084
  /** Settle loop driving correlate + drain to quiescence. */
2997
3085
  _build_settle(options) {
2998
3086
  return new SettleLoop(