@rotorsoft/act 1.26.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
@@ -637,6 +637,10 @@ var InMemoryStream = class {
637
637
  // re-claimed (and `retry` is never bumped) until its due-time passes. Unlike
638
638
  // in-process backoff, this is durable store state shared across workers.
639
639
  _deferred_at = void 0;
640
+ // Work mark (#1485): highest event id observed to resolve to this target.
641
+ // `undefined` means UNKNOWN — the row predates the mark, so `claim` falls
642
+ // back to the legacy has-work probe rather than treating it as "no work".
643
+ _correlated_at = void 0;
640
644
  constructor(stream, source, priority = 0, lane = DEFAULT_LANE) {
641
645
  this.stream = stream;
642
646
  this.source = source;
@@ -660,6 +664,17 @@ var InMemoryStream = class {
660
664
  bump_priority(priority) {
661
665
  if (priority > this._priority) this._priority = priority;
662
666
  }
667
+ get correlated_at() {
668
+ return this._correlated_at;
669
+ }
670
+ /**
671
+ * Raise the work mark via {@link subscribe}: keeps the maximum, for every
672
+ * value including zero and negatives, so a mark never regresses.
673
+ */
674
+ mark(correlated_at) {
675
+ if (this._correlated_at === void 0 || correlated_at > this._correlated_at)
676
+ this._correlated_at = correlated_at;
677
+ }
663
678
  /**
664
679
  * Set the priority outright via {@link prioritize}: operator
665
680
  * runtime override that ignores the build-time `max()` invariant.
@@ -1014,6 +1029,7 @@ var InMemoryStore = class {
1014
1029
  async claim(lagging, leading, by, millis, lane) {
1015
1030
  await sleep();
1016
1031
  const has_work = (s) => {
1032
+ if (s.correlated_at !== void 0) return s.at < s.correlated_at;
1017
1033
  if (!s.source) return s.at < this._max_non_snap_event_id;
1018
1034
  if (is_literal_source(s.source)) {
1019
1035
  const max_id = this._max_event_id_by_stream.get(s.source);
@@ -1075,17 +1091,18 @@ var InMemoryStore = class {
1075
1091
  stream,
1076
1092
  source,
1077
1093
  priority = 0,
1078
- lane = DEFAULT_LANE
1094
+ lane = DEFAULT_LANE,
1095
+ correlated_at: correlated_at2
1079
1096
  } of streams) {
1080
1097
  const existing = this._streams.get(stream);
1081
1098
  if (existing) {
1082
1099
  existing.bump_priority(priority);
1083
1100
  existing.lane = lane;
1101
+ if (correlated_at2 !== void 0) existing.mark(correlated_at2);
1084
1102
  } else {
1085
- this._streams.set(
1086
- stream,
1087
- new InMemoryStream(stream, source, priority, lane)
1088
- );
1103
+ const created = new InMemoryStream(stream, source, priority, lane);
1104
+ if (correlated_at2 !== void 0) created.mark(correlated_at2);
1105
+ this._streams.set(stream, created);
1089
1106
  subscribed++;
1090
1107
  }
1091
1108
  }
@@ -1312,7 +1329,8 @@ var InMemoryStore = class {
1312
1329
  leased_by: s.leased_by,
1313
1330
  leased_until: s.leased_until,
1314
1331
  lane: s.lane,
1315
- deferred_at: s.deferred_at
1332
+ deferred_at: s.deferred_at,
1333
+ correlated_at: s.correlated_at
1316
1334
  });
1317
1335
  count++;
1318
1336
  if (count >= limit) break;
@@ -2401,12 +2419,10 @@ function classify_registry(registry, states) {
2401
2419
  const target_lanes = /* @__PURE__ */ new Map();
2402
2420
  const reactive_events = /* @__PURE__ */ new Set();
2403
2421
  const event_to_lanes = /* @__PURE__ */ new Map();
2404
- let has_dynamic_resolvers = false;
2405
2422
  for (const [name, register] of Object.entries(registry.events)) {
2406
2423
  if (register.reactions.size > 0) reactive_events.add(name);
2407
2424
  for (const reaction of register.reactions.values()) {
2408
2425
  if (typeof reaction.resolver === "function") {
2409
- has_dynamic_resolvers = true;
2410
2426
  event_to_lanes.set(name, ALL_LANES);
2411
2427
  } else {
2412
2428
  const { target, source, priority = 0, lane } = reaction.resolver;
@@ -2442,7 +2458,6 @@ function classify_registry(registry, states) {
2442
2458
  }
2443
2459
  return {
2444
2460
  static_targets: [...statics.values()],
2445
- has_dynamic_resolvers,
2446
2461
  reactive_events,
2447
2462
  event_to_state,
2448
2463
  event_to_lanes
@@ -2926,7 +2941,8 @@ async function run_close_cycle(targets, deps) {
2926
2941
  stream_info,
2927
2942
  deps.reactive_events_size,
2928
2943
  skipped,
2929
- deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
2944
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE,
2945
+ deps.catch_up_correlation
2930
2946
  );
2931
2947
  if (!safe.length) return { truncated: windowed_result, skipped };
2932
2948
  const { guarded, guard_events } = await guard_with_tombstones(
@@ -3045,8 +3061,16 @@ async function scan_stream_heads(streams) {
3045
3061
  }
3046
3062
  return out;
3047
3063
  }
3048
- 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) {
3049
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
+ }
3050
3074
  const pending_set = /* @__PURE__ */ new Set();
3051
3075
  const source_regex = /* @__PURE__ */ new Map();
3052
3076
  const get_regex = (source) => {
@@ -3065,6 +3089,8 @@ async function partition_by_safety(stream_info, reactive_events_size, skipped, p
3065
3089
  (position) => {
3066
3090
  last = position.stream;
3067
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;
3068
3094
  for (const [stream, info] of stream_info) {
3069
3095
  if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
3070
3096
  pending_set.add(stream);
@@ -3078,7 +3104,8 @@ async function partition_by_safety(stream_info, reactive_events_size, skipped, p
3078
3104
  }
3079
3105
  const safe = [];
3080
3106
  for (const [stream] of stream_info) {
3081
- if (pending_set.has(stream)) skipped.push(stream);
3107
+ if (pending_set.has(stream) || uncorrelated.has(stream))
3108
+ skipped.push(stream);
3082
3109
  else safe.push(stream);
3083
3110
  }
3084
3111
  return safe;
@@ -3168,21 +3195,24 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
3168
3195
 
3169
3196
  // src/internal/correlate-cycle.ts
3170
3197
  var DEFAULT_COLD_START_BACK_SCAN = 1e4;
3198
+ var PATTERN_CACHE_SIZE = 32;
3171
3199
  var CorrelateCycle = class {
3172
3200
  _checkpoint = -1;
3173
3201
  _initialized = false;
3174
3202
  /** In-flight init, memoized for single-flight and cleared on failure. */
3175
3203
  _init_promise;
3176
3204
  _timer = void 0;
3177
- // target → the priority it was last subscribed at. A dynamic target is
3178
- // re-subscribed (so the store's `GREATEST` upsert runs) only when a later
3179
- // scan resolves a *higher* priority for it — the runtime `max()` invariant
3180
- // (#1363). Static targets are recorded at +Infinity so a dynamic resolution
3181
- // 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}.
3182
3211
  _subscribed;
3212
+ /** Compiled pattern sources, bounded by {@link PATTERN_CACHE_SIZE}. */
3213
+ _patterns = new LruMap(PATTERN_CACHE_SIZE);
3183
3214
  _registry;
3184
3215
  _static_targets;
3185
- _has_dynamic_resolvers;
3186
3216
  _cd;
3187
3217
  _on_init;
3188
3218
  /**
@@ -3212,7 +3242,6 @@ var CorrelateCycle = class {
3212
3242
  constructor({
3213
3243
  registry,
3214
3244
  static_targets,
3215
- has_dynamic_resolvers,
3216
3245
  cd,
3217
3246
  max_subscribed_streams,
3218
3247
  run_scoped,
@@ -3223,7 +3252,6 @@ var CorrelateCycle = class {
3223
3252
  this._subscribed = new LruMap(max_subscribed_streams);
3224
3253
  this._registry = registry;
3225
3254
  this._static_targets = static_targets;
3226
- this._has_dynamic_resolvers = has_dynamic_resolvers;
3227
3255
  this._cd = cd;
3228
3256
  this._on_init = on_init;
3229
3257
  this._run_scoped = run_scoped;
@@ -3234,19 +3262,10 @@ var CorrelateCycle = class {
3234
3262
  get checkpoint() {
3235
3263
  return this._checkpoint;
3236
3264
  }
3237
- /**
3238
- * Whether any reaction uses a dynamic resolver — i.e. whether `correlate`
3239
- * performs a store scan rather than the static no-op early-return. The
3240
- * settle loop reads this to avoid recording a fictitious circuit-breaker
3241
- * success on a static app whose correlate never touches the store (#1329).
3242
- */
3243
- get has_dynamic_resolvers() {
3244
- return this._has_dynamic_resolvers;
3245
- }
3246
3265
  /**
3247
3266
  * Initialize correlation state on first call.
3248
- * - Reads max(at) from store, then floors the cold-start checkpoint at
3249
- * `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
3250
3269
  * committed-but-not-correlated before a crash is re-scanned on
3251
3270
  * restart instead of skipped (ACT-1207)
3252
3271
  * - Subscribes static resolver targets (idempotent upsert)
@@ -3268,10 +3287,14 @@ var CorrelateCycle = class {
3268
3287
  const { watermark, correlated_at } = await store2().subscribe([
3269
3288
  ...this._static_targets
3270
3289
  ]);
3271
- 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);
3272
3291
  this._on_init?.();
3273
- for (const { stream } of this._static_targets) {
3274
- 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
+ });
3275
3298
  }
3276
3299
  await this._on_init_async?.();
3277
3300
  }
@@ -3296,14 +3319,34 @@ var CorrelateCycle = class {
3296
3319
  }
3297
3320
  }
3298
3321
  /**
3299
- * Discover dynamic-resolver targets in the events past the checkpoint
3300
- * and register any new streams via `cd.subscribe`. Static targets are
3301
- * 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.
3302
3347
  */
3303
3348
  async correlate(query = { after: -1, limit: 10 }) {
3304
3349
  await this.init();
3305
- if (!this._has_dynamic_resolvers)
3306
- return { subscribed: 0, last_id: this._checkpoint };
3307
3350
  const after = Math.max(this._checkpoint, query.after || -1);
3308
3351
  const correlated = /* @__PURE__ */ new Map();
3309
3352
  let last_id = after;
@@ -3313,47 +3356,53 @@ var CorrelateCycle = class {
3313
3356
  const register = this._registry.events[event.name];
3314
3357
  if (register) {
3315
3358
  for (const reaction of register.reactions.values()) {
3316
- if (typeof reaction.resolver !== "function") continue;
3317
- const resolved = reaction.resolver(event);
3318
- if (resolved && (resolved.priority ?? 0) > (this._subscribed.get(resolved.target) ?? Number.NEGATIVE_INFINITY)) {
3319
- const incoming_priority = resolved.priority ?? 0;
3320
- const entry = correlated.get(resolved.target) || {
3321
- source: resolved.source,
3322
- priority: incoming_priority,
3323
- lane: resolved.lane,
3324
- payloads: []
3325
- };
3326
- if (incoming_priority > entry.priority) {
3327
- entry.priority = incoming_priority;
3328
- entry.lane = resolved.lane;
3329
- }
3330
- entry.payloads.push({
3331
- ...reaction,
3332
- source: resolved.source,
3333
- event
3334
- });
3335
- 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;
3336
3376
  }
3377
+ if (this._in_fetch_window(resolved.source, event.stream))
3378
+ entry.correlated_at = event.id;
3379
+ correlated.set(resolved.target, entry);
3337
3380
  }
3338
3381
  }
3339
3382
  },
3340
3383
  { ...query, after }
3341
3384
  );
3342
- if (correlated.size) {
3343
- const streams = [...correlated.entries()].map(
3344
- ([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({
3345
3389
  stream,
3346
- source,
3347
- priority,
3348
- lane
3349
- })
3350
- );
3390
+ source: entry.source,
3391
+ priority: entry.priority,
3392
+ lane: entry.lane,
3393
+ correlated_at: entry.correlated_at
3394
+ });
3395
+ }
3396
+ if (streams.length) {
3351
3397
  const { subscribed } = await this._cd.subscribe(streams, last_id);
3352
3398
  this._checkpoint = last_id;
3353
- if (subscribed) {
3354
- for (const { stream, priority } of streams) {
3355
- this._subscribed.set(stream, priority);
3356
- }
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
+ });
3357
3406
  }
3358
3407
  return { subscribed, last_id };
3359
3408
  }
@@ -4661,7 +4710,7 @@ var SettleLoop = class {
4661
4710
  ...correlate_query,
4662
4711
  after: after_before
4663
4712
  });
4664
- if (this._deps.correlate_probes_store) this._deps.breaker.passed();
4713
+ this._deps.breaker.passed();
4665
4714
  const drain = await this._deps.drain(drain_options);
4666
4715
  settled_drain = settled_drain ? {
4667
4716
  fetched: [...settled_drain.fetched, ...drain.fetched],
@@ -4707,6 +4756,8 @@ var SettleLoop = class {
4707
4756
 
4708
4757
  // src/act.ts
4709
4758
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
4759
+ var CLOSE_CATCH_UP_LIMIT = 1e3;
4760
+ var CLOSE_CATCH_UP_PASSES = 20;
4710
4761
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
4711
4762
  function validate_only_lanes(options, lanes) {
4712
4763
  if (!options.onlyLanes || options.onlyLanes.length === 0) return;
@@ -5022,6 +5073,7 @@ var Act = class {
5022
5073
  const close_actor = { id: "$close", name: "close" };
5023
5074
  const result = await run_close_cycle(targets, {
5024
5075
  reactive_events_size: this._reactive_events.size,
5076
+ catch_up_correlation: (until) => this._catch_up_correlation(until),
5025
5077
  event_to_state: this._event_to_state,
5026
5078
  load: this._es.load,
5027
5079
  tombstone: this._es.tombstone,
@@ -5108,7 +5160,6 @@ var Act = class {
5108
5160
  return new CorrelateCycle({
5109
5161
  registry: this.registry,
5110
5162
  static_targets: classification.static_targets,
5111
- has_dynamic_resolvers: classification.has_dynamic_resolvers,
5112
5163
  cd: this._cd,
5113
5164
  max_subscribed_streams: options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
5114
5165
  on_init: () => {
@@ -5159,11 +5210,7 @@ var Act = class {
5159
5210
  correlate: (q) => this.correlate(q),
5160
5211
  drain: (o) => this.drain(o),
5161
5212
  on_settled: (drain) => this.emit("settled", drain),
5162
- breaker: this._breaker,
5163
- // Static-reaction apps' correlate is a no-op (no store call), so a
5164
- // settle pass carries no store-health signal — don't let it record a
5165
- // breaker success (#1329).
5166
- correlate_probes_store: this._correlate.has_dynamic_resolvers
5213
+ breaker: this._breaker
5167
5214
  },
5168
5215
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
5169
5216
  );
@@ -5536,8 +5583,13 @@ var Act = class {
5536
5583
  * Drain uses a dual-frontier strategy to balance processing of new streams (lagging)
5537
5584
  * vs active streams (leading). The ratio adapts based on event pressure.
5538
5585
  *
5539
- * Call `correlate()` before `drain()` to discover target streams. For a higher-level
5540
- * 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}.
5541
5593
  *
5542
5594
  * @param options - Drain configuration — see {@link DrainOptions} for fields
5543
5595
  * (`streamLimit`, `eventLimit`, `leaseMillis`).
@@ -6087,6 +6139,26 @@ var Act = class {
6087
6139
  * removed, so a later correlate can re-subscribe it (#1398). Restart
6088
6140
  * targets keep their row, so only fully-retired streams are forgotten.
6089
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
+ }
6090
6162
  _forget_closed_subscriptions(result) {
6091
6163
  const retired = [...result.truncated.entries()].filter(([, r]) => r.committed.name === TOMBSTONE_EVENT).map(([stream]) => stream);
6092
6164
  if (retired.length) this._correlate.forget_subscribed(retired);
@@ -6098,6 +6170,7 @@ var Act = class {
6098
6170
  const close_actor = { id: "$close", name: "close" };
6099
6171
  const result = await run_close_cycle(targets, {
6100
6172
  reactive_events_size: this._reactive_events.size,
6173
+ catch_up_correlation: (until) => this._catch_up_correlation(until),
6101
6174
  event_to_state: this._event_to_state,
6102
6175
  load: this._es.load,
6103
6176
  tombstone: this._es.tombstone,