@malloy-publisher/server 0.0.208 → 0.0.209
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/dist/app/api-doc.yaml +84 -66
- package/dist/app/assets/{EnvironmentPage-DDRxo015.js → EnvironmentPage-BRMCY9d8.js} +1 -1
- package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
- package/dist/app/assets/{MainPage-DHVFRXPc.js → MainPage-sZdUjUcu.js} +2 -2
- package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
- package/dist/app/assets/{ModelPage-B8tF_hYc.js → ModelPage-Bh62OIEq.js} +1 -1
- package/dist/app/assets/{PackagePage-LzaaviPn.js → PackagePage-DJW4xjLp.js} +1 -1
- package/dist/app/assets/{RouteError-vAYvRAi3.js → RouteError-Vi5yGs_F.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CTjs2hXr.js → WorkbookPage-BvJMi21d.js} +1 -1
- package/dist/app/assets/{core-AOmIKwkc.es-B29cca-e.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
- package/dist/app/assets/{index-Dj4uKosi.js → index-CCcHdeew.js} +1 -1
- package/dist/app/assets/{index-CVGIZdxd.js → index-CNFX-CGL.js} +1 -1
- package/dist/app/assets/{index-Db2wvjL3.js → index-DuqTjxM_.js} +114 -114
- package/dist/app/assets/{index.umd-BRRXibWA.js → index.umd-GgEb4WfT.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +5822 -304
- package/package.json +2 -1
- package/src/controller/materialization.controller.spec.ts +125 -0
- package/src/controller/materialization.controller.ts +23 -27
- package/src/materialization_metrics.ts +117 -34
- package/src/server-old.ts +2 -11
- package/src/server.ts +2 -10
- package/src/service/build_plan.spec.ts +116 -0
- package/src/service/build_plan.ts +238 -0
- package/src/service/connection.ts +4 -0
- package/src/service/connection_config.spec.ts +182 -1
- package/src/service/connection_config.ts +70 -0
- package/src/service/db_utils.spec.ts +159 -1
- package/src/service/db_utils.ts +131 -0
- package/src/service/materialization_service.spec.ts +388 -184
- package/src/service/materialization_service.ts +156 -442
- package/src/service/materialization_test_fixtures.ts +119 -0
- package/src/service/package.ts +41 -1
- package/src/storage/DatabaseInterface.ts +5 -13
- package/src/storage/duckdb/MaterializationRepository.ts +5 -14
- package/src/storage/duckdb/schema.ts +4 -5
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
- package/dist/app/assets/HomePage-BeIoPOVO.js +0 -1
- package/dist/app/assets/MaterializationsPage-BYnr56IV.js +0 -1
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
AtomicField,
|
|
3
|
-
BuildGraph as MalloyBuildGraph,
|
|
4
|
-
MalloyConfig,
|
|
5
2
|
Connection as MalloyConnection,
|
|
6
3
|
PersistSource,
|
|
7
4
|
} from "@malloydata/malloy";
|
|
8
5
|
import { Manifest } from "@malloydata/malloy";
|
|
9
|
-
import { components } from "../api";
|
|
10
6
|
import {
|
|
11
7
|
BadRequestError,
|
|
12
8
|
InvalidStateTransitionError,
|
|
@@ -15,10 +11,12 @@ import {
|
|
|
15
11
|
} from "../errors";
|
|
16
12
|
import { logger } from "../logger";
|
|
17
13
|
import {
|
|
18
|
-
|
|
14
|
+
MaterializationMode,
|
|
15
|
+
recordAutoLoadOutcome,
|
|
19
16
|
recordDropTables,
|
|
20
|
-
|
|
17
|
+
recordMaterializationRun,
|
|
21
18
|
recordSourceBuildDuration,
|
|
19
|
+
recordSourcesOutcome,
|
|
22
20
|
} from "../materialization_metrics";
|
|
23
21
|
import {
|
|
24
22
|
BuildInstruction,
|
|
@@ -32,15 +30,15 @@ import {
|
|
|
32
30
|
ResourceRepository,
|
|
33
31
|
} from "../storage/DatabaseInterface";
|
|
34
32
|
import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
|
|
33
|
+
import {
|
|
34
|
+
CompiledBuildPlan,
|
|
35
|
+
compilePackageBuildPlan,
|
|
36
|
+
computeBuildId,
|
|
37
|
+
} from "./build_plan";
|
|
35
38
|
import { EnvironmentStore } from "./environment_store";
|
|
36
|
-
import { Model } from "./model";
|
|
37
39
|
import { splitTablePath } from "./quoting";
|
|
38
40
|
import { resolveEnvironmentId } from "./resolve_environment";
|
|
39
41
|
|
|
40
|
-
type WireBuildGraph = components["schemas"]["BuildGraph"];
|
|
41
|
-
type WirePersistSourcePlan = components["schemas"]["PersistSourcePlan"];
|
|
42
|
-
type WireColumn = components["schemas"]["Column"];
|
|
43
|
-
|
|
44
42
|
/**
|
|
45
43
|
* Length of the buildId prefix used when synthesizing staging table names.
|
|
46
44
|
* buildId is a 64-char SHA-256 hex string; 12 hex chars is 48 bits of
|
|
@@ -54,46 +52,6 @@ export function stagingSuffix(buildId: string): string {
|
|
|
54
52
|
return `_${buildId.substring(0, STAGING_BUILD_ID_LEN)}`;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
/** Output columns of a persist source, degrading to [] if unavailable. */
|
|
58
|
-
function deriveColumns(persistSource: PersistSource): WireColumn[] {
|
|
59
|
-
try {
|
|
60
|
-
return persistSource._explore.intrinsicFields
|
|
61
|
-
.filter((f) => f.isAtomicField())
|
|
62
|
-
.map((f) => ({
|
|
63
|
-
name: f.name,
|
|
64
|
-
type: String((f as AtomicField).type),
|
|
65
|
-
}));
|
|
66
|
-
} catch (err) {
|
|
67
|
-
logger.warn("Failed to derive columns for persist source", {
|
|
68
|
-
sourceID: persistSource.sourceID,
|
|
69
|
-
error: err instanceof Error ? err.message : String(err),
|
|
70
|
-
});
|
|
71
|
-
return [];
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Flatten Malloy's nested BuildNode.dependsOn into a list of sourceIDs. */
|
|
76
|
-
function flattenDependsOn(node: {
|
|
77
|
-
dependsOn: { sourceID: string }[];
|
|
78
|
-
}): string[] {
|
|
79
|
-
return node.dependsOn.map((d) => d.sourceID);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* The buildId for a persist source: a stable digest of its connection identity
|
|
84
|
-
* and canonical SQL. Centralizes the (source, connectionDigests) call shape so
|
|
85
|
-
* planning, self-instruction, and build all agree on the same id.
|
|
86
|
-
*/
|
|
87
|
-
function computeBuildId(
|
|
88
|
-
source: PersistSource,
|
|
89
|
-
connectionDigests: Record<string, string>,
|
|
90
|
-
): string {
|
|
91
|
-
return source.makeBuildId(
|
|
92
|
-
connectionDigests[source.connectionName],
|
|
93
|
-
source.getSQL(),
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
55
|
/**
|
|
98
56
|
* Physical table name the publisher self-assigns in auto-run mode: the
|
|
99
57
|
* `#@ persist name=<table>` value if present, else the Malloy source name.
|
|
@@ -110,7 +68,7 @@ function selfAssignTableName(persistSource: PersistSource): string {
|
|
|
110
68
|
}
|
|
111
69
|
}
|
|
112
70
|
|
|
113
|
-
/** Classify a thrown
|
|
71
|
+
/** Classify a thrown build error as cancelled (cooperative abort) or failed. */
|
|
114
72
|
function outcomeFor(
|
|
115
73
|
_err: unknown,
|
|
116
74
|
signal: AbortSignal | undefined,
|
|
@@ -119,41 +77,16 @@ function outcomeFor(
|
|
|
119
77
|
}
|
|
120
78
|
|
|
121
79
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*/
|
|
127
|
-
async function resolvePackageConnections(
|
|
128
|
-
pkg: { getMalloyConnection(name: string): Promise<MalloyConnection> },
|
|
129
|
-
names: Iterable<string>,
|
|
130
|
-
): Promise<Map<string, MalloyConnection>> {
|
|
131
|
-
const map = new Map<string, MalloyConnection>();
|
|
132
|
-
const seen = new Set<string>();
|
|
133
|
-
for (const name of names) {
|
|
134
|
-
if (!name || seen.has(name)) continue;
|
|
135
|
-
seen.add(name);
|
|
136
|
-
try {
|
|
137
|
-
map.set(name, await pkg.getMalloyConnection(name));
|
|
138
|
-
} catch (err) {
|
|
139
|
-
logger.warn(`Failed to resolve connection ${name}`, {
|
|
140
|
-
error: err instanceof Error ? err.message : String(err),
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return map;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Allowed status transitions for the two-round protocol. MANIFEST_FILE_READY,
|
|
149
|
-
* FAILED, and CANCELLED are terminal.
|
|
80
|
+
* Allowed status transitions. The build runs without an intermediate
|
|
81
|
+
* plan-ready pause: PENDING advances straight to MANIFEST_ROWS_READY once the
|
|
82
|
+
* tables are built, then to MANIFEST_FILE_READY. MANIFEST_FILE_READY, FAILED,
|
|
83
|
+
* and CANCELLED are terminal.
|
|
150
84
|
*/
|
|
151
85
|
const VALID_TRANSITIONS: Record<
|
|
152
86
|
MaterializationStatus,
|
|
153
87
|
MaterializationStatus[]
|
|
154
88
|
> = {
|
|
155
|
-
PENDING: ["
|
|
156
|
-
BUILD_PLAN_READY: ["MANIFEST_ROWS_READY", "FAILED", "CANCELLED"],
|
|
89
|
+
PENDING: ["MANIFEST_ROWS_READY", "FAILED", "CANCELLED"],
|
|
157
90
|
MANIFEST_ROWS_READY: ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"],
|
|
158
91
|
MANIFEST_FILE_READY: [],
|
|
159
92
|
FAILED: [],
|
|
@@ -161,14 +94,16 @@ const VALID_TRANSITIONS: Record<
|
|
|
161
94
|
};
|
|
162
95
|
|
|
163
96
|
/**
|
|
164
|
-
* Orchestrates
|
|
97
|
+
* Orchestrates single-call materialization builds.
|
|
165
98
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
99
|
+
* The build plan is a deterministic property of the compiled package
|
|
100
|
+
* (`Package.buildPlan`), so there is no separate plan round-trip. On create
|
|
101
|
+
* the publisher either auto-runs (self-assigns physical names from the
|
|
102
|
+
* `#@ persist name=` annotation and builds + auto-loads every persist source)
|
|
103
|
+
* or, when the caller supplies `buildInstructions` derived from
|
|
104
|
+
* `Package.buildPlan`, builds directly into the caller-assigned names without
|
|
105
|
+
* auto-loading the manifest. Both paths build in the background and return the
|
|
106
|
+
* PENDING record immediately.
|
|
172
107
|
*
|
|
173
108
|
* At most one active materialization per (environment, package) is enforced
|
|
174
109
|
* by the DB-level unique index on `materializations.active_key` (see
|
|
@@ -210,8 +145,15 @@ export class MaterializationService {
|
|
|
210
145
|
);
|
|
211
146
|
}
|
|
212
147
|
this.validateTransition(current.status, next);
|
|
213
|
-
|
|
148
|
+
// Terminal transitions are operationally interesting (info); the
|
|
149
|
+
// intermediate MANIFEST_ROWS_READY hop is routine bookkeeping (debug).
|
|
150
|
+
const terminal =
|
|
151
|
+
next === "MANIFEST_FILE_READY" ||
|
|
152
|
+
next === "FAILED" ||
|
|
153
|
+
next === "CANCELLED";
|
|
154
|
+
logger[terminal ? "info" : "debug"]("Materialization transition", {
|
|
214
155
|
materializationId: id,
|
|
156
|
+
packageName: current.packageName,
|
|
215
157
|
from: current.status,
|
|
216
158
|
to: next,
|
|
217
159
|
});
|
|
@@ -255,11 +197,19 @@ export class MaterializationService {
|
|
|
255
197
|
return m;
|
|
256
198
|
}
|
|
257
199
|
|
|
258
|
-
// ====================
|
|
200
|
+
// ==================== CREATE + BUILD ====================
|
|
259
201
|
|
|
260
202
|
/**
|
|
261
|
-
* Create a materialization and
|
|
262
|
-
*
|
|
203
|
+
* Create a materialization and build it in the background. Returns the
|
|
204
|
+
* PENDING record immediately.
|
|
205
|
+
*
|
|
206
|
+
* Auto-run (default, no `buildInstructions`): self-assign physical names and
|
|
207
|
+
* build + auto-load every persist source. Orchestrated (`buildInstructions`
|
|
208
|
+
* present): build directly into the caller-assigned names from the package's
|
|
209
|
+
* build plan, without auto-loading the manifest. When `buildInstructions` is
|
|
210
|
+
* present it is validated synchronously against the package's compiled build
|
|
211
|
+
* plan so a bad instruction is rejected at create time rather than failing
|
|
212
|
+
* the background run.
|
|
263
213
|
*/
|
|
264
214
|
async createMaterialization(
|
|
265
215
|
environmentName: string,
|
|
@@ -267,7 +217,7 @@ export class MaterializationService {
|
|
|
267
217
|
options: {
|
|
268
218
|
forceRefresh?: boolean;
|
|
269
219
|
sourceNames?: string[];
|
|
270
|
-
|
|
220
|
+
buildInstructions?: BuildInstruction[];
|
|
271
221
|
} = {},
|
|
272
222
|
): Promise<Materialization> {
|
|
273
223
|
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
@@ -276,7 +226,13 @@ export class MaterializationService {
|
|
|
276
226
|
environmentName,
|
|
277
227
|
false,
|
|
278
228
|
);
|
|
279
|
-
await environment.getPackage(packageName, false);
|
|
229
|
+
const pkg = await environment.getPackage(packageName, false);
|
|
230
|
+
|
|
231
|
+
const buildInstructions = options.buildInstructions;
|
|
232
|
+
const orchestrated = buildInstructions !== undefined;
|
|
233
|
+
if (orchestrated) {
|
|
234
|
+
this.validateInstructions(pkg.getBuildPlan(), buildInstructions);
|
|
235
|
+
}
|
|
280
236
|
|
|
281
237
|
const active = await this.repository.getActiveMaterialization(
|
|
282
238
|
environmentId,
|
|
@@ -288,12 +244,11 @@ export class MaterializationService {
|
|
|
288
244
|
);
|
|
289
245
|
}
|
|
290
246
|
|
|
291
|
-
const pauseBetweenPhases = options.pauseBetweenPhases ?? false;
|
|
292
247
|
const forceRefresh = options.forceRefresh ?? false;
|
|
293
248
|
const metadata = {
|
|
294
249
|
forceRefresh,
|
|
295
250
|
sourceNames: options.sourceNames ?? null,
|
|
296
|
-
|
|
251
|
+
mode: orchestrated ? "orchestrated" : "auto",
|
|
297
252
|
};
|
|
298
253
|
|
|
299
254
|
let created: Materialization;
|
|
@@ -319,99 +274,48 @@ export class MaterializationService {
|
|
|
319
274
|
throw err;
|
|
320
275
|
}
|
|
321
276
|
|
|
322
|
-
// Default (auto-run): compile, self-assign names, build, and auto-load in
|
|
323
|
-
// one pass. Opt-in two-round: pause at BUILD_PLAN_READY for the control
|
|
324
|
-
// plane to drive Round 2.
|
|
325
277
|
this.runInBackground(created.id, (signal) =>
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
packageName,
|
|
338
|
-
options.sourceNames,
|
|
339
|
-
forceRefresh,
|
|
340
|
-
signal,
|
|
341
|
-
),
|
|
278
|
+
this.runBuild(
|
|
279
|
+
created.id,
|
|
280
|
+
environmentName,
|
|
281
|
+
packageName,
|
|
282
|
+
{
|
|
283
|
+
sourceNames: options.sourceNames,
|
|
284
|
+
forceRefresh,
|
|
285
|
+
buildInstructions,
|
|
286
|
+
},
|
|
287
|
+
signal,
|
|
288
|
+
),
|
|
342
289
|
);
|
|
343
290
|
|
|
344
291
|
return created;
|
|
345
292
|
}
|
|
346
293
|
|
|
347
|
-
private async runRound1(
|
|
348
|
-
id: string,
|
|
349
|
-
environmentName: string,
|
|
350
|
-
packageName: string,
|
|
351
|
-
sourceNames: string[] | undefined,
|
|
352
|
-
signal?: AbortSignal,
|
|
353
|
-
): Promise<void> {
|
|
354
|
-
logger.info("Materialization Round 1: compile + plan", {
|
|
355
|
-
materializationId: id,
|
|
356
|
-
packageName,
|
|
357
|
-
});
|
|
358
|
-
const startedAt = Date.now();
|
|
359
|
-
|
|
360
|
-
try {
|
|
361
|
-
const environment = await this.environmentStore.getEnvironment(
|
|
362
|
-
environmentName,
|
|
363
|
-
false,
|
|
364
|
-
);
|
|
365
|
-
const pkg = await environment.getPackage(packageName, false);
|
|
366
|
-
|
|
367
|
-
const { graphs, sources, connectionDigests } =
|
|
368
|
-
await environment.withPackageLock(packageName, () =>
|
|
369
|
-
this.compilePackageBuildPlan(pkg, signal),
|
|
370
|
-
);
|
|
371
|
-
|
|
372
|
-
const buildPlan = this.deriveBuildPlan(
|
|
373
|
-
graphs,
|
|
374
|
-
sources,
|
|
375
|
-
connectionDigests,
|
|
376
|
-
sourceNames,
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
await this.transition(id, "BUILD_PLAN_READY", { buildPlan });
|
|
380
|
-
this.recordRound("round1", "success", startedAt);
|
|
381
|
-
logger.info("Materialization Round 1 complete", {
|
|
382
|
-
materializationId: id,
|
|
383
|
-
packageName,
|
|
384
|
-
sourceCount: Object.keys(buildPlan.sources).length,
|
|
385
|
-
durationMs: Date.now() - startedAt,
|
|
386
|
-
});
|
|
387
|
-
} catch (err) {
|
|
388
|
-
this.recordRound("round1", outcomeFor(err, signal), startedAt);
|
|
389
|
-
throw err;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// ==================== AUTO-RUN (DEFAULT) ====================
|
|
394
|
-
|
|
395
294
|
/**
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
* no second round.
|
|
295
|
+
* Single-call build, shared by auto-run and orchestrated mode. Compiles the
|
|
296
|
+
* package build plan, derives the build instructions (self-assigned for
|
|
297
|
+
* auto-run; caller-supplied for orchestrated), builds the instructed sources
|
|
298
|
+
* into their physical tables, and commits the manifest. Auto-run additionally
|
|
299
|
+
* loads the fresh manifest into the package models; orchestrated leaves
|
|
300
|
+
* distribution to the caller.
|
|
403
301
|
*/
|
|
404
|
-
private async
|
|
302
|
+
private async runBuild(
|
|
405
303
|
id: string,
|
|
406
304
|
environmentName: string,
|
|
407
305
|
packageName: string,
|
|
408
|
-
|
|
409
|
-
|
|
306
|
+
opts: {
|
|
307
|
+
sourceNames: string[] | undefined;
|
|
308
|
+
forceRefresh: boolean;
|
|
309
|
+
buildInstructions: BuildInstruction[] | undefined;
|
|
310
|
+
},
|
|
410
311
|
signal: AbortSignal,
|
|
411
312
|
): Promise<void> {
|
|
412
|
-
|
|
313
|
+
const orchestrated = opts.buildInstructions !== undefined;
|
|
314
|
+
const mode: MaterializationMode = orchestrated ? "orchestrated" : "auto";
|
|
315
|
+
logger.info("Materialization build started", {
|
|
413
316
|
materializationId: id,
|
|
414
317
|
packageName,
|
|
318
|
+
mode,
|
|
415
319
|
});
|
|
416
320
|
const startedAt = Date.now();
|
|
417
321
|
|
|
@@ -424,31 +328,31 @@ export class MaterializationService {
|
|
|
424
328
|
const pkg = await environment.getPackage(packageName, false);
|
|
425
329
|
|
|
426
330
|
const compiled = await environment.withPackageLock(packageName, () =>
|
|
427
|
-
|
|
331
|
+
compilePackageBuildPlan(pkg, signal),
|
|
428
332
|
);
|
|
429
333
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
334
|
+
let instructions: BuildInstruction[];
|
|
335
|
+
let carried: Record<string, ManifestEntry>;
|
|
336
|
+
if (orchestrated) {
|
|
337
|
+
instructions = opts.buildInstructions!;
|
|
338
|
+
carried = {};
|
|
339
|
+
} else {
|
|
340
|
+
// Skip-if-unchanged: reuse tables from the most recent successful
|
|
341
|
+
// manifest for sources whose buildId is unchanged, unless
|
|
342
|
+
// forceRefresh.
|
|
343
|
+
const priorEntries = opts.forceRefresh
|
|
344
|
+
? {}
|
|
345
|
+
: await this.getMostRecentManifestEntries(
|
|
346
|
+
environmentId,
|
|
347
|
+
packageName,
|
|
348
|
+
id,
|
|
349
|
+
);
|
|
350
|
+
({ instructions, carried } = this.deriveSelfInstructions(
|
|
351
|
+
compiled,
|
|
352
|
+
opts.sourceNames,
|
|
353
|
+
priorEntries,
|
|
354
|
+
));
|
|
355
|
+
}
|
|
452
356
|
|
|
453
357
|
const entries = await this.executeInstructedBuild(
|
|
454
358
|
compiled,
|
|
@@ -457,28 +361,38 @@ export class MaterializationService {
|
|
|
457
361
|
signal,
|
|
458
362
|
);
|
|
459
363
|
|
|
364
|
+
const sourcesBuilt = instructions.length;
|
|
365
|
+
const sourcesReused = Object.keys(carried).length;
|
|
460
366
|
const durationMs = Date.now() - startedAt;
|
|
461
367
|
await this.commitManifest(id, entries, {
|
|
462
|
-
forceRefresh,
|
|
463
|
-
sourceNames: sourceNames ?? null,
|
|
464
|
-
|
|
465
|
-
sourcesBuilt
|
|
466
|
-
sourcesReused
|
|
368
|
+
forceRefresh: opts.forceRefresh,
|
|
369
|
+
sourceNames: opts.sourceNames ?? null,
|
|
370
|
+
mode,
|
|
371
|
+
sourcesBuilt,
|
|
372
|
+
sourcesReused,
|
|
467
373
|
durationMs,
|
|
468
374
|
});
|
|
469
375
|
|
|
470
|
-
|
|
376
|
+
// Auto-run owns distribution: load the fresh manifest into the package
|
|
377
|
+
// models so subsequent queries resolve to the materialized tables.
|
|
378
|
+
// Orchestrated leaves distribution to the caller (manifestLocation).
|
|
379
|
+
if (!orchestrated) {
|
|
380
|
+
await this.autoLoadManifest(environment, packageName, entries);
|
|
381
|
+
}
|
|
471
382
|
|
|
472
|
-
|
|
473
|
-
|
|
383
|
+
recordSourcesOutcome("built", sourcesBuilt);
|
|
384
|
+
recordSourcesOutcome("reused", sourcesReused);
|
|
385
|
+
this.recordRun(mode, "success", startedAt);
|
|
386
|
+
logger.info("Materialization build complete", {
|
|
474
387
|
materializationId: id,
|
|
475
388
|
packageName,
|
|
476
|
-
|
|
477
|
-
|
|
389
|
+
mode,
|
|
390
|
+
sourcesBuilt,
|
|
391
|
+
sourcesReused,
|
|
478
392
|
durationMs,
|
|
479
393
|
});
|
|
480
394
|
} catch (err) {
|
|
481
|
-
this.
|
|
395
|
+
this.recordRun(mode, outcomeFor(err, signal), startedAt);
|
|
482
396
|
throw err;
|
|
483
397
|
}
|
|
484
398
|
}
|
|
@@ -491,11 +405,7 @@ export class MaterializationService {
|
|
|
491
405
|
* rebuilt.
|
|
492
406
|
*/
|
|
493
407
|
private deriveSelfInstructions(
|
|
494
|
-
compiled:
|
|
495
|
-
graphs: MalloyBuildGraph[];
|
|
496
|
-
sources: Record<string, PersistSource>;
|
|
497
|
-
connectionDigests: Record<string, string>;
|
|
498
|
-
},
|
|
408
|
+
compiled: CompiledBuildPlan,
|
|
499
409
|
sourceNames: string[] | undefined,
|
|
500
410
|
priorEntries: Record<string, ManifestEntry>,
|
|
501
411
|
): {
|
|
@@ -591,11 +501,13 @@ export class MaterializationService {
|
|
|
591
501
|
packageName,
|
|
592
502
|
manifestEntries,
|
|
593
503
|
);
|
|
504
|
+
recordAutoLoadOutcome("success");
|
|
594
505
|
logger.info("Auto-run: loaded manifest into package models", {
|
|
595
506
|
packageName,
|
|
596
507
|
entryCount: Object.keys(manifestEntries).length,
|
|
597
508
|
});
|
|
598
509
|
} catch (err) {
|
|
510
|
+
recordAutoLoadOutcome("failure");
|
|
599
511
|
logger.warn("Auto-run: failed to load manifest into package models", {
|
|
600
512
|
packageName,
|
|
601
513
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -603,51 +515,21 @@ export class MaterializationService {
|
|
|
603
515
|
}
|
|
604
516
|
}
|
|
605
517
|
|
|
606
|
-
// ==================== ROUND 2: BUILD ====================
|
|
607
|
-
|
|
608
518
|
/**
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
519
|
+
* Validate caller-supplied build instructions against the package's compiled
|
|
520
|
+
* build plan: every instructed buildId must be a planned source, and only
|
|
521
|
+
* COPY realization is supported. Throws when the package declares no persist
|
|
522
|
+
* source (no plan to build against).
|
|
612
523
|
*/
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
packageName: string,
|
|
616
|
-
id: string,
|
|
524
|
+
private validateInstructions(
|
|
525
|
+
plan: BuildPlan | null,
|
|
617
526
|
instructions: BuildInstruction[],
|
|
618
|
-
):
|
|
619
|
-
const m = await this.getMaterialization(environmentName, packageName, id);
|
|
620
|
-
|
|
621
|
-
if (!m.pauseBetweenPhases) {
|
|
622
|
-
throw new InvalidStateTransitionError(
|
|
623
|
-
`Materialization ${id} is an auto-run materialization; action=build does not apply (set pauseBetweenPhases=true for the two-round flow)`,
|
|
624
|
-
);
|
|
625
|
-
}
|
|
626
|
-
if (m.status !== "BUILD_PLAN_READY") {
|
|
627
|
-
throw new InvalidStateTransitionError(
|
|
628
|
-
`Materialization ${id} is ${m.status}, expected BUILD_PLAN_READY`,
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
const plan = m.buildPlan;
|
|
527
|
+
): void {
|
|
632
528
|
if (!plan) {
|
|
633
|
-
throw new
|
|
634
|
-
|
|
529
|
+
throw new BadRequestError(
|
|
530
|
+
"Package has no persist sources; buildInstructions cannot be applied",
|
|
635
531
|
);
|
|
636
532
|
}
|
|
637
|
-
|
|
638
|
-
this.validateInstructions(plan, instructions);
|
|
639
|
-
|
|
640
|
-
this.runInBackground(id, (signal) =>
|
|
641
|
-
this.runRound2(id, environmentName, packageName, instructions, signal),
|
|
642
|
-
);
|
|
643
|
-
|
|
644
|
-
return m;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
private validateInstructions(
|
|
648
|
-
plan: BuildPlan,
|
|
649
|
-
instructions: BuildInstruction[],
|
|
650
|
-
): void {
|
|
651
533
|
const plannedBuildIds = new Set<string>();
|
|
652
534
|
for (const source of Object.values(plan.sources)) {
|
|
653
535
|
plannedBuildIds.add(source.buildId);
|
|
@@ -659,80 +541,24 @@ export class MaterializationService {
|
|
|
659
541
|
`Instruction references unknown buildId '${instruction.buildId}'`,
|
|
660
542
|
);
|
|
661
543
|
}
|
|
662
|
-
//
|
|
544
|
+
// COPY-only for now; SNAPSHOT lands once clone semantics are defined.
|
|
663
545
|
if (instruction.realization === "SNAPSHOT") {
|
|
664
546
|
throw new BadRequestError(
|
|
665
|
-
"realization=SNAPSHOT is not supported
|
|
547
|
+
"realization=SNAPSHOT is not supported (COPY only)",
|
|
666
548
|
);
|
|
667
549
|
}
|
|
668
550
|
}
|
|
669
551
|
}
|
|
670
552
|
|
|
671
|
-
private async runRound2(
|
|
672
|
-
id: string,
|
|
673
|
-
environmentName: string,
|
|
674
|
-
packageName: string,
|
|
675
|
-
instructions: BuildInstruction[],
|
|
676
|
-
signal: AbortSignal,
|
|
677
|
-
): Promise<void> {
|
|
678
|
-
logger.info("Materialization Round 2: build", {
|
|
679
|
-
materializationId: id,
|
|
680
|
-
packageName,
|
|
681
|
-
sourceCount: instructions.length,
|
|
682
|
-
});
|
|
683
|
-
const startedAt = Date.now();
|
|
684
|
-
|
|
685
|
-
try {
|
|
686
|
-
const environment = await this.environmentStore.getEnvironment(
|
|
687
|
-
environmentName,
|
|
688
|
-
false,
|
|
689
|
-
);
|
|
690
|
-
const pkg = await environment.getPackage(packageName, false);
|
|
691
|
-
|
|
692
|
-
const compiled = await environment.withPackageLock(packageName, () =>
|
|
693
|
-
this.compilePackageBuildPlan(pkg, signal),
|
|
694
|
-
);
|
|
695
|
-
|
|
696
|
-
const entries = await this.executeInstructedBuild(
|
|
697
|
-
compiled,
|
|
698
|
-
instructions,
|
|
699
|
-
{},
|
|
700
|
-
signal,
|
|
701
|
-
);
|
|
702
|
-
|
|
703
|
-
const durationMs = Date.now() - startedAt;
|
|
704
|
-
await this.commitManifest(id, entries, {
|
|
705
|
-
sourcesBuilt: Object.keys(entries).length,
|
|
706
|
-
durationMs,
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
this.recordRound("round2", "success", startedAt);
|
|
710
|
-
logger.info("Materialization Round 2 complete", {
|
|
711
|
-
materializationId: id,
|
|
712
|
-
packageName,
|
|
713
|
-
sourcesBuilt: Object.keys(entries).length,
|
|
714
|
-
durationMs,
|
|
715
|
-
});
|
|
716
|
-
} catch (err) {
|
|
717
|
-
this.recordRound("round2", outcomeFor(err, signal), startedAt);
|
|
718
|
-
throw err;
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
|
|
722
553
|
/**
|
|
723
|
-
* Shared build loop for both auto-run and
|
|
724
|
-
* carried-forward (reused) upstream entries so downstream
|
|
725
|
-
* then builds each instructed source in dependency
|
|
726
|
-
* entry map (carried + freshly built). The package
|
|
727
|
-
* caller; this runs outside the package lock.
|
|
554
|
+
* Shared build loop for both auto-run and orchestrated builds. Seeds the
|
|
555
|
+
* manifest with carried-forward (reused) upstream entries so downstream
|
|
556
|
+
* references resolve, then builds each instructed source in dependency
|
|
557
|
+
* order. Returns the full entry map (carried + freshly built). The package
|
|
558
|
+
* was compiled by the caller; this runs outside the package lock.
|
|
728
559
|
*/
|
|
729
560
|
private async executeInstructedBuild(
|
|
730
|
-
compiled:
|
|
731
|
-
graphs: MalloyBuildGraph[];
|
|
732
|
-
sources: Record<string, PersistSource>;
|
|
733
|
-
connectionDigests: Record<string, string>;
|
|
734
|
-
connections: Map<string, MalloyConnection>;
|
|
735
|
-
},
|
|
561
|
+
compiled: CompiledBuildPlan,
|
|
736
562
|
instructions: BuildInstruction[],
|
|
737
563
|
seedEntries: Record<string, ManifestEntry>,
|
|
738
564
|
signal: AbortSignal,
|
|
@@ -825,7 +651,7 @@ export class MaterializationService {
|
|
|
825
651
|
await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
|
|
826
652
|
} catch (cleanupErr) {
|
|
827
653
|
logger.warn(
|
|
828
|
-
"
|
|
654
|
+
"Failed to clean up staging table after a failed build; physical leak",
|
|
829
655
|
{
|
|
830
656
|
stagingTableName,
|
|
831
657
|
connectionName: persistSource.connectionName,
|
|
@@ -839,12 +665,11 @@ export class MaterializationService {
|
|
|
839
665
|
throw err;
|
|
840
666
|
}
|
|
841
667
|
|
|
842
|
-
// Make this table visible to downstream sources built later this
|
|
668
|
+
// Make this table visible to downstream sources built later in this run.
|
|
843
669
|
manifest.update(buildId, { tableName: physicalTableName });
|
|
844
670
|
|
|
845
671
|
const durationMs = Math.round(performance.now() - startTime);
|
|
846
672
|
recordSourceBuildDuration(durationMs);
|
|
847
|
-
// Shared by auto-run and Round 2, so the message is path-neutral.
|
|
848
673
|
logger.info(`Built materialized source ${persistSource.name}`, {
|
|
849
674
|
physicalTableName,
|
|
850
675
|
durationMs,
|
|
@@ -873,7 +698,6 @@ export class MaterializationService {
|
|
|
873
698
|
|
|
874
699
|
const cancellable: MaterializationStatus[] = [
|
|
875
700
|
"PENDING",
|
|
876
|
-
"BUILD_PLAN_READY",
|
|
877
701
|
"MANIFEST_ROWS_READY",
|
|
878
702
|
];
|
|
879
703
|
if (!cancellable.includes(m.status)) {
|
|
@@ -898,10 +722,11 @@ export class MaterializationService {
|
|
|
898
722
|
* (MANIFEST_FILE_READY, FAILED, CANCELLED) can be deleted; an active run must
|
|
899
723
|
* be stopped first.
|
|
900
724
|
*
|
|
901
|
-
* By default this removes the publisher's record only —
|
|
902
|
-
*
|
|
903
|
-
* the physical tables recorded in this run's manifest as a
|
|
904
|
-
* cleanup (a drop failure is logged, not fatal, so the record
|
|
725
|
+
* By default this removes the publisher's record only — physical-table GC is
|
|
726
|
+
* the caller's responsibility. When `dropTables` is set, the publisher
|
|
727
|
+
* additionally drops the physical tables recorded in this run's manifest as a
|
|
728
|
+
* best-effort cleanup (a drop failure is logged, not fatal, so the record
|
|
729
|
+
* still deletes).
|
|
905
730
|
*/
|
|
906
731
|
async deleteMaterialization(
|
|
907
732
|
environmentName: string,
|
|
@@ -999,9 +824,9 @@ export class MaterializationService {
|
|
|
999
824
|
}
|
|
1000
825
|
|
|
1001
826
|
/**
|
|
1002
|
-
* Finalize a successful build: advance MANIFEST_ROWS_READY
|
|
1003
|
-
* persist the assembled manifest.
|
|
1004
|
-
* supplies the per-
|
|
827
|
+
* Finalize a successful build: advance to MANIFEST_ROWS_READY then
|
|
828
|
+
* MANIFEST_FILE_READY and persist the assembled manifest. The caller
|
|
829
|
+
* supplies the per-run metadata. The build itself happens before this.
|
|
1005
830
|
*/
|
|
1006
831
|
private async commitManifest(
|
|
1007
832
|
id: string,
|
|
@@ -1021,125 +846,14 @@ export class MaterializationService {
|
|
|
1021
846
|
});
|
|
1022
847
|
}
|
|
1023
848
|
|
|
1024
|
-
// ==================== BUILD PLAN COMPILATION ====================
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* Compile every model in the package and collect the dependency-ordered
|
|
1028
|
-
* build graphs, persist sources, connection digests, and resolved
|
|
1029
|
-
* connections.
|
|
1030
|
-
*/
|
|
1031
|
-
private async compilePackageBuildPlan(
|
|
1032
|
-
pkg: {
|
|
1033
|
-
getModelPaths(): string[];
|
|
1034
|
-
getPackagePath(): string;
|
|
1035
|
-
getMalloyConfig(): MalloyConfig;
|
|
1036
|
-
getMalloyConnection(name: string): Promise<MalloyConnection>;
|
|
1037
|
-
},
|
|
1038
|
-
signal?: AbortSignal,
|
|
1039
|
-
): Promise<{
|
|
1040
|
-
graphs: MalloyBuildGraph[];
|
|
1041
|
-
sources: Record<string, PersistSource>;
|
|
1042
|
-
connectionDigests: Record<string, string>;
|
|
1043
|
-
connections: Map<string, MalloyConnection>;
|
|
1044
|
-
}> {
|
|
1045
|
-
const allGraphs: MalloyBuildGraph[] = [];
|
|
1046
|
-
const allSources: Record<string, PersistSource> = {};
|
|
1047
|
-
|
|
1048
|
-
for (const modelPath of pkg.getModelPaths()) {
|
|
1049
|
-
if (signal?.aborted) throw new Error("Build cancelled");
|
|
1050
|
-
|
|
1051
|
-
const { runtime, modelURL, importBaseURL } =
|
|
1052
|
-
await Model.getModelRuntime(
|
|
1053
|
-
pkg.getPackagePath(),
|
|
1054
|
-
modelPath,
|
|
1055
|
-
pkg.getMalloyConfig(),
|
|
1056
|
-
);
|
|
1057
|
-
const malloyModel = await runtime
|
|
1058
|
-
.loadModel(modelURL, { importBaseURL })
|
|
1059
|
-
.getModel();
|
|
1060
|
-
|
|
1061
|
-
// getBuildPlan() returns empty graphs for models with no #@ persist
|
|
1062
|
-
// sources, so non-persist models are simply skipped below.
|
|
1063
|
-
const buildPlan = malloyModel.getBuildPlan();
|
|
1064
|
-
for (const msg of buildPlan.tagParseLog) {
|
|
1065
|
-
logger.warn("Persist annotation issue", {
|
|
1066
|
-
modelPath,
|
|
1067
|
-
message: msg.message,
|
|
1068
|
-
severity: msg.severity,
|
|
1069
|
-
});
|
|
1070
|
-
}
|
|
1071
|
-
if (buildPlan.graphs.length === 0) continue;
|
|
1072
|
-
|
|
1073
|
-
allGraphs.push(...buildPlan.graphs);
|
|
1074
|
-
for (const [sourceID, source] of Object.entries(buildPlan.sources)) {
|
|
1075
|
-
allSources[sourceID] = source;
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
const connections = await resolvePackageConnections(
|
|
1080
|
-
pkg,
|
|
1081
|
-
allGraphs.map((g) => g.connectionName),
|
|
1082
|
-
);
|
|
1083
|
-
const connectionDigests: Record<string, string> = {};
|
|
1084
|
-
for (const graph of allGraphs) {
|
|
1085
|
-
const conn = connections.get(graph.connectionName);
|
|
1086
|
-
if (conn && !connectionDigests[graph.connectionName]) {
|
|
1087
|
-
connectionDigests[graph.connectionName] = await conn.getDigest();
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
return {
|
|
1092
|
-
graphs: allGraphs,
|
|
1093
|
-
sources: allSources,
|
|
1094
|
-
connectionDigests,
|
|
1095
|
-
connections,
|
|
1096
|
-
};
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
/** Project the Malloy build plan into the trimmed v0 wire BuildPlan. */
|
|
1100
|
-
private deriveBuildPlan(
|
|
1101
|
-
graphs: MalloyBuildGraph[],
|
|
1102
|
-
sources: Record<string, PersistSource>,
|
|
1103
|
-
connectionDigests: Record<string, string>,
|
|
1104
|
-
sourceNames: string[] | undefined,
|
|
1105
|
-
): BuildPlan {
|
|
1106
|
-
const include = sourceNames ? new Set(sourceNames) : null;
|
|
1107
|
-
|
|
1108
|
-
const wireGraphs: WireBuildGraph[] = graphs.map((graph) => ({
|
|
1109
|
-
connectionName: graph.connectionName,
|
|
1110
|
-
nodes: graph.nodes.map((level) =>
|
|
1111
|
-
level.map((node) => ({
|
|
1112
|
-
sourceID: node.sourceID,
|
|
1113
|
-
dependsOn: flattenDependsOn(node),
|
|
1114
|
-
})),
|
|
1115
|
-
),
|
|
1116
|
-
}));
|
|
1117
|
-
|
|
1118
|
-
const wireSources: Record<string, WirePersistSourcePlan> = {};
|
|
1119
|
-
for (const [sourceID, source] of Object.entries(sources)) {
|
|
1120
|
-
if (include && !include.has(source.name)) continue;
|
|
1121
|
-
wireSources[sourceID] = {
|
|
1122
|
-
name: source.name,
|
|
1123
|
-
sourceID: source.sourceID,
|
|
1124
|
-
connectionName: source.connectionName,
|
|
1125
|
-
dialect: source.dialectName,
|
|
1126
|
-
buildId: computeBuildId(source, connectionDigests),
|
|
1127
|
-
sql: source.getSQL(),
|
|
1128
|
-
columns: deriveColumns(source),
|
|
1129
|
-
};
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
return { graphs: wireGraphs, sources: wireSources };
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
849
|
// ==================== HELPERS ====================
|
|
1136
850
|
|
|
1137
|
-
private
|
|
1138
|
-
|
|
851
|
+
private recordRun(
|
|
852
|
+
mode: MaterializationMode,
|
|
1139
853
|
outcome: "success" | "failed" | "cancelled",
|
|
1140
854
|
startedAtMs: number,
|
|
1141
855
|
): void {
|
|
1142
|
-
|
|
856
|
+
recordMaterializationRun(mode, outcome, Date.now() - startedAtMs);
|
|
1143
857
|
}
|
|
1144
858
|
|
|
1145
859
|
private runInBackground(
|