@rotorsoft/act 1.16.0 → 1.17.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 +0 -9
- package/dist/@types/act.d.ts.map +1 -1
- package/dist/@types/adapters/in-memory-store.d.ts +10 -0
- package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
- package/dist/@types/builders/state-builder.d.ts +19 -26
- package/dist/@types/builders/state-builder.d.ts.map +1 -1
- package/dist/@types/internal/autoclose-policy.d.ts +16 -0
- package/dist/@types/internal/autoclose-policy.d.ts.map +1 -1
- package/dist/@types/internal/close-signal.d.ts +51 -0
- package/dist/@types/internal/close-signal.d.ts.map +1 -0
- package/dist/@types/internal/defer-signal.d.ts +27 -0
- package/dist/@types/internal/defer-signal.d.ts.map +1 -0
- package/dist/@types/internal/defer-timer.d.ts +67 -0
- package/dist/@types/internal/defer-timer.d.ts.map +1 -0
- package/dist/@types/internal/drain-cycle.d.ts +39 -17
- package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
- package/dist/@types/internal/drain.d.ts +3 -1
- package/dist/@types/internal/drain.d.ts.map +1 -1
- package/dist/@types/internal/index.d.ts +4 -3
- package/dist/@types/internal/index.d.ts.map +1 -1
- package/dist/@types/internal/reactions.d.ts.map +1 -1
- package/dist/@types/internal/tracing.d.ts.map +1 -1
- package/dist/@types/types/action.d.ts +9 -0
- package/dist/@types/types/action.d.ts.map +1 -1
- package/dist/@types/types/ports.d.ts +31 -0
- package/dist/@types/types/ports.d.ts.map +1 -1
- package/dist/{chunk-46OCUAVF.js → chunk-7XME4ZLK.js} +52 -2
- package/dist/chunk-7XME4ZLK.js.map +1 -0
- package/dist/index.cjs +592 -527
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +543 -528
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +55 -5
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.js +5 -5
- package/dist/test/index.js.map +1 -1
- package/package.json +7 -4
- package/dist/@types/internal/autoclose-cycle.d.ts +0 -185
- package/dist/@types/internal/autoclose-cycle.d.ts.map +0 -1
- package/dist/chunk-46OCUAVF.js.map +0 -1
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-7XME4ZLK.js";
|
|
23
23
|
import {
|
|
24
24
|
ActorSchema,
|
|
25
25
|
CausationEventSchema,
|
|
@@ -540,353 +540,6 @@ function in_autoclose_window(window, now) {
|
|
|
540
540
|
return window.start < window.end ? hour >= window.start && hour < window.end : hour >= window.start || hour < window.end;
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
-
// src/internal/close-cycle.ts
|
|
544
|
-
var SAFETY_PROBE_PAGE_SIZE = 1e3;
|
|
545
|
-
async function run_close_cycle(targets, deps) {
|
|
546
|
-
const target_map = new Map(targets.map((t) => [t.stream, t]));
|
|
547
|
-
const streams = [...target_map.keys()];
|
|
548
|
-
const skipped = [];
|
|
549
|
-
const stream_info = await scan_stream_heads(streams);
|
|
550
|
-
const safe = await partition_by_safety(
|
|
551
|
-
stream_info,
|
|
552
|
-
deps.reactive_events_size,
|
|
553
|
-
skipped,
|
|
554
|
-
deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
|
|
555
|
-
);
|
|
556
|
-
if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
|
|
557
|
-
const { guarded, guard_events } = await guard_with_tombstones(
|
|
558
|
-
safe,
|
|
559
|
-
stream_info,
|
|
560
|
-
deps.correlation,
|
|
561
|
-
deps.tombstone,
|
|
562
|
-
skipped
|
|
563
|
-
);
|
|
564
|
-
if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
|
|
565
|
-
const seed_states = await load_restart_seeds(
|
|
566
|
-
guarded,
|
|
567
|
-
target_map,
|
|
568
|
-
stream_info,
|
|
569
|
-
deps.event_to_state,
|
|
570
|
-
deps.load,
|
|
571
|
-
deps.logger
|
|
572
|
-
);
|
|
573
|
-
await run_archive_callbacks(guarded, target_map);
|
|
574
|
-
const truncated = await truncate_and_warm_cache(
|
|
575
|
-
guarded,
|
|
576
|
-
seed_states,
|
|
577
|
-
guard_events,
|
|
578
|
-
deps.correlation
|
|
579
|
-
);
|
|
580
|
-
return { truncated, skipped };
|
|
581
|
-
}
|
|
582
|
-
async function scan_stream_heads(streams) {
|
|
583
|
-
const stats = await store().query_stats(streams, {
|
|
584
|
-
exclude: [SNAP_EVENT]
|
|
585
|
-
});
|
|
586
|
-
const out = /* @__PURE__ */ new Map();
|
|
587
|
-
for (const [stream, { head }] of stats) {
|
|
588
|
-
if (head.name === TOMBSTONE_EVENT) continue;
|
|
589
|
-
out.set(stream, {
|
|
590
|
-
max_id: head.id,
|
|
591
|
-
version: head.version,
|
|
592
|
-
last_event_name: head.name
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
|
-
return out;
|
|
596
|
-
}
|
|
597
|
-
async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
|
|
598
|
-
if (reactive_events_size === 0) return [...stream_info.keys()];
|
|
599
|
-
const pending_set = /* @__PURE__ */ new Set();
|
|
600
|
-
const source_regex = /* @__PURE__ */ new Map();
|
|
601
|
-
const get_regex = (source) => {
|
|
602
|
-
let re = source_regex.get(source);
|
|
603
|
-
if (!re) {
|
|
604
|
-
re = new RegExp(source);
|
|
605
|
-
source_regex.set(source, re);
|
|
606
|
-
}
|
|
607
|
-
return re;
|
|
608
|
-
};
|
|
609
|
-
const targets = [...stream_info.keys()];
|
|
610
|
-
let after;
|
|
611
|
-
for (; ; ) {
|
|
612
|
-
let last;
|
|
613
|
-
const { count } = await store().query_streams(
|
|
614
|
-
(position) => {
|
|
615
|
-
last = position.stream;
|
|
616
|
-
const source_re = position.source ? get_regex(position.source) : void 0;
|
|
617
|
-
for (const [stream, info] of stream_info) {
|
|
618
|
-
if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
|
|
619
|
-
pending_set.add(stream);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
},
|
|
623
|
-
{ after, limit: page_size, source_matches: targets }
|
|
624
|
-
);
|
|
625
|
-
if (count < page_size) break;
|
|
626
|
-
after = last;
|
|
627
|
-
}
|
|
628
|
-
const safe = [];
|
|
629
|
-
for (const [stream] of stream_info) {
|
|
630
|
-
if (pending_set.has(stream)) skipped.push(stream);
|
|
631
|
-
else safe.push(stream);
|
|
632
|
-
}
|
|
633
|
-
return safe;
|
|
634
|
-
}
|
|
635
|
-
async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
|
|
636
|
-
const guarded = [];
|
|
637
|
-
const guard_events = /* @__PURE__ */ new Map();
|
|
638
|
-
await Promise.all(
|
|
639
|
-
safe.map(async (stream) => {
|
|
640
|
-
const info = stream_info.get(stream);
|
|
641
|
-
const committed = await tombstone2(stream, info.version, correlation);
|
|
642
|
-
if (committed) {
|
|
643
|
-
guarded.push(stream);
|
|
644
|
-
guard_events.set(stream, { id: committed.id, stream });
|
|
645
|
-
} else {
|
|
646
|
-
skipped.push(stream);
|
|
647
|
-
}
|
|
648
|
-
})
|
|
649
|
-
);
|
|
650
|
-
return { guarded, guard_events };
|
|
651
|
-
}
|
|
652
|
-
async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
|
|
653
|
-
const seed_states = /* @__PURE__ */ new Map();
|
|
654
|
-
await Promise.all(
|
|
655
|
-
guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
|
|
656
|
-
const last_event_name = stream_info.get(stream).last_event_name;
|
|
657
|
-
const owner_state = event_to_state.get(last_event_name);
|
|
658
|
-
if (!owner_state) {
|
|
659
|
-
logger.error(
|
|
660
|
-
`Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
|
|
661
|
-
);
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
const snap2 = await load2(owner_state, { stream });
|
|
665
|
-
seed_states.set(stream, snap2.state);
|
|
666
|
-
})
|
|
667
|
-
);
|
|
668
|
-
return seed_states;
|
|
669
|
-
}
|
|
670
|
-
async function run_archive_callbacks(guarded, target_map) {
|
|
671
|
-
for (const stream of guarded) {
|
|
672
|
-
const archive_fn = target_map.get(stream)?.archive;
|
|
673
|
-
if (archive_fn) await archive_fn();
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
|
|
677
|
-
const trunc_targets = guarded.map((stream) => {
|
|
678
|
-
const snapshot = seed_states.get(stream);
|
|
679
|
-
const guard = guard_events.get(stream);
|
|
680
|
-
return {
|
|
681
|
-
stream,
|
|
682
|
-
snapshot,
|
|
683
|
-
meta: {
|
|
684
|
-
correlation,
|
|
685
|
-
causation: {
|
|
686
|
-
event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
};
|
|
690
|
-
});
|
|
691
|
-
const truncated = await store().truncate(trunc_targets);
|
|
692
|
-
await Promise.all(
|
|
693
|
-
guarded.map(async (stream) => {
|
|
694
|
-
const entry = truncated.get(stream);
|
|
695
|
-
const state2 = seed_states.get(stream);
|
|
696
|
-
if (state2 && entry) {
|
|
697
|
-
await cache().set(stream, {
|
|
698
|
-
state: state2,
|
|
699
|
-
version: entry.committed.version,
|
|
700
|
-
event_id: entry.committed.id,
|
|
701
|
-
patches: 0,
|
|
702
|
-
snaps: 1
|
|
703
|
-
});
|
|
704
|
-
} else {
|
|
705
|
-
await cache().invalidate(stream);
|
|
706
|
-
}
|
|
707
|
-
})
|
|
708
|
-
);
|
|
709
|
-
return truncated;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// src/internal/correlator.ts
|
|
713
|
-
import { randomInt } from "crypto";
|
|
714
|
-
var BASE = 36;
|
|
715
|
-
var SEG_WIDTH = 4;
|
|
716
|
-
var SEG_SPACE = BASE ** SEG_WIDTH;
|
|
717
|
-
function seg(n) {
|
|
718
|
-
return n.toString(BASE).padStart(SEG_WIDTH, "0");
|
|
719
|
-
}
|
|
720
|
-
var default_correlator = ({ state: state2, action: action2 }) => {
|
|
721
|
-
const s = state2.slice(0, SEG_WIDTH).toLowerCase();
|
|
722
|
-
const a = action2.slice(0, SEG_WIDTH).toLowerCase();
|
|
723
|
-
const ts = seg(Date.now() % SEG_SPACE);
|
|
724
|
-
const rnd = seg(randomInt(SEG_SPACE));
|
|
725
|
-
return `${s}-${a}-${ts}${rnd}`;
|
|
726
|
-
};
|
|
727
|
-
function close_correlation(correlator, actor) {
|
|
728
|
-
return correlator({
|
|
729
|
-
state: "$close",
|
|
730
|
-
action: "close",
|
|
731
|
-
stream: "$close",
|
|
732
|
-
actor
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// src/internal/autoclose-cycle.ts
|
|
737
|
-
function yield_between_batches(ms) {
|
|
738
|
-
if (ms <= 0) return Promise.resolve();
|
|
739
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
740
|
-
}
|
|
741
|
-
async function run_autoclose_cycle(deps) {
|
|
742
|
-
const s = store();
|
|
743
|
-
let inspected = 0;
|
|
744
|
-
let evaluated = 0;
|
|
745
|
-
let predicate_errors = 0;
|
|
746
|
-
const truncated = /* @__PURE__ */ new Map();
|
|
747
|
-
const skipped = [];
|
|
748
|
-
const page = deps.config.closeBatchSize;
|
|
749
|
-
let after;
|
|
750
|
-
for (; ; ) {
|
|
751
|
-
const stats = await s.query_stats(
|
|
752
|
-
{},
|
|
753
|
-
{ count: true, exclude: [TOMBSTONE_EVENT], after, limit: page }
|
|
754
|
-
);
|
|
755
|
-
if (stats.size === 0) break;
|
|
756
|
-
inspected += stats.size;
|
|
757
|
-
const candidates = [];
|
|
758
|
-
for (const [stream, { head, count }] of stats) {
|
|
759
|
-
const owner = deps.event_to_state.get(head.name);
|
|
760
|
-
if (!owner) continue;
|
|
761
|
-
const predicate = deps.autoclose_policy(owner.name);
|
|
762
|
-
if (!predicate) continue;
|
|
763
|
-
let close = false;
|
|
764
|
-
try {
|
|
765
|
-
close = predicate(stream, head, count ?? 0);
|
|
766
|
-
evaluated += 1;
|
|
767
|
-
} catch (err) {
|
|
768
|
-
predicate_errors += 1;
|
|
769
|
-
deps.logger.error(
|
|
770
|
-
`Autoclose predicate for state "${owner.name}" threw on stream "${stream}": ${err instanceof Error ? err.message : String(err)}`
|
|
771
|
-
);
|
|
772
|
-
close = deps.config.closeOnError;
|
|
773
|
-
}
|
|
774
|
-
if (!close) continue;
|
|
775
|
-
const archiver = deps.autoclose_archiver(owner.name);
|
|
776
|
-
const archive = archiver ? () => archiver(stream, head) : void 0;
|
|
777
|
-
candidates.push({ stream, archive });
|
|
778
|
-
}
|
|
779
|
-
if (candidates.length > 0) {
|
|
780
|
-
const result = await run_close_cycle(candidates, {
|
|
781
|
-
reactive_events_size: deps.reactive_events_size,
|
|
782
|
-
event_to_state: deps.event_to_state,
|
|
783
|
-
load: deps.load,
|
|
784
|
-
tombstone: deps.tombstone,
|
|
785
|
-
logger: deps.logger,
|
|
786
|
-
correlation: deps.correlation
|
|
787
|
-
});
|
|
788
|
-
for (const [stream, entry] of result.truncated)
|
|
789
|
-
truncated.set(stream, entry);
|
|
790
|
-
for (const stream of result.skipped) skipped.push(stream);
|
|
791
|
-
await yield_between_batches(deps.config.closeYieldMs);
|
|
792
|
-
}
|
|
793
|
-
if (stats.size < page) break;
|
|
794
|
-
after = [...stats.keys()].at(-1);
|
|
795
|
-
}
|
|
796
|
-
return {
|
|
797
|
-
inspected,
|
|
798
|
-
evaluated,
|
|
799
|
-
predicate_errors,
|
|
800
|
-
close_result: { truncated, skipped }
|
|
801
|
-
};
|
|
802
|
-
}
|
|
803
|
-
var AutocloseController = class {
|
|
804
|
-
deps;
|
|
805
|
-
_timer;
|
|
806
|
-
/**
|
|
807
|
-
* Reentrancy guard. The interval fires the next tick on cadence
|
|
808
|
-
* regardless of how long the previous one took; this flag drops
|
|
809
|
-
* overlapping ticks so the cycle stays sequential per controller.
|
|
810
|
-
*/
|
|
811
|
-
_running = false;
|
|
812
|
-
constructor(deps) {
|
|
813
|
-
this.deps = deps;
|
|
814
|
-
}
|
|
815
|
-
/**
|
|
816
|
-
* Start the cycle ticker. Returns `false` if already running so
|
|
817
|
-
* accidental double-start doesn't stack timers (matches
|
|
818
|
-
* `_correlate.start_polling` semantics).
|
|
819
|
-
*/
|
|
820
|
-
start() {
|
|
821
|
-
if (this._timer) return false;
|
|
822
|
-
this._timer = setInterval(() => {
|
|
823
|
-
this.run_once().catch(() => {
|
|
824
|
-
});
|
|
825
|
-
}, this.deps.config.autocloseCycleMinutes * 6e4);
|
|
826
|
-
this._timer.unref();
|
|
827
|
-
return true;
|
|
828
|
-
}
|
|
829
|
-
/**
|
|
830
|
-
* Stop the cycle ticker. Idempotent.
|
|
831
|
-
*/
|
|
832
|
-
stop() {
|
|
833
|
-
if (this._timer) {
|
|
834
|
-
clearInterval(this._timer);
|
|
835
|
-
this._timer = void 0;
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
/**
|
|
839
|
-
* Whether the ticker is currently running.
|
|
840
|
-
*/
|
|
841
|
-
get is_running() {
|
|
842
|
-
return this._timer !== void 0;
|
|
843
|
-
}
|
|
844
|
-
/**
|
|
845
|
-
* Run one cycle synchronously. The controller's interval callback
|
|
846
|
-
* delegates here; tests can invoke it directly to deterministically
|
|
847
|
-
* exercise the cycle without spinning real timers.
|
|
848
|
-
*
|
|
849
|
-
* Overlapping invocations short-circuit: if a previous tick is
|
|
850
|
-
* still running, this one drops and returns `null`. The orchestrator
|
|
851
|
-
* never sees a queue of pending ticks regardless of how slow the
|
|
852
|
-
* predicate / archive / truncate are.
|
|
853
|
-
*/
|
|
854
|
-
async run_once() {
|
|
855
|
-
if (this._running) return null;
|
|
856
|
-
if (this.deps.breaker.state(Date.now()) === "open") return null;
|
|
857
|
-
if (!in_autoclose_window(this.deps.config.autocloseWindow, /* @__PURE__ */ new Date()))
|
|
858
|
-
return null;
|
|
859
|
-
this._running = true;
|
|
860
|
-
try {
|
|
861
|
-
const correlation = close_correlation(
|
|
862
|
-
this.deps.correlator,
|
|
863
|
-
this.deps.close_actor
|
|
864
|
-
);
|
|
865
|
-
const result = await run_autoclose_cycle({
|
|
866
|
-
autoclose_policy: this.deps.autoclose_policy,
|
|
867
|
-
autoclose_archiver: this.deps.autoclose_archiver,
|
|
868
|
-
event_to_state: this.deps.event_to_state,
|
|
869
|
-
reactive_events_size: this.deps.reactive_events_size,
|
|
870
|
-
load: this.deps.load,
|
|
871
|
-
tombstone: this.deps.tombstone,
|
|
872
|
-
logger: this.deps.logger,
|
|
873
|
-
config: this.deps.config,
|
|
874
|
-
correlation
|
|
875
|
-
});
|
|
876
|
-
this.deps.breaker.passed();
|
|
877
|
-
if (result.close_result.truncated.size > 0) {
|
|
878
|
-
this.deps.on_closed(result.close_result);
|
|
879
|
-
}
|
|
880
|
-
return result;
|
|
881
|
-
} catch (error) {
|
|
882
|
-
this.deps.breaker.failed(Date.now(), error);
|
|
883
|
-
throw error;
|
|
884
|
-
} finally {
|
|
885
|
-
this._running = false;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
};
|
|
889
|
-
|
|
890
543
|
// src/internal/autoclose-policy.ts
|
|
891
544
|
import { z as z2 } from "zod";
|
|
892
545
|
var MIN_AFTER_MS = 6e4;
|
|
@@ -934,6 +587,13 @@ function compile_is(is) {
|
|
|
934
587
|
function compile_reaches(reaches) {
|
|
935
588
|
return (_stream, _head, count) => count >= reaches;
|
|
936
589
|
}
|
|
590
|
+
function policy_min_after_ms(options) {
|
|
591
|
+
const parsed = AutoclosePolicySchema.parse(options);
|
|
592
|
+
const windows = [];
|
|
593
|
+
if (parsed.after) windows.push(parsed.after.days * 864e5);
|
|
594
|
+
if (parsed.or?.after) windows.push(parsed.or.after.days * 864e5);
|
|
595
|
+
return windows.length ? Math.min(...windows) : void 0;
|
|
596
|
+
}
|
|
937
597
|
function compile_autoclose_policy(options) {
|
|
938
598
|
const parsed = AutoclosePolicySchema.parse(options);
|
|
939
599
|
const and_preds = [];
|
|
@@ -993,107 +653,304 @@ function classify_registry(registry, states) {
|
|
|
993
653
|
}
|
|
994
654
|
}
|
|
995
655
|
}
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
const event_to_state = /* @__PURE__ */ new Map();
|
|
999
|
-
for (const merged of states.values()) {
|
|
1000
|
-
for (const event_name of Object.keys(merged.events)) {
|
|
1001
|
-
event_to_state.set(event_name, merged);
|
|
1002
|
-
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const event_to_state = /* @__PURE__ */ new Map();
|
|
659
|
+
for (const merged of states.values()) {
|
|
660
|
+
for (const event_name of Object.keys(merged.events)) {
|
|
661
|
+
event_to_state.set(event_name, merged);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
static_targets: [...statics.values()],
|
|
666
|
+
has_dynamic_resolvers,
|
|
667
|
+
reactive_events,
|
|
668
|
+
event_to_state,
|
|
669
|
+
event_to_lanes
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/internal/circuit-breaker.ts
|
|
674
|
+
import { z as z3 } from "zod";
|
|
675
|
+
var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
|
|
676
|
+
var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
|
|
677
|
+
var CircuitBreakerOptionsSchema = z3.object({
|
|
678
|
+
failureThreshold: z3.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
|
|
679
|
+
cooldownMs: z3.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
|
|
680
|
+
});
|
|
681
|
+
var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
|
|
682
|
+
var CircuitBreaker = class {
|
|
683
|
+
_failures = 0;
|
|
684
|
+
_opened_at;
|
|
685
|
+
_wake;
|
|
686
|
+
_threshold;
|
|
687
|
+
_cooldown_ms;
|
|
688
|
+
_hooks;
|
|
689
|
+
constructor(config2, hooks = {}) {
|
|
690
|
+
this._threshold = config2.failureThreshold;
|
|
691
|
+
this._cooldown_ms = config2.cooldownMs;
|
|
692
|
+
this._hooks = hooks;
|
|
693
|
+
}
|
|
694
|
+
/** Current state given the wall-clock `now`. */
|
|
695
|
+
state(now) {
|
|
696
|
+
if (this._opened_at === void 0) return "closed";
|
|
697
|
+
return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
|
|
698
|
+
}
|
|
699
|
+
/** A store op passed — reset to closed and cancel any pending retry. */
|
|
700
|
+
passed() {
|
|
701
|
+
this._failures = 0;
|
|
702
|
+
this._opened_at = void 0;
|
|
703
|
+
this._clear_wake();
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* A store op failed. Opens the breaker when the consecutive-failure
|
|
707
|
+
* threshold is reached, or immediately re-opens (restarting the cooldown)
|
|
708
|
+
* if a half-open trial just failed. Returns the resulting state.
|
|
709
|
+
*
|
|
710
|
+
* Callers gate on the state directly — `state(now) === "open"` means skip.
|
|
711
|
+
* On opening, schedules the `on_retry` wake so recovery is automatic;
|
|
712
|
+
* always surfaces the failure via `on_error`.
|
|
713
|
+
*/
|
|
714
|
+
failed(now, error) {
|
|
715
|
+
const circuit = this._advance(now);
|
|
716
|
+
if (circuit === "open") this._schedule_wake();
|
|
717
|
+
this._hooks.on_error?.(error, circuit);
|
|
718
|
+
return circuit;
|
|
719
|
+
}
|
|
720
|
+
/** Cancel the pending retry timer. Idempotent; call on shutdown. */
|
|
721
|
+
stop() {
|
|
722
|
+
this._clear_wake();
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
|
|
726
|
+
* the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
|
|
727
|
+
* tests of the pure state machine). The timer is `unref`'d so it never
|
|
728
|
+
* keeps the process alive.
|
|
729
|
+
*/
|
|
730
|
+
_schedule_wake() {
|
|
731
|
+
if (!this._hooks.on_retry) return;
|
|
732
|
+
this._clear_wake();
|
|
733
|
+
this._wake = setTimeout(() => {
|
|
734
|
+
this._wake = void 0;
|
|
735
|
+
this._hooks.on_retry?.();
|
|
736
|
+
}, this._cooldown_ms);
|
|
737
|
+
this._wake.unref();
|
|
738
|
+
}
|
|
739
|
+
_clear_wake() {
|
|
740
|
+
if (this._wake) {
|
|
741
|
+
clearTimeout(this._wake);
|
|
742
|
+
this._wake = void 0;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/** Pure state transition for a failure — no side effects. */
|
|
746
|
+
_advance(now) {
|
|
747
|
+
if (this._opened_at !== void 0) {
|
|
748
|
+
this._opened_at = now;
|
|
749
|
+
return "open";
|
|
750
|
+
}
|
|
751
|
+
this._failures += 1;
|
|
752
|
+
if (this._failures >= this._threshold) {
|
|
753
|
+
this._opened_at = now;
|
|
754
|
+
return "open";
|
|
755
|
+
}
|
|
756
|
+
return "closed";
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
// src/internal/close-cycle.ts
|
|
761
|
+
var SAFETY_PROBE_PAGE_SIZE = 1e3;
|
|
762
|
+
async function run_close_cycle(targets, deps) {
|
|
763
|
+
const target_map = new Map(targets.map((t) => [t.stream, t]));
|
|
764
|
+
const streams = [...target_map.keys()];
|
|
765
|
+
const skipped = [];
|
|
766
|
+
const stream_info = await scan_stream_heads(streams);
|
|
767
|
+
const safe = await partition_by_safety(
|
|
768
|
+
stream_info,
|
|
769
|
+
deps.reactive_events_size,
|
|
770
|
+
skipped,
|
|
771
|
+
deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
|
|
772
|
+
);
|
|
773
|
+
if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
|
|
774
|
+
const { guarded, guard_events } = await guard_with_tombstones(
|
|
775
|
+
safe,
|
|
776
|
+
stream_info,
|
|
777
|
+
deps.correlation,
|
|
778
|
+
deps.tombstone,
|
|
779
|
+
skipped
|
|
780
|
+
);
|
|
781
|
+
if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
|
|
782
|
+
const seed_states = await load_restart_seeds(
|
|
783
|
+
guarded,
|
|
784
|
+
target_map,
|
|
785
|
+
stream_info,
|
|
786
|
+
deps.event_to_state,
|
|
787
|
+
deps.load,
|
|
788
|
+
deps.logger
|
|
789
|
+
);
|
|
790
|
+
await run_archive_callbacks(guarded, target_map);
|
|
791
|
+
const truncated = await truncate_and_warm_cache(
|
|
792
|
+
guarded,
|
|
793
|
+
seed_states,
|
|
794
|
+
guard_events,
|
|
795
|
+
deps.correlation
|
|
796
|
+
);
|
|
797
|
+
return { truncated, skipped };
|
|
798
|
+
}
|
|
799
|
+
async function scan_stream_heads(streams) {
|
|
800
|
+
const stats = await store().query_stats(streams, {
|
|
801
|
+
exclude: [SNAP_EVENT]
|
|
802
|
+
});
|
|
803
|
+
const out = /* @__PURE__ */ new Map();
|
|
804
|
+
for (const [stream, { head }] of stats) {
|
|
805
|
+
if (head.name === TOMBSTONE_EVENT) continue;
|
|
806
|
+
out.set(stream, {
|
|
807
|
+
max_id: head.id,
|
|
808
|
+
version: head.version,
|
|
809
|
+
last_event_name: head.name
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
return out;
|
|
813
|
+
}
|
|
814
|
+
async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
|
|
815
|
+
if (reactive_events_size === 0) return [...stream_info.keys()];
|
|
816
|
+
const pending_set = /* @__PURE__ */ new Set();
|
|
817
|
+
const source_regex = /* @__PURE__ */ new Map();
|
|
818
|
+
const get_regex = (source) => {
|
|
819
|
+
let re = source_regex.get(source);
|
|
820
|
+
if (!re) {
|
|
821
|
+
re = new RegExp(source);
|
|
822
|
+
source_regex.set(source, re);
|
|
823
|
+
}
|
|
824
|
+
return re;
|
|
825
|
+
};
|
|
826
|
+
const targets = [...stream_info.keys()];
|
|
827
|
+
let after;
|
|
828
|
+
for (; ; ) {
|
|
829
|
+
let last;
|
|
830
|
+
const { count } = await store().query_streams(
|
|
831
|
+
(position) => {
|
|
832
|
+
last = position.stream;
|
|
833
|
+
const source_re = position.source ? get_regex(position.source) : void 0;
|
|
834
|
+
for (const [stream, info] of stream_info) {
|
|
835
|
+
if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
|
|
836
|
+
pending_set.add(stream);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
{ after, limit: page_size, source_matches: targets }
|
|
841
|
+
);
|
|
842
|
+
if (count < page_size) break;
|
|
843
|
+
after = last;
|
|
844
|
+
}
|
|
845
|
+
const safe = [];
|
|
846
|
+
for (const [stream] of stream_info) {
|
|
847
|
+
if (pending_set.has(stream)) skipped.push(stream);
|
|
848
|
+
else safe.push(stream);
|
|
849
|
+
}
|
|
850
|
+
return safe;
|
|
851
|
+
}
|
|
852
|
+
async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
|
|
853
|
+
const guarded = [];
|
|
854
|
+
const guard_events = /* @__PURE__ */ new Map();
|
|
855
|
+
await Promise.all(
|
|
856
|
+
safe.map(async (stream) => {
|
|
857
|
+
const info = stream_info.get(stream);
|
|
858
|
+
const committed = await tombstone2(stream, info.version, correlation);
|
|
859
|
+
if (committed) {
|
|
860
|
+
guarded.push(stream);
|
|
861
|
+
guard_events.set(stream, { id: committed.id, stream });
|
|
862
|
+
} else {
|
|
863
|
+
skipped.push(stream);
|
|
864
|
+
}
|
|
865
|
+
})
|
|
866
|
+
);
|
|
867
|
+
return { guarded, guard_events };
|
|
868
|
+
}
|
|
869
|
+
async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
|
|
870
|
+
const seed_states = /* @__PURE__ */ new Map();
|
|
871
|
+
await Promise.all(
|
|
872
|
+
guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
|
|
873
|
+
const last_event_name = stream_info.get(stream).last_event_name;
|
|
874
|
+
const owner_state = event_to_state.get(last_event_name);
|
|
875
|
+
if (!owner_state) {
|
|
876
|
+
logger.error(
|
|
877
|
+
`Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
|
|
878
|
+
);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const snap2 = await load2(owner_state, { stream });
|
|
882
|
+
seed_states.set(stream, snap2.state);
|
|
883
|
+
})
|
|
884
|
+
);
|
|
885
|
+
return seed_states;
|
|
886
|
+
}
|
|
887
|
+
async function run_archive_callbacks(guarded, target_map) {
|
|
888
|
+
for (const stream of guarded) {
|
|
889
|
+
const archive_fn = target_map.get(stream)?.archive;
|
|
890
|
+
if (archive_fn) await archive_fn();
|
|
1003
891
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
892
|
+
}
|
|
893
|
+
async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
|
|
894
|
+
const trunc_targets = guarded.map((stream) => {
|
|
895
|
+
const snapshot = seed_states.get(stream);
|
|
896
|
+
const guard = guard_events.get(stream);
|
|
897
|
+
return {
|
|
898
|
+
stream,
|
|
899
|
+
snapshot,
|
|
900
|
+
meta: {
|
|
901
|
+
correlation,
|
|
902
|
+
causation: {
|
|
903
|
+
event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
});
|
|
908
|
+
const truncated = await store().truncate(trunc_targets);
|
|
909
|
+
await Promise.all(
|
|
910
|
+
guarded.map(async (stream) => {
|
|
911
|
+
const entry = truncated.get(stream);
|
|
912
|
+
const state2 = seed_states.get(stream);
|
|
913
|
+
if (state2 && entry) {
|
|
914
|
+
await cache().set(stream, {
|
|
915
|
+
state: state2,
|
|
916
|
+
version: entry.committed.version,
|
|
917
|
+
event_id: entry.committed.id,
|
|
918
|
+
patches: 0,
|
|
919
|
+
snaps: 1
|
|
920
|
+
});
|
|
921
|
+
} else {
|
|
922
|
+
await cache().invalidate(stream);
|
|
923
|
+
}
|
|
924
|
+
})
|
|
925
|
+
);
|
|
926
|
+
return truncated;
|
|
1011
927
|
}
|
|
1012
928
|
|
|
1013
|
-
// src/internal/
|
|
1014
|
-
|
|
1015
|
-
var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
|
|
1016
|
-
var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
|
|
1017
|
-
var CircuitBreakerOptionsSchema = z3.object({
|
|
1018
|
-
failureThreshold: z3.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
|
|
1019
|
-
cooldownMs: z3.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
|
|
1020
|
-
});
|
|
1021
|
-
var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
|
|
1022
|
-
var CircuitBreaker = class {
|
|
1023
|
-
_failures = 0;
|
|
1024
|
-
_opened_at;
|
|
1025
|
-
_wake;
|
|
1026
|
-
_threshold;
|
|
1027
|
-
_cooldown_ms;
|
|
1028
|
-
_hooks;
|
|
1029
|
-
constructor(config2, hooks = {}) {
|
|
1030
|
-
this._threshold = config2.failureThreshold;
|
|
1031
|
-
this._cooldown_ms = config2.cooldownMs;
|
|
1032
|
-
this._hooks = hooks;
|
|
1033
|
-
}
|
|
1034
|
-
/** Current state given the wall-clock `now`. */
|
|
1035
|
-
state(now) {
|
|
1036
|
-
if (this._opened_at === void 0) return "closed";
|
|
1037
|
-
return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
|
|
1038
|
-
}
|
|
1039
|
-
/** A store op passed — reset to closed and cancel any pending retry. */
|
|
1040
|
-
passed() {
|
|
1041
|
-
this._failures = 0;
|
|
1042
|
-
this._opened_at = void 0;
|
|
1043
|
-
this._clear_wake();
|
|
1044
|
-
}
|
|
929
|
+
// src/internal/close-signal.ts
|
|
930
|
+
var CloseSignal = class extends Error {
|
|
1045
931
|
/**
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
* Callers gate on the state directly — `state(now) === "open"` means skip.
|
|
1051
|
-
* On opening, schedules the `on_retry` wake so recovery is automatic;
|
|
1052
|
-
* always surfaces the failure via `on_error`.
|
|
932
|
+
* Stream to close. Omitted → the reaction's own lease stream (a self-close).
|
|
933
|
+
* The synthesized autoclose reaction sets it to the aggregate stream, since
|
|
934
|
+
* its lease runs on a synthetic `__autoclose__` target.
|
|
1053
935
|
*/
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
this._hooks.on_error?.(error, circuit);
|
|
1058
|
-
return circuit;
|
|
1059
|
-
}
|
|
1060
|
-
/** Cancel the pending retry timer. Idempotent; call on shutdown. */
|
|
1061
|
-
stop() {
|
|
1062
|
-
this._clear_wake();
|
|
1063
|
-
}
|
|
936
|
+
stream;
|
|
937
|
+
/** Optional archive callback to run while the stream is guarded. */
|
|
938
|
+
archive;
|
|
1064
939
|
/**
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
940
|
+
* Watermark to ack the requesting reaction to before the close runs. The
|
|
941
|
+
* close-cycle safety guard skips a stream whose subscriptions (matched by
|
|
942
|
+
* source) lag the head; the autoclose reaction's `source` is the aggregate,
|
|
943
|
+
* so it must advance its own watermark to the live head id it evaluated
|
|
944
|
+
* against — otherwise it blocks its own close. Defaults to the triggering
|
|
945
|
+
* event id when omitted.
|
|
1069
946
|
*/
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
this.
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
this._wake.unref();
|
|
1078
|
-
}
|
|
1079
|
-
_clear_wake() {
|
|
1080
|
-
if (this._wake) {
|
|
1081
|
-
clearTimeout(this._wake);
|
|
1082
|
-
this._wake = void 0;
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
/** Pure state transition for a failure — no side effects. */
|
|
1086
|
-
_advance(now) {
|
|
1087
|
-
if (this._opened_at !== void 0) {
|
|
1088
|
-
this._opened_at = now;
|
|
1089
|
-
return "open";
|
|
1090
|
-
}
|
|
1091
|
-
this._failures += 1;
|
|
1092
|
-
if (this._failures >= this._threshold) {
|
|
1093
|
-
this._opened_at = now;
|
|
1094
|
-
return "open";
|
|
1095
|
-
}
|
|
1096
|
-
return "closed";
|
|
947
|
+
at;
|
|
948
|
+
constructor(opts) {
|
|
949
|
+
super("reaction requested close");
|
|
950
|
+
this.name = "CloseSignal";
|
|
951
|
+
this.stream = opts?.stream;
|
|
952
|
+
this.archive = opts?.archive;
|
|
953
|
+
this.at = opts?.at;
|
|
1097
954
|
}
|
|
1098
955
|
};
|
|
1099
956
|
|
|
@@ -1225,9 +1082,122 @@ var CorrelateCycle = class {
|
|
|
1225
1082
|
}
|
|
1226
1083
|
};
|
|
1227
1084
|
|
|
1085
|
+
// src/internal/correlator.ts
|
|
1086
|
+
import { randomInt } from "crypto";
|
|
1087
|
+
var BASE = 36;
|
|
1088
|
+
var SEG_WIDTH = 4;
|
|
1089
|
+
var SEG_SPACE = BASE ** SEG_WIDTH;
|
|
1090
|
+
function seg(n) {
|
|
1091
|
+
return n.toString(BASE).padStart(SEG_WIDTH, "0");
|
|
1092
|
+
}
|
|
1093
|
+
var default_correlator = ({ state: state2, action: action2 }) => {
|
|
1094
|
+
const s = state2.slice(0, SEG_WIDTH).toLowerCase();
|
|
1095
|
+
const a = action2.slice(0, SEG_WIDTH).toLowerCase();
|
|
1096
|
+
const ts = seg(Date.now() % SEG_SPACE);
|
|
1097
|
+
const rnd = seg(randomInt(SEG_SPACE));
|
|
1098
|
+
return `${s}-${a}-${ts}${rnd}`;
|
|
1099
|
+
};
|
|
1100
|
+
function close_correlation(correlator, actor) {
|
|
1101
|
+
return correlator({
|
|
1102
|
+
state: "$close",
|
|
1103
|
+
action: "close",
|
|
1104
|
+
stream: "$close",
|
|
1105
|
+
actor
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// src/internal/defer-signal.ts
|
|
1110
|
+
var DeferSignal = class extends Error {
|
|
1111
|
+
/** Wall-clock time (ms since epoch) to re-visit the stream. */
|
|
1112
|
+
defer_at;
|
|
1113
|
+
constructor(defer_at) {
|
|
1114
|
+
super("reaction deferred");
|
|
1115
|
+
this.name = "DeferSignal";
|
|
1116
|
+
this.defer_at = defer_at;
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1228
1120
|
// src/internal/drain-cycle.ts
|
|
1229
1121
|
import { randomUUID } from "crypto";
|
|
1230
1122
|
|
|
1123
|
+
// src/internal/defer-timer.ts
|
|
1124
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
1125
|
+
var DeferTimer = class {
|
|
1126
|
+
_due = /* @__PURE__ */ new Map();
|
|
1127
|
+
_timer;
|
|
1128
|
+
_on_wake;
|
|
1129
|
+
/**
|
|
1130
|
+
* @param on_wake - invoked once each time the earliest due-time elapses,
|
|
1131
|
+
* after the come-due entries have been removed. Consumers use it to
|
|
1132
|
+
* re-arm their loop (the drain sets its `armed` flag; autoclose runs a
|
|
1133
|
+
* tick).
|
|
1134
|
+
*/
|
|
1135
|
+
constructor(on_wake) {
|
|
1136
|
+
this._on_wake = on_wake;
|
|
1137
|
+
}
|
|
1138
|
+
/** Number of currently parked streams. */
|
|
1139
|
+
get size() {
|
|
1140
|
+
return this._due.size;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* True while `stream` is parked with a due-time still in the future.
|
|
1144
|
+
* Consumers skip work for deferred streams until their window elapses.
|
|
1145
|
+
*/
|
|
1146
|
+
is_deferred = (stream) => {
|
|
1147
|
+
const next = this._due.get(stream);
|
|
1148
|
+
return next !== void 0 && next > Date.now();
|
|
1149
|
+
};
|
|
1150
|
+
/**
|
|
1151
|
+
* Park `stream` for a re-visit at `at` (ms since epoch). A plain
|
|
1152
|
+
* overwrite: the caller computes the authoritative next-visit for the
|
|
1153
|
+
* stream (the drain's `handle` already reconciles a stream's reactions
|
|
1154
|
+
* into one result per cycle), so there is no stale value to merge against.
|
|
1155
|
+
*/
|
|
1156
|
+
set(stream, at) {
|
|
1157
|
+
this._due.set(stream, at);
|
|
1158
|
+
}
|
|
1159
|
+
/** Drop `stream` from the parked set (e.g. on a successful ack or block). */
|
|
1160
|
+
delete(stream) {
|
|
1161
|
+
this._due.delete(stream);
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* (Re)schedule the wake timer at the earliest pending due-time. Idempotent
|
|
1165
|
+
* — collapses many parked streams into a single timer. A no-op clears any
|
|
1166
|
+
* pending timer when the map is empty.
|
|
1167
|
+
*
|
|
1168
|
+
* The timer is `unref()`-ed so pending re-visits never keep the process
|
|
1169
|
+
* alive on their own.
|
|
1170
|
+
*/
|
|
1171
|
+
schedule() {
|
|
1172
|
+
if (this._timer) clearTimeout(this._timer);
|
|
1173
|
+
if (this._due.size === 0) {
|
|
1174
|
+
this._timer = void 0;
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
1178
|
+
for (const t of this._due.values()) if (t < earliest) earliest = t;
|
|
1179
|
+
const delay = Math.min(
|
|
1180
|
+
Math.max(0, earliest - Date.now()),
|
|
1181
|
+
MAX_TIMER_DELAY_MS
|
|
1182
|
+
);
|
|
1183
|
+
this._timer = setTimeout(() => {
|
|
1184
|
+
this._timer = void 0;
|
|
1185
|
+
const now = Date.now();
|
|
1186
|
+
for (const [stream, at] of this._due)
|
|
1187
|
+
if (at <= now) this._due.delete(stream);
|
|
1188
|
+
this._on_wake();
|
|
1189
|
+
}, delay);
|
|
1190
|
+
this._timer.unref();
|
|
1191
|
+
}
|
|
1192
|
+
/** Cancel any pending wake timer. Idempotent. Leaves the parked set intact. */
|
|
1193
|
+
stop() {
|
|
1194
|
+
if (this._timer) {
|
|
1195
|
+
clearTimeout(this._timer);
|
|
1196
|
+
this._timer = void 0;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1231
1201
|
// src/internal/drain-ratio.ts
|
|
1232
1202
|
var RATIO_MIN = 0.2;
|
|
1233
1203
|
var RATIO_MAX = 0.8;
|
|
@@ -1263,6 +1233,7 @@ async function fetch(leased, eventLimit) {
|
|
|
1263
1233
|
}
|
|
1264
1234
|
var ack = (leases) => store().ack(leases);
|
|
1265
1235
|
var block = (leases) => store().block(leases);
|
|
1236
|
+
var defer = (input, deferred_at) => store().defer(input, deferred_at);
|
|
1266
1237
|
var subscribe = (streams) => store().subscribe(streams);
|
|
1267
1238
|
|
|
1268
1239
|
// src/internal/event-sourcing.ts
|
|
@@ -1740,6 +1711,7 @@ function build_drain(logger) {
|
|
|
1740
1711
|
fetch,
|
|
1741
1712
|
ack,
|
|
1742
1713
|
block,
|
|
1714
|
+
defer,
|
|
1743
1715
|
subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
|
|
1744
1716
|
if (!result.subscribed) return;
|
|
1745
1717
|
const lanes = new Set(streams.map((s) => s.lane ?? "default"));
|
|
@@ -1808,7 +1780,8 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
|
|
|
1808
1780
|
fetched: [],
|
|
1809
1781
|
handled: [],
|
|
1810
1782
|
acked: [],
|
|
1811
|
-
blocked: []
|
|
1783
|
+
blocked: [],
|
|
1784
|
+
closeable: []
|
|
1812
1785
|
};
|
|
1813
1786
|
}
|
|
1814
1787
|
const fetched = await ops.fetch(active, eventLimit);
|
|
@@ -1842,12 +1815,23 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
|
|
|
1842
1815
|
})
|
|
1843
1816
|
);
|
|
1844
1817
|
const acked = await ops.ack(
|
|
1845
|
-
handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
|
|
1818
|
+
handled.filter((h) => h.defer === void 0 && (h.handled > 0 || !h.error)).map((h) => ({ ...h.lease, at: h.acked_at }))
|
|
1846
1819
|
);
|
|
1847
1820
|
const blocked = await ops.block(
|
|
1848
1821
|
handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
|
|
1849
1822
|
);
|
|
1850
|
-
|
|
1823
|
+
const deferrals = handled.filter((h) => h.defer !== void 0);
|
|
1824
|
+
if (deferrals.length) {
|
|
1825
|
+
const by_time = /* @__PURE__ */ new Map();
|
|
1826
|
+
for (const h of deferrals) {
|
|
1827
|
+
const list = by_time.get(h.defer);
|
|
1828
|
+
if (list) list.push(h.lease.stream);
|
|
1829
|
+
else by_time.set(h.defer, [h.lease.stream]);
|
|
1830
|
+
}
|
|
1831
|
+
for (const [at, streams] of by_time) await ops.defer(streams, at);
|
|
1832
|
+
}
|
|
1833
|
+
const closeable = handled.filter((h) => h.close !== void 0).map((h) => h.close);
|
|
1834
|
+
return { leased, fetched, handled, acked, blocked, closeable };
|
|
1851
1835
|
}
|
|
1852
1836
|
var EMPTY_DRAIN = {
|
|
1853
1837
|
fetched: [],
|
|
@@ -1860,14 +1844,16 @@ var DrainController = class {
|
|
|
1860
1844
|
_locked = false;
|
|
1861
1845
|
_ratio = 0.5;
|
|
1862
1846
|
/**
|
|
1863
|
-
* Per-stream
|
|
1864
|
-
*
|
|
1865
|
-
* ack or terminal block. Lives in
|
|
1866
|
-
* by design (see {@link BackoffOptions}
|
|
1847
|
+
* Per-stream re-visit schedule (#1090): `stream → next visit` (ms since
|
|
1848
|
+
* epoch). Holds both retry backoff (`HandleResult.next_attempt_at`) and the
|
|
1849
|
+
* `defer` outcome; cleared on successful ack or terminal block. Lives in
|
|
1850
|
+
* process memory — per-worker pacing by design (see {@link BackoffOptions}
|
|
1851
|
+
* for the multi-worker trade-off). Its wake re-arms drain at the earliest
|
|
1852
|
+
* pending visit.
|
|
1867
1853
|
*/
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1854
|
+
_defer = new DeferTimer(() => {
|
|
1855
|
+
this._armed = true;
|
|
1856
|
+
});
|
|
1871
1857
|
/** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
|
|
1872
1858
|
_worker;
|
|
1873
1859
|
_stopped = false;
|
|
@@ -1887,32 +1873,6 @@ var DrainController = class {
|
|
|
1887
1873
|
get armed() {
|
|
1888
1874
|
return this._armed;
|
|
1889
1875
|
}
|
|
1890
|
-
/** Returns true when `stream` is currently within a backoff window. */
|
|
1891
|
-
is_deferred = (stream) => {
|
|
1892
|
-
const next = this._backoff.get(stream);
|
|
1893
|
-
return next !== void 0 && next > Date.now();
|
|
1894
|
-
};
|
|
1895
|
-
/**
|
|
1896
|
-
* Schedule the next drain re-arm at the earliest pending backoff
|
|
1897
|
-
* expiry. Called only when the backoff map is non-empty (caller guard).
|
|
1898
|
-
* Idempotent — collapses many simultaneously deferred streams into a
|
|
1899
|
-
* single timer.
|
|
1900
|
-
*/
|
|
1901
|
-
schedule_backoff_wake() {
|
|
1902
|
-
if (this._backoff_timer) clearTimeout(this._backoff_timer);
|
|
1903
|
-
let earliest = Number.POSITIVE_INFINITY;
|
|
1904
|
-
for (const t of this._backoff.values()) if (t < earliest) earliest = t;
|
|
1905
|
-
const delay = Math.max(0, earliest - Date.now());
|
|
1906
|
-
this._backoff_timer = setTimeout(() => {
|
|
1907
|
-
this._backoff_timer = void 0;
|
|
1908
|
-
const now = Date.now();
|
|
1909
|
-
for (const [stream, at] of this._backoff) {
|
|
1910
|
-
if (at <= now) this._backoff.delete(stream);
|
|
1911
|
-
}
|
|
1912
|
-
this._armed = true;
|
|
1913
|
-
}, delay);
|
|
1914
|
-
this._backoff_timer.unref();
|
|
1915
|
-
}
|
|
1916
1876
|
/** Lane this controller drains (undefined = legacy single-lane span). */
|
|
1917
1877
|
get lane() {
|
|
1918
1878
|
return this._deps.lane;
|
|
@@ -1944,6 +1904,7 @@ var DrainController = class {
|
|
|
1944
1904
|
clearTimeout(this._worker);
|
|
1945
1905
|
this._worker = void 0;
|
|
1946
1906
|
}
|
|
1907
|
+
this._defer.stop();
|
|
1947
1908
|
}
|
|
1948
1909
|
/** Run one drain pass. Short-circuits when not armed or already running. */
|
|
1949
1910
|
async drain(options = {}) {
|
|
@@ -1969,7 +1930,7 @@ var DrainController = class {
|
|
|
1969
1930
|
leading,
|
|
1970
1931
|
eventLimit,
|
|
1971
1932
|
leaseMillis,
|
|
1972
|
-
this.
|
|
1933
|
+
this._defer.size > 0 ? this._defer.is_deferred : void 0,
|
|
1973
1934
|
this._deps.lane
|
|
1974
1935
|
);
|
|
1975
1936
|
this._deps.breaker.passed();
|
|
@@ -1977,19 +1938,19 @@ var DrainController = class {
|
|
|
1977
1938
|
this._armed = false;
|
|
1978
1939
|
return EMPTY_DRAIN;
|
|
1979
1940
|
}
|
|
1980
|
-
const { leased, fetched, handled, acked, blocked } = cycle;
|
|
1941
|
+
const { leased, fetched, handled, acked, blocked, closeable } = cycle;
|
|
1981
1942
|
trace_cycle(this._deps.logger, leased, fetched, handled, acked, blocked);
|
|
1982
1943
|
this._ratio = compute_lag_lead_ratio(handled, lagging, leading);
|
|
1983
|
-
for (const lease of acked) this.
|
|
1984
|
-
for (const lease of blocked) this.
|
|
1944
|
+
for (const lease of acked) this._defer.delete(lease.stream);
|
|
1945
|
+
for (const lease of blocked) this._defer.delete(lease.stream);
|
|
1985
1946
|
for (const h of handled) {
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
}
|
|
1947
|
+
const next = h.defer ?? (h.block ? void 0 : h.next_attempt_at);
|
|
1948
|
+
if (next !== void 0) this._defer.set(h.lease.stream, next);
|
|
1989
1949
|
}
|
|
1990
|
-
if (this.
|
|
1950
|
+
if (this._defer.size > 0) this._defer.schedule();
|
|
1991
1951
|
if (acked.length) this._deps.on_acked(acked);
|
|
1992
1952
|
if (blocked.length) this._deps.on_blocked(blocked);
|
|
1953
|
+
if (closeable.length) await this._deps.on_close(closeable);
|
|
1993
1954
|
const has_errors = handled.some(({ error }) => error);
|
|
1994
1955
|
if (!acked.length && !blocked.length && !has_errors) this._armed = false;
|
|
1995
1956
|
return { fetched, leased, acked, blocked };
|
|
@@ -2205,6 +2166,20 @@ function build_handle(deps) {
|
|
|
2205
2166
|
at = event.id;
|
|
2206
2167
|
handled++;
|
|
2207
2168
|
} catch (error) {
|
|
2169
|
+
if (error instanceof DeferSignal)
|
|
2170
|
+
return { lease, handled, acked_at: at, defer: error.defer_at };
|
|
2171
|
+
if (error instanceof CloseSignal)
|
|
2172
|
+
return {
|
|
2173
|
+
lease,
|
|
2174
|
+
handled: handled + 1,
|
|
2175
|
+
// Advance to the live head the handler evaluated against (when
|
|
2176
|
+
// provided) so the close-cycle guard sees this reaction caught up.
|
|
2177
|
+
acked_at: error.at ?? event.id,
|
|
2178
|
+
// Close the signalled stream (the autoclose reaction's aggregate,
|
|
2179
|
+
// which differs from its synthetic lease stream); a self-closing
|
|
2180
|
+
// user reaction omits it and closes its own lease stream.
|
|
2181
|
+
close: { stream: error.stream ?? stream, archive: error.archive }
|
|
2182
|
+
};
|
|
2208
2183
|
return finalize(
|
|
2209
2184
|
lease,
|
|
2210
2185
|
handled,
|
|
@@ -2307,6 +2282,7 @@ var SettleLoop = class {
|
|
|
2307
2282
|
// src/act.ts
|
|
2308
2283
|
var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
|
|
2309
2284
|
var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
|
|
2285
|
+
var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
|
|
2310
2286
|
var Act = class {
|
|
2311
2287
|
_emitter = new EventEmitter();
|
|
2312
2288
|
/** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
|
|
@@ -2348,15 +2324,6 @@ var Act = class {
|
|
|
2348
2324
|
* @internal
|
|
2349
2325
|
*/
|
|
2350
2326
|
_autoclose_config;
|
|
2351
|
-
/**
|
|
2352
|
-
* App-level autoclose controller. `undefined` when no state declares
|
|
2353
|
-
* `.autocloses(...)` — the controller is never constructed, so
|
|
2354
|
-
* `start_correlations()` / `stop_correlations()` skip the autoclose
|
|
2355
|
-
* lifecycle and the orchestrator pays zero per-tick cost.
|
|
2356
|
-
*
|
|
2357
|
-
* @internal
|
|
2358
|
-
*/
|
|
2359
|
-
_autoclose;
|
|
2360
2327
|
/**
|
|
2361
2328
|
* Emit a lifecycle event. The payload type is inferred from the event name
|
|
2362
2329
|
* via {@link ActLifecycleEvents}.
|
|
@@ -2497,6 +2464,60 @@ var Act = class {
|
|
|
2497
2464
|
bound_forget: this._bound_forget
|
|
2498
2465
|
});
|
|
2499
2466
|
this._handle_batch = build_handle_batch(this._logger);
|
|
2467
|
+
for (const st of this._states.values()) {
|
|
2468
|
+
const predicate = st.autoclose;
|
|
2469
|
+
if (!predicate) continue;
|
|
2470
|
+
const after_ms = st.autoclose_after_ms;
|
|
2471
|
+
const archiver = st.archive;
|
|
2472
|
+
const reaction = {
|
|
2473
|
+
// Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
|
|
2474
|
+
// per-aggregate `__autoclose__` key — so the autoclose reaction never
|
|
2475
|
+
// shares a watermark with the aggregate's own reactions. A shared
|
|
2476
|
+
// watermark would let autoclose's defer short-circuit the aggregate's
|
|
2477
|
+
// other reactions (the "a defer affects all reactions on a stream"
|
|
2478
|
+
// hazard). The close still targets the aggregate (`source`).
|
|
2479
|
+
resolver: (e) => ({
|
|
2480
|
+
target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
|
|
2481
|
+
source: e.stream
|
|
2482
|
+
}),
|
|
2483
|
+
// Never block on autoclose: a transient query/store error should retry,
|
|
2484
|
+
// not quarantine the synthetic stream.
|
|
2485
|
+
options: { blockOnError: false, maxRetries: 3 },
|
|
2486
|
+
handler: async (event) => {
|
|
2487
|
+
const aggregate = event.stream;
|
|
2488
|
+
const config2 = this._autoclose_config;
|
|
2489
|
+
if (!in_autoclose_window(config2.autocloseWindow, /* @__PURE__ */ new Date()))
|
|
2490
|
+
throw new DeferSignal(
|
|
2491
|
+
Date.now() + config2.autocloseCycleMinutes * 6e4
|
|
2492
|
+
);
|
|
2493
|
+
const stats = await store().query_stats([aggregate], {
|
|
2494
|
+
count: true,
|
|
2495
|
+
exclude: [TOMBSTONE_EVENT]
|
|
2496
|
+
});
|
|
2497
|
+
const entry = stats.get(aggregate);
|
|
2498
|
+
if (!entry) return;
|
|
2499
|
+
const head = entry.head;
|
|
2500
|
+
if (predicate(aggregate, head, entry.count))
|
|
2501
|
+
throw new CloseSignal({
|
|
2502
|
+
stream: aggregate,
|
|
2503
|
+
archive: archiver ? () => archiver(aggregate, head) : void 0,
|
|
2504
|
+
// Ack this reaction's own watermark to the live head so the
|
|
2505
|
+
// close-cycle guard (which matches subscriptions by source =
|
|
2506
|
+
// aggregate) sees it caught up instead of blocking its own close.
|
|
2507
|
+
at: head.id
|
|
2508
|
+
});
|
|
2509
|
+
if (after_ms !== void 0)
|
|
2510
|
+
throw new DeferSignal(head.created.getTime() + after_ms);
|
|
2511
|
+
}
|
|
2512
|
+
};
|
|
2513
|
+
const key = `__autoclose_${st.name}`;
|
|
2514
|
+
for (const event_name of Object.keys(st.events)) {
|
|
2515
|
+
this.registry.events[event_name]?.reactions.set(
|
|
2516
|
+
key,
|
|
2517
|
+
reaction
|
|
2518
|
+
);
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2500
2521
|
const {
|
|
2501
2522
|
static_targets,
|
|
2502
2523
|
has_dynamic_resolvers,
|
|
@@ -2545,6 +2566,22 @@ var Act = class {
|
|
|
2545
2566
|
handle_batch: this._handle_batch,
|
|
2546
2567
|
on_acked: (acked) => this.emit("acked", acked),
|
|
2547
2568
|
on_blocked: (blocked) => this.emit("blocked", blocked),
|
|
2569
|
+
// Reaction-requested close (#1090). Runs the same close machinery as
|
|
2570
|
+
// `app.close` (tombstone guard + archive + atomic truncate) for the
|
|
2571
|
+
// targets a handler signalled via `CloseSignal`. No `correlate()` here
|
|
2572
|
+
// — the drain that produced these targets has already correlated.
|
|
2573
|
+
on_close: async (targets) => {
|
|
2574
|
+
const close_actor = { id: "$close", name: "close" };
|
|
2575
|
+
const result = await run_close_cycle(targets, {
|
|
2576
|
+
reactive_events_size: this._reactive_events.size,
|
|
2577
|
+
event_to_state: this._event_to_state,
|
|
2578
|
+
load: this._es.load,
|
|
2579
|
+
tombstone: this._es.tombstone,
|
|
2580
|
+
logger: this._logger,
|
|
2581
|
+
correlation: close_correlation(this._correlator, close_actor)
|
|
2582
|
+
});
|
|
2583
|
+
this.emit("closed", result);
|
|
2584
|
+
},
|
|
2548
2585
|
breaker: this._breaker,
|
|
2549
2586
|
// Pass lane only when a true per-lane controller is active.
|
|
2550
2587
|
// The all-lanes (single default) case keeps lane=undefined so
|
|
@@ -2591,27 +2628,6 @@ var Act = class {
|
|
|
2591
2628
|
},
|
|
2592
2629
|
options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
|
|
2593
2630
|
);
|
|
2594
|
-
const states_with_policy = [...this._states.values()].filter(
|
|
2595
|
-
(s) => s.autoclose
|
|
2596
|
-
);
|
|
2597
|
-
this._autoclose = states_with_policy.length ? new AutocloseController({
|
|
2598
|
-
// Internal cycle takes the loose `(string) => …` signature; the
|
|
2599
|
-
// public `registry.autoclose_policy` narrows to
|
|
2600
|
-
// `keyof TStateMap & string`. Thin lambdas bridge across the
|
|
2601
|
-
// narrowing — runtime semantics are unchanged.
|
|
2602
|
-
autoclose_policy: (name) => this.registry.autoclose_policy(name),
|
|
2603
|
-
autoclose_archiver: (name) => this.registry.autoclose_archiver(name),
|
|
2604
|
-
event_to_state: this._event_to_state,
|
|
2605
|
-
reactive_events_size: this._reactive_events.size,
|
|
2606
|
-
load: this._es.load,
|
|
2607
|
-
tombstone: this._es.tombstone,
|
|
2608
|
-
logger: this._logger,
|
|
2609
|
-
config: this._autoclose_config,
|
|
2610
|
-
correlator: this._correlator,
|
|
2611
|
-
close_actor: { id: "$autoclose", name: "autoclose" },
|
|
2612
|
-
on_closed: (result) => this.emit("closed", result),
|
|
2613
|
-
breaker: this._breaker
|
|
2614
|
-
}) : void 0;
|
|
2615
2631
|
this._notify_disposer = this._wire_notify(options.scoped?.store ?? store());
|
|
2616
2632
|
dispose(() => this.shutdown());
|
|
2617
2633
|
}
|
|
@@ -3105,7 +3121,6 @@ var Act = class {
|
|
|
3105
3121
|
*/
|
|
3106
3122
|
start_correlations(query = {}, frequency = 1e4, callback) {
|
|
3107
3123
|
const started = this._correlate.start_polling(query, frequency, callback);
|
|
3108
|
-
this._autoclose?.start();
|
|
3109
3124
|
return started;
|
|
3110
3125
|
}
|
|
3111
3126
|
/**
|
|
@@ -3127,7 +3142,6 @@ var Act = class {
|
|
|
3127
3142
|
*/
|
|
3128
3143
|
stop_correlations() {
|
|
3129
3144
|
this._correlate.stop_polling();
|
|
3130
|
-
this._autoclose?.stop();
|
|
3131
3145
|
}
|
|
3132
3146
|
/**
|
|
3133
3147
|
* Cancels any pending or active settle cycle.
|
|
@@ -3930,20 +3944,21 @@ function action_builder(state2) {
|
|
|
3930
3944
|
internal.disclose = disclose;
|
|
3931
3945
|
return builder;
|
|
3932
3946
|
},
|
|
3933
|
-
autocloses(
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
} else if (predicate_or_policy !== null && typeof predicate_or_policy === "object") {
|
|
3938
|
-
predicate = compile_autoclose_policy(
|
|
3939
|
-
predicate_or_policy
|
|
3947
|
+
autocloses(policy) {
|
|
3948
|
+
if (typeof policy === "function") {
|
|
3949
|
+
throw new Error(
|
|
3950
|
+
".autocloses(fn) is no longer supported \u2014 pass a declarative policy ({ after, is, reaches, or }) or call app.close(...) from your own reaction for custom logic."
|
|
3940
3951
|
);
|
|
3941
|
-
}
|
|
3952
|
+
}
|
|
3953
|
+
if (policy === null || typeof policy !== "object") {
|
|
3942
3954
|
throw new Error(
|
|
3943
|
-
".autocloses(...) requires a
|
|
3955
|
+
".autocloses(...) requires a policy object; got " + typeof policy
|
|
3944
3956
|
);
|
|
3945
3957
|
}
|
|
3946
|
-
internal.autoclose =
|
|
3958
|
+
internal.autoclose = compile_autoclose_policy(
|
|
3959
|
+
policy
|
|
3960
|
+
);
|
|
3961
|
+
internal.autoclose_after_ms = policy_min_after_ms(policy);
|
|
3947
3962
|
return builder;
|
|
3948
3963
|
},
|
|
3949
3964
|
archives(archive) {
|