@malloy-publisher/server 0.0.198-dev → 0.0.198-dev1
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 +42 -1
- package/dist/app/api-doc.yaml +51 -0
- package/dist/app/assets/EnvironmentPage-Dpee_Kn6.js +1 -0
- package/dist/app/assets/HomePage-DLRWTNoL.js +1 -0
- package/dist/app/assets/MainPage-DsVt5QGM.js +2 -0
- package/dist/app/assets/ModelPage-AwAugZ37.js +1 -0
- package/dist/app/assets/PackagePage-XQ-EWGTC.js +1 -0
- package/dist/app/assets/RouteError-3Mv8JQw7.js +1 -0
- package/dist/app/assets/WorkbookPage-DHYYpcYc.js +1 -0
- package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-DfcpQGVP.es-DQggNOdX.js} +14 -14
- package/dist/app/assets/{index-C513UodQ.js → index-BUp81Qdm.js} +15 -15
- package/dist/app/assets/index-D1pdwrUW.js +1803 -0
- package/dist/app/assets/index-Dv5bF4Ii.js +451 -0
- package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-CQH4LZU8.js} +1 -1
- package/dist/app/index.html +2 -3
- package/dist/compile_worker.mjs +628 -0
- package/dist/default-publisher.config.json +23 -0
- package/dist/instrumentation.mjs +36 -38
- package/dist/server.mjs +2060 -913
- 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/compile/compile_pool.spec.ts +227 -0
- package/src/compile/compile_pool.ts +729 -0
- package/src/compile/compile_worker.ts +683 -0
- package/src/compile/protocol.ts +251 -0
- package/src/config.spec.ts +306 -0
- package/src/config.ts +222 -2
- package/src/controller/compile.controller.ts +3 -1
- package/src/controller/connection.controller.ts +1 -1
- package/src/controller/model.controller.ts +8 -1
- package/src/controller/package.controller.ts +70 -29
- package/src/controller/query.controller.ts +3 -0
- package/src/default-publisher.config.json +23 -0
- package/src/errors.spec.ts +42 -0
- package/src/errors.ts +21 -0
- package/src/health.spec.ts +90 -0
- package/src/health.ts +86 -45
- package/src/logger.ts +1 -3
- package/src/mcp/tools/discovery_tools.ts +6 -2
- package/src/mcp/tools/execute_query_tool.ts +12 -0
- 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 +49 -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 +621 -176
- package/src/service/environment_admission.spec.ts +180 -0
- package/src/service/environment_store.ts +22 -0
- package/src/service/filter_integration.spec.ts +110 -0
- package/src/service/givens_integration.spec.ts +192 -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/model.spec.ts +105 -0
- package/src/service/model.ts +317 -10
- package/src/service/model_worker_path.spec.ts +125 -0
- 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/concurrent_package/concurrent_package.integration.spec.ts +280 -0
- 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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: exercise `Model.create` with the worker pool
|
|
3
|
+
* enabled (MALLOY_COMPILE_WORKERS=1).
|
|
4
|
+
*
|
|
5
|
+
* Validates that the worker-compile path:
|
|
6
|
+
* - produces a Model with a populated modelDef + sources + queries
|
|
7
|
+
* - defers materializer construction (none until first query)
|
|
8
|
+
* - falls back to in-process compile for notebooks
|
|
9
|
+
* - falls through to in-process compile when the worker pool fails
|
|
10
|
+
*
|
|
11
|
+
* Kept separate from `model.spec.ts` so the existing tests keep
|
|
12
|
+
* running on the in-process path without paying worker startup cost.
|
|
13
|
+
*/
|
|
14
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as os from "os";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import { __setCompilePoolForTests } from "../compile/compile_pool";
|
|
19
|
+
import { Model } from "./model";
|
|
20
|
+
|
|
21
|
+
const ORIGINAL_ENV = process.env.MALLOY_COMPILE_WORKERS;
|
|
22
|
+
|
|
23
|
+
describe("Model.create via worker pool", () => {
|
|
24
|
+
let tempDir: string;
|
|
25
|
+
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
process.env.MALLOY_COMPILE_WORKERS = "1";
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
if (ORIGINAL_ENV === undefined) {
|
|
32
|
+
delete process.env.MALLOY_COMPILE_WORKERS;
|
|
33
|
+
} else {
|
|
34
|
+
process.env.MALLOY_COMPILE_WORKERS = ORIGINAL_ENV;
|
|
35
|
+
}
|
|
36
|
+
await __setCompilePoolForTests(null);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
if (tempDir) {
|
|
41
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
42
|
+
tempDir = "";
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("compiles a .malloy file via worker and returns a usable Model", async () => {
|
|
47
|
+
const { DuckDBConnection } = await import("@malloydata/db-duckdb");
|
|
48
|
+
tempDir = fs.mkdtempSync(
|
|
49
|
+
path.join(os.tmpdir(), "publisher-model-worker-"),
|
|
50
|
+
);
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(tempDir, "trivial.malloy"),
|
|
53
|
+
`source: nums is duckdb.sql("select 1 as a") extend {
|
|
54
|
+
measure: total is a.sum()
|
|
55
|
+
}`,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const duckdb = new DuckDBConnection("duckdb", ":memory:");
|
|
59
|
+
try {
|
|
60
|
+
const model = await Model.create(
|
|
61
|
+
"test-pkg",
|
|
62
|
+
tempDir,
|
|
63
|
+
"trivial.malloy",
|
|
64
|
+
new Map([["duckdb", duckdb]]),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(model).toBeInstanceOf(Model);
|
|
68
|
+
const apiModel = await model.getModel();
|
|
69
|
+
expect(apiModel.type).toBe("source");
|
|
70
|
+
expect(apiModel.modelDef).toBeDefined();
|
|
71
|
+
expect(apiModel.modelDef!.length).toBeGreaterThan(10);
|
|
72
|
+
// Single source `nums` from the worker-extracted ApiSource[]
|
|
73
|
+
expect(apiModel.sources?.[0]?.name).toBe("nums");
|
|
74
|
+
} finally {
|
|
75
|
+
await duckdb.close();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("propagates compilation errors as ModelCompilationError", async () => {
|
|
80
|
+
const { DuckDBConnection } = await import("@malloydata/db-duckdb");
|
|
81
|
+
const { ModelCompilationError } = await import("../errors");
|
|
82
|
+
tempDir = fs.mkdtempSync(
|
|
83
|
+
path.join(os.tmpdir(), "publisher-model-worker-"),
|
|
84
|
+
);
|
|
85
|
+
fs.writeFileSync(
|
|
86
|
+
path.join(tempDir, "broken.malloy"),
|
|
87
|
+
`source: nums is duckdb.sql("select 1 as a") extend {
|
|
88
|
+
measure: total is THIS_FUNC_DOES_NOT_EXIST(a)
|
|
89
|
+
}`,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const duckdb = new DuckDBConnection("duckdb", ":memory:");
|
|
93
|
+
try {
|
|
94
|
+
const model = await Model.create(
|
|
95
|
+
"test-pkg",
|
|
96
|
+
tempDir,
|
|
97
|
+
"broken.malloy",
|
|
98
|
+
new Map([["duckdb", duckdb]]),
|
|
99
|
+
);
|
|
100
|
+
// Either the Model surfaces with `compilationError` populated
|
|
101
|
+
// (returned by the worker, re-wrapped on the main thread) or
|
|
102
|
+
// getModel() throws — both are equivalent under the existing
|
|
103
|
+
// error contract; we accept either.
|
|
104
|
+
try {
|
|
105
|
+
await model.getModel();
|
|
106
|
+
// If getModel didn't throw, the compile error should be
|
|
107
|
+
// visible via the Model's `compilationError` field.
|
|
108
|
+
expect(
|
|
109
|
+
(model as unknown as { compilationError?: Error })
|
|
110
|
+
.compilationError,
|
|
111
|
+
).toBeDefined();
|
|
112
|
+
} catch (err) {
|
|
113
|
+
expect(err).toBeInstanceOf(Error);
|
|
114
|
+
// Compile errors come back as ModelCompilationError
|
|
115
|
+
// (worker serializes MalloyError with
|
|
116
|
+
// isCompilationError=true; pool re-wraps).
|
|
117
|
+
expect(
|
|
118
|
+
err instanceof ModelCompilationError || err instanceof Error,
|
|
119
|
+
).toBe(true);
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
await duckdb.close();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
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
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { metrics } from "@opentelemetry/api";
|
|
2
|
+
|
|
3
|
+
import type { MemoryGovernorConfig } from "../config";
|
|
4
|
+
import { logger } from "../logger";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Snapshot returned by {@link PackageMemoryGovernor.getStatus} for
|
|
8
|
+
* health endpoints, tests, and ad-hoc logging.
|
|
9
|
+
*/
|
|
10
|
+
export interface MemoryGovernorStatus {
|
|
11
|
+
rssBytes: number;
|
|
12
|
+
maxMemoryBytes: number;
|
|
13
|
+
highWaterBytes: number;
|
|
14
|
+
lowWaterBytes: number;
|
|
15
|
+
backpressured: boolean;
|
|
16
|
+
/** Wall-clock ms of the last successful RSS sample. */
|
|
17
|
+
lastSampledAt: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Function that returns the current process RSS in bytes. Injectable
|
|
22
|
+
* so unit tests can drive the governor with a deterministic source
|
|
23
|
+
* without spinning real allocations.
|
|
24
|
+
*/
|
|
25
|
+
export type RssSampler = () => number;
|
|
26
|
+
|
|
27
|
+
const DEFAULT_RSS_SAMPLER: RssSampler = () => process.memoryUsage().rss;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Polls process RSS on a fixed interval and toggles a single
|
|
31
|
+
* `backpressured` flag using a low/high-water hysteresis band:
|
|
32
|
+
*
|
|
33
|
+
* - RSS >= highWater → set `backpressured = true`
|
|
34
|
+
* - RSS <= lowWater → set `backpressured = false`
|
|
35
|
+
* - in between → leave the flag unchanged
|
|
36
|
+
*
|
|
37
|
+
* Controllers consult {@link isBackpressured} on hot paths that would
|
|
38
|
+
* load a *new* package into memory (`addPackage`, reload, install) and
|
|
39
|
+
* throw `ServiceUnavailableError` so the request fails fast as 503
|
|
40
|
+
* instead of pushing the pod into an OOM kill.
|
|
41
|
+
*
|
|
42
|
+
* Already-loaded packages remain fully serviceable while back-pressure
|
|
43
|
+
* is active — this is admission control on new memory, not a cache
|
|
44
|
+
* eviction. Recovery happens naturally as in-flight traffic completes
|
|
45
|
+
* and the kernel reclaims pages.
|
|
46
|
+
*
|
|
47
|
+
* Disabled by default; only constructed when
|
|
48
|
+
* `getMemoryGovernorConfig()` returns a non-null config (driven by
|
|
49
|
+
* `PUBLISHER_MAX_MEMORY_BYTES`).
|
|
50
|
+
*/
|
|
51
|
+
export class PackageMemoryGovernor {
|
|
52
|
+
private readonly config: MemoryGovernorConfig;
|
|
53
|
+
private readonly rssSampler: RssSampler;
|
|
54
|
+
private readonly highWaterBytes: number;
|
|
55
|
+
private readonly lowWaterBytes: number;
|
|
56
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
57
|
+
private backpressured = false;
|
|
58
|
+
private lastSampledRss = 0;
|
|
59
|
+
private lastSampledAt: number | null = null;
|
|
60
|
+
private readonly backpressureActivationsCounter: ReturnType<
|
|
61
|
+
ReturnType<typeof metrics.getMeter>["createCounter"]
|
|
62
|
+
>;
|
|
63
|
+
|
|
64
|
+
constructor(config: MemoryGovernorConfig, rssSampler?: RssSampler) {
|
|
65
|
+
this.config = config;
|
|
66
|
+
this.rssSampler = rssSampler ?? DEFAULT_RSS_SAMPLER;
|
|
67
|
+
this.highWaterBytes = Math.floor(
|
|
68
|
+
config.maxMemoryBytes * config.highWaterFraction,
|
|
69
|
+
);
|
|
70
|
+
this.lowWaterBytes = Math.floor(
|
|
71
|
+
config.maxMemoryBytes * config.lowWaterFraction,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const meter = metrics.getMeter("publisher");
|
|
75
|
+
|
|
76
|
+
// Periodic gauge: current process RSS in bytes.
|
|
77
|
+
meter
|
|
78
|
+
.createObservableGauge("publisher_process_rss_bytes", {
|
|
79
|
+
description:
|
|
80
|
+
"Current resident set size of the publisher process in bytes",
|
|
81
|
+
unit: "By",
|
|
82
|
+
})
|
|
83
|
+
.addCallback((observation) => {
|
|
84
|
+
observation.observe(this.rssSampler());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Periodic gauge: 1 when admission control is rejecting new
|
|
88
|
+
// package loads, 0 otherwise.
|
|
89
|
+
meter
|
|
90
|
+
.createObservableGauge("publisher_memory_backpressure_active", {
|
|
91
|
+
description:
|
|
92
|
+
"1 when the publisher is rejecting new package loads to stay under PUBLISHER_MAX_MEMORY_BYTES; 0 otherwise",
|
|
93
|
+
})
|
|
94
|
+
.addCallback((observation) => {
|
|
95
|
+
observation.observe(this.backpressured ? 1 : 0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Cumulative counter for how many times we have transitioned
|
|
99
|
+
// from `false → true`. Useful for alerting on a flapping pod.
|
|
100
|
+
this.backpressureActivationsCounter = meter.createCounter(
|
|
101
|
+
"publisher_memory_backpressure_activations_total",
|
|
102
|
+
{
|
|
103
|
+
description:
|
|
104
|
+
"Number of times the memory governor has activated back-pressure",
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Static gauges so dashboards can render the band alongside RSS
|
|
109
|
+
// without needing to plumb config separately.
|
|
110
|
+
meter
|
|
111
|
+
.createObservableGauge("publisher_memory_max_bytes", {
|
|
112
|
+
description: "Configured PUBLISHER_MAX_MEMORY_BYTES",
|
|
113
|
+
unit: "By",
|
|
114
|
+
})
|
|
115
|
+
.addCallback((observation) =>
|
|
116
|
+
observation.observe(this.config.maxMemoryBytes),
|
|
117
|
+
);
|
|
118
|
+
meter
|
|
119
|
+
.createObservableGauge("publisher_memory_high_water_bytes", {
|
|
120
|
+
description: "RSS threshold at which back-pressure activates",
|
|
121
|
+
unit: "By",
|
|
122
|
+
})
|
|
123
|
+
.addCallback((observation) =>
|
|
124
|
+
observation.observe(this.highWaterBytes),
|
|
125
|
+
);
|
|
126
|
+
meter
|
|
127
|
+
.createObservableGauge("publisher_memory_low_water_bytes", {
|
|
128
|
+
description: "RSS threshold at which back-pressure clears",
|
|
129
|
+
unit: "By",
|
|
130
|
+
})
|
|
131
|
+
.addCallback((observation) => observation.observe(this.lowWaterBytes));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Begin periodic RSS sampling. Safe to call multiple times — extra
|
|
136
|
+
* calls are no-ops. The interval is `.unref()`'d so the governor
|
|
137
|
+
* does not keep the process alive on its own.
|
|
138
|
+
*/
|
|
139
|
+
public start(): void {
|
|
140
|
+
if (this.timer !== null) return;
|
|
141
|
+
// Take an immediate sample so a freshly-started server with
|
|
142
|
+
// pre-existing high RSS goes into back-pressure right away
|
|
143
|
+
// instead of waiting `checkIntervalMs` for the first tick.
|
|
144
|
+
this.tick();
|
|
145
|
+
this.timer = setInterval(() => this.tick(), this.config.checkIntervalMs);
|
|
146
|
+
// Tolerate environments without Timer#unref (e.g. some bundlers).
|
|
147
|
+
(
|
|
148
|
+
this.timer as ReturnType<typeof setInterval> & {
|
|
149
|
+
unref?: () => void;
|
|
150
|
+
}
|
|
151
|
+
).unref?.();
|
|
152
|
+
logger.info(
|
|
153
|
+
`PackageMemoryGovernor started (max=${this.config.maxMemoryBytes}B, high=${this.highWaterBytes}B, low=${this.lowWaterBytes}B, interval=${this.config.checkIntervalMs}ms, backpressure=${this.config.backpressureEnabled})`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Stop the periodic sampler. Idempotent. Clears the back-pressure
|
|
159
|
+
* flag so any in-process logic that consults
|
|
160
|
+
* {@link isBackpressured} during shutdown sees a permissive state.
|
|
161
|
+
*/
|
|
162
|
+
public stop(): void {
|
|
163
|
+
if (this.timer !== null) {
|
|
164
|
+
clearInterval(this.timer);
|
|
165
|
+
this.timer = null;
|
|
166
|
+
}
|
|
167
|
+
this.backpressured = false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Sample RSS once and apply the hysteresis band. Exposed (rather
|
|
172
|
+
* than kept private) so callers can force a fresh check right
|
|
173
|
+
* after they finish loading a new package, and so tests can drive
|
|
174
|
+
* the governor synchronously.
|
|
175
|
+
*/
|
|
176
|
+
public tick(): void {
|
|
177
|
+
let rss: number;
|
|
178
|
+
try {
|
|
179
|
+
rss = this.rssSampler();
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// Sampling failures must never crash the server. Log and
|
|
182
|
+
// skip; the next interval will retry. Leave the flag
|
|
183
|
+
// unchanged so we neither over- nor under-react to a single
|
|
184
|
+
// measurement glitch.
|
|
185
|
+
logger.error("PackageMemoryGovernor: RSS sample failed", {
|
|
186
|
+
error: err,
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.lastSampledRss = rss;
|
|
191
|
+
this.lastSampledAt = Date.now();
|
|
192
|
+
|
|
193
|
+
if (!this.config.backpressureEnabled) {
|
|
194
|
+
// Feature dial: keep sampling for metrics but never flip
|
|
195
|
+
// the flag. Useful for monitoring-only rollouts before
|
|
196
|
+
// enabling the actual 503 behaviour.
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (rss >= this.highWaterBytes && !this.backpressured) {
|
|
201
|
+
this.backpressured = true;
|
|
202
|
+
this.backpressureActivationsCounter.add(1);
|
|
203
|
+
logger.warn(
|
|
204
|
+
`PackageMemoryGovernor: activating back-pressure (rss=${rss}B >= high=${this.highWaterBytes}B). New package loads will be rejected with HTTP 503 until rss <= ${this.lowWaterBytes}B.`,
|
|
205
|
+
);
|
|
206
|
+
} else if (rss <= this.lowWaterBytes && this.backpressured) {
|
|
207
|
+
this.backpressured = false;
|
|
208
|
+
logger.info(
|
|
209
|
+
`PackageMemoryGovernor: clearing back-pressure (rss=${rss}B <= low=${this.lowWaterBytes}B).`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* True iff new package-load requests should be rejected with HTTP
|
|
216
|
+
* 503. Cheap O(1) read of a private boolean; safe to call on every
|
|
217
|
+
* request.
|
|
218
|
+
*/
|
|
219
|
+
public isBackpressured(): boolean {
|
|
220
|
+
return this.backpressured;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
public getStatus(): MemoryGovernorStatus {
|
|
224
|
+
return {
|
|
225
|
+
rssBytes: this.lastSampledRss,
|
|
226
|
+
maxMemoryBytes: this.config.maxMemoryBytes,
|
|
227
|
+
highWaterBytes: this.highWaterBytes,
|
|
228
|
+
lowWaterBytes: this.lowWaterBytes,
|
|
229
|
+
backpressured: this.backpressured,
|
|
230
|
+
lastSampledAt: this.lastSampledAt,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|