@rotorsoft/act 1.13.0 → 1.14.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
@@ -19,7 +19,7 @@ import {
19
19
  sleep,
20
20
  store,
21
21
  validate
22
- } from "./chunk-QCSXT5MB.js";
22
+ } from "./chunk-QKVY5QAQ.js";
23
23
  import {
24
24
  ActorSchema,
25
25
  CausationEventSchema,
@@ -34,6 +34,7 @@ import {
34
34
  QuerySchema,
35
35
  REDACTED,
36
36
  SHREDDED,
37
+ StoreError,
37
38
  StreamClosedError,
38
39
  TargetSchema,
39
40
  ValidationError,
@@ -43,7 +44,7 @@ import {
43
44
  pii_split,
44
45
  pii_strip,
45
46
  sensitive
46
- } from "./chunk-NGVWJXEF.js";
47
+ } from "./chunk-4TJN4G7L.js";
47
48
  import "./chunk-5WRI5ZAA.js";
48
49
 
49
50
  // src/signals.ts
@@ -764,10 +765,7 @@ var AutocloseController = class {
764
765
  start() {
765
766
  if (this._timer) return false;
766
767
  this._timer = setInterval(() => {
767
- this.run_once().catch((err) => {
768
- this.deps.logger.error(
769
- `Autoclose cycle errored: ${err instanceof Error ? err.message : String(err)}`
770
- );
768
+ this.run_once().catch(() => {
771
769
  });
772
770
  }, this.deps.config.autocloseCycleMs);
773
771
  this._timer.unref();
@@ -800,6 +798,7 @@ var AutocloseController = class {
800
798
  */
801
799
  async run_once() {
802
800
  if (this._running) return null;
801
+ if (this.deps.breaker.state(Date.now()) === "open") return null;
803
802
  this._running = true;
804
803
  try {
805
804
  const correlation = close_correlation(
@@ -817,10 +816,14 @@ var AutocloseController = class {
817
816
  config: this.deps.config,
818
817
  correlation
819
818
  });
819
+ this.deps.breaker.passed();
820
820
  if (result.close_result.truncated.size > 0) {
821
821
  this.deps.on_closed(result.close_result);
822
822
  }
823
823
  return result;
824
+ } catch (error) {
825
+ this.deps.breaker.failed(Date.now(), error);
826
+ throw error;
824
827
  } finally {
825
828
  this._running = false;
826
829
  }
@@ -950,6 +953,93 @@ function classify_registry(registry, states) {
950
953
  };
951
954
  }
952
955
 
956
+ // src/internal/circuit-breaker.ts
957
+ import { z as z3 } from "zod";
958
+ var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
959
+ var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
960
+ var CircuitBreakerOptionsSchema = z3.object({
961
+ failureThreshold: z3.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
962
+ cooldownMs: z3.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
963
+ });
964
+ var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
965
+ var CircuitBreaker = class {
966
+ _failures = 0;
967
+ _opened_at;
968
+ _wake;
969
+ _threshold;
970
+ _cooldown_ms;
971
+ _hooks;
972
+ constructor(config2, hooks = {}) {
973
+ this._threshold = config2.failureThreshold;
974
+ this._cooldown_ms = config2.cooldownMs;
975
+ this._hooks = hooks;
976
+ }
977
+ /** Current state given the wall-clock `now`. */
978
+ state(now) {
979
+ if (this._opened_at === void 0) return "closed";
980
+ return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
981
+ }
982
+ /** A store op passed — reset to closed and cancel any pending retry. */
983
+ passed() {
984
+ this._failures = 0;
985
+ this._opened_at = void 0;
986
+ this._clear_wake();
987
+ }
988
+ /**
989
+ * A store op failed. Opens the breaker when the consecutive-failure
990
+ * threshold is reached, or immediately re-opens (restarting the cooldown)
991
+ * if a half-open trial just failed. Returns the resulting state.
992
+ *
993
+ * Callers gate on the state directly — `state(now) === "open"` means skip.
994
+ * On opening, schedules the `on_retry` wake so recovery is automatic;
995
+ * always surfaces the failure via `on_error`.
996
+ */
997
+ failed(now, error) {
998
+ const circuit = this._advance(now);
999
+ if (circuit === "open") this._schedule_wake();
1000
+ this._hooks.on_error?.(error, circuit);
1001
+ return circuit;
1002
+ }
1003
+ /** Cancel the pending retry timer. Idempotent; call on shutdown. */
1004
+ stop() {
1005
+ this._clear_wake();
1006
+ }
1007
+ /**
1008
+ * Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
1009
+ * the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
1010
+ * tests of the pure state machine). The timer is `unref`'d so it never
1011
+ * keeps the process alive.
1012
+ */
1013
+ _schedule_wake() {
1014
+ if (!this._hooks.on_retry) return;
1015
+ this._clear_wake();
1016
+ this._wake = setTimeout(() => {
1017
+ this._wake = void 0;
1018
+ this._hooks.on_retry?.();
1019
+ }, this._cooldown_ms);
1020
+ this._wake.unref();
1021
+ }
1022
+ _clear_wake() {
1023
+ if (this._wake) {
1024
+ clearTimeout(this._wake);
1025
+ this._wake = void 0;
1026
+ }
1027
+ }
1028
+ /** Pure state transition for a failure — no side effects. */
1029
+ _advance(now) {
1030
+ if (this._opened_at !== void 0) {
1031
+ this._opened_at = now;
1032
+ return "open";
1033
+ }
1034
+ this._failures += 1;
1035
+ if (this._failures >= this._threshold) {
1036
+ this._opened_at = now;
1037
+ return "open";
1038
+ }
1039
+ return "closed";
1040
+ }
1041
+ };
1042
+
953
1043
  // src/internal/correlate-cycle.ts
954
1044
  var CorrelateCycle = class {
955
1045
  _checkpoint = -1;
@@ -1802,6 +1892,8 @@ var DrainController = class {
1802
1892
  async drain(options = {}) {
1803
1893
  if (!this._armed) return EMPTY_DRAIN;
1804
1894
  if (this._locked) return EMPTY_DRAIN;
1895
+ if (this._deps.breaker.state(Date.now()) === "open")
1896
+ return EMPTY_DRAIN;
1805
1897
  const d = this._deps.defaults ?? {};
1806
1898
  const streamLimit = d.streamLimit ?? options.streamLimit ?? 10;
1807
1899
  const eventLimit = d.eventLimit ?? options.eventLimit ?? 10;
@@ -1823,6 +1915,7 @@ var DrainController = class {
1823
1915
  this._backoff.size > 0 ? this.is_deferred : void 0,
1824
1916
  this._deps.lane
1825
1917
  );
1918
+ this._deps.breaker.passed();
1826
1919
  if (!cycle) {
1827
1920
  this._armed = false;
1828
1921
  return EMPTY_DRAIN;
@@ -1844,7 +1937,7 @@ var DrainController = class {
1844
1937
  if (!acked.length && !blocked.length && !has_errors) this._armed = false;
1845
1938
  return { fetched, leased, acked, blocked };
1846
1939
  } catch (error) {
1847
- this._deps.logger.error(error);
1940
+ this._deps.breaker.failed(Date.now(), error);
1848
1941
  return EMPTY_DRAIN;
1849
1942
  } finally {
1850
1943
  this._locked = false;
@@ -2132,12 +2225,15 @@ var SettleLoop = class {
2132
2225
  ...correlate_query,
2133
2226
  after: this._deps.checkpoint()
2134
2227
  });
2228
+ this._deps.breaker.passed();
2135
2229
  last_drain = await this._deps.drain(drain_options);
2136
2230
  const made_progress = subscribed > 0 || last_drain.acked.length > 0 || last_drain.blocked.length > 0;
2137
2231
  if (!made_progress) break;
2138
2232
  }
2139
2233
  if (last_drain) this._deps.on_settled(last_drain);
2140
- })().catch((err) => this._deps.logger.error(err)).finally(() => {
2234
+ })().catch((err) => {
2235
+ this._deps.breaker.failed(Date.now(), err);
2236
+ }).finally(() => {
2141
2237
  this._running = false;
2142
2238
  });
2143
2239
  }, debounceMs);
@@ -2156,6 +2252,8 @@ var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
2156
2252
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
2157
2253
  var Act = class {
2158
2254
  _emitter = new EventEmitter();
2255
+ /** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
2256
+ _breaker;
2159
2257
  /** #803: gate the `Store.notify` subscription side. */
2160
2258
  _listen;
2161
2259
  /** #803: gate the local reaction pipeline (drain controllers, settle, correlate). */
@@ -2209,6 +2307,22 @@ var Act = class {
2209
2307
  emit(event, args) {
2210
2308
  return this._emitter.emit(event, args);
2211
2309
  }
2310
+ /**
2311
+ * The single store-failure handler: log it, then emit the `error`
2312
+ * lifecycle event. Wired into the circuit breaker's `on_error`, so it
2313
+ * runs on every `failed()` — drain / settle / autoclose just call
2314
+ * `breaker.failed(now, error)` and never log or emit themselves.
2315
+ *
2316
+ * The emit is guarded against Node's `EventEmitter` contract that an
2317
+ * unhandled `"error"` emission is rethrown (which would crash the process
2318
+ * from inside the drain catch); logging is unconditional so failures are
2319
+ * never silent.
2320
+ */
2321
+ _emit_error(error, circuit) {
2322
+ this._logger.error(error);
2323
+ if (this._emitter.listenerCount("error") > 0)
2324
+ this.emit("error", { error, circuit });
2325
+ }
2212
2326
  /**
2213
2327
  * Register a listener for a lifecycle event. The listener receives the
2214
2328
  * event-specific payload.
@@ -2337,6 +2451,25 @@ var Act = class {
2337
2451
  this._listen = options.listen !== false;
2338
2452
  this._drain = options.drain !== false;
2339
2453
  this._autoclose_config = resolveAutocloseConfig(options);
2454
+ this._breaker = new CircuitBreaker(
2455
+ resolveCircuitBreakerConfig(options.circuitBreaker),
2456
+ {
2457
+ on_error: (error, circuit) => this._emit_error(error, circuit),
2458
+ // Re-probe the store when the cooldown elapses, so recovery is
2459
+ // automatic even on the default lane (which has no periodic poller).
2460
+ // The breaker is shared by every store-polling loop (drain, the
2461
+ // settle correlate, autoclose), so any of them can trip it — but a
2462
+ // bare `drain()` only touches the store when the controller is armed,
2463
+ // so a trip from a correlate failure could go unprobed. `settle()`
2464
+ // always runs a correlate (a store query) before draining, so it
2465
+ // re-probes regardless of which loop opened the breaker: one success
2466
+ // closes it and every loop resumes on its own cadence. A failed probe
2467
+ // re-opens the breaker and reschedules the wake.
2468
+ on_retry: () => {
2469
+ this.settle({ debounceMs: 0 });
2470
+ }
2471
+ }
2472
+ );
2340
2473
  this._event_to_state = event_to_state;
2341
2474
  this._event_to_lanes = event_to_lanes;
2342
2475
  const all_lanes = ["default", ...lanes.map((l) => l.name)];
@@ -2355,6 +2488,7 @@ var Act = class {
2355
2488
  handle_batch: this._handle_batch,
2356
2489
  on_acked: (acked) => this.emit("acked", acked),
2357
2490
  on_blocked: (blocked) => this.emit("blocked", blocked),
2491
+ breaker: this._breaker,
2358
2492
  // Pass lane only when a true per-lane controller is active.
2359
2493
  // The all-lanes (single default) case keeps lane=undefined so
2360
2494
  // adapter SQL collapses to the pre-1103 shape.
@@ -2391,12 +2525,12 @@ var Act = class {
2391
2525
  );
2392
2526
  this._settle = new SettleLoop(
2393
2527
  {
2394
- logger: this._logger,
2395
2528
  init: () => this._correlate.init(),
2396
2529
  checkpoint: () => this._correlate.checkpoint,
2397
2530
  correlate: (q) => this.correlate(q),
2398
2531
  drain: (o) => this.drain(o),
2399
- on_settled: (drain) => this.emit("settled", drain)
2532
+ on_settled: (drain) => this.emit("settled", drain),
2533
+ breaker: this._breaker
2400
2534
  },
2401
2535
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
2402
2536
  );
@@ -2418,7 +2552,8 @@ var Act = class {
2418
2552
  config: this._autoclose_config,
2419
2553
  correlator: this._correlator,
2420
2554
  close_actor: { id: "$autoclose", name: "autoclose" },
2421
- on_closed: (result) => this.emit("closed", result)
2555
+ on_closed: (result) => this.emit("closed", result),
2556
+ breaker: this._breaker
2422
2557
  }) : void 0;
2423
2558
  this._notify_disposer = this._wire_notify(options.scoped?.store ?? store());
2424
2559
  dispose(() => this.shutdown());
@@ -2441,6 +2576,7 @@ var Act = class {
2441
2576
  this._emitter.removeAllListeners();
2442
2577
  this.stop_correlations();
2443
2578
  this.stop_settling();
2579
+ this._breaker.stop();
2444
2580
  for (const c of this._drain_controllers.values()) c.stop();
2445
2581
  const disposer = await this._notify_disposer;
2446
2582
  if (disposer) await disposer();
@@ -3957,6 +4093,7 @@ export {
3957
4093
  REDACTED,
3958
4094
  SHREDDED,
3959
4095
  SNAP_EVENT,
4096
+ StoreError,
3960
4097
  StreamClosedError,
3961
4098
  TOMBSTONE_EVENT,
3962
4099
  TargetSchema,