@malloy-publisher/server 0.0.197 → 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 (54) hide show
  1. package/README.docker.md +47 -0
  2. package/build.ts +26 -1
  3. package/dist/app/api-doc.yaml +54 -20
  4. package/dist/app/assets/{EnvironmentPage-BVkQH_xQ.js → EnvironmentPage-Dpee_Kn6.js} +1 -1
  5. package/dist/app/assets/{HomePage-BgH9UkjK.js → HomePage-DLRWTNoL.js} +1 -1
  6. package/dist/app/assets/{MainPage-DiBxABem.js → MainPage-DsVt5QGM.js} +1 -1
  7. package/dist/app/assets/{ModelPage-oS70fj83.js → ModelPage-AwAugZ37.js} +1 -1
  8. package/dist/app/assets/{PackagePage-F_qLDAdv.js → PackagePage-XQ-EWGTC.js} +1 -1
  9. package/dist/app/assets/{RouteError-WqpffppN.js → RouteError-3Mv8JQw7.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-_YmC-ebR.js → WorkbookPage-DHYYpcYc.js} +1 -1
  11. package/dist/app/assets/{core-B8L9xCYT.es-BcRLJTnC.js → core-DfcpQGVP.es-DQggNOdX.js} +1 -1
  12. package/dist/app/assets/{index-C3XPaTaS.js → index-BUp81Qdm.js} +1 -1
  13. package/dist/app/assets/{index-rg8Ok8nl.js → index-D1pdwrUW.js} +1 -1
  14. package/dist/app/assets/{index-BMViiwtJ.js → index-Dv5bF4Ii.js} +4 -4
  15. package/dist/app/assets/{index.umd-CCAfKkxY.js → index.umd-CQH4LZU8.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/compile_worker.mjs +628 -0
  18. package/dist/instrumentation.mjs +36 -36
  19. package/dist/server.mjs +1781 -809
  20. package/package.json +1 -1
  21. package/src/compile/compile_pool.spec.ts +227 -0
  22. package/src/compile/compile_pool.ts +729 -0
  23. package/src/compile/compile_worker.ts +683 -0
  24. package/src/compile/protocol.ts +251 -0
  25. package/src/config.spec.ts +81 -0
  26. package/src/config.ts +126 -0
  27. package/src/controller/compile.controller.ts +3 -1
  28. package/src/controller/model.controller.ts +8 -1
  29. package/src/controller/package.controller.ts +70 -29
  30. package/src/controller/query.controller.ts +3 -0
  31. package/src/errors.ts +13 -0
  32. package/src/health.spec.ts +90 -0
  33. package/src/health.ts +86 -71
  34. package/src/mcp/tools/discovery_tools.ts +6 -2
  35. package/src/mcp/tools/execute_query_tool.ts +12 -0
  36. package/src/path_safety.spec.ts +158 -0
  37. package/src/path_safety.ts +140 -0
  38. package/src/server.ts +29 -0
  39. package/src/service/environment.ts +616 -199
  40. package/src/service/environment_admission.spec.ts +180 -0
  41. package/src/service/environment_store.spec.ts +0 -19
  42. package/src/service/environment_store.ts +24 -21
  43. package/src/service/filter_integration.spec.ts +110 -0
  44. package/src/service/givens_integration.spec.ts +192 -0
  45. package/src/service/manifest_service.spec.ts +7 -2
  46. package/src/service/manifest_service.ts +8 -2
  47. package/src/service/materialization_service.ts +14 -3
  48. package/src/service/model.spec.ts +105 -0
  49. package/src/service/model.ts +317 -10
  50. package/src/service/model_worker_path.spec.ts +125 -0
  51. package/src/service/package_memory_governor.spec.ts +173 -0
  52. package/src/service/package_memory_governor.ts +233 -0
  53. package/src/service/package_race.spec.ts +208 -0
  54. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
@@ -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
+ }
@@ -0,0 +1,280 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ /**
4
+ * Regression test for the per-package download/unzip race.
5
+ *
6
+ * Before fix: concurrent POST/PATCH/GET-with-reload against the same package
7
+ * name landed in `downloadPackage` *before* acquiring the per-package mutex.
8
+ * Multiple callers would `rm -rf <targetPath>` and then `cp -r` / `extractAllTo`
9
+ * in parallel, leaving the directory in an inconsistent state. Subsequent
10
+ * reads failed with "Package manifest for ... does not exist" and any
11
+ * in-flight model compilation would 500 with "compiling model path not found".
12
+ *
13
+ * After fix: download is run inside the per-package mutex, so concurrent
14
+ * callers serialize on the same target path. All N requests must succeed
15
+ * and the package must be usable afterwards.
16
+ */
17
+
18
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
19
+ import path from "path";
20
+ import { fileURLToPath } from "url";
21
+ import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = path.dirname(__filename);
25
+
26
+ // Use a unique env name per run so stale state from a previous run (env
27
+ // directory, persisted env metadata in publisher.db) can't poison startup.
28
+ const ENV_NAME = `concurrent-package-test-env-${Date.now()}`;
29
+ const PACKAGE_NAME = "gcs_faa";
30
+ const CONCURRENCY = 12;
31
+
32
+ const FORBIDDEN_ERROR_FRAGMENTS = [
33
+ "Package manifest for",
34
+ "does not exist",
35
+ "compiling model path not found",
36
+ "model path not found",
37
+ ];
38
+
39
+ function findForbiddenError(body: unknown): string | undefined {
40
+ const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
41
+ return FORBIDDEN_ERROR_FRAGMENTS.find((frag) => text.includes(frag));
42
+ }
43
+
44
+ describe("Concurrent package operations (E2E)", () => {
45
+ let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
46
+ let baseUrl: string;
47
+ let fixtureDir: string;
48
+
49
+ beforeAll(async () => {
50
+ env = await startRestE2E();
51
+ baseUrl = env.baseUrl;
52
+ fixtureDir = "gs://publisher_test_packages/gcs_faa.zip";
53
+
54
+ // Seed the environment with the package once so the env exists on disk.
55
+ // addEnvironment requires at least one package.
56
+ const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({
60
+ name: ENV_NAME,
61
+ packages: [{ name: PACKAGE_NAME, location: fixtureDir }],
62
+ connections: [],
63
+ }),
64
+ });
65
+ if (!createRes.ok) {
66
+ const body = await createRes.text();
67
+ throw new Error(
68
+ `Failed to seed test environment (${createRes.status}): ${body}`,
69
+ );
70
+ }
71
+
72
+ // Wait for the seeded package to be loadable.
73
+ const deadline = Date.now() + 30_000;
74
+ let ready = false;
75
+ while (!ready && Date.now() < deadline) {
76
+ try {
77
+ const res = await fetch(
78
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
79
+ );
80
+ if (res.ok) {
81
+ ready = true;
82
+ break;
83
+ }
84
+ } catch {
85
+ // not ready yet
86
+ }
87
+ await new Promise((r) => setTimeout(r, 250));
88
+ }
89
+ if (!ready) {
90
+ throw new Error("Seeded package did not become available in time");
91
+ }
92
+ });
93
+
94
+ afterAll(async () => {
95
+ if (baseUrl) {
96
+ try {
97
+ await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
98
+ method: "DELETE",
99
+ });
100
+ } catch {
101
+ // best-effort cleanup
102
+ }
103
+ }
104
+ await env?.stop();
105
+ env = null;
106
+ });
107
+
108
+ it("concurrent POST /packages for the same name all succeed", async () => {
109
+ const requests = Array.from({ length: CONCURRENCY }, () =>
110
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({
114
+ name: PACKAGE_NAME,
115
+ location: fixtureDir,
116
+ }),
117
+ }),
118
+ );
119
+
120
+ const responses = await Promise.all(requests);
121
+ const bodies = await Promise.all(
122
+ responses.map(async (r) => ({
123
+ status: r.status,
124
+ body: await r.json().catch(() => null),
125
+ })),
126
+ );
127
+
128
+ for (const { status, body } of bodies) {
129
+ expect(status).toBe(200);
130
+ const forbidden = findForbiddenError(body);
131
+ expect(forbidden).toBeUndefined();
132
+ }
133
+
134
+ // Package must be loadable after the storm.
135
+ const res = await fetch(
136
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
137
+ );
138
+ expect(res.status).toBe(200);
139
+ const meta = (await res.json()) as { name?: string };
140
+ expect(meta.name).toBe(PACKAGE_NAME);
141
+
142
+ // Models must be listable — proves the model files survived the unzip
143
+ // race and Package.create read a consistent directory.
144
+ const modelsRes = await fetch(
145
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
146
+ );
147
+ expect(modelsRes.status).toBe(200);
148
+ const models = (await modelsRes.json()) as Array<{ path?: string }>;
149
+ expect(Array.isArray(models)).toBe(true);
150
+ expect(models.length).toBeGreaterThan(0);
151
+ });
152
+
153
+ it("concurrent GET /packages/:name?reload=true all succeed", async () => {
154
+ const requests = Array.from({ length: CONCURRENCY }, () =>
155
+ fetch(
156
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}?reload=true`,
157
+ ),
158
+ );
159
+ const responses = await Promise.all(requests);
160
+ const bodies = await Promise.all(
161
+ responses.map(async (r) => ({
162
+ status: r.status,
163
+ body: await r.json().catch(() => null),
164
+ })),
165
+ );
166
+ for (const { status, body } of bodies) {
167
+ expect(status).toBe(200);
168
+ const forbidden = findForbiddenError(body);
169
+ expect(forbidden).toBeUndefined();
170
+ }
171
+
172
+ // Models must still list cleanly.
173
+ const modelsRes = await fetch(
174
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
175
+ );
176
+ expect(modelsRes.status).toBe(200);
177
+ });
178
+
179
+ it("simultaneous POST + PATCH for the same package serialize cleanly", async () => {
180
+ // Fire create and update at the same time. Both should land under the
181
+ // same per-package mutex. Whichever wins the mutex first runs first;
182
+ // the loser sees a coherent post-condition. After both settle the
183
+ // package must be loadable and the description must reflect the PATCH
184
+ // when the PATCH ran *after* a successful POST.
185
+ const newDescription = `concurrent-update-${Date.now()}`;
186
+ const [postRes, patchRes] = await Promise.all([
187
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
188
+ method: "POST",
189
+ headers: { "Content-Type": "application/json" },
190
+ body: JSON.stringify({
191
+ name: PACKAGE_NAME,
192
+ location: fixtureDir,
193
+ }),
194
+ }),
195
+ fetch(
196
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
197
+ {
198
+ method: "PATCH",
199
+ headers: { "Content-Type": "application/json" },
200
+ body: JSON.stringify({
201
+ name: PACKAGE_NAME,
202
+ description: newDescription,
203
+ }),
204
+ },
205
+ ),
206
+ ]);
207
+
208
+ // POST is idempotent here — always succeeds.
209
+ expect(postRes.status).toBe(200);
210
+ const postBody = await postRes.json().catch(() => null);
211
+ expect(findForbiddenError(postBody)).toBeUndefined();
212
+
213
+ // PATCH either succeeds (ran after POST loaded the package) or 404s
214
+ // (ran before POST populated `this.packages`). It must NOT silently
215
+ // rewrite disk and then 404, and must never surface the forbidden
216
+ // error fragments.
217
+ const patchBody = await patchRes.json().catch(() => null);
218
+ expect(findForbiddenError(patchBody)).toBeUndefined();
219
+ expect([200, 404]).toContain(patchRes.status);
220
+
221
+ // Whatever happened, the package must remain loadable.
222
+ const getRes = await fetch(
223
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
224
+ );
225
+ expect(getRes.status).toBe(200);
226
+ const meta = (await getRes.json()) as {
227
+ name?: string;
228
+ description?: string;
229
+ };
230
+ expect(meta.name).toBe(PACKAGE_NAME);
231
+ });
232
+
233
+ it("interleaved POST + GET-reload + model list never surface stale-dir errors", async () => {
234
+ // Worst-case interleave: writers and readers hammering the same
235
+ // package. None of them should observe a missing manifest or a
236
+ // half-extracted directory.
237
+ const work: Array<Promise<{ status: number; body: unknown }>> = [];
238
+ for (let i = 0; i < CONCURRENCY; i++) {
239
+ work.push(
240
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
241
+ method: "POST",
242
+ headers: { "Content-Type": "application/json" },
243
+ body: JSON.stringify({
244
+ name: PACKAGE_NAME,
245
+ location: fixtureDir,
246
+ }),
247
+ }).then(async (r) => ({
248
+ status: r.status,
249
+ body: await r.json().catch(() => null),
250
+ })),
251
+ );
252
+ work.push(
253
+ fetch(
254
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}?reload=true`,
255
+ ).then(async (r) => ({
256
+ status: r.status,
257
+ body: await r.json().catch(() => null),
258
+ })),
259
+ );
260
+ work.push(
261
+ fetch(
262
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
263
+ ).then(async (r) => ({
264
+ status: r.status,
265
+ body: await r.json().catch(() => null),
266
+ })),
267
+ );
268
+ }
269
+
270
+ const results = await Promise.all(work);
271
+ for (const { status, body } of results) {
272
+ const forbidden = findForbiddenError(body);
273
+ expect(forbidden).toBeUndefined();
274
+ // Model list while the package is being rewritten can transiently
275
+ // return 404 (the package is unloaded mid-rewrite), but never 5xx,
276
+ // and never with the forbidden error fragments.
277
+ expect(status).toBeLessThan(500);
278
+ }
279
+ });
280
+ });