@icazemier/sway 1.0.0-beta.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Ivo Cazemier
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ <img src="gibbons.png" width="200" />
2
+
3
+ # @icazemier/sway
4
+
5
+ `Promise.all()` with adaptive concurrency control. A gradient-based controller continuously measures throughput and adjusts the concurrency level to maximise task completion speed — zero dependencies.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @icazemier/sway
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { sway } from '@icazemier/sway';
17
+
18
+ const { results, stats } = await sway(
19
+ urls.map(url => () => fetch(url).then(r => r.json())),
20
+ { maxConcurrency: 16 }
21
+ );
22
+
23
+ console.log(results); // resolved values in original order
24
+ console.log(stats.peakConcurrency); // highest concurrency reached
25
+ console.log(stats.avgConcurrency); // average concurrency across the run
26
+ ```
27
+
28
+ ## How it works
29
+
30
+ Sway starts at `initialConcurrency` and probes every `probeInterval` completed tasks. Each probe measures throughput (tasks/sec) using an exponential moving average and compares it to the previous measurement:
31
+
32
+ - **Gradient > 0** — throughput improving, increase concurrency by 1
33
+ - **Gradient < 0** — throughput degrading, decrease concurrency by 1
34
+ - **Gradient = 0** — at optimum, hold steady
35
+
36
+ Concurrency is always clamped between `minConcurrency` and `maxConcurrency`. The `smoothingFactor` controls how responsive the EMA is to change (lower = calmer).
37
+
38
+ ## Options
39
+
40
+ All values are **counts** or **ratios** — no time-based units.
41
+
42
+ | Option | Default | Unit | Description |
43
+ | -------------------- | ------- | ----- | --------------------------------------------- |
44
+ | `maxConcurrency` | `64` | tasks | Max concurrent in-flight tasks |
45
+ | `minConcurrency` | `1` | tasks | Min concurrent in-flight tasks |
46
+ | `initialConcurrency` | `4` | tasks | Concurrent in-flight tasks to start with |
47
+ | `smoothingFactor` | `0.3` | ratio | EMA smoothing (0–1), lower = calmer |
48
+ | `probeInterval` | `8` | tasks | Completed tasks between probe adjustments |
49
+
50
+ ## Error handling
51
+
52
+ Sway rejects on the first error, just like `Promise.all()`.
53
+
54
+ ```ts
55
+ try {
56
+ await sway(tasks);
57
+ } catch (err) {
58
+ // first task rejection
59
+ }
60
+ ```
61
+
62
+ ## Iterables
63
+
64
+ Accepts any `Iterable` — arrays, generators, or custom iterables. Tasks are pulled lazily from the iterator.
65
+
66
+ ```ts
67
+ function* generateTasks() {
68
+ for (const id of ids) {
69
+ yield () => processItem(id);
70
+ }
71
+ }
72
+
73
+ const { results } = await sway(generateTasks());
74
+ ```
75
+
76
+ ## Advanced: AdaptiveController
77
+
78
+ The controller is exported separately for custom integrations.
79
+
80
+ ```ts
81
+ import { AdaptiveController } from '@icazemier/sway';
82
+
83
+ const controller = new AdaptiveController({ maxConcurrency: 32 });
84
+ controller.getConcurrency(); // current level
85
+ controller.recordCompletion(); // signal a completed task
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,54 @@
1
+ import { SwayOptions, SwayStats } from './interfaces.js';
2
+ /**
3
+ * Gradient-based concurrency controller.
4
+ *
5
+ * Measures throughput via an exponential moving average (EMA) and adjusts the
6
+ * concurrency level to maximise task completion speed. The controller probes
7
+ * every {@link SwayOptions.probeInterval | probeInterval} completed tasks,
8
+ * compares the current EMA throughput against the previous one, and nudges
9
+ * concurrency up or down by one depending on the gradient direction.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const controller = new AdaptiveController({ maxConcurrency: 16 });
14
+ * controller.getConcurrency(); // 4 (default initial)
15
+ * controller.recordCompletion();
16
+ * ```
17
+ */
18
+ export declare class AdaptiveController {
19
+ private concurrency;
20
+ private readonly maxConcurrency;
21
+ private readonly minConcurrency;
22
+ private readonly smoothingFactor;
23
+ private readonly probeInterval;
24
+ private completionsSinceLastProbe;
25
+ private emaThroughput;
26
+ private previousEmaThroughput;
27
+ private lastProbeTime;
28
+ private peakConcurrency;
29
+ private concurrencySum;
30
+ private concurrencySamples;
31
+ private adjustmentCount;
32
+ /**
33
+ * @param options - Tuning knobs for the controller (all optional)
34
+ */
35
+ constructor(options?: SwayOptions);
36
+ /**
37
+ * Signal that a task has completed. Triggers a probe when
38
+ * {@link SwayOptions.probeInterval | probeInterval} completions have accumulated.
39
+ */
40
+ recordCompletion(): void;
41
+ /** Returns the current concurrency level. */
42
+ getConcurrency(): number;
43
+ /**
44
+ * Build a {@link SwayStats} snapshot.
45
+ *
46
+ * @param totalTasks - Total tasks executed
47
+ * @param totalDurationMs - Wall-clock duration of the run in milliseconds
48
+ * @returns Performance telemetry
49
+ */
50
+ getStats(totalTasks: number, totalDurationMs: number): SwayStats;
51
+ private probe;
52
+ private setConcurrency;
53
+ private clamp;
54
+ }
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AdaptiveController = void 0;
4
+ const DEFAULT_MAX_CONCURRENCY = 64;
5
+ const DEFAULT_MIN_CONCURRENCY = 1;
6
+ const DEFAULT_INITIAL_CONCURRENCY = 4;
7
+ const DEFAULT_SMOOTHING_FACTOR = 0.3;
8
+ const DEFAULT_PROBE_INTERVAL = 8;
9
+ /**
10
+ * Gradient-based concurrency controller.
11
+ *
12
+ * Measures throughput via an exponential moving average (EMA) and adjusts the
13
+ * concurrency level to maximise task completion speed. The controller probes
14
+ * every {@link SwayOptions.probeInterval | probeInterval} completed tasks,
15
+ * compares the current EMA throughput against the previous one, and nudges
16
+ * concurrency up or down by one depending on the gradient direction.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const controller = new AdaptiveController({ maxConcurrency: 16 });
21
+ * controller.getConcurrency(); // 4 (default initial)
22
+ * controller.recordCompletion();
23
+ * ```
24
+ */
25
+ class AdaptiveController {
26
+ /**
27
+ * @param options - Tuning knobs for the controller (all optional)
28
+ */
29
+ constructor(options) {
30
+ var _a, _b, _c, _d, _e;
31
+ this.completionsSinceLastProbe = 0;
32
+ this.emaThroughput = null;
33
+ this.previousEmaThroughput = null;
34
+ this.concurrencySum = 0;
35
+ this.concurrencySamples = 0;
36
+ this.adjustmentCount = 0;
37
+ this.maxConcurrency = (_a = options === null || options === void 0 ? void 0 : options.maxConcurrency) !== null && _a !== void 0 ? _a : DEFAULT_MAX_CONCURRENCY;
38
+ this.minConcurrency = (_b = options === null || options === void 0 ? void 0 : options.minConcurrency) !== null && _b !== void 0 ? _b : DEFAULT_MIN_CONCURRENCY;
39
+ this.concurrency =
40
+ (_c = options === null || options === void 0 ? void 0 : options.initialConcurrency) !== null && _c !== void 0 ? _c : DEFAULT_INITIAL_CONCURRENCY;
41
+ this.smoothingFactor = (_d = options === null || options === void 0 ? void 0 : options.smoothingFactor) !== null && _d !== void 0 ? _d : DEFAULT_SMOOTHING_FACTOR;
42
+ this.probeInterval = (_e = options === null || options === void 0 ? void 0 : options.probeInterval) !== null && _e !== void 0 ? _e : DEFAULT_PROBE_INTERVAL;
43
+ this.concurrency = this.clamp(this.concurrency);
44
+ this.peakConcurrency = this.concurrency;
45
+ this.lastProbeTime = performance.now();
46
+ }
47
+ /**
48
+ * Signal that a task has completed. Triggers a probe when
49
+ * {@link SwayOptions.probeInterval | probeInterval} completions have accumulated.
50
+ */
51
+ recordCompletion() {
52
+ this.completionsSinceLastProbe++;
53
+ this.concurrencySum += this.concurrency;
54
+ this.concurrencySamples++;
55
+ if (this.completionsSinceLastProbe >= this.probeInterval) {
56
+ this.probe();
57
+ }
58
+ }
59
+ /** Returns the current concurrency level. */
60
+ getConcurrency() {
61
+ return this.concurrency;
62
+ }
63
+ /**
64
+ * Build a {@link SwayStats} snapshot.
65
+ *
66
+ * @param totalTasks - Total tasks executed
67
+ * @param totalDurationMs - Wall-clock duration of the run in milliseconds
68
+ * @returns Performance telemetry
69
+ */
70
+ getStats(totalTasks, totalDurationMs) {
71
+ return {
72
+ totalTasks,
73
+ totalDurationMs,
74
+ peakConcurrency: this.peakConcurrency,
75
+ avgConcurrency: this.concurrencySamples > 0
76
+ ? this.concurrencySum / this.concurrencySamples
77
+ : this.concurrency,
78
+ adjustments: this.adjustmentCount,
79
+ };
80
+ }
81
+ probe() {
82
+ const now = performance.now();
83
+ const elapsed = now - this.lastProbeTime;
84
+ if (elapsed <= 0) {
85
+ this.completionsSinceLastProbe = 0;
86
+ this.lastProbeTime = now;
87
+ return;
88
+ }
89
+ const currentThroughput = this.completionsSinceLastProbe / (elapsed / 1000);
90
+ if (this.emaThroughput === null) {
91
+ this.emaThroughput = currentThroughput;
92
+ }
93
+ else {
94
+ this.emaThroughput =
95
+ this.smoothingFactor * currentThroughput +
96
+ (1 - this.smoothingFactor) * this.emaThroughput;
97
+ }
98
+ if (this.previousEmaThroughput !== null) {
99
+ const gradient = this.emaThroughput - this.previousEmaThroughput;
100
+ if (gradient > 0) {
101
+ this.setConcurrency(this.concurrency + 1);
102
+ }
103
+ else if (gradient < 0) {
104
+ this.setConcurrency(this.concurrency - 1);
105
+ }
106
+ }
107
+ this.previousEmaThroughput = this.emaThroughput;
108
+ this.completionsSinceLastProbe = 0;
109
+ this.lastProbeTime = now;
110
+ }
111
+ setConcurrency(value) {
112
+ const clamped = this.clamp(value);
113
+ if (clamped !== this.concurrency) {
114
+ this.concurrency = clamped;
115
+ this.adjustmentCount++;
116
+ if (this.concurrency > this.peakConcurrency) {
117
+ this.peakConcurrency = this.concurrency;
118
+ }
119
+ }
120
+ }
121
+ clamp(value) {
122
+ return Math.max(this.minConcurrency, Math.min(this.maxConcurrency, value));
123
+ }
124
+ }
125
+ exports.AdaptiveController = AdaptiveController;
@@ -0,0 +1,3 @@
1
+ export { sway } from './sway.js';
2
+ export { AdaptiveController } from './adaptive-controller.js';
3
+ export type { SwayOptions, SwayResult, SwayStats } from './interfaces.js';
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AdaptiveController = exports.sway = void 0;
4
+ var sway_js_1 = require("./sway.js");
5
+ Object.defineProperty(exports, "sway", { enumerable: true, get: function () { return sway_js_1.sway; } });
6
+ var adaptive_controller_js_1 = require("./adaptive-controller.js");
7
+ Object.defineProperty(exports, "AdaptiveController", { enumerable: true, get: function () { return adaptive_controller_js_1.AdaptiveController; } });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Configuration options for {@link sway}.
3
+ */
4
+ export interface SwayOptions {
5
+ /** Upper bound for concurrent in-flight tasks (default: `64`) */
6
+ maxConcurrency?: number;
7
+ /** Lower bound for concurrent in-flight tasks (default: `1`) */
8
+ minConcurrency?: number;
9
+ /** Number of concurrent in-flight tasks to start with (default: `4`) */
10
+ initialConcurrency?: number;
11
+ /** EMA smoothing factor, ratio between 0 and 1 — lower values produce calmer adjustments (default: `0.3`) */
12
+ smoothingFactor?: number;
13
+ /** Number of completed tasks between probe adjustments (default: `8`) */
14
+ probeInterval?: number;
15
+ }
16
+ /**
17
+ * Performance telemetry collected during a {@link sway} run.
18
+ */
19
+ export interface SwayStats {
20
+ /** Total number of tasks that were executed */
21
+ totalTasks: number;
22
+ /** Wall-clock duration of the entire run in milliseconds */
23
+ totalDurationMs: number;
24
+ /** Highest concurrency level reached */
25
+ peakConcurrency: number;
26
+ /** Weighted average concurrency level across the run */
27
+ avgConcurrency: number;
28
+ /** Number of times the controller changed the concurrency level */
29
+ adjustments: number;
30
+ }
31
+ /**
32
+ * Return value of {@link sway}.
33
+ *
34
+ * @typeParam T - The resolved type of each task
35
+ */
36
+ export interface SwayResult<T> {
37
+ /** Resolved values in the same order as the input tasks */
38
+ results: T[];
39
+ /** Performance telemetry for the run */
40
+ stats: SwayStats;
41
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,25 @@
1
+ import { SwayOptions, SwayResult } from './interfaces.js';
2
+ /**
3
+ * Run async tasks concurrently with adaptive concurrency control.
4
+ *
5
+ * Works like `Promise.all()` but automatically tunes the number of in-flight
6
+ * tasks using a gradient-based controller that maximises throughput.
7
+ * Rejects on the first error (fail-fast), just like `Promise.all()`.
8
+ *
9
+ * @typeParam T - The resolved type of each task
10
+ * @param tasks - An iterable of zero-argument async functions (thunks)
11
+ * @param options - Optional tuning parameters for the adaptive controller
12
+ * @returns Resolved values in input order together with performance stats
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { sway } from '@icazemier/sway';
17
+ *
18
+ * const { results, stats } = await sway(
19
+ * urls.map(url => () => fetch(url).then(r => r.json())),
20
+ * { maxConcurrency: 16 }
21
+ * );
22
+ * console.log(stats.peakConcurrency, stats.avgConcurrency);
23
+ * ```
24
+ */
25
+ export declare function sway<T>(tasks: Iterable<() => Promise<T>>, options?: SwayOptions): Promise<SwayResult<T>>;
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sway = sway;
4
+ const adaptive_controller_js_1 = require("./adaptive-controller.js");
5
+ /**
6
+ * Run async tasks concurrently with adaptive concurrency control.
7
+ *
8
+ * Works like `Promise.all()` but automatically tunes the number of in-flight
9
+ * tasks using a gradient-based controller that maximises throughput.
10
+ * Rejects on the first error (fail-fast), just like `Promise.all()`.
11
+ *
12
+ * @typeParam T - The resolved type of each task
13
+ * @param tasks - An iterable of zero-argument async functions (thunks)
14
+ * @param options - Optional tuning parameters for the adaptive controller
15
+ * @returns Resolved values in input order together with performance stats
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { sway } from '@icazemier/sway';
20
+ *
21
+ * const { results, stats } = await sway(
22
+ * urls.map(url => () => fetch(url).then(r => r.json())),
23
+ * { maxConcurrency: 16 }
24
+ * );
25
+ * console.log(stats.peakConcurrency, stats.avgConcurrency);
26
+ * ```
27
+ */
28
+ async function sway(tasks, options) {
29
+ const controller = new adaptive_controller_js_1.AdaptiveController(options);
30
+ const iterator = tasks[Symbol.iterator]();
31
+ const results = [];
32
+ const startTime = performance.now();
33
+ let nextIndex = 0;
34
+ let activeTasks = 0;
35
+ let settled = false;
36
+ let totalTasks = 0;
37
+ return new Promise((resolve, reject) => {
38
+ const tryReject = (error) => {
39
+ if (!settled) {
40
+ settled = true;
41
+ reject(error);
42
+ }
43
+ };
44
+ const scheduleNext = () => {
45
+ while (!settled && activeTasks < controller.getConcurrency()) {
46
+ const next = iterator.next();
47
+ if (next.done) {
48
+ if (activeTasks === 0) {
49
+ settled = true;
50
+ const totalDurationMs = performance.now() - startTime;
51
+ resolve({
52
+ results,
53
+ stats: controller.getStats(totalTasks, totalDurationMs),
54
+ });
55
+ }
56
+ return;
57
+ }
58
+ const index = nextIndex++;
59
+ totalTasks++;
60
+ activeTasks++;
61
+ next
62
+ .value()
63
+ .then((value) => {
64
+ if (settled)
65
+ return;
66
+ results[index] = value;
67
+ activeTasks--;
68
+ controller.recordCompletion();
69
+ scheduleNext();
70
+ })
71
+ .catch(tryReject);
72
+ }
73
+ };
74
+ scheduleNext();
75
+ if (nextIndex === 0 && activeTasks === 0) {
76
+ settled = true;
77
+ const totalDurationMs = performance.now() - startTime;
78
+ resolve({
79
+ results,
80
+ stats: controller.getStats(0, totalDurationMs),
81
+ });
82
+ }
83
+ });
84
+ }
@@ -0,0 +1,54 @@
1
+ import { SwayOptions, SwayStats } from './interfaces.js';
2
+ /**
3
+ * Gradient-based concurrency controller.
4
+ *
5
+ * Measures throughput via an exponential moving average (EMA) and adjusts the
6
+ * concurrency level to maximise task completion speed. The controller probes
7
+ * every {@link SwayOptions.probeInterval | probeInterval} completed tasks,
8
+ * compares the current EMA throughput against the previous one, and nudges
9
+ * concurrency up or down by one depending on the gradient direction.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const controller = new AdaptiveController({ maxConcurrency: 16 });
14
+ * controller.getConcurrency(); // 4 (default initial)
15
+ * controller.recordCompletion();
16
+ * ```
17
+ */
18
+ export declare class AdaptiveController {
19
+ private concurrency;
20
+ private readonly maxConcurrency;
21
+ private readonly minConcurrency;
22
+ private readonly smoothingFactor;
23
+ private readonly probeInterval;
24
+ private completionsSinceLastProbe;
25
+ private emaThroughput;
26
+ private previousEmaThroughput;
27
+ private lastProbeTime;
28
+ private peakConcurrency;
29
+ private concurrencySum;
30
+ private concurrencySamples;
31
+ private adjustmentCount;
32
+ /**
33
+ * @param options - Tuning knobs for the controller (all optional)
34
+ */
35
+ constructor(options?: SwayOptions);
36
+ /**
37
+ * Signal that a task has completed. Triggers a probe when
38
+ * {@link SwayOptions.probeInterval | probeInterval} completions have accumulated.
39
+ */
40
+ recordCompletion(): void;
41
+ /** Returns the current concurrency level. */
42
+ getConcurrency(): number;
43
+ /**
44
+ * Build a {@link SwayStats} snapshot.
45
+ *
46
+ * @param totalTasks - Total tasks executed
47
+ * @param totalDurationMs - Wall-clock duration of the run in milliseconds
48
+ * @returns Performance telemetry
49
+ */
50
+ getStats(totalTasks: number, totalDurationMs: number): SwayStats;
51
+ private probe;
52
+ private setConcurrency;
53
+ private clamp;
54
+ }
@@ -0,0 +1,127 @@
1
+ const DEFAULT_MAX_CONCURRENCY = 64;
2
+ const DEFAULT_MIN_CONCURRENCY = 1;
3
+ const DEFAULT_INITIAL_CONCURRENCY = 4;
4
+ const DEFAULT_SMOOTHING_FACTOR = 0.3;
5
+ const DEFAULT_PROBE_INTERVAL = 8;
6
+ /**
7
+ * Gradient-based concurrency controller.
8
+ *
9
+ * Measures throughput via an exponential moving average (EMA) and adjusts the
10
+ * concurrency level to maximise task completion speed. The controller probes
11
+ * every {@link SwayOptions.probeInterval | probeInterval} completed tasks,
12
+ * compares the current EMA throughput against the previous one, and nudges
13
+ * concurrency up or down by one depending on the gradient direction.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * const controller = new AdaptiveController({ maxConcurrency: 16 });
18
+ * controller.getConcurrency(); // 4 (default initial)
19
+ * controller.recordCompletion();
20
+ * ```
21
+ */
22
+ export class AdaptiveController {
23
+ concurrency;
24
+ maxConcurrency;
25
+ minConcurrency;
26
+ smoothingFactor;
27
+ probeInterval;
28
+ completionsSinceLastProbe = 0;
29
+ emaThroughput = null;
30
+ previousEmaThroughput = null;
31
+ lastProbeTime;
32
+ peakConcurrency;
33
+ concurrencySum = 0;
34
+ concurrencySamples = 0;
35
+ adjustmentCount = 0;
36
+ /**
37
+ * @param options - Tuning knobs for the controller (all optional)
38
+ */
39
+ constructor(options) {
40
+ this.maxConcurrency = options?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
41
+ this.minConcurrency = options?.minConcurrency ?? DEFAULT_MIN_CONCURRENCY;
42
+ this.concurrency =
43
+ options?.initialConcurrency ?? DEFAULT_INITIAL_CONCURRENCY;
44
+ this.smoothingFactor = options?.smoothingFactor ?? DEFAULT_SMOOTHING_FACTOR;
45
+ this.probeInterval = options?.probeInterval ?? DEFAULT_PROBE_INTERVAL;
46
+ this.concurrency = this.clamp(this.concurrency);
47
+ this.peakConcurrency = this.concurrency;
48
+ this.lastProbeTime = performance.now();
49
+ }
50
+ /**
51
+ * Signal that a task has completed. Triggers a probe when
52
+ * {@link SwayOptions.probeInterval | probeInterval} completions have accumulated.
53
+ */
54
+ recordCompletion() {
55
+ this.completionsSinceLastProbe++;
56
+ this.concurrencySum += this.concurrency;
57
+ this.concurrencySamples++;
58
+ if (this.completionsSinceLastProbe >= this.probeInterval) {
59
+ this.probe();
60
+ }
61
+ }
62
+ /** Returns the current concurrency level. */
63
+ getConcurrency() {
64
+ return this.concurrency;
65
+ }
66
+ /**
67
+ * Build a {@link SwayStats} snapshot.
68
+ *
69
+ * @param totalTasks - Total tasks executed
70
+ * @param totalDurationMs - Wall-clock duration of the run in milliseconds
71
+ * @returns Performance telemetry
72
+ */
73
+ getStats(totalTasks, totalDurationMs) {
74
+ return {
75
+ totalTasks,
76
+ totalDurationMs,
77
+ peakConcurrency: this.peakConcurrency,
78
+ avgConcurrency: this.concurrencySamples > 0
79
+ ? this.concurrencySum / this.concurrencySamples
80
+ : this.concurrency,
81
+ adjustments: this.adjustmentCount,
82
+ };
83
+ }
84
+ probe() {
85
+ const now = performance.now();
86
+ const elapsed = now - this.lastProbeTime;
87
+ if (elapsed <= 0) {
88
+ this.completionsSinceLastProbe = 0;
89
+ this.lastProbeTime = now;
90
+ return;
91
+ }
92
+ const currentThroughput = this.completionsSinceLastProbe / (elapsed / 1000);
93
+ if (this.emaThroughput === null) {
94
+ this.emaThroughput = currentThroughput;
95
+ }
96
+ else {
97
+ this.emaThroughput =
98
+ this.smoothingFactor * currentThroughput +
99
+ (1 - this.smoothingFactor) * this.emaThroughput;
100
+ }
101
+ if (this.previousEmaThroughput !== null) {
102
+ const gradient = this.emaThroughput - this.previousEmaThroughput;
103
+ if (gradient > 0) {
104
+ this.setConcurrency(this.concurrency + 1);
105
+ }
106
+ else if (gradient < 0) {
107
+ this.setConcurrency(this.concurrency - 1);
108
+ }
109
+ }
110
+ this.previousEmaThroughput = this.emaThroughput;
111
+ this.completionsSinceLastProbe = 0;
112
+ this.lastProbeTime = now;
113
+ }
114
+ setConcurrency(value) {
115
+ const clamped = this.clamp(value);
116
+ if (clamped !== this.concurrency) {
117
+ this.concurrency = clamped;
118
+ this.adjustmentCount++;
119
+ if (this.concurrency > this.peakConcurrency) {
120
+ this.peakConcurrency = this.concurrency;
121
+ }
122
+ }
123
+ }
124
+ clamp(value) {
125
+ return Math.max(this.minConcurrency, Math.min(this.maxConcurrency, value));
126
+ }
127
+ }
@@ -0,0 +1,3 @@
1
+ export { sway } from './sway.js';
2
+ export { AdaptiveController } from './adaptive-controller.js';
3
+ export type { SwayOptions, SwayResult, SwayStats } from './interfaces.js';
@@ -0,0 +1,2 @@
1
+ export { sway } from './sway.js';
2
+ export { AdaptiveController } from './adaptive-controller.js';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Configuration options for {@link sway}.
3
+ */
4
+ export interface SwayOptions {
5
+ /** Upper bound for concurrent in-flight tasks (default: `64`) */
6
+ maxConcurrency?: number;
7
+ /** Lower bound for concurrent in-flight tasks (default: `1`) */
8
+ minConcurrency?: number;
9
+ /** Number of concurrent in-flight tasks to start with (default: `4`) */
10
+ initialConcurrency?: number;
11
+ /** EMA smoothing factor, ratio between 0 and 1 — lower values produce calmer adjustments (default: `0.3`) */
12
+ smoothingFactor?: number;
13
+ /** Number of completed tasks between probe adjustments (default: `8`) */
14
+ probeInterval?: number;
15
+ }
16
+ /**
17
+ * Performance telemetry collected during a {@link sway} run.
18
+ */
19
+ export interface SwayStats {
20
+ /** Total number of tasks that were executed */
21
+ totalTasks: number;
22
+ /** Wall-clock duration of the entire run in milliseconds */
23
+ totalDurationMs: number;
24
+ /** Highest concurrency level reached */
25
+ peakConcurrency: number;
26
+ /** Weighted average concurrency level across the run */
27
+ avgConcurrency: number;
28
+ /** Number of times the controller changed the concurrency level */
29
+ adjustments: number;
30
+ }
31
+ /**
32
+ * Return value of {@link sway}.
33
+ *
34
+ * @typeParam T - The resolved type of each task
35
+ */
36
+ export interface SwayResult<T> {
37
+ /** Resolved values in the same order as the input tasks */
38
+ results: T[];
39
+ /** Performance telemetry for the run */
40
+ stats: SwayStats;
41
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,25 @@
1
+ import { SwayOptions, SwayResult } from './interfaces.js';
2
+ /**
3
+ * Run async tasks concurrently with adaptive concurrency control.
4
+ *
5
+ * Works like `Promise.all()` but automatically tunes the number of in-flight
6
+ * tasks using a gradient-based controller that maximises throughput.
7
+ * Rejects on the first error (fail-fast), just like `Promise.all()`.
8
+ *
9
+ * @typeParam T - The resolved type of each task
10
+ * @param tasks - An iterable of zero-argument async functions (thunks)
11
+ * @param options - Optional tuning parameters for the adaptive controller
12
+ * @returns Resolved values in input order together with performance stats
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { sway } from '@icazemier/sway';
17
+ *
18
+ * const { results, stats } = await sway(
19
+ * urls.map(url => () => fetch(url).then(r => r.json())),
20
+ * { maxConcurrency: 16 }
21
+ * );
22
+ * console.log(stats.peakConcurrency, stats.avgConcurrency);
23
+ * ```
24
+ */
25
+ export declare function sway<T>(tasks: Iterable<() => Promise<T>>, options?: SwayOptions): Promise<SwayResult<T>>;
@@ -0,0 +1,81 @@
1
+ import { AdaptiveController } from './adaptive-controller.js';
2
+ /**
3
+ * Run async tasks concurrently with adaptive concurrency control.
4
+ *
5
+ * Works like `Promise.all()` but automatically tunes the number of in-flight
6
+ * tasks using a gradient-based controller that maximises throughput.
7
+ * Rejects on the first error (fail-fast), just like `Promise.all()`.
8
+ *
9
+ * @typeParam T - The resolved type of each task
10
+ * @param tasks - An iterable of zero-argument async functions (thunks)
11
+ * @param options - Optional tuning parameters for the adaptive controller
12
+ * @returns Resolved values in input order together with performance stats
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { sway } from '@icazemier/sway';
17
+ *
18
+ * const { results, stats } = await sway(
19
+ * urls.map(url => () => fetch(url).then(r => r.json())),
20
+ * { maxConcurrency: 16 }
21
+ * );
22
+ * console.log(stats.peakConcurrency, stats.avgConcurrency);
23
+ * ```
24
+ */
25
+ export async function sway(tasks, options) {
26
+ const controller = new AdaptiveController(options);
27
+ const iterator = tasks[Symbol.iterator]();
28
+ const results = [];
29
+ const startTime = performance.now();
30
+ let nextIndex = 0;
31
+ let activeTasks = 0;
32
+ let settled = false;
33
+ let totalTasks = 0;
34
+ return new Promise((resolve, reject) => {
35
+ const tryReject = (error) => {
36
+ if (!settled) {
37
+ settled = true;
38
+ reject(error);
39
+ }
40
+ };
41
+ const scheduleNext = () => {
42
+ while (!settled && activeTasks < controller.getConcurrency()) {
43
+ const next = iterator.next();
44
+ if (next.done) {
45
+ if (activeTasks === 0) {
46
+ settled = true;
47
+ const totalDurationMs = performance.now() - startTime;
48
+ resolve({
49
+ results,
50
+ stats: controller.getStats(totalTasks, totalDurationMs),
51
+ });
52
+ }
53
+ return;
54
+ }
55
+ const index = nextIndex++;
56
+ totalTasks++;
57
+ activeTasks++;
58
+ next
59
+ .value()
60
+ .then((value) => {
61
+ if (settled)
62
+ return;
63
+ results[index] = value;
64
+ activeTasks--;
65
+ controller.recordCompletion();
66
+ scheduleNext();
67
+ })
68
+ .catch(tryReject);
69
+ }
70
+ };
71
+ scheduleNext();
72
+ if (nextIndex === 0 && activeTasks === 0) {
73
+ settled = true;
74
+ const totalDurationMs = performance.now() - startTime;
75
+ resolve({
76
+ results,
77
+ stats: controller.getStats(0, totalDurationMs),
78
+ });
79
+ }
80
+ });
81
+ }
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@icazemier/sway",
3
+ "private": false,
4
+ "version": "1.0.0-beta.1",
5
+ "type": "module",
6
+ "description": "Adaptive concurrent task runner — like Promise.all() with gradient-based concurrency control",
7
+ "contributors": [
8
+ {
9
+ "name": "Ivo Cazemier",
10
+ "email": "git@warmemelk.nl",
11
+ "url": "https://github.com/icazemier"
12
+ }
13
+ ],
14
+ "keywords": [
15
+ "concurrency",
16
+ "async",
17
+ "promise",
18
+ "parallel",
19
+ "adaptive",
20
+ "throttle",
21
+ "task runner"
22
+ ],
23
+ "files": [
24
+ "build/"
25
+ ],
26
+ "main": "./build/cjs/index.js",
27
+ "module": "./build/esm/index.js",
28
+ "exports": {
29
+ ".": {
30
+ "import": "./build/esm/index.js",
31
+ "require": "./build/cjs/index.js"
32
+ }
33
+ },
34
+ "types": "./build/esm/index.d.ts",
35
+ "engines": {
36
+ "node": ">=20.0.0"
37
+ },
38
+ "os": [
39
+ "darwin",
40
+ "linux",
41
+ "win32"
42
+ ],
43
+ "scripts": {
44
+ "test": "cross-env NODE_ENV=test vitest run --coverage",
45
+ "build:esm": "tsc -p tsconfig-build-esm.json",
46
+ "build:cjs": "tsc -p tsconfig-build-cjs.json",
47
+ "build": "rimraf build && npm run build:esm && npm run build:cjs && npm run fixup && npm run docs",
48
+ "docs": "typedoc --out docs src/index.ts",
49
+ "fixup": "node ./fixup.mjs",
50
+ "clean:build": "rimraf build",
51
+ "clean:docs": "rimraf docs",
52
+ "lint": "eslint . --ext .ts",
53
+ "lint:fix": "eslint . --ext .ts --fix",
54
+ "prepare": "husky",
55
+ "release": "semantic-release",
56
+ "release:dry": "semantic-release --dry-run"
57
+ },
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "https://github.com/icazemier/sway"
61
+ },
62
+ "homepage": "https://github.com/icazemier/sway#readme",
63
+ "bugs": {
64
+ "url": "https://github.com/icazemier/sway/issues"
65
+ },
66
+ "license": "MIT",
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
70
+ "devDependencies": {
71
+ "@commitlint/cli": "^19.6.0",
72
+ "@commitlint/config-conventional": "^19.6.0",
73
+ "@eslint/js": "^9.26.0",
74
+ "@semantic-release/changelog": "^6.0.3",
75
+ "@semantic-release/git": "^10.0.1",
76
+ "@types/node": "^22.15.15",
77
+ "@vitest/coverage-v8": "^4.0.18",
78
+ "conventional-changelog-conventionalcommits": "^8.0.0",
79
+ "cross-env": "^7.0.3",
80
+ "eslint": "^9.26.0",
81
+ "eslint-config-prettier": "^10.1.3",
82
+ "eslint-plugin-prettier": "^5.4.0",
83
+ "husky": "^9.1.7",
84
+ "rimraf": "^6.0.1",
85
+ "semantic-release": "^25.0.3",
86
+ "typedoc": "^0.28.3",
87
+ "typescript": "^5.2.2",
88
+ "typescript-eslint": "^8.31.1",
89
+ "vitest": "^4.0.18"
90
+ }
91
+ }