@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.
- package/dist/app/api-doc.yaml +394 -395
- package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
- package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
- package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
- package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
- package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
- package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
- package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
- package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
- package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
- package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
- package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/server.mjs +1272 -1299
- package/package.json +1 -1
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +12 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +121 -0
- package/src/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/server-old.ts +7 -149
- package/src/server.ts +9 -188
- package/src/service/authorize_integration.spec.ts +67 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +0 -23
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +584 -500
- package/src/service/materialization_service.ts +839 -657
- package/src/service/model.ts +419 -15
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +339 -29
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +194 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- package/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- package/tests/unit/storage/StorageManager.test.ts +0 -166
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
*
|
|
80
|
-
*
|
|
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: ["
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
if (!execution) {
|
|
206
|
+
const current = await this.repository.getMaterializationById(id);
|
|
207
|
+
if (!current) {
|
|
192
208
|
throw new MaterializationNotFoundError(
|
|
193
|
-
`
|
|
209
|
+
`Materialization ${id} not found`,
|
|
194
210
|
);
|
|
195
211
|
}
|
|
196
|
-
this.validateTransition(
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
// ====================
|
|
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
|
-
|
|
242
|
+
id: string,
|
|
222
243
|
): Promise<Materialization> {
|
|
223
244
|
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
224
|
-
const
|
|
245
|
+
const m = await this.repository.getMaterializationById(id);
|
|
225
246
|
if (
|
|
226
|
-
!
|
|
227
|
-
|
|
228
|
-
|
|
247
|
+
!m ||
|
|
248
|
+
m.environmentId !== environmentId ||
|
|
249
|
+
m.packageName !== packageName
|
|
229
250
|
) {
|
|
230
251
|
throw new MaterializationNotFoundError(
|
|
231
|
-
`Materialization ${
|
|
252
|
+
`Materialization ${id} not found for package ${packageName}`,
|
|
232
253
|
);
|
|
233
254
|
}
|
|
234
|
-
return
|
|
255
|
+
return m;
|
|
235
256
|
}
|
|
236
257
|
|
|
237
|
-
// ====================
|
|
258
|
+
// ==================== ROUND 1: CREATE + PLAN ====================
|
|
238
259
|
|
|
239
260
|
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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: {
|
|
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
|
|
272
|
-
|
|
294
|
+
forceRefresh,
|
|
295
|
+
sourceNames: options.sourceNames ?? null,
|
|
296
|
+
pauseBetweenPhases,
|
|
273
297
|
};
|
|
274
298
|
|
|
299
|
+
let created: Materialization;
|
|
275
300
|
try {
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
367
|
+
const { graphs, sources, connectionDigests } =
|
|
368
|
+
await environment.withPackageLock(packageName, () =>
|
|
369
|
+
this.compilePackageBuildPlan(pkg, signal),
|
|
370
|
+
);
|
|
336
371
|
|
|
337
|
-
|
|
372
|
+
const buildPlan = this.deriveBuildPlan(
|
|
373
|
+
graphs,
|
|
374
|
+
sources,
|
|
375
|
+
connectionDigests,
|
|
376
|
+
sourceNames,
|
|
377
|
+
);
|
|
338
378
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
387
|
+
} catch (err) {
|
|
388
|
+
this.recordRound("round1", outcomeFor(err, signal), startedAt);
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
354
391
|
}
|
|
355
392
|
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
408
|
+
sourceNames: string[] | undefined,
|
|
409
|
+
forceRefresh: boolean,
|
|
410
|
+
signal: AbortSignal,
|
|
362
411
|
): Promise<void> {
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
419
|
+
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
420
|
+
const environment = await this.environmentStore.getEnvironment(
|
|
368
421
|
environmentName,
|
|
369
|
-
|
|
370
|
-
packageName,
|
|
371
|
-
!!metadata.forceRefresh,
|
|
372
|
-
abortController.signal,
|
|
422
|
+
false,
|
|
373
423
|
);
|
|
424
|
+
const pkg = await environment.getPackage(packageName, false);
|
|
374
425
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
481
|
+
this.recordRound("auto", outcomeFor(err, signal), startedAt);
|
|
482
|
+
throw err;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
400
485
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
*
|
|
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
|
|
432
|
-
|
|
550
|
+
private async getMostRecentManifestEntries(
|
|
551
|
+
environmentId: string,
|
|
433
552
|
packageName: string,
|
|
434
|
-
|
|
435
|
-
): Promise<
|
|
436
|
-
const
|
|
437
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
*
|
|
469
|
-
*
|
|
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
|
|
472
|
-
|
|
573
|
+
private async autoLoadManifest(
|
|
574
|
+
environment: {
|
|
575
|
+
reloadAllModelsForPackage(
|
|
576
|
+
packageName: string,
|
|
577
|
+
manifest: BuildManifest["entries"],
|
|
578
|
+
): Promise<void>;
|
|
579
|
+
},
|
|
473
580
|
packageName: string,
|
|
474
|
-
|
|
581
|
+
entries: Record<string, ManifestEntry>,
|
|
475
582
|
): Promise<void> {
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
// ====================
|
|
606
|
+
// ==================== ROUND 2: BUILD ====================
|
|
492
607
|
|
|
493
608
|
/**
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
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
|
|
613
|
+
async buildMaterialization(
|
|
522
614
|
environmentName: string,
|
|
523
615
|
packageName: string,
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
616
|
+
id: string,
|
|
617
|
+
instructions: BuildInstruction[],
|
|
618
|
+
): Promise<Materialization> {
|
|
619
|
+
const m = await this.getMaterialization(environmentName, packageName, id);
|
|
527
620
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
539
|
-
environmentName,
|
|
540
|
-
false,
|
|
541
|
-
);
|
|
542
|
-
const pkg = await environment.getPackage(packageName, false);
|
|
638
|
+
this.validateInstructions(plan, instructions);
|
|
543
639
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
packageName,
|
|
640
|
+
this.runInBackground(id, (signal) =>
|
|
641
|
+
this.runRound2(id, environmentName, packageName, instructions, signal),
|
|
547
642
|
);
|
|
548
643
|
|
|
549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
675
|
+
instructions: BuildInstruction[],
|
|
584
676
|
signal: AbortSignal,
|
|
585
|
-
): Promise<
|
|
586
|
-
logger.info("
|
|
587
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
|
776
|
+
const entry = await this.buildOneSource(
|
|
657
777
|
persistSource,
|
|
658
|
-
|
|
778
|
+
instruction,
|
|
659
779
|
connection,
|
|
660
780
|
connectionDigests,
|
|
661
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
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,
|
|
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
|
|
1038
|
+
signal?: AbortSignal,
|
|
712
1039
|
): Promise<{
|
|
713
|
-
graphs:
|
|
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
|
|
719
|
-
const allGraphs: BuildGraph[] = [];
|
|
1045
|
+
const allGraphs: MalloyBuildGraph[] = [];
|
|
720
1046
|
const allSources: Record<string, PersistSource> = {};
|
|
721
1047
|
|
|
722
|
-
for (const modelPath of
|
|
723
|
-
if (signal
|
|
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
|
-
|
|
733
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
}
|