@malloy-publisher/server 0.0.198-dev → 0.0.198-dev2
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 +32 -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/default-publisher.config.json +23 -0
- package/dist/instrumentation.mjs +22 -3
- package/dist/server.mjs +1522 -651
- package/dist/service/schema_worker.mjs +61 -0
- 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/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 +73 -45
- package/src/instrumentation.ts +50 -0
- 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 +54 -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 +31 -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 +91 -7
- package/src/service/package.spec.ts +11 -7
- package/src/service/package.ts +53 -56
- 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/service/process_stats_reporter.ts +169 -0
- package/src/service/schema_worker.ts +123 -0
- package/src/service/schema_worker_pool.ts +278 -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_environment/concurrent_environment.integration.spec.ts +235 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { Environment } from "./environment";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Race-condition regression tests for the package-directory pipeline.
|
|
9
|
+
*
|
|
10
|
+
* Three tests, all deterministic without timing-based flakiness:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Behavioral race repro** — concurrently install (rewrite the
|
|
13
|
+
* package directory) and read (`getModelFileText`); assert no
|
|
14
|
+
* `ENOENT` is observed. On the pre-fix code, the read would fail
|
|
15
|
+
* mid-rewrite. With the per-package mutex now covering both paths,
|
|
16
|
+
* all reads succeed.
|
|
17
|
+
*
|
|
18
|
+
* 2. **Mutex coverage** — manually hold `withPackageLock` and assert
|
|
19
|
+
* that a concurrent reader is pending until released. Pins the
|
|
20
|
+
* invariant that readers actually take the lock.
|
|
21
|
+
*
|
|
22
|
+
* 3. **Download does not block compile** — start an `installPackage`
|
|
23
|
+
* whose downloader never resolves on its own, then assert that
|
|
24
|
+
* `getModelFileText` resolves promptly. This pins the Phase 1 /
|
|
25
|
+
* Phase 2 split — if a future regression accidentally moves the
|
|
26
|
+
* download inside the lock, this test fails.
|
|
27
|
+
*/
|
|
28
|
+
describe("package directory race", () => {
|
|
29
|
+
let rootDir: string;
|
|
30
|
+
let envPath: string;
|
|
31
|
+
let fixtureDir: string;
|
|
32
|
+
|
|
33
|
+
const PUBLISHER_JSON = JSON.stringify({
|
|
34
|
+
name: "pkg",
|
|
35
|
+
description: "race-test fixture",
|
|
36
|
+
});
|
|
37
|
+
const MODEL_MALLOY = `source: ones is duckdb.sql("SELECT 1 as x")\n`;
|
|
38
|
+
|
|
39
|
+
async function writeFixture(targetDir: string): Promise<void> {
|
|
40
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
41
|
+
await fs.writeFile(
|
|
42
|
+
path.join(targetDir, "publisher.json"),
|
|
43
|
+
PUBLISHER_JSON,
|
|
44
|
+
);
|
|
45
|
+
await fs.writeFile(path.join(targetDir, "model.malloy"), MODEL_MALLOY);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function copyDir(src: string, dst: string): Promise<void> {
|
|
49
|
+
await fs.mkdir(dst, { recursive: true });
|
|
50
|
+
await fs.cp(src, dst, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-race-"));
|
|
55
|
+
envPath = path.join(rootDir, "env");
|
|
56
|
+
fixtureDir = path.join(rootDir, "fixture");
|
|
57
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
58
|
+
await writeFixture(fixtureDir);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(async () => {
|
|
62
|
+
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("(A) concurrent installs and reads never observe a half-rewritten tree", async () => {
|
|
66
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
67
|
+
|
|
68
|
+
// Initial install to populate the canonical path.
|
|
69
|
+
await env.installPackage("pkg", (stagingPath) =>
|
|
70
|
+
copyDir(fixtureDir, stagingPath),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const ITERATIONS = 30;
|
|
74
|
+
const errors: unknown[] = [];
|
|
75
|
+
let mutatorDone = false;
|
|
76
|
+
|
|
77
|
+
// Mutator loop: re-install the package over and over. Each iteration
|
|
78
|
+
// exercises the full Phase 1 (no-lock) + Phase 2 (locked) swap.
|
|
79
|
+
const mutator = (async () => {
|
|
80
|
+
try {
|
|
81
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
82
|
+
try {
|
|
83
|
+
await env.installPackage("pkg", (stagingPath) =>
|
|
84
|
+
copyDir(fixtureDir, stagingPath),
|
|
85
|
+
);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
errors.push({ kind: "install", err });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
mutatorDone = true;
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
|
|
95
|
+
// Reader loop: hammer `getModelFileText` while installs run. On the
|
|
96
|
+
// pre-fix code (no lock on reads), the read would sometimes hit ENOENT
|
|
97
|
+
// because the canonical dir was momentarily missing during the rename
|
|
98
|
+
// window. With the per-package mutex covering reads as well, this
|
|
99
|
+
// window is never observable.
|
|
100
|
+
const reader = (async () => {
|
|
101
|
+
while (!mutatorDone) {
|
|
102
|
+
try {
|
|
103
|
+
const text = await env.getModelFileText("pkg", "model.malloy");
|
|
104
|
+
expect(text).toBe(MODEL_MALLOY);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
errors.push({ kind: "read", err });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
|
|
111
|
+
await mutator;
|
|
112
|
+
await reader;
|
|
113
|
+
|
|
114
|
+
// Any error here means the lock wasn't actually covering one of the
|
|
115
|
+
// sides — that's the regression we're guarding against.
|
|
116
|
+
if (errors.length > 0) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Observed ${errors.length} race-window error(s): ${JSON.stringify(
|
|
119
|
+
errors.slice(0, 3),
|
|
120
|
+
(_k, v) => (v instanceof Error ? `${v.name}: ${v.message}` : v),
|
|
121
|
+
)}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}, 60_000);
|
|
125
|
+
|
|
126
|
+
it("(B) compile-time disk reads queue behind withPackageLock", async () => {
|
|
127
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
128
|
+
await env.installPackage("pkg", (stagingPath) =>
|
|
129
|
+
copyDir(fixtureDir, stagingPath),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const lockEntered = defer<void>();
|
|
133
|
+
const releaseLock = defer<void>();
|
|
134
|
+
|
|
135
|
+
// Hold the per-package mutex from "outside" — simulates a mutator
|
|
136
|
+
// (install / delete / writePackageManifest) being in flight.
|
|
137
|
+
const lockHolder = env.withPackageLock("pkg", async () => {
|
|
138
|
+
lockEntered.resolve();
|
|
139
|
+
await releaseLock.promise;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await lockEntered.promise;
|
|
143
|
+
|
|
144
|
+
// While the lock is held, the reader must NOT make progress.
|
|
145
|
+
const readPromise = env.getModelFileText("pkg", "model.malloy");
|
|
146
|
+
const TIMEOUT_SENTINEL = Symbol("timeout");
|
|
147
|
+
const raced = await Promise.race([
|
|
148
|
+
readPromise,
|
|
149
|
+
new Promise<typeof TIMEOUT_SENTINEL>((resolve) =>
|
|
150
|
+
setTimeout(() => resolve(TIMEOUT_SENTINEL), 50),
|
|
151
|
+
),
|
|
152
|
+
]);
|
|
153
|
+
expect(raced).toBe(TIMEOUT_SENTINEL);
|
|
154
|
+
|
|
155
|
+
// Release the lock; the reader must now complete.
|
|
156
|
+
releaseLock.resolve();
|
|
157
|
+
await lockHolder;
|
|
158
|
+
const text = await readPromise;
|
|
159
|
+
expect(text).toBe(MODEL_MALLOY);
|
|
160
|
+
}, 15_000);
|
|
161
|
+
|
|
162
|
+
it("(C) a slow download does not block concurrent reads", async () => {
|
|
163
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
164
|
+
// Initial install to make the package present.
|
|
165
|
+
await env.installPackage("pkg", (stagingPath) =>
|
|
166
|
+
copyDir(fixtureDir, stagingPath),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const downloadGate = defer<void>();
|
|
170
|
+
|
|
171
|
+
// Kick off an install whose Phase 1 downloader stalls until we open
|
|
172
|
+
// the gate. Phase 2 (the brief locked swap) cannot run until then.
|
|
173
|
+
const slowInstall = env.installPackage("pkg", async (stagingPath) => {
|
|
174
|
+
await downloadGate.promise;
|
|
175
|
+
await copyDir(fixtureDir, stagingPath);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// The reader must resolve well before we open the gate, proving the
|
|
179
|
+
// per-package mutex is NOT held during Phase 1.
|
|
180
|
+
const readStart = Date.now();
|
|
181
|
+
const text = await env.getModelFileText("pkg", "model.malloy");
|
|
182
|
+
const readElapsedMs = Date.now() - readStart;
|
|
183
|
+
|
|
184
|
+
expect(text).toBe(MODEL_MALLOY);
|
|
185
|
+
// 1s is generous; in practice this resolves in single-digit ms.
|
|
186
|
+
expect(readElapsedMs).toBeLessThan(1_000);
|
|
187
|
+
|
|
188
|
+
// Now open the gate and let the install complete.
|
|
189
|
+
downloadGate.resolve();
|
|
190
|
+
await slowInstall;
|
|
191
|
+
}, 15_000);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
interface Deferred<T> {
|
|
195
|
+
promise: Promise<T>;
|
|
196
|
+
resolve: (value: T) => void;
|
|
197
|
+
reject: (reason?: unknown) => void;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function defer<T>(): Deferred<T> {
|
|
201
|
+
let resolve!: (value: T) => void;
|
|
202
|
+
let reject!: (reason?: unknown) => void;
|
|
203
|
+
const promise = new Promise<T>((res, rej) => {
|
|
204
|
+
resolve = res;
|
|
205
|
+
reject = rej;
|
|
206
|
+
});
|
|
207
|
+
return { promise, resolve, reject };
|
|
208
|
+
}
|