@rotorsoft/act 1.27.0 → 1.29.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.cjs CHANGED
@@ -1028,19 +1028,7 @@ var InMemoryStore = class {
1028
1028
  */
1029
1029
  async claim(lagging, leading, by, millis, lane) {
1030
1030
  await sleep();
1031
- const has_work = (s) => {
1032
- if (s.correlated_at !== void 0) return s.at < s.correlated_at;
1033
- if (!s.source) return s.at < this._max_non_snap_event_id;
1034
- if (is_literal_source(s.source)) {
1035
- const max_id = this._max_event_id_by_stream.get(s.source);
1036
- return max_id !== void 0 && max_id > s.at;
1037
- }
1038
- const re = new RegExp(s.source);
1039
- for (const [stream_name, max_id] of this._max_event_id_by_stream) {
1040
- if (max_id > s.at && re.test(stream_name)) return true;
1041
- }
1042
- return false;
1043
- };
1031
+ const has_work = (s) => s.correlated_at !== void 0 && s.at < s.correlated_at;
1044
1032
  const available = [...this._streams.values()].filter(
1045
1033
  (s) => s.is_available && has_work(s) && (lane === void 0 || s.lane === lane)
1046
1034
  );
@@ -1329,7 +1317,8 @@ var InMemoryStore = class {
1329
1317
  leased_by: s.leased_by,
1330
1318
  leased_until: s.leased_until,
1331
1319
  lane: s.lane,
1332
- deferred_at: s.deferred_at
1320
+ deferred_at: s.deferred_at,
1321
+ correlated_at: s.correlated_at
1333
1322
  });
1334
1323
  count++;
1335
1324
  if (count >= limit) break;
@@ -2418,12 +2407,10 @@ function classify_registry(registry, states) {
2418
2407
  const target_lanes = /* @__PURE__ */ new Map();
2419
2408
  const reactive_events = /* @__PURE__ */ new Set();
2420
2409
  const event_to_lanes = /* @__PURE__ */ new Map();
2421
- let has_dynamic_resolvers = false;
2422
2410
  for (const [name, register] of Object.entries(registry.events)) {
2423
2411
  if (register.reactions.size > 0) reactive_events.add(name);
2424
2412
  for (const reaction of register.reactions.values()) {
2425
2413
  if (typeof reaction.resolver === "function") {
2426
- has_dynamic_resolvers = true;
2427
2414
  event_to_lanes.set(name, ALL_LANES);
2428
2415
  } else {
2429
2416
  const { target, source, priority = 0, lane } = reaction.resolver;
@@ -2459,7 +2446,6 @@ function classify_registry(registry, states) {
2459
2446
  }
2460
2447
  return {
2461
2448
  static_targets: [...statics.values()],
2462
- has_dynamic_resolvers,
2463
2449
  reactive_events,
2464
2450
  event_to_state,
2465
2451
  event_to_lanes
@@ -2943,7 +2929,8 @@ async function run_close_cycle(targets, deps) {
2943
2929
  stream_info,
2944
2930
  deps.reactive_events_size,
2945
2931
  skipped,
2946
- deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
2932
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE,
2933
+ deps.catch_up_correlation
2947
2934
  );
2948
2935
  if (!safe.length) return { truncated: windowed_result, skipped };
2949
2936
  const { guarded, guard_events } = await guard_with_tombstones(
@@ -3062,8 +3049,16 @@ async function scan_stream_heads(streams) {
3062
3049
  }
3063
3050
  return out;
3064
3051
  }
3065
- async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
3052
+ async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size, catch_up_correlation) {
3066
3053
  if (reactive_events_size === 0) return [...stream_info.keys()];
3054
+ let needed = -1;
3055
+ for (const info of stream_info.values())
3056
+ needed = Math.max(needed, info.max_id);
3057
+ const checkpoint = await catch_up_correlation(needed);
3058
+ const uncorrelated = /* @__PURE__ */ new Set();
3059
+ for (const [stream, info] of stream_info) {
3060
+ if (checkpoint < info.max_id) uncorrelated.add(stream);
3061
+ }
3067
3062
  const pending_set = /* @__PURE__ */ new Set();
3068
3063
  const source_regex = /* @__PURE__ */ new Map();
3069
3064
  const get_regex = (source) => {
@@ -3082,6 +3077,8 @@ async function partition_by_safety(stream_info, reactive_events_size, skipped, p
3082
3077
  (position) => {
3083
3078
  last = position.stream;
3084
3079
  const source_re = position.source ? get_regex(position.source) : void 0;
3080
+ const has_work = position.correlated_at !== void 0 && position.at < position.correlated_at;
3081
+ if (!has_work) return;
3085
3082
  for (const [stream, info] of stream_info) {
3086
3083
  if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
3087
3084
  pending_set.add(stream);
@@ -3095,7 +3092,8 @@ async function partition_by_safety(stream_info, reactive_events_size, skipped, p
3095
3092
  }
3096
3093
  const safe = [];
3097
3094
  for (const [stream] of stream_info) {
3098
- if (pending_set.has(stream)) skipped.push(stream);
3095
+ if (pending_set.has(stream) || uncorrelated.has(stream))
3096
+ skipped.push(stream);
3099
3097
  else safe.push(stream);
3100
3098
  }
3101
3099
  return safe;
@@ -3185,21 +3183,24 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
3185
3183
 
3186
3184
  // src/internal/correlate-cycle.ts
3187
3185
  var DEFAULT_COLD_START_BACK_SCAN = 1e4;
3186
+ var PATTERN_CACHE_SIZE = 32;
3188
3187
  var CorrelateCycle = class {
3189
3188
  _checkpoint = -1;
3190
3189
  _initialized = false;
3191
3190
  /** In-flight init, memoized for single-flight and cleared on failure. */
3192
3191
  _init_promise;
3193
3192
  _timer = void 0;
3194
- // target → the priority it was last subscribed at. A dynamic target is
3195
- // re-subscribed (so the store's `GREATEST` upsert runs) only when a later
3196
- // scan resolves a *higher* priority for it — the runtime `max()` invariant
3197
- // (#1363). Static targets are recorded at +Infinity so a dynamic resolution
3198
- // never re-opens them (their priority is owned by the build-time subscribe).
3193
+ // target → what it was last subscribed at. Every scan re-subscribes the
3194
+ // targets it resolved (that is how the work mark lands), so this no longer
3195
+ // decides *whether* a target is sent — it decides *what* is sent with it:
3196
+ // a resolution raises priority/lane only when it beats the recorded floor,
3197
+ // and otherwise re-sends the row's own values so the mark changes nothing
3198
+ // else. See {@link Subscription}.
3199
3199
  _subscribed;
3200
+ /** Compiled pattern sources, bounded by {@link PATTERN_CACHE_SIZE}. */
3201
+ _patterns = new LruMap(PATTERN_CACHE_SIZE);
3200
3202
  _registry;
3201
3203
  _static_targets;
3202
- _has_dynamic_resolvers;
3203
3204
  _cd;
3204
3205
  _on_init;
3205
3206
  /**
@@ -3229,7 +3230,6 @@ var CorrelateCycle = class {
3229
3230
  constructor({
3230
3231
  registry,
3231
3232
  static_targets,
3232
- has_dynamic_resolvers,
3233
3233
  cd,
3234
3234
  max_subscribed_streams,
3235
3235
  run_scoped,
@@ -3240,7 +3240,6 @@ var CorrelateCycle = class {
3240
3240
  this._subscribed = new LruMap(max_subscribed_streams);
3241
3241
  this._registry = registry;
3242
3242
  this._static_targets = static_targets;
3243
- this._has_dynamic_resolvers = has_dynamic_resolvers;
3244
3243
  this._cd = cd;
3245
3244
  this._on_init = on_init;
3246
3245
  this._run_scoped = run_scoped;
@@ -3251,19 +3250,10 @@ var CorrelateCycle = class {
3251
3250
  get checkpoint() {
3252
3251
  return this._checkpoint;
3253
3252
  }
3254
- /**
3255
- * Whether any reaction uses a dynamic resolver — i.e. whether `correlate`
3256
- * performs a store scan rather than the static no-op early-return. The
3257
- * settle loop reads this to avoid recording a fictitious circuit-breaker
3258
- * success on a static app whose correlate never touches the store (#1329).
3259
- */
3260
- get has_dynamic_resolvers() {
3261
- return this._has_dynamic_resolvers;
3262
- }
3263
3253
  /**
3264
3254
  * Initialize correlation state on first call.
3265
- * - Reads max(at) from store, then floors the cold-start checkpoint at
3266
- * `watermark - back_scan` when dynamic resolvers exist, so an event
3255
+ * - Reads the durable correlate checkpoint (and max(at)) from the store,
3256
+ * flooring a first boot at `watermark - back_scan` so an event
3267
3257
  * committed-but-not-correlated before a crash is re-scanned on
3268
3258
  * restart instead of skipped (ACT-1207)
3269
3259
  * - Subscribes static resolver targets (idempotent upsert)
@@ -3285,10 +3275,14 @@ var CorrelateCycle = class {
3285
3275
  const { watermark, correlated_at } = await store2().subscribe([
3286
3276
  ...this._static_targets
3287
3277
  ]);
3288
- this._checkpoint = !this._has_dynamic_resolvers ? watermark : correlated_at >= 0 ? correlated_at : Math.max(-1, watermark - this._cold_start_back_scan);
3278
+ this._checkpoint = correlated_at >= 0 ? correlated_at : Math.max(-1, watermark - this._cold_start_back_scan);
3289
3279
  this._on_init?.();
3290
- for (const { stream } of this._static_targets) {
3291
- this._subscribed.set(stream, Number.POSITIVE_INFINITY);
3280
+ for (const { stream, priority = 0, lane } of this._static_targets) {
3281
+ this._subscribed.set(stream, {
3282
+ floor: Number.POSITIVE_INFINITY,
3283
+ priority,
3284
+ lane
3285
+ });
3292
3286
  }
3293
3287
  await this._on_init_async?.();
3294
3288
  }
@@ -3313,14 +3307,34 @@ var CorrelateCycle = class {
3313
3307
  }
3314
3308
  }
3315
3309
  /**
3316
- * Discover dynamic-resolver targets in the events past the checkpoint
3317
- * and register any new streams via `cd.subscribe`. Static targets are
3318
- * subscribed at init time, so this only walks dynamic resolvers.
3310
+ * Would an event from `stream` be fetched for a target subscribed with
3311
+ * `source`? The subscription's source is the filter `fetch` queries with
3312
+ * — literal names by equality, patterns compiled as a `RegExp` — so an
3313
+ * event outside it is not work for that target and must not mark it.
3314
+ * No source means the target consumes every stream.
3315
+ */
3316
+ _in_fetch_window(source, stream) {
3317
+ if (source === void 0 || source === stream) return true;
3318
+ if (is_literal_source(source)) return false;
3319
+ let pattern = this._patterns.get(source);
3320
+ if (!pattern) {
3321
+ pattern = new RegExp(source);
3322
+ this._patterns.set(source, pattern);
3323
+ }
3324
+ return pattern.test(stream);
3325
+ }
3326
+ /**
3327
+ * Scan the events past the checkpoint, resolve each to its target
3328
+ * streams, and record what it found through `cd.subscribe` — new dynamic
3329
+ * targets get registered, and every target an event resolved to gets its
3330
+ * **work mark** raised to that event's id (#1487).
3331
+ *
3332
+ * Both resolver kinds are walked. A static target is already subscribed
3333
+ * at init, but marking it is what makes it claimable without probing the
3334
+ * event log, so the scan runs for every app.
3319
3335
  */
3320
3336
  async correlate(query = { after: -1, limit: 10 }) {
3321
3337
  await this.init();
3322
- if (!this._has_dynamic_resolvers)
3323
- return { subscribed: 0, last_id: this._checkpoint };
3324
3338
  const after = Math.max(this._checkpoint, query.after || -1);
3325
3339
  const correlated = /* @__PURE__ */ new Map();
3326
3340
  let last_id = after;
@@ -3330,52 +3344,61 @@ var CorrelateCycle = class {
3330
3344
  const register = this._registry.events[event.name];
3331
3345
  if (register) {
3332
3346
  for (const reaction of register.reactions.values()) {
3333
- if (typeof reaction.resolver !== "function") continue;
3334
- const resolved = reaction.resolver(event);
3335
- if (resolved && (resolved.priority ?? 0) > (this._subscribed.get(resolved.target) ?? Number.NEGATIVE_INFINITY)) {
3336
- const incoming_priority = resolved.priority ?? 0;
3337
- const entry = correlated.get(resolved.target) || {
3338
- source: resolved.source,
3339
- priority: incoming_priority,
3340
- lane: resolved.lane,
3341
- payloads: []
3342
- };
3343
- if (incoming_priority > entry.priority) {
3344
- entry.priority = incoming_priority;
3345
- entry.lane = resolved.lane;
3346
- }
3347
- entry.payloads.push({
3348
- ...reaction,
3349
- source: resolved.source,
3350
- event
3351
- });
3352
- correlated.set(resolved.target, entry);
3347
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
3348
+ if (!resolved) continue;
3349
+ const recorded = this._subscribed.get(resolved.target);
3350
+ const priority = resolved.priority ?? 0;
3351
+ const upgraded = !recorded || priority > recorded.floor;
3352
+ const carried = upgraded ? { priority, lane: resolved.lane } : { priority: recorded.priority, lane: recorded.lane };
3353
+ const entry = correlated.get(resolved.target) || {
3354
+ source: resolved.source,
3355
+ priority: carried.priority,
3356
+ lane: carried.lane,
3357
+ upgraded,
3358
+ correlated_at: void 0
3359
+ };
3360
+ if (carried.priority > entry.priority) {
3361
+ entry.priority = carried.priority;
3362
+ entry.lane = carried.lane;
3363
+ entry.upgraded = upgraded;
3353
3364
  }
3365
+ if (this._in_fetch_window(resolved.source, event.stream))
3366
+ entry.correlated_at = event.id;
3367
+ correlated.set(resolved.target, entry);
3354
3368
  }
3355
3369
  }
3356
3370
  },
3357
3371
  { ...query, after }
3358
3372
  );
3359
- if (correlated.size) {
3360
- const streams = [...correlated.entries()].map(
3361
- ([stream, { source, priority, lane }]) => ({
3373
+ const streams = [];
3374
+ for (const [stream, entry] of correlated) {
3375
+ if (entry.upgraded || entry.correlated_at !== void 0)
3376
+ streams.push({
3362
3377
  stream,
3363
- source,
3364
- priority,
3365
- lane
3366
- })
3367
- );
3378
+ source: entry.source,
3379
+ priority: entry.priority,
3380
+ lane: entry.lane,
3381
+ correlated_at: entry.correlated_at
3382
+ });
3383
+ }
3384
+ if (streams.length) {
3368
3385
  const { subscribed } = await this._cd.subscribe(streams, last_id);
3386
+ const marked = streams.filter(
3387
+ (entry) => entry.correlated_at !== void 0
3388
+ ).length;
3369
3389
  this._checkpoint = last_id;
3370
- if (subscribed) {
3371
- for (const { stream, priority } of streams) {
3372
- this._subscribed.set(stream, priority);
3373
- }
3390
+ for (const { stream, priority, lane } of streams) {
3391
+ if (correlated.get(stream)?.upgraded)
3392
+ this._subscribed.set(stream, {
3393
+ floor: priority,
3394
+ priority,
3395
+ lane
3396
+ });
3374
3397
  }
3375
- return { subscribed, last_id };
3398
+ return { subscribed, last_id, marked };
3376
3399
  }
3377
3400
  this._checkpoint = last_id;
3378
- return { subscribed: 0, last_id };
3401
+ return { subscribed: 0, last_id, marked: 0 };
3379
3402
  }
3380
3403
  /**
3381
3404
  * Start a periodic correlation worker. Returns false if one is already
@@ -4678,7 +4701,7 @@ var SettleLoop = class {
4678
4701
  ...correlate_query,
4679
4702
  after: after_before
4680
4703
  });
4681
- if (this._deps.correlate_probes_store) this._deps.breaker.passed();
4704
+ this._deps.breaker.passed();
4682
4705
  const drain = await this._deps.drain(drain_options);
4683
4706
  settled_drain = settled_drain ? {
4684
4707
  fetched: [...settled_drain.fetched, ...drain.fetched],
@@ -4724,6 +4747,8 @@ var SettleLoop = class {
4724
4747
 
4725
4748
  // src/act.ts
4726
4749
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
4750
+ var CLOSE_CATCH_UP_LIMIT = 1e3;
4751
+ var CLOSE_CATCH_UP_PASSES = 20;
4727
4752
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
4728
4753
  function validate_only_lanes(options, lanes) {
4729
4754
  if (!options.onlyLanes || options.onlyLanes.length === 0) return;
@@ -5039,6 +5064,7 @@ var Act = class {
5039
5064
  const close_actor = { id: "$close", name: "close" };
5040
5065
  const result = await run_close_cycle(targets, {
5041
5066
  reactive_events_size: this._reactive_events.size,
5067
+ catch_up_correlation: (until) => this._catch_up_correlation(until),
5042
5068
  event_to_state: this._event_to_state,
5043
5069
  load: this._es.load,
5044
5070
  tombstone: this._es.tombstone,
@@ -5125,7 +5151,6 @@ var Act = class {
5125
5151
  return new CorrelateCycle({
5126
5152
  registry: this.registry,
5127
5153
  static_targets: classification.static_targets,
5128
- has_dynamic_resolvers: classification.has_dynamic_resolvers,
5129
5154
  cd: this._cd,
5130
5155
  max_subscribed_streams: options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
5131
5156
  on_init: () => {
@@ -5176,11 +5201,7 @@ var Act = class {
5176
5201
  correlate: (q) => this.correlate(q),
5177
5202
  drain: (o) => this.drain(o),
5178
5203
  on_settled: (drain) => this.emit("settled", drain),
5179
- breaker: this._breaker,
5180
- // Static-reaction apps' correlate is a no-op (no store call), so a
5181
- // settle pass carries no store-health signal — don't let it record a
5182
- // breaker success (#1329).
5183
- correlate_probes_store: this._correlate.has_dynamic_resolvers
5204
+ breaker: this._breaker
5184
5205
  },
5185
5206
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
5186
5207
  );
@@ -5553,8 +5574,13 @@ var Act = class {
5553
5574
  * Drain uses a dual-frontier strategy to balance processing of new streams (lagging)
5554
5575
  * vs active streams (leading). The ratio adapts based on event pressure.
5555
5576
  *
5556
- * Call `correlate()` before `drain()` to discover target streams. For a higher-level
5557
- * API that handles debouncing, correlation, and signaling automatically, use {@link settle}.
5577
+ * Call `correlate()` before `drain()`. It is not only how dynamic targets
5578
+ * are discovered: a stream is claimable while `at < correlated_at`, and
5579
+ * `correlate` is the only component that raises that mark (#1487), so a
5580
+ * commit no correlate has seen is not drainable — including for static
5581
+ * targets, which were served by a probe of the event log before. For a
5582
+ * higher-level API that handles debouncing, correlation, and signaling
5583
+ * automatically, use {@link settle}.
5558
5584
  *
5559
5585
  * @param options - Drain configuration — see {@link DrainOptions} for fields
5560
5586
  * (`streamLimit`, `eventLimit`, `leaseMillis`).
@@ -5683,10 +5709,10 @@ var Act = class {
5683
5709
  async correlate(query = { after: -1, limit: 10 }) {
5684
5710
  if (!this._drain) return { subscribed: 0, last_id: -1 };
5685
5711
  return this._scoped(async () => {
5686
- const result = await this._correlate.correlate(query);
5687
- if (result.subscribed > 0 && this._reactive_events.size > 0)
5712
+ const { subscribed, last_id, marked } = await this._correlate.correlate(query);
5713
+ if ((subscribed > 0 || marked > 0) && this._reactive_events.size > 0)
5688
5714
  this._arm_all();
5689
- return result;
5715
+ return { subscribed, last_id };
5690
5716
  });
5691
5717
  }
5692
5718
  /**
@@ -6104,6 +6130,26 @@ var Act = class {
6104
6130
  * removed, so a later correlate can re-subscribe it (#1398). Restart
6105
6131
  * targets keep their row, so only fully-retired streams are forgotten.
6106
6132
  */
6133
+ /**
6134
+ * Advance correlation until the read cursor reaches `until`, and report
6135
+ * where it landed (#1487). Used by the close cycle's safety probe, which
6136
+ * cannot judge a subscription's pending work over events correlate has
6137
+ * not resolved yet.
6138
+ *
6139
+ * Bounded on both ends: it stops as soon as a pass makes no progress (the
6140
+ * log has no more to give), and after {@link CLOSE_CATCH_UP_PASSES}
6141
+ * windows, so a close behind an enormous backlog degrades to skipping the
6142
+ * stream — the documented retryable outcome — instead of scanning the
6143
+ * whole log inside an operator call.
6144
+ */
6145
+ async _catch_up_correlation(until) {
6146
+ for (let pass = 0; pass < CLOSE_CATCH_UP_PASSES && this._correlate.checkpoint < until; pass++) {
6147
+ const before = this._correlate.checkpoint;
6148
+ await this.correlate({ limit: CLOSE_CATCH_UP_LIMIT });
6149
+ if (this._correlate.checkpoint <= before) break;
6150
+ }
6151
+ return this._correlate.checkpoint;
6152
+ }
6107
6153
  _forget_closed_subscriptions(result) {
6108
6154
  const retired = [...result.truncated.entries()].filter(([, r]) => r.committed.name === TOMBSTONE_EVENT).map(([stream]) => stream);
6109
6155
  if (retired.length) this._correlate.forget_subscribed(retired);
@@ -6115,6 +6161,7 @@ var Act = class {
6115
6161
  const close_actor = { id: "$close", name: "close" };
6116
6162
  const result = await run_close_cycle(targets, {
6117
6163
  reactive_events_size: this._reactive_events.size,
6164
+ catch_up_correlation: (until) => this._catch_up_correlation(until),
6118
6165
  event_to_state: this._event_to_state,
6119
6166
  load: this._es.load,
6120
6167
  tombstone: this._es.tombstone,