@mrjacket/ahko 0.2.0 → 0.4.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
@@ -26,9 +26,13 @@ __export(src_exports, {
26
26
  AhkoError: () => AhkoError,
27
27
  AhkoQueueError: () => AhkoQueueError,
28
28
  AhkoTimeoutError: () => AhkoTimeoutError,
29
+ DEFAULT_BASE_DELAY: () => DEFAULT_BASE_DELAY,
30
+ DEFAULT_MAX_DELAY: () => DEFAULT_MAX_DELAY,
29
31
  EScheduleStrategy: () => EScheduleStrategy,
30
32
  ETaskState: () => ETaskState,
31
- VERSION: () => VERSION
33
+ VERSION: () => VERSION,
34
+ calculateBackoff: () => calculateBackoff,
35
+ combineSignals: () => combineSignals
32
36
  });
33
37
  module.exports = __toCommonJS(src_exports);
34
38
 
@@ -70,6 +74,26 @@ var EScheduleStrategy = /* @__PURE__ */ ((EScheduleStrategy2) => {
70
74
  return EScheduleStrategy2;
71
75
  })(EScheduleStrategy || {});
72
76
 
77
+ // src/errors/timeout.error.ts
78
+ var AhkoTimeoutError = class extends AhkoError {
79
+ /**
80
+ * The timeout threshold in milliseconds that was exceeded, if configured.
81
+ */
82
+ timeoutMs;
83
+ /**
84
+ * Creates a new AhkoTimeoutError.
85
+ *
86
+ * @param message - Explanation of timeout expiry.
87
+ * @param options - Standard Error options including optional timeoutMs and cause.
88
+ */
89
+ constructor(message = "Task execution timed out", options) {
90
+ super(message, options);
91
+ this.name = "AhkoTimeoutError";
92
+ this.timeoutMs = options?.timeoutMs;
93
+ Object.setPrototypeOf(this, new.target.prototype);
94
+ }
95
+ };
96
+
73
97
  // src/models/state.model.ts
