@malloy-publisher/server 0.0.198-dev4 → 0.0.198-dev6
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/build.ts +17 -13
- package/dist/instrumentation.mjs +21 -0
- package/dist/package_load_worker.mjs +12213 -0
- package/dist/server.mjs +2026 -2622
- package/package.json +2 -3
- package/src/health.ts +5 -3
- package/src/instrumentation.ts +50 -0
- package/src/package_load/package_load_pool.spec.ts +252 -0
- package/src/package_load/package_load_pool.ts +920 -0
- package/src/{compile/compile_worker.ts → package_load/package_load_worker.ts} +505 -246
- package/src/package_load/protocol.ts +336 -0
- package/src/server.ts +12 -0
- package/src/service/environment_store.ts +24 -3
- package/src/service/given.ts +80 -0
- package/src/service/model.ts +255 -291
- package/src/service/package.spec.ts +10 -0
- package/src/service/package.ts +268 -259
- package/src/service/package_worker_path.spec.ts +196 -0
- package/dist/compile_worker.mjs +0 -633
- package/src/compile/compile_pool.spec.ts +0 -292
- package/src/compile/compile_pool.ts +0 -796
- package/src/compile/protocol.ts +0 -270
- package/src/service/model_worker_path.spec.ts +0 -133
package/src/service/model.ts
CHANGED
|
@@ -29,7 +29,11 @@ import * as fs from "fs/promises";
|
|
|
29
29
|
import { createRequire } from "module";
|
|
30
30
|
import * as path from "path";
|
|
31
31
|
import { components } from "../api";
|
|
32
|
-
import {
|
|
32
|
+
import { deserializeError } from "../package_load/package_load_pool";
|
|
33
|
+
import type {
|
|
34
|
+
SerializedModel,
|
|
35
|
+
SerializedNotebookCell,
|
|
36
|
+
} from "../package_load/protocol";
|
|
33
37
|
import {
|
|
34
38
|
MODEL_FILE_SUFFIX,
|
|
35
39
|
NOTEBOOK_FILE_SUFFIX,
|
|
@@ -52,6 +56,7 @@ import {
|
|
|
52
56
|
type FilterDefinition,
|
|
53
57
|
type FilterParams,
|
|
54
58
|
} from "./filter";
|
|
59
|
+
import { malloyGivenToApi, type MalloyGiven } from "./given";
|
|
55
60
|
|
|
56
61
|
type ApiCompiledModel = components["schemas"]["CompiledModel"];
|
|
57
62
|
type ApiNotebookCell = components["schemas"]["NotebookCell"];
|
|
@@ -76,61 +81,6 @@ const MALLOY_VERSION = (
|
|
|
76
81
|
export type ModelType = "model" | "notebook";
|
|
77
82
|
type ModelConnectionInput = MalloyConfig | Map<string, Connection>;
|
|
78
83
|
|
|
79
|
-
/**
|
|
80
|
-
* Structural type for a Malloy SDK `Given` instance (the value type of
|
|
81
|
-
* `Model.givens`). The `Given` class is declared in
|
|
82
|
-
* `@malloydata/malloy/dist/api/foundation/core.d.ts` but is not re-exported
|
|
83
|
-
* from the package root, so we duck-type against the surface we use rather
|
|
84
|
-
* than importing it.
|
|
85
|
-
*/
|
|
86
|
-
interface MalloyGiven {
|
|
87
|
-
readonly name: string;
|
|
88
|
-
readonly type: { type: string; filterType?: string };
|
|
89
|
-
getTaglines(prefix?: RegExp): string[];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Convert a Malloy SDK `Given` (returned from `Model.givens`) to the wire
|
|
94
|
-
* shape declared in `api-doc.yaml`. Two fields are deliberately not surfaced:
|
|
95
|
-
*
|
|
96
|
-
* - `location` — Malloy's `DocumentLocation.url` is an absolute `file://`
|
|
97
|
-
* path on the publisher's filesystem. Surfacing it would leak the OS user,
|
|
98
|
-
* install directory, and internal layout. Existing `Filter` introspection
|
|
99
|
-
* does not expose location either; matching that floor. A future PR can
|
|
100
|
-
* add a sanitized package-relative path if a client needs it.
|
|
101
|
-
*
|
|
102
|
-
* - `default` / `defaultText` — Malloy's API only exposes the parsed
|
|
103
|
-
* `ConstantExpr` AST, not a rendered source string. Rendering it here
|
|
104
|
-
* would duplicate the Malloy printer. Add when Malloy surfaces a
|
|
105
|
-
* stringified accessor.
|
|
106
|
-
*
|
|
107
|
-
* `annotations` is restricted to `#(...)` declaration annotations (the
|
|
108
|
-
* caller-facing kind, e.g. `#(doc)`). `getTaglines()` with no prefix would
|
|
109
|
-
* also return `##` doc-comment lines and the model-level `##!` pragma,
|
|
110
|
-
* which aren't part of the given's surface contract.
|
|
111
|
-
*
|
|
112
|
-
* Type rendering: `GivenTypeDef` is typed as `AtomicTypeDef |
|
|
113
|
-
* FilterExpressionParamTypeDef`, but Malloy's grammar only emits the
|
|
114
|
-
* scalar parameter types (`string` | `number` | `boolean` | `date` |
|
|
115
|
-
* `timestamp` | `timestamptz` | `filter expression` | `error`) for
|
|
116
|
-
* given declarations today. If the grammar expands to allow array or
|
|
117
|
-
* record givens, the bare `type.type` discriminator (`'array'`,
|
|
118
|
-
* `'record'`) will land in the wire response with no element info —
|
|
119
|
-
* revisit when that happens.
|
|
120
|
-
*/
|
|
121
|
-
function malloyGivenToApi(given: MalloyGiven): ApiGiven {
|
|
122
|
-
const type = given.type;
|
|
123
|
-
const renderedType =
|
|
124
|
-
type.type === "filter expression"
|
|
125
|
-
? `filter<${type.filterType}>`
|
|
126
|
-
: type.type;
|
|
127
|
-
return {
|
|
128
|
-
name: given.name,
|
|
129
|
-
type: renderedType,
|
|
130
|
-
annotations: given.getTaglines(/^#\(/),
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
84
|
interface RunnableNotebookCell {
|
|
135
85
|
type: "code" | "markdown";
|
|
136
86
|
text: string;
|
|
@@ -141,31 +91,12 @@ interface RunnableNotebookCell {
|
|
|
141
91
|
queryInfo?: Malloy.QueryInfo;
|
|
142
92
|
}
|
|
143
93
|
|
|
144
|
-
/**
|
|
145
|
-
* Lazily produces a `ModelMaterializer` on demand. Used by the worker-
|
|
146
|
-
* compile path: the worker returns a fully-built `modelDef` but cannot
|
|
147
|
-
* ship the materializer (it binds to a Runtime that holds live native
|
|
148
|
-
* connection handles and would not survive a structured-clone). The
|
|
149
|
-
* first query that actually needs to execute calls this builder,
|
|
150
|
-
* which constructs the materializer in-process. After construction
|
|
151
|
-
* Malloy caches the compiled model internally on the materializer,
|
|
152
|
-
* so subsequent queries pay no recompile cost.
|
|
153
|
-
*/
|
|
154
|
-
type MaterializerBuilder = () => Promise<ModelMaterializer>;
|
|
155
|
-
|
|
156
94
|
export class Model {
|
|
157
95
|
private packageName: string;
|
|
158
96
|
private modelPath: string;
|
|
159
97
|
private dataStyles: DataStyles;
|
|
160
98
|
private modelType: ModelType;
|
|
161
99
|
private modelMaterializer: ModelMaterializer | undefined;
|
|
162
|
-
/**
|
|
163
|
-
* Lazy builder used when the model was compiled in a worker_threads
|
|
164
|
-
* worker. The first `getQueryResults`/`executeNotebookCell` call
|
|
165
|
-
* invokes this and caches the result in `modelMaterializer`.
|
|
166
|
-
*/
|
|
167
|
-
private materializerBuilder: MaterializerBuilder | undefined;
|
|
168
|
-
private materializerBuildPromise: Promise<ModelMaterializer> | undefined;
|
|
169
100
|
private modelDef: ModelDef | undefined;
|
|
170
101
|
private modelInfo: Malloy.ModelInfo | undefined;
|
|
171
102
|
private sources: ApiSource[] | undefined;
|
|
@@ -179,8 +110,6 @@ export class Model {
|
|
|
179
110
|
* `Model.givens` already collapses inheritance; we just stash the list
|
|
180
111
|
* for surfacing on the compiled-model response. */
|
|
181
112
|
private givens: ApiGiven[] | undefined;
|
|
182
|
-
/** Cached responses from `getStandardModel()` so we don't re-stringify a multi-MB modelDef on every GET. */
|
|
183
|
-
private cachedStandardModel: ApiCompiledModel | undefined;
|
|
184
113
|
private meter = metrics.getMeter("publisher");
|
|
185
114
|
private queryExecutionHistogram = this.meter.createHistogram(
|
|
186
115
|
"malloy_model_query_duration",
|
|
@@ -205,7 +134,15 @@ export class Model {
|
|
|
205
134
|
compilationError: MalloyError | Error | undefined,
|
|
206
135
|
filterMap?: Map<string, FilterDefinition[]>,
|
|
207
136
|
givens?: ApiGiven[],
|
|
208
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Precomputed `modelDefToModelInfo(modelDef)`. The package-load
|
|
139
|
+
* worker emits it as part of `SerializedModel` so we don't
|
|
140
|
+
* re-derive it on every package load. Callers that build a
|
|
141
|
+
* `Model` from a raw `modelDef` (e.g. test fixtures via
|
|
142
|
+
* `Model.create`) can omit this and let the constructor
|
|
143
|
+
* derive it lazily.
|
|
144
|
+
*/
|
|
145
|
+
modelInfo?: Malloy.ModelInfo,
|
|
209
146
|
) {
|
|
210
147
|
this.packageName = packageName;
|
|
211
148
|
this.modelPath = modelPath;
|
|
@@ -213,7 +150,6 @@ export class Model {
|
|
|
213
150
|
this.modelType = modelType;
|
|
214
151
|
this.modelDef = modelDef;
|
|
215
152
|
this.modelMaterializer = modelMaterializer;
|
|
216
|
-
this.materializerBuilder = materializerBuilder;
|
|
217
153
|
this.sources = sources;
|
|
218
154
|
this.queries = queries;
|
|
219
155
|
this.sourceInfos = sourceInfos;
|
|
@@ -221,31 +157,9 @@ export class Model {
|
|
|
221
157
|
this.compilationError = compilationError;
|
|
222
158
|
this.filterMap = filterMap ?? new Map();
|
|
223
159
|
this.givens = givens;
|
|
224
|
-
this.modelInfo =
|
|
225
|
-
|
|
226
|
-
: undefined;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Resolve the in-process `ModelMaterializer`, building it lazily if
|
|
231
|
-
* the model was compiled in a worker_threads worker. Memoizes both
|
|
232
|
-
* the materializer and the in-flight build promise so concurrent
|
|
233
|
-
* queries on the same model share a single construction.
|
|
234
|
-
*/
|
|
235
|
-
private async ensureMaterializer(): Promise<ModelMaterializer> {
|
|
236
|
-
if (this.modelMaterializer) return this.modelMaterializer;
|
|
237
|
-
if (!this.materializerBuilder) {
|
|
238
|
-
throw new BadRequestError("Model has no queryable entities.");
|
|
239
|
-
}
|
|
240
|
-
if (!this.materializerBuildPromise) {
|
|
241
|
-
this.materializerBuildPromise = this.materializerBuilder().then(
|
|
242
|
-
(mm) => {
|
|
243
|
-
this.modelMaterializer = mm;
|
|
244
|
-
return mm;
|
|
245
|
-
},
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
return this.materializerBuildPromise;
|
|
160
|
+
this.modelInfo =
|
|
161
|
+
modelInfo ??
|
|
162
|
+
(this.modelDef ? modelDefToModelInfo(this.modelDef) : undefined);
|
|
249
163
|
}
|
|
250
164
|
|
|
251
165
|
/**
|
|
@@ -267,159 +181,16 @@ export class Model {
|
|
|
267
181
|
return runMatch?.[1] ?? arrowMatch?.[1];
|
|
268
182
|
}
|
|
269
183
|
|
|
270
|
-
public static async create(
|
|
271
|
-
packageName: string,
|
|
272
|
-
packagePath: string,
|
|
273
|
-
modelPath: string,
|
|
274
|
-
malloyConfig: ModelConnectionInput,
|
|
275
|
-
options?: { buildManifest?: BuildManifest["entries"] },
|
|
276
|
-
): Promise<Model> {
|
|
277
|
-
// Worker-pool fast path for plain `.malloy` files. Notebooks
|
|
278
|
-
// stay in-process for v1 — their per-cell ModelMaterializer
|
|
279
|
-
// chain is too entangled to ship across a worker boundary.
|
|
280
|
-
// The MALLOY_COMPILE_WORKERS=0 kill switch / pool.enabled check
|
|
281
|
-
// funnels everything through the legacy in-process path when
|
|
282
|
-
// the pool is disabled, so this is safe to land dark.
|
|
283
|
-
const pool = getCompilePool();
|
|
284
|
-
if (pool.enabled && modelPath.endsWith(MODEL_FILE_SUFFIX)) {
|
|
285
|
-
try {
|
|
286
|
-
return await Model.createViaWorker(
|
|
287
|
-
packageName,
|
|
288
|
-
packagePath,
|
|
289
|
-
modelPath,
|
|
290
|
-
malloyConfig,
|
|
291
|
-
pool,
|
|
292
|
-
options,
|
|
293
|
-
);
|
|
294
|
-
} catch (poolError) {
|
|
295
|
-
// Real compile errors propagate to the caller as a Model
|
|
296
|
-
// with `compilationError` populated, matching the
|
|
297
|
-
// in-process path's contract.
|
|
298
|
-
if (
|
|
299
|
-
poolError instanceof ModelCompilationError ||
|
|
300
|
-
poolError instanceof MalloyError
|
|
301
|
-
) {
|
|
302
|
-
return Model.makeErrorModel(
|
|
303
|
-
packageName,
|
|
304
|
-
modelPath,
|
|
305
|
-
poolError instanceof MalloyError
|
|
306
|
-
? new ModelCompilationError(poolError)
|
|
307
|
-
: poolError,
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
// Anything else (worker exited, RPC timeout) — fall back
|
|
311
|
-
// to in-process compile so a transient pool failure
|
|
312
|
-
// doesn't take a package down.
|
|
313
|
-
logger.warn(
|
|
314
|
-
"Compile worker failed; falling back to in-process compile",
|
|
315
|
-
{ packageName, modelPath, error: poolError },
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
return Model.createInProcess(
|
|
320
|
-
packageName,
|
|
321
|
-
packagePath,
|
|
322
|
-
modelPath,
|
|
323
|
-
malloyConfig,
|
|
324
|
-
options,
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
184
|
/**
|
|
329
|
-
* Compile
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
185
|
+
* Compile a single model in-process. Kept as a library entry point
|
|
186
|
+
* for test fixtures and any future caller that needs an ad-hoc
|
|
187
|
+
* `Model` from a `.malloy` / `.malloynb` file. Production package
|
|
188
|
+
* loads (`Package.create`) and reloads (`Package.reloadAllModels`)
|
|
189
|
+
* route through the package-load worker pool and dispatch through
|
|
190
|
+
* {@link Model.fromSerialized} instead — neither calls this on the
|
|
191
|
+
* main thread.
|
|
335
192
|
*/
|
|
336
|
-
|
|
337
|
-
packageName: string,
|
|
338
|
-
packagePath: string,
|
|
339
|
-
modelPath: string,
|
|
340
|
-
malloyConfig: ModelConnectionInput,
|
|
341
|
-
pool: ReturnType<typeof getCompilePool>,
|
|
342
|
-
options?: { buildManifest?: BuildManifest["entries"] },
|
|
343
|
-
): Promise<Model> {
|
|
344
|
-
const resolvedConfig = Model.toMalloyConfig(malloyConfig);
|
|
345
|
-
const outcome = await pool.compile({
|
|
346
|
-
packagePath,
|
|
347
|
-
modelPath,
|
|
348
|
-
malloyConfig: resolvedConfig,
|
|
349
|
-
// Package-level configs wrap a "duckdb" default; matches
|
|
350
|
-
// Package.buildPackageMalloyConfig.
|
|
351
|
-
defaultConnectionName: "duckdb",
|
|
352
|
-
urlReader: URL_READER,
|
|
353
|
-
buildManifest: options?.buildManifest,
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
// Materializer construction is deferred until a query actually
|
|
357
|
-
// runs. Build it the same way the in-process path does so
|
|
358
|
-
// execution semantics stay identical.
|
|
359
|
-
const materializerBuilder: MaterializerBuilder = async () => {
|
|
360
|
-
const { runtime, modelURL, importBaseURL } =
|
|
361
|
-
await Model.getModelRuntime(
|
|
362
|
-
packagePath,
|
|
363
|
-
modelPath,
|
|
364
|
-
malloyConfig,
|
|
365
|
-
options,
|
|
366
|
-
);
|
|
367
|
-
return Model.getStandardModelMaterializer(
|
|
368
|
-
runtime,
|
|
369
|
-
importBaseURL,
|
|
370
|
-
modelURL,
|
|
371
|
-
modelPath,
|
|
372
|
-
);
|
|
373
|
-
};
|
|
374
|
-
|
|
375
|
-
return new Model(
|
|
376
|
-
packageName,
|
|
377
|
-
modelPath,
|
|
378
|
-
{} as DataStyles,
|
|
379
|
-
"model",
|
|
380
|
-
undefined, // modelMaterializer — built lazily
|
|
381
|
-
outcome.modelDef,
|
|
382
|
-
outcome.sources as ApiSource[],
|
|
383
|
-
outcome.queries as ApiQuery[],
|
|
384
|
-
outcome.sourceInfos.length > 0 ? outcome.sourceInfos : undefined,
|
|
385
|
-
undefined, // runnableNotebookCells — .malloy is not a notebook
|
|
386
|
-
undefined, // compilationError
|
|
387
|
-
outcome.filterMap,
|
|
388
|
-
outcome.givens as ApiGiven[] | undefined,
|
|
389
|
-
materializerBuilder,
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
private static makeErrorModel(
|
|
394
|
-
packageName: string,
|
|
395
|
-
modelPath: string,
|
|
396
|
-
error: Error,
|
|
397
|
-
): Model {
|
|
398
|
-
const isNotebook = modelPath.endsWith(NOTEBOOK_FILE_SUFFIX);
|
|
399
|
-
return new Model(
|
|
400
|
-
packageName,
|
|
401
|
-
modelPath,
|
|
402
|
-
{} as DataStyles,
|
|
403
|
-
isNotebook ? "notebook" : "model",
|
|
404
|
-
undefined,
|
|
405
|
-
undefined,
|
|
406
|
-
undefined,
|
|
407
|
-
undefined,
|
|
408
|
-
undefined,
|
|
409
|
-
undefined,
|
|
410
|
-
error,
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Legacy in-process compile path. Retained for:
|
|
416
|
-
* - notebooks (`.malloynb`), whose per-cell materializer chain
|
|
417
|
-
* is too coupled to the Runtime to ship to a worker for v1.
|
|
418
|
-
* - environments where MALLOY_COMPILE_WORKERS=0.
|
|
419
|
-
* - fallback when the worker pool encounters a non-compile
|
|
420
|
-
* failure (worker exit, RPC timeout).
|
|
421
|
-
*/
|
|
422
|
-
private static async createInProcess(
|
|
193
|
+
public static async create(
|
|
423
194
|
packageName: string,
|
|
424
195
|
packagePath: string,
|
|
425
196
|
modelPath: string,
|
|
@@ -456,10 +227,12 @@ export class Model {
|
|
|
456
227
|
modelDef = compiledModel._modelDef;
|
|
457
228
|
// Malloy's `Model.givens` already collapses inheritance from imports
|
|
458
229
|
// and applies any `finalizeGivens` runtime config. Just read it.
|
|
459
|
-
const malloyGivens = Array.from(
|
|
230
|
+
const malloyGivens = Array.from(
|
|
231
|
+
compiledModel.givens.values(),
|
|
232
|
+
) as MalloyGiven[];
|
|
460
233
|
givens =
|
|
461
234
|
malloyGivens.length > 0
|
|
462
|
-
? malloyGivens.map(malloyGivenToApi)
|
|
235
|
+
? (malloyGivens.map(malloyGivenToApi) as ApiGiven[])
|
|
463
236
|
: undefined;
|
|
464
237
|
const sourceResult = Model.getSources(modelPath, modelDef, givens);
|
|
465
238
|
sources = sourceResult.sources;
|
|
@@ -556,6 +329,123 @@ export class Model {
|
|
|
556
329
|
}
|
|
557
330
|
}
|
|
558
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Construct a `Model` from a worker-compiled `SerializedModel`. All
|
|
334
|
+
* the heavy compile work (parse, type check, IR build, sourceInfo
|
|
335
|
+
* extraction, per-cell notebook compile) already ran inside a
|
|
336
|
+
* `worker_threads` worker; this factory just rewraps the wire data
|
|
337
|
+
* into a live `Model`.
|
|
338
|
+
*
|
|
339
|
+
* Hydrates the `ModelMaterializer` (and, for notebooks, the
|
|
340
|
+
* per-cell materializers + runnables) **eagerly** via
|
|
341
|
+
* `Runtime._loadModelFromModelDef` /
|
|
342
|
+
* `ModelMaterializer._loadQueryFromQueryDef`. These are constant-time
|
|
343
|
+
* wraps around the worker's pre-compiled `modelDef` / `queryDef` —
|
|
344
|
+
* no parse, no type-check, no schema fetch — so doing it here at
|
|
345
|
+
* package-load time costs microseconds per model and keeps the
|
|
346
|
+
* resulting `Model` interchangeable with one produced by
|
|
347
|
+
* `Model.create` (no lazy-init branches in the hot path).
|
|
348
|
+
*/
|
|
349
|
+
public static fromSerialized(
|
|
350
|
+
packageName: string,
|
|
351
|
+
_packagePath: string,
|
|
352
|
+
malloyConfig: ModelConnectionInput,
|
|
353
|
+
data: SerializedModel,
|
|
354
|
+
): Model {
|
|
355
|
+
const modelDef = data.modelDef as ModelDef | undefined;
|
|
356
|
+
const modelInfo = data.modelInfo as Malloy.ModelInfo | undefined;
|
|
357
|
+
const dataStyles = (data.dataStyles ?? {}) as DataStyles;
|
|
358
|
+
const sources = data.sources as ApiSource[] | undefined;
|
|
359
|
+
const queries = data.queries as ApiQuery[] | undefined;
|
|
360
|
+
const sourceInfos = data.sourceInfos as Malloy.SourceInfo[] | undefined;
|
|
361
|
+
const givens = data.givens as ApiGiven[] | undefined;
|
|
362
|
+
const filterMap = data.filterMap
|
|
363
|
+
? new Map(data.filterMap as Array<[string, FilterDefinition[]]>)
|
|
364
|
+
: undefined;
|
|
365
|
+
|
|
366
|
+
// No modelDef → either an empty notebook (no MALLOY statements)
|
|
367
|
+
// or a corrupt worker payload. Build a Model with no materializer;
|
|
368
|
+
// downstream getQueryResults / executeNotebookCell will throw a
|
|
369
|
+
// clean BadRequestError if a caller tries to run a query. We
|
|
370
|
+
// still preserve markdown cells for an all-markdown notebook so
|
|
371
|
+
// `getNotebook()` can serve raw text.
|
|
372
|
+
if (!modelDef) {
|
|
373
|
+
return new Model(
|
|
374
|
+
packageName,
|
|
375
|
+
data.modelPath,
|
|
376
|
+
dataStyles,
|
|
377
|
+
data.modelType,
|
|
378
|
+
undefined,
|
|
379
|
+
undefined,
|
|
380
|
+
sources,
|
|
381
|
+
queries,
|
|
382
|
+
sourceInfos,
|
|
383
|
+
data.modelType === "notebook"
|
|
384
|
+
? hydrateMarkdownOnlyCells(data.notebookCells)
|
|
385
|
+
: undefined,
|
|
386
|
+
undefined,
|
|
387
|
+
filterMap,
|
|
388
|
+
givens,
|
|
389
|
+
modelInfo,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const runtime = makeHydrationRuntime(malloyConfig);
|
|
394
|
+
const modelMaterializer = runtime._loadModelFromModelDef(modelDef);
|
|
395
|
+
const runnableNotebookCells =
|
|
396
|
+
data.modelType === "notebook"
|
|
397
|
+
? hydrateNotebookCells(runtime, data.notebookCells)
|
|
398
|
+
: undefined;
|
|
399
|
+
|
|
400
|
+
return new Model(
|
|
401
|
+
packageName,
|
|
402
|
+
data.modelPath,
|
|
403
|
+
dataStyles,
|
|
404
|
+
data.modelType,
|
|
405
|
+
modelMaterializer,
|
|
406
|
+
modelDef,
|
|
407
|
+
sources,
|
|
408
|
+
queries,
|
|
409
|
+
sourceInfos,
|
|
410
|
+
runnableNotebookCells,
|
|
411
|
+
undefined, // compilationError
|
|
412
|
+
filterMap,
|
|
413
|
+
givens,
|
|
414
|
+
modelInfo,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Build a Model representing a compilation failure (no modelDef,
|
|
420
|
+
* no materializer). Matches the shape `Model.create` returns when
|
|
421
|
+
* it catches a `MalloyError`, so the rest of the system handles
|
|
422
|
+
* both paths uniformly (the iteration loop in `Package.create`
|
|
423
|
+
* reads `compilationError` via a structural cast).
|
|
424
|
+
*/
|
|
425
|
+
public static fromCompilationError(
|
|
426
|
+
packageName: string,
|
|
427
|
+
modelPath: string,
|
|
428
|
+
modelType: ModelType,
|
|
429
|
+
error: Error,
|
|
430
|
+
): Model {
|
|
431
|
+
return new Model(
|
|
432
|
+
packageName,
|
|
433
|
+
modelPath,
|
|
434
|
+
{} as DataStyles,
|
|
435
|
+
modelType,
|
|
436
|
+
undefined,
|
|
437
|
+
undefined,
|
|
438
|
+
undefined,
|
|
439
|
+
undefined,
|
|
440
|
+
undefined,
|
|
441
|
+
undefined,
|
|
442
|
+
error,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Look up the deserialized error helper for callers (e.g. Package.create). */
|
|
447
|
+
public static deserializeCompilationError = deserializeError;
|
|
448
|
+
|
|
559
449
|
public getPath(): string {
|
|
560
450
|
return this.modelPath;
|
|
561
451
|
}
|
|
@@ -635,25 +525,9 @@ export class Model {
|
|
|
635
525
|
);
|
|
636
526
|
}
|
|
637
527
|
let runnable: QueryMaterializer;
|
|
638
|
-
if (!this.modelDef || !this.modelInfo)
|
|
528
|
+
if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
|
|
639
529
|
throw new BadRequestError("Model has no queryable entities.");
|
|
640
530
|
|
|
641
|
-
// Resolve the materializer — either already-built (in-process
|
|
642
|
-
// create path, or a previous query on this Model) or lazily
|
|
643
|
-
// constructed now (worker-compile path on first query).
|
|
644
|
-
let materializer: ModelMaterializer;
|
|
645
|
-
try {
|
|
646
|
-
materializer = await this.ensureMaterializer();
|
|
647
|
-
} catch (error) {
|
|
648
|
-
if (error instanceof BadRequestError) throw error;
|
|
649
|
-
if (error instanceof MalloyError) throw error;
|
|
650
|
-
throw new BadRequestError(
|
|
651
|
-
error instanceof Error
|
|
652
|
-
? `Failed to prepare model: ${error.message}`
|
|
653
|
-
: "Failed to prepare model.",
|
|
654
|
-
);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
531
|
// Wrap loadQuery calls in try-catch to handle query parsing errors
|
|
658
532
|
try {
|
|
659
533
|
let queryString: string;
|
|
@@ -694,7 +568,7 @@ export class Model {
|
|
|
694
568
|
}
|
|
695
569
|
}
|
|
696
570
|
|
|
697
|
-
runnable =
|
|
571
|
+
runnable = this.modelMaterializer.loadQuery(queryString);
|
|
698
572
|
} catch (error) {
|
|
699
573
|
// Re-throw BadRequestError as-is
|
|
700
574
|
if (error instanceof BadRequestError) {
|
|
@@ -783,23 +657,17 @@ export class Model {
|
|
|
783
657
|
}
|
|
784
658
|
|
|
785
659
|
private getStandardModel(): ApiCompiledModel {
|
|
786
|
-
|
|
787
|
-
// (potentially multi-MB) JSON.stringify result can be memoised.
|
|
788
|
-
// Without this cache every `GET /environments/:e/packages/:p/
|
|
789
|
-
// models/:m` re-stringifies the whole tree on the main thread —
|
|
790
|
-
// a known source of multi-hundred-ms event-loop pauses that
|
|
791
|
-
// chips away at the K8s liveness budget.
|
|
792
|
-
if (this.cachedStandardModel) return this.cachedStandardModel;
|
|
793
|
-
const compiled: ApiCompiledModel = {
|
|
660
|
+
return {
|
|
794
661
|
type: "source",
|
|
795
662
|
packageName: this.packageName,
|
|
796
663
|
modelPath: this.modelPath,
|
|
797
664
|
malloyVersion: MALLOY_VERSION,
|
|
798
665
|
dataStyles: JSON.stringify(this.dataStyles),
|
|
799
666
|
modelDef: JSON.stringify(this.modelDef),
|
|
800
|
-
modelInfo
|
|
801
|
-
|
|
802
|
-
|
|
667
|
+
// `this.modelInfo` is precomputed once at construction (either
|
|
668
|
+
// by the worker or in the Model.create constructor); don't
|
|
669
|
+
// re-run `modelDefToModelInfo` on every API hit.
|
|
670
|
+
modelInfo: JSON.stringify(this.modelInfo ?? {}),
|
|
803
671
|
sourceInfos: this.getSourceInfos()?.map((sourceInfo) =>
|
|
804
672
|
JSON.stringify(sourceInfo),
|
|
805
673
|
),
|
|
@@ -807,8 +675,6 @@ export class Model {
|
|
|
807
675
|
queries: this.queries,
|
|
808
676
|
givens: this.givens,
|
|
809
677
|
} as ApiCompiledModel;
|
|
810
|
-
this.cachedStandardModel = compiled;
|
|
811
|
-
return compiled;
|
|
812
678
|
}
|
|
813
679
|
|
|
814
680
|
private async getNotebookModel(): Promise<ApiRawNotebook> {
|
|
@@ -853,9 +719,7 @@ export class Model {
|
|
|
853
719
|
packageName: this.packageName,
|
|
854
720
|
modelPath: this.modelPath,
|
|
855
721
|
malloyVersion: MALLOY_VERSION,
|
|
856
|
-
modelInfo: JSON.stringify(
|
|
857
|
-
this.modelDef ? modelDefToModelInfo(this.modelDef) : {},
|
|
858
|
-
),
|
|
722
|
+
modelInfo: JSON.stringify(this.modelInfo ?? {}),
|
|
859
723
|
sources: this.modelDef && this.sources,
|
|
860
724
|
queries: this.modelDef && this.queries,
|
|
861
725
|
annotations: allAnnotations,
|
|
@@ -1375,3 +1239,103 @@ export class Model {
|
|
|
1375
1239
|
}
|
|
1376
1240
|
}
|
|
1377
1241
|
}
|
|
1242
|
+
|
|
1243
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1244
|
+
// Helpers for hydrating worker-compiled models on the main thread
|
|
1245
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Minimal subset of `Runtime` we use here. The `_` methods are
|
|
1249
|
+
* marked `@internal` in Malloy but are the only API for constructing
|
|
1250
|
+
* a materializer / query materializer from an existing `modelDef` /
|
|
1251
|
+
* queryDef — the public `loadModel(url)` path always recompiles.
|
|
1252
|
+
*/
|
|
1253
|
+
type HydrationRuntime = Runtime & {
|
|
1254
|
+
_loadModelFromModelDef(modelDef: ModelDef): ModelMaterializer;
|
|
1255
|
+
};
|
|
1256
|
+
type HydrationMaterializer = ModelMaterializer & {
|
|
1257
|
+
_loadQueryFromQueryDef(query: unknown): QueryMaterializer;
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
function makeHydrationRuntime(
|
|
1261
|
+
malloyConfig: ModelConnectionInput,
|
|
1262
|
+
): HydrationRuntime {
|
|
1263
|
+
const urlReader = new HackyDataStylesAccumulator(URL_READER);
|
|
1264
|
+
const config =
|
|
1265
|
+
malloyConfig instanceof MalloyConfig
|
|
1266
|
+
? malloyConfig
|
|
1267
|
+
: (() => {
|
|
1268
|
+
const c = new MalloyConfig({ connections: {} });
|
|
1269
|
+
c.wrapConnections(
|
|
1270
|
+
() => new FixedConnectionMap(malloyConfig, "duckdb"),
|
|
1271
|
+
);
|
|
1272
|
+
return c;
|
|
1273
|
+
})();
|
|
1274
|
+
return new Runtime({ urlReader, config }) as HydrationRuntime;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Build the live `RunnableNotebookCell[]` from worker-emitted
|
|
1279
|
+
* per-cell data. Each MALLOY cell is hydrated via
|
|
1280
|
+
* `Runtime._loadModelFromModelDef` (for the cell's scope) and
|
|
1281
|
+
* `ModelMaterializer._loadQueryFromQueryDef` (for the cell's
|
|
1282
|
+
* runnable) — no recompile.
|
|
1283
|
+
*/
|
|
1284
|
+
function hydrateNotebookCells(
|
|
1285
|
+
runtime: HydrationRuntime,
|
|
1286
|
+
notebookCells: SerializedNotebookCell[] | undefined,
|
|
1287
|
+
): RunnableNotebookCell[] {
|
|
1288
|
+
if (!notebookCells) return [];
|
|
1289
|
+
return notebookCells.map((sc): RunnableNotebookCell => {
|
|
1290
|
+
if (sc.type === "markdown") {
|
|
1291
|
+
return { type: "markdown", text: sc.text };
|
|
1292
|
+
}
|
|
1293
|
+
const cellModelDef = sc.cellModelDef as ModelDef | undefined;
|
|
1294
|
+
let modelMaterializer: ModelMaterializer | undefined;
|
|
1295
|
+
let runnable: QueryMaterializer | undefined;
|
|
1296
|
+
if (cellModelDef) {
|
|
1297
|
+
modelMaterializer = runtime._loadModelFromModelDef(cellModelDef);
|
|
1298
|
+
if (sc.cellQueryDef !== undefined) {
|
|
1299
|
+
try {
|
|
1300
|
+
runnable = (
|
|
1301
|
+
modelMaterializer as HydrationMaterializer
|
|
1302
|
+
)._loadQueryFromQueryDef(sc.cellQueryDef);
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
// Hydration shouldn't fail for a queryDef the worker
|
|
1305
|
+
// already prepared, but if Malloy's internal shape
|
|
1306
|
+
// drifts we'd rather drop the runnable than crash the
|
|
1307
|
+
// whole notebook. The cell remains markdown-runnable.
|
|
1308
|
+
logger.warn("Failed to hydrate notebook cell queryDef", {
|
|
1309
|
+
error,
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
return {
|
|
1315
|
+
type: "code",
|
|
1316
|
+
text: sc.text,
|
|
1317
|
+
runnable,
|
|
1318
|
+
modelMaterializer,
|
|
1319
|
+
newSources: sc.newSources as Malloy.SourceInfo[] | undefined,
|
|
1320
|
+
queryInfo: sc.queryInfo as Malloy.QueryInfo | undefined,
|
|
1321
|
+
};
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* For an all-markdown notebook (no MALLOY statements → no
|
|
1327
|
+
* `modelDef`), we still want to preserve the cell list so
|
|
1328
|
+
* `getNotebook()` can serve raw text. This skips materializer
|
|
1329
|
+
* hydration (there's nothing to hydrate) and returns markdown-only
|
|
1330
|
+
* cells.
|
|
1331
|
+
*/
|
|
1332
|
+
function hydrateMarkdownOnlyCells(
|
|
1333
|
+
notebookCells: SerializedNotebookCell[] | undefined,
|
|
1334
|
+
): RunnableNotebookCell[] | undefined {
|
|
1335
|
+
if (!notebookCells) return undefined;
|
|
1336
|
+
return notebookCells.map((sc): RunnableNotebookCell => {
|
|
1337
|
+
if (sc.type === "markdown") return { type: "markdown", text: sc.text };
|
|
1338
|
+
// A code cell without a hydratable scope — surface text only.
|
|
1339
|
+
return { type: "code", text: sc.text };
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
2
|
+
import "@malloydata/db-duckdb/native";
|
|
1
3
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
4
|
import { Stats } from "fs";
|
|
3
5
|
import fs from "fs/promises";
|
|
@@ -340,10 +342,18 @@ describe("service/package", () => {
|
|
|
340
342
|
it("should return the size of the database file", async () => {
|
|
341
343
|
sinon.stub(fs, "stat").resolves({ size: 13 } as Stats);
|
|
342
344
|
|
|
345
|
+
// `getDatabaseInfo` now requires the caller to pass in the
|
|
346
|
+
// shared DuckDB connection (resolved once by `readDatabases`
|
|
347
|
+
// off the package's MalloyConfig). For this isolated unit
|
|
348
|
+
// test we mint a fresh ephemeral one — production paths
|
|
349
|
+
// reuse a single connection per package via `Package.create`.
|
|
350
|
+
const conn = new DuckDBConnection("duckdb");
|
|
351
|
+
|
|
343
352
|
// @ts-expect-error Accessing private static method for testing
|
|
344
353
|
const info = await Package.getDatabaseInfo(
|
|
345
354
|
testPackageDirectory,
|
|
346
355
|
"database.csv",
|
|
356
|
+
conn,
|
|
347
357
|
);
|
|
348
358
|
|
|
349
359
|
expect(info).toEqual({
|