@malloy-publisher/server 0.0.205 → 0.0.207

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/app/api-doc.yaml +394 -395
  2. package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
  3. package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
  4. package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
  6. package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
  8. package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
  10. package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
  11. package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
  12. package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
  13. package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
  14. package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +18 -8
  17. package/dist/package_load_worker.mjs +19 -2
  18. package/dist/server.mjs +1272 -1299
  19. package/package.json +1 -1
  20. package/src/constants.ts +12 -0
  21. package/src/controller/materialization.controller.ts +79 -35
  22. package/src/controller/package.controller.spec.ts +179 -0
  23. package/src/controller/package.controller.ts +60 -73
  24. package/src/dto/package.dto.ts +16 -1
  25. package/src/errors.spec.ts +12 -0
  26. package/src/errors.ts +18 -0
  27. package/src/health.spec.ts +34 -1
  28. package/src/instrumentation.ts +33 -17
  29. package/src/materialization_metrics.ts +121 -0
  30. package/src/package_load/package_load_pool.ts +7 -1
  31. package/src/package_load/package_load_worker.ts +44 -4
  32. package/src/package_load/protocol.ts +7 -1
  33. package/src/server-old.ts +7 -149
  34. package/src/server.ts +9 -188
  35. package/src/service/authorize_integration.spec.ts +67 -0
  36. package/src/service/environment.ts +270 -12
  37. package/src/service/environment_store.spec.ts +0 -81
  38. package/src/service/environment_store.ts +0 -23
  39. package/src/service/explore_visibility.spec.ts +434 -0
  40. package/src/service/exports_probe.spec.ts +107 -0
  41. package/src/service/manifest_loader.spec.ts +99 -0
  42. package/src/service/manifest_loader.ts +95 -0
  43. package/src/service/materialization_service.spec.ts +584 -500
  44. package/src/service/materialization_service.ts +839 -657
  45. package/src/service/model.ts +419 -15
  46. package/src/service/package.spec.ts +14 -2
  47. package/src/service/package.ts +339 -29
  48. package/src/service/package_rollback.spec.ts +190 -0
  49. package/src/service/package_worker_path.spec.ts +223 -0
  50. package/src/service/query_boundary.spec.ts +470 -0
  51. package/src/storage/DatabaseInterface.ts +35 -57
  52. package/src/storage/StorageManager.mock.ts +0 -9
  53. package/src/storage/StorageManager.ts +7 -290
  54. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  55. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  56. package/src/storage/duckdb/schema.ts +4 -20
  57. package/tests/integration/materialization/manifest_binding.integration.spec.ts +194 -0
  58. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
  59. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  60. package/src/controller/manifest.controller.ts +0 -38
  61. package/src/service/manifest_service.spec.ts +0 -206
  62. package/src/service/manifest_service.ts +0 -117
  63. package/src/service/materialized_table_gc.spec.ts +0 -384
  64. package/src/service/materialized_table_gc.ts +0 -231
  65. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  66. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  67. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  68. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  69. package/tests/unit/storage/StorageManager.test.ts +0 -166
@@ -1,10 +1,12 @@
1
1
  import type {
2
- BuildGraph,
2
+ AtomicField,
3
+ BuildGraph as MalloyBuildGraph,
3
4
  MalloyConfig,
4
5
  Connection as MalloyConnection,
5
6
  PersistSource,
6
7
  } from "@malloydata/malloy";
7
8
  import { Manifest } from "@malloydata/malloy";
