@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.
Files changed (86) hide show
  1. package/README.docker.md +135 -20
  2. package/README.md +15 -0
  3. package/build.ts +42 -1
  4. package/dist/app/api-doc.yaml +51 -0
  5. package/dist/app/assets/EnvironmentPage-Dpee_Kn6.js +1 -0
  6. package/dist/app/assets/HomePage-DLRWTNoL.js +1 -0
  7. package/dist/app/assets/MainPage-DsVt5QGM.js +2 -0
  8. package/dist/app/assets/ModelPage-AwAugZ37.js +1 -0
  9. package/dist/app/assets/PackagePage-XQ-EWGTC.js +1 -0
  10. package/dist/app/assets/RouteError-3Mv8JQw7.js +1 -0
  11. package/dist/app/assets/WorkbookPage-DHYYpcYc.js +1 -0
  12. package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-DfcpQGVP.es-DQggNOdX.js} +14 -14
  13. package/dist/app/assets/{index-C513UodQ.js → index-BUp81Qdm.js} +15 -15
  14. package/dist/app/assets/index-D1pdwrUW.js +1803 -0
  15. package/dist/app/assets/index-Dv5bF4Ii.js +451 -0
  16. package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-CQH4LZU8.js} +1 -1
  17. package/dist/app/index.html +2 -3
  18. package/dist/compile_worker.mjs +628 -0
  19. package/dist/default-publisher.config.json +23 -0
  20. package/dist/instrumentation.mjs +36 -38
  21. package/dist/server.mjs +2060 -913
  22. package/package.json +11 -12
  23. package/publisher.config.example.bigquery.json +33 -0
  24. package/publisher.config.example.duckdb.json +23 -0
  25. package/publisher.config.json +1 -11
  26. package/src/compile/compile_pool.spec.ts +227 -0
  27. package/src/compile/compile_pool.ts +729 -0
  28. package/src/compile/compile_worker.ts +683 -0
  29. package/src/compile/protocol.ts +251 -0
  30. package/src/config.spec.ts +306 -0
  31. package/src/config.ts +222 -2
  32. package/src/controller/compile.controller.ts +3 -1
  33. package/src/controller/connection.controller.ts +1 -1
  34. package/src/controller/model.controller.ts +8 -1
  35. package/src/controller/package.controller.ts +70 -29
  36. package/src/controller/query.controller.ts +3 -0
  37. package/src/default-publisher.config.json +23 -0
  38. package/src/errors.spec.ts +42 -0
  39. package/src/errors.ts +21 -0
  40. package/src/health.spec.ts +90 -0
  41. package/src/health.ts +86 -45
  42. package/src/logger.ts +1 -3
  43. package/src/mcp/tools/discovery_tools.ts +6 -2
  44. package/src/mcp/tools/execute_query_tool.ts +12 -0
  45. package/src/path_safety.spec.ts +158 -0
  46. package/src/path_safety.ts +140 -0
  47. package/src/pg_helpers.spec.ts +226 -0
  48. package/src/pg_helpers.ts +129 -0
  49. package/src/server-old.ts +3 -23
  50. package/src/server.ts +49 -0
  51. package/src/service/connection.spec.ts +6 -4
  52. package/src/service/connection.ts +8 -3
  53. package/src/service/connection_config.ts +2 -2
  54. package/src/service/environment.ts +621 -176
  55. package/src/service/environment_admission.spec.ts +180 -0
  56. package/src/service/environment_store.ts +22 -0
  57. package/src/service/filter_integration.spec.ts +110 -0
  58. package/src/service/givens_integration.spec.ts +192 -0
  59. package/src/service/manifest_service.spec.ts +7 -2
  60. package/src/service/manifest_service.ts +8 -2
  61. package/src/service/materialization_service.ts +14 -3
  62. package/src/service/model.spec.ts +105 -0
  63. package/src/service/model.ts +317 -10
  64. package/src/service/model_worker_path.spec.ts +125 -0
  65. package/src/service/package.ts +4 -3
  66. package/src/service/package_memory_governor.spec.ts +173 -0
  67. package/src/service/package_memory_governor.ts +233 -0
  68. package/src/service/package_race.spec.ts +208 -0
  69. package/src/storage/StorageManager.ts +71 -11
  70. package/src/storage/duckdb/schema.ts +41 -0
  71. package/src/utils.ts +11 -0
  72. package/tests/harness/rest_e2e.ts +2 -2
  73. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
  74. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
  75. package/tests/unit/duckdb/attached_databases.test.ts +5 -5
  76. package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
  77. package/tests/unit/storage/StorageManager.test.ts +166 -0
  78. package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
  79. package/dist/app/assets/HomePage-DMop21VG.js +0 -1
  80. package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
  81. package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
  82. package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
  83. package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
  84. package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
  85. package/dist/app/assets/index-5K9YjIxF.js +0 -456
  86. 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
+ });
@@ -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
- let files = undefined;
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
+ }