@indigoai-us/hq-cloud 6.15.25 → 6.15.27
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/bandwidth.d.ts +128 -0
- package/dist/bandwidth.d.ts.map +1 -0
- package/dist/bandwidth.js +254 -0
- package/dist/bandwidth.js.map +1 -0
- package/dist/bandwidth.test.d.ts +2 -0
- package/dist/bandwidth.test.d.ts.map +1 -0
- package/dist/bandwidth.test.js +243 -0
- package/dist/bandwidth.test.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +10 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +17 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +58 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +165 -48
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +83 -13
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +75 -14
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +90 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +13 -2
- package/dist/object-io.js.map +1 -1
- package/dist/sync-core.d.ts.map +1 -1
- package/dist/sync-core.js +7 -2
- package/dist/sync-core.js.map +1 -1
- package/dist/sync-core.test.js +36 -0
- package/dist/sync-core.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -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 @@
|
|
|
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
|