9
+ import { components } from "../api";
8
10
  import {
9
11
  BadRequestError,
10
12
  InvalidStateTransitionError,
@@ -12,48 +14,115 @@ import {
12
14
  MaterializationNotFoundError,
13
15
  } from "../errors";
14
16
  import { logger } from "../logger";
15
- import type { ManifestEntry } from "../storage/DatabaseInterface";
16
17
  import {
18
+ MaterializationRound,
19
+ recordDropTables,
20
+ recordMaterializationRound,
21
+ recordSourceBuildDuration,
22
+ } from "../materialization_metrics";
23
+ import {
24
+ BuildInstruction,
25
+ BuildManifest,
26
+ BuildManifestResult,
27
+ BuildPlan,
17
28
  Materialization,
18
29
  MaterializationStatus,
30
+ MaterializationUpdate,
31
+ ManifestEntry,
19
32
  ResourceRepository,
20
33
  } from "../storage/DatabaseInterface";
21
34
  import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
22
35
  import { EnvironmentStore } from "./environment_store";
23
- import { ManifestService } from "./manifest_service";
24
- import {
25
- dropManifestEntries,
26
- GcResult,
27
- liveTableKey,
28
- } from "./materialized_table_gc";
29
36
  import { Model } from "./model";
30
37
  import { splitTablePath } from "./quoting";
31
38
  import { resolveEnvironmentId } from "./resolve_environment";
32
39
 
40
+ type WireBuildGraph = components["schemas"]["BuildGraph"];
41
+ type WirePersistSourcePlan = components["schemas"]["PersistSourcePlan"];
42
+ type WireColumn = components["schemas"]["Column"];
43
+
33
44
  /**
34
- * Length of the BuildID prefix used when synthesizing staging table names.
35
- * BuildID is a 64-char SHA-256 hex string; 12 hex chars is 48 bits of entropy
36
- * plenty of uniqueness per source, and keeps the final identifier well
37
- * inside every dialect's limit (Postgres is the tightest at 63).
45
+ * Length of the buildId prefix used when synthesizing staging table names.
46
+ * buildId is a 64-char SHA-256 hex string; 12 hex chars is 48 bits of
47
+ * entropy, well inside every dialect's identifier limit (Postgres is the
48
+ * tightest at 63).
38
49
  */
39
50
  const STAGING_BUILD_ID_LEN = 12;
40
51
 
41
- /**
42
- * Return the staging suffix `_<truncatedBuildId>` appended to a table name
43
- * while it is being built. The suffix is fully derivable from a manifest
44
- * entry's `buildId`, which is how GC finds and drops orphaned staging tables.
45
- */
52
+ /** Staging suffix appended to a table name while it is being built. */
46
53
  export function stagingSuffix(buildId: string): string {
47
54
  return `_${buildId.substring(0, STAGING_BUILD_ID_LEN)}`;
48
55
  }
49
56
 
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
+
50
82
  /**
51
- * Resolve a Map<name, Connection> for just the names a materialization
52
- * step is about to touch. The package's MalloyConfig caches each lookup,
53
- * so subsequent calls with overlapping names are cheap. A failed lookup
54
- * is logged and the name is omitted from the result — downstream code
55
- * (`forceDeleteRowOnMissingConnection` in teardown, or the explicit
56
- * "connection X not found" check in build) handles a missing entry.
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
+ /**
98
+ * Physical table name the publisher self-assigns in auto-run mode: the
99
+ * `#@ persist name=<table>` value if present, else the Malloy source name.
100
+ * The author owns quoting the `name=` value for the dialect.
101
+ */
102
+ function selfAssignTableName(persistSource: PersistSource): string {
103
+ try {
104
+ return (
105
+ persistSource.annotations.parseAsTag("@").tag.text("name") ||
106
+ persistSource.name
107
+ );
108
+ } catch {
109
+ return persistSource.name;
110
+ }
111
+ }
112
+
113
+ /** Classify a thrown round error as cancelled (cooperative abort) or failed. */
114
+ function outcomeFor(
115
+ _err: unknown,
116
+ signal: AbortSignal | undefined,
117
+ ): "failed" | "cancelled" {
118
+ return signal?.aborted ? "cancelled" : "failed";
119
+ }
120
+
121
+ /**
122
+ * Resolve a Map<name, Connection> for the names a step is about to touch.
123
+ * The package's MalloyConfig caches each lookup, so repeated calls are cheap.
124
+ * A failed lookup is logged and omitted; downstream code reports the missing
125
+ * connection explicitly.
57
126
  */
58
127
  async function resolvePackageConnections(
59
128
  pkg: { getMalloyConnection(name: string): Promise<MalloyConnection> },
@@ -76,87 +145,41 @@ async function resolvePackageConnections(
76
145
  }
77
146
 
78
147
  /**
79
- * Build a stable key for a `(connectionName, tableName)` pair.
80
- * Used to check whether a persist target was created by a previous build.
81
- */
82
- export function manifestTableKey(
83
- connectionName: string,
84
- tableName: string,
85
- ): string {
86
- return `${connectionName}::${tableName}`;
87
- }
88
-
89
- /**
90
- * Probe whether a table physically exists on the given connection by
91
- * running a zero-row SELECT. Returns `true` if the table resolves,
92
- * `false` if the query fails (assumed "table not found").
93
- */
94
- /**
95
- * `tableName` is interpolated verbatim into the probe SQL — the caller
96
- * supplies it already quoted for the dialect (the `#@ persist name=…`
97
- * contract), matching how Malloy substitutes the name on the read side.
98
- */
99
- export async function tablePhysicallyExists(
100
- connection: MalloyConnection,
101
- tableName: string,
102
- ): Promise<boolean> {
103
- try {
104
- await connection.runSQL(`SELECT 1 FROM ${tableName} WHERE 1=0`);
105
- return true;
106
- } catch {
107
- return false;
108
- }
109
- }
110
-
111
- /**
112
- * Allowed execution status transitions. SUCCESS, FAILED, and CANCELLED are
113
- * terminal — once an execution reaches one of these states it is immutable.
148
+ * Allowed status transitions for the two-round protocol. MANIFEST_FILE_READY,
149
+ * FAILED, and CANCELLED are terminal.
114
150
  */
115
151
  const VALID_TRANSITIONS: Record<
116
152
  MaterializationStatus,
117
153
  MaterializationStatus[]
118
154
  > = {
119
- PENDING: ["RUNNING", "CANCELLED"],
120
- RUNNING: ["SUCCESS", "FAILED", "CANCELLED"],
121
- SUCCESS: [],
155
+ PENDING: ["BUILD_PLAN_READY", "FAILED", "CANCELLED"],
156
+ BUILD_PLAN_READY: ["MANIFEST_ROWS_READY", "FAILED", "CANCELLED"],
157
+ MANIFEST_ROWS_READY: ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"],
158
+ MANIFEST_FILE_READY: [],
122
159
  FAILED: [],
123
160
  CANCELLED: [],
124
161
  };
125
162
 
126
163
  /**
127
- * Orchestrates package-level materialization builds: triggering builds,
128
- * cancellation, and the actual Malloy build that materializes persist
129
- * sources into database tables.
130
- *
131
- * A build targets an entire package — all models are compiled and all
132
- * persist sources across all models are processed in dependency order.
133
- * The manifest is optionally activated after a successful build so
134
- * subsequent queries resolve persist references to materialized tables.
164
+ * Orchestrates the two-round materialization protocol.
135
165
  *
136
- * Enforces at-most-one concurrent build per (environment, package) via a
137
- * DB-level unique index on `materializations.active_key` (see
138
- * `MaterializationRepository`), and supports cooperative cancellation
139
- * through `AbortController`.
166
+ * Round 1 (on create): compile the package, compute a build plan (per-source
167
+ * buildId, columns, lineage inputs, connection capability) and pause at
168
+ * BUILD_PLAN_READY without writing any tables. Round 2 (action=build): build
169
+ * only the control-plane-instructed sources into their assigned physical
170
+ * names and assemble the manifest, which is returned inline for the control
171
+ * plane to persist.
140
172
  *
141
- * **Multi-worker caveat:** the `materializations` table lives in each
142
- * worker's *local* DuckDB, so the active-materialization lock is only
143
- * enforced within a single Publisher process. In orchestrated deployments
144
- * (shared DuckLake manifest catalog), builds must be externally
145
- * single-writer until a shared lease is added — see the scope note on
146
- * `DuckLakeManifestStore`.
173
+ * At most one active materialization per (environment, package) is enforced
174
+ * by the DB-level unique index on `materializations.active_key` (see
175
+ * {@link MaterializationRepository}). Cancellation is cooperative via
176
+ * AbortController.
147
177
  */
148
178
  export class MaterializationService {
149
- /**
150
- * Tracks in-flight executions so they can be cancelled. This map only
151
- * lives in-process memory — entries are lost on server restart, which is
152
- * why `stopMaterialization` has an orphaned-execution fallback path.
153
- */
179
+ /** In-flight runs, so they can be cancelled. In-process only. */
154
180
  private runningAbortControllers = new Map<string, AbortController>();
155
181
 
156
- constructor(
157
- private environmentStore: EnvironmentStore,
158
- private manifestService: ManifestService,
159
- ) {}
182
+ constructor(private environmentStore: EnvironmentStore) {}
160
183
 
161
184
  private get repository(): ResourceRepository {
162
185
  return this.environmentStore.storageManager.getRepository();
@@ -168,39 +191,37 @@ export class MaterializationService {
168
191
  current: MaterializationStatus,
169
192
  next: MaterializationStatus,
170
193
  ): void {
171
- const allowed = VALID_TRANSITIONS[current];
172
- if (!allowed.includes(next)) {
194
+ if (!VALID_TRANSITIONS[current].includes(next)) {
173
195
  throw new InvalidStateTransitionError(
174
196
  `Cannot transition from ${current} to ${next}`,
175
197
  );
176
198
  }
177
199
  }
178
200
 
179
- private async transitionExecution(
180
- executionId: string,
181
- newStatus: MaterializationStatus,
182
- extra?: {
183
- startedAt?: Date;
184
- completedAt?: Date;
185
- error?: string | null;
186
- metadata?: Record<string, unknown> | null;
187
- },
201
+ private async transition(
202
+ id: string,
203
+ next: MaterializationStatus,
204
+ extra?: Omit<MaterializationUpdate, "status">,
188
205
  ): Promise<Materialization> {
189
- const execution =
190
- await this.repository.getMaterializationById(executionId);
191
- if (!execution) {
206
+ const current = await this.repository.getMaterializationById(id);
207
+ if (!current) {
192
208
  throw new MaterializationNotFoundError(
193
- `Execution ${executionId} not found`,
209
+ `Materialization ${id} not found`,
194
210
  );
195
211
  }
196
- this.validateTransition(execution.status, newStatus);
197
- return this.repository.updateMaterialization(executionId, {
198
- status: newStatus,
212
+ this.validateTransition(current.status, next);
213
+ logger.info("Materialization transition", {
214
+ materializationId: id,
215
+ from: current.status,
216
+ to: next,
217
+ });
218
+ return this.repository.updateMaterialization(id, {
219
+ status: next,
199
220
  ...extra,
200
221
  });
201
222
  }
202
223
 
203
- // ==================== BUILD QUERIES ====================
224
+ // ==================== QUERIES ====================
204
225
 
205
226
  async listMaterializations(
206
227
  environmentName: string,
@@ -218,45 +239,45 @@ export class MaterializationService {
218
239
  async getMaterialization(
219
240
  environmentName: string,
220
241
  packageName: string,
221
- buildId: string,
242
+ id: string,
222
243
  ): Promise<Materialization> {
223
244
  const environmentId = await this.resolveEnvironmentId(environmentName);
224
- const execution = await this.repository.getMaterializationById(buildId);
245
+ const m = await this.repository.getMaterializationById(id);
225
246
  if (
226
- !execution ||
227
- execution.environmentId !== environmentId ||
228
- execution.packageName !== packageName
247
+ !m ||
248
+ m.environmentId !== environmentId ||
249
+ m.packageName !== packageName
229
250
  ) {
230
251
  throw new MaterializationNotFoundError(
231
- `Materialization ${buildId} not found for package ${packageName}`,
252
+ `Materialization ${id} not found for package ${packageName}`,
232
253
  );
233
254
  }
234
- return execution;
255
+ return m;
235
256
  }
236
257
 
237
- // ==================== BUILD LIFECYCLE ====================
258
+ // ==================== ROUND 1: CREATE + PLAN ====================
238
259
 
239
260
  /**
240
- * Creates a new build in PENDING state. Build options are stored in
241
- * metadata so `startMaterialization` can read them back.
261
+ * Create a materialization and start Round 1 (compile + plan) in the
262
+ * background. Returns the PENDING record immediately.
242
263
  */
243
264
  async createMaterialization(
244
265
  environmentName: string,
245
266
  packageName: string,
246
- options: { forceRefresh?: boolean; autoLoadManifest?: boolean } = {},
267
+ options: {
268
+ forceRefresh?: boolean;
269
+ sourceNames?: string[];
270
+ pauseBetweenPhases?: boolean;
271
+ } = {},
247
272
  ): Promise<Materialization> {
248
273
  const environmentId = await this.resolveEnvironmentId(environmentName);
249
274
 
250
- // Verify the package exists.
251
275
  const environment = await this.environmentStore.getEnvironment(
252
276
  environmentName,
253
277
  false,
254
278
  );
255
279
  await environment.getPackage(packageName, false);
256
280
 
257
- // A non-atomic probe for a helpful error message. The DB-level unique
258
- // index on active_key is the actual race-free guard — see the catch
259
- // block below.
260
281
  const active = await this.repository.getActiveMaterialization(
261
282
  environmentId,
262
283
  packageName,
@@ -267,13 +288,17 @@ export class MaterializationService {
267
288
  );
268
289
  }
269
290
 
291
+ const pauseBetweenPhases = options.pauseBetweenPhases ?? false;
292
+ const forceRefresh = options.forceRefresh ?? false;
270
293
  const metadata = {
271
- forceRefresh: options.forceRefresh ?? false,
272
- autoLoadManifest: options.autoLoadManifest ?? false,
294
+ forceRefresh,
295
+ sourceNames: options.sourceNames ?? null,
296
+ pauseBetweenPhases,
273
297
  };
274
298
 
299
+ let created: Materialization;
275
300
  try {
276
- return await this.repository.createMaterialization(
301
+ created = await this.repository.createMaterialization(
277
302
  environmentId,
278
303
  packageName,
279
304
  "PENDING",
@@ -281,8 +306,6 @@ export class MaterializationService {
281
306
  );
282
307
  } catch (err) {
283
308
  if (err instanceof DuplicateActiveMaterializationError) {
284
- // Lost the race with a concurrent create. Re-read to report the
285
- // winner's id for parity with the non-racy error above.
286
309
  const winner = await this.repository.getActiveMaterialization(
287
310
  environmentId,
288
311
  packageName,
@@ -295,343 +318,443 @@ export class MaterializationService {
295
318
  }
296
319
  throw err;
297
320
  }
321
+
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
+ this.runInBackground(created.id, (signal) =>
326
+ pauseBetweenPhases
327
+ ? this.runRound1(
328
+ created.id,
329
+ environmentName,
330
+ packageName,
331
+ options.sourceNames,
332
+ signal,
333
+ )
334
+ : this.runAuto(
335
+ created.id,
336
+ environmentName,
337
+ packageName,
338
+ options.sourceNames,
339
+ forceRefresh,
340
+ signal,
341
+ ),
342
+ );
343
+
344
+ return created;
298
345
  }
299
346
 
300
- /**
301
- * Transitions a PENDING build to RUNNING and starts execution in the
302
- * background. Returns the RUNNING execution immediately.
303
- */
304
- async startMaterialization(
347
+ private async runRound1(
348
+ id: string,
305
349
  environmentName: string,
306
350
  packageName: string,
307
- buildId: string,
308
- ): Promise<Materialization> {
309
- const environmentId = await this.resolveEnvironmentId(environmentName);
310
- const execution = await this.getMaterialization(
311
- environmentName,
351
+ sourceNames: string[] | undefined,
352
+ signal?: AbortSignal,
353
+ ): Promise<void> {
354
+ logger.info("Materialization Round 1: compile + plan", {
355
+ materializationId: id,
312
356
  packageName,
313
- buildId,
314
- );
315
-
316
- if (execution.status !== "PENDING") {
317
- throw new InvalidStateTransitionError(
318
- `Materialization ${buildId} is ${execution.status}, expected PENDING`,
319
- );
320
- }
357
+ });
358
+ const startedAt = Date.now();
321
359
 
322
- // Check for a *different* active materialization on this package.
323
- const active = await this.repository.getActiveMaterialization(
324
- environmentId,
325
- packageName,
326
- );
327
- if (active && active.id !== execution.id) {
328
- throw new MaterializationConflictError(
329
- `Package ${packageName} already has an active materialization (${active.id})`,
360
+ try {
361
+ const environment = await this.environmentStore.getEnvironment(
362
+ environmentName,
363
+ false,
330
364
  );
331
- }
365
+ const pkg = await environment.getPackage(packageName, false);
332
366
 
333
- const running = await this.transitionExecution(execution.id, "RUNNING", {
334
- startedAt: new Date(),
335
- });
367
+ const { graphs, sources, connectionDigests } =
368
+ await environment.withPackageLock(packageName, () =>
369
+ this.compilePackageBuildPlan(pkg, signal),
370
+ );
336
371
 
337
- const metadata = (execution.metadata ?? {}) as Record<string, unknown>;
372
+ const buildPlan = this.deriveBuildPlan(
373
+ graphs,
374
+ sources,
375
+ connectionDigests,
376
+ sourceNames,
377
+ );
338
378
 
339
- // Fire-and-forget: run the build in the background.
340
- this.runMaterialization(
341
- execution.id,
342
- environmentName,
343
- environmentId,
344
- packageName,
345
- metadata,
346
- ).catch((err) => {
347
- logger.error("Unhandled error in background build", {
348
- executionId: execution.id,
349
- error: err instanceof Error ? err.message : String(err),
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,
350
386
  });
351
- });
352
-
353
- return running;
387
+ } catch (err) {
388
+ this.recordRound("round1", outcomeFor(err, signal), startedAt);
389
+ throw err;
390
+ }
354
391
  }
355
392
 
356
- private async runMaterialization(
357
- executionId: string,
393
+ // ==================== AUTO-RUN (DEFAULT) ====================
394
+
395
+ /**
396
+ * Auto-run (default, pauseBetweenPhases=false). Compile + plan, then
397
+ * self-assign physical table names (the `#@ persist name=` value, else the
398
+ * source name) with realization=COPY, skip sources whose buildId is
399
+ * unchanged since the most recent successful manifest (unless forceRefresh),
400
+ * build the rest, assemble the manifest, and auto-load it into the package
401
+ * models so subsequent queries resolve to the materialized tables. No pause,
402
+ * no second round.
403
+ */
404
+ private async runAuto(
405
+ id: string,
358
406
  environmentName: string,
359
- environmentId: string,
360
407
  packageName: string,
361
- metadata: Record<string, unknown>,
408
+ sourceNames: string[] | undefined,
409
+ forceRefresh: boolean,
410
+ signal: AbortSignal,
362
411
  ): Promise<void> {
363
- const abortController = new AbortController();
364
- this.runningAbortControllers.set(executionId, abortController);
412
+ logger.info("Materialization auto-run: compile + build + load", {
413
+ materializationId: id,
414
+ packageName,
415
+ });
416
+ const startedAt = Date.now();
365
417
 
366
418
  try {
367
- const buildMetadata = await this.executeBuild(
419
+ const environmentId = await this.resolveEnvironmentId(environmentName);
420
+ const environment = await this.environmentStore.getEnvironment(
368
421
  environmentName,
369
- environmentId,
370
- packageName,
371
- !!metadata.forceRefresh,
372
- abortController.signal,
422
+ false,
373
423
  );
424
+ const pkg = await environment.getPackage(packageName, false);
374
425
 
375
- if (metadata.autoLoadManifest) {
376
- const updatedManifest = await this.manifestService.getManifest(
377
- environmentId,
378
- packageName,
379
- );
380
- const environment = await this.environmentStore.getEnvironment(
381
- environmentName,
382
- false,
383
- );
384
- // Ensure the package is loaded, then reload models under the
385
- // per-package mutex so the disk reads are serialized against
386
- // installPackage / deletePackage.
387
- await environment.getPackage(packageName, false);
388
- await environment.reloadAllModelsForPackage(
389
- packageName,
390
- updatedManifest.entries,
391
- );
392
- }
426
+ const compiled = await environment.withPackageLock(packageName, () =>
427
+ this.compilePackageBuildPlan(pkg, signal),
428
+ );
393
429
 
394
- await this.transitionExecution(executionId, "SUCCESS", {
395
- completedAt: new Date(),
396
- metadata: { ...metadata, ...buildMetadata },
430
+ const buildPlan = this.deriveBuildPlan(
431
+ compiled.graphs,
432
+ compiled.sources,
433
+ compiled.connectionDigests,
434
+ sourceNames,
435
+ );
436
+ await this.transition(id, "BUILD_PLAN_READY", { buildPlan });
437
+
438
+ // Skip-if-unchanged: reuse tables from the most recent successful
439
+ // manifest for sources whose buildId is unchanged, unless forceRefresh.
440
+ const priorEntries = forceRefresh
441
+ ? {}
442
+ : await this.getMostRecentManifestEntries(
443
+ environmentId,
444
+ packageName,
445
+ id,
446
+ );
447
+ const { instructions, carried } = this.deriveSelfInstructions(
448
+ compiled,
449
+ sourceNames,
450
+ priorEntries,
451
+ );
452
+
453
+ const entries = await this.executeInstructedBuild(
454
+ compiled,
455
+ instructions,
456
+ carried,
457
+ signal,
458
+ );
459
+
460
+ const durationMs = Date.now() - startedAt;
461
+ await this.commitManifest(id, entries, {
462
+ forceRefresh,
463
+ sourceNames: sourceNames ?? null,
464
+ pauseBetweenPhases: false,
465
+ sourcesBuilt: instructions.length,
466
+ sourcesReused: Object.keys(carried).length,
467
+ durationMs,
468
+ });
469
+
470
+ await this.autoLoadManifest(environment, packageName, entries);
471
+
472
+ this.recordRound("auto", "success", startedAt);
473
+ logger.info("Materialization auto-run complete", {
474
+ materializationId: id,
475
+ packageName,
476
+ sourcesBuilt: instructions.length,
477
+ sourcesReused: Object.keys(carried).length,
478
+ durationMs,
397
479
  });
398
480
  } catch (err) {
399
- const errorMessage = err instanceof Error ? err.message : String(err);
481
+ this.recordRound("auto", outcomeFor(err, signal), startedAt);
482
+ throw err;
483
+ }
484
+ }
400
485
 
401
- try {
402
- if (abortController.signal.aborted) {
403
- await this.transitionExecution(executionId, "CANCELLED", {
404
- completedAt: new Date(),
405
- error: "Build cancelled",
406
- });
407
- } else {
408
- await this.transitionExecution(executionId, "FAILED", {
409
- completedAt: new Date(),
410
- error: errorMessage,
486
+ /**
487
+ * Derive the publisher's own build instructions for auto-run. Each persist
488
+ * source (respecting the optional sourceNames filter) gets a self-assigned
489
+ * physical table name and COPY realization, unless its buildId is unchanged
490
+ * since `priorEntries` — those are carried forward (reused) instead of
491
+ * rebuilt.
492
+ */
493
+ private deriveSelfInstructions(
494
+ compiled: {
495
+ graphs: MalloyBuildGraph[];
496
+ sources: Record<string, PersistSource>;
497
+ connectionDigests: Record<string, string>;
498
+ },
499
+ sourceNames: string[] | undefined,
500
+ priorEntries: Record<string, ManifestEntry>,
501
+ ): {
502
+ instructions: BuildInstruction[];
503
+ carried: Record<string, ManifestEntry>;
504
+ } {
505
+ const include = sourceNames ? new Set(sourceNames) : null;
506
+ const instructions: BuildInstruction[] = [];
507
+ const carried: Record<string, ManifestEntry> = {};
508
+ const seen = new Set<string>();
509
+
510
+ for (const graph of compiled.graphs) {
511
+ for (const level of graph.nodes) {
512
+ for (const node of level) {
513
+ const persistSource = compiled.sources[node.sourceID];
514
+ if (!persistSource) continue;
515
+ if (include && !include.has(persistSource.name)) continue;
516
+
517
+ const buildId = computeBuildId(
518
+ persistSource,
519
+ compiled.connectionDigests,
520
+ );
521
+ if (seen.has(buildId)) continue;
522
+ seen.add(buildId);
523
+
524
+ const prior = priorEntries[buildId];
525
+ if (prior && prior.physicalTableName) {
526
+ carried[buildId] = prior;
527
+ continue;
528
+ }
529
+
530
+ instructions.push({
531
+ buildId,
532
+ materializedTableId: `local-${buildId.substring(
533
+ 0,
534
+ STAGING_BUILD_ID_LEN,
535
+ )}`,
536
+ physicalTableName: selfAssignTableName(persistSource),
537
+ realization: "COPY",
411
538
  });
412
539
  }
413
- } catch (transitionErr) {
414
- logger.error("Failed to transition execution after build error", {
415
- executionId,
416
- originalError: errorMessage,
417
- transitionError:
418
- transitionErr instanceof Error
419
- ? transitionErr.message
420
- : String(transitionErr),
421
- });
422
540
  }
423
- } finally {
424
- this.runningAbortControllers.delete(executionId);
425
541
  }
542
+
543
+ return { instructions, carried };
426
544
  }
427
545
 
428
546
  /**
429
- * Cancels a running build. Takes a specific buildId.
547
+ * Entries of the most recent successful (MANIFEST_FILE_READY) materialization
548
+ * for this package, used for skip-if-unchanged. Excludes the in-flight run.
430
549
  */
431
- async stopMaterialization(
432
- environmentName: string,
550
+ private async getMostRecentManifestEntries(
551
+ environmentId: string,
433
552
  packageName: string,
434
- buildId: string,
435
- ): Promise<Materialization> {
436
- const execution = await this.getMaterialization(
437
- environmentName,
553
+ excludeId: string,
554
+ ): Promise<Record<string, ManifestEntry>> {
555
+ const list = await this.repository.listMaterializations(
556
+ environmentId,
438
557
  packageName,
439
- buildId,
440
558
  );
441
-
442
- if (execution.status !== "RUNNING" && execution.status !== "PENDING") {
443
- throw new InvalidStateTransitionError(
444
- `Materialization ${buildId} is ${execution.status}, cannot stop`,
445
- );
446
- }
447
-
448
- if (execution.status === "PENDING") {
449
- return this.transitionExecution(execution.id, "CANCELLED", {
450
- completedAt: new Date(),
451
- error: "Build cancelled before starting",
452
- });
453
- }
454
-
455
- const abortController = this.runningAbortControllers.get(execution.id);
456
- if (abortController) {
457
- abortController.abort();
458
- return execution;
459
- } else {
460
- return this.transitionExecution(execution.id, "CANCELLED", {
461
- completedAt: new Date(),
462
- error: "Force cancelled: execution was orphaned",
463
- });
559
+ for (const m of list) {
560
+ if (m.id === excludeId) continue;
561
+ if (m.status === "MANIFEST_FILE_READY" && m.manifest?.entries) {
562
+ return m.manifest.entries;
563
+ }
464
564
  }
565
+ return {};
465
566
  }
466
567
 
467
568
  /**
468
- * Deletes a materialization record. Only terminal materializations
469
- * (SUCCESS, FAILED, CANCELLED) can be deleted.
569
+ * Load a freshly produced manifest into the package's models so persist
570
+ * references resolve to the materialized tables. Best-effort: a load failure
571
+ * is logged, not fatal (the run already reached MANIFEST_FILE_READY).
470
572
  */
471
- async deleteMaterialization(
472
- environmentName: string,
573
+ private async autoLoadManifest(
574
+ environment: {
575
+ reloadAllModelsForPackage(
576
+ packageName: string,
577
+ manifest: BuildManifest["entries"],
578
+ ): Promise<void>;
579
+ },
473
580
  packageName: string,
474
- materializationId: string,
581
+ entries: Record<string, ManifestEntry>,
475
582
  ): Promise<void> {
476
- const execution = await this.getMaterialization(
477
- environmentName,
478
- packageName,
479
- materializationId,
480
- );
481
-
482
- if (execution.status === "PENDING" || execution.status === "RUNNING") {
483
- throw new InvalidStateTransitionError(
484
- `Cannot delete materialization ${materializationId} while it is ${execution.status}`,
583
+ const manifestEntries: BuildManifest["entries"] = {};
584
+ for (const [buildId, entry] of Object.entries(entries)) {
585
+ if (entry.physicalTableName) {
586
+ manifestEntries[buildId] = { tableName: entry.physicalTableName };
587
+ }
588
+ }
589
+ try {
590
+ await environment.reloadAllModelsForPackage(
591
+ packageName,
592
+ manifestEntries,
485
593
  );
594
+ logger.info("Auto-run: loaded manifest into package models", {
595
+ packageName,
596
+ entryCount: Object.keys(manifestEntries).length,
597
+ });
598
+ } catch (err) {
599
+ logger.warn("Auto-run: failed to load manifest into package models", {
600
+ packageName,
601
+ error: err instanceof Error ? err.message : String(err),
602
+ });
486
603
  }
487
-
488
- await this.repository.deleteMaterialization(execution.id);
489
604
  }
490
605
 
491
- // ==================== PACKAGE TEARDOWN ====================
606
+ // ==================== ROUND 2: BUILD ====================
492
607
 
493
608
  /**
494
- * Drop every materialized table and manifest row for a package.
495
- *
496
- * This is the only out-of-band teardown surface exposed by the
497
- * publisher and is intended for a single caller: the controlplane,
498
- * invoking it on the truly destructive path before the package/environment
499
- * is torn down. The publisher's `DELETE` endpoints are *not* wired into
500
- * teardown because the controlplane invokes them for non-destructive
501
- * unload too (down-replicate, drain, archive) where the entity still
502
- * exists on other replicas, and dropping shared tables there would
503
- * corrupt surviving workers.
504
- *
505
- * Reconciliation of stale rows against the package's live source code
506
- * happens inline at the end of every successful build (see
507
- * {@link executeBuild} Step 5) — that is where "active" vs. "orphan"
508
- * is authoritatively determined, using the manifest's own `touch()`
509
- * bookkeeping. This endpoint does not re-derive that information; it
510
- * drops the manifest in its entirety, which is the correct behavior
511
- * for the pre-deletion use case and avoids pulling the package's
512
- * source through the compiler just to reach a foregone conclusion.
513
- *
514
- * Refuses to run while a materialization is active for the package
515
- * (same serialization the inline GC gets by piggy-backing on the
516
- * build).
517
- *
518
- * `dryRun` returns what would be dropped without issuing any DROP or
519
- * deleting manifest rows.
609
+ * Round 2. Validates the instructions against the stored build plan,
610
+ * transitions out of BUILD_PLAN_READY, and builds the instructed sources
611
+ * in the background. Returns the accepted record (202).
520
612
  */
521
- async teardownPackage(
613
+ async buildMaterialization(
522
614
  environmentName: string,
523
615
  packageName: string,
524
- options: { dryRun?: boolean } = {},
525
- ): Promise<GcResult> {
526
- const environmentId = await this.resolveEnvironmentId(environmentName);
616
+ id: string,
617
+ instructions: BuildInstruction[],
618
+ ): Promise<Materialization> {
619
+ const m = await this.getMaterialization(environmentName, packageName, id);
527
620
 
528
- const active = await this.repository.getActiveMaterialization(
529
- environmentId,
530
- packageName,
531
- );
532
- if (active) {
533
- throw new MaterializationConflictError(
534
- `Package ${packageName} has an active materialization (${active.id}); cannot tear down`,
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;
632
+ if (!plan) {
633
+ throw new InvalidStateTransitionError(
634
+ `Materialization ${id} has no build plan`,
535
635
  );
536
636
  }
537
637
 
538
- const environment = await this.environmentStore.getEnvironment(
539
- environmentName,
540
- false,
541
- );
542
- const pkg = await environment.getPackage(packageName, false);
638
+ this.validateInstructions(plan, instructions);
543
639
 
544
- const entries = await this.manifestService.listEntries(
545
- environmentId,
546
- packageName,
640
+ this.runInBackground(id, (signal) =>
641
+ this.runRound2(id, environmentName, packageName, instructions, signal),
547
642
  );
548
643
 
549
- const connections = await resolvePackageConnections(
550
- pkg,
551
- entries.map((e) => e.connectionName),
552
- );
553
-
554
- // `forceDeleteRowOnMissingConnection`: teardown is the one place
555
- // where we'd rather lose the manifest row than leave it pointing at
556
- // a vanished connection. We also deliberately omit `liveTables`:
557
- // in teardown everything is stale, nothing is live.
558
- return dropManifestEntries(entries, {
559
- connections,
560
- manifestService: this.manifestService,
561
- environmentId,
562
- dryRun: options.dryRun,
563
- forceDeleteRowOnMissingConnection: true,
564
- });
644
+ return m;
565
645
  }
566
646
 
567
- // ==================== BUILD LOGIC ====================
647
+ private validateInstructions(
648
+ plan: BuildPlan,
649
+ instructions: BuildInstruction[],
650
+ ): void {
651
+ const plannedBuildIds = new Set<string>();
652
+ for (const source of Object.values(plan.sources)) {
653
+ plannedBuildIds.add(source.buildId);
654
+ }
568
655
 
569
- /**
570
- * Core build pipeline (5 steps):
571
- * 1. LOAD — Load existing manifest.
572
- * 2. COMPILE & PLAN — Compile all models, collect dependency graphs.
573
- * 3. BUILD — Walk graphs in dependency order, materialize each source.
574
- * 4. GC — Drop stale physical tables + prune manifest rows.
575
- *
576
- * Build success is not gated on GC — failures are surfaced in
577
- * `gcErrors` metadata so the controller/UI can show them.
578
- */
579
- private async executeBuild(
656
+ for (const instruction of instructions) {
657
+ if (!plannedBuildIds.has(instruction.buildId)) {
658
+ throw new BadRequestError(
659
+ `Instruction references unknown buildId '${instruction.buildId}'`,
660
+ );
661
+ }
662
+ // v0 is COPY-only; SNAPSHOT lands once clone semantics are defined.
663
+ if (instruction.realization === "SNAPSHOT") {
664
+ throw new BadRequestError(
665
+ "realization=SNAPSHOT is not supported in v0 (COPY only)",
666
+ );
667
+ }
668
+ }
669
+ }
670
+
671
+ private async runRound2(
672
+ id: string,
580
673
  environmentName: string,
581
- environmentId: string,
582
674
  packageName: string,
583
- forceRefresh: boolean,
675
+ instructions: BuildInstruction[],
584
676
  signal: AbortSignal,
585
- ): Promise<Record<string, unknown>> {
586
- logger.info("Starting materialization build", {
587
- environmentName,
677
+ ): Promise<void> {
678
+ logger.info("Materialization Round 2: build", {
679
+ materializationId: id,
588
680
  packageName,
681
+ sourceCount: instructions.length,
589
682
  });
683
+ const startedAt = Date.now();
590
684
 
591
- const environment = await this.environmentStore.getEnvironment(
592
- environmentName,
593
- false,
594
- );
595
- const pkg = await environment.getPackage(packageName, false);
596
-
597
- // ── STEP 1: LOAD ───────────────────────────────────────────────
598
- const manifest = new Manifest();
599
- const existingManifest = await this.manifestService.getManifest(
600
- environmentId,
601
- packageName,
602
- );
603
- manifest.loadText(JSON.stringify(existingManifest));
604
-
605
- const existingEntries = await this.manifestService.listEntries(
606
- environmentId,
607
- packageName,
608
- );
609
- const knownMaterializedTables = new Set(
610
- existingEntries.map((e: ManifestEntry) =>
611
- manifestTableKey(e.connectionName, e.tableName),
612
- ),
613
- );
685
+ try {
686
+ const environment = await this.environmentStore.getEnvironment(
687
+ environmentName,
688
+ false,
689
+ );
690
+ const pkg = await environment.getPackage(packageName, false);
614
691
 
615
- // ── STEP 2: COMPILE & PLAN ─────────────────────────────────────
616
- // `connections` is built lazily from the connection names the plan
617
- // actually targets — no upfront ATTACH on every environment connection.
618
- // Hold the per-package mutex for the duration of the compile so the
619
- // `fs.stat` + `runtime.loadModel` calls inside `compilePackageBuildPlan`
620
- // are serialized against `installPackage` / `deletePackage`.
621
- const { graphs, sources, connectionDigests, connections } =
622
- await environment.withPackageLock(packageName, () =>
692
+ const compiled = await environment.withPackageLock(packageName, () =>
623
693
  this.compilePackageBuildPlan(pkg, signal),
624
694
  );
625
695
 
626
- if (graphs.length === 0) {
627
- logger.info("No persist sources to build");
628
- return { sourcesBuilt: 0, sourcesSkipped: 0 };
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;
629
719
  }
720
+ }
721
+
722
+ /**
723
+ * Shared build loop for both auto-run and Round 2. Seeds the manifest with
724
+ * carried-forward (reused) upstream entries so downstream references resolve,
725
+ * then builds each instructed source in dependency order. Returns the full
726
+ * entry map (carried + freshly built). The package was compiled by the
727
+ * caller; this runs outside the package lock.
728
+ */
729
+ private async executeInstructedBuild(
730
+ compiled: {
731
+ graphs: MalloyBuildGraph[];
732
+ sources: Record<string, PersistSource>;
733
+ connectionDigests: Record<string, string>;
734
+ connections: Map<string, MalloyConnection>;
735
+ },
736
+ instructions: BuildInstruction[],
737
+ seedEntries: Record<string, ManifestEntry>,
738
+ signal: AbortSignal,
739
+ ): Promise<Record<string, ManifestEntry>> {
740
+ const { graphs, sources, connectionDigests, connections } = compiled;
630
741
 
631
- // ── STEP 3: BUILD ──────────────────────────────────────────────
632
- let sourcesBuilt = 0;
633
- let sourcesSkipped = 0;
634
- const sourceResults: Record<string, unknown>[] = [];
742
+ const byBuildId = new Map<string, BuildInstruction>();
743
+ for (const instruction of instructions) {
744
+ byBuildId.set(instruction.buildId, instruction);
745
+ }
746
+
747
+ // Accumulates physical names as sources are built so downstream sources
748
+ // resolve their upstream references to the freshly-assigned tables. Seed
749
+ // it with carried-forward entries so reused upstreams resolve too.
750
+ const manifest = new Manifest();
751
+ const entries: Record<string, ManifestEntry> = {};
752
+ for (const [buildId, entry] of Object.entries(seedEntries)) {
753
+ if (entry.physicalTableName) {
754
+ manifest.update(buildId, { tableName: entry.physicalTableName });
755
+ }
756
+ entries[buildId] = entry;
757
+ }
635
758
 
636
759
  for (const graph of graphs) {
637
760
  const connection = connections.get(graph.connectionName);
@@ -640,66 +763,270 @@ export class MaterializationService {
640
763
  `Connection '${graph.connectionName}' not found`,
641
764
  );
642
765
  }
643
-
644
766
  for (const level of graph.nodes) {
645
767
  for (const node of level) {
646
768
  if (signal.aborted) throw new Error("Build cancelled");
647
-
648
769
  const persistSource = sources[node.sourceID];
649
- if (!persistSource) {
650
- logger.warn(
651
- `Source ${node.sourceID} not found in build plan, skipping`,
652
- );
653
- continue;
654
- }
770
+ if (!persistSource) continue;
771
+
772
+ const buildId = computeBuildId(persistSource, connectionDigests);
773
+ const instruction = byBuildId.get(buildId);
774
+ if (!instruction) continue;
655
775
 
656
- const result = await this.buildOneSource(
776
+ const entry = await this.buildOneSource(
657
777
  persistSource,
658
- manifest,
778
+ instruction,
659
779
  connection,
660
780
  connectionDigests,
661
- forceRefresh,
662
- environmentId,
663
- packageName,
664
- knownMaterializedTables,
781
+ manifest,
665
782
  );
666
-
667
- sourceResults.push(result);
668
- if (result.status === "built") sourcesBuilt++;
669
- else sourcesSkipped++;
783
+ entries[buildId] = entry;
670
784
  }
671
785
  }
672
786
  }
673
787
 
674
- // ── STEP 4: GC ─────────────────────────────────────────────────
675
- const gcResult = await this.runPostBuildGc(
676
- manifest,
677
- environmentId,
678
- packageName,
679
- connections,
680
- );
788
+ return entries;
789
+ }
790
+
791
+ /**
792
+ * Build a single instructed source into its assigned physical table.
793
+ * COPY uses a staging table + atomic rename for crash-safety; the staging
794
+ * name derives from the buildId. Records and returns the manifest entry.
795
+ */
796
+ private async buildOneSource(
797
+ persistSource: PersistSource,
798
+ instruction: BuildInstruction,
799
+ connection: MalloyConnection,
800
+ connectionDigests: Record<string, string>,
801
+ manifest: Manifest,
802
+ ): Promise<ManifestEntry> {
803
+ const buildId = instruction.buildId;
804
+ const physicalTableName = instruction.physicalTableName;
805
+ const buildSQL = persistSource.getSQL({
806
+ buildManifest: manifest.buildManifest,
807
+ connectionDigests,
808
+ });
809
+
810
+ const { bareName } = splitTablePath(physicalTableName);
811
+ const stagingTableName = `${physicalTableName}${stagingSuffix(buildId)}`;
812
+
813
+ const startTime = performance.now();
814
+ await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
815
+ try {
816
+ await connection.runSQL(
817
+ `CREATE TABLE ${stagingTableName} AS (${buildSQL})`,
818
+ );
819
+ await connection.runSQL(`DROP TABLE IF EXISTS ${physicalTableName}`);
820
+ await connection.runSQL(
821
+ `ALTER TABLE ${stagingTableName} RENAME TO ${bareName}`,
822
+ );
823
+ } catch (err) {
824
+ try {
825
+ await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
826
+ } catch (cleanupErr) {
827
+ logger.warn(
828
+ "Round 2: failed to clean up staging table after a failed build; physical leak",
829
+ {
830
+ stagingTableName,
831
+ connectionName: persistSource.connectionName,
832
+ cleanupError:
833
+ cleanupErr instanceof Error
834
+ ? cleanupErr.message
835
+ : String(cleanupErr),
836
+ },
837
+ );
838
+ }
839
+ throw err;
840
+ }
681
841
 
682
- logger.info("Materialization build complete", {
683
- sourcesBuilt,
684
- sourcesSkipped,
685
- gcDropped: gcResult.dropped.length,
686
- gcErrors: gcResult.errors.length,
842
+ // Make this table visible to downstream sources built later this round.
843
+ manifest.update(buildId, { tableName: physicalTableName });
844
+
845
+ const durationMs = Math.round(performance.now() - startTime);
846
+ recordSourceBuildDuration(durationMs);
847
+ // Shared by auto-run and Round 2, so the message is path-neutral.
848
+ logger.info(`Built materialized source ${persistSource.name}`, {
849
+ physicalTableName,
850
+ durationMs,
687
851
  });
688
852
 
689
853
  return {
690
- sourcesBuilt,
691
- sourcesSkipped,
692
- sources: sourceResults,
693
- gcDropped: gcResult.dropped,
694
- gcErrors: gcResult.errors,
854
+ buildId,
855
+ sourceName: persistSource.name,
856
+ materializedTableId: instruction.materializedTableId,
857
+ physicalTableName,
858
+ connectionName: persistSource.connectionName,
859
+ realization: instruction.realization,
860
+ rowCount: null,
861
+ };
862
+ }
863
+
864
+ // ==================== CANCELLATION ====================
865
+
866
+ /** Cancel a non-terminal materialization. */
867
+ async stopMaterialization(
868
+ environmentName: string,
869
+ packageName: string,
870
+ id: string,
871
+ ): Promise<Materialization> {
872
+ const m = await this.getMaterialization(environmentName, packageName, id);
873
+
874
+ const cancellable: MaterializationStatus[] = [
875
+ "PENDING",
876
+ "BUILD_PLAN_READY",
877
+ "MANIFEST_ROWS_READY",
878
+ ];
879
+ if (!cancellable.includes(m.status)) {
880
+ throw new InvalidStateTransitionError(
881
+ `Materialization ${id} is ${m.status}, cannot stop`,
882
+ );
883
+ }
884
+
885
+ const abortController = this.runningAbortControllers.get(id);
886
+ if (abortController) {
887
+ abortController.abort();
888
+ return m;
889
+ }
890
+ return this.transition(id, "CANCELLED", {
891
+ completedAt: new Date(),
892
+ error: "Cancelled",
893
+ });
894
+ }
895
+
896
+ /**
897
+ * Delete a materialization record. Only terminal materializations
898
+ * (MANIFEST_FILE_READY, FAILED, CANCELLED) can be deleted; an active run must
899
+ * be stopped first.
900
+ *
901
+ * By default this removes the publisher's record only — the control plane
902
+ * owns table GC. When `dropTables` is set, the publisher additionally drops
903
+ * the physical tables recorded in this run's manifest as a best-effort
904
+ * cleanup (a drop failure is logged, not fatal, so the record still deletes).
905
+ */
906
+ async deleteMaterialization(
907
+ environmentName: string,
908
+ packageName: string,
909
+ id: string,
910
+ options: { dropTables?: boolean } = {},
911
+ ): Promise<void> {
912
+ const m = await this.getMaterialization(environmentName, packageName, id);
913
+
914
+ const terminal: MaterializationStatus[] = [
915
+ "MANIFEST_FILE_READY",
916
+ "FAILED",
917
+ "CANCELLED",
918
+ ];
919
+ if (!terminal.includes(m.status)) {
920
+ throw new InvalidStateTransitionError(
921
+ `Cannot delete materialization ${id} while it is ${m.status}`,
922
+ );
923
+ }
924
+
925
+ if (options.dropTables) {
926
+ await this.dropMaterializedTables(environmentName, packageName, m);
927
+ }
928
+
929
+ await this.repository.deleteMaterialization(id);
930
+ }
931
+
932
+ /**
933
+ * Best-effort drop of every physical table this run produced, read from the
934
+ * materialization's manifest. Resolves each entry's connection by name and
935
+ * issues `DROP TABLE IF EXISTS` for the physical table and its (possible)
936
+ * leftover staging table. Failures are logged and swallowed so a partial
937
+ * cleanup never blocks deletion of the record.
938
+ */
939
+ private async dropMaterializedTables(
940
+ environmentName: string,
941
+ packageName: string,
942
+ m: Materialization,
943
+ ): Promise<void> {
944
+ const entries = m.manifest?.entries;
945
+ if (!entries || Object.keys(entries).length === 0) {
946
+ return;
947
+ }
948
+
949
+ const environment = await this.environmentStore.getEnvironment(
950
+ environmentName,
951
+ false,
952
+ );
953
+ const pkg = await environment.getPackage(packageName, false);
954
+ const connectionCache = new Map<string, MalloyConnection>();
955
+
956
+ for (const entry of Object.values(entries)) {
957
+ const connectionName = entry.connectionName;
958
+ const physicalTableName = entry.physicalTableName;
959
+ if (!connectionName || !physicalTableName) {
960
+ logger.warn("Skipping manifest entry with no connection/table", {
961
+ materializationId: m.id,
962
+ buildId: entry.buildId,
963
+ });
964
+ continue;
965
+ }
966
+
967
+ try {
968
+ let connection = connectionCache.get(connectionName);
969
+ if (!connection) {
970
+ connection = await pkg.getMalloyConnection(connectionName);
971
+ connectionCache.set(connectionName, connection);
972
+ }
973
+ await connection.runSQL(
974
+ `DROP TABLE IF EXISTS ${physicalTableName}`,
975
+ );
976
+ // A crash between staging-create and rename can leave the staging
977
+ // table behind; clean it up too while we hold the connection.
978
+ await connection.runSQL(
979
+ `DROP TABLE IF EXISTS ${physicalTableName}${stagingSuffix(
980
+ entry.buildId,
981
+ )}`,
982
+ );
983
+ recordDropTables("success");
984
+ logger.info("Dropped materialized table on delete", {
985
+ materializationId: m.id,
986
+ physicalTableName,
987
+ connectionName,
988
+ });
989
+ } catch (err) {
990
+ recordDropTables("failure");
991
+ logger.warn("Failed to drop materialized table on delete", {
992
+ materializationId: m.id,
993
+ physicalTableName,
994
+ connectionName,
995
+ error: err instanceof Error ? err.message : String(err),
996
+ });
997
+ }
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * Finalize a successful build: advance MANIFEST_ROWS_READY -> FILE_READY and
1003
+ * persist the assembled manifest. Shared by auto-run and Round 2; the caller
1004
+ * supplies the per-path metadata. The build itself happens before this.
1005
+ */
1006
+ private async commitManifest(
1007
+ id: string,
1008
+ entries: Record<string, ManifestEntry>,
1009
+ metadata: Record<string, unknown>,
1010
+ ): Promise<void> {
1011
+ await this.transition(id, "MANIFEST_ROWS_READY");
1012
+ const manifest: BuildManifestResult = {
1013
+ builtAt: new Date().toISOString(),
1014
+ entries,
1015
+ strict: false,
695
1016
  };
1017
+ await this.transition(id, "MANIFEST_FILE_READY", {
1018
+ completedAt: new Date(),
1019
+ manifest,
1020
+ metadata,
1021
+ });
696
1022
  }
697
1023
 
698
- // ==================== BUILD HELPERS ====================
1024
+ // ==================== BUILD PLAN COMPILATION ====================
699
1025
 
700
1026
  /**
701
1027
  * Compile every model in the package and collect the dependency-ordered
702
- * build graphs, persist sources, and pre-computed connection digests.
1028
+ * build graphs, persist sources, connection digests, and resolved
1029
+ * connections.
703
1030
  */
704
1031
  private async compilePackageBuildPlan(
705
1032
  pkg: {
@@ -708,19 +1035,18 @@ export class MaterializationService {
708
1035
  getMalloyConfig(): MalloyConfig;
709
1036
  getMalloyConnection(name: string): Promise<MalloyConnection>;
710
1037
  },
711
- signal: AbortSignal,
1038
+ signal?: AbortSignal,
712
1039
  ): Promise<{
713
- graphs: BuildGraph[];
1040
+ graphs: MalloyBuildGraph[];
714
1041
  sources: Record<string, PersistSource>;
715
1042
  connectionDigests: Record<string, string>;
716
1043
  connections: Map<string, MalloyConnection>;
717
1044
  }> {
718
- const modelPaths = pkg.getModelPaths();
719
- const allGraphs: BuildGraph[] = [];
1045
+ const allGraphs: MalloyBuildGraph[] = [];
720
1046
  const allSources: Record<string, PersistSource> = {};
721
1047
 
722
- for (const modelPath of modelPaths) {
723
- if (signal.aborted) throw new Error("Build cancelled");
1048
+ for (const modelPath of pkg.getModelPaths()) {
1049
+ if (signal?.aborted) throw new Error("Build cancelled");
724
1050
 
725
1051
  const { runtime, modelURL, importBaseURL } =
726
1052
  await Model.getModelRuntime(
@@ -728,72 +1054,28 @@ export class MaterializationService {
728
1054
  modelPath,
729
1055
  pkg.getMalloyConfig(),
730
1056
  );
1057
+ const malloyModel = await runtime
1058
+ .loadModel(modelURL, { importBaseURL })
1059
+ .getModel();
731
1060
 
732
- const modelMaterializer = runtime.loadModel(modelURL, {
733
- importBaseURL,
734
- });
735
- const malloyModel = await modelMaterializer.getModel();
736
-
737
- // getBuildPlan() throws if the tag is missing, so check first to
738
- // keep plain models in the same package buildable.
739
- const modelTag = malloyModel.annotations.parseAsTag("!").tag;
740
- if (!modelTag.has("experimental", "persistence")) {
741
- logger.debug(
742
- "Model has no ##! experimental.persistence tag, skipping",
743
- { modelPath },
744
- );
745
- continue;
746
- }
747
-
1061
+ // getBuildPlan() returns empty graphs for models with no #@ persist
1062
+ // sources, so non-persist models are simply skipped below.
748
1063
  const buildPlan = malloyModel.getBuildPlan();
749
-
750
1064
  for (const msg of buildPlan.tagParseLog) {
751
1065
  logger.warn("Persist annotation issue", {
752
1066
  modelPath,
753
1067
  message: msg.message,
754
1068
  severity: msg.severity,
755
- code: msg.code,
756
1069
  });
757
1070
  }
1071
+ if (buildPlan.graphs.length === 0) continue;
758
1072
 
759
- if (buildPlan.graphs.length > 0) {
760
- allGraphs.push(...buildPlan.graphs);
761
- for (const [sourceID, source] of Object.entries(
762
- buildPlan.sources,
763
- )) {
764
- if (allSources[sourceID]) {
765
- logger.warn(
766
- `Duplicate sourceID "${sourceID}" from model ${modelPath}, overwriting previous definition`,
767
- );
768
- }
769
- allSources[sourceID] = source;
770
- }
1073
+ allGraphs.push(...buildPlan.graphs);
1074
+ for (const [sourceID, source] of Object.entries(buildPlan.sources)) {
1075
+ allSources[sourceID] = source;
771
1076
  }
772
1077
  }
773
1078
 
774
- logger.info("Build plan", {
775
- sourceCount: Object.keys(allSources).length,
776
- graphCount: allGraphs.length,
777
- });
778
-
779
- // Fail fast if two persist sources target the same (connection, table).
780
- const tableOwners = new Map<string, string>();
781
- for (const [sourceID, source] of Object.entries(allSources)) {
782
- const tableName =
783
- source.annotations.parseAsTag("@").tag.text("name") || source.name;
784
- const key = `${source.connectionName}::${tableName}`;
785
- const existing = tableOwners.get(key);
786
- if (existing) {
787
- throw new BadRequestError(
788
- `Persist target collision: sources '${existing}' and '${sourceID}' both resolve to table '${tableName}' on connection '${source.connectionName}'. Disambiguate with '#@ persist name=...'.`,
789
- );
790
- }
791
- tableOwners.set(key, sourceID);
792
- }
793
-
794
- // Resolve only the connections this build plan actually targets;
795
- // the package's MalloyConfig caches each lookup so the build phase
796
- // sees the same Connection instance and avoids re-resolving.
797
1079
  const connections = await resolvePackageConnections(
798
1080
  pkg,
799
1081
  allGraphs.map((g) => g.connectionName),
@@ -814,187 +1096,87 @@ export class MaterializationService {
814
1096
  };
815
1097
  }
816
1098
 
817
- /**
818
- * Materialize a single persist source: skip if up-to-date, otherwise
819
- * build via staging table (CREATE → DROP old → RENAME), then write
820
- * the manifest entry. Stale entries are cleaned up by post-build GC.
821
- */
822
- private async buildOneSource(
823
- persistSource: PersistSource,
824
- manifest: Manifest,
825
- connection: MalloyConnection,
1099
+ /** Project the Malloy build plan into the trimmed v0 wire BuildPlan. */
1100
+ private deriveBuildPlan(
1101
+ graphs: MalloyBuildGraph[],
1102
+ sources: Record<string, PersistSource>,
826
1103
  connectionDigests: Record<string, string>,
827
- forceRefresh: boolean,
828
- environmentId: string,
829
- packageName: string,
830
- knownMaterializedTables: Set<string>,
831
- ): Promise<Record<string, unknown>> {
832
- const buildIdSQL = persistSource.getSQL();
833
- const digest = connectionDigests[persistSource.connectionName];
834
- const buildId = persistSource.makeBuildId(digest, buildIdSQL);
835
-
836
- // Already built — mark active so it survives GC.
837
- if (manifest.buildManifest.entries[buildId] && !forceRefresh) {
838
- manifest.touch(buildId);
839
- logger.info(`Source ${persistSource.name} up to date, skipping`, {
840
- buildId,
841
- });
842
- return { name: persistSource.name, status: "skipped", buildId };
843
- }
844
-
845
- const buildSQL = persistSource.getSQL({
846
- buildManifest: manifest.buildManifest,
847
- connectionDigests,
848
- });
849
-
850
- const connectionName = persistSource.connectionName;
851
- const tableName =
852
- persistSource.annotations.parseAsTag("@").tag.text("name") ||
853
- persistSource.name;
854
- const { bareName } = splitTablePath(tableName);
855
- const stagingTableName = `${tableName}${stagingSuffix(buildId)}`;
856
-
857
- // Table names go into DDL verbatim. Malloy assumes a table name handed
858
- // to it (here, via the build manifest) is already quoted for the
859
- // dialect and substitutes it into generated SQL as-is; our DDL has to
860
- // match that exact identifier or the CREATE and the read diverge. The
861
- // model author owns quoting the `#@ persist name=...` value.
862
-
863
- // Guard: refuse to overwrite a pre-existing table that was not
864
- // created by a previous materialization build. Without this check a
865
- // model author could accidentally target a table name that already
866
- // holds real data (e.g. `#@ persist name=customers`), and the
867
- // DROP TABLE below would silently destroy it.
868
- const tableKey = manifestTableKey(connectionName, tableName);
869
- if (!knownMaterializedTables.has(tableKey)) {
870
- if (await tablePhysicallyExists(connection, tableName)) {
871
- throw new BadRequestError(
872
- `Refusing to materialize source '${persistSource.name}': ` +
873
- `target table '${tableName}' already exists on connection ` +
874
- `'${connectionName}' but was not created by a previous ` +
875
- `materialization build. Use '#@ persist name=...' to ` +
876
- `choose a different table name, or drop the existing ` +
877
- `table manually if it is no longer needed.`,
878
- );
879
- }
880
- }
881
-
882
- logger.info(`Building source ${persistSource.name}`, {
883
- tableName,
884
- connectionName,
885
- });
886
-
887
- const startTime = performance.now();
888
-
889
- await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
890
-
891
- // If any step after CREATE throws we must best-effort drop the
892
- // staging table, else it orphans under a name that GC will never
893
- // find (no manifest row is written for a failed build).
894
- try {
895
- await connection.runSQL(
896
- `CREATE TABLE ${stagingTableName} AS (${buildSQL})`,
897
- );
898
- await connection.runSQL(`DROP TABLE IF EXISTS ${tableName}`);
899
- await connection.runSQL(
900
- `ALTER TABLE ${stagingTableName} RENAME TO ${bareName}`,
901
- );
902
- } catch (err) {
903
- try {
904
- await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
905
- } catch (cleanupErr) {
906
- logger.warn(
907
- "Build: failed to clean up staging table after a failed rebuild; physical leak",
908
- {
909
- stagingTableName,
910
- connectionName,
911
- cleanupError:
912
- cleanupErr instanceof Error
913
- ? cleanupErr.message
914
- : String(cleanupErr),
915
- },
916
- );
917
- }
918
- throw err;
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
+ };
919
1130
  }
920
1131
 
921
- const duration = performance.now() - startTime;
922
-
923
- knownMaterializedTables.add(tableKey);
924
- manifest.update(buildId, { tableName });
925
-
926
- await this.manifestService.writeEntry(
927
- environmentId,
928
- packageName,
929
- buildId,
930
- tableName,
931
- persistSource.name,
932
- connectionName,
933
- );
934
-
935
- logger.info(`Built source ${persistSource.name}`, {
936
- tableName,
937
- durationMs: Math.round(duration),
938
- });
939
-
940
- return {
941
- name: persistSource.name,
942
- status: "built",
943
- buildId,
944
- tableName,
945
- durationMs: Math.round(duration),
946
- };
1132
+ return { graphs: wireGraphs, sources: wireSources };
947
1133
  }
948
1134
 
949
- /**
950
- * Post-build GC: drop physical tables + manifest rows for entries whose
951
- * BuildID is no longer produced by an active persist source.
952
- *
953
- * `liveTables` prevents a fresh build from having its table dropped when
954
- * a stale row still references the same `(connection, tableName)` pair.
955
- */
956
- private async runPostBuildGc(
957
- manifest: Manifest,
958
- environmentId: string,
959
- packageName: string,
960
- connections: Map<string, MalloyConnection>,
961
- ): Promise<GcResult> {
962
- const activeManifest = manifest.activeEntries;
963
- const allDbEntries = await this.manifestService.listEntries(
964
- environmentId,
965
- packageName,
966
- );
967
-
968
- const liveTables = new Set<string>();
969
- for (const entry of allDbEntries) {
970
- if (activeManifest.entries[entry.buildId]) {
971
- liveTables.add(liveTableKey(entry.connectionName, entry.tableName));
972
- }
973
- }
974
-
975
- const staleEntries = allDbEntries.filter(
976
- (entry) => !activeManifest.entries[entry.buildId],
977
- );
1135
+ // ==================== HELPERS ====================
978
1136
 
979
- const gcResult = await dropManifestEntries(staleEntries, {
980
- connections,
981
- manifestService: this.manifestService,
982
- environmentId,
983
- liveTables,
984
- });
1137
+ private recordRound(
1138
+ round: MaterializationRound,
1139
+ outcome: "success" | "failed" | "cancelled",
1140
+ startedAtMs: number,
1141
+ ): void {
1142
+ recordMaterializationRound(round, outcome, Date.now() - startedAtMs);
1143
+ }
985
1144
 
986
- if (gcResult.errors.length > 0) {
987
- logger.warn("Materialization GC surfaced errors", {
988
- errorCount: gcResult.errors.length,
989
- droppedCount: gcResult.dropped.length,
1145
+ private runInBackground(
1146
+ id: string,
1147
+ run: (signal: AbortSignal) => Promise<void>,
1148
+ ): void {
1149
+ const abortController = new AbortController();
1150
+ this.runningAbortControllers.set(id, abortController);
1151
+
1152
+ run(abortController.signal)
1153
+ .catch(async (err) => {
1154
+ const message = err instanceof Error ? err.message : String(err);
1155
+ const next = abortController.signal.aborted
1156
+ ? "CANCELLED"
1157
+ : "FAILED";
1158
+ try {
1159
+ await this.repository.updateMaterialization(id, {
1160
+ status: next,
1161
+ completedAt: new Date(),
1162
+ error: abortController.signal.aborted ? "Cancelled" : message,
1163
+ });
1164
+ } catch (transitionErr) {
1165
+ logger.error("Failed to record materialization failure", {
1166
+ materializationId: id,
1167
+ originalError: message,
1168
+ transitionError:
1169
+ transitionErr instanceof Error
1170
+ ? transitionErr.message
1171
+ : String(transitionErr),
1172
+ });
1173
+ }
1174
+ })
1175
+ .finally(() => {
1176
+ this.runningAbortControllers.delete(id);
990
1177
  });
991
- }
992
-
993
- return gcResult;
994
1178
  }
995
1179
 
996
- // ==================== HELPERS ====================
997
-
998
1180
  private resolveEnvironmentId(environmentName: string): Promise<string> {
999
1181
  return resolveEnvironmentId(this.repository, environmentName);
1000
1182
  }