@kindgi/bounds 0.0.0-stage → 0.1.8-rc.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.
- package/LICENSE +201 -0
- package/README.md +110 -2
- package/dist/bounds.d.ts +111 -0
- package/dist/bounds.d.ts.map +1 -0
- package/dist/bounds.js +95 -0
- package/dist/bounds.js.map +1 -0
- package/dist/budget.d.ts +65 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +145 -0
- package/dist/budget.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/pattern.d.ts +62 -0
- package/dist/pattern.d.ts.map +1 -0
- package/dist/pattern.js +253 -0
- package/dist/pattern.js.map +1 -0
- package/dist/tenant.d.ts +31 -0
- package/dist/tenant.d.ts.map +1 -0
- package/dist/tenant.js +33 -0
- package/dist/tenant.js.map +1 -0
- package/dist/testing.d.ts +6 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +8 -0
- package/dist/testing.js.map +1 -0
- package/package.json +53 -3
- package/src/bounds.ts +192 -0
- package/src/budget.ts +181 -0
- package/src/index.ts +32 -0
- package/src/pattern.ts +271 -0
- package/src/tenant.ts +60 -0
- package/src/testing.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kindgi/bounds",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.8-rc.0",
|
|
4
|
+
"description": "The limits a Kindgi server puts on what a tenant sends it: pattern length and engine (V8, or linear-time RE2 when hosted), schema size and depth, template limits, and the size and idle time of bodies read from tenant-named endpoints. Set once at boot; read by the packages that compile or read tenant input.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/kindgi/kindgi-sdk.git",
|
|
9
|
+
"directory": "packages/bounds"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/kindgi/kindgi-sdk/tree/main/packages/bounds#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/kindgi/kindgi-sdk/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./testing": {
|
|
25
|
+
"types": "./dist/testing.d.ts",
|
|
26
|
+
"import": "./dist/testing.js",
|
|
27
|
+
"default": "./dist/testing.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.12.0"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"provenance": true
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.10.5",
|
|
44
|
+
"typescript": "^5.7.3",
|
|
45
|
+
"vitest": "^2.1.8"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"re2js": "^2.8.6"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc -p tsconfig.build.json",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
55
|
+
}
|
|
6
56
|
}
|
package/src/bounds.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The limits a Kindgi server puts on what a tenant sends it: one setting
|
|
6
|
+
* for the whole process, set once at boot (`configureTenantInputBounds`)
|
|
7
|
+
* and read where tenant input is compiled or read (`tenantInputBounds`).
|
|
8
|
+
*
|
|
9
|
+
* The runtime sets {@link HOSTED_BOUNDS} under the hosted profile, where
|
|
10
|
+
* one tenant's input must never stall the server for the others. Anywhere
|
|
11
|
+
* else (a self-hosted server, a pack's own process, a test) it's
|
|
12
|
+
* {@link SELF_HOSTED_BOUNDS}: today's behaviour, with generous limits on
|
|
13
|
+
* bodies, schemas and a render's memory.
|
|
14
|
+
*
|
|
15
|
+
* Why one setting rather than an argument: the places that compile tenant
|
|
16
|
+
* input are deep inside the SDK's packages (guardrails, schema, agents,
|
|
17
|
+
* flow, tools). One explicit call at boot is testable, and a second call
|
|
18
|
+
* throwing keeps it from changing while the server runs. Why not the
|
|
19
|
+
* environment: a library that reads `process.env` behaves differently in a
|
|
20
|
+
* test than in the server.
|
|
21
|
+
*/
|
|
22
|
+
export interface TenantInputBounds {
|
|
23
|
+
/**
|
|
24
|
+
* The engine a tenant's pattern runs on: V8's, or RE2's, which runs in
|
|
25
|
+
* linear time. On RE2, a tenant's JSON Schema checks its data in linear
|
|
26
|
+
* time too (`@kindgi/schema`): its patterns run on RE2, its formats are
|
|
27
|
+
* ajv-formats' `fast` ones, and `uniqueItems` compares items by a key.
|
|
28
|
+
*/
|
|
29
|
+
readonly regex: 'v8' | 're2';
|
|
30
|
+
/** The longest a tenant's pattern may be: its source plus its flags, in characters. */
|
|
31
|
+
readonly patternMaxLength: number;
|
|
32
|
+
/**
|
|
33
|
+
* The most citations a `must-cite` guardrail may require (`minCitations`).
|
|
34
|
+
* It counts matches up to that many, and on RE2 each match can read the
|
|
35
|
+
* rest of the output, so this caps what one evaluation costs.
|
|
36
|
+
*/
|
|
37
|
+
readonly mustCiteMaxCitations: number;
|
|
38
|
+
/** The largest schema compiled: its JSON, in bytes, before it's compiled. */
|
|
39
|
+
readonly schemaMaxBytes: number;
|
|
40
|
+
/** The deepest a schema may nest, objects and arrays. */
|
|
41
|
+
readonly schemaMaxDepth: number;
|
|
42
|
+
/** Compiled schemas kept, by their JSON, the least recently used dropped first. */
|
|
43
|
+
readonly schemaCacheEntries: number;
|
|
44
|
+
/**
|
|
45
|
+
* The most schema JSON those compiled schemas may add up to, in bytes: a
|
|
46
|
+
* compiled schema takes 4 to 15 times its JSON in memory for real API
|
|
47
|
+
* schemas, and up to about 20 times for the worst shapes measured.
|
|
48
|
+
*/
|
|
49
|
+
readonly schemaCacheMaxBytes: number;
|
|
50
|
+
/**
|
|
51
|
+
* The time a tenant's schemas may spend compiling in any 60 seconds, in
|
|
52
|
+
* milliseconds: the compiles that count (`runAsTenant` says whose work
|
|
53
|
+
* it is, and which count). Past it, the tenant's next compile that
|
|
54
|
+
* counts is refused until the window moves on; schemas already compiled
|
|
55
|
+
* keep working.
|
|
56
|
+
*/
|
|
57
|
+
readonly schemaCompileBudgetMs: number;
|
|
58
|
+
/**
|
|
59
|
+
* The time a tenant's templates may spend rendering in any 60 seconds,
|
|
60
|
+
* in milliseconds, for renders done as its work (`runAsTenant`). Past it,
|
|
61
|
+
* the tenant's next render is refused until the window moves on. A
|
|
62
|
+
* render holds the event loop while it runs.
|
|
63
|
+
*/
|
|
64
|
+
readonly templateRenderBudgetMs: number;
|
|
65
|
+
/** A template's limits (liquidjs's own). */
|
|
66
|
+
readonly liquid: {
|
|
67
|
+
/** The longest template parsed, in characters. */
|
|
68
|
+
readonly parseLimit: number;
|
|
69
|
+
/** The longest one render may take, in milliseconds. */
|
|
70
|
+
readonly renderLimitMs: number;
|
|
71
|
+
/**
|
|
72
|
+
* The most one render may allocate, in liquidjs's own units (roughly
|
|
73
|
+
* characters of strings and arrays it builds), not bytes: 8,388,608 is
|
|
74
|
+
* not 8 MiB, and isn't meant to be.
|
|
75
|
+
*/
|
|
76
|
+
readonly memoryLimit: number;
|
|
77
|
+
};
|
|
78
|
+
/** The largest body read from a tenant-named endpoint, decompressed, in bytes. */
|
|
79
|
+
readonly responseMaxBytes: number;
|
|
80
|
+
/** The longest wait between two chunks of such a body, in milliseconds. */
|
|
81
|
+
readonly responseIdleMs: number;
|
|
82
|
+
/** The largest one event of a `text/event-stream` may be; the stream itself has no total. */
|
|
83
|
+
readonly sseEventMaxBytes: number;
|
|
84
|
+
/** The largest request body the API reads, in bytes (routes that take more set their own). */
|
|
85
|
+
readonly requestMaxBytes: number;
|
|
86
|
+
/**
|
|
87
|
+
* The deepest a request's JSON may nest, in levels of objects and arrays,
|
|
88
|
+
* the body's own being 1. It leaves room for a schema as deep as
|
|
89
|
+
* `schemaMaxDepth` inside the body that carries it.
|
|
90
|
+
*/
|
|
91
|
+
readonly requestJsonMaxDepth: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const KiB = 1024;
|
|
95
|
+
const MiB = 1024 * KiB;
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* The numbers: fitted to limits the code already enforces (a pack
|
|
99
|
+
* service's body and an HTTP tool's response 10 MiB, a webhook body
|
|
100
|
+
* 256 KiB, MCP sign-in 64 KiB), until hosted usage tunes them. The
|
|
101
|
+
* compile cache's bytes and the compile budget are measured: a 256 KiB
|
|
102
|
+
* schema compiles to 4–5 MiB of heap in up to 0.75 s, and a 100 KiB one
|
|
103
|
+
* derived from a public OpenAPI document in 0.1–0.2 s.
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Anywhere but the hosted profile: today's behaviour. A pattern on V8's
|
|
108
|
+
* engine, a template's length and render time, and idle time without
|
|
109
|
+
* limits; caps where today has none that a self-hoster could still hurt
|
|
110
|
+
* itself without (bodies, schemas, the compile cache, a render's memory).
|
|
111
|
+
*/
|
|
112
|
+
export const SELF_HOSTED_BOUNDS: TenantInputBounds = deepFreeze({
|
|
113
|
+
regex: 'v8',
|
|
114
|
+
patternMaxLength: 8_192,
|
|
115
|
+
mustCiteMaxCitations: Number.POSITIVE_INFINITY,
|
|
116
|
+
schemaMaxBytes: 1 * MiB,
|
|
117
|
+
schemaMaxDepth: 64,
|
|
118
|
+
schemaCacheEntries: 1_000,
|
|
119
|
+
schemaCacheMaxBytes: Number.POSITIVE_INFINITY,
|
|
120
|
+
schemaCompileBudgetMs: Number.POSITIVE_INFINITY,
|
|
121
|
+
templateRenderBudgetMs: Number.POSITIVE_INFINITY,
|
|
122
|
+
liquid: {
|
|
123
|
+
parseLimit: Number.POSITIVE_INFINITY,
|
|
124
|
+
renderLimitMs: Number.POSITIVE_INFINITY,
|
|
125
|
+
memoryLimit: 16_777_216,
|
|
126
|
+
},
|
|
127
|
+
responseMaxBytes: 10 * MiB,
|
|
128
|
+
responseIdleMs: Number.POSITIVE_INFINITY,
|
|
129
|
+
sseEventMaxBytes: 10 * MiB,
|
|
130
|
+
requestMaxBytes: 10 * MiB,
|
|
131
|
+
requestJsonMaxDepth: 128,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/** The hosted profile: patterns on RE2 (linear time), and tight limits. */
|
|
135
|
+
export const HOSTED_BOUNDS: TenantInputBounds = deepFreeze({
|
|
136
|
+
regex: 're2',
|
|
137
|
+
patternMaxLength: 1_024,
|
|
138
|
+
mustCiteMaxCitations: 100,
|
|
139
|
+
schemaMaxBytes: 256 * KiB,
|
|
140
|
+
schemaMaxDepth: 64,
|
|
141
|
+
schemaCacheEntries: 512,
|
|
142
|
+
schemaCacheMaxBytes: 8 * MiB,
|
|
143
|
+
schemaCompileBudgetMs: 2_000,
|
|
144
|
+
templateRenderBudgetMs: 5_000,
|
|
145
|
+
liquid: {
|
|
146
|
+
parseLimit: 262_144,
|
|
147
|
+
renderLimitMs: 1_000,
|
|
148
|
+
memoryLimit: 8_388_608,
|
|
149
|
+
},
|
|
150
|
+
responseMaxBytes: 10 * MiB,
|
|
151
|
+
responseIdleMs: 60_000,
|
|
152
|
+
sseEventMaxBytes: 10 * MiB,
|
|
153
|
+
requestMaxBytes: 2 * MiB,
|
|
154
|
+
requestJsonMaxDepth: 128,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let configured: TenantInputBounds | undefined;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sets the process's bounds, once, at boot. A second call throws: they
|
|
161
|
+
* never change while the server runs.
|
|
162
|
+
*/
|
|
163
|
+
export function configureTenantInputBounds(bounds: TenantInputBounds): void {
|
|
164
|
+
if (configured !== undefined) {
|
|
165
|
+
throw new Error('Tenant input bounds are already set: they are set once, at boot.');
|
|
166
|
+
}
|
|
167
|
+
configured = deepFreeze(structuredClone(bounds));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The process's bounds: what boot set, or {@link SELF_HOSTED_BOUNDS}. */
|
|
171
|
+
export function tenantInputBounds(): TenantInputBounds {
|
|
172
|
+
return configured ?? SELF_HOSTED_BOUNDS;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* For tests (`@kindgi/bounds/testing`): runs with other bounds, and puts
|
|
177
|
+
* back what was set.
|
|
178
|
+
*/
|
|
179
|
+
export function overrideTenantInputBounds(bounds: TenantInputBounds): () => void {
|
|
180
|
+
const before = configured;
|
|
181
|
+
configured = deepFreeze(structuredClone(bounds));
|
|
182
|
+
return () => {
|
|
183
|
+
configured = before;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function deepFreeze<T extends object>(value: T): T {
|
|
188
|
+
for (const inner of Object.values(value)) {
|
|
189
|
+
if (inner !== null && typeof inner === 'object') deepFreeze(inner);
|
|
190
|
+
}
|
|
191
|
+
return Object.freeze(value);
|
|
192
|
+
}
|
package/src/budget.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Budgets kept per tenant: the time a tenant's work of one kind (its
|
|
6
|
+
* schema compiles, its template renders) takes in any 60 seconds. That
|
|
7
|
+
* work holds the event loop while it runs, so a tenant past its budget
|
|
8
|
+
* waits instead of the others: its next piece of that work is refused
|
|
9
|
+
* until the window moves on, and may be retried then. The budget doesn't
|
|
10
|
+
* stop a piece it let start, so a tenant's minute can run past the budget
|
|
11
|
+
* by one piece.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The window a budget kept per tenant counts over, in milliseconds. */
|
|
15
|
+
export const BUDGET_WINDOW_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
/** Which budget refused: what the API's refusal names in `details.limit`. */
|
|
18
|
+
export type TenantBudgetLimit = 'schema-compile-time' | 'template-render-time';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A tenant's work refused: it spent its budget of `budgetMs` (`limit`) in
|
|
22
|
+
* the last 60 seconds, and is under it again after `retryAfterMs`. Every
|
|
23
|
+
* budget's refusal is one of these, so what answers for one (the API, an
|
|
24
|
+
* agent's turn, a run's step) answers for them all.
|
|
25
|
+
*/
|
|
26
|
+
export class TenantBudgetError extends Error {
|
|
27
|
+
constructor(
|
|
28
|
+
message: string,
|
|
29
|
+
readonly limit: TenantBudgetLimit,
|
|
30
|
+
readonly budgetMs: number,
|
|
31
|
+
readonly retryAfterMs: number,
|
|
32
|
+
) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'TenantBudgetError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Where a budget reads the time. */
|
|
39
|
+
export interface BudgetClock {
|
|
40
|
+
/** The wall time, in milliseconds: where a piece of work sits in the window. */
|
|
41
|
+
now(): number;
|
|
42
|
+
/** A monotonic time, in milliseconds: how long a piece of work took. */
|
|
43
|
+
monotonic(): number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The system's clock, read when asked: `Date.now` and `performance.now`. */
|
|
47
|
+
export const SYSTEM_CLOCK: BudgetClock = {
|
|
48
|
+
now: () => Date.now(),
|
|
49
|
+
monotonic: () => performance.now(),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
interface Spent {
|
|
53
|
+
/** When the piece of work started, by the wall clock. */
|
|
54
|
+
readonly at: number;
|
|
55
|
+
/** How long it took, in whole microseconds. */
|
|
56
|
+
readonly us: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One tenant's work in the window: its pieces in the order they started,
|
|
61
|
+
* from `head` on, and their total. Whole microseconds, so adding pieces
|
|
62
|
+
* and dropping them never drifts from what they add up to.
|
|
63
|
+
*/
|
|
64
|
+
interface Ledger {
|
|
65
|
+
pieces: Spent[];
|
|
66
|
+
head: number;
|
|
67
|
+
spentUs: number;
|
|
68
|
+
/**
|
|
69
|
+
* Once refused: when the tenant is under its budget again. Read only
|
|
70
|
+
* while it's over the budget; a piece charged clears it.
|
|
71
|
+
*/
|
|
72
|
+
refusedUntil: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The time each tenant spent on one kind of work in the last 60 seconds,
|
|
77
|
+
* against `budgetMs`; `refuse` makes the error for a tenant past it. An
|
|
78
|
+
* infinite budget keeps no count. Charging a piece costs the same however
|
|
79
|
+
* many pieces the window holds.
|
|
80
|
+
*/
|
|
81
|
+
export class TenantTimeBudget {
|
|
82
|
+
private readonly ledgers = new Map<string, Ledger>();
|
|
83
|
+
private readonly budgetUs: number;
|
|
84
|
+
private sweptAt = 0;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
private readonly budgetMs: number,
|
|
88
|
+
private readonly refuse: (budgetMs: number, retryAfterMs: number) => TenantBudgetError,
|
|
89
|
+
private readonly clock: BudgetClock = SYSTEM_CLOCK,
|
|
90
|
+
) {
|
|
91
|
+
this.budgetUs = budgetMs * 1_000;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Runs `work` as `tenant`'s, and returns what it returns: refused with
|
|
96
|
+
* `refuse`'s error, before it starts, once the tenant has spent its
|
|
97
|
+
* budget. Its time is charged, a failed piece's too.
|
|
98
|
+
*/
|
|
99
|
+
charge<T>(tenant: string, work: () => T): T {
|
|
100
|
+
if (this.budgetMs === Number.POSITIVE_INFINITY) return work();
|
|
101
|
+
const now = this.clock.now();
|
|
102
|
+
this.sweep(now);
|
|
103
|
+
this.check(tenant, now);
|
|
104
|
+
const started = this.clock.monotonic();
|
|
105
|
+
try {
|
|
106
|
+
return work();
|
|
107
|
+
} finally {
|
|
108
|
+
this.spend(tenant, now, this.clock.monotonic() - started);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Once a window: drops the work that left it, and the tenants with none left. */
|
|
113
|
+
private sweep(now: number): void {
|
|
114
|
+
if (now - this.sweptAt <= BUDGET_WINDOW_MS) return;
|
|
115
|
+
this.sweptAt = now;
|
|
116
|
+
for (const [tenant, ledger] of this.ledgers) {
|
|
117
|
+
if (this.inWindow(ledger, now) === 0) this.ledgers.delete(tenant);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Throws `refuse`'s error when `tenant` has spent its budget. */
|
|
122
|
+
private check(tenant: string, now: number): void {
|
|
123
|
+
const ledger = this.ledgerOf(tenant);
|
|
124
|
+
this.inWindow(ledger, now);
|
|
125
|
+
if (ledger.spentUs < this.budgetUs) return;
|
|
126
|
+
// Read after the count, so a wall clock set back never refuses a tenant under its budget.
|
|
127
|
+
if (now < ledger.refusedUntil) throw this.refuse(this.budgetMs, ledger.refusedUntil - now);
|
|
128
|
+
// Under the budget again once enough of the oldest work leaves the window.
|
|
129
|
+
let left = ledger.spentUs;
|
|
130
|
+
let under = now;
|
|
131
|
+
for (let i = ledger.head; i < ledger.pieces.length; i += 1) {
|
|
132
|
+
const spent = ledger.pieces[i] as Spent;
|
|
133
|
+
left -= spent.us;
|
|
134
|
+
under = spent.at + BUDGET_WINDOW_MS;
|
|
135
|
+
if (left < this.budgetUs) break;
|
|
136
|
+
}
|
|
137
|
+
// It holds until a piece is charged: what the tenant starts now is refused.
|
|
138
|
+
ledger.refusedUntil = under;
|
|
139
|
+
throw this.refuse(this.budgetMs, under - now);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Charges `tenant` for work started `at` that took `ms`. */
|
|
143
|
+
private spend(tenant: string, at: number, ms: number): void {
|
|
144
|
+
const ledger = this.ledgerOf(tenant);
|
|
145
|
+
const us = Math.round(ms * 1_000);
|
|
146
|
+
ledger.spentUs += us;
|
|
147
|
+
// A piece that started before a refusal can end after it, and move the wait.
|
|
148
|
+
ledger.refusedUntil = 0;
|
|
149
|
+
// Work ends in the order it started, but for a piece inside another one.
|
|
150
|
+
let i = ledger.pieces.length;
|
|
151
|
+
while (i > ledger.head && (ledger.pieces[i - 1] as Spent).at > at) i -= 1;
|
|
152
|
+
ledger.pieces.splice(i, 0, { at, us });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Drops `ledger`'s work that left the window ending `now`; returns how many pieces are left. */
|
|
156
|
+
private inWindow(ledger: Ledger, now: number): number {
|
|
157
|
+
const { pieces } = ledger;
|
|
158
|
+
while (
|
|
159
|
+
ledger.head < pieces.length &&
|
|
160
|
+
(pieces[ledger.head] as Spent).at <= now - BUDGET_WINDOW_MS
|
|
161
|
+
) {
|
|
162
|
+
ledger.spentUs -= (pieces[ledger.head] as Spent).us;
|
|
163
|
+
ledger.head += 1;
|
|
164
|
+
}
|
|
165
|
+
// Copied down once as many were dropped as are kept: each piece is copied about once.
|
|
166
|
+
if (ledger.head > 0 && ledger.head * 2 >= pieces.length) {
|
|
167
|
+
ledger.pieces = pieces.slice(ledger.head);
|
|
168
|
+
ledger.head = 0;
|
|
169
|
+
}
|
|
170
|
+
return ledger.pieces.length - ledger.head;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private ledgerOf(tenant: string): Ledger {
|
|
174
|
+
let ledger = this.ledgers.get(tenant);
|
|
175
|
+
if (ledger === undefined) {
|
|
176
|
+
ledger = { pieces: [], head: 0, spentUs: 0, refusedUntil: 0 };
|
|
177
|
+
this.ledgers.set(tenant, ledger);
|
|
178
|
+
}
|
|
179
|
+
return ledger;
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
HOSTED_BOUNDS,
|
|
6
|
+
SELF_HOSTED_BOUNDS,
|
|
7
|
+
type TenantInputBounds,
|
|
8
|
+
configureTenantInputBounds,
|
|
9
|
+
tenantInputBounds,
|
|
10
|
+
} from './bounds.js';
|
|
11
|
+
export {
|
|
12
|
+
BUDGET_WINDOW_MS,
|
|
13
|
+
type BudgetClock,
|
|
14
|
+
SYSTEM_CLOCK,
|
|
15
|
+
TenantBudgetError,
|
|
16
|
+
type TenantBudgetLimit,
|
|
17
|
+
TenantTimeBudget,
|
|
18
|
+
} from './budget.js';
|
|
19
|
+
export {
|
|
20
|
+
type CompiledTenantPattern,
|
|
21
|
+
type TenantPattern,
|
|
22
|
+
compileTenantPattern,
|
|
23
|
+
tenantPatternSyntaxProblem,
|
|
24
|
+
} from './pattern.js';
|
|
25
|
+
export {
|
|
26
|
+
type RunAsTenantOptions,
|
|
27
|
+
type TenantCompiles,
|
|
28
|
+
type TenantScope,
|
|
29
|
+
currentTenant,
|
|
30
|
+
runAsTenant,
|
|
31
|
+
runOutsideTenant,
|
|
32
|
+
} from './tenant.js';
|