@rotorsoft/act 1.27.0 → 1.28.1

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
@@ -1329,7 +1329,8 @@ var InMemoryStore = class {
1329
1329
  leased_by: s.leased_by,
1330
1330
  leased_until: s.leased_until,
1331
1331
  lane: s.lane,
1332
- deferred_at: s.deferred_at
1332
+ deferred_at: s.deferred_at,
1333
+ correlated_at: s.correlated_at
1333
1334
  });
1334
1335
  count++;
1335
1336
  if (count >= limit) break;
@@ -2418,12 +2419,10 @@ function classify_registry(registry, states) {
2418
2419
  const target_lanes = /* @__PURE__ */ new Map();
2419
2420
  const reactive_events = /* @__PURE__ */ new Set();
2420
2421
  const event_to_lanes = /* @__PURE__ */ new Map();
2421
- let has_dynamic_resolvers = false;
2422
2422
  for (const [name, register] of Object.entries(registry.events)) {
2423
2423
  if (register.reactions.size > 0) reactive_events.add(name);
2424
2424
  for (const reaction of register.reactions.values()) {
2425
2425
  if (typeof reaction.resolver === "function") {
2426
- has_dynamic_resolvers = true;
2427
2426
  event_to_lanes.set(name, ALL_LANES);
2428
2427
  } else {
2429
2428
  const { target, source, priority = 0, lane } = reaction.resolver;
@@ -2459,7 +2458,6 @@ function classify_registry(registry, states) {
2459
2458
  }
2460
2459
  return {
2461
2460
  static_targets: [...statics.values()],
2462
- has_dynamic_resolvers,
2463
2461
  reactive_events,
2464
2462
  event_to_state,
2465
2463
  event_to_lanes
@@ -2943,7 +2941,8 @@ async function run_close_cycle(targets, deps) {
2943
2941
  stream_info,
2944
2942
  deps.reactive_events_size,
2945
2943
  skipped,
2946
- deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
2944
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE,
2945
+ deps.catch_up_correlation
2947
2946
  );
2948
2947
  if (!safe.length) return { truncated: windowed_result, skipped };
2949
2948
  const { guarded, guard_events } = await guard_with_tombstones(
@@ -3062,8 +3061,16 @@ async function scan_stream_heads(streams) {
3062
3061
  }
3063
3062
  return out;
3064
3063
  }
3065
- async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
3064
+ async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size, catch_up_correlation) {
3066
3065
  if (reactive_events_size === 0) return [...stream_info.keys()];
3066
+ let needed = -1;
3067
+ for (const info of stream_info.values())
3068
+ needed = Math.max(needed, info.max_id);
3069
+ const checkpoint = await catch_up_correlation(needed);
3070
+ const uncorrelated = /* @__PURE__ */ new Set();
3071
+ for (const [stream, info] of stream_info) {
3072
+ if (checkpoint < info.max_id) uncorrelated.add(stream);
3073
+ }
3067
3074
  const pending_set = /* @__PURE__ */ new Set();
3068
3075
  const source_regex = /* @__PURE__ */ new Map();
3069
3076
  const get_regex = (source) => {
@@ -3082,6 +3089,8 @@ async function partition_by_safety(stream_info, reactive_events_size, skipped, p
3082
3089
  (position) => {
3083
3090
  last = position.stream;
3084
3091
  const source_re = position.source ? get_regex(position.source) : void 0;
3092
+ const has_work = position.correlated_at === void 0 || position.at < position.correlated_at;
3093
+ if (!has_work) return;
3085
3094
  for (const [stream, info] of stream_info) {
3086
3095
  if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
3087
3096
  pending_set.add(stream);
@@ -3095,7 +3104,8 @@ async function partition_by_safety(stream_info, reactive_events_size, skipped, p
3095
3104
  }
3096
3105
  const safe = [];
3097
3106
  for (const [stream] of stream_info) {
3098
- if (pending_set.has(stream)) skipped.push(stream);
3107
+ if (pending_set.has(stream) || uncorrelated.has(stream))
3108
+ skipped.push(stream);
3099
3109
  else safe.push(stream);
3100
3110
  }
3101
3111
  return safe;
@@ -3185,21 +3195,24 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
3185
3195
 
3186
3196
  // src/internal/correlate-cycle.ts
3187
3197
  var DEFAULT_COLD_START_BACK_SCAN = 1e4;
3198
+ var PATTERN_CACHE_SIZE = 32;
3188
3199
  var CorrelateCycle = class {
3189
3200
  _checkpoint = -1;
3190
3201
  _initialized = false;
3191
3202
  /** In-flight init, memoized for single-flight and cleared on failure. */
3192
3203
  _init_promise;
3193
3204
  _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).
3205
+ // target → what it was last subscribed at. Every scan re-subscribes the
3206
+ // targets it resolved (that is how the work mark lands), so this no longer
3207
+ // decides *whether* a target is sent — it decides *what* is sent with it:
3208
+ // a resolution raises priority/lane only when it beats the recorded floor,
3209
+ // and otherwise re-sends the row's own values so the mark changes nothing
3210
+ // else. See {@link Subscription}.
3199
3211
  _subscribed;
3212
+ /** Compiled pattern sources, bounded by {@link PATTERN_CACHE_SIZE}. */
3213
+ _patterns = new LruMap(PATTERN_CACHE_SIZE);
3200
3214
  _registry;
3201
3215
  _static_targets;
3202
- _has_dynamic_resolvers;
3203
3216
  _cd;
3204
3217
  _on_init;
3205
3218
  /**
@@ -3229,7 +3242,6 @@ var CorrelateCycle = class {
3229
3242
  constructor({
3230
3243
  registry,
3231
3244
  static_targets,
3232
- has_dynamic_resolvers,
3233
3245
  cd,
3234
3246
  max_subscribed_streams,
3235
3247
  run_scoped,
@@ -3240,7 +3252,6 @@ var CorrelateCycle = class {
3240
3252
  this._subscribed = new LruMap(max_subscribed_streams);
3241
3253
  this._registry = registry;
3242
3254
  this._static_targets = static_targets;
3243
- this._has_dynamic_resolvers = has_dynamic_resolvers;
3244
3255
  this._cd = cd;
3245
3256
  this._on_init = on_init;
3246
3257
  this._run_scoped = run_scoped;
@@ -3251,19 +3262,10 @@ var CorrelateCycle = class {
3251
3262
  get checkpoint() {
3252
3263
  return this._checkpoint;
3253
3264
  }
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
3265
  /**
3264
3266
  * 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
3267
+ * - Reads the durable correlate checkpoint (and max(at)) from the store,
3268
+ * flooring a first boot at `watermark - back_scan` so an event
3267
3269
  * committed-but-not-correlated before a crash is re-scanned on
3268
3270
  * restart instead of skipped (ACT-1207)
3269
3271
  * - Subscribes static resolver targets (idempotent upsert)
@@ -3285,10 +3287,14 @@ var CorrelateCycle = class {
3285
3287
  const { watermark, correlated_at } = await store2().subscribe([
3286
3288
  ...this._static_targets
3287
3289
  ]);
3288
- this._checkpoint = !this._has_dynamic_resolvers ? watermark : correlated_at >= 0 ? correlated_at : Math.max(-1, watermark - this._cold_start_back_scan);
3290
+ this._checkpoint = correlated_at >= 0 ? correlated_at : Math.max(-1, watermark - this._cold_start_back_scan);
3289
3291
  this._on_init?.();
3290
- for (const { stream } of this._static_targets) {
3291
- this._subscribed.set(stream, Number.POSITIVE_INFINITY);
3292
+ for (const { stream, priority = 0, lane } of this._static_targets) {
3293
+ this._subscribed.set(stream, {
3294
+ floor: Number.POSITIVE_INFINITY,
3295
+ priority,
3296
+ lane
3297
+ });
3292
3298
  }
3293
3299
  await this._on_init_async?.();
3294
3300
  }
@@ -3313,14 +3319,34 @@ var CorrelateCycle = class {
3313
3319
  }
3314
3320
  }
3315
3321
  /**
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.
3322
+ * Would an event from `stream` be fetched for a target subscribed with
3323
+ * `source`? The subscription's source is the filter `fetch` queries with
3324
+ * — literal names by equality, patterns compiled as a `RegExp` — so an
3325
+ * event outside it is not work for that target and must not mark it.
3326
+ * No source means the target consumes every stream.
3327
+ */
3328
+ _in_fetch_window(source, stream) {
3329
+ if (source === void 0 || source === stream) return true;
3330
+ if (is_literal_source(source)) return false;
3331
+ let pattern = this._patterns.get(source);
3332
+ if (!pattern) {
3333
+ pattern = new RegExp(source);
3334
+ this._patterns.set(source, pattern);
3335
+ }
3336
+ return pattern.test(stream);
3337
+ }
3338
+ /**
3339
+ * Scan the events past the checkpoint, resolve each to its target
3340
+ * streams, and record what it found through `cd.subscribe` — new dynamic
3341
+ * targets get registered, and every target an event resolved to gets its
3342
+ * **work mark** raised to that event's id (#1487).
3343
+ *
3344
+ * Both resolver kinds are walked. A static target is already subscribed
3345
+ * at init, but marking it is what makes it claimable without probing the
3346
+ * event log, so the scan runs for every app.
3319
3347
  */
3320
3348
  async correlate(query = { after: -1, limit: 10 }) {
3321
3349
  await this.init();
3322
- if (!this._has_dynamic_resolvers)
3323
- return { subscribed: 0, last_id: this._checkpoint };
3324
3350
  const after = Math.max(this._checkpoint, query.after || -1);
3325
3351
  const correlated = /* @__PURE__ */ new Map();
3326
3352
  let last_id = after;
@@ -3330,47 +3356,53 @@ var CorrelateCycle = class {
3330
3356
  const register = this._registry.events[event.name];
3331
3357
  if (register) {
3332
3358
  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);
3359
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
3360
+ if (!resolved) continue;
3361
+ const recorded = this._subscribed.get(resolved.target);
3362
+ const priority = resolved.priority ?? 0;
3363
+ const upgraded = !recorded || priority > recorded.floor;
3364
+ const carried = upgraded ? { priority, lane: resolved.lane } : { priority: recorded.priority, lane: recorded.lane };
3365
+ const entry = correlated.get(resolved.target) || {
3366
+ source: resolved.source,
3367
+ priority: carried.priority,
3368
+ lane: carried.lane,
3369
+ upgraded,
3370
+ correlated_at: void 0
3371
+ };
3372
+ if (carried.priority > entry.priority) {
3373
+ entry.priority = carried.priority;
3374
+ entry.lane = carried.lane;
3375
+ entry.upgraded = upgraded;
3353
3376
  }
3377
+ if (this._in_fetch_window(resolved.source, event.stream))
3378
+ entry.correlated_at = event.id;
3379
+ correlated.set(resolved.target, entry);
3354
3380
  }
3355
3381
  }
3356
3382
  },
3357
3383
  { ...query, after }
3358
3384
  );
3359
- if (correlated.size) {
3360
- const streams = [...correlated.entries()].map(
3361
- ([stream, { source, priority, lane }]) => ({
3385
+ const streams = [];
3386
+ for (const [stream, entry] of correlated) {
3387
+ if (entry.upgraded || entry.correlated_at !== void 0)
3388
+ streams.push({
3362
3389
  stream,
3363
- source,
3364
- priority,
3365
- lane
3366
- })
3367
- );
3390
+ source: entry.source,
3391
+ priority: entry.priority,
3392
+ lane: entry.lane,
3393
+ correlated_at: entry.correlated_at
3394
+ });
3395
+ }
3396
+ if (streams.length) {
3368
3397
  const { subscribed } = await this._cd.subscribe(streams, last_id);
3369
3398
  this._checkpoint = last_id;
3370
- if (subscribed) {
3371
- for (const { stream, priority } of streams) {
3372
- this._subscribed.set(stream, priority);
3373
- }
3399
+ for (const { stream, priority, lane } of streams) {
3400
+ if (correlated.get(stream)?.upgraded)
3401
+ this._subscribed.set(stream, {
3402
+ floor: priority,
3403
+ priority,
3404
+ lane
3405
+ });
3374
3406
  }
3375
3407
  return { subscribed, last_id };
3376
3408
  }
@@ -4678,7 +4710,7 @@ var SettleLoop = class {
4678
4710
  ...correlate_query,
4679
4711
  after: after_before
4680
4712
  });
4681
- if (this._deps.correlate_probes_store) this._deps.breaker.passed();
4713
+ this._deps.breaker.passed();
4682
4714
  const drain = await this._deps.drain(drain_options);
4683
4715
  settled_drain = settled_drain ? {
4684
4716
  fetched: [...settled_drain.fetched, ...drain.fetched],
@@ -4724,6 +4756,8 @@ var SettleLoop = class {
4724
4756
 
4725
4757
  // src/act.ts
4726
4758
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
4759
+ var CLOSE_CATCH_UP_LIMIT = 1e3;
4760
+ var CLOSE_CATCH_UP_PASSES = 20;
4727
4761
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
4728
4762
  function validate_only_lanes(options, lanes) {
4729
4763
  if (!options.onlyLanes || options.onlyLanes.length === 0) return;
@@ -5039,6 +5073,7 @@ var Act = class {
5039
5073
  const close_actor = { id: "$close", name: "close" };
5040
5074
  const result = await run_close_cycle(targets, {
5041
5075
  reactive_events_size: this._reactive_events.size,
5076
+ catch_up_correlation: (until) => this._catch_up_correlation(until),
5042
5077
  event_to_state: this._event_to_state,
5043
5078
  load: this._es.load,
5044
5079
  tombstone: this._es.tombstone,
@@ -5125,7 +5160,6 @@ var Act = class {
5125
5160
  return new CorrelateCycle({
5126
5161
  registry: this.registry,
5127
5162
  static_targets: classification.static_targets,
5128
- has_dynamic_resolvers: classification.has_dynamic_resolvers,
5129
5163
  cd: this._cd,
5130
5164
  max_subscribed_streams: options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
5131
5165
  on_init: () => {
@@ -5176,11 +5210,7 @@ var Act = class {
5176
5210
  correlate: (q) => this.correlate(q),
5177
5211
  drain: (o) => this.drain(o),
5178
5212
  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
5213
+ breaker: this._breaker
5184
5214
  },
5185
5215
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
5186
5216
  );
@@ -5553,8 +5583,13 @@ var Act = class {
5553
5583
  * Drain uses a dual-frontier strategy to balance processing of new streams (lagging)
5554
5584
  * vs active streams (leading). The ratio adapts based on event pressure.
5555
5585
  *
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}.
5586
+ * Call `correlate()` before `drain()`. It is not only how dynamic targets
5587
+ * are discovered: a stream is claimable while `at < correlated_at`, and
5588
+ * `correlate` is the only component that raises that mark (#1487), so a
5589
+ * commit no correlate has seen is not drainable — including for static
5590
+ * targets, which were served by a probe of the event log before. For a
5591
+ * higher-level API that handles debouncing, correlation, and signaling
5592
+ * automatically, use {@link settle}.
5558
5593
  *
5559
5594
  * @param options - Drain configuration — see {@link DrainOptions} for fields
5560
5595
  * (`streamLimit`, `eventLimit`, `leaseMillis`).
@@ -6104,6 +6139,26 @@ var Act = class {
6104
6139
  * removed, so a later correlate can re-subscribe it (#1398). Restart
6105
6140
  * targets keep their row, so only fully-retired streams are forgotten.
6106
6141
  */
6142
+ /**
6143
+ * Advance correlation until the read cursor reaches `until`, and report
6144
+ * where it landed (#1487). Used by the close cycle's safety probe, which
6145
+ * cannot judge a subscription's pending work over events correlate has
6146
+ * not resolved yet.
6147
+ *
6148
+ * Bounded on both ends: it stops as soon as a pass makes no progress (the
6149
+ * log has no more to give), and after {@link CLOSE_CATCH_UP_PASSES}
6150
+ * windows, so a close behind an enormous backlog degrades to skipping the
6151
+ * stream — the documented retryable outcome — instead of scanning the
6152
+ * whole log inside an operator call.
6153
+ */
6154
+ async _catch_up_correlation(until) {
6155
+ for (let pass = 0; pass < CLOSE_CATCH_UP_PASSES && this._correlate.checkpoint < until; pass++) {
6156
+ const before = this._correlate.checkpoint;
6157
+ await this.correlate({ limit: CLOSE_CATCH_UP_LIMIT });
6158
+ if (this._correlate.checkpoint <= before) break;
6159
+ }
6160
+ return this._correlate.checkpoint;
6161
+ }
6107
6162
  _forget_closed_subscriptions(result) {
6108
6163
  const retired = [...result.truncated.entries()].filter(([, r]) => r.committed.name === TOMBSTONE_EVENT).map(([stream]) => stream);
6109
6164
  if (retired.length) this._correlate.forget_subscribed(retired);
@@ -6115,6 +6170,7 @@ var Act = class {
6115
6170
  const close_actor = { id: "$close", name: "close" };
6116
6171
  const result = await run_close_cycle(targets, {
6117
6172
  reactive_events_size: this._reactive_events.size,
6173
+ catch_up_correlation: (until) => this._catch_up_correlation(until),
6118
6174
  event_to_state: this._event_to_state,
6119
6175
  load: this._es.load,
6120
6176
  tombstone: this._es.tombstone,