@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,729 @@
1
+ /**
2
+ * Main-thread half of the compile worker pool.
3
+ *
4
+ * Why this exists: Malloy compile is pure JavaScript and runs on the
5
+ * V8 main thread. A large package can hold the event loop for many
6
+ * seconds, which is long enough to time out the Kubernetes
7
+ * `/health/liveness` probe (timeout=5s, see worker/main.tf). Moving
8
+ * compile into `worker_threads` keeps the main loop responsive
9
+ * regardless of compile cost.
10
+ *
11
+ * Public surface:
12
+ * - `getCompilePool()` — lazy singleton; respects MALLOY_COMPILE_WORKERS
13
+ * - `pool.compile({...})` — submit one model for off-thread compile
14
+ * - `pool.shutdown()` — graceful drain (called from health.ts)
15
+ *
16
+ * Lifecycle:
17
+ * - Workers are spawned lazily on first use, up to N (configurable).
18
+ * - Each worker emits {type:'ready'} once initialized; the pool
19
+ * waits for that before dispatching to a newly-spawned worker.
20
+ * - Jobs are dispatched to the worker with the fewest in-flight
21
+ * jobs (least-busy load balancing). A worker can hold multiple
22
+ * jobs concurrently because compile interleaves on `await`s.
23
+ * - On worker `exit` (uncaught error, OOM), the pool fails any
24
+ * in-flight jobs on that worker with a clean error, then respawns
25
+ * lazily on the next request.
26
+ *
27
+ * Schema-fetch RPC routing:
28
+ * - When a worker proxies `fetchSchemaForTables` back to the main
29
+ * thread, the message includes a `jobId`. The pool keeps a
30
+ * `jobId → MalloyConfig` map for in-flight jobs so it knows which
31
+ * live connection to dispatch the request to. The mapping is
32
+ * installed when the job is submitted and removed when it
33
+ * resolves.
34
+ */
35
+ import type {
36
+ Annotation,
37
+ FetchSchemaOptions,
38
+ InfoConnection,
39
+ LookupConnection,
40
+ MalloyConfig,
41
+ ModelDef,
42
+ SQLSourceDef,
43
+ TableSourceDef,
44
+ } from "@malloydata/malloy";
45
+ import * as Malloy from "@malloydata/malloy-interfaces";
46
+ import { fileURLToPath } from "url";
47
+ import { dirname, join } from "path";
48
+ import { Worker } from "node:worker_threads";
49
+ import { ModelCompilationError } from "../errors";
50
+ import { logger } from "../logger";
51
+ import type { FilterDefinition } from "../service/filter";
52
+ import type {
53
+ CompileJobError,
54
+ CompileJobRequest,
55
+ CompileJobResult,
56
+ ConnectionMetadataRequest,
57
+ ConnectionMetadataResponse,
58
+ MainToWorkerMessage,
59
+ ReadUrlRequest,
60
+ ReadUrlResponse,
61
+ RpcErrorResponse,
62
+ SchemaForSqlRequest,
63
+ SchemaForSqlResponse,
64
+ SchemaForTablesRequest,
65
+ SchemaForTablesResponse,
66
+ SerializedError,
67
+ WorkerToMainMessage,
68
+ } from "./protocol";
69
+
70
+ // ──────────────────────────────────────────────────────────────────────
71
+ // Configuration
72
+ // ──────────────────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Returns the configured worker pool size, or 0 if disabled.
76
+ *
77
+ * Defaults to 0 (off) so the change ships dark and only takes effect
78
+ * where Terraform / operator config opts in via
79
+ * `MALLOY_COMPILE_WORKERS=N`. This lets us land the feature, validate
80
+ * it in staging, and roll out per cluster without surprising any
81
+ * environment that's happy with the in-process compile path.
82
+ */
83
+ export function getCompileWorkerCount(): number {
84
+ const raw = process.env.MALLOY_COMPILE_WORKERS;
85
+ if (raw === undefined) return 0;
86
+ const parsed = Number.parseInt(raw, 10);
87
+ if (!Number.isFinite(parsed) || parsed < 0) return 0;
88
+ return parsed;
89
+ }
90
+
91
+ // Wall-clock cap for a single job — if a worker hangs, we don't
92
+ // want to leak a Promise forever. Twice the K8s liveness threshold
93
+ // (5s × 15 fails = 75s) gives us a comfortable margin against false
94
+ // timeouts during cold starts while still failing fast on real hangs.
95
+ const COMPILE_JOB_TIMEOUT_MS = Number.parseInt(
96
+ process.env.MALLOY_COMPILE_JOB_TIMEOUT_MS ?? "120000",
97
+ 10,
98
+ );
99
+
100
+ // ──────────────────────────────────────────────────────────────────────
101
+ // Worker-side stub of the job request, plus the resolved connection
102
+ // lookup the pool services schema RPCs against.
103
+ // ──────────────────────────────────────────────────────────────────────
104
+
105
+ interface JobContext {
106
+ jobId: string;
107
+ connections: LookupConnection<InfoConnection>;
108
+ urlReader: CompileUrlReader;
109
+ resolve: (result: CompileJobResult) => void;
110
+ reject: (err: Error) => void;
111
+ timeout: ReturnType<typeof setTimeout>;
112
+ }
113
+
114
+ interface PoolWorker {
115
+ id: number;
116
+ worker: Worker;
117
+ ready: Promise<void>;
118
+ inFlight: Set<string>; // job ids
119
+ exited: boolean;
120
+ }
121
+
122
+ /**
123
+ * Minimal URL-reader shape the pool dispatches to. Intentionally
124
+ * narrower than Malloy's `URLReader` (which can also return
125
+ * `{contents, invalidationKey}`) — the pool only needs the string
126
+ * payload to forward back to the worker.
127
+ */
128
+ export interface CompileUrlReader {
129
+ readURL(
130
+ url: URL,
131
+ ): Promise<string | { contents: string; invalidationKey?: unknown }>;
132
+ }
133
+
134
+ export interface CompileRequest {
135
+ packagePath: string;
136
+ modelPath: string;
137
+ /**
138
+ * The live MalloyConfig. We don't ship it across the worker
139
+ * boundary; we hold it on the main side and answer the worker's
140
+ * proxy RPCs against `config.connections`.
141
+ */
142
+ malloyConfig: MalloyConfig;
143
+ /** Default connection name (passed verbatim to the worker). */
144
+ defaultConnectionName: string | null;
145
+ /** Custom URLReader for non-file:// imports; falls back to fs in the worker. */
146
+ urlReader?: CompileUrlReader;
147
+ /** Optional buildManifest passed through to Malloy Runtime. */
148
+ buildManifest?: unknown;
149
+ }
150
+
151
+ export interface CompileOutcome {
152
+ modelDef: ModelDef;
153
+ sourceInfos: Malloy.SourceInfo[];
154
+ sources: unknown[];
155
+ queries: unknown[];
156
+ filterMap: Map<string, FilterDefinition[]>;
157
+ /** Pre-converted API-shape givens or undefined if the model declared none. */
158
+ givens?: unknown[];
159
+ compileDurationMs: number;
160
+ }
161
+
162
+ // ──────────────────────────────────────────────────────────────────────
163
+ // Path to the bundled worker script
164
+ // ──────────────────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Locate the worker entrypoint. In production builds it's bundled to
168
+ * `dist/compile_worker.mjs` next to `server.mjs`. In dev (bun --watch)
169
+ * the source `.ts` next to this file is loaded directly — Bun supports
170
+ * `new Worker(<ts-url>)`. Falling back to the source path also keeps
171
+ * `bun test` happy without a build step.
172
+ */
173
+ function resolveWorkerScript(): URL {
174
+ const thisFile = fileURLToPath(import.meta.url);
175
+ const thisDir = dirname(thisFile);
176
+ // dist layout: dist/compile_worker.mjs sibling to dist/server.mjs.
177
+ // The bundler emits compile_pool.ts inlined into server.mjs (it's
178
+ // not a separate entry), but compile_worker.mjs IS a separate entry
179
+ // so `new Worker(...)` can load it. See build.ts.
180
+ const distCandidate = join(thisDir, "compile_worker.mjs");
181
+ if (thisFile.endsWith(".mjs") || thisFile.endsWith(".js")) {
182
+ return new URL(`file://${distCandidate}`);
183
+ }
184
+ // Dev / test: load the .ts directly.
185
+ const tsCandidate = join(thisDir, "compile_worker.ts");
186
+ return new URL(`file://${tsCandidate}`);
187
+ }
188
+
189
+ // ──────────────────────────────────────────────────────────────────────
190
+ // The pool
191
+ // ──────────────────────────────────────────────────────────────────────
192
+
193
+ export class CompileWorkerPool {
194
+ private readonly workers: PoolWorker[] = [];
195
+ private readonly maxWorkers: number;
196
+ private nextWorkerId = 0;
197
+ private readonly jobs = new Map<string, JobContext>();
198
+ private nextJobId = 0;
199
+ private shuttingDown = false;
200
+ private readonly workerScript: URL;
201
+
202
+ constructor(maxWorkers: number, workerScript?: URL) {
203
+ this.maxWorkers = maxWorkers;
204
+ this.workerScript = workerScript ?? resolveWorkerScript();
205
+ }
206
+
207
+ get enabled(): boolean {
208
+ return this.maxWorkers > 0;
209
+ }
210
+
211
+ get size(): number {
212
+ return this.workers.filter((w) => !w.exited).length;
213
+ }
214
+
215
+ async compile(request: CompileRequest): Promise<CompileOutcome> {
216
+ if (!this.enabled) {
217
+ throw new Error(
218
+ "CompileWorkerPool.compile called while disabled (MALLOY_COMPILE_WORKERS=0)",
219
+ );
220
+ }
221
+ if (this.shuttingDown) {
222
+ throw new Error("CompileWorkerPool is shutting down");
223
+ }
224
+
225
+ const worker = await this.acquireWorker();
226
+ this.nextJobId += 1;
227
+ const jobId = `job-${this.nextJobId}`;
228
+
229
+ return new Promise<CompileOutcome>((resolve, reject) => {
230
+ const timeout = setTimeout(() => {
231
+ this.failJob(
232
+ jobId,
233
+ new Error(
234
+ `Compile job timed out after ${COMPILE_JOB_TIMEOUT_MS}ms (model=${request.modelPath})`,
235
+ ),
236
+ );
237
+ }, COMPILE_JOB_TIMEOUT_MS);
238
+
239
+ this.jobs.set(jobId, {
240
+ jobId,
241
+ connections: request.malloyConfig.connections,
242
+ urlReader: request.urlReader ?? defaultUrlReader,
243
+ resolve: (result) => {
244
+ clearTimeout(timeout);
245
+ resolve(adaptResult(result));
246
+ },
247
+ reject: (err) => {
248
+ clearTimeout(timeout);
249
+ reject(err);
250
+ },
251
+ timeout,
252
+ });
253
+
254
+ worker.inFlight.add(jobId);
255
+
256
+ const message: CompileJobRequest = {
257
+ type: "compile",
258
+ requestId: jobId,
259
+ packagePath: request.packagePath,
260
+ modelPath: request.modelPath,
261
+ defaultConnectionName: request.defaultConnectionName,
262
+ buildManifest: request.buildManifest,
263
+ };
264
+ worker.worker.postMessage(message);
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Drain every in-flight job and terminate the workers. Safe to
270
+ * call multiple times. Awaits each worker's `exit` event.
271
+ */
272
+ async shutdown(): Promise<void> {
273
+ if (this.shuttingDown) return;
274
+ this.shuttingDown = true;
275
+ const exits = this.workers.map(
276
+ (pw) =>
277
+ new Promise<void>((resolve) => {
278
+ if (pw.exited) {
279
+ resolve();
280
+ return;
281
+ }
282
+ pw.worker.once("exit", () => resolve());
283
+ const msg: { type: "shutdown" } = { type: "shutdown" };
284
+ pw.worker.postMessage(msg);
285
+ // Hard ceiling — if a worker won't exit, terminate it.
286
+ setTimeout(() => {
287
+ if (!pw.exited) {
288
+ void pw.worker.terminate().finally(() => resolve());
289
+ }
290
+ }, 10_000);
291
+ }),
292
+ );
293
+ await Promise.all(exits);
294
+ }
295
+
296
+ // ────────────────────────────────────────────────────────────────
297
+ // Internals
298
+ // ────────────────────────────────────────────────────────────────
299
+
300
+ private async acquireWorker(): Promise<PoolWorker> {
301
+ // Prune any dead workers from the front so size accounting is
302
+ // honest; spawn lazily up to maxWorkers.
303
+ this.workers.splice(
304
+ 0,
305
+ this.workers.length,
306
+ ...this.workers.filter((w) => !w.exited),
307
+ );
308
+ if (this.workers.length < this.maxWorkers) {
309
+ const pw = this.spawnWorker();
310
+ this.workers.push(pw);
311
+ await pw.ready;
312
+ return pw;
313
+ }
314
+ // Least-busy
315
+ const alive = this.workers.filter((w) => !w.exited);
316
+ let best = alive[0];
317
+ for (const candidate of alive) {
318
+ if (candidate.inFlight.size < best.inFlight.size) {
319
+ best = candidate;
320
+ }
321
+ }
322
+ await best.ready;
323
+ return best;
324
+ }
325
+
326
+ private spawnWorker(): PoolWorker {
327
+ this.nextWorkerId += 1;
328
+ const id = this.nextWorkerId;
329
+ logger.info(
330
+ `CompileWorkerPool: spawning worker #${id} (script=${this.workerScript.toString()})`,
331
+ );
332
+ const worker = new Worker(this.workerScript, {
333
+ // Worker stderr/stdout flow through the parent's by default;
334
+ // we don't override.
335
+ name: `malloy-compile-worker-${id}`,
336
+ });
337
+ let readyResolve!: () => void;
338
+ const ready = new Promise<void>((r) => (readyResolve = r));
339
+
340
+ const pw: PoolWorker = {
341
+ id,
342
+ worker,
343
+ ready,
344
+ inFlight: new Set(),
345
+ exited: false,
346
+ };
347
+
348
+ worker.on("message", (msg: WorkerToMainMessage) => {
349
+ this.handleWorkerMessage(pw, msg, readyResolve);
350
+ });
351
+
352
+ worker.on("error", (err) => {
353
+ logger.error(`CompileWorkerPool: worker #${id} errored`, {
354
+ error: err,
355
+ });
356
+ });
357
+
358
+ worker.on("exit", (code) => {
359
+ pw.exited = true;
360
+ logger.warn(
361
+ `CompileWorkerPool: worker #${id} exited (code=${code}, inFlight=${pw.inFlight.size})`,
362
+ );
363
+ // Fail any jobs that were running on this worker — don't
364
+ // strand callers waiting for a result that will never come.
365
+ for (const jobId of pw.inFlight) {
366
+ this.failJob(
367
+ jobId,
368
+ new Error(
369
+ `Compile worker #${id} exited unexpectedly (code=${code})`,
370
+ ),
371
+ );
372
+ }
373
+ pw.inFlight.clear();
374
+ });
375
+
376
+ return pw;
377
+ }
378
+
379
+ private handleWorkerMessage(
380
+ pw: PoolWorker,
381
+ msg: WorkerToMainMessage,
382
+ markReady: () => void,
383
+ ): void {
384
+ switch (msg.type) {
385
+ case "ready":
386
+ markReady();
387
+ return;
388
+ case "compile-result":
389
+ this.completeJob(pw, msg);
390
+ return;
391
+ case "compile-error":
392
+ this.errorJob(pw, msg);
393
+ return;
394
+ case "connection-metadata":
395
+ void this.handleConnectionMetadata(pw, msg);
396
+ return;
397
+ case "schema-for-tables":
398
+ void this.handleSchemaForTables(pw, msg);
399
+ return;
400
+ case "schema-for-sql":
401
+ void this.handleSchemaForSql(pw, msg);
402
+ return;
403
+ case "read-url":
404
+ void this.handleReadUrl(pw, msg);
405
+ return;
406
+ default: {
407
+ const exhaustive: never = msg;
408
+ void exhaustive;
409
+ return;
410
+ }
411
+ }
412
+ }
413
+
414
+ private async handleConnectionMetadata(
415
+ pw: PoolWorker,
416
+ msg: ConnectionMetadataRequest,
417
+ ): Promise<void> {
418
+ const ctx = this.jobs.get(msg.jobId);
419
+ const reply = (
420
+ response: ConnectionMetadataResponse | RpcErrorResponse,
421
+ ): void => {
422
+ pw.worker.postMessage(response as MainToWorkerMessage);
423
+ };
424
+ if (!ctx) {
425
+ reply({
426
+ type: "rpc-error",
427
+ requestId: msg.requestId,
428
+ ok: false,
429
+ error: { name: "Error", message: `Unknown jobId ${msg.jobId}` },
430
+ });
431
+ return;
432
+ }
433
+ try {
434
+ const conn = await ctx.connections.lookupConnection(
435
+ msg.connectionName,
436
+ );
437
+ reply({
438
+ type: "connection-metadata-response",
439
+ requestId: msg.requestId,
440
+ ok: true,
441
+ metadata: {
442
+ name: msg.connectionName,
443
+ dialectName: conn.dialectName,
444
+ digest:
445
+ typeof conn.getDigest === "function"
446
+ ? conn.getDigest()
447
+ : msg.connectionName,
448
+ },
449
+ });
450
+ } catch (error) {
451
+ reply({
452
+ type: "rpc-error",
453
+ requestId: msg.requestId,
454
+ ok: false,
455
+ error: serializeError(error),
456
+ });
457
+ }
458
+ }
459
+
460
+ private completeJob(pw: PoolWorker, msg: CompileJobResult): void {
461
+ const ctx = this.jobs.get(msg.requestId);
462
+ if (!ctx) return;
463
+ this.jobs.delete(msg.requestId);
464
+ pw.inFlight.delete(msg.requestId);
465
+ ctx.resolve(msg);
466
+ }
467
+
468
+ private errorJob(pw: PoolWorker, msg: CompileJobError): void {
469
+ const ctx = this.jobs.get(msg.requestId);
470
+ if (!ctx) return;
471
+ this.jobs.delete(msg.requestId);
472
+ pw.inFlight.delete(msg.requestId);
473
+ ctx.reject(deserializeError(msg.error));
474
+ }
475
+
476
+ private failJob(jobId: string, error: Error): void {
477
+ const ctx = this.jobs.get(jobId);
478
+ if (!ctx) return;
479
+ this.jobs.delete(jobId);
480
+ ctx.reject(error);
481
+ }
482
+
483
+ private async handleSchemaForTables(
484
+ pw: PoolWorker,
485
+ msg: SchemaForTablesRequest,
486
+ ): Promise<void> {
487
+ const ctx = this.jobs.get(msg.jobId);
488
+ const reply = (
489
+ response: SchemaForTablesResponse | RpcErrorResponse,
490
+ ): void => {
491
+ pw.worker.postMessage(response as MainToWorkerMessage);
492
+ };
493
+ if (!ctx) {
494
+ reply({
495
+ type: "rpc-error",
496
+ requestId: msg.requestId,
497
+ ok: false,
498
+ error: { name: "Error", message: `Unknown jobId ${msg.jobId}` },
499
+ });
500
+ return;
501
+ }
502
+ try {
503
+ const conn = await ctx.connections.lookupConnection(
504
+ msg.connectionName,
505
+ );
506
+ const result = await conn.fetchSchemaForTables(
507
+ msg.tables,
508
+ buildFetchOptions(msg.options),
509
+ );
510
+ reply({
511
+ type: "schema-for-tables-response",
512
+ requestId: msg.requestId,
513
+ ok: true,
514
+ schemas: result.schemas as Record<string, TableSourceDef>,
515
+ errors: result.errors,
516
+ });
517
+ } catch (error) {
518
+ reply({
519
+ type: "rpc-error",
520
+ requestId: msg.requestId,
521
+ ok: false,
522
+ error: serializeError(error),
523
+ });
524
+ }
525
+ }
526
+
527
+ private async handleSchemaForSql(
528
+ pw: PoolWorker,
529
+ msg: SchemaForSqlRequest,
530
+ ): Promise<void> {
531
+ const ctx = this.jobs.get(msg.jobId);
532
+ const reply = (
533
+ response: SchemaForSqlResponse | RpcErrorResponse,
534
+ ): void => {
535
+ pw.worker.postMessage(response as MainToWorkerMessage);
536
+ };
537
+ if (!ctx) {
538
+ reply({
539
+ type: "rpc-error",
540
+ requestId: msg.requestId,
541
+ ok: false,
542
+ error: { name: "Error", message: `Unknown jobId ${msg.jobId}` },
543
+ });
544
+ return;
545
+ }
546
+ try {
547
+ const conn = await ctx.connections.lookupConnection(
548
+ msg.connectionName,
549
+ );
550
+ const result = await conn.fetchSchemaForSQLStruct(
551
+ msg.sentence as Parameters<
552
+ InfoConnection["fetchSchemaForSQLStruct"]
553
+ >[0],
554
+ buildFetchOptions(msg.options),
555
+ );
556
+ if (result.error !== undefined) {
557
+ reply({
558
+ type: "schema-for-sql-response",
559
+ requestId: msg.requestId,
560
+ ok: true,
561
+ error: result.error,
562
+ });
563
+ } else {
564
+ reply({
565
+ type: "schema-for-sql-response",
566
+ requestId: msg.requestId,
567
+ ok: true,
568
+ structDef: result.structDef as SQLSourceDef,
569
+ });
570
+ }
571
+ } catch (error) {
572
+ reply({
573
+ type: "rpc-error",
574
+ requestId: msg.requestId,
575
+ ok: false,
576
+ error: serializeError(error),
577
+ });
578
+ }
579
+ }
580
+
581
+ private async handleReadUrl(
582
+ pw: PoolWorker,
583
+ msg: ReadUrlRequest,
584
+ ): Promise<void> {
585
+ const ctx = this.jobs.get(msg.jobId);
586
+ const reply = (response: ReadUrlResponse | RpcErrorResponse): void => {
587
+ pw.worker.postMessage(response as MainToWorkerMessage);
588
+ };
589
+ if (!ctx) {
590
+ reply({
591
+ type: "rpc-error",
592
+ requestId: msg.requestId,
593
+ ok: false,
594
+ error: { name: "Error", message: `Unknown jobId ${msg.jobId}` },
595
+ });
596
+ return;
597
+ }
598
+ try {
599
+ const raw = await ctx.urlReader.readURL(new URL(msg.url));
600
+ const contents = typeof raw === "string" ? raw : raw.contents;
601
+ reply({
602
+ type: "read-url-response",
603
+ requestId: msg.requestId,
604
+ ok: true,
605
+ contents,
606
+ });
607
+ } catch (error) {
608
+ reply({
609
+ type: "rpc-error",
610
+ requestId: msg.requestId,
611
+ ok: false,
612
+ error: serializeError(error),
613
+ });
614
+ }
615
+ }
616
+ }
617
+
618
+ function buildFetchOptions(options: {
619
+ refreshTimestamp?: number;
620
+ modelAnnotation?: Annotation;
621
+ }): FetchSchemaOptions {
622
+ const out: FetchSchemaOptions = {};
623
+ if (options.refreshTimestamp !== undefined) {
624
+ out.refreshTimestamp = options.refreshTimestamp;
625
+ }
626
+ if (options.modelAnnotation !== undefined) {
627
+ out.modelAnnotation = options.modelAnnotation;
628
+ }
629
+ return out;
630
+ }
631
+
632
+ // Translate the worker's flat-array filterMap into a Map for use sites.
633
+ function adaptResult(result: CompileJobResult): CompileOutcome {
634
+ return {
635
+ modelDef: result.modelDef as ModelDef,
636
+ sourceInfos: result.sourceInfos as Malloy.SourceInfo[],
637
+ sources: result.sources,
638
+ queries: result.queries,
639
+ filterMap: new Map(
640
+ result.filterMap.map(([k, v]) => [k, v as FilterDefinition[]]),
641
+ ),
642
+ givens: result.givens,
643
+ compileDurationMs: result.compileDurationMs,
644
+ };
645
+ }
646
+
647
+ function serializeError(error: unknown): SerializedError {
648
+ if (error instanceof Error) {
649
+ return {
650
+ name: error.name,
651
+ message: error.message,
652
+ stack: error.stack,
653
+ };
654
+ }
655
+ return { name: "Error", message: String(error) };
656
+ }
657
+
658
+ function deserializeError(serialized: SerializedError): Error {
659
+ // Compilation errors retain their identity so callers that
660
+ // catch(MalloyError) / catch(ModelCompilationError) keep working
661
+ // when they live on the main thread. The MalloyError ctor takes
662
+ // a non-trivial argument shape, so we construct a plain Error
663
+ // with `.problems` attached (downstream code reads `.message`
664
+ // and `.problems` on both shapes).
665
+ const err = new Error(serialized.message);
666
+ err.name = serialized.name;
667
+ if (serialized.stack) err.stack = serialized.stack;
668
+ if (serialized.malloyProblems) {
669
+ (err as unknown as { problems: unknown }).problems =
670
+ serialized.malloyProblems;
671
+ }
672
+ if (serialized.isCompilationError) {
673
+ // ModelCompilationError's ctor expects a MalloyError-shaped
674
+ // input but at runtime only reads `.message`. We pass our
675
+ // reconstituted Error (with `.problems` attached) — the cast
676
+ // narrows the constructor's nominal type without losing data.
677
+ const wrapped = new ModelCompilationError(
678
+ err as unknown as ConstructorParameters<
679
+ typeof ModelCompilationError
680
+ >[0],
681
+ );
682
+ if (serialized.stack) wrapped.stack = serialized.stack;
683
+ return wrapped;
684
+ }
685
+ return err;
686
+ }
687
+
688
+ const defaultUrlReader = {
689
+ readURL: async (url: URL): Promise<string> => {
690
+ const { promises: fs } = await import("fs");
691
+ const { fileURLToPath } = await import("url");
692
+ const filePath =
693
+ url.protocol === "file:" ? fileURLToPath(url) : url.toString();
694
+ return fs.readFile(filePath, "utf8");
695
+ },
696
+ };
697
+
698
+ // ──────────────────────────────────────────────────────────────────────
699
+ // Singleton accessor
700
+ // ──────────────────────────────────────────────────────────────────────
701
+
702
+ let singleton: CompileWorkerPool | null = null;
703
+
704
+ export function getCompilePool(): CompileWorkerPool {
705
+ if (singleton === null) {
706
+ const n = getCompileWorkerCount();
707
+ singleton = new CompileWorkerPool(n);
708
+ if (n > 0) {
709
+ logger.info(
710
+ `Malloy compile worker pool enabled (size=${n}). Set MALLOY_COMPILE_WORKERS=0 to disable.`,
711
+ );
712
+ } else {
713
+ logger.info(
714
+ "Malloy compile worker pool DISABLED (MALLOY_COMPILE_WORKERS=0). Compile runs on the main event loop.",
715
+ );
716
+ }
717
+ }
718
+ return singleton;
719
+ }
720
+
721
+ /** Test-only: replace the singleton (and shut down the previous one). */
722
+ export async function __setCompilePoolForTests(
723
+ pool: CompileWorkerPool | null,
724
+ ): Promise<void> {
725
+ if (singleton && singleton !== pool) {
726
+ await singleton.shutdown();
727
+ }
728
+ singleton = pool;
729
+ }