@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.
- package/README.docker.md +135 -20
- package/README.md +15 -0
- package/build.ts +42 -1
- package/dist/app/api-doc.yaml +51 -0
- package/dist/app/assets/EnvironmentPage-Dpee_Kn6.js +1 -0
- package/dist/app/assets/HomePage-DLRWTNoL.js +1 -0
- package/dist/app/assets/MainPage-DsVt5QGM.js +2 -0
- package/dist/app/assets/ModelPage-AwAugZ37.js +1 -0
- package/dist/app/assets/PackagePage-XQ-EWGTC.js +1 -0
- package/dist/app/assets/RouteError-3Mv8JQw7.js +1 -0
- package/dist/app/assets/WorkbookPage-DHYYpcYc.js +1 -0
- package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-DfcpQGVP.es-DQggNOdX.js} +14 -14
- package/dist/app/assets/{index-C513UodQ.js → index-BUp81Qdm.js} +15 -15
- package/dist/app/assets/index-D1pdwrUW.js +1803 -0
- package/dist/app/assets/index-Dv5bF4Ii.js +451 -0
- package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-CQH4LZU8.js} +1 -1
- package/dist/app/index.html +2 -3
- package/dist/compile_worker.mjs +628 -0
- package/dist/default-publisher.config.json +23 -0
- package/dist/instrumentation.mjs +36 -38
- package/dist/server.mjs +2060 -913
- package/package.json +11 -12
- package/publisher.config.example.bigquery.json +33 -0
- package/publisher.config.example.duckdb.json +23 -0
- package/publisher.config.json +1 -11
- package/src/compile/compile_pool.spec.ts +227 -0
- package/src/compile/compile_pool.ts +729 -0
- package/src/compile/compile_worker.ts +683 -0
- package/src/compile/protocol.ts +251 -0
- package/src/config.spec.ts +306 -0
- package/src/config.ts +222 -2
- package/src/controller/compile.controller.ts +3 -1
- package/src/controller/connection.controller.ts +1 -1
- package/src/controller/model.controller.ts +8 -1
- package/src/controller/package.controller.ts +70 -29
- package/src/controller/query.controller.ts +3 -0
- package/src/default-publisher.config.json +23 -0
- package/src/errors.spec.ts +42 -0
- package/src/errors.ts +21 -0
- package/src/health.spec.ts +90 -0
- package/src/health.ts +86 -45
- package/src/logger.ts +1 -3
- package/src/mcp/tools/discovery_tools.ts +6 -2
- package/src/mcp/tools/execute_query_tool.ts +12 -0
- package/src/path_safety.spec.ts +158 -0
- package/src/path_safety.ts +140 -0
- package/src/pg_helpers.spec.ts +226 -0
- package/src/pg_helpers.ts +129 -0
- package/src/server-old.ts +3 -23
- package/src/server.ts +49 -0
- package/src/service/connection.spec.ts +6 -4
- package/src/service/connection.ts +8 -3
- package/src/service/connection_config.ts +2 -2
- package/src/service/environment.ts +621 -176
- package/src/service/environment_admission.spec.ts +180 -0
- package/src/service/environment_store.ts +22 -0
- package/src/service/filter_integration.spec.ts +110 -0
- package/src/service/givens_integration.spec.ts +192 -0
- package/src/service/manifest_service.spec.ts +7 -2
- package/src/service/manifest_service.ts +8 -2
- package/src/service/materialization_service.ts +14 -3
- package/src/service/model.spec.ts +105 -0
- package/src/service/model.ts +317 -10
- package/src/service/model_worker_path.spec.ts +125 -0
- package/src/service/package.ts +4 -3
- package/src/service/package_memory_governor.spec.ts +173 -0
- package/src/service/package_memory_governor.ts +233 -0
- package/src/service/package_race.spec.ts +208 -0
- package/src/storage/StorageManager.ts +71 -11
- package/src/storage/duckdb/schema.ts +41 -0
- package/src/utils.ts +11 -0
- package/tests/harness/rest_e2e.ts +2 -2
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
- package/tests/unit/duckdb/attached_databases.test.ts +5 -5
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
- package/tests/unit/storage/StorageManager.test.ts +166 -0
- package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
- package/dist/app/assets/HomePage-DMop21VG.js +0 -1
- package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
- package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
- package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
- package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
- package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
- package/dist/app/assets/index-5K9YjIxF.js +0 -456
- package/dist/app/assets/index-DIgzgp69.js +0 -1742
|
@@ -234,6 +234,111 @@ describe("service/model", () => {
|
|
|
234
234
|
|
|
235
235
|
sinon.restore();
|
|
236
236
|
});
|
|
237
|
+
|
|
238
|
+
it("forwards givens to runnable.getPreparedResult and .run", async () => {
|
|
239
|
+
const givensArg = { region: "EU" };
|
|
240
|
+
const preparedResultStub = sinon
|
|
241
|
+
.stub()
|
|
242
|
+
.resolves({ resultExplore: { limit: 10 } });
|
|
243
|
+
const runStub = sinon
|
|
244
|
+
.stub()
|
|
245
|
+
.rejects(new MalloyError("stub-stop", []));
|
|
246
|
+
const modelMaterializer = {
|
|
247
|
+
loadQuery: sinon.stub().returns({
|
|
248
|
+
getPreparedResult: preparedResultStub,
|
|
249
|
+
run: runStub,
|
|
250
|
+
}),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const model = new Model(
|
|
254
|
+
packageName,
|
|
255
|
+
mockModelPath,
|
|
256
|
+
{},
|
|
257
|
+
"model",
|
|
258
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
259
|
+
modelMaterializer as any,
|
|
260
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
261
|
+
{ contents: {}, exports: [], queryList: [] } as any,
|
|
262
|
+
undefined,
|
|
263
|
+
undefined,
|
|
264
|
+
undefined,
|
|
265
|
+
undefined,
|
|
266
|
+
undefined,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await expect(
|
|
270
|
+
model.getQueryResults(
|
|
271
|
+
undefined,
|
|
272
|
+
undefined,
|
|
273
|
+
"run: orders -> summary",
|
|
274
|
+
undefined,
|
|
275
|
+
undefined,
|
|
276
|
+
givensArg,
|
|
277
|
+
),
|
|
278
|
+
).rejects.toThrow(MalloyError);
|
|
279
|
+
|
|
280
|
+
expect(preparedResultStub.calledOnce).toBe(true);
|
|
281
|
+
expect(preparedResultStub.firstCall.args[0]).toEqual({
|
|
282
|
+
givens: givensArg,
|
|
283
|
+
});
|
|
284
|
+
expect(runStub.firstCall.args[0]).toMatchObject({
|
|
285
|
+
givens: givensArg,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
sinon.restore();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("executeNotebookCell", () => {
|
|
293
|
+
it("forwards givens to runnable.getPreparedResult and .run", async () => {
|
|
294
|
+
const givensArg = { target_code: "AA" };
|
|
295
|
+
const preparedResultStub = sinon
|
|
296
|
+
.stub()
|
|
297
|
+
.resolves({ resultExplore: { limit: 10 } });
|
|
298
|
+
const runStub = sinon
|
|
299
|
+
.stub()
|
|
300
|
+
.rejects(new MalloyError("stub-stop", []));
|
|
301
|
+
const cellRunnable = {
|
|
302
|
+
getPreparedResult: preparedResultStub,
|
|
303
|
+
run: runStub,
|
|
304
|
+
};
|
|
305
|
+
const runnableCells = [
|
|
306
|
+
{
|
|
307
|
+
type: "code" as const,
|
|
308
|
+
text: "run: orders -> by_code",
|
|
309
|
+
runnable: cellRunnable,
|
|
310
|
+
},
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
const model = new Model(
|
|
314
|
+
packageName,
|
|
315
|
+
"test.malloynb",
|
|
316
|
+
{},
|
|
317
|
+
"notebook",
|
|
318
|
+
undefined,
|
|
319
|
+
undefined,
|
|
320
|
+
undefined,
|
|
321
|
+
undefined,
|
|
322
|
+
undefined,
|
|
323
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
324
|
+
runnableCells as any,
|
|
325
|
+
undefined,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
await expect(
|
|
329
|
+
model.executeNotebookCell(0, undefined, undefined, givensArg),
|
|
330
|
+
).rejects.toThrow(MalloyError);
|
|
331
|
+
|
|
332
|
+
expect(preparedResultStub.calledOnce).toBe(true);
|
|
333
|
+
expect(preparedResultStub.firstCall.args[0]).toEqual({
|
|
334
|
+
givens: givensArg,
|
|
335
|
+
});
|
|
336
|
+
expect(runStub.firstCall.args[0]).toMatchObject({
|
|
337
|
+
givens: givensArg,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
sinon.restore();
|
|
341
|
+
});
|
|
237
342
|
});
|
|
238
343
|
});
|
|
239
344
|
|
package/src/service/model.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
API,
|
|
4
4
|
Connection,
|
|
5
5
|
FixedConnectionMap,
|
|
6
|
+
GivenValue,
|
|
6
7
|
isSourceDef,
|
|
7
8
|
MalloyConfig,
|
|
8
9
|
MalloyError,
|
|
@@ -28,6 +29,7 @@ import * as fs from "fs/promises";
|
|
|
28
29
|
import { createRequire } from "module";
|
|
29
30
|
import * as path from "path";
|
|
30
31
|
import { components } from "../api";
|
|
32
|
+
import { getCompilePool } from "../compile/compile_pool";
|
|
31
33
|
import {
|
|
32
34
|
MODEL_FILE_SUFFIX,
|
|
33
35
|
NOTEBOOK_FILE_SUFFIX,
|
|
@@ -56,6 +58,7 @@ type ApiNotebookCell = components["schemas"]["NotebookCell"];
|
|
|
56
58
|
type ApiRawNotebook = components["schemas"]["RawNotebook"];
|
|
57
59
|
type ApiSource = components["schemas"]["Source"];
|
|
58
60
|
type ApiFilter = components["schemas"]["Filter"];
|
|
61
|
+
type ApiGiven = components["schemas"]["Given"];
|
|
59
62
|
type ApiView = components["schemas"]["View"];
|
|
60
63
|
type ApiQuery = components["schemas"]["Query"];
|
|
61
64
|
export type ApiConnection = components["schemas"]["Connection"];
|
|
@@ -73,6 +76,61 @@ const MALLOY_VERSION = (
|
|
|
73
76
|
export type ModelType = "model" | "notebook";
|
|
74
77
|
type ModelConnectionInput = MalloyConfig | Map<string, Connection>;
|
|
75
78
|
|
|
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
|
+
|
|
76
134
|
interface RunnableNotebookCell {
|
|
77
135
|
type: "code" | "markdown";
|
|
78
136
|
text: string;
|
|
@@ -83,12 +141,31 @@ interface RunnableNotebookCell {
|
|
|
83
141
|
queryInfo?: Malloy.QueryInfo;
|
|
84
142
|
}
|
|
85
143
|
|
|
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
|
+
|
|
86
156
|
export class Model {
|
|
87
157
|
private packageName: string;
|
|
88
158
|
private modelPath: string;
|
|
89
159
|
private dataStyles: DataStyles;
|
|
90
160
|
private modelType: ModelType;
|
|
91
161
|
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;
|
|
92
169
|
private modelDef: ModelDef | undefined;
|
|
93
170
|
private modelInfo: Malloy.ModelInfo | undefined;
|
|
94
171
|
private sources: ApiSource[] | undefined;
|
|
@@ -98,6 +175,12 @@ export class Model {
|
|
|
98
175
|
private compilationError: MalloyError | Error | undefined;
|
|
99
176
|
/** Parsed #(filter) definitions keyed by source name. */
|
|
100
177
|
private filterMap: Map<string, FilterDefinition[]>;
|
|
178
|
+
/** Givens declared on the model, in declaration order. Malloy's
|
|
179
|
+
* `Model.givens` already collapses inheritance; we just stash the list
|
|
180
|
+
* for surfacing on the compiled-model response. */
|
|
181
|
+
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;
|
|
101
184
|
private meter = metrics.getMeter("publisher");
|
|
102
185
|
private queryExecutionHistogram = this.meter.createHistogram(
|
|
103
186
|
"malloy_model_query_duration",
|
|
@@ -121,6 +204,8 @@ export class Model {
|
|
|
121
204
|
runnableNotebookCells: RunnableNotebookCell[] | undefined,
|
|
122
205
|
compilationError: MalloyError | Error | undefined,
|
|
123
206
|
filterMap?: Map<string, FilterDefinition[]>,
|
|
207
|
+
givens?: ApiGiven[],
|
|
208
|
+
materializerBuilder?: MaterializerBuilder,
|
|
124
209
|
) {
|
|
125
210
|
this.packageName = packageName;
|
|
126
211
|
this.modelPath = modelPath;
|
|
@@ -128,17 +213,41 @@ export class Model {
|
|
|
128
213
|
this.modelType = modelType;
|
|
129
214
|
this.modelDef = modelDef;
|
|
130
215
|
this.modelMaterializer = modelMaterializer;
|
|
216
|
+
this.materializerBuilder = materializerBuilder;
|
|
131
217
|
this.sources = sources;
|
|
132
218
|
this.queries = queries;
|
|
133
219
|
this.sourceInfos = sourceInfos;
|
|
134
220
|
this.runnableNotebookCells = runnableNotebookCells;
|
|
135
221
|
this.compilationError = compilationError;
|
|
136
222
|
this.filterMap = filterMap ?? new Map();
|
|
223
|
+
this.givens = givens;
|
|
137
224
|
this.modelInfo = this.modelDef
|
|
138
225
|
? modelDefToModelInfo(this.modelDef)
|
|
139
226
|
: undefined;
|
|
140
227
|
}
|
|
141
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;
|
|
249
|
+
}
|
|
250
|
+
|
|
142
251
|
/**
|
|
143
252
|
* Get the parsed filter definitions for a given source name.
|
|
144
253
|
* Returns an empty array if no filters are declared.
|
|
@@ -164,6 +273,158 @@ export class Model {
|
|
|
164
273
|
modelPath: string,
|
|
165
274
|
malloyConfig: ModelConnectionInput,
|
|
166
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
|
+
/**
|
|
329
|
+
* Compile via the {@link CompileWorkerPool}. Builds a `Model` whose
|
|
330
|
+
* `modelDef` / `sources` / `queries` / `sourceInfos` / `givens` are
|
|
331
|
+
* populated up-front, but whose `ModelMaterializer` is constructed
|
|
332
|
+
* lazily on the first query through {@link ensureMaterializer}.
|
|
333
|
+
* This keeps the heavy CPU work (parse, type-check, IR build) off
|
|
334
|
+
* the main event loop so the K8s liveness probe stays responsive.
|
|
335
|
+
*/
|
|
336
|
+
private static async createViaWorker(
|
|
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(
|
|
423
|
+
packageName: string,
|
|
424
|
+
packagePath: string,
|
|
425
|
+
modelPath: string,
|
|
426
|
+
malloyConfig: ModelConnectionInput,
|
|
427
|
+
options?: { buildManifest?: BuildManifest["entries"] },
|
|
167
428
|
): Promise<Model> {
|
|
168
429
|
// getModelRuntime might throw a ModelNotFoundError. It's the callers responsibility
|
|
169
430
|
// to pass a valid model path or handle the error.
|
|
@@ -188,10 +449,19 @@ export class Model {
|
|
|
188
449
|
let sources = undefined;
|
|
189
450
|
let queries = undefined;
|
|
190
451
|
let filterMap: Map<string, FilterDefinition[]> | undefined;
|
|
452
|
+
let givens: ApiGiven[] | undefined;
|
|
191
453
|
const sourceInfos: Malloy.SourceInfo[] = [];
|
|
192
454
|
if (modelMaterializer) {
|
|
193
|
-
|
|
194
|
-
|
|
455
|
+
const compiledModel = await modelMaterializer.getModel();
|
|
456
|
+
modelDef = compiledModel._modelDef;
|
|
457
|
+
// Malloy's `Model.givens` already collapses inheritance from imports
|
|
458
|
+
// and applies any `finalizeGivens` runtime config. Just read it.
|
|
459
|
+
const malloyGivens = Array.from(compiledModel.givens.values());
|
|
460
|
+
givens =
|
|
461
|
+
malloyGivens.length > 0
|
|
462
|
+
? malloyGivens.map(malloyGivenToApi)
|
|
463
|
+
: undefined;
|
|
464
|
+
const sourceResult = Model.getSources(modelPath, modelDef, givens);
|
|
195
465
|
sources = sourceResult.sources;
|
|
196
466
|
filterMap = sourceResult.filterMap;
|
|
197
467
|
queries = Model.getQueries(modelPath, modelDef);
|
|
@@ -255,6 +525,7 @@ export class Model {
|
|
|
255
525
|
runnableNotebookCells,
|
|
256
526
|
undefined,
|
|
257
527
|
filterMap,
|
|
528
|
+
givens,
|
|
258
529
|
);
|
|
259
530
|
} catch (error) {
|
|
260
531
|
let computedError = error;
|
|
@@ -342,6 +613,7 @@ export class Model {
|
|
|
342
613
|
query?: string,
|
|
343
614
|
filterParams?: FilterParams,
|
|
344
615
|
bypassFilters?: boolean,
|
|
616
|
+
givens?: Record<string, GivenValue>,
|
|
345
617
|
): Promise<{
|
|
346
618
|
result: Malloy.Result;
|
|
347
619
|
compactResult: QueryData;
|
|
@@ -363,9 +635,25 @@ export class Model {
|
|
|
363
635
|
);
|
|
364
636
|
}
|
|
365
637
|
let runnable: QueryMaterializer;
|
|
366
|
-
if (!this.
|
|
638
|
+
if (!this.modelDef || !this.modelInfo)
|
|
367
639
|
throw new BadRequestError("Model has no queryable entities.");
|
|
368
640
|
|
|
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
|
+
|
|
369
657
|
// Wrap loadQuery calls in try-catch to handle query parsing errors
|
|
370
658
|
try {
|
|
371
659
|
let queryString: string;
|
|
@@ -406,7 +694,7 @@ export class Model {
|
|
|
406
694
|
}
|
|
407
695
|
}
|
|
408
696
|
|
|
409
|
-
runnable =
|
|
697
|
+
runnable = materializer.loadQuery(queryString);
|
|
410
698
|
} catch (error) {
|
|
411
699
|
// Re-throw BadRequestError as-is
|
|
412
700
|
if (error instanceof BadRequestError) {
|
|
@@ -436,13 +724,14 @@ export class Model {
|
|
|
436
724
|
}
|
|
437
725
|
|
|
438
726
|
const rowLimit =
|
|
439
|
-
(await runnable.getPreparedResult()).resultExplore.limit ||
|
|
727
|
+
(await runnable.getPreparedResult({ givens })).resultExplore.limit ||
|
|
728
|
+
ROW_LIMIT;
|
|
440
729
|
const endTime = performance.now();
|
|
441
730
|
const executionTime = endTime - startTime;
|
|
442
731
|
|
|
443
732
|
let queryResults;
|
|
444
733
|
try {
|
|
445
|
-
queryResults = await runnable.run({ rowLimit });
|
|
734
|
+
queryResults = await runnable.run({ rowLimit, givens });
|
|
446
735
|
} catch (error) {
|
|
447
736
|
// Record error metrics
|
|
448
737
|
const errorEndTime = performance.now();
|
|
@@ -494,7 +783,14 @@ export class Model {
|
|
|
494
783
|
}
|
|
495
784
|
|
|
496
785
|
private getStandardModel(): ApiCompiledModel {
|
|
497
|
-
|
|
786
|
+
// modelDef is immutable for the lifetime of this Model, so the
|
|
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 = {
|
|
498
794
|
type: "source",
|
|
499
795
|
packageName: this.packageName,
|
|
500
796
|
modelPath: this.modelPath,
|
|
@@ -509,7 +805,10 @@ export class Model {
|
|
|
509
805
|
),
|
|
510
806
|
sources: this.sources,
|
|
511
807
|
queries: this.queries,
|
|
808
|
+
givens: this.givens,
|
|
512
809
|
} as ApiCompiledModel;
|
|
810
|
+
this.cachedStandardModel = compiled;
|
|
811
|
+
return compiled;
|
|
513
812
|
}
|
|
514
813
|
|
|
515
814
|
private async getNotebookModel(): Promise<ApiRawNotebook> {
|
|
@@ -568,6 +867,7 @@ export class Model {
|
|
|
568
867
|
cellIndex: number,
|
|
569
868
|
filterParams?: FilterParams,
|
|
570
869
|
bypassFilters?: boolean,
|
|
870
|
+
givens?: Record<string, GivenValue>,
|
|
571
871
|
): Promise<{
|
|
572
872
|
type: "code" | "markdown";
|
|
573
873
|
text: string;
|
|
@@ -629,9 +929,9 @@ export class Model {
|
|
|
629
929
|
}
|
|
630
930
|
|
|
631
931
|
const rowLimit =
|
|
632
|
-
(await runnableToExecute.getPreparedResult())
|
|
633
|
-
.limit || ROW_LIMIT;
|
|
634
|
-
const result = await runnableToExecute.run({ rowLimit });
|
|
932
|
+
(await runnableToExecute.getPreparedResult({ givens }))
|
|
933
|
+
.resultExplore.limit || ROW_LIMIT;
|
|
934
|
+
const result = await runnableToExecute.run({ rowLimit, givens });
|
|
635
935
|
const query = (await runnableToExecute.getPreparedQuery())._query;
|
|
636
936
|
queryName = (query as NamedQueryDef).as || query.name;
|
|
637
937
|
queryResult =
|
|
@@ -758,6 +1058,7 @@ export class Model {
|
|
|
758
1058
|
private static getSources(
|
|
759
1059
|
modelPath: string,
|
|
760
1060
|
modelDef: ModelDef,
|
|
1061
|
+
givens?: ApiGiven[],
|
|
761
1062
|
): {
|
|
762
1063
|
sources: ApiSource[];
|
|
763
1064
|
filterMap: Map<string, FilterDefinition[]>;
|
|
@@ -846,6 +1147,12 @@ export class Model {
|
|
|
846
1147
|
annotations,
|
|
847
1148
|
views,
|
|
848
1149
|
filters,
|
|
1150
|
+
// Malloy exposes givens at the model level, not per-source.
|
|
1151
|
+
// First pass: surface the full model-level list on every source
|
|
1152
|
+
// — matches how filter introspection already collapses
|
|
1153
|
+
// inheritance into the per-source list. Refine to view-scoped
|
|
1154
|
+
// filtering if a customer asks.
|
|
1155
|
+
givens,
|
|
849
1156
|
} as ApiSource;
|
|
850
1157
|
});
|
|
851
1158
|
|