@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/.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.cjs
CHANGED
|
@@ -57,6 +57,7 @@ __export(index_exports, {
|
|
|
57
57
|
REDACTED: () => REDACTED,
|
|
58
58
|
SHREDDED: () => SHREDDED,
|
|
59
59
|
SNAP_EVENT: () => SNAP_EVENT,
|
|
60
|
+
StoreError: () => StoreError,
|
|
60
61
|
StreamClosedError: () => StreamClosedError,
|
|
61
62
|
TOMBSTONE_EVENT: () => TOMBSTONE_EVENT,
|
|
62
63
|
TargetSchema: () => TargetSchema,
|
|
@@ -307,7 +308,8 @@ var Errors = {
|
|
|
307
308
|
InvariantError: "ERR_INVARIANT",
|
|
308
309
|
ConcurrencyError: "ERR_CONCURRENCY",
|
|
309
310
|
StreamClosedError: "ERR_STREAM_CLOSED",
|
|
310
|
-
NonRetryableError: "ERR_NON_RETRYABLE"
|
|
311
|
+
NonRetryableError: "ERR_NON_RETRYABLE",
|
|
312
|
+
StoreError: "ERR_STORE"
|
|
311
313
|
};
|
|
312
314
|
var ValidationError = class extends Error {
|
|
313
315
|
/** The type of target being validated (e.g., "action", "event") */
|
|
@@ -376,6 +378,15 @@ var StreamClosedError = class extends Error {
|
|
|
376
378
|
this.stream = stream;
|
|
377
379
|
}
|
|
378
380
|
};
|
|
381
|
+
var StoreError = class extends Error {
|
|
382
|
+
/** The store operation that failed (e.g. `"claim"`, `"ack"`, `"commit"`). */
|
|
383
|
+
operation;
|
|
384
|
+
constructor(operation, options) {
|
|
385
|
+
super(`Store operation "${operation}" failed`, options);
|
|
386
|
+
this.name = Errors.StoreError;
|
|
387
|
+
this.operation = operation;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
379
390
|
var NonRetryableError = class extends Error {
|
|
380
391
|
/** The original failure, if any. Mirrors the standard `Error.cause` shape. */
|
|
381
392
|
cause;
|
|
@@ -2136,10 +2147,7 @@ var AutocloseController = class {
|
|
|
2136
2147
|
start() {
|
|
2137
2148
|
if (this._timer) return false;
|
|
2138
2149
|
this._timer = setInterval(() => {
|
|
2139
|
-
this.run_once().catch((
|
|
2140
|
-
this.deps.logger.error(
|
|
2141
|
-
`Autoclose cycle errored: ${err instanceof Error ? err.message : String(err)}`
|
|
2142
|
-
);
|
|
2150
|
+
this.run_once().catch(() => {
|
|
2143
2151
|
});
|
|
2144
2152
|
}, this.deps.config.autocloseCycleMs);
|
|
2145
2153
|
this._timer.unref();
|
|
@@ -2172,6 +2180,7 @@ var AutocloseController = class {
|
|
|
2172
2180
|
*/
|
|
2173
2181
|
async run_once() {
|
|
2174
2182
|
if (this._running) return null;
|
|
2183
|
+
if (this.deps.breaker.state(Date.now()) === "open") return null;
|
|
2175
2184
|
this._running = true;
|
|
2176
2185
|
try {
|
|
2177
2186
|
const correlation = close_correlation(
|
|
@@ -2189,10 +2198,14 @@ var AutocloseController = class {
|
|
|
2189
2198
|
config: this.deps.config,
|
|
2190
2199
|
correlation
|
|
2191
2200
|
});
|
|
2201
|
+
this.deps.breaker.passed();
|
|
2192
2202
|
if (result.close_result.truncated.size > 0) {
|
|
2193
2203
|
this.deps.on_closed(result.close_result);
|
|
2194
2204
|
}
|
|
2195
2205
|
return result;
|
|
2206
|
+
} catch (error) {
|
|
2207
|
+
this.deps.breaker.failed(Date.now(), error);
|
|
2208
|
+
throw error;
|
|
2196
2209
|
} finally {
|
|
2197
2210
|
this._running = false;
|
|
2198
2211
|
}
|
|
@@ -2322,6 +2335,93 @@ function classify_registry(registry, states) {
|
|
|
2322
2335
|
};
|
|
2323
2336
|
}
|
|
2324
2337
|
|
|
2338
|
+
// src/internal/circuit-breaker.ts
|
|
2339
|
+
var import_zod7 = require("zod");
|
|
2340
|
+
var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
|
|
2341
|
+
var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
|
|
2342
|
+
var CircuitBreakerOptionsSchema = import_zod7.z.object({
|
|
2343
|
+
failureThreshold: import_zod7.z.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
|
|
2344
|
+
cooldownMs: import_zod7.z.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
|
|
2345
|
+
});
|
|
2346
|
+
var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
|
|
2347
|
+
var CircuitBreaker = class {
|
|
2348
|
+
_failures = 0;
|
|
2349
|
+
_opened_at;
|
|
2350
|
+
_wake;
|
|
2351
|
+
_threshold;
|
|
2352
|
+
_cooldown_ms;
|
|
2353
|
+
_hooks;
|
|
2354
|
+
constructor(config2, hooks = {}) {
|
|
2355
|
+
this._threshold = config2.failureThreshold;
|
|
2356
|
+
this._cooldown_ms = config2.cooldownMs;
|
|
2357
|
+
this._hooks = hooks;
|
|
2358
|
+
}
|
|
2359
|
+
/** Current state given the wall-clock `now`. */
|
|
2360
|
+
state(now) {
|
|
2361
|
+
if (this._opened_at === void 0) return "closed";
|
|
2362
|
+
return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
|
|
2363
|
+
}
|
|
2364
|
+
/** A store op passed — reset to closed and cancel any pending retry. */
|
|
2365
|
+
passed() {
|
|
2366
|
+
this._failures = 0;
|
|
2367
|
+
this._opened_at = void 0;
|
|
2368
|
+
this._clear_wake();
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* A store op failed. Opens the breaker when the consecutive-failure
|
|
2372
|
+
* threshold is reached, or immediately re-opens (restarting the cooldown)
|
|
2373
|
+
* if a half-open trial just failed. Returns the resulting state.
|
|
2374
|
+
*
|
|
2375
|
+
* Callers gate on the state directly — `state(now) === "open"` means skip.
|
|
2376
|
+
* On opening, schedules the `on_retry` wake so recovery is automatic;
|
|
2377
|
+
* always surfaces the failure via `on_error`.
|
|
2378
|
+
*/
|
|
2379
|
+
failed(now, error) {
|
|
2380
|
+
const circuit = this._advance(now);
|
|
2381
|
+
if (circuit === "open") this._schedule_wake();
|
|
2382
|
+
this._hooks.on_error?.(error, circuit);
|
|
2383
|
+
return circuit;
|
|
2384
|
+
}
|
|
2385
|
+
/** Cancel the pending retry timer. Idempotent; call on shutdown. */
|
|
2386
|
+
stop() {
|
|
2387
|
+
this._clear_wake();
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
|
|
2391
|
+
* the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
|
|
2392
|
+
* tests of the pure state machine). The timer is `unref`'d so it never
|
|
2393
|
+
* keeps the process alive.
|
|
2394
|
+
*/
|
|
2395
|
+
_schedule_wake() {
|
|
2396
|
+
if (!this._hooks.on_retry) return;
|
|
2397
|
+
this._clear_wake();
|
|
2398
|
+
this._wake = setTimeout(() => {
|
|
2399
|
+
this._wake = void 0;
|
|
2400
|
+
this._hooks.on_retry?.();
|
|
2401
|
+
}, this._cooldown_ms);
|
|
2402
|
+
this._wake.unref();
|
|
2403
|
+
}
|
|
2404
|
+
_clear_wake() {
|
|
2405
|
+
if (this._wake) {
|
|
2406
|
+
clearTimeout(this._wake);
|
|
2407
|
+
this._wake = void 0;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
/** Pure state transition for a failure — no side effects. */
|
|
2411
|
+
_advance(now) {
|
|
2412
|
+
if (this._opened_at !== void 0) {
|
|
2413
|
+
this._opened_at = now;
|
|
2414
|
+
return "open";
|
|
2415
|
+
}
|
|
2416
|
+
this._failures += 1;
|
|
2417
|
+
if (this._failures >= this._threshold) {
|
|
2418
|
+
this._opened_at = now;
|
|
2419
|
+
return "open";
|
|
2420
|
+
}
|
|
2421
|
+
return "closed";
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
2424
|
+
|
|
2325
2425
|
// src/internal/correlate-cycle.ts
|
|
2326
2426
|
var CorrelateCycle = class {
|
|
2327
2427
|
_checkpoint = -1;
|
|
@@ -3174,6 +3274,8 @@ var DrainController = class {
|
|
|
3174
3274
|
async drain(options = {}) {
|
|
3175
3275
|
if (!this._armed) return EMPTY_DRAIN;
|
|
3176
3276
|
if (this._locked) return EMPTY_DRAIN;
|
|
3277
|
+
if (this._deps.breaker.state(Date.now()) === "open")
|
|
3278
|
+
return EMPTY_DRAIN;
|
|
3177
3279
|
const d = this._deps.defaults ?? {};
|
|
3178
3280
|
const streamLimit = d.streamLimit ?? options.streamLimit ?? 10;
|
|
3179
3281
|
const eventLimit = d.eventLimit ?? options.eventLimit ?? 10;
|
|
@@ -3195,6 +3297,7 @@ var DrainController = class {
|
|
|
3195
3297
|
this._backoff.size > 0 ? this.is_deferred : void 0,
|
|
3196
3298
|
this._deps.lane
|
|
3197
3299
|
);
|
|
3300
|
+
this._deps.breaker.passed();
|
|
3198
3301
|
if (!cycle) {
|
|
3199
3302
|
this._armed = false;
|
|
3200
3303
|
return EMPTY_DRAIN;
|
|
@@ -3216,7 +3319,7 @@ var DrainController = class {
|
|
|
3216
3319
|
if (!acked.length && !blocked.length && !has_errors) this._armed = false;
|
|
3217
3320
|
return { fetched, leased, acked, blocked };
|
|
3218
3321
|
} catch (error) {
|
|
3219
|
-
this._deps.
|
|
3322
|
+
this._deps.breaker.failed(Date.now(), error);
|
|
3220
3323
|
return EMPTY_DRAIN;
|
|
3221
3324
|
} finally {
|
|
3222
3325
|
this._locked = false;
|
|
@@ -3225,7 +3328,7 @@ var DrainController = class {
|
|
|
3225
3328
|
};
|
|
3226
3329
|
|
|
3227
3330
|
// src/internal/merge.ts
|
|
3228
|
-
var
|
|
3331
|
+
var import_zod8 = require("zod");
|
|
3229
3332
|
function base_type_name(zodType) {
|
|
3230
3333
|
let t = zodType;
|
|
3231
3334
|
while (typeof t.unwrap === "function") {
|
|
@@ -3234,7 +3337,7 @@ function base_type_name(zodType) {
|
|
|
3234
3337
|
return t.constructor.name;
|
|
3235
3338
|
}
|
|
3236
3339
|
function merge_schemas(existing, incoming, state_name) {
|
|
3237
|
-
if (existing instanceof
|
|
3340
|
+
if (existing instanceof import_zod8.ZodObject && incoming instanceof import_zod8.ZodObject) {
|
|
3238
3341
|
const existing_shape = existing.shape;
|
|
3239
3342
|
const incoming_shape = incoming.shape;
|
|
3240
3343
|
for (const key of Object.keys(incoming_shape)) {
|
|
@@ -3504,12 +3607,15 @@ var SettleLoop = class {
|
|
|
3504
3607
|
...correlate_query,
|
|
3505
3608
|
after: this._deps.checkpoint()
|
|
3506
3609
|
});
|
|
3610
|
+
this._deps.breaker.passed();
|
|
3507
3611
|
last_drain = await this._deps.drain(drain_options);
|
|
3508
3612
|
const made_progress = subscribed > 0 || last_drain.acked.length > 0 || last_drain.blocked.length > 0;
|
|
3509
3613
|
if (!made_progress) break;
|
|
3510
3614
|
}
|
|
3511
3615
|
if (last_drain) this._deps.on_settled(last_drain);
|
|
3512
|
-
})().catch((err) =>
|
|
3616
|
+
})().catch((err) => {
|
|
3617
|
+
this._deps.breaker.failed(Date.now(), err);
|
|
3618
|
+
}).finally(() => {
|
|
3513
3619
|
this._running = false;
|
|
3514
3620
|
});
|
|
3515
3621
|
}, debounceMs);
|
|
@@ -3528,6 +3634,8 @@ var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
|
|
|
3528
3634
|
var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
|
|
3529
3635
|
var Act = class {
|
|
3530
3636
|
_emitter = new import_node_events.default();
|
|
3637
|
+
/** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
|
|
3638
|
+
_breaker;
|
|
3531
3639
|
/** #803: gate the `Store.notify` subscription side. */
|
|
3532
3640
|
_listen;
|
|
3533
3641
|
/** #803: gate the local reaction pipeline (drain controllers, settle, correlate). */
|
|
@@ -3581,6 +3689,22 @@ var Act = class {
|
|
|
3581
3689
|
emit(event, args) {
|
|
3582
3690
|
return this._emitter.emit(event, args);
|
|
3583
3691
|
}
|
|
3692
|
+
/**
|
|
3693
|
+
* The single store-failure handler: log it, then emit the `error`
|
|
3694
|
+
* lifecycle event. Wired into the circuit breaker's `on_error`, so it
|
|
3695
|
+
* runs on every `failed()` — drain / settle / autoclose just call
|
|
3696
|
+
* `breaker.failed(now, error)` and never log or emit themselves.
|
|
3697
|
+
*
|
|
3698
|
+
* The emit is guarded against Node's `EventEmitter` contract that an
|
|
3699
|
+
* unhandled `"error"` emission is rethrown (which would crash the process
|
|
3700
|
+
* from inside the drain catch); logging is unconditional so failures are
|
|
3701
|
+
* never silent.
|
|
3702
|
+
*/
|
|
3703
|
+
_emit_error(error, circuit) {
|
|
3704
|
+
this._logger.error(error);
|
|
3705
|
+
if (this._emitter.listenerCount("error") > 0)
|
|
3706
|
+
this.emit("error", { error, circuit });
|
|
3707
|
+
}
|
|
3584
3708
|
/**
|
|
3585
3709
|
* Register a listener for a lifecycle event. The listener receives the
|
|
3586
3710
|
* event-specific payload.
|
|
@@ -3709,6 +3833,25 @@ var Act = class {
|
|
|
3709
3833
|
this._listen = options.listen !== false;
|
|
3710
3834
|
this._drain = options.drain !== false;
|
|
3711
3835
|
this._autoclose_config = resolveAutocloseConfig(options);
|
|
3836
|
+
this._breaker = new CircuitBreaker(
|
|
3837
|
+
resolveCircuitBreakerConfig(options.circuitBreaker),
|
|
3838
|
+
{
|
|
3839
|
+
on_error: (error, circuit) => this._emit_error(error, circuit),
|
|
3840
|
+
// Re-probe the store when the cooldown elapses, so recovery is
|
|
3841
|
+
// automatic even on the default lane (which has no periodic poller).
|
|
3842
|
+
// The breaker is shared by every store-polling loop (drain, the
|
|
3843
|
+
// settle correlate, autoclose), so any of them can trip it — but a
|
|
3844
|
+
// bare `drain()` only touches the store when the controller is armed,
|
|
3845
|
+
// so a trip from a correlate failure could go unprobed. `settle()`
|
|
3846
|
+
// always runs a correlate (a store query) before draining, so it
|
|
3847
|
+
// re-probes regardless of which loop opened the breaker: one success
|
|
3848
|
+
// closes it and every loop resumes on its own cadence. A failed probe
|
|
3849
|
+
// re-opens the breaker and reschedules the wake.
|
|
3850
|
+
on_retry: () => {
|
|
3851
|
+
this.settle({ debounceMs: 0 });
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
);
|
|
3712
3855
|
this._event_to_state = event_to_state;
|
|
3713
3856
|
this._event_to_lanes = event_to_lanes;
|
|
3714
3857
|
const all_lanes = ["default", ...lanes.map((l) => l.name)];
|
|
@@ -3727,6 +3870,7 @@ var Act = class {
|
|
|
3727
3870
|
handle_batch: this._handle_batch,
|
|
3728
3871
|
on_acked: (acked) => this.emit("acked", acked),
|
|
3729
3872
|
on_blocked: (blocked) => this.emit("blocked", blocked),
|
|
3873
|
+
breaker: this._breaker,
|
|
3730
3874
|
// Pass lane only when a true per-lane controller is active.
|
|
3731
3875
|
// The all-lanes (single default) case keeps lane=undefined so
|
|
3732
3876
|
// adapter SQL collapses to the pre-1103 shape.
|
|
@@ -3763,12 +3907,12 @@ var Act = class {
|
|
|
3763
3907
|
);
|
|
3764
3908
|
this._settle = new SettleLoop(
|
|
3765
3909
|
{
|
|
3766
|
-
logger: this._logger,
|
|
3767
3910
|
init: () => this._correlate.init(),
|
|
3768
3911
|
checkpoint: () => this._correlate.checkpoint,
|
|
3769
3912
|
correlate: (q) => this.correlate(q),
|
|
3770
3913
|
drain: (o) => this.drain(o),
|
|
3771
|
-
on_settled: (drain) => this.emit("settled", drain)
|
|
3914
|
+
on_settled: (drain) => this.emit("settled", drain),
|
|
3915
|
+
breaker: this._breaker
|
|
3772
3916
|
},
|
|
3773
3917
|
options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
|
|
3774
3918
|
);
|
|
@@ -3790,7 +3934,8 @@ var Act = class {
|
|
|
3790
3934
|
config: this._autoclose_config,
|
|
3791
3935
|
correlator: this._correlator,
|
|
3792
3936
|
close_actor: { id: "$autoclose", name: "autoclose" },
|
|
3793
|
-
on_closed: (result) => this.emit("closed", result)
|
|
3937
|
+
on_closed: (result) => this.emit("closed", result),
|
|
3938
|
+
breaker: this._breaker
|
|
3794
3939
|
}) : void 0;
|
|
3795
3940
|
this._notify_disposer = this._wire_notify(options.scoped?.store ?? store2());
|
|
3796
3941
|
dispose(() => this.shutdown());
|
|
@@ -3813,6 +3958,7 @@ var Act = class {
|
|
|
3813
3958
|
this._emitter.removeAllListeners();
|
|
3814
3959
|
this.stop_correlations();
|
|
3815
3960
|
this.stop_settling();
|
|
3961
|
+
this._breaker.stop();
|
|
3816
3962
|
for (const c of this._drain_controllers.values()) c.stop();
|
|
3817
3963
|
const disposer = await this._notify_disposer;
|
|
3818
3964
|
if (disposer) await disposer();
|
|
@@ -5330,6 +5476,7 @@ function write_line(writer, line) {
|
|
|
5330
5476
|
REDACTED,
|
|
5331
5477
|
SHREDDED,
|
|
5332
5478
|
SNAP_EVENT,
|
|
5479
|
+
StoreError,
|
|
5333
5480
|
StreamClosedError,
|
|
5334
5481
|
TOMBSTONE_EVENT,
|
|
5335
5482
|
TargetSchema,
|