@indigoai-us/hq-cloud 6.15.25 → 6.15.26

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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Bandwidth governor — process-wide pacing for S3 payload transfers.
3
+ *
4
+ * The desktop app spawns `hq-sync-runner` on customer machines; on slow links
5
+ * an unthrottled sync saturates the connection. The runner honors three env
6
+ * vars (set by the desktop app, all optional):
7
+ *
8
+ * - `HQ_SYNC_MAX_BYTES_PER_SEC` — integer; total S3 payload rate cap,
9
+ * uploads + downloads combined. 0 / unset / invalid ⇒ unlimited.
10
+ * - `HQ_SYNC_MAX_CONCURRENCY` — integer; max simultaneous S3 object
11
+ * transfers (clamps the transfer pool). 0 / unset / invalid ⇒ the
12
+ * existing adaptive default.
13
+ * - `HQ_SYNC_BANDWIDTH_PERCENT` — integer 1..100; adaptive mode. The
14
+ * governor keeps a rolling estimate of link capacity (max observed
15
+ * aggregate throughput over the last 10 minutes, measured during actual
16
+ * transfers) and paces to percent/100 × that estimate, floored at
17
+ * 64 KiB/s so sync always progresses. When both PERCENT and
18
+ * MAX_BYTES_PER_SEC are set, the smaller resulting cap wins.
19
+ *
20
+ * Only DATA-plane transfers are paced (upload bodies, download streams).
21
+ * Control-plane calls (list, head, delete, presign mints) are never
22
+ * throttled — see the wiring in object-io.ts.
23
+ *
24
+ * Mechanism: a single token bucket shared by every transfer in the process,
25
+ * refilled continuously at the effective rate with a one-second burst
26
+ * allowance. Transfers acquire tokens per chunk; when the bucket is empty the
27
+ * acquire sleeps for exactly the deficit, so at most one chunk is ever in
28
+ * flight beyond the bucket (backpressure, no extra buffering). Deliberately
29
+ * NOT a delay-based congestion controller — dead simple, deterministic, and
30
+ * testable under virtual time.
31
+ */
32
+ export declare const MAX_BYTES_PER_SEC_ENV = "HQ_SYNC_MAX_BYTES_PER_SEC";
33
+ export declare const MAX_CONCURRENCY_ENV = "HQ_SYNC_MAX_CONCURRENCY";
34
+ export declare const BANDWIDTH_PERCENT_ENV = "HQ_SYNC_BANDWIDTH_PERCENT";
35
+ /** Adaptive-mode floor: sync always progresses at ≥ 64 KiB/s. */
36
+ export declare const MIN_ADAPTIVE_BYTES_PER_SEC: number;
37
+ export interface BandwidthPolicy {
38
+ /** Fixed aggregate cap in bytes/sec; undefined = no fixed cap. */
39
+ maxBytesPerSec?: number;
40
+ /** Adaptive percent of observed link capacity (1..100); undefined = off. */
41
+ percent?: number;
42
+ /** Transfer-pool clamp; undefined = keep the adaptive default. */
43
+ maxConcurrency?: number;
44
+ }
45
+ /**
46
+ * Parse the env contract. Invalid, zero, or unset values disable the
47
+ * corresponding knob rather than erroring — a misconfigured desktop app must
48
+ * never break sync, only fail open to today's behavior.
49
+ */
50
+ export declare function parseBandwidthPolicy(env?: Record<string, string | undefined>): BandwidthPolicy;
51
+ /**
52
+ * Rolling link-capacity estimate: max observed aggregate throughput over the
53
+ * last {@link ESTIMATOR_WINDOW_MS}. Bytes are recorded into per-second
54
+ * buckets as transfers deliver them; capacity is the largest bucket in the
55
+ * window (bytes in one second = B/s). A tiny always-current accumulator —
56
+ * at most 600 live entries, pruned on every touch.
57
+ */
58
+ export declare class ThroughputEstimator {
59
+ private readonly now;
60
+ private readonly buckets;
61
+ constructor(now?: () => number);
62
+ private prune;
63
+ record(bytes: number): void;
64
+ /** Max observed B/s in the window; undefined before any observation. */
65
+ capacityBps(): number | undefined;
66
+ }
67
+ interface ClockOpts {
68
+ now?: () => number;
69
+ sleep?: (ms: number) => Promise<void>;
70
+ }
71
+ /**
72
+ * Continuous-refill token bucket with a one-second burst allowance and a
73
+ * dynamic rate (re-read on every acquire, so adaptive mode retunes live).
74
+ * `acquire(n)` debits n tokens and sleeps for exactly the deficit when the
75
+ * bucket goes negative — the caller's chunk proceeds, and the NEXT chunk
76
+ * waits, so no more than one chunk is ever in flight beyond the bucket.
77
+ */
78
+ export declare class TokenBucket {
79
+ private readonly rateBps;
80
+ private tokens;
81
+ private lastRefillMs;
82
+ private readonly now;
83
+ private readonly sleep;
84
+ constructor(rateBps: () => number | undefined, opts?: ClockOpts);
85
+ acquire(bytes: number): Promise<void>;
86
+ }
87
+ /**
88
+ * Process-wide governor: one bucket + one estimator shared by every transfer.
89
+ * Inert (all pass-throughs) when the policy carries no rate knobs.
90
+ */
91
+ export declare class BandwidthGovernor {
92
+ readonly policy: BandwidthPolicy;
93
+ private readonly estimator;
94
+ private readonly bucket;
95
+ constructor(policy: BandwidthPolicy, opts?: ClockOpts);
96
+ /** True when any payload pacing is configured. */
97
+ isPacing(): boolean;
98
+ /**
99
+ * The rate the bucket refills at right now. Fixed cap, adaptive cap
100
+ * (percent × rolling max, floored at {@link MIN_ADAPTIVE_BYTES_PER_SEC}),
101
+ * or — when both are set — the smaller. Adaptive mode with no observation
102
+ * yet is unlimited: the first transfers measure the link.
103
+ */
104
+ effectiveRateBps(): number | undefined;
105
+ /**
106
+ * Pace `bytes` of payload through the shared bucket and feed the capacity
107
+ * estimator. Instant when pacing is off.
108
+ */
109
+ acquire(bytes: number): Promise<void>;
110
+ /**
111
+ * Wrap a download body so each chunk acquires tokens before it is yielded.
112
+ * Delivers every byte unmodified; returns the original iterable when
113
+ * pacing is off (zero overhead on the default path).
114
+ */
115
+ throttleBody(body: AsyncIterable<Uint8Array>): AsyncIterable<Uint8Array>;
116
+ /** Clamp the transfer pool size with HQ_SYNC_MAX_CONCURRENCY (if set). */
117
+ clampConcurrency(poolSize: number): number;
118
+ /** One-line human description of the effective policy for startup logs. */
119
+ describePolicy(): string;
120
+ }
121
+ export declare function getBandwidthGovernor(): BandwidthGovernor;
122
+ /**
123
+ * Test seam: install a governor (or null to drop the singleton so the next
124
+ * get re-reads the env). Production never calls this.
125
+ */
126
+ export declare function setBandwidthGovernorForTesting(governor: BandwidthGovernor | null): void;
127
+ export {};
128
+ //# sourceMappingURL=bandwidth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bandwidth.d.ts","sourceRoot":"","sources":["../src/bandwidth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,eAAO,MAAM,qBAAqB,8BAA8B,CAAC;AACjE,eAAO,MAAM,mBAAmB,4BAA4B,CAAC;AAC7D,eAAO,MAAM,qBAAqB,8BAA8B,CAAC;AAEjE,iEAAiE;AACjE,eAAO,MAAM,0BAA0B,QAAY,CAAC;AAOpD,MAAM,WAAW,eAAe;IAC9B,kEAAkE;IAClE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAWD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GACpD,eAAe,CASjB;AAED;;;;;;GAMG;AACH,qBAAa,mBAAmB;IAGlB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAFhC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;gBAExB,GAAG,GAAE,MAAM,MAAiB;IAEzD,OAAO,CAAC,KAAK;IAOb,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAQ3B,wEAAwE;IACxE,WAAW,IAAI,MAAM,GAAG,SAAS;CAQlC;AAED,UAAU,SAAS;IACjB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAKD;;;;;;GAMG;AACH,qBAAa,WAAW;IAOpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAN1B,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgC;gBAGnC,OAAO,EAAE,MAAM,MAAM,GAAG,SAAS,EAClD,IAAI,GAAE,SAAc;IAOhB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAwB5C;AAED;;;GAGG;AACH,qBAAa,iBAAiB;IAC5B,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAsB;IAChD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,MAAM,EAAE,eAAe,EAAE,IAAI,GAAE,SAAc;IAMzD,kDAAkD;IAClD,QAAQ,IAAI,OAAO;IAOnB;;;;;OAKG;IACH,gBAAgB,IAAI,MAAM,GAAG,SAAS;IAkBtC;;;OAGG;IACG,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAM3C;;;;OAIG;IACH,YAAY,CAAC,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC;IAWxE,0EAA0E;IAC1E,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAM1C,2EAA2E;IAC3E,cAAc,IAAI,MAAM;CAkBzB;AASD,wBAAgB,oBAAoB,IAAI,iBAAiB,CAKxD;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,iBAAiB,GAAG,IAAI,GACjC,IAAI,CAEN"}
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Bandwidth governor — process-wide pacing for S3 payload transfers.
3
+ *
4
+ * The desktop app spawns `hq-sync-runner` on customer machines; on slow links
5
+ * an unthrottled sync saturates the connection. The runner honors three env
6
+ * vars (set by the desktop app, all optional):
7
+ *
8
+ * - `HQ_SYNC_MAX_BYTES_PER_SEC` — integer; total S3 payload rate cap,
9
+ * uploads + downloads combined. 0 / unset / invalid ⇒ unlimited.
10
+ * - `HQ_SYNC_MAX_CONCURRENCY` — integer; max simultaneous S3 object
11
+ * transfers (clamps the transfer pool). 0 / unset / invalid ⇒ the
12
+ * existing adaptive default.
13
+ * - `HQ_SYNC_BANDWIDTH_PERCENT` — integer 1..100; adaptive mode. The
14
+ * governor keeps a rolling estimate of link capacity (max observed
15
+ * aggregate throughput over the last 10 minutes, measured during actual
16
+ * transfers) and paces to percent/100 × that estimate, floored at
17
+ * 64 KiB/s so sync always progresses. When both PERCENT and
18
+ * MAX_BYTES_PER_SEC are set, the smaller resulting cap wins.
19
+ *
20
+ * Only DATA-plane transfers are paced (upload bodies, download streams).
21
+ * Control-plane calls (list, head, delete, presign mints) are never
22
+ * throttled — see the wiring in object-io.ts.
23
+ *
24
+ * Mechanism: a single token bucket shared by every transfer in the process,
25
+ * refilled continuously at the effective rate with a one-second burst
26
+ * allowance. Transfers acquire tokens per chunk; when the bucket is empty the
27
+ * acquire sleeps for exactly the deficit, so at most one chunk is ever in
28
+ * flight beyond the bucket (backpressure, no extra buffering). Deliberately
29
+ * NOT a delay-based congestion controller — dead simple, deterministic, and
30
+ * testable under virtual time.
31
+ */
32
+ export const MAX_BYTES_PER_SEC_ENV = "HQ_SYNC_MAX_BYTES_PER_SEC";
33
+ export const MAX_CONCURRENCY_ENV = "HQ_SYNC_MAX_CONCURRENCY";
34
+ export const BANDWIDTH_PERCENT_ENV = "HQ_SYNC_BANDWIDTH_PERCENT";
35
+ /** Adaptive-mode floor: sync always progresses at ≥ 64 KiB/s. */
36
+ export const MIN_ADAPTIVE_BYTES_PER_SEC = 64 * 1024;
37
+ /** Rolling window the link-capacity estimate looks back over. */
38
+ const ESTIMATOR_WINDOW_MS = 10 * 60 * 1000;
39
+ /** Estimator bucket granularity: per-second byte counts. */
40
+ const ESTIMATOR_BUCKET_MS = 1000;
41
+ /** Strict positive-integer env parse; anything else ⇒ undefined (feature off). */
42
+ function parsePositiveInt(raw) {
43
+ if (raw === undefined || raw === "" || !/^\d+$/.test(raw.trim())) {
44
+ return undefined;
45
+ }
46
+ const n = Number.parseInt(raw, 10);
47
+ return Number.isSafeInteger(n) && n > 0 ? n : undefined;
48
+ }
49
+ /**
50
+ * Parse the env contract. Invalid, zero, or unset values disable the
51
+ * corresponding knob rather than erroring — a misconfigured desktop app must
52
+ * never break sync, only fail open to today's behavior.
53
+ */
54
+ export function parseBandwidthPolicy(env = process.env) {
55
+ const maxBytesPerSec = parsePositiveInt(env[MAX_BYTES_PER_SEC_ENV]);
56
+ const maxConcurrency = parsePositiveInt(env[MAX_CONCURRENCY_ENV]);
57
+ const rawPercent = parsePositiveInt(env[BANDWIDTH_PERCENT_ENV]);
58
+ const percent = rawPercent !== undefined && rawPercent >= 1 && rawPercent <= 100
59
+ ? rawPercent
60
+ : undefined;
61
+ return { maxBytesPerSec, percent, maxConcurrency };
62
+ }
63
+ /**
64
+ * Rolling link-capacity estimate: max observed aggregate throughput over the
65
+ * last {@link ESTIMATOR_WINDOW_MS}. Bytes are recorded into per-second
66
+ * buckets as transfers deliver them; capacity is the largest bucket in the
67
+ * window (bytes in one second = B/s). A tiny always-current accumulator —
68
+ * at most 600 live entries, pruned on every touch.
69
+ */
70
+ export class ThroughputEstimator {
71
+ now;
72
+ buckets = new Map();
73
+ constructor(now = Date.now) {
74
+ this.now = now;
75
+ }
76
+ prune(nowMs) {
77
+ const oldest = Math.floor((nowMs - ESTIMATOR_WINDOW_MS) / ESTIMATOR_BUCKET_MS);
78
+ for (const key of this.buckets.keys()) {
79
+ if (key < oldest)
80
+ this.buckets.delete(key);
81
+ }
82
+ }
83
+ record(bytes) {
84
+ if (bytes <= 0)
85
+ return;
86
+ const nowMs = this.now();
87
+ this.prune(nowMs);
88
+ const bucket = Math.floor(nowMs / ESTIMATOR_BUCKET_MS);
89
+ this.buckets.set(bucket, (this.buckets.get(bucket) ?? 0) + bytes);
90
+ }
91
+ /** Max observed B/s in the window; undefined before any observation. */
92
+ capacityBps() {
93
+ this.prune(this.now());
94
+ let max;
95
+ for (const bytes of this.buckets.values()) {
96
+ if (max === undefined || bytes > max)
97
+ max = bytes;
98
+ }
99
+ return max;
100
+ }
101
+ }
102
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
103
+ /**
104
+ * Continuous-refill token bucket with a one-second burst allowance and a
105
+ * dynamic rate (re-read on every acquire, so adaptive mode retunes live).
106
+ * `acquire(n)` debits n tokens and sleeps for exactly the deficit when the
107
+ * bucket goes negative — the caller's chunk proceeds, and the NEXT chunk
108
+ * waits, so no more than one chunk is ever in flight beyond the bucket.
109
+ */
110
+ export class TokenBucket {
111
+ rateBps;
112
+ tokens = 0;
113
+ lastRefillMs;
114
+ now;
115
+ sleep;
116
+ constructor(rateBps, opts = {}) {
117
+ this.rateBps = rateBps;
118
+ this.now = opts.now ?? Date.now;
119
+ this.sleep = opts.sleep ?? realSleep;
120
+ this.lastRefillMs = this.now();
121
+ }
122
+ async acquire(bytes) {
123
+ if (bytes <= 0)
124
+ return;
125
+ const rate = this.rateBps();
126
+ if (rate === undefined || rate <= 0) {
127
+ // Unlimited: reset so a later rate change doesn't grant a stale burst.
128
+ this.tokens = 0;
129
+ this.lastRefillMs = this.now();
130
+ return;
131
+ }
132
+ const nowMs = this.now();
133
+ const burst = rate; // one second of headroom
134
+ const refilled = this.tokens + ((nowMs - this.lastRefillMs) / 1000) * rate;
135
+ this.tokens = Math.min(burst, refilled);
136
+ this.lastRefillMs = nowMs;
137
+ this.tokens -= bytes;
138
+ if (this.tokens < 0) {
139
+ const waitMs = (-this.tokens / rate) * 1000;
140
+ await this.sleep(waitMs);
141
+ // Credit the slept time back so the deficit is repaid exactly once.
142
+ this.tokens += (waitMs / 1000) * rate;
143
+ this.lastRefillMs = this.now();
144
+ }
145
+ }
146
+ }
147
+ /**
148
+ * Process-wide governor: one bucket + one estimator shared by every transfer.
149
+ * Inert (all pass-throughs) when the policy carries no rate knobs.
150
+ */
151
+ export class BandwidthGovernor {
152
+ policy;
153
+ estimator;
154
+ bucket;
155
+ constructor(policy, opts = {}) {
156
+ this.policy = policy;
157
+ this.estimator = new ThroughputEstimator(opts.now ?? Date.now);
158
+ this.bucket = new TokenBucket(() => this.effectiveRateBps(), opts);
159
+ }
160
+ /** True when any payload pacing is configured. */
161
+ isPacing() {
162
+ return (this.policy.maxBytesPerSec !== undefined ||
163
+ this.policy.percent !== undefined);
164
+ }
165
+ /**
166
+ * The rate the bucket refills at right now. Fixed cap, adaptive cap
167
+ * (percent × rolling max, floored at {@link MIN_ADAPTIVE_BYTES_PER_SEC}),
168
+ * or — when both are set — the smaller. Adaptive mode with no observation
169
+ * yet is unlimited: the first transfers measure the link.
170
+ */
171
+ effectiveRateBps() {
172
+ const fixed = this.policy.maxBytesPerSec;
173
+ let adaptive;
174
+ if (this.policy.percent !== undefined) {
175
+ const capacity = this.estimator.capacityBps();
176
+ if (capacity !== undefined) {
177
+ adaptive = Math.max(MIN_ADAPTIVE_BYTES_PER_SEC, (capacity * this.policy.percent) / 100);
178
+ }
179
+ }
180
+ if (fixed !== undefined && adaptive !== undefined) {
181
+ return Math.min(fixed, adaptive);
182
+ }
183
+ return fixed ?? adaptive;
184
+ }
185
+ /**
186
+ * Pace `bytes` of payload through the shared bucket and feed the capacity
187
+ * estimator. Instant when pacing is off.
188
+ */
189
+ async acquire(bytes) {
190
+ if (!this.isPacing() || bytes <= 0)
191
+ return;
192
+ await this.bucket.acquire(bytes);
193
+ this.estimator.record(bytes);
194
+ }
195
+ /**
196
+ * Wrap a download body so each chunk acquires tokens before it is yielded.
197
+ * Delivers every byte unmodified; returns the original iterable when
198
+ * pacing is off (zero overhead on the default path).
199
+ */
200
+ throttleBody(body) {
201
+ if (!this.isPacing())
202
+ return body;
203
+ const acquire = (n) => this.acquire(n);
204
+ return (async function* () {
205
+ for await (const chunk of body) {
206
+ await acquire(chunk.byteLength);
207
+ yield chunk;
208
+ }
209
+ })();
210
+ }
211
+ /** Clamp the transfer pool size with HQ_SYNC_MAX_CONCURRENCY (if set). */
212
+ clampConcurrency(poolSize) {
213
+ const cap = this.policy.maxConcurrency;
214
+ if (cap === undefined)
215
+ return poolSize;
216
+ return Math.max(1, Math.min(poolSize, cap));
217
+ }
218
+ /** One-line human description of the effective policy for startup logs. */
219
+ describePolicy() {
220
+ const parts = [];
221
+ if (this.policy.maxBytesPerSec !== undefined) {
222
+ parts.push(`${this.policy.maxBytesPerSec} B/s cap`);
223
+ }
224
+ if (this.policy.percent !== undefined) {
225
+ parts.push(`adaptive ${this.policy.percent}% of observed link ` +
226
+ `(floor ${MIN_ADAPTIVE_BYTES_PER_SEC} B/s)`);
227
+ }
228
+ if (this.policy.maxConcurrency !== undefined) {
229
+ parts.push(`concurrency ${this.policy.maxConcurrency}`);
230
+ }
231
+ return parts.length > 0
232
+ ? `bandwidth governor: ${parts.join(", ")}`
233
+ : "bandwidth governor: off";
234
+ }
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // Process singleton — every transport instance shares ONE bucket so the cap
238
+ // is the aggregate across all in-flight uploads + downloads.
239
+ // ---------------------------------------------------------------------------
240
+ let singleton;
241
+ export function getBandwidthGovernor() {
242
+ if (!singleton) {
243
+ singleton = new BandwidthGovernor(parseBandwidthPolicy());
244
+ }
245
+ return singleton;
246
+ }
247
+ /**
248
+ * Test seam: install a governor (or null to drop the singleton so the next
249
+ * get re-reads the env). Production never calls this.
250
+ */
251
+ export function setBandwidthGovernorForTesting(governor) {
252
+ singleton = governor ?? undefined;
253
+ }
254
+ //# sourceMappingURL=bandwidth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bandwidth.js","sourceRoot":"","sources":["../src/bandwidth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=bandwidth.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bandwidth.test.d.ts","sourceRoot":"","sources":["../src/bandwidth.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,243 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { BANDWIDTH_PERCENT_ENV, BandwidthGovernor, MAX_BYTES_PER_SEC_ENV, MAX_CONCURRENCY_ENV, MIN_ADAPTIVE_BYTES_PER_SEC, ThroughputEstimator, TokenBucket, parseBandwidthPolicy, } from "./bandwidth.js";
3
+ /**
4
+ * Virtual clock: `sleep` advances time instantly, so pacing math is asserted
5
+ * deterministically with zero wall-clock waits.
6
+ */
7
+ function virtualClock(startMs = 0) {
8
+ let t = startMs;
9
+ return {
10
+ now: () => t,
11
+ sleep: async (ms) => {
12
+ t += ms;
13
+ },
14
+ advance: (ms) => {
15
+ t += ms;
16
+ },
17
+ time: () => t,
18
+ };
19
+ }
20
+ describe("parseBandwidthPolicy", () => {
21
+ it("returns all-off for an empty env", () => {
22
+ expect(parseBandwidthPolicy({})).toEqual({
23
+ maxBytesPerSec: undefined,
24
+ percent: undefined,
25
+ maxConcurrency: undefined,
26
+ });
27
+ });
28
+ it("parses valid positive integers", () => {
29
+ const policy = parseBandwidthPolicy({
30
+ [MAX_BYTES_PER_SEC_ENV]: "500000",
31
+ [MAX_CONCURRENCY_ENV]: "2",
32
+ [BANDWIDTH_PERCENT_ENV]: "50",
33
+ });
34
+ expect(policy).toEqual({
35
+ maxBytesPerSec: 500000,
36
+ maxConcurrency: 2,
37
+ percent: 50,
38
+ });
39
+ });
40
+ it("treats 0, negative, and non-numeric values as unset (feature off)", () => {
41
+ for (const bad of ["0", "-5", "abc", "1.5", "", " ", "1e6"]) {
42
+ const policy = parseBandwidthPolicy({
43
+ [MAX_BYTES_PER_SEC_ENV]: bad,
44
+ [MAX_CONCURRENCY_ENV]: bad,
45
+ [BANDWIDTH_PERCENT_ENV]: bad,
46
+ });
47
+ expect(policy, `input ${JSON.stringify(bad)}`).toEqual({
48
+ maxBytesPerSec: undefined,
49
+ percent: undefined,
50
+ maxConcurrency: undefined,
51
+ });
52
+ }
53
+ });
54
+ it("rejects percent outside 1..100 but accepts the bounds", () => {
55
+ expect(parseBandwidthPolicy({ [BANDWIDTH_PERCENT_ENV]: "101" }).percent).toBeUndefined();
56
+ expect(parseBandwidthPolicy({ [BANDWIDTH_PERCENT_ENV]: "1" }).percent).toBe(1);
57
+ expect(parseBandwidthPolicy({ [BANDWIDTH_PERCENT_ENV]: "100" }).percent).toBe(100);
58
+ });
59
+ });
60
+ describe("TokenBucket", () => {
61
+ it("passes instantly when the rate is unlimited", async () => {
62
+ const clock = virtualClock();
63
+ const bucket = new TokenBucket(() => undefined, clock);
64
+ await bucket.acquire(10_000_000);
65
+ expect(clock.time()).toBe(0);
66
+ });
67
+ it("paces a sustained transfer at the configured rate (virtual time)", async () => {
68
+ const clock = virtualClock();
69
+ const rate = 100_000; // B/s
70
+ const bucket = new TokenBucket(() => rate, clock);
71
+ // 1 MB in 64 KiB chunks at 100 kB/s must take ~10 s. The one-second
72
+ // burst allowance shaves at most 1 s off the ideal duration.
73
+ const chunk = 64 * 1024;
74
+ const total = 1_000_000;
75
+ for (let sent = 0; sent < total; sent += chunk) {
76
+ await bucket.acquire(Math.min(chunk, total - sent));
77
+ }
78
+ const ideal = (total / rate) * 1000;
79
+ expect(clock.time()).toBeGreaterThanOrEqual(ideal - 1000);
80
+ expect(clock.time()).toBeLessThanOrEqual(ideal + 1);
81
+ });
82
+ it("grants an initial burst of at most one second of tokens", async () => {
83
+ const clock = virtualClock();
84
+ const bucket = new TokenBucket(() => 1000, clock);
85
+ clock.advance(60_000); // long idle must NOT accumulate 60s of tokens
86
+ await bucket.acquire(1000); // one second's worth: free (burst)
87
+ expect(clock.time()).toBe(60_000);
88
+ await bucket.acquire(1000); // next second's worth: must wait ~1s
89
+ expect(clock.time()).toBeCloseTo(61_000, 5);
90
+ });
91
+ it("handles a single acquire larger than the burst", async () => {
92
+ const clock = virtualClock();
93
+ const bucket = new TokenBucket(() => 1000, clock);
94
+ // Fresh bucket starts empty (no free initial burst): 5s of tokens → 5s.
95
+ await bucket.acquire(5000);
96
+ expect(clock.time()).toBeCloseTo(5000, 5);
97
+ });
98
+ it("re-reads the rate on every acquire (live retuning)", async () => {
99
+ const clock = virtualClock();
100
+ let rate = undefined;
101
+ const bucket = new TokenBucket(() => rate, clock);
102
+ await bucket.acquire(1_000_000); // unlimited: instant
103
+ expect(clock.time()).toBe(0);
104
+ rate = 1000;
105
+ await bucket.acquire(2000); // bucket empty → full 2s deficit at 1 kB/s
106
+ expect(clock.time()).toBeCloseTo(2000, 5);
107
+ });
108
+ });
109
+ describe("ThroughputEstimator", () => {
110
+ it("is undefined before any observation", () => {
111
+ const clock = virtualClock();
112
+ expect(new ThroughputEstimator(clock.now).capacityBps()).toBeUndefined();
113
+ });
114
+ it("reports the max per-second aggregate observed", () => {
115
+ const clock = virtualClock(1_000_000);
116
+ const est = new ThroughputEstimator(clock.now);
117
+ est.record(50_000);
118
+ est.record(30_000); // same second → aggregates to 80 kB/s
119
+ clock.advance(1000);
120
+ est.record(10_000);
121
+ expect(est.capacityBps()).toBe(80_000);
122
+ });
123
+ it("forgets observations older than the 10-minute window", () => {
124
+ const clock = virtualClock(1_000_000);
125
+ const est = new ThroughputEstimator(clock.now);
126
+ est.record(1_000_000);
127
+ clock.advance(9 * 60 * 1000);
128
+ est.record(5_000);
129
+ expect(est.capacityBps()).toBe(1_000_000); // still inside window
130
+ clock.advance(2 * 60 * 1000); // old peak now > 10 min ago
131
+ expect(est.capacityBps()).toBe(5_000);
132
+ });
133
+ });
134
+ describe("BandwidthGovernor.effectiveRateBps", () => {
135
+ it("is undefined (unlimited) with no policy", () => {
136
+ expect(new BandwidthGovernor({}).effectiveRateBps()).toBeUndefined();
137
+ });
138
+ it("uses the fixed cap alone", () => {
139
+ expect(new BandwidthGovernor({ maxBytesPerSec: 250_000 }).effectiveRateBps()).toBe(250_000);
140
+ });
141
+ it("percent mode is unlimited until the link has been observed", () => {
142
+ const gov = new BandwidthGovernor({ percent: 50 }, virtualClock());
143
+ expect(gov.effectiveRateBps()).toBeUndefined();
144
+ });
145
+ it("percent mode paces to percent × observed capacity", async () => {
146
+ const clock = virtualClock(1_000_000);
147
+ const gov = new BandwidthGovernor({ percent: 50 }, clock);
148
+ await gov.acquire(1_000_000); // observe ~1 MB/s in one second-bucket
149
+ expect(gov.effectiveRateBps()).toBe(500_000);
150
+ });
151
+ it("floors the adaptive cap at 64 KiB/s", async () => {
152
+ const clock = virtualClock(1_000_000);
153
+ const gov = new BandwidthGovernor({ percent: 1 }, clock);
154
+ await gov.acquire(100_000); // 1% of 100 kB/s = 1 kB/s → floored
155
+ expect(gov.effectiveRateBps()).toBe(MIN_ADAPTIVE_BYTES_PER_SEC);
156
+ });
157
+ it("uses the smaller of fixed and adaptive caps when both are set", async () => {
158
+ const clock = virtualClock(1_000_000);
159
+ const gov = new BandwidthGovernor({ maxBytesPerSec: 300_000, percent: 50 }, clock);
160
+ // No observation yet → fixed cap governs.
161
+ expect(gov.effectiveRateBps()).toBe(300_000);
162
+ await gov.acquire(1_000_000); // adaptive = 500 kB/s > fixed 300 kB/s
163
+ expect(gov.effectiveRateBps()).toBe(300_000);
164
+ const gov2 = new BandwidthGovernor({ maxBytesPerSec: 300_000, percent: 10 }, clock);
165
+ await gov2.acquire(1_000_000); // adaptive = 100 kB/s < fixed
166
+ expect(gov2.effectiveRateBps()).toBe(100_000);
167
+ });
168
+ });
169
+ describe("BandwidthGovernor.throttleBody", () => {
170
+ async function* chunks(sizes) {
171
+ for (const [i, size] of sizes.entries()) {
172
+ yield new Uint8Array(size).fill(i % 256);
173
+ }
174
+ }
175
+ async function drain(body) {
176
+ const parts = [];
177
+ for await (const c of body)
178
+ parts.push(Buffer.from(c));
179
+ return Buffer.concat(parts);
180
+ }
181
+ it("returns the original iterable when pacing is off", () => {
182
+ const gov = new BandwidthGovernor({ maxConcurrency: 4 });
183
+ const body = chunks([10]);
184
+ expect(gov.throttleBody(body)).toBe(body);
185
+ });
186
+ it("delivers every byte unmodified, in order", async () => {
187
+ const clock = virtualClock();
188
+ const gov = new BandwidthGovernor({ maxBytesPerSec: 1_000_000 }, clock);
189
+ const sizes = [1, 65_536, 3, 100_000, 0, 7];
190
+ const expected = await drain(chunks(sizes));
191
+ const actual = await drain(gov.throttleBody(chunks(sizes)));
192
+ expect(actual.equals(expected)).toBe(true);
193
+ });
194
+ it("respects the rate cap over a simulated transfer (virtual time)", async () => {
195
+ const clock = virtualClock();
196
+ const rate = 200_000;
197
+ const gov = new BandwidthGovernor({ maxBytesPerSec: rate }, clock);
198
+ const sizes = Array.from({ length: 31 }, () => 64 * 1024); // ~2 MB
199
+ const total = sizes.reduce((a, b) => a + b, 0);
200
+ await drain(gov.throttleBody(chunks(sizes)));
201
+ const ideal = (total / rate) * 1000;
202
+ expect(clock.time()).toBeGreaterThanOrEqual(ideal - 1000); // burst slack
203
+ expect(clock.time()).toBeLessThanOrEqual(ideal + 1);
204
+ });
205
+ it("aggregates concurrent streams under ONE shared cap", async () => {
206
+ const clock = virtualClock();
207
+ const rate = 100_000;
208
+ const gov = new BandwidthGovernor({ maxBytesPerSec: rate }, clock);
209
+ const sizes = Array.from({ length: 8 }, () => 50_000); // 400 kB each
210
+ await Promise.all([
211
+ drain(gov.throttleBody(chunks(sizes))),
212
+ drain(gov.throttleBody(chunks(sizes))),
213
+ ]);
214
+ const total = 2 * sizes.reduce((a, b) => a + b, 0); // 800 kB combined
215
+ const ideal = (total / rate) * 1000;
216
+ expect(clock.time()).toBeGreaterThanOrEqual(ideal - 1000);
217
+ });
218
+ });
219
+ describe("BandwidthGovernor.clampConcurrency", () => {
220
+ it("passes the pool size through when unset", () => {
221
+ expect(new BandwidthGovernor({}).clampConcurrency(64)).toBe(64);
222
+ });
223
+ it("clamps the pool down but never raises it", () => {
224
+ const gov = new BandwidthGovernor({ maxConcurrency: 2 });
225
+ expect(gov.clampConcurrency(64)).toBe(2);
226
+ expect(gov.clampConcurrency(1)).toBe(1);
227
+ const loose = new BandwidthGovernor({ maxConcurrency: 100 });
228
+ expect(loose.clampConcurrency(64)).toBe(64);
229
+ });
230
+ });
231
+ describe("BandwidthGovernor.describePolicy", () => {
232
+ it("reports off when nothing is configured", () => {
233
+ expect(new BandwidthGovernor({}).describePolicy()).toBe("bandwidth governor: off");
234
+ });
235
+ it("reports the effective policy in one line", () => {
236
+ expect(new BandwidthGovernor({
237
+ maxBytesPerSec: 500000,
238
+ maxConcurrency: 2,
239
+ }).describePolicy()).toBe("bandwidth governor: 500000 B/s cap, concurrency 2");
240
+ expect(new BandwidthGovernor({ percent: 40 }).describePolicy()).toBe(`bandwidth governor: adaptive 40% of observed link (floor ${MIN_ADAPTIVE_BYTES_PER_SEC} B/s)`);
241
+ });
242
+ });
243
+ //# sourceMappingURL=bandwidth.test.js.map