@rotorsoft/act 0.40.0 → 0.41.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.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  sleep,
19
19
  store,
20
20
  validate
21
- } from "./chunk-TP2OZWHP.js";
21
+ } from "./chunk-M5YFKVRV.js";
22
22
  import {
23
23
  ActorSchema,
24
24
  CausationEventSchema,
@@ -392,10 +392,20 @@ function computeLagLeadRatio(handled, lagging, leading) {
392
392
  }
393
393
 
394
394
  // src/internal/drain-cycle.ts
395
- async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis) {
395
+ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred) {
396
396
  const leased = await ops.claim(lagging, leading, randomUUID2(), leaseMillis);
397
397
  if (!leased.length) return void 0;
398
- const fetched = await ops.fetch(leased, eventLimit);
398
+ const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
399
+ if (!active.length) {
400
+ return {
401
+ leased,
402
+ fetched: [],
403
+ handled: [],
404
+ acked: [],
405
+ blocked: []
406
+ };
407
+ }
408
+ const fetched = await ops.fetch(active, eventLimit);
399
409
  const fetchMap = /* @__PURE__ */ new Map();
400
410
  const fetch_window_at = fetched.reduce(
401
411
  (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
@@ -414,7 +424,7 @@ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch,
414
424
  fetchMap.set(stream, { fetch: f, payloads });
415
425
  }
416
426
  const handled = await Promise.all(
417
- leased.map((lease) => {
427
+ active.map((lease) => {
418
428
  const entry = fetchMap.get(lease.stream);
419
429
  const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
420
430
  const { payloads } = entry;
@@ -446,6 +456,15 @@ var DrainController = class {
446
456
  _armed = false;
447
457
  _locked = false;
448
458
  _ratio = 0.5;
459
+ /**
460
+ * Per-stream backoff: `stream → nextAttemptAt` (ms since epoch). Set by
461
+ * `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
462
+ * ack or terminal block. Lives in process memory — per-worker pacing
463
+ * by design (see {@link BackoffOptions} for the multi-worker trade-off).
464
+ */
465
+ _backoff = /* @__PURE__ */ new Map();
466
+ /** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
467
+ _backoffTimer;
449
468
  /**
450
469
  * Signal that a commit (or reset / cold-start) may have produced work.
451
470
  * Subsequent `drain()` calls will run the pipeline; once the pipeline
@@ -458,6 +477,32 @@ var DrainController = class {
458
477
  get armed() {
459
478
  return this._armed;
460
479
  }
480
+ /** Returns true when `stream` is currently within a backoff window. */
481
+ isDeferred = (stream) => {
482
+ const next = this._backoff.get(stream);
483
+ return next !== void 0 && next > Date.now();
484
+ };
485
+ /**
486
+ * Schedule the next drain re-arm at the earliest pending backoff
487
+ * expiry. Called only when the backoff map is non-empty (caller guard).
488
+ * Idempotent — collapses many simultaneously deferred streams into a
489
+ * single timer.
490
+ */
491
+ scheduleBackoffWake() {
492
+ if (this._backoffTimer) clearTimeout(this._backoffTimer);
493
+ let earliest = Number.POSITIVE_INFINITY;
494
+ for (const t of this._backoff.values()) if (t < earliest) earliest = t;
495
+ const delay = Math.max(0, earliest - Date.now());
496
+ this._backoffTimer = setTimeout(() => {
497
+ this._backoffTimer = void 0;
498
+ const now = Date.now();
499
+ for (const [stream, at] of this._backoff) {
500
+ if (at <= now) this._backoff.delete(stream);
501
+ }
502
+ this._armed = true;
503
+ }, delay);
504
+ this._backoffTimer.unref();
505
+ }
461
506
  /** Run one drain pass. Short-circuits when not armed or already running. */
462
507
  async drain({
463
508
  streamLimit = 10,
@@ -479,7 +524,8 @@ var DrainController = class {
479
524
  lagging,
480
525
  leading,
481
526
  eventLimit,
482
- leaseMillis
527
+ leaseMillis,
528
+ this._backoff.size > 0 ? this.isDeferred : void 0
483
529
  );
484
530
  if (!cycle) {
485
531
  this._armed = false;
@@ -487,6 +533,14 @@ var DrainController = class {
487
533
  }
488
534
  const { leased, fetched, handled, acked, blocked } = cycle;
489
535
  this._ratio = computeLagLeadRatio(handled, lagging, leading);
536
+ for (const lease of acked) this._backoff.delete(lease.stream);
537
+ for (const lease of blocked) this._backoff.delete(lease.stream);
538
+ for (const h of handled) {
539
+ if (h.nextAttemptAt !== void 0 && !h.block) {
540
+ this._backoff.set(h.lease.stream, h.nextAttemptAt);
541
+ }
542
+ }
543
+ if (this._backoff.size > 0) this.scheduleBackoffWake();
490
544
  if (acked.length) this.deps.onAcked(acked);
491
545
  if (blocked.length) this.deps.onBlocked(blocked);
492
546
  const hasErrors = handled.some(({ error }) => error);
@@ -681,6 +735,27 @@ var _this_ = ({ stream }) => ({
681
735
  target: stream
682
736
  });
683
737
 
738
+ // src/internal/backoff.ts
739
+ function computeBackoffDelay(retry, opts) {
740
+ if (!opts || opts.baseMs <= 0) return 0;
741
+ const r = Math.max(0, retry);
742
+ let delay;
743
+ switch (opts.strategy) {
744
+ case "fixed":
745
+ delay = opts.baseMs;
746
+ break;
747
+ case "linear":
748
+ delay = opts.baseMs * (r + 1);
749
+ break;
750
+ case "exponential":
751
+ delay = opts.baseMs * 2 ** r;
752
+ if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
753
+ break;
754
+ }
755
+ if (opts.jitter) delay = delay * (0.5 + Math.random());
756
+ return Math.max(0, Math.floor(delay));
757
+ }
758
+
684
759
  // src/internal/reactions.ts
685
760
  function finalize(lease, handled, at, error, options, logger) {
686
761
  if (!error) return { lease, handled, at };
@@ -688,12 +763,14 @@ function finalize(lease, handled, at, error, options, logger) {
688
763
  const block2 = lease.retry >= options.maxRetries && options.blockOnError;
689
764
  if (block2)
690
765
  logger.error(`Blocking ${lease.stream} after ${lease.retry} retries.`);
766
+ const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
691
767
  return {
692
768
  lease,
693
769
  handled,
694
770
  at,
695
771
  error: handled === 0 ? error.message : void 0,
696
- block: block2
772
+ block: block2,
773
+ nextAttemptAt
697
774
  };
698
775
  }
699
776
  function buildHandle(deps) {
@@ -2013,7 +2090,8 @@ function act() {
2013
2090
  resolver: _this_,
2014
2091
  options: {
2015
2092
  blockOnError: options?.blockOnError ?? true,
2016
- maxRetries: options?.maxRetries ?? 3
2093
+ maxRetries: options?.maxRetries ?? 3,
2094
+ backoff: options?.backoff
2017
2095
  }
2018
2096
  };
2019
2097
  if (!handler.name)
@@ -2139,7 +2217,8 @@ function slice() {
2139
2217
  resolver: _this_,
2140
2218
  options: {
2141
2219
  blockOnError: options?.blockOnError ?? true,
2142
- maxRetries: options?.maxRetries ?? 3
2220
+ maxRetries: options?.maxRetries ?? 3,
2221
+ backoff: options?.backoff
2143
2222
  }
2144
2223
  };
2145
2224
  if (!handler.name)