@rotorsoft/act 1.12.1 → 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/.tsbuildinfo +1 -1
- package/dist/@types/act.d.ts +37 -0
- package/dist/@types/act.d.ts.map +1 -1
- package/dist/@types/internal/autoclose-cycle.d.ts +9 -0
- package/dist/@types/internal/autoclose-cycle.d.ts.map +1 -1
- package/dist/@types/internal/circuit-breaker.d.ts +90 -0
- package/dist/@types/internal/circuit-breaker.d.ts.map +1 -0
- package/dist/@types/internal/drain-cycle.d.ts +9 -0
- package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
- package/dist/@types/internal/index.d.ts +1 -0
- package/dist/@types/internal/index.d.ts.map +1 -1
- package/dist/@types/internal/settle.d.ts +10 -2
- package/dist/@types/internal/settle.d.ts.map +1 -1
- package/dist/@types/types/errors.d.ts +31 -0
- package/dist/@types/types/errors.d.ts.map +1 -1
- package/dist/{chunk-NGVWJXEF.js → chunk-4TJN4G7L.js} +13 -2
- package/dist/chunk-4TJN4G7L.js.map +1 -0
- package/dist/{chunk-QCSXT5MB.js → chunk-QKVY5QAQ.js} +2 -2
- package/dist/index.cjs +159 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +148 -11
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +2 -1
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.js +2 -2
- package/dist/types/index.cjs +13 -1
- package/dist/types/index.cjs.map +1 -1
- package/dist/types/index.js +3 -1
- package/package.json +2 -2
- package/dist/chunk-NGVWJXEF.js.map +0 -1
- /package/dist/{chunk-QCSXT5MB.js.map → chunk-QKVY5QAQ.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
sleep,
|
|
20
20
|
store,
|
|
21
21
|
validate
|
|
22
|
-
} from "./chunk-
|
|
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-
|
|
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((
|
|
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.
|
|
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) =>
|
|
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,
|