@adaptive-concurrency-toolkit/core 1.0.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -0
  3. package/dist/clock.d.ts +20 -0
  4. package/dist/clock.js +23 -0
  5. package/dist/clock.js.map +1 -0
  6. package/dist/index.d.ts +10 -0
  7. package/dist/index.js +8 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/limit/aimd-limit.d.ts +51 -0
  10. package/dist/limit/aimd-limit.js +75 -0
  11. package/dist/limit/aimd-limit.js.map +1 -0
  12. package/dist/limit/fixed-limit.d.ts +17 -0
  13. package/dist/limit/fixed-limit.js +22 -0
  14. package/dist/limit/fixed-limit.js.map +1 -0
  15. package/dist/limit/gradient2-limit.d.ts +80 -0
  16. package/dist/limit/gradient2-limit.js +139 -0
  17. package/dist/limit/gradient2-limit.js.map +1 -0
  18. package/dist/limit/index.d.ts +5 -0
  19. package/dist/limit/index.js +5 -0
  20. package/dist/limit/index.js.map +1 -0
  21. package/dist/limit/limit.d.ts +30 -0
  22. package/dist/limit/limit.js +2 -0
  23. package/dist/limit/limit.js.map +1 -0
  24. package/dist/limit/vegas-limit.d.ts +80 -0
  25. package/dist/limit/vegas-limit.js +137 -0
  26. package/dist/limit/vegas-limit.js.map +1 -0
  27. package/dist/limiter/index.d.ts +2 -0
  28. package/dist/limiter/index.js +2 -0
  29. package/dist/limiter/index.js.map +1 -0
  30. package/dist/limiter/limiter.d.ts +18 -0
  31. package/dist/limiter/limiter.js +2 -0
  32. package/dist/limiter/limiter.js.map +1 -0
  33. package/dist/limiter/simple-limiter.d.ts +28 -0
  34. package/dist/limiter/simple-limiter.js +70 -0
  35. package/dist/limiter/simple-limiter.js.map +1 -0
  36. package/dist/types.d.ts +27 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/util/ema.d.ts +27 -0
  40. package/dist/util/ema.js +53 -0
  41. package/dist/util/ema.js.map +1 -0
  42. package/dist/util/listeners.d.ts +12 -0
  43. package/dist/util/listeners.js +49 -0
  44. package/dist/util/listeners.js.map +1 -0
  45. package/dist/util/math.d.ts +1 -0
  46. package/dist/util/math.js +4 -0
  47. package/dist/util/math.js.map +1 -0
  48. package/package.json +54 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Savin
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,227 @@
1
+ # @adaptive-concurrency-toolkit/core
2
+
3
+ Core adaptive concurrency algorithms inspired by Netflix's
4
+ [`concurrency-limits`](https://github.com/Netflix/concurrency-limits).
5
+
6
+ This package gives you `Limit` algorithms that infer a healthy in-flight
7
+ ceiling from observed round-trip latency and drop signals, plus a `Limiter`
8
+ that gates work against the inferred ceiling. Higher-level wrappers (HTTP,
9
+ RPC, queues) live in sibling packages.
10
+
11
+ ## What problem does this solve?
12
+
13
+ Fixed concurrency limits (semaphores, connection pools) are hard to tune:
14
+ too low and you waste capacity, too high and the downstream collapses under
15
+ queue-induced latency. Adaptive concurrency observes latency and failure
16
+ signals at runtime and adjusts the limit so the system runs at the knee of
17
+ the latency curve - high throughput, low queueing.
18
+
19
+ The trade-off vs. a fixed semaphore: there is a control loop with state,
20
+ warm-up cost, and parameters to understand. For homogeneous traffic against
21
+ a known dependency, a fixed limit often suffices. For heterogeneous traffic
22
+ or shared dependencies, adaptive limits pay off.
23
+
24
+ ## Quick start
25
+
26
+ ```ts
27
+ import { Gradient2Limit, SimpleLimiter } from '@adaptive-concurrency-toolkit/core';
28
+
29
+ const limit = new Gradient2Limit({
30
+ initialLimit: 20,
31
+ minLimit: 1,
32
+ maxLimit: 200,
33
+ });
34
+ const limiter = new SimpleLimiter(limit);
35
+
36
+ async function call(req: Request): Promise<Response> {
37
+ const listener = limiter.acquire();
38
+ if (!listener) {
39
+ // No permit available - shed load. Typical responses: 429, fallback path,
40
+ // bounded retry queue. The point is to fail fast rather than pile on.
41
+ return new Response('Too Many Requests', { status: 429 });
42
+ }
43
+ try {
44
+ const res = await fetch(req);
45
+ // 5xx and timeouts indicate the upstream is overloaded. We report these
46
+ // as drops so the algorithm contracts. 4xx are caller errors - they
47
+ // shouldn't influence the limit, so we ignore them.
48
+ if (res.status >= 500) listener.onDropped();
49
+ else if (res.status >= 400) listener.onIgnore();
50
+ else listener.onSuccess();
51
+ return res;
52
+ } catch (err) {
53
+ // Network errors / aborts are also overload signals in most setups.
54
+ listener.onDropped();
55
+ throw err;
56
+ }
57
+ }
58
+ ```
59
+
60
+ Exactly one of `onSuccess` / `onDropped` / `onIgnore` must be called per
61
+ acquired listener. Subsequent calls are no-ops, so it's safe to wrap the
62
+ release in a `finally`.
63
+
64
+ ## Algorithms
65
+
66
+ All four implement the same `Limit` interface, so you can swap them with one
67
+ line of code.
68
+
69
+ ### `FixedLimit`
70
+
71
+ Constant ceiling - does not adapt.
72
+
73
+ **Use when:** you have a known-good limit (e.g. a connection pool size), or
74
+ you're rolling out adaptive concurrency gradually and want a baseline to
75
+ compare against.
76
+
77
+ **Pros:** zero cognitive overhead, predictable, no warm-up.
78
+ **Cons:** doesn't react to changes in upstream capacity, traffic mix, or
79
+ dependency health.
80
+
81
+ ### `AimdLimit` - Additive Increase, Multiplicative Decrease
82
+
83
+ Same control loop family as TCP Reno. On every sample:
84
+
85
+ ```
86
+ drop or rtt > timeout → limit ← max(min, ⌊limit · backoffRatio⌋)
87
+ success at high util. → limit ← min(max, limit + 1)
88
+ otherwise → hold
89
+ ```
90
+
91
+ **Use when:** you have a clean drop signal (a request that fails is a clear
92
+ overload indicator - e.g. 5xx, 429, or a strict latency SLA used as
93
+ `rttTimeoutNanos`), and you want a simple, well-understood algorithm.
94
+
95
+ **Pros:** simple, fast to react to drops, no windowing, easy to reason
96
+ about. Good default when drops dominate the signal.
97
+ **Cons:** no notion of latency gradient - won't preemptively back off when
98
+ RTT is creeping up but requests haven't started failing yet. Increases one
99
+ unit at a time, so warm-up to high concurrency is slow. Per-sample updates
100
+ can be jittery under bursty traffic.
101
+
102
+ ### `VegasLimit` - TCP Vegas-style queue-size estimation
103
+
104
+ Per window (default 1 s) compute:
105
+
106
+ ```
107
+ queue = limit · (1 − rttNoLoad / windowMinRtt)
108
+ queue ≤ α(limit) → limit + log10(limit)
109
+ queue ≥ β(limit) → limit − log10(limit)
110
+ otherwise → hold
111
+ ```
112
+
113
+ `rttNoLoad` is a rolling minimum RTT (the "no queueing" floor), periodically
114
+ re-probed so it adapts to baseline drift. Drops cause an immediate
115
+ multiplicative back-off.
116
+
117
+ **Use when:** you care about latency, not just failures - e.g. an upstream
118
+ that silently queues requests instead of rejecting them. Vegas reduces the
119
+ limit as soon as queueing inflates RTT, before drops appear.
120
+
121
+ **Pros:** reacts to latency, not just drops. Logarithmic step keeps the
122
+ limit stable at high concurrency. Self-calibrates the latency floor.
123
+ **Cons:** needs enough samples per window to be reliable (default 10 per
124
+ 1 s). Sensitive to a stuck `rttNoLoad` if the probe interval is too long
125
+ relative to baseline shifts. Math is less intuitive than AIMD.
126
+
127
+ ### `Gradient2Limit` - long/short RTT ratio with queue-size hedge
128
+
129
+ Per window:
130
+
131
+ ```
132
+ shortRtt = window min RTT
133
+ longRtt = EMA over windows of shortRtt
134
+ gradient = clamp(tolerance · longRtt / shortRtt, minGradient, 1)
135
+ queue = 4 · √limit (configurable)
136
+ newLimit = limit · gradient + queue
137
+ if newLimit < limit: smooth toward old limit
138
+ ```
139
+
140
+ The gradient is capped at 1, so `limit · gradient` alone never grows the
141
+ ceiling - growth comes from the queue-size hedge. Decreases are smoothed to
142
+ avoid collapsing on a single bad window.
143
+
144
+ **Use when:** you want Vegas-style latency sensitivity but smoother behavior
145
+ and a more predictable scaling curve. This is the algorithm Netflix
146
+ recommends as a general-purpose default.
147
+
148
+ **Pros:** smoother than AIMD/Vegas, scales gracefully across orders of
149
+ magnitude (the `√limit` hedge keeps relative growth steady), tolerant of
150
+ single-window outliers.
151
+ **Cons:** more parameters to understand (`tolerance`, `smoothing`,
152
+ `minGradient`, queue-size function). Warm-up of `longRtt` EMA takes
153
+ ~100 windows to stabilize.
154
+
155
+ ## Choosing between them
156
+
157
+ | Signal you trust most | Pick |
158
+ | ------------------------------- | -------------------------------- |
159
+ | Static, known-good capacity | `FixedLimit` |
160
+ | Drops / failures | `AimdLimit` |
161
+ | Latency, with clear drop signal | `Gradient2Limit` (default) |
162
+ | Latency, mostly silent queueing | `VegasLimit` or `Gradient2Limit` |
163
+
164
+ If you're not sure, start with `Gradient2Limit` at `initialLimit ≈ p99
165
+ in-flight from current production`, `maxLimit ≈ 2–4× initial`. Watch the
166
+ limit time series - if it pegs at `maxLimit` continuously, raise the cap; if
167
+ it oscillates wildly, increase `smoothing` or lengthen `windowNanos`.
168
+
169
+ ## Sample semantics
170
+
171
+ The `Limiter` reports each completed acquisition to its `Limit` algorithm
172
+ via:
173
+
174
+ ```ts
175
+ onSample(startTimeNanos, rttNanos, inflight, didDrop);
176
+ ```
177
+
178
+ - `inflight` is the in-flight count at the moment the permit was issued -
179
+ algorithms use it to gate growth (don't grow if you weren't using the
180
+ current limit).
181
+ - `didDrop` is `true` only when the caller called `onDropped()`. A 4xx that
182
+ ended in `onIgnore()` does not appear as a drop.
183
+ - `onIgnore()` releases the permit but produces no sample - RTT is
184
+ discarded. This is the right choice for client-side errors, cancellations,
185
+ and short-circuited paths that don't represent real upstream work.
186
+
187
+ ## Picking parameters
188
+
189
+ - **`initialLimit`** - start near your current steady-state in-flight.
190
+ Too low wastes warm-up time; too high risks overshoot on cold caches.
191
+ - **`minLimit`** - should always allow at least one request through so
192
+ health probes succeed. `1` is a safe default; raise it only if you
193
+ _know_ the downstream can handle higher concurrency at all times.
194
+ - **`maxLimit`** - a safety cap, not a target. Pick well above your
195
+ expected steady state but below what would overload a healthy downstream.
196
+ - **`windowNanos`** (Vegas, Gradient2) - long enough to collect a useful
197
+ RTT distribution (≥ 10 × p99 latency), short enough to react. 1 s is a
198
+ reasonable default for HTTP-scale latencies.
199
+ - **`backoffRatio`** - 0.9 is gentle, 0.5 is aggressive. Aggressive
200
+ back-off is appropriate for upstreams that genuinely collapse under
201
+ load; gentle is better when drops can be transient.
202
+
203
+ ## Performance notes
204
+
205
+ - Sample reporting is allocation-free on the algorithm's hot path -
206
+ `onSample` takes positional `number` / `boolean` args.
207
+ - The `Limiter` allocates exactly one `Listener` object per `acquire()`.
208
+ - Time is read once per acquire and once per release via `performance.now()`
209
+ scaled to nanoseconds (a `number`, not `bigint`, to keep arithmetic fast).
210
+ - No timers, no background tasks. Adjustments happen synchronously when
211
+ samples cross a window boundary.
212
+
213
+ ## Testing
214
+
215
+ Use `ManualClock` to drive the algorithms deterministically:
216
+
217
+ ```ts
218
+ import { Gradient2Limit, SimpleLimiter, ManualClock } from '@adaptive-concurrency-toolkit/core';
219
+
220
+ const clock = new ManualClock();
221
+ const limit = new Gradient2Limit({ initialLimit: 10, windowNanos: 1_000_000_000 });
222
+ const limiter = new SimpleLimiter(limit, { clock });
223
+
224
+ const l = limiter.acquire()!;
225
+ clock.advanceMillis(50);
226
+ l.onSuccess(); // reports a 50 ms sample
227
+ ```
@@ -0,0 +1,20 @@
1
+ import type { Nanos } from './types.ts';
2
+ /**
3
+ * Monotonic time source. The default implementation uses
4
+ * `performance.now()` scaled to nanoseconds — fast, allocation-free, monotonic.
5
+ *
6
+ * A custom clock is useful for deterministic tests and for embedding in
7
+ * environments without `performance` (workers, etc).
8
+ */
9
+ export interface Clock {
10
+ nowNanos(): Nanos;
11
+ }
12
+ export declare const defaultClock: Clock;
13
+ export declare class ManualClock implements Clock {
14
+ private current;
15
+ constructor(initial?: Nanos);
16
+ nowNanos(): Nanos;
17
+ advanceNanos(delta: Nanos): void;
18
+ advanceMillis(delta: number): void;
19
+ setNanos(value: Nanos): void;
20
+ }
package/dist/clock.js ADDED
@@ -0,0 +1,23 @@
1
+ const NS_PER_MS = 1_000_000;
2
+ export const defaultClock = {
3
+ nowNanos: () => performance.now() * NS_PER_MS,
4
+ };
5
+ export class ManualClock {
6
+ current;
7
+ constructor(initial = 0) {
8
+ this.current = initial;
9
+ }
10
+ nowNanos() {
11
+ return this.current;
12
+ }
13
+ advanceNanos(delta) {
14
+ this.current += delta;
15
+ }
16
+ advanceMillis(delta) {
17
+ this.current += delta * NS_PER_MS;
18
+ }
19
+ setNanos(value) {
20
+ this.current = value;
21
+ }
22
+ }
23
+ //# sourceMappingURL=clock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clock.js","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAaA,MAAM,SAAS,GAAG,SAAS,CAAC;AAE5B,MAAM,CAAC,MAAM,YAAY,GAAU;IACjC,QAAQ,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE,GAAG,SAAS;CAC9C,CAAC;AAEF,MAAM,OAAO,WAAW;IACd,OAAO,CAAQ;IACvB,YAAY,UAAiB,CAAC;QAC5B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACzB,CAAC;IACD,QAAQ;QACN,OAAO,IAAI,CAAC,OAAO,CAAC;IACtB,CAAC;IACD,YAAY,CAAC,KAAY;QACvB,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC;IACxB,CAAC;IACD,aAAa,CAAC,KAAa;QACzB,IAAI,CAAC,OAAO,IAAI,KAAK,GAAG,SAAS,CAAC;IACpC,CAAC;IACD,QAAQ,CAAC,KAAY;QACnB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;IACvB,CAAC;CACF"}
@@ -0,0 +1,10 @@
1
+ export type { Nanos, Listener, LimitChangeListener, Unsubscribe } from './types.ts';
2
+ export { type Clock, defaultClock, ManualClock } from './clock.ts';
3
+ export type { Limit } from './limit/limit.ts';
4
+ export { FixedLimit, type FixedLimitOptions } from './limit/fixed-limit.ts';
5
+ export { AimdLimit, type AimdLimitOptions } from './limit/aimd-limit.ts';
6
+ export { VegasLimit, type VegasLimitOptions } from './limit/vegas-limit.ts';
7
+ export { Gradient2Limit, type Gradient2LimitOptions } from './limit/gradient2-limit.ts';
8
+ export type { Limiter } from './limiter/limiter.ts';
9
+ export { SimpleLimiter, type SimpleLimiterOptions } from './limiter/simple-limiter.ts';
10
+ export { Ema } from './util/ema.ts';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { defaultClock, ManualClock } from "./clock.js";
2
+ export { FixedLimit } from "./limit/fixed-limit.js";
3
+ export { AimdLimit } from "./limit/aimd-limit.js";
4
+ export { VegasLimit } from "./limit/vegas-limit.js";
5
+ export { Gradient2Limit } from "./limit/gradient2-limit.js";
6
+ export { SimpleLimiter } from "./limiter/simple-limiter.js";
7
+ export { Ema } from "./util/ema.js";
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAGnE,OAAO,EAAE,UAAU,EAA0B,MAAM,wBAAwB,CAAC;AAC5E,OAAO,EAAE,SAAS,EAAyB,MAAM,uBAAuB,CAAC;AACzE,OAAO,EAAE,UAAU,EAA0B,MAAM,wBAAwB,CAAC;AAC5E,OAAO,EAAE,cAAc,EAA8B,MAAM,4BAA4B,CAAC;AAGxF,OAAO,EAAE,aAAa,EAA6B,MAAM,6BAA6B,CAAC;AAEvF,OAAO,EAAE,GAAG,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,51 @@
1
+ import type { LimitChangeListener, Nanos, Unsubscribe } from '../types.ts';
2
+ import type { Limit } from './limit.ts';
3
+ export interface AimdLimitOptions {
4
+ /** Initial limit. Defaults to 10. */
5
+ readonly initialLimit?: number;
6
+ /** Lower bound. Defaults to 1. */
7
+ readonly minLimit?: number;
8
+ /** Upper bound. Defaults to 1000. */
9
+ readonly maxLimit?: number;
10
+ /**
11
+ * Multiplicative decrease factor applied to the limit on a drop or RTT
12
+ * timeout. Defaults to 0.9. Must be in (0, 1).
13
+ */
14
+ readonly backoffRatio?: number;
15
+ /**
16
+ * RTT (in ns) above which a sample is treated as a drop signal even if the
17
+ * caller reported success. Defaults to +∞ (never).
18
+ */
19
+ readonly rttTimeoutNanos?: number;
20
+ /**
21
+ * Fraction of the current limit that must be in flight before additive
22
+ * increases take effect. Defaults to 0.5 — i.e. we only grow when we are
23
+ * actually using at least half the headroom.
24
+ */
25
+ readonly utilizationThreshold?: number;
26
+ }
27
+ /**
28
+ * Additive Increase / Multiplicative Decrease.
29
+ *
30
+ * Inspired by TCP congestion control:
31
+ *
32
+ * - on drop or `rtt > rttTimeoutNanos`: limit ← max(minLimit, ⌊limit · backoffRatio⌋)
33
+ * - on success while utilization ≥ θ: limit ← min(maxLimit, limit + 1)
34
+ * - otherwise: no-op
35
+ *
36
+ * Reacts immediately to every sample (no internal windowing); this gives fast
37
+ * decreases but can be noisy. For smoother behavior prefer {@link Gradient2Limit}.
38
+ */
39
+ export declare class AimdLimit implements Limit {
40
+ private _limit;
41
+ readonly minLimit: number;
42
+ readonly maxLimit: number;
43
+ readonly backoffRatio: number;
44
+ readonly rttTimeoutNanos: number;
45
+ readonly utilizationThreshold: number;
46
+ private readonly listeners;
47
+ constructor(opts?: AimdLimitOptions);
48
+ get limit(): number;
49
+ onSample(_start: Nanos, rttNanos: Nanos, inflight: number, didDrop: boolean): void;
50
+ onChange(listener: LimitChangeListener): Unsubscribe;
51
+ }
@@ -0,0 +1,75 @@
1
+ import { ChangeListeners } from "../util/listeners.js";
2
+ /**
3
+ * Additive Increase / Multiplicative Decrease.
4
+ *
5
+ * Inspired by TCP congestion control:
6
+ *
7
+ * - on drop or `rtt > rttTimeoutNanos`: limit ← max(minLimit, ⌊limit · backoffRatio⌋)
8
+ * - on success while utilization ≥ θ: limit ← min(maxLimit, limit + 1)
9
+ * - otherwise: no-op
10
+ *
11
+ * Reacts immediately to every sample (no internal windowing); this gives fast
12
+ * decreases but can be noisy. For smoother behavior prefer {@link Gradient2Limit}.
13
+ */
14
+ export class AimdLimit {
15
+ _limit;
16
+ minLimit;
17
+ maxLimit;
18
+ backoffRatio;
19
+ rttTimeoutNanos;
20
+ utilizationThreshold;
21
+ listeners = new ChangeListeners();
22
+ constructor(opts = {}) {
23
+ const initial = opts.initialLimit ?? 10;
24
+ const min = opts.minLimit ?? 1;
25
+ const max = opts.maxLimit ?? 1000;
26
+ const backoff = opts.backoffRatio ?? 0.9;
27
+ const utilization = opts.utilizationThreshold ?? 0.5;
28
+ const timeout = opts.rttTimeoutNanos ?? Number.POSITIVE_INFINITY;
29
+ if (!(min >= 1) || !Number.isFinite(min)) {
30
+ throw new RangeError(`minLimit must be >= 1, got ${min}`);
31
+ }
32
+ if (!(max >= min)) {
33
+ throw new RangeError(`maxLimit must be >= minLimit, got ${max} < ${min}`);
34
+ }
35
+ if (!(initial >= min && initial <= max)) {
36
+ throw new RangeError(`initialLimit must be in [${min}, ${max}], got ${initial}`);
37
+ }
38
+ if (!(backoff > 0 && backoff < 1)) {
39
+ throw new RangeError(`backoffRatio must be in (0, 1), got ${backoff}`);
40
+ }
41
+ if (!(utilization > 0 && utilization <= 1)) {
42
+ throw new RangeError(`utilizationThreshold must be in (0, 1], got ${utilization}`);
43
+ }
44
+ if (!(timeout > 0)) {
45
+ throw new RangeError(`rttTimeoutNanos must be > 0, got ${timeout}`);
46
+ }
47
+ this._limit = initial;
48
+ this.minLimit = min;
49
+ this.maxLimit = max;
50
+ this.backoffRatio = backoff;
51
+ this.utilizationThreshold = utilization;
52
+ this.rttTimeoutNanos = timeout;
53
+ }
54
+ get limit() {
55
+ return this._limit;
56
+ }
57
+ onSample(_start, rttNanos, inflight, didDrop) {
58
+ const current = this._limit;
59
+ let next = current;
60
+ if (didDrop || rttNanos > this.rttTimeoutNanos) {
61
+ next = Math.max(this.minLimit, Math.floor(current * this.backoffRatio));
62
+ }
63
+ else if (inflight >= current * this.utilizationThreshold) {
64
+ next = Math.min(this.maxLimit, current + 1);
65
+ }
66
+ if (next !== current) {
67
+ this._limit = next;
68
+ this.listeners.emit(next);
69
+ }
70
+ }
71
+ onChange(listener) {
72
+ return this.listeners.add(listener);
73
+ }
74
+ }
75
+ //# sourceMappingURL=aimd-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aimd-limit.js","sourceRoot":"","sources":["../../src/limit/aimd-limit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import type { LimitChangeListener, Nanos, Unsubscribe } from '../types.ts';
2
+ import type { Limit } from './limit.ts';
3
+ export interface FixedLimitOptions {
4
+ /** Constant concurrency ceiling. Must be a positive integer. */
5
+ readonly limit: number;
6
+ }
7
+ /**
8
+ * Constant {@link Limit}. Useful as a baseline, for tests, and as the default
9
+ * when adaptive behavior is undesirable.
10
+ */
11
+ export declare class FixedLimit implements Limit {
12
+ readonly limit: number;
13
+ private readonly listeners;
14
+ constructor(opts: FixedLimitOptions);
15
+ onSample(_start: Nanos, _rtt: Nanos, _inflight: number, _didDrop: boolean): void;
16
+ onChange(listener: LimitChangeListener): Unsubscribe;
17
+ }
@@ -0,0 +1,22 @@
1
+ import { ChangeListeners } from "../util/listeners.js";
2
+ /**
3
+ * Constant {@link Limit}. Useful as a baseline, for tests, and as the default
4
+ * when adaptive behavior is undesirable.
5
+ */
6
+ export class FixedLimit {
7
+ limit;
8
+ listeners = new ChangeListeners();
9
+ constructor(opts) {
10
+ if (!Number.isInteger(opts.limit) || opts.limit < 1) {
11
+ throw new RangeError(`limit must be a positive integer, got ${opts.limit}`);
12
+ }
13
+ this.limit = opts.limit;
14
+ }
15
+ onSample(_start, _rtt, _inflight, _didDrop) {
16
+ /* no-op */
17
+ }
18
+ onChange(listener) {
19
+ return this.listeners.add(listener);
20
+ }
21
+ }
22
+ //# sourceMappingURL=fixed-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fixed-limit.js","sourceRoot":"","sources":["../../src/limit/fixed-limit.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAQvD;;;GAGG;AACH,MAAM,OAAO,UAAU;IACZ,KAAK,CAAS;IACN,SAAS,GAAG,IAAI,eAAe,EAAE,CAAC;IAEnD,YAAY,IAAuB;QACjC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,GAAG,CAAC,EAAE,CAAC;YACpD,MAAM,IAAI,UAAU,CAAC,yCAAyC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;QAC9E,CAAC;QACD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;IAC1B,CAAC;IAED,QAAQ,CAAC,MAAa,EAAE,IAAW,EAAE,SAAiB,EAAE,QAAiB;QACvE,WAAW;IACb,CAAC;IAED,QAAQ,CAAC,QAA6B;QACpC,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACtC,CAAC;CACF"}
@@ -0,0 +1,80 @@
1
+ import type { LimitChangeListener, Nanos, Unsubscribe } from '../types.ts';
2
+ import type { Limit } from './limit.ts';
3
+ export interface Gradient2LimitOptions {
4
+ readonly initialLimit?: number;
5
+ readonly minLimit?: number;
6
+ readonly maxLimit?: number;
7
+ /** Window duration in nanoseconds. Default 1 s. */
8
+ readonly windowNanos?: number;
9
+ /** Minimum samples per window. Default 10. */
10
+ readonly minWindowSamples?: number;
11
+ /**
12
+ * Long-window memory in windows. The long RTT EMA acts as the baseline.
13
+ * Default 100 (i.e. ~100 windows of memory).
14
+ */
15
+ readonly longWindowCount?: number;
16
+ /**
17
+ * Queue-size hedge as a function of the current limit. Default:
18
+ * `4·√limit` — the same form Netflix uses, which keeps a small absolute
19
+ * buffer at low concurrencies and a larger one at high concurrencies.
20
+ */
21
+ readonly queueSize?: (limit: number) => number;
22
+ /**
23
+ * Multiplier on `longRtt / shortRtt`. Values > 1 make the algorithm
24
+ * tolerate small latency inflations before reducing the limit. Default 1.5.
25
+ */
26
+ readonly tolerance?: number;
27
+ /**
28
+ * Smoothing for limit *decreases* — newLimit = α·newLimit + (1−α)·oldLimit.
29
+ * Prevents single-window spikes from collapsing the limit. Default 0.2.
30
+ */
31
+ readonly smoothing?: number;
32
+ /** Floor for the gradient. Default 0.5 — limit never halves in one step. */
33
+ readonly minGradient?: number;
34
+ /** Multiplicative back-off on a drop sample. Default 0.9. */
35
+ readonly backoffRatio?: number;
36
+ }
37
+ /**
38
+ * Gradient2 — adaptive limit based on the ratio of long-window RTT baseline to
39
+ * the current short-window RTT.
40
+ *
41
+ * Per window:
42
+ *
43
+ * shortRtt = window min RTT
44
+ * longRtt = EMA over windows of shortRtt
45
+ * gradient = clamp(tolerance · longRtt / shortRtt, minGradient, 1)
46
+ * queue = queueSize(limit)
47
+ * newLimit = limit · gradient + queue
48
+ * if newLimit < limit: newLimit ← smoothing · newLimit + (1−smoothing) · limit
49
+ *
50
+ * The `gradient ≤ 1` constraint means we only grow via the queue-size hedge —
51
+ * `limit · gradient` alone can never increase the limit. This matches the
52
+ * intuition that a fast response window is permission to add a probe, not to
53
+ * scale up multiplicatively.
54
+ */
55
+ export declare class Gradient2Limit implements Limit {
56
+ private _limit;
57
+ readonly minLimit: number;
58
+ readonly maxLimit: number;
59
+ readonly windowNanos: number;
60
+ readonly minWindowSamples: number;
61
+ readonly tolerance: number;
62
+ readonly smoothing: number;
63
+ readonly minGradient: number;
64
+ readonly backoffRatio: number;
65
+ private readonly queueSizeFn;
66
+ private readonly longRtt;
67
+ private readonly listeners;
68
+ private windowStartNanos;
69
+ private windowMinRttNanos;
70
+ private windowMaxInflight;
71
+ private windowSamples;
72
+ private windowDropped;
73
+ constructor(opts?: Gradient2LimitOptions);
74
+ get limit(): number;
75
+ /** Long-window RTT baseline (ns). 0 until the first window commits. */
76
+ get longRttNanos(): number;
77
+ onSample(startNanos: Nanos, rttNanos: Nanos, inflight: number, didDrop: boolean): void;
78
+ onChange(listener: LimitChangeListener): Unsubscribe;
79
+ private commitWindow;
80
+ }