@mrjacket/ahko 1.1.0 → 1.1.5

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
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
+ AdaptiveCoordinator: () => AdaptiveCoordinator,
33
34
  Ahko: () => Ahko,
34
35
  AhkoCancellationError: () => AhkoCancellationError,
35
36
  AhkoCircuitBreakerOpenError: () => AhkoCircuitBreakerOpenError,
@@ -71,6 +72,21 @@ var AhkoError = class extends Error {
71
72
  }
72
73
  };
73
74
 
75
+ // src/errors/cancellation.error.ts
76
+ var AhkoCancellationError = class extends AhkoError {
77
+ /**
78
+ * Creates a new AhkoCancellationError.
79
+ *
80
+ * @param message - Reason for cancellation.
81
+ * @param options - Standard Error options including cause.
82
+ */
83
+ constructor(message = "Task was cancelled", options) {
84
+ super(message, options);
85
+ this.name = "AhkoCancellationError";
86
+ Object.setPrototypeOf(this, new.target.prototype);
87
+ }
88
+ };
89
+
74
90
  // src/errors/configuration.error.ts
75
91
  var AhkoConfigurationError = class extends AhkoError {
76
92
  /**
@@ -257,6 +273,137 @@ function calculateBackoff(attempt, options, randomFn = Math.random) {
257
273
  return Math.floor(cappedDelay);
258
274
  }
259
275
 
276
+ // src/scheduler/adaptive-coordinator.ts
277
+ var AdaptiveCoordinator = class {
278
+ _currentConcurrency;
279
+ minConcurrency;
280
+ maxConcurrency;
281
+ targetLatencyMs;
282
+ sampleWindowSize;
283
+ backoffFactor;
284
+ recentDurations = [];
285
+ lastAverageLatencyMs = 0;
286
+ onConcurrencyChange;
287
+ /**
288
+ * Initializes a new AdaptiveCoordinator instance.
289
+ *
290
+ * @param options - Adaptive concurrency configuration options.
291
+ * @param initialConcurrency - Starting scheduler concurrency limit.
292
+ * @param onConcurrencyChange - Callback invoked when concurrency changes.
293
+ * @throws {AhkoConfigurationError} If options are invalid.
294
+ */
295
+ constructor(options, initialConcurrency, onConcurrencyChange) {
296
+ if (typeof options.targetLatencyMs !== "number" || Number.isNaN(options.targetLatencyMs) || !Number.isFinite(options.targetLatencyMs) || options.targetLatencyMs <= 0) {
297
+ throw new AhkoConfigurationError(
298
+ `Invalid targetLatencyMs "${options.targetLatencyMs}". targetLatencyMs must be a positive number greater than 0.`
299
+ );
300
+ }
301
+ const min = options.minConcurrency ?? 1;
302
+ if (typeof min !== "number" || Number.isNaN(min) || min < 1 || !Number.isInteger(min)) {
303
+ throw new AhkoConfigurationError(
304
+ `Invalid minConcurrency "${min}". minConcurrency must be an integer greater than or equal to 1.`
305
+ );
306
+ }
307
+ const defaultMax = Number.isFinite(initialConcurrency) ? Math.max(min, initialConcurrency * 2) : Math.max(min, 10);
308
+ const max = options.maxConcurrency ?? defaultMax;
309
+ if (typeof max !== "number" || Number.isNaN(max) || max < min || !Number.isInteger(max)) {
310
+ throw new AhkoConfigurationError(
311
+ `Invalid maxConcurrency "${max}". maxConcurrency must be an integer greater than or equal to minConcurrency (${min}).`
312
+ );
313
+ }
314
+ const windowSize = options.sampleWindowSize ?? 5;
315
+ if (typeof windowSize !== "number" || Number.isNaN(windowSize) || windowSize < 1 || !Number.isInteger(windowSize)) {
316
+ throw new AhkoConfigurationError(
317
+ `Invalid sampleWindowSize "${windowSize}". sampleWindowSize must be an integer greater than or equal to 1.`
318
+ );
319
+ }
320
+ const factor = options.backoffFactor ?? 0.7;
321
+ if (typeof factor !== "number" || Number.isNaN(factor) || factor <= 0.1 || factor >= 0.99) {
322
+ throw new AhkoConfigurationError(
323
+ `Invalid backoffFactor "${factor}". backoffFactor must be a number between 0.1 and 0.99.`
324
+ );
325
+ }
326
+ this.minConcurrency = min;
327
+ this.maxConcurrency = max;
328
+ this.targetLatencyMs = options.targetLatencyMs;
329
+ this.sampleWindowSize = windowSize;
330
+ this.backoffFactor = factor;
331
+ this.onConcurrencyChange = onConcurrencyChange;
332
+ const clampedInitial = Number.isFinite(initialConcurrency) ? Math.min(Math.max(initialConcurrency, min), max) : min;
333
+ this._currentConcurrency = clampedInitial;
334
+ }
335
+ /**
336
+ * Current effective concurrency limit dictated by the adaptive controller.
337
+ */
338
+ get currentConcurrency() {
339
+ return this._currentConcurrency;
340
+ }
341
+ /**
342
+ * Manually overrides the current concurrency within [minConcurrency, maxConcurrency].
343
+ *
344
+ * @param concurrency - New concurrency limit to set.
345
+ */
346
+ setConcurrency(concurrency) {
347
+ const clamped = Math.min(Math.max(concurrency, this.minConcurrency), this.maxConcurrency);
348
+ if (clamped !== this._currentConcurrency) {
349
+ const prev = this._currentConcurrency;
350
+ this._currentConcurrency = clamped;
351
+ this.onConcurrencyChange(prev, clamped, "Manual concurrency override");
352
+ }
353
+ }
354
+ /**
355
+ * Records a task execution duration sample and triggers AIMD adjustment if window is filled.
356
+ *
357
+ * @param durationMs - Execution duration in milliseconds of the completed task.
358
+ */
359
+ recordDuration(durationMs) {
360
+ this.recentDurations.push(durationMs);
361
+ if (this.recentDurations.length < this.sampleWindowSize) {
362
+ return;
363
+ }
364
+ const total = this.recentDurations.reduce((sum, val) => sum + val, 0);
365
+ const average = total / this.recentDurations.length;
366
+ this.lastAverageLatencyMs = average;
367
+ this.recentDurations = [];
368
+ if (average > this.targetLatencyMs) {
369
+ const decreased = Math.max(
370
+ this.minConcurrency,
371
+ Math.floor(this._currentConcurrency * this.backoffFactor)
372
+ );
373
+ if (decreased !== this._currentConcurrency) {
374
+ const prev = this._currentConcurrency;
375
+ this._currentConcurrency = decreased;
376
+ this.onConcurrencyChange(
377
+ prev,
378
+ decreased,
379
+ `Average latency (${Math.round(average)}ms) exceeded target (${this.targetLatencyMs}ms). Scaled down.`
380
+ );
381
+ }
382
+ } else if (average < this.targetLatencyMs * 0.75) {
383
+ const increased = Math.min(this.maxConcurrency, this._currentConcurrency + 1);
384
+ if (increased !== this._currentConcurrency) {
385
+ const prev = this._currentConcurrency;
386
+ this._currentConcurrency = increased;
387
+ this.onConcurrencyChange(
388
+ prev,
389
+ increased,
390
+ `Average latency (${Math.round(average)}ms) below target threshold. Scaled up.`
391
+ );
392
+ }
393
+ }
394
+ }
395
+ /**
396
+ * Returns a snapshot of adaptive telemetry metrics.
397
+ */
398
+ getStats() {
399
+ return {
400
+ currentConcurrency: this._currentConcurrency,
401
+ averageLatencyMs: this.lastAverageLatencyMs,
402
+ samplesRecorded: this.recentDurations.length
403
+ };
404
+ }
405
+ };
406
+
260
407
  // src/models/circuit-breaker.model.ts