74
98
  var ETaskState = /* @__PURE__ */ ((ETaskState2) => {
75
99
  ETaskState2["PENDING"] = "pending";
@@ -81,6 +105,31 @@ var ETaskState = /* @__PURE__ */ ((ETaskState2) => {
81
105
  return ETaskState2;
82
106
  })(ETaskState || {});
83
107
 
108
+ // src/retry/backoff.ts
109
+ var DEFAULT_BASE_DELAY = 250;
110
+ var DEFAULT_MAX_DELAY = 1e4;
111
+ function calculateBackoff(attempt, options, randomFn = Math.random) {
112
+ const backoff = options?.backoff ?? "exponential";
113
+ if (backoff === "none") {
114
+ return 0;
115
+ }
116
+ const baseDelay = typeof options?.baseDelay === "number" && !Number.isNaN(options.baseDelay) && options.baseDelay >= 0 ? options.baseDelay : DEFAULT_BASE_DELAY;
117
+ const maxDelay = typeof options?.maxDelay === "number" && !Number.isNaN(options.maxDelay) && options.maxDelay >= baseDelay ? options.maxDelay : Math.max(DEFAULT_MAX_DELAY, baseDelay);
118
+ let calculatedDelay;
119
+ if (backoff === "linear") {
120
+ calculatedDelay = baseDelay * Math.max(1, attempt);
121
+ } else {
122
+ const exponent = Math.max(0, attempt - 1);
123
+ const factor = exponent > 30 ? 2 ** 30 : 2 ** exponent;
124
+ calculatedDelay = baseDelay * factor;
125
+ }
126
+ const cappedDelay = Math.min(calculatedDelay, maxDelay);
127
+ if (options?.jitter) {
128
+ return Math.floor(randomFn() * (cappedDelay + 1));
129
+ }
130
+ return Math.floor(cappedDelay);
131
+ }
132
+
84
133
  // src/scheduler/idle-scheduler.ts
85
134
  var IdleScheduler = class {
86
135
  /**
@@ -132,6 +181,10 @@ var TaskQueue = class {
132
181
  delayedEntries = /* @__PURE__ */ new Set();
133
182
  /** Set of tasks currently awaiting an idle opportunity */
134
183
  idleEntries = /* @__PURE__ */ new Set();
184
+ /** Set of tasks currently awaiting a retry backoff timer */
185
+ retryEntries = /* @__PURE__ */ new Set();
186
+ /** WeakMap associating task runners with their scheduling options */
187
+ runnerOptions = /* @__PURE__ */ new WeakMap();
135
188
  /** Cumulative completed tasks counter */
136
189
  completedTasks = 0;
137
190
  /** Cumulative failed tasks counter */
@@ -170,6 +223,33 @@ var TaskQueue = class {
170
223
  `Unsupported schedule strategy "${String(strategy)}". Supported strategies: "immediate", "delay", "idle".`
171
224
  );
172
225
  }
226
+ if (options?.retry) {
227
+ if (typeof options.retry.attempts !== "number" || Number.isNaN(options.retry.attempts) || options.retry.attempts < 1 || !Number.isInteger(options.retry.attempts)) {
228
+ throw new AhkoConfigurationError(
229
+ `Invalid retry attempts "${options.retry.attempts}". attempts must be an integer greater than or equal to 1.`
230
+ );
231
+ }
232
+ if (options.retry.baseDelay !== void 0 && (typeof options.retry.baseDelay !== "number" || Number.isNaN(options.retry.baseDelay) || options.retry.baseDelay < 0)) {
233
+ throw new AhkoConfigurationError(
234
+ `Invalid retry baseDelay "${options.retry.baseDelay}". baseDelay must be a non-negative number in milliseconds.`
235
+ );
236
+ }
237
+ if (options.retry.maxDelay !== void 0 && (typeof options.retry.maxDelay !== "number" || Number.isNaN(options.retry.maxDelay) || options.retry.maxDelay < 0)) {
238
+ throw new AhkoConfigurationError(
239
+ `Invalid retry maxDelay "${options.retry.maxDelay}". maxDelay must be a non-negative number in milliseconds.`
240
+ );
241
+ }
242
+ }
243
+ if (options?.timeoutMs !== void 0) {
244
+ if (typeof options.timeoutMs !== "number" || Number.isNaN(options.timeoutMs) || !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
245
+ throw new AhkoConfigurationError(
246
+ `Invalid timeoutMs "${options.timeoutMs}". timeoutMs must be a positive finite number greater than 0.`
247
+ );
248
+ }
249
+ }
250
+ if (options) {
251
+ this.runnerOptions.set(runner, options);
252
+ }
173
253
  if (runner.state === "cancelled" /* CANCELLED */) {
174
254
  this.cancelledTasks++;
175
255
  return runner.promise;
@@ -286,28 +366,86 @@ var TaskQueue = class {
286
366
  }
287
367
  /**
288
368
  * Internal execution of an active task runner.
289
- * Settle caller promise strictly after stats and active status are updated.
290
369
  */
291
370
  async executeRunner(runner) {
371
+ const options = this.runnerOptions.get(runner);
292
372
  try {
293
373
  const result = await runner.run();
294
374
  this.completedTasks++;
295
375
  this.activeRunners.delete(runner);
376
+ this.runnerOptions.delete(runner);
296
377
  runner.resolve(result);
297
378
  } catch (error) {
298
379
  if (runner.state === "cancelled" /* CANCELLED */) {
299
380
  this.cancelledTasks++;
300
- } else if (runner.state === "timed_out" /* TIMED_OUT */) {
381
+ this.activeRunners.delete(runner);
382
+ this.runnerOptions.delete(runner);
383
+ runner.reject(error);
384
+ return;
385
+ }
386
+ const shouldRetry = await runner.canRetry(error, options?.retry);
387
+ if (shouldRetry) {
388
+ this.activeRunners.delete(runner);
389
+ this.scheduleRetry(runner, options);
390
+ return;
391
+ }
392
+ if (runner.state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
301
393
  this.timedOutTasks++;
302
394
  } else {
303
395
  this.failedTasks++;
304
396
  }
305
397
  this.activeRunners.delete(runner);
398
+ this.runnerOptions.delete(runner);
306
399
  runner.reject(error);
307
400
  } finally {
308
401
  this.pump();
309
402
  }
310
403
  }
404
+ /**
405
+ * Schedules a retry attempt following backoff delay,
406
+ * without holding a concurrency slot.
407
+ */
408
+ scheduleRetry(runner, options) {
409
+ const backoffDelay = calculateBackoff(runner.attempt - 1, options?.retry);
410
+ if (backoffDelay === 0) {
411
+ runner.onCancel = () => {
412
+ const index = this.queue.indexOf(runner);
413
+ if (index !== -1) {
414
+ this.queue.splice(index, 1);
415
+ this.cancelledTasks++;
416
+ }
417
+ };
418
+ this.queue.push(runner);
419
+ this.pump();
420
+ return;
421
+ }
422
+ const retryEntry = {
423
+ runner,
424
+ timerId: setTimeout(() => {
425
+ this.retryEntries.delete(retryEntry);
426
+ if (runner.state === "cancelled" /* CANCELLED */) {
427
+ return;
428
+ }
429
+ runner.onCancel = () => {
430
+ const index = this.queue.indexOf(runner);
431
+ if (index !== -1) {
432
+ this.queue.splice(index, 1);
433
+ this.cancelledTasks++;
434
+ }
435
+ };
436
+ this.queue.push(runner);
437
+ this.pump();
438
+ }, backoffDelay)
439
+ };
440
+ this.retryEntries.add(retryEntry);
441
+ runner.onCancel = () => {
442
+ if (this.retryEntries.has(retryEntry)) {
443
+ clearTimeout(retryEntry.timerId);
444
+ this.retryEntries.delete(retryEntry);
445
+ this.cancelledTasks++;
446
+ }
447
+ };
448
+ }
311
449
  /**
312
450
  * Returns telemetry snapshot for the scheduler.
313
451
  *
@@ -316,7 +454,7 @@ var TaskQueue = class {
316
454
  getStats() {
317
455
  return Object.freeze({
318
456
  activeTasks: this.activeRunners.size,
319
- pendingTasks: this.queue.length + this.delayedEntries.size + this.idleEntries.size,
457
+ pendingTasks: this.queue.length + this.delayedEntries.size + this.idleEntries.size + this.retryEntries.size,
320
458
  completedTasks: this.completedTasks,
321
459
  failedTasks: this.failedTasks,
322
460
  cancelledTasks: this.cancelledTasks,
@@ -354,6 +492,10 @@ var TaskRunner = class {
354
492
  task;
355
493
  /** User-supplied AbortSignal for external cancellation */
356
494
  externalSignal;
495
+ /** Maximum execution duration allowed in milliseconds */
496
+ timeoutMs;
497
+ /** Active timeout timer identifier */
498
+ timeoutTimerId;
357
499
  /** Abort event listener reference for clean detachment */
358
500
  abortListener;
359
501
  /** Promise resolve handler */
@@ -364,16 +506,20 @@ var TaskRunner = class {
364
506
  promise;
365
507
  /** Callback invoked when runner is cancelled while pending */
366
508
  onCancel;
509
+ /** Current execution attempt count (1-indexed) */
510
+ attempt = 1;
367
511
  /**
368
512
  * Creates a new TaskRunner instance.
369
513
  *
370
514
  * @param task - The asynchronous work unit to run.
371
515
  * @param externalSignal - Optional external AbortSignal to propagate.
516
+ * @param timeoutMs - Optional maximum execution time in milliseconds.
372
517
  */
373
- constructor(task, externalSignal) {
518
+ constructor(task, externalSignal, timeoutMs) {
374
519
  this.taskId = `task_${Date.now().toString(36)}_${(++taskIdCounter).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
375
520
  this.task = task;
376
521
  this.externalSignal = externalSignal;
522
+ this.timeoutMs = timeoutMs;
377
523
  this.abortController = new AbortController();
378
524
  this.promise = new Promise((resolve, reject) => {
379
525
  this.resolvePromise = resolve;
@@ -387,6 +533,7 @@ var TaskRunner = class {
387
533
  typeof reason === "string" ? reason : "Task was cancelled prior to execution",
388
534
  { cause: reason instanceof Error ? reason : void 0 }
389
535
  );
536
+ this.abortController.abort(cancelError);
390
537
  this.rejectPromise(cancelError);
391
538
  } else {
392
539
  this.abortListener = () => {
@@ -408,6 +555,7 @@ var TaskRunner = class {
408
555
  * @param value - Value to resolve with.
409
556
  */
410
557
  resolve(value) {
558
+ this.cleanup();
411
559
  this.resolvePromise(value);
412
560
  }
413
561
  /**
@@ -416,12 +564,45 @@ var TaskRunner = class {
416
564
  * @param reason - Reason to reject with.
417
565
  */
418
566
  reject(reason) {
567
+ this.cleanup();
419
568
  this.rejectPromise(reason);
420
569
  }
570
+ /**
571
+ * Evaluates if the task should be retried following an execution failure or timeout.
572
+ *
573
+ * @param error - The error encountered during the attempt.
574
+ * @param retryOptions - Configured retry policy.
575
+ * @returns A promise resolving to true if retry should proceed, false otherwise.
576
+ */
577
+ async canRetry(error, retryOptions) {
578
+ if (this._state === "cancelled" /* CANCELLED */ || (this.externalSignal?.aborted ?? false)) {
579
+ return false;
580
+ }
581
+ if (!retryOptions || typeof retryOptions.attempts !== "number") {
582
+ return false;
583
+ }
584
+ if (this.attempt >= retryOptions.attempts) {
585
+ return false;
586
+ }
587
+ if (typeof retryOptions.shouldRetry === "function") {
588
+ try {
589
+ const allowed = await retryOptions.shouldRetry(error, this.attempt);
590
+ if (!allowed) {
591
+ return false;
592
+ }
593
+ } catch {
594
+ return false;
595
+ }
596
+ }
597
+ this.attempt++;
598
+ this._state = "pending" /* PENDING */;
599
+ this.abortController = new AbortController();
600
+ return true;
601
+ }
421
602
  /**
422
603
  * Executes the task within an allocated concurrency slot.
423
604
  *
424
- * @returns A promise resolving to the task result or rejecting on failure/cancellation.
605
+ * @returns A promise resolving to the task result or rejecting on failure/cancellation/timeout.
425
606
  */
426
607
  async run() {
427
608
  if (this._state === "cancelled" /* CANCELLED */) {
@@ -432,16 +613,100 @@ var TaskRunner = class {
432
613
  signal: this.abortController.signal,
433
614
  taskId: this.taskId
434
615
  };
616
+ let abortListener;
617
+ const abortPromise = new Promise((_, reject) => {
618
+ abortListener = () => {
619
+ if (this._state === "timed_out" /* TIMED_OUT */) {
620
+ reject(
621
+ new AhkoTimeoutError(
622
+ `Task execution timed out after ${this.timeoutMs}ms`,
623
+ { timeoutMs: this.timeoutMs }
624
+ )
625
+ );
626
+ } else {
627
+ const reason = this.abortController.signal.reason;
628
+ reject(
629
+ new AhkoCancellationError("Task was cancelled during execution", {
630
+ cause: reason instanceof Error ? reason : void 0
631
+ })
632
+ );
633
+ }
634
+ };
635
+ this.abortController.signal.addEventListener("abort", abortListener, { once: true });
636
+ });
637
+ let timeoutPromise;
638
+ if (this.timeoutMs !== void 0) {
639
+ timeoutPromise = new Promise((_, reject) => {
640
+ this.timeoutTimerId = setTimeout(() => {
641
+ if (this._state !== "running" /* RUNNING */) {
642
+ return;
643
+ }
644
+ this._state = "timed_out" /* TIMED_OUT */;
645
+ const timeoutError = new AhkoTimeoutError(
646
+ `Task execution timed out after ${this.timeoutMs}ms`,
647
+ { timeoutMs: this.timeoutMs }
648
+ );
649
+ this.abortController.abort(timeoutError);
650
+ reject(timeoutError);
651
+ }, this.timeoutMs);
652
+ });
653
+ }
654
+ let taskExecutionPromise;
655
+ try {
656
+ taskExecutionPromise = Promise.resolve(this.task(context));
657
+ } catch (syncError) {
658
+ taskExecutionPromise = Promise.reject(syncError);
659
+ }
660
+ taskExecutionPromise.catch(() => {
661
+ });
662
+ const racePromises = [
663
+ taskExecutionPromise,
664
+ abortPromise
665
+ ];
666
+ if (timeoutPromise) {
667
+ racePromises.push(timeoutPromise);
668
+ }
435
669
  try {
436
- const result = await this.task(context);
670
+ const result = await Promise.race(racePromises);
671
+ this.clearTimeoutTimer();
672
+ if (abortListener) {
673
+ this.abortController.signal.removeEventListener("abort", abortListener);
674
+ }
675
+ if (this._state === "timed_out" /* TIMED_OUT */) {
676
+ throw new AhkoTimeoutError(
677
+ `Task execution timed out after ${this.timeoutMs}ms`,
678
+ { timeoutMs: this.timeoutMs }
679
+ );
680
+ }
681
+ if (this._state === "cancelled" /* CANCELLED */) {
682
+ throw new AhkoCancellationError("Task was cancelled during execution");
683
+ }
437
684
  this._state = "completed" /* COMPLETED */;
438
- this.cleanup();
439
685
  return result;
440
686
  } catch (error) {
441
- this.cleanup();
442
- const isCancelled = this._state === "cancelled" /* CANCELLED */ || this.abortController.signal.aborted;
687
+ this.clearTimeoutTimer();
688
+ if (abortListener) {
689
+ this.abortController.signal.removeEventListener("abort", abortListener);
690
+ }
691
+ if (this._state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
692
+ this._state = "timed_out" /* TIMED_OUT */;
693
+ if (error instanceof AhkoTimeoutError) {
694
+ throw error;
695
+ }
696
+ throw new AhkoTimeoutError(
697
+ `Task execution timed out after ${this.timeoutMs}ms`,
698
+ {
699
+ timeoutMs: this.timeoutMs,
700
+ cause: error instanceof Error ? error : void 0
701
+ }
702
+ );
703
+ }
704
+ const isCancelled = this._state === "cancelled" /* CANCELLED */ || this.abortController.signal.aborted || (this.externalSignal?.aborted ?? false);
443
705
  if (isCancelled) {
444
706
  this._state = "cancelled" /* CANCELLED */;
707
+ if (error instanceof AhkoCancellationError) {
708
+ throw error;
709
+ }
445
710
  throw new AhkoCancellationError("Task was cancelled during execution", {
446
711
  cause: error instanceof Error ? error : void 0
447
712
  });
@@ -450,6 +715,15 @@ var TaskRunner = class {
450
715
  throw error;
451
716
  }
452
717
  }
718
+ /**
719
+ * Clears the active timeout timer.
720
+ */
721
+ clearTimeoutTimer() {
722
+ if (this.timeoutTimerId !== void 0) {
723
+ clearTimeout(this.timeoutTimerId);
724
+ this.timeoutTimerId = void 0;
725
+ }
726
+ }
453
727
  /**
454
728
  * Cancels the task, aborting pending or running execution.
455
729
  *
@@ -461,6 +735,7 @@ var TaskRunner = class {
461
735
  }
462
736
  const wasPending = this._state === "pending" /* PENDING */;
463
737
  this._state = "cancelled" /* CANCELLED */;
738
+ this.clearTimeoutTimer();
464
739
  this.abortController.abort(reason);
465
740
  this.cleanup();
466
741
  if (wasPending) {
@@ -482,6 +757,7 @@ var TaskRunner = class {
482
757
  * Detaches event listeners from external signal to guarantee memory safety.
483
758
  */
484
759
  cleanup() {
760
+ this.clearTimeoutTimer();
485
761
  if (this.externalSignal && this.abortListener) {
486
762
  this.externalSignal.removeEventListener("abort", this.abortListener);
487
763
  }
@@ -533,7 +809,7 @@ var Ahko = class {
533
809
  if (typeof task !== "function") {
534
810
  throw new AhkoConfigurationError("Task must be a valid function.");
535
811
  }
536
- const runner = new TaskRunner(task, options?.signal);
812
+ const runner = new TaskRunner(task, options?.signal, options?.timeoutMs);
537
813
  return this.queue.enqueue(runner, options);
538
814
  }
539
815
  /**
@@ -582,7 +858,7 @@ var Ahko = class {
582
858
  };
583
859
 
584
860
  // src/version.ts
585
- var VERSION = "0.2.0";
861
+ var VERSION = "0.4.0";
586
862
 
587
863
  // src/errors/queue.error.ts
588
864
  var AhkoQueueError = class extends AhkoError {
@@ -599,20 +875,60 @@ var AhkoQueueError = class extends AhkoError {
599
875
  }
600
876
  };
601
877
 
602
- // src/errors/timeout.error.ts
603
- var AhkoTimeoutError = class extends AhkoError {
604
- /**
605
- * Creates a new AhkoTimeoutError.
606
- *
607
- * @param message - Explanation of timeout expiry.
608
- * @param options - Standard Error options including cause.
609
- */
610
- constructor(message = "Task execution timed out", options) {
611
- super(message, options);
612
- this.name = "AhkoTimeoutError";
613
- Object.setPrototypeOf(this, new.target.prototype);
878
+ // src/scheduler/signal.ts
879
+ function combineSignals(signals) {
880
+ const activeSignals = signals.filter(
881
+ (signal) => signal !== void 0
882
+ );
883
+ if (activeSignals.length === 0) {
884
+ const controller2 = new AbortController();
885
+ return {
886
+ signal: controller2.signal,
887
+ cleanup: () => {
888
+ }
889
+ };
614
890
  }
615
- };
891
+ const alreadyAborted = activeSignals.find((s) => s.aborted);
892
+ if (alreadyAborted) {
893
+ const controller2 = new AbortController();
894
+ controller2.abort(alreadyAborted.reason);
895
+ return {
896
+ signal: controller2.signal,
897
+ cleanup: () => {
898
+ }
899
+ };
900
+ }
901
+ if (activeSignals.length === 1) {
902
+ return {
903
+ signal: activeSignals[0],
904
+ cleanup: () => {
905
+ }
906
+ };
907
+ }
908
+ const controller = new AbortController();
909
+ const cleanupFns = [];
910
+ const onAbort = (event) => {
911
+ const target = event.target;
912
+ cleanup();
913
+ controller.abort(target.reason);
914
+ };
915
+ for (const sig of activeSignals) {
916
+ sig.addEventListener("abort", onAbort, { once: true });
917
+ cleanupFns.push(() => {
918
+ sig.removeEventListener("abort", onAbort);
919
+ });
920
+ }
921
+ const cleanup = () => {
922
+ for (const fn of cleanupFns) {
923
+ fn();
924
+ }
925
+ cleanupFns.length = 0;
926
+ };
927
+ return {
928
+ signal: controller.signal,
929
+ cleanup
930
+ };
931
+ }
616
932
  // Annotate the CommonJS export names for ESM import in node:
617
933
  0 && (module.exports = {
618
934
  Ahko,
@@ -621,8 +937,12 @@ var AhkoTimeoutError = class extends AhkoError {
621
937
  AhkoError,
622
938
  AhkoQueueError,
623
939
  AhkoTimeoutError,
940
+ DEFAULT_BASE_DELAY,
941
+ DEFAULT_MAX_DELAY,
624
942
  EScheduleStrategy,
625
943
  ETaskState,
626
- VERSION
944
+ VERSION,
945
+ calculateBackoff,
946
+ combineSignals
627
947
  });
628
948
  //# sourceMappingURL=index.cjs.map