@malloy-publisher/server 0.0.198-dev → 0.0.198
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/README.docker.md +135 -20
- package/README.md +15 -0
- package/build.ts +16 -0
- package/dist/app/assets/EnvironmentPage-C7rtH4mC.js +1 -0
- package/dist/app/assets/HomePage-DwkH7OrS.js +1 -0
- package/dist/app/assets/MainPage-D38LtZDV.js +2 -0
- package/dist/app/assets/ModelPage-DOol8Mz7.js +1 -0
- package/dist/app/assets/PackagePage-0tgzA_kO.js +1 -0
- package/dist/app/assets/RouteError-BaMsOSly.js +1 -0
- package/dist/app/assets/WorkbookPage-Cx4SePkx.js +1 -0
- package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-CbsC6R_Y.es-Cwf6asf3.js} +14 -14
- package/dist/app/assets/index-DL6BZTuw.js +1803 -0
- package/dist/app/assets/{index-C513UodQ.js → index-DNofXMxi.js} +15 -15
- package/dist/app/assets/index-U38AyjJL.js +451 -0
- package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-B68wGGkM.js} +1 -1
- package/dist/app/index.html +2 -3
- package/dist/default-publisher.config.json +23 -0
- package/dist/instrumentation.mjs +1 -3
- package/dist/server.mjs +1104 -567
- package/package.json +11 -12
- package/publisher.config.example.bigquery.json +33 -0
- package/publisher.config.example.duckdb.json +23 -0
- package/publisher.config.json +1 -11
- package/src/config.spec.ts +306 -0
- package/src/config.ts +222 -2
- package/src/controller/connection.controller.ts +1 -1
- package/src/controller/package.controller.ts +70 -29
- package/src/default-publisher.config.json +23 -0
- package/src/errors.spec.ts +42 -0
- package/src/errors.ts +21 -0
- package/src/logger.ts +1 -3
- package/src/mcp/tools/discovery_tools.ts +6 -2
- package/src/path_safety.spec.ts +158 -0
- package/src/path_safety.ts +140 -0
- package/src/pg_helpers.spec.ts +226 -0
- package/src/pg_helpers.ts +129 -0
- package/src/server-old.ts +3 -23
- package/src/server.ts +33 -0
- package/src/service/connection.spec.ts +6 -4
- package/src/service/connection.ts +8 -3
- package/src/service/connection_config.ts +2 -2
- package/src/service/environment.ts +619 -175
- package/src/service/environment_admission.spec.ts +180 -0
- package/src/service/environment_store.ts +22 -0
- package/src/service/manifest_service.spec.ts +7 -2
- package/src/service/manifest_service.ts +8 -2
- package/src/service/materialization_service.ts +14 -3
- package/src/service/package.ts +4 -3
- package/src/service/package_memory_governor.spec.ts +173 -0
- package/src/service/package_memory_governor.ts +233 -0
- package/src/service/package_race.spec.ts +208 -0
- package/src/storage/StorageManager.ts +71 -11
- package/src/storage/duckdb/schema.ts +41 -0
- package/src/utils.ts +11 -0
- package/tests/harness/rest_e2e.ts +2 -2
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
- package/tests/unit/duckdb/attached_databases.test.ts +5 -5
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
- package/tests/unit/storage/StorageManager.test.ts +166 -0
- package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
- package/dist/app/assets/HomePage-DMop21VG.js +0 -1
- package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
- package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
- package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
- package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
- package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
- package/dist/app/assets/index-5K9YjIxF.js +0 -456
- package/dist/app/assets/index-DIgzgp69.js +0 -1742
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
|
|
6
|
+
import { ServiceUnavailableError } from "../errors";
|
|
7
|
+
import { buildEnvironmentMalloyConfig } from "./connection";
|
|
8
|
+
import { Environment } from "./environment";
|
|
9
|
+
import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal subset of {@link PackageMemoryGovernor} that
|
|
13
|
+
* `Environment.assertCanAdmitNewPackage` actually consults. Allows us
|
|
14
|
+
* to drive the gate from the tests without spinning the real OTel
|
|
15
|
+
* instrumentation pipeline.
|
|
16
|
+
*/
|
|
17
|
+
class StubGovernor {
|
|
18
|
+
public backpressured = false;
|
|
19
|
+
isBackpressured(): boolean {
|
|
20
|
+
return this.backpressured;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeEnvironment(envPath: string): Environment {
|
|
25
|
+
const malloyConfig = buildEnvironmentMalloyConfig([], envPath);
|
|
26
|
+
return new Environment("test-env", envPath, malloyConfig, []);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("Environment admission gate (memory governor choke point)", () => {
|
|
30
|
+
let envDir: string;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
envDir = fs.mkdtempSync(
|
|
34
|
+
path.join(os.tmpdir(), "publisher-env-admission-"),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
fs.rmSync(envDir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("admits new packages when no governor is attached (legacy behaviour)", async () => {
|
|
43
|
+
const env = makeEnvironment(envDir);
|
|
44
|
+
// No governor set; the gate must be a pure no-op. The package
|
|
45
|
+
// doesn't exist on disk, so addPackage rejects with
|
|
46
|
+
// PackageNotFoundError — that we get any error other than 503
|
|
47
|
+
// is the assertion.
|
|
48
|
+
let caught: unknown;
|
|
49
|
+
try {
|
|
50
|
+
await env.addPackage("does-not-exist");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
caught = err;
|
|
53
|
+
}
|
|
54
|
+
expect(caught).toBeDefined();
|
|
55
|
+
expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects getPackage cache-miss with 503 when back-pressured", async () => {
|
|
59
|
+
const env = makeEnvironment(envDir);
|
|
60
|
+
const governor = new StubGovernor();
|
|
61
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
62
|
+
governor.backpressured = true;
|
|
63
|
+
|
|
64
|
+
// No package by this name has ever been loaded, so this is the
|
|
65
|
+
// exact "lazy-load on cache miss" path Monty flagged. The gate
|
|
66
|
+
// must throw before Package.create touches the disk.
|
|
67
|
+
await expect(env.getPackage("ghost", false)).rejects.toBeInstanceOf(
|
|
68
|
+
ServiceUnavailableError,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects getPackage reload=true with 503 when back-pressured", async () => {
|
|
73
|
+
const env = makeEnvironment(envDir);
|
|
74
|
+
const governor = new StubGovernor();
|
|
75
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
76
|
+
governor.backpressured = true;
|
|
77
|
+
|
|
78
|
+
await expect(env.getPackage("ghost", true)).rejects.toBeInstanceOf(
|
|
79
|
+
ServiceUnavailableError,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects addPackage with 503 when back-pressured (after the 404 check passes)", async () => {
|
|
84
|
+
// Create a real (empty) package directory so the existence
|
|
85
|
+
// check passes and the gate gets to run. Without this, the
|
|
86
|
+
// PackageNotFoundError would mask the 503 we want to assert.
|
|
87
|
+
const pkgName = "real-pkg";
|
|
88
|
+
fs.mkdirSync(path.join(envDir, pkgName));
|
|
89
|
+
|
|
90
|
+
const env = makeEnvironment(envDir);
|
|
91
|
+
const governor = new StubGovernor();
|
|
92
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
93
|
+
governor.backpressured = true;
|
|
94
|
+
|
|
95
|
+
await expect(env.addPackage(pkgName)).rejects.toBeInstanceOf(
|
|
96
|
+
ServiceUnavailableError,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns 404 (not 503) when the package directory does not exist, even under pressure", async () => {
|
|
101
|
+
// 404 must take precedence over 503: a permanent "you forgot to
|
|
102
|
+
// upload the package" error should not be masked as a transient
|
|
103
|
+
// "retry later" — otherwise operators chase phantom memory
|
|
104
|
+
// problems while the real fix is a missing artifact.
|
|
105
|
+
const env = makeEnvironment(envDir);
|
|
106
|
+
const governor = new StubGovernor();
|
|
107
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
108
|
+
governor.backpressured = true;
|
|
109
|
+
|
|
110
|
+
let caught: unknown;
|
|
111
|
+
try {
|
|
112
|
+
await env.addPackage("never-existed");
|
|
113
|
+
} catch (err) {
|
|
114
|
+
caught = err;
|
|
115
|
+
}
|
|
116
|
+
expect(caught).toBeDefined();
|
|
117
|
+
expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("allowAdmission=true bypasses the gate (for future warmup/probe callers)", async () => {
|
|
121
|
+
const env = makeEnvironment(envDir);
|
|
122
|
+
const governor = new StubGovernor();
|
|
123
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
124
|
+
governor.backpressured = true;
|
|
125
|
+
|
|
126
|
+
// With the bypass, the gate must not fire — the call should
|
|
127
|
+
// proceed to the real loader and fail there with some other
|
|
128
|
+
// error (PackageNotFoundError-equivalent from Package.create on
|
|
129
|
+
// a non-existent directory). The assertion is "not 503".
|
|
130
|
+
let caught: unknown;
|
|
131
|
+
try {
|
|
132
|
+
await env.getPackage("ghost", false, { allowAdmission: true });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
caught = err;
|
|
135
|
+
}
|
|
136
|
+
expect(caught).toBeDefined();
|
|
137
|
+
expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("clearing back-pressure on the governor immediately re-admits new loads", async () => {
|
|
141
|
+
const env = makeEnvironment(envDir);
|
|
142
|
+
const governor = new StubGovernor();
|
|
143
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
144
|
+
|
|
145
|
+
governor.backpressured = true;
|
|
146
|
+
await expect(env.getPackage("ghost", false)).rejects.toBeInstanceOf(
|
|
147
|
+
ServiceUnavailableError,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Flip the flag (simulating the periodic poller crossing the
|
|
151
|
+
// low-water mark) and verify the next call no longer 503s.
|
|
152
|
+
governor.backpressured = false;
|
|
153
|
+
let caught: unknown;
|
|
154
|
+
try {
|
|
155
|
+
await env.getPackage("ghost", false);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
caught = err;
|
|
158
|
+
}
|
|
159
|
+
expect(caught).toBeDefined();
|
|
160
|
+
expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("detaching the governor (set null) reverts to legacy admit-everything", async () => {
|
|
164
|
+
const env = makeEnvironment(envDir);
|
|
165
|
+
const governor = new StubGovernor();
|
|
166
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
167
|
+
governor.backpressured = true;
|
|
168
|
+
|
|
169
|
+
env.setMemoryGovernor(null);
|
|
170
|
+
|
|
171
|
+
let caught: unknown;
|
|
172
|
+
try {
|
|
173
|
+
await env.getPackage("ghost", false);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
caught = err;
|
|
176
|
+
}
|
|
177
|
+
expect(caught).toBeDefined();
|
|
178
|
+
expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -30,6 +30,7 @@ import { formatDuration, logger } from "../logger";
|
|
|
30
30
|
import { Connection } from "../storage/DatabaseInterface";
|
|
31
31
|
import { StorageConfig, StorageManager } from "../storage/StorageManager";
|
|
32
32
|
import { Environment, PackageStatus } from "./environment";
|
|
33
|
+
import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
33
34
|
type ApiEnvironment = components["schemas"]["Environment"];
|
|
34
35
|
|
|
35
36
|
const AZURE_SUPPORTED_SCHEMES = ["https://", "http://", "abfss://", "az://"];
|
|
@@ -101,6 +102,10 @@ export class EnvironmentStore {
|
|
|
101
102
|
followRegionRedirects: true,
|
|
102
103
|
});
|
|
103
104
|
private gcsClient: Storage;
|
|
105
|
+
// Shared by every Environment so the back-pressure decision is
|
|
106
|
+
// process-wide. Set once at server start via setMemoryGovernor;
|
|
107
|
+
// new Environments pick it up at construction.
|
|
108
|
+
private memoryGovernor: PackageMemoryGovernor | null = null;
|
|
104
109
|
|
|
105
110
|
constructor(serverRootPath: string) {
|
|
106
111
|
this.serverRootPath = serverRootPath;
|
|
@@ -117,6 +122,19 @@ export class EnvironmentStore {
|
|
|
117
122
|
this.finishedInitialization = this.initialize();
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Attach (or detach with `null`) the shared {@link PackageMemoryGovernor}.
|
|
127
|
+
* Propagated to every Environment so the back-pressure decision is
|
|
128
|
+
* process-wide, and remembered so any Environment created *after*
|
|
129
|
+
* this call also picks it up at construction.
|
|
130
|
+
*/
|
|
131
|
+
public setMemoryGovernor(governor: PackageMemoryGovernor | null): void {
|
|
132
|
+
this.memoryGovernor = governor;
|
|
133
|
+
for (const env of this.environments.values()) {
|
|
134
|
+
env.setMemoryGovernor(governor);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
120
138
|
private async addConfiguredEnvironment(environment: ProcessedEnvironment) {
|
|
121
139
|
try {
|
|
122
140
|
await this.addEnvironment(
|
|
@@ -238,6 +256,9 @@ export class EnvironmentStore {
|
|
|
238
256
|
...conn.config,
|
|
239
257
|
})),
|
|
240
258
|
);
|
|
259
|
+
environmentInstance.setMemoryGovernor(
|
|
260
|
+
this.memoryGovernor,
|
|
261
|
+
);
|
|
241
262
|
|
|
242
263
|
// Get packages from database
|
|
243
264
|
const packages = await repository.listPackages(
|
|
@@ -837,6 +858,7 @@ export class EnvironmentStore {
|
|
|
837
858
|
absoluteEnvironmentPath,
|
|
838
859
|
environment.connections || [],
|
|
839
860
|
);
|
|
861
|
+
newEnvironment.setMemoryGovernor(this.memoryGovernor);
|
|
840
862
|
|
|
841
863
|
if (!newEnvironment.metadata) newEnvironment.metadata = {};
|
|
842
864
|
newEnvironment.metadata.location = absoluteEnvironmentPath;
|
|
@@ -39,6 +39,7 @@ function createMocks() {
|
|
|
39
39
|
} as unknown as sinon.SinonStubbedInstance<ResourceRepository>;
|
|
40
40
|
|
|
41
41
|
const reloadAllModels = sandbox.stub().resolves();
|
|
42
|
+
const reloadAllModelsForPackage = sandbox.stub().resolves();
|
|
42
43
|
|
|
43
44
|
const pkg = {
|
|
44
45
|
reloadAllModels,
|
|
@@ -46,6 +47,7 @@ function createMocks() {
|
|
|
46
47
|
|
|
47
48
|
const environment = {
|
|
48
49
|
getPackage: sandbox.stub().resolves(pkg),
|
|
50
|
+
reloadAllModelsForPackage,
|
|
49
51
|
};
|
|
50
52
|
|
|
51
53
|
const environmentStore = {
|
|
@@ -66,6 +68,7 @@ function createMocks() {
|
|
|
66
68
|
environment,
|
|
67
69
|
pkg,
|
|
68
70
|
reloadAllModels,
|
|
71
|
+
reloadAllModelsForPackage,
|
|
69
72
|
service,
|
|
70
73
|
};
|
|
71
74
|
}
|
|
@@ -153,7 +156,9 @@ describe("ManifestService", () => {
|
|
|
153
156
|
),
|
|
154
157
|
).toBe(true);
|
|
155
158
|
expect(ctx.environment.getPackage.calledWith("pkg", false)).toBe(true);
|
|
156
|
-
expect(
|
|
159
|
+
expect(
|
|
160
|
+
ctx.reloadAllModelsForPackage.calledWith("pkg", manifest.entries),
|
|
161
|
+
).toBe(true);
|
|
157
162
|
});
|
|
158
163
|
|
|
159
164
|
it("should return an empty manifest when no entries exist", async () => {
|
|
@@ -170,7 +175,7 @@ describe("ManifestService", () => {
|
|
|
170
175
|
);
|
|
171
176
|
|
|
172
177
|
expect(result.entries).toEqual({});
|
|
173
|
-
expect(ctx.
|
|
178
|
+
expect(ctx.reloadAllModelsForPackage.calledWith("pkg", {})).toBe(true);
|
|
174
179
|
});
|
|
175
180
|
});
|
|
176
181
|
|
|
@@ -82,8 +82,14 @@ export class ManifestService {
|
|
|
82
82
|
environmentName,
|
|
83
83
|
false,
|
|
84
84
|
);
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// Ensure the package is loaded, then reload its models under the
|
|
86
|
+
// per-package mutex so the disk reads are serialized against
|
|
87
|
+
// installPackage / deletePackage.
|
|
88
|
+
await environment.getPackage(packageName, false);
|
|
89
|
+
await environment.reloadAllModelsForPackage(
|
|
90
|
+
packageName,
|
|
91
|
+
manifest.entries,
|
|
92
|
+
);
|
|
87
93
|
|
|
88
94
|
logger.info("Reloaded manifest and recompiled models", {
|
|
89
95
|
environmentId,
|
|
@@ -376,8 +376,14 @@ export class MaterializationService {
|
|
|
376
376
|
environmentName,
|
|
377
377
|
false,
|
|
378
378
|
);
|
|
379
|
-
|
|
380
|
-
|
|
379
|
+
// Ensure the package is loaded, then reload models under the
|
|
380
|
+
// per-package mutex so the disk reads are serialized against
|
|
381
|
+
// installPackage / deletePackage.
|
|
382
|
+
await environment.getPackage(packageName, false);
|
|
383
|
+
await environment.reloadAllModelsForPackage(
|
|
384
|
+
packageName,
|
|
385
|
+
updatedManifest.entries,
|
|
386
|
+
);
|
|
381
387
|
}
|
|
382
388
|
|
|
383
389
|
await this.transitionExecution(executionId, "SUCCESS", {
|
|
@@ -604,8 +610,13 @@ export class MaterializationService {
|
|
|
604
610
|
// ── STEP 2: COMPILE & PLAN ─────────────────────────────────────
|
|
605
611
|
// `connections` is built lazily from the connection names the plan
|
|
606
612
|
// actually targets — no upfront ATTACH on every environment connection.
|
|
613
|
+
// Hold the per-package mutex for the duration of the compile so the
|
|
614
|
+
// `fs.stat` + `runtime.loadModel` calls inside `compilePackageBuildPlan`
|
|
615
|
+
// are serialized against `installPackage` / `deletePackage`.
|
|
607
616
|
const { graphs, sources, connectionDigests, connections } =
|
|
608
|
-
await
|
|
617
|
+
await environment.withPackageLock(packageName, () =>
|
|
618
|
+
this.compilePackageBuildPlan(pkg, signal),
|
|
619
|
+
);
|
|
609
620
|
|
|
610
621
|
if (graphs.length === 0) {
|
|
611
622
|
logger.info("No persist sources to build");
|
package/src/service/package.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { PackageNotFoundError } from "../errors";
|
|
25
25
|
import { formatDuration, logger } from "../logger";
|
|
26
26
|
import { BuildManifest } from "../storage/DatabaseInterface";
|
|
27
|
+
import { ignoreDotfiles } from "../utils";
|
|
27
28
|
import { Model } from "./model";
|
|
28
29
|
|
|
29
30
|
type ApiDatabase = components["schemas"]["Database"];
|
|
@@ -42,6 +43,7 @@ type PackageConnectionInput =
|
|
|
42
43
|
| (() => MalloyConfig);
|
|
43
44
|
|
|
44
45
|
const ENABLE_LIST_MODEL_COMPILATION = true;
|
|
46
|
+
|
|
45
47
|
export class Package {
|
|
46
48
|
private environmentName: string;
|
|
47
49
|
private packageName: string;
|
|
@@ -380,7 +382,7 @@ export class Package {
|
|
|
380
382
|
private static async getModelPaths(packagePath: string): Promise<string[]> {
|
|
381
383
|
let files = undefined;
|
|
382
384
|
try {
|
|
383
|
-
files = await recursive(packagePath);
|
|
385
|
+
files = await recursive(packagePath, [ignoreDotfiles]);
|
|
384
386
|
} catch (error) {
|
|
385
387
|
logger.error(error);
|
|
386
388
|
throw new PackageNotFoundError(
|
|
@@ -449,8 +451,7 @@ export class Package {
|
|
|
449
451
|
private static async getDatabasePaths(
|
|
450
452
|
packagePath: string,
|
|
451
453
|
): Promise<string[]> {
|
|
452
|
-
|
|
453
|
-
files = await recursive(packagePath);
|
|
454
|
+
const files = await recursive(packagePath, [ignoreDotfiles]);
|
|
454
455
|
return files
|
|
455
456
|
.map((fullPath: string) => {
|
|
456
457
|
return path.relative(packagePath, fullPath).replace(/\\/g, "/");
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { MemoryGovernorConfig } from "../config";
|
|
4
|
+
import { PackageMemoryGovernor } from "./package_memory_governor";
|
|
5
|
+
|
|
6
|
+
const ONE_GB = 1024 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
function makeConfig(
|
|
9
|
+
overrides: Partial<MemoryGovernorConfig> = {},
|
|
10
|
+
): MemoryGovernorConfig {
|
|
11
|
+
return {
|
|
12
|
+
maxMemoryBytes: ONE_GB,
|
|
13
|
+
highWaterFraction: 0.8,
|
|
14
|
+
lowWaterFraction: 0.7,
|
|
15
|
+
checkIntervalMs: 5_000,
|
|
16
|
+
backpressureEnabled: true,
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Test driver that lets us push a sequence of RSS values into the
|
|
23
|
+
* governor and inspect the state machine's reactions deterministically
|
|
24
|
+
* — no real allocations, no real timers.
|
|
25
|
+
*/
|
|
26
|
+
class FakeRssSampler {
|
|
27
|
+
private value = 0;
|
|
28
|
+
constructor(initial = 0) {
|
|
29
|
+
this.value = initial;
|
|
30
|
+
}
|
|
31
|
+
set(value: number): void {
|
|
32
|
+
this.value = value;
|
|
33
|
+
}
|
|
34
|
+
sampler = (): number => this.value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("PackageMemoryGovernor", () => {
|
|
38
|
+
it("does not activate back-pressure below the high-water mark", () => {
|
|
39
|
+
const rss = new FakeRssSampler(0.5 * ONE_GB);
|
|
40
|
+
const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
|
|
41
|
+
gov.tick();
|
|
42
|
+
expect(gov.isBackpressured()).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("activates back-pressure at or above the high-water mark", () => {
|
|
46
|
+
const rss = new FakeRssSampler(0);
|
|
47
|
+
const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
|
|
48
|
+
|
|
49
|
+
rss.set(0.79 * ONE_GB);
|
|
50
|
+
gov.tick();
|
|
51
|
+
expect(gov.isBackpressured()).toBe(false);
|
|
52
|
+
|
|
53
|
+
// 0.8 * 1GB is exactly the high-water threshold; using >= so it
|
|
54
|
+
// trips on the boundary.
|
|
55
|
+
rss.set(0.8 * ONE_GB);
|
|
56
|
+
gov.tick();
|
|
57
|
+
expect(gov.isBackpressured()).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not clear back-pressure inside the hysteresis band", () => {
|
|
61
|
+
const rss = new FakeRssSampler(0.9 * ONE_GB);
|
|
62
|
+
const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
|
|
63
|
+
gov.tick();
|
|
64
|
+
expect(gov.isBackpressured()).toBe(true);
|
|
65
|
+
|
|
66
|
+
// Between low (0.7) and high (0.8) — must stay backpressured.
|
|
67
|
+
rss.set(0.75 * ONE_GB);
|
|
68
|
+
gov.tick();
|
|
69
|
+
expect(gov.isBackpressured()).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("clears back-pressure at or below the low-water mark", () => {
|
|
73
|
+
const rss = new FakeRssSampler(0.9 * ONE_GB);
|
|
74
|
+
const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
|
|
75
|
+
gov.tick();
|
|
76
|
+
expect(gov.isBackpressured()).toBe(true);
|
|
77
|
+
|
|
78
|
+
// The implementation floors lowWaterBytes (= 0.7 * 1GB → 751619276),
|
|
79
|
+
// so we need to feed a value at or below that integer — `0.7 * 1GB`
|
|
80
|
+
// as a float is 751619276.8 which sits just above the threshold.
|
|
81
|
+
rss.set(0.69 * ONE_GB);
|
|
82
|
+
gov.tick();
|
|
83
|
+
expect(gov.isBackpressured()).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("re-activates after recovery if RSS climbs again", () => {
|
|
87
|
+
const rss = new FakeRssSampler(0);
|
|
88
|
+
const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
|
|
89
|
+
|
|
90
|
+
rss.set(0.85 * ONE_GB);
|
|
91
|
+
gov.tick();
|
|
92
|
+
expect(gov.isBackpressured()).toBe(true);
|
|
93
|
+
|
|
94
|
+
rss.set(0.6 * ONE_GB);
|
|
95
|
+
gov.tick();
|
|
96
|
+
expect(gov.isBackpressured()).toBe(false);
|
|
97
|
+
|
|
98
|
+
rss.set(0.9 * ONE_GB);
|
|
99
|
+
gov.tick();
|
|
100
|
+
expect(gov.isBackpressured()).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("samples but never flips the flag when backpressureEnabled=false", () => {
|
|
104
|
+
const rss = new FakeRssSampler(0.95 * ONE_GB);
|
|
105
|
+
const gov = new PackageMemoryGovernor(
|
|
106
|
+
makeConfig({ backpressureEnabled: false }),
|
|
107
|
+
rss.sampler,
|
|
108
|
+
);
|
|
109
|
+
gov.tick();
|
|
110
|
+
expect(gov.isBackpressured()).toBe(false);
|
|
111
|
+
// Status still tracks RSS even though the flag is suppressed.
|
|
112
|
+
expect(gov.getStatus().rssBytes).toBe(0.95 * ONE_GB);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("survives a throwing sampler without crashing or flipping state", () => {
|
|
116
|
+
let throwOnce = true;
|
|
117
|
+
const gov = new PackageMemoryGovernor(makeConfig(), () => {
|
|
118
|
+
if (throwOnce) {
|
|
119
|
+
throwOnce = false;
|
|
120
|
+
throw new Error("simulated sampling failure");
|
|
121
|
+
}
|
|
122
|
+
return 0.4 * ONE_GB;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// First tick: sampler throws; governor swallows it and leaves
|
|
126
|
+
// the state untouched.
|
|
127
|
+
gov.tick();
|
|
128
|
+
expect(gov.isBackpressured()).toBe(false);
|
|
129
|
+
|
|
130
|
+
// Second tick succeeds.
|
|
131
|
+
gov.tick();
|
|
132
|
+
expect(gov.isBackpressured()).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("start() takes an immediate sample so a hot-start respects the cap", () => {
|
|
136
|
+
const rss = new FakeRssSampler(0.95 * ONE_GB);
|
|
137
|
+
const gov = new PackageMemoryGovernor(
|
|
138
|
+
// Big interval so we know the initial sample isn't from a
|
|
139
|
+
// delayed tick.
|
|
140
|
+
makeConfig({ checkIntervalMs: 60_000 }),
|
|
141
|
+
rss.sampler,
|
|
142
|
+
);
|
|
143
|
+
gov.start();
|
|
144
|
+
expect(gov.isBackpressured()).toBe(true);
|
|
145
|
+
gov.stop();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("stop() clears back-pressure and is idempotent", () => {
|
|
149
|
+
const rss = new FakeRssSampler(0.95 * ONE_GB);
|
|
150
|
+
const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
|
|
151
|
+
gov.tick();
|
|
152
|
+
expect(gov.isBackpressured()).toBe(true);
|
|
153
|
+
|
|
154
|
+
gov.stop();
|
|
155
|
+
expect(gov.isBackpressured()).toBe(false);
|
|
156
|
+
// Second call is a no-op (no thrown error, flag stays cleared).
|
|
157
|
+
gov.stop();
|
|
158
|
+
expect(gov.isBackpressured()).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("exposes computed threshold bytes through getStatus", () => {
|
|
162
|
+
const rss = new FakeRssSampler(0.4 * ONE_GB);
|
|
163
|
+
const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
|
|
164
|
+
gov.tick();
|
|
165
|
+
const status = gov.getStatus();
|
|
166
|
+
expect(status.maxMemoryBytes).toBe(ONE_GB);
|
|
167
|
+
expect(status.highWaterBytes).toBe(Math.floor(0.8 * ONE_GB));
|
|
168
|
+
expect(status.lowWaterBytes).toBe(Math.floor(0.7 * ONE_GB));
|
|
169
|
+
expect(status.rssBytes).toBe(0.4 * ONE_GB);
|
|
170
|
+
expect(status.backpressured).toBe(false);
|
|
171
|
+
expect(typeof status.lastSampledAt).toBe("number");
|
|
172
|
+
});
|
|
173
|
+
});
|