261
408
  var ECircuitState = /* @__PURE__ */ ((ECircuitState2) => {
262
409
  ECircuitState2["CLOSED"] = "closed";
@@ -374,21 +521,6 @@ var CircuitBreakerCoordinator = class {
374
521
  }
375
522
  };
376
523
 
377
- // src/errors/cancellation.error.ts
378
- var AhkoCancellationError = class extends AhkoError {
379
- /**
380
- * Creates a new AhkoCancellationError.
381
- *
382
- * @param message - Reason for cancellation.
383
- * @param options - Standard Error options including cause.
384
- */
385
- constructor(message = "Task was cancelled", options) {
386
- super(message, options);
387
- this.name = "AhkoCancellationError";
388
- Object.setPrototypeOf(this, new.target.prototype);
389
- }
390
- };
391
-
392
524
  // src/scheduler/debounce-coordinator.ts
393
525
  var DebounceCoordinator = class {
394
526
  entries = /* @__PURE__ */ new Map();
@@ -785,7 +917,7 @@ var AhkoEventEmitter = class {
785
917
  // src/scheduler/task-queue.ts
786
918
  var TaskQueue = class {
787
919
  /** Maximum concurrent active tasks */
788
- concurrency;
920
+ _concurrency;
789
921
  /** Minimum interval in milliseconds between consecutive task starts */
790
922
  minIntervalMs;
791
923
  /** Timestamp of the most recent task start */
@@ -802,6 +934,8 @@ var TaskQueue = class {
802
934
  idleEntries = /* @__PURE__ */ new Set();
803
935
  /** Set of tasks currently awaiting a retry backoff timer */
804
936
  retryEntries = /* @__PURE__ */ new Set();
937
+ /** Tag index for selective cancellation and task classification */
938
+ tagIndex = /* @__PURE__ */ new Map();
805
939
  /** Coordinator for debounced tasks with key coalescing */
806
940
  debounceCoordinator = new DebounceCoordinator();
807
941
  /** Coordinator for throttled tasks with leading/trailing coalescing */
@@ -810,6 +944,8 @@ var TaskQueue = class {
810
944
  emitter = new AhkoEventEmitter();
811
945
  /** Circuit breaker coordinator if configured */
812
946
  circuitBreakerCoordinator;
947
+ /** Adaptive concurrency coordinator if configured */
948
+ adaptiveCoordinator;
813
949
  /** Pause state flag */
814
950
  _isPaused = false;
815
951
  /** Set of pending resolvers awaiting scheduler idle transition */
@@ -834,9 +970,10 @@ var TaskQueue = class {
834
970
  * @param concurrency - Maximum concurrent tasks (defaults to Infinity).
835
971
  * @param minIntervalMs - Minimum interval in milliseconds between task dispatches.
836
972
  * @param circuitBreakerOptions - Optional circuit breaker policy configuration.
973
+ * @param adaptiveOptions - Optional adaptive concurrency policy configuration.
837
974
  * @throws {AhkoConfigurationError} If concurrency is less than 1 or minIntervalMs is invalid.
838
975
  */
839
- constructor(concurrency = Infinity, minIntervalMs = 0, circuitBreakerOptions) {
976
+ constructor(concurrency = Infinity, minIntervalMs = 0, circuitBreakerOptions, adaptiveOptions) {
840
977
  if (Number.isNaN(concurrency) || concurrency < 1) {
841
978
  throw new AhkoConfigurationError(
842
979
  `Invalid concurrency "${concurrency}". Must be a number greater than or equal to 1.`
@@ -847,14 +984,62 @@ var TaskQueue = class {
847
984
  `Invalid minIntervalMs "${minIntervalMs}". minIntervalMs must be a non-negative finite number.`
848
985
  );
849
986
  }
850
- this.concurrency = concurrency;
987
+ this._concurrency = concurrency;
851
988
  this.minIntervalMs = minIntervalMs;
852
989
  if (circuitBreakerOptions) {
853
990
  this.circuitBreakerCoordinator = new CircuitBreakerCoordinator(circuitBreakerOptions);
854
991
  }
992
+ if (adaptiveOptions) {
993
+ this.adaptiveCoordinator = new AdaptiveCoordinator(
994
+ adaptiveOptions,
995
+ this._concurrency,
996
+ (previous, current, reason) => {
997
+ this._concurrency = current;
998
+ this.emitter.emit("concurrency:change", {
999
+ previousConcurrency: previous,
1000
+ currentConcurrency: current,
1001
+ reason
1002
+ });
1003
+ this.pump();
1004
+ }
1005
+ );
1006
+ this._concurrency = this.adaptiveCoordinator.currentConcurrency;
1007
+ }
855
1008
  this.debounceCoordinator.onSettled = () => this.checkIdle();
856
1009
  this.throttleCoordinator.onSettled = () => this.checkIdle();
857
1010
  }
1011
+ /**
1012
+ * Current concurrency capacity limit.
1013
+ */
1014
+ get concurrency() {
1015
+ return this._concurrency;
1016
+ }
1017
+ /**
1018
+ * Dynamically adjusts the concurrency limit at runtime.
1019
+ *
1020
+ * @param newConcurrency - New maximum concurrency (must be >= 1).
1021
+ * @throws {AhkoConfigurationError} If newConcurrency is less than 1.
1022
+ */
1023
+ setConcurrency(newConcurrency) {
1024
+ if (Number.isNaN(newConcurrency) || newConcurrency < 1) {
1025
+ throw new AhkoConfigurationError(
1026
+ `Invalid concurrency "${newConcurrency}". Must be a number greater than or equal to 1.`
1027
+ );
1028
+ }
1029
+ const previous = this._concurrency;
1030
+ this._concurrency = newConcurrency;
1031
+ if (this.adaptiveCoordinator) {
1032
+ this.adaptiveCoordinator.setConcurrency(newConcurrency);
1033
+ }
1034
+ if (newConcurrency !== previous) {
1035
+ this.emitter.emit("concurrency:change", {
1036
+ previousConcurrency: previous,
1037
+ currentConcurrency: newConcurrency,
1038
+ reason: "Manual concurrency update"
1039
+ });
1040
+ }
1041
+ this.pump();
1042
+ }
858
1043
  /**
859
1044
  * Pauses queue execution. Running tasks will complete normally, but no new pending tasks will be dispatched.
860
1045
  */
@@ -876,6 +1061,76 @@ var TaskQueue = class {
876
1061
  isPaused() {
877
1062
  return this._isPaused;
878
1063
  }
1064
+ /**
1065
+ * Cancels all pending, delayed, and active tasks marked with the specified tag.
1066
+ *
1067
+ * @param tag - Tag identifier to match.
1068
+ * @param reason - Optional cancellation reason.
1069
+ * @returns Total count of tasks cancelled.
1070
+ */
1071
+ cancelByTag(tag, reason) {
1072
+ const runners = this.tagIndex.get(tag);
1073
+ if (!runners || runners.size === 0) {
1074
+ return 0;
1075
+ }
1076
+ const list = Array.from(runners);
1077
+ let count = 0;
1078
+ for (const runner of list) {
1079
+ if (runner.state === "pending" /* PENDING */ || runner.state === "running" /* RUNNING */) {
1080
+ runner.cancel(reason ?? `Task cancelled by tag "${tag}"`);
1081
+ count++;
1082
+ }
1083
+ }
1084
+ return count;
1085
+ }
1086
+ /**
1087
+ * Returns active and pending task counts for a given tag.
1088
+ *
1089
+ * @param tag - Tag identifier.
1090
+ */
1091
+ getStatsByTag(tag) {
1092
+ const runners = this.tagIndex.get(tag);
1093
+ if (!runners) {
1094
+ return { activeTasks: 0, pendingTasks: 0 };
1095
+ }
1096
+ let active = 0;
1097
+ let pending = 0;
1098
+ for (const runner of runners) {
1099
+ if (runner.state === "running" /* RUNNING */) {
1100
+ active++;
1101
+ } else if (runner.state === "pending" /* PENDING */) {
1102
+ pending++;
1103
+ }
1104
+ }
1105
+ return { activeTasks: active, pendingTasks: pending };
1106
+ }
1107
+ /**
1108
+ * Indexes a runner under all its associated tags.
1109
+ */
1110
+ indexTaskTags(runner) {
1111
+ for (const tag of runner.tags) {
1112
+ let set = this.tagIndex.get(tag);
1113
+ if (!set) {
1114
+ set = /* @__PURE__ */ new Set();
1115
+ this.tagIndex.set(tag, set);
1116
+ }
1117
+ set.add(runner);
1118
+ }
1119
+ }
1120
+ /**
1121
+ * Removes a runner from the tag index upon settlement.
1122
+ */
1123
+ cleanupTaskTags(runner) {
1124
+ for (const tag of runner.tags) {
1125
+ const set = this.tagIndex.get(tag);
1126
+ if (set) {
1127
+ set.delete(runner);
1128
+ if (set.size === 0) {
1129
+ this.tagIndex.delete(tag);
1130
+ }
1131
+ }
1132
+ }
1133
+ }
879
1134
  /**
880
1135
  * Inserts a task runner into the queue based on priority weight (descending).
881
1136
  * Preserves FIFO ordering among tasks with identical priority.
@@ -957,6 +1212,11 @@ var TaskQueue = class {
957
1212
  if (options) {
958
1213
  this.runnerOptions.set(runner, options);
959
1214
  }
1215
+ this.indexTaskTags(runner);
1216
+ runner.promise.finally(() => {
1217
+ this.cleanupTaskTags(runner);
1218
+ }).catch(() => {
1219
+ });
960
1220
  if (runner.state === "cancelled" /* CANCELLED */) {
961
1221
  this.cancelledTasks++;
962
1222
  return runner.promise;
@@ -1135,7 +1395,7 @@ var TaskQueue = class {
1135
1395
  * and queue is not paused.
1136
1396
  */
1137
1397
  pump() {
1138
- if (this._isPaused || this.queue.length === 0 || this.activeRunners.size >= this.concurrency) {
1398
+ if (this._isPaused || this.queue.length === 0 || this.activeRunners.size >= this._concurrency) {
1139
1399
  return;
1140
1400
  }
1141
1401
  if (this.minIntervalMs > 0 && this.lastTaskStartTime > 0) {
@@ -1152,7 +1412,7 @@ var TaskQueue = class {
1152
1412
  return;
1153
1413
  }
1154
1414
  }
1155
- while (!this._isPaused && this.activeRunners.size < this.concurrency && this.queue.length > 0) {
1415
+ while (!this._isPaused && this.activeRunners.size < this._concurrency && this.queue.length > 0) {
1156
1416
  if (this.minIntervalMs > 0 && this.lastTaskStartTime > 0) {
1157
1417
  const now = Date.now();
1158
1418
  const elapsed = now - this.lastTaskStartTime;
@@ -1194,7 +1454,7 @@ var TaskQueue = class {
1194
1454
  this.lastTaskStartTime = Date.now();
1195
1455
  void this.executeRunner(runner);
1196
1456
  if (this.minIntervalMs > 0) {
1197
- if (this.queue.length > 0 && this.activeRunners.size < this.concurrency) {
1457
+ if (this.queue.length > 0 && this.activeRunners.size < this._concurrency) {
1198
1458
  if (this.rateLimitTimer === void 0) {
1199
1459
  this.rateLimitTimer = setTimeout(() => {
1200
1460
  this.rateLimitTimer = void 0;
@@ -1219,6 +1479,7 @@ var TaskQueue = class {
1219
1479
  try {
1220
1480
  const result = await runner.run();
1221
1481
  this.circuitBreakerCoordinator?.recordSuccess();
1482
+ this.adaptiveCoordinator?.recordDuration(runner.lastDurationMs);
1222
1483
  this.completedTasks++;
1223
1484
  this.activeRunners.delete(runner);
1224
1485
  this.runnerOptions.delete(runner);
@@ -1257,6 +1518,7 @@ var TaskQueue = class {
1257
1518
  if (this.circuitBreakerCoordinator && !(error instanceof AhkoCircuitBreakerOpenError)) {
1258
1519
  this.circuitBreakerCoordinator.recordFailure(error);
1259
1520
  }
1521
+ this.adaptiveCoordinator?.recordDuration(runner.lastDurationMs);
1260
1522
  if (runner.state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
1261
1523
  this.timedOutTasks++;
1262
1524
  this.emitter.emit("task:timeout", {
@@ -1456,9 +1718,10 @@ var TaskQueue = class {
1456
1718
  timedOutTasks: this.timedOutTasks,
1457
1719
  retriedTasks: this.retriedTasks,
1458
1720
  totalDispatched: this.totalDispatched,
1459
- capacity: this.concurrency,
1721
+ capacity: this._concurrency,
1460
1722
  isPaused: this._isPaused,
1461
- circuitState: this.circuitBreakerCoordinator?.state
1723
+ circuitState: this.circuitBreakerCoordinator?.state,
1724
+ adaptive: this.adaptiveCoordinator?.getStats()
1462
1725
  });
1463
1726
  }
1464
1727
  };
@@ -1494,18 +1757,22 @@ var TaskRunner = class {
1494
1757
  attempt = 1;
1495
1758
  /** Duration of the most recent execution attempt in milliseconds */
1496
1759
  lastDurationMs = 0;
1760
+ /** Set of classification tags associated with this task */
1761
+ tags;
1497
1762
  /**
1498
1763
  * Creates a new TaskRunner instance.
1499
1764
  *
1500
1765
  * @param task - The asynchronous work unit to run.
1501
1766
  * @param externalSignal - Optional external AbortSignal to propagate.
1502
1767
  * @param timeoutMs - Optional maximum execution time in milliseconds.
1768
+ * @param tags - Optional array of tags for classifying and selectively cancelling tasks.
1503
1769
  */
1504
- constructor(task, externalSignal, timeoutMs) {
1770
+ constructor(task, externalSignal, timeoutMs, tags) {
1505
1771
  this.taskId = `task_${Date.now().toString(36)}_${(++taskIdCounter).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
1506
1772
  this.task = task;
1507
1773
  this.externalSignal = externalSignal;
1508
1774
  this.timeoutMs = timeoutMs;
1775
+ this.tags = new Set(tags ?? []);
1509
1776
  this.abortController = new AbortController();
1510
1777
  this.promise = new Promise((resolve, reject) => {
1511
1778
  this.resolvePromise = resolve;
@@ -1832,7 +2099,8 @@ var Ahko = class _Ahko {
1832
2099
  return new _Ahko({
1833
2100
  ...profile,
1834
2101
  ...overrides,
1835
- circuitBreaker: overrides?.circuitBreaker ?? profile?.circuitBreaker
2102
+ circuitBreaker: overrides?.circuitBreaker ?? profile?.circuitBreaker,
2103
+ adaptive: overrides?.adaptive ?? profile?.adaptive
1836
2104
  });
1837
2105
  }
1838
2106
  /**
@@ -1851,22 +2119,40 @@ var Ahko = class _Ahko {
1851
2119
  const mergedOptions = {
1852
2120
  ...profile,
1853
2121
  ...options,
1854
- circuitBreaker: options?.circuitBreaker ?? profile?.circuitBreaker
2122
+ circuitBreaker: options?.circuitBreaker ?? profile?.circuitBreaker,
2123
+ adaptive: options?.adaptive ?? profile?.adaptive
1855
2124
  };
1856
2125
  if (profile) {
1857
2126
  this.defaultScheduleOptions = {
1858
2127
  priority: profile.priority,
1859
2128
  retry: profile.retry,
1860
2129
  timeoutMs: profile.timeoutMs,
1861
- totalTimeoutMs: profile.totalTimeoutMs
2130
+ totalTimeoutMs: profile.totalTimeoutMs,
2131
+ tags: profile.tags
1862
2132
  };
1863
2133
  }
1864
2134
  this.queue = new TaskQueue(
1865
2135
  mergedOptions.concurrency,
1866
2136
  mergedOptions.minIntervalMs,
1867
- mergedOptions.circuitBreaker
2137
+ mergedOptions.circuitBreaker,
2138
+ mergedOptions.adaptive
1868
2139
  );
1869
2140
  }
2141
+ /**
2142
+ * Current concurrency limit.
2143
+ */
2144
+ get concurrency() {
2145
+ return this.queue.concurrency;
2146
+ }
2147
+ /**
2148
+ * Dynamically updates the concurrency limit of the scheduler.
2149
+ *
2150
+ * @param concurrency - New maximum concurrency (must be >= 1).
2151
+ * @throws {AhkoConfigurationError} If concurrency is invalid.
2152
+ */
2153
+ setConcurrency(concurrency) {
2154
+ this.queue.setConcurrency(concurrency);
2155
+ }
1870
2156
  /**
1871
2157
  * Pauses scheduler dispatch. In-flight tasks run to completion, but pending tasks remain queued.
1872
2158
  */
@@ -1946,9 +2232,11 @@ var Ahko = class _Ahko {
1946
2232
  if (typeof task !== "function") {
1947
2233
  throw new AhkoConfigurationError("Task must be a valid function.");
1948
2234
  }
2235
+ const mergedTags = options?.tags ?? this.defaultScheduleOptions?.tags;
1949
2236
  const mergedOptions = {
1950
2237
  ...this.defaultScheduleOptions,
1951
- ...options
2238
+ ...options,
2239
+ tags: mergedTags
1952
2240
  };
1953
2241
  const strategy = mergedOptions.strategy ?? "immediate" /* IMMEDIATE */;
1954
2242
  if (strategy === "debounce" /* DEBOUNCE */) {
@@ -1991,7 +2279,12 @@ var Ahko = class _Ahko {
1991
2279
  (t, opts) => this.schedule(t, { ...opts, strategy: "immediate" /* IMMEDIATE */ })
1992
2280
  );
1993
2281
  }
1994
- const runner = new TaskRunner(task, mergedOptions.signal, mergedOptions.timeoutMs);
2282
+ const runner = new TaskRunner(
2283
+ task,
2284
+ mergedOptions.signal,
2285
+ mergedOptions.timeoutMs,
2286
+ mergedOptions.tags
2287
+ );
1995
2288
  return this.queue.enqueue(runner, mergedOptions);
1996
2289
  }
1997
2290
  /**
@@ -2052,6 +2345,176 @@ var Ahko = class _Ahko {
2052
2345
  waitMs
2053
2346
  });
2054
2347
  }
2348
+ /**
2349
+ * Transforms an iterable of items concurrently using an asynchronous mapping function.
2350
+ *
2351
+ * Results are guaranteed to be returned in the original index order.
2352
+ * Concurrency can be capped per-batch or fall back to the scheduler's global limit.
2353
+ *
2354
+ * @template TItem - Type of input elements.
2355
+ * @template TResult - Type of mapped elements.
2356
+ * @param items - Iterable sequence of items to process.
2357
+ * @param fn - Mapper callback receiving item, index, and task context.
2358
+ * @param options - Batch execution options (concurrency, stopOnError, retry, signal, tags, etc.).
2359
+ * @returns Array of transformed results in index order.
2360
+ *
2361
+ * @throws {AhkoConfigurationError} If fn is not a function or concurrency is invalid.
2362
+ * @throws {AhkoCancellationError} If batch or item is cancelled.
2363
+ *
2364
+ * @example
2365
+ * ```typescript
2366
+ * const urls = ["/api/1", "/api/2", "/api/3"];
2367
+ * const data = await ahko.map(urls, async (url, i, { signal }) => {
2368
+ * const res = await fetch(url, { signal });
2369
+ * return res.json();
2370
+ * }, { concurrency: 2 });
2371
+ * ```
2372
+ */
2373
+ async map(items, fn, options) {
2374
+ if (typeof fn !== "function") {
2375
+ throw new AhkoConfigurationError("Mapper function must be a valid function.");
2376
+ }
2377
+ if (options?.concurrency !== void 0 && (typeof options.concurrency !== "number" || Number.isNaN(options.concurrency) || options.concurrency < 1)) {
2378
+ throw new AhkoConfigurationError(
2379
+ `Invalid concurrency "${options.concurrency}". Must be a number greater than or equal to 1.`
2380
+ );
2381
+ }
2382
+ const list = Array.from(items);
2383
+ if (list.length === 0) {
2384
+ return [];
2385
+ }
2386
+ const { concurrency, stopOnError = false, signal: externalSignal, ...scheduleOpts } = options ?? {};
2387
+ if (externalSignal?.aborted) {
2388
+ throw new AhkoCancellationError(
2389
+ externalSignal.reason ? `Batch cancelled: ${String(externalSignal.reason)}` : "Batch cancelled"
2390
+ );
2391
+ }
2392
+ const abortController = new AbortController();
2393
+ const results = new Array(list.length);
2394
+ let firstError = void 0;
2395
+ let hasAborted = false;
2396
+ const localizedLimit = concurrency !== void 0 ? Math.floor(concurrency) : Number.isFinite(this.concurrency) ? this.concurrency : Infinity;
2397
+ return new Promise((resolve, reject) => {
2398
+ let currentIndex = 0;
2399
+ let activeCount = 0;
2400
+ let settledCount = 0;
2401
+ const onExternalAbort = () => {
2402
+ const reason = externalSignal?.reason ?? "Batch cancelled by external signal";
2403
+ const err = new AhkoCancellationError(
2404
+ typeof reason === "string" ? reason : "Batch cancelled by external signal"
2405
+ );
2406
+ cleanupAndReject(err);
2407
+ };
2408
+ if (externalSignal) {
2409
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
2410
+ }
2411
+ const cleanupAndReject = (err) => {
2412
+ if (!hasAborted) {
2413
+ hasAborted = true;
2414
+ abortController.abort(err);
2415
+ }
2416
+ if (externalSignal) {
2417
+ externalSignal.removeEventListener("abort", onExternalAbort);
2418
+ }
2419
+ reject(err);
2420
+ };
2421
+ const checkCompletion = () => {
2422
+ if (settledCount === list.length) {
2423
+ if (externalSignal) {
2424
+ externalSignal.removeEventListener("abort", onExternalAbort);
2425
+ }
2426
+ if (firstError !== void 0) {
2427
+ reject(firstError);
2428
+ } else {
2429
+ resolve(results);
2430
+ }
2431
+ }
2432
+ };
2433
+ const launchNext = () => {
2434
+ if (hasAborted && stopOnError) {
2435
+ return;
2436
+ }
2437
+ while (currentIndex < list.length && activeCount < localizedLimit && !(hasAborted && stopOnError)) {
2438
+ const index = currentIndex++;
2439
+ const item = list[index];
2440
+ activeCount++;
2441
+ const taskPromise = this.schedule(
2442
+ (context) => fn(item, index, context),
2443
+ {
2444
+ ...scheduleOpts,
2445
+ signal: abortController.signal
2446
+ }
2447
+ );
2448
+ taskPromise.then((result) => {
2449
+ results[index] = result;
2450
+ }).catch((err) => {
2451
+ if (firstError === void 0) {
2452
+ firstError = err;
2453
+ }
2454
+ if (stopOnError && !hasAborted) {
2455
+ cleanupAndReject(err);
2456
+ return;
2457
+ }
2458
+ }).finally(() => {
2459
+ activeCount--;
2460
+ settledCount++;
2461
+ if (hasAborted && stopOnError) {
2462
+ return;
2463
+ }
2464
+ if (currentIndex < list.length) {
2465
+ launchNext();
2466
+ } else {
2467
+ checkCompletion();
2468
+ }
2469
+ });
2470
+ }
2471
+ };
2472
+ if (abortController.signal.aborted) {
2473
+ cleanupAndReject(abortController.signal.reason);
2474
+ return;
2475
+ }
2476
+ launchNext();
2477
+ });
2478
+ }
2479
+ /**
2480
+ * Iterates sequentially or concurrently over an iterable sequence of items,
2481
+ * executing the callback function for each element.
2482
+ *
2483
+ * @template TItem - Type of input elements.
2484
+ * @param items - Iterable sequence of items to process.
2485
+ * @param fn - Callback receiving item, index, and task context.
2486
+ * @param options - Batch execution options.
2487
+ * @returns Promise resolving once all items have finished executing.
2488
+ *
2489
+ * @example
2490
+ * ```typescript
2491
+ * await ahko.each(userQueue, async (user, index, { signal }) => {
2492
+ * await sendWelcomeEmail(user, { signal });
2493
+ * }, { concurrency: 5 });
2494
+ * ```
2495
+ */
2496
+ async each(items, fn, options) {
2497
+ await this.map(items, fn, options);
2498
+ }
2499
+ /**
2500
+ * Cancels all pending, delayed, and active tasks tagged with the given tag.
2501
+ *
2502
+ * @param tag - Tag identifier.
2503
+ * @param reason - Optional cancellation reason.
2504
+ * @returns Total number of tasks cancelled.
2505
+ */
2506
+ cancelByTag(tag, reason) {
2507
+ return this.queue.cancelByTag(tag, reason);
2508
+ }
2509
+ /**
2510
+ * Retrieves active and pending task counts for a given tag.
2511
+ *
2512
+ * @param tag - Tag identifier.
2513
+ * @returns Object with activeTasks and pendingTasks counts.
2514
+ */
2515
+ statsByTag(tag) {
2516
+ return this.queue.getStatsByTag(tag);
2517
+ }
2055
2518
  /**
2056
2519
  * Retrieves real-time telemetry metrics from the scheduler.
2057
2520
  *
@@ -2146,7 +2609,7 @@ var Ahko = class _Ahko {
2146
2609
  };
2147
2610
 
2148
2611
  // src/version.ts
2149
- var VERSION = "1.1.0";
2612
+ var VERSION = "1.1.5";
2150
2613
 
2151
2614
  // src/errors/queue.error.ts
2152
2615
  var AhkoQueueError = class extends AhkoError {
@@ -2219,6 +2682,7 @@ function combineSignals(signals) {
2219
2682
  }
2220
2683
  // Annotate the CommonJS export names for ESM import in node:
2221
2684
  0 && (module.exports = {
2685
+ AdaptiveCoordinator,
2222
2686
  Ahko,
2223
2687
  AhkoCancellationError,
2224
2688
  AhkoCircuitBreakerOpenError,