@rotorsoft/act 1.24.1 → 1.25.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
@@ -931,6 +931,18 @@ function resolveSettleConfig(options) {
931
931
  SettleOptionsSchema.parse(options);
932
932
  return options;
933
933
  }
934
+ var MAX_SHUTDOWN_GRACE_MS = 3e4;
935
+ var DEFAULT_SHUTDOWN_GRACE_MS = 1e4;
936
+ var ShutdownOptionsSchema = z2.object({
937
+ // `0` is legal and is exactly today's behavior: stop scheduling and
938
+ // return without waiting.
939
+ graceMs: z2.number().min(0).optional()
940
+ }).loose();
941
+ function resolveShutdownConfig(options) {
942
+ if (options === void 0) return void 0;
943
+ ShutdownOptionsSchema.parse(options);
944
+ return options;
945
+ }
934
946
  var ActOptionsSchema = z2.object({
935
947
  maxSubscribedStreams: z2.number().int().min(1).optional(),
936
948
  settleDebounceMs: z2.number().int().min(0).optional()
@@ -2636,6 +2648,16 @@ var DrainController = class {
2636
2648
  /** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
2637
2649
  _worker;
2638
2650
  _stopped = false;
2651
+ /**
2652
+ * Resolves when the cycle currently in flight finishes; `undefined` when
2653
+ * no cycle is running (#1442). `_locked` answers "is a cycle running?" for
2654
+ * the overlap guard; this answers "tell me when it is done" for a graceful
2655
+ * shutdown, which needs to await the handler rather than abandon it
2656
+ * mid-`await` with the stream still leased. Never rejects — `drain()`
2657
+ * contains its own errors — so awaiting it is always safe.
2658
+ */
2659
+ _inflight;
2660
+ _inflight_done;
2639
2661
  _deps;
2640
2662
  constructor(deps) {
2641
2663
  this._deps = deps;
@@ -2668,6 +2690,24 @@ var DrainController = class {
2668
2690
  get armed() {
2669
2691
  return this._armed;
2670
2692
  }
2693
+ /**
2694
+ * The cycle currently in flight, or `undefined` when idle (#1442). A
2695
+ * graceful shutdown awaits this so an in-flight handler reaches its `ack`
2696
+ * — which releases the stream's lease — instead of being abandoned with
2697
+ * the lease held until it expires.
2698
+ */
2699
+ get inflight() {
2700
+ return this._inflight;
2701
+ }
2702
+ /**
2703
+ * This lane's configured lease budget, or `undefined` when the lane didn't
2704
+ * pin one. It is the operator's own statement of how long a handler may
2705
+ * legitimately hold a stream, which makes it the right basis for a
2706
+ * shutdown grace budget (#1442).
2707
+ */
2708
+ get lease_millis() {
2709
+ return this._deps.defaults?.leaseMillis;
2710
+ }
2671
2711
  /** Lane this controller drains (undefined = legacy single-lane span). */
2672
2712
  get lane() {
2673
2713
  return this._deps.lane;
@@ -2714,6 +2754,9 @@ var DrainController = class {
2714
2754
  const leaseMillis = d.leaseMillis ?? options.leaseMillis ?? 1e4;
2715
2755
  try {
2716
2756
  this._locked = true;
2757
+ this._inflight = new Promise((done) => {
2758
+ this._inflight_done = done;
2759
+ });
2717
2760
  const lagging = Math.ceil(streamLimit * this._ratio);
2718
2761
  const leading = streamLimit - lagging;
2719
2762
  const cycle = await run_drain_cycle(
@@ -2755,6 +2798,9 @@ var DrainController = class {
2755
2798
  return EMPTY_DRAIN;
2756
2799
  } finally {
2757
2800
  this._locked = false;
2801
+ this._inflight = void 0;
2802
+ this._inflight_done?.();
2803
+ this._inflight_done = void 0;
2758
2804
  }
2759
2805
  }
2760
2806
  };
@@ -3512,29 +3558,87 @@ var Act = class {
3512
3558
  /** True after the first `shutdown()` call. Guards idempotency. */
3513
3559
  _shutdown_promise;
3514
3560
  /**
3515
- * Per-instance teardown: remove lifecycle listeners, stop the
3516
- * correlation worker, cancel any pending settle cycle, and tear
3517
- * down the cross-process notify subscription.
3561
+ * Per-instance teardown: stop scheduling new work, give drain cycles
3562
+ * already in flight a bounded chance to finish, then remove lifecycle
3563
+ * listeners and tear down the cross-process notify subscription.
3564
+ *
3565
+ * The order is deliberate (#1442). Scheduling stops first, so nothing new
3566
+ * is claimed while teardown runs. Then in-flight cycles are awaited up to
3567
+ * `graceMs`: a reaction handler parked on an `await` holds its stream's
3568
+ * lease until it acks, so abandoning it costs the replacement worker up to
3569
+ * `leaseMillis` of dead time on that stream and discards the round of work
3570
+ * (which #1418 then redelivers). Listeners come off *after* that wait, not
3571
+ * before, so an `acked` / `blocked` subscriber still observes the work
3572
+ * that completed during the grace window.
3518
3573
  *
3519
- * Idempotent — repeated calls return the same promise. Registered
3520
- * automatically with the global `dispose()` registry at construction,
3521
- * so process-wide `dispose()()` covers it; test helpers (or operators
3522
- * that mint short-lived Acts) call it explicitly for prompt cleanup.
3574
+ * The budget is a ceiling, not a delay — teardown continues the moment the
3575
+ * last in-flight cycle finishes. When it is exhausted, teardown proceeds
3576
+ * anyway: one stuck handler must not hang a deploy, which is the failure
3577
+ * mode an unbounded wait would trade for.
3578
+ *
3579
+ * Idempotent — repeated calls return the same promise, and the first
3580
+ * call's `graceMs` is the one that applies. Registered automatically with
3581
+ * the global `dispose()` registry at construction, so process-wide
3582
+ * `dispose()()` covers it; test helpers (or operators that mint
3583
+ * short-lived Acts) call it explicitly for prompt cleanup.
3584
+ *
3585
+ * @param options - See {@link ShutdownOptions}. Defaults the grace budget
3586
+ * to the largest lane `leaseMillis` (capped at 30s).
3523
3587
  */
3524
- shutdown() {
3588
+ shutdown(options) {
3525
3589
  if (!this._shutdown_promise) {
3590
+ resolveShutdownConfig(options);
3526
3591
  this._shutdown_promise = (async () => {
3527
- this._emitter.removeAllListeners();
3528
3592
  this.stop_correlations();
3529
3593
  this.stop_settling();
3530
3594
  this._breaker.stop();
3531
3595
  for (const c of this._drain_controllers.values()) c.stop();
3596
+ await this._await_inflight(options?.graceMs);
3597
+ this._emitter.removeAllListeners();
3532
3598
  const disposer = await this._notify_disposer;
3533
3599
  if (disposer) await disposer();
3534
3600
  })();
3535
3601
  }
3536
3602
  return this._shutdown_promise;
3537
3603
  }
3604
+ /**
3605
+ * Wait for every in-flight drain cycle, or for the grace budget to elapse,
3606
+ * whichever comes first. Cycle promises never reject (`drain()` contains
3607
+ * its own errors), so this never throws.
3608
+ *
3609
+ * An omitted budget is derived from the lanes that actually have a cycle
3610
+ * in flight: their `leaseMillis` is the operator's own statement of how
3611
+ * long one of their handlers may hold a stream, which makes it the honest
3612
+ * ceiling for how long teardown should wait for that handler. A parked
3613
+ * lane that pinned no lease contributes `drain()`'s own fallback, and the
3614
+ * whole thing is capped so a long-leased lane cannot hold a deploy open.
3615
+ * Idle lanes do not count — nothing is running on them to wait for.
3616
+ */
3617
+ _derive_grace_ms(running) {
3618
+ let max = 0;
3619
+ for (const c of running)
3620
+ max = Math.max(max, c.lease_millis ?? DEFAULT_SHUTDOWN_GRACE_MS);
3621
+ return Math.min(max, MAX_SHUTDOWN_GRACE_MS);
3622
+ }
3623
+ async _await_inflight(grace_ms) {
3624
+ const running = [...this._drain_controllers.values()].filter(
3625
+ (c) => c.inflight !== void 0
3626
+ );
3627
+ if (running.length === 0) return;
3628
+ const grace = grace_ms ?? this._derive_grace_ms(running);
3629
+ if (grace <= 0) return;
3630
+ const inflight = running.map((c) => c.inflight);
3631
+ let timer;
3632
+ const budget = new Promise((resolve) => {
3633
+ timer = setTimeout(resolve, grace);
3634
+ timer.unref();
3635
+ });
3636
+ try {
3637
+ await Promise.race([Promise.all(inflight), budget]);
3638
+ } finally {
3639
+ clearTimeout(timer);
3640
+ }
3641
+ }
3538
3642
  /**
3539
3643
  * Subscribe to {@link Store.notify} when both the store and the
3540
3644
  * registry support it. Returns the disposer (or `undefined` when no