@malloy-publisher/server 0.0.204 → 0.0.206
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.ts +10 -1
- package/dist/app/api-doc.yaml +494 -397
- package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
- package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
- package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
- package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
- package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.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/runtime/publisher.js +318 -0
- package/dist/server.mjs +1703 -1443
- package/package.json +5 -4
- package/scripts/bake-duckdb-extensions.js +104 -0
- 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/controller/watch-mode.controller.ts +176 -46
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +33 -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 +66 -0
- package/src/mcp/error_messages.spec.ts +35 -0
- package/src/mcp/error_messages.ts +14 -1
- package/src/mcp/handler_utils.ts +12 -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/runtime/publisher.js +318 -0
- package/src/server-old.ts +7 -149
- package/src/server.ts +488 -190
- package/src/service/authorize_integration.spec.ts +163 -2
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +142 -34
- 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 +324 -512
- package/src/service/materialization_service.ts +816 -656
- package/src/service/model.ts +444 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +271 -20
- 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/DuckDBConnection.ts +70 -124
- 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/fixtures/authorize-compile/model.malloy +9 -0
- package/tests/fixtures/authorize-compile/publisher.json +4 -0
- package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
- package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/data.csv +3 -0
- package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
- package/tests/fixtures/html-pages-test/public/data.json +1 -0
- package/tests/fixtures/html-pages-test/public/index.html +9 -0
- package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
- package/tests/fixtures/html-pages-test/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/report.malloy +1 -0
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
- package/tests/unit/duckdb/attached_databases.test.ts +111 -0
- package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- package/tests/unit/duckdb/repositories.test.ts +208 -0
- package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
- package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
- package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
- 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,98 @@ 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
|
+
recordMaterializationRound,
|
|
20
|
+
} from "../materialization_metrics";
|
|
21
|
+
import {
|
|
22
|
+
BuildInstruction,
|
|
23
|
+
BuildManifest,
|
|
24
|
+
BuildManifestResult,
|
|
25
|
+
BuildPlan,
|
|
17
26
|
Materialization,
|
|
18
27
|
MaterializationStatus,
|
|
28
|
+
MaterializationUpdate,
|
|
29
|
+
ManifestEntry,
|
|
19
30
|
ResourceRepository,
|
|
20
31
|
} from "../storage/DatabaseInterface";
|
|
21
32
|
import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
|
|
22
33
|
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
34
|
import { Model } from "./model";
|
|
30
35
|
import { splitTablePath } from "./quoting";
|
|
31
36
|
import { resolveEnvironmentId } from "./resolve_environment";
|
|
32
37
|
|
|
38
|
+
type WireBuildGraph = components["schemas"]["BuildGraph"];
|
|
39
|
+
type WirePersistSourcePlan = components["schemas"]["PersistSourcePlan"];
|
|
40
|
+
type WireColumn = components["schemas"]["Column"];
|
|
41
|
+
|
|
33
42
|
/**
|
|
34
|
-
* Length of the
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
43
|
+
* Length of the buildId prefix used when synthesizing staging table names.
|
|
44
|
+
* buildId is a 64-char SHA-256 hex string; 12 hex chars is 48 bits of
|
|
45
|
+
* entropy, well inside every dialect's identifier limit (Postgres is the
|
|
46
|
+
* tightest at 63).
|
|
38
47
|
*/
|
|
39
48
|
const STAGING_BUILD_ID_LEN = 12;
|
|
40
49
|
|
|
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
|
-
*/
|
|
50
|
+
/** Staging suffix appended to a table name while it is being built. */
|
|
46
51
|
export function stagingSuffix(buildId: string): string {
|
|
47
52
|
return `_${buildId.substring(0, STAGING_BUILD_ID_LEN)}`;
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
/** Output columns of a persist source, degrading to [] if unavailable. */
|
|
56
|
+
function deriveColumns(persistSource: PersistSource): WireColumn[] {
|
|
57
|
+
try {
|
|
58
|
+
return persistSource._explore.intrinsicFields
|
|
59
|
+
.filter((f) => f.isAtomicField())
|
|
60
|
+
.map((f) => ({
|
|
61
|
+
name: f.name,
|
|
62
|
+
type: String((f as AtomicField).type),
|
|
63
|
+
}));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
logger.warn("Failed to derive columns for persist source", {
|
|
66
|
+
sourceID: persistSource.sourceID,
|
|
67
|
+
error: err instanceof Error ? err.message : String(err),
|
|
68
|
+
});
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Flatten Malloy's nested BuildNode.dependsOn into a list of sourceIDs. */
|
|
74
|
+
function flattenDependsOn(node: {
|
|
75
|
+
dependsOn: { sourceID: string }[];
|
|
76
|
+
}): string[] {
|
|
77
|
+
return node.dependsOn.map((d) => d.sourceID);
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
81
|
+
* Physical table name the publisher self-assigns in auto-run mode: the
|
|
82
|
+
* `#@ persist name=<table>` value if present, else the Malloy source name.
|
|
83
|
+
* The author owns quoting the `name=` value for the dialect.
|
|
84
|
+
*/
|
|
85
|
+
function selfAssignTableName(persistSource: PersistSource): string {
|
|
86
|
+
try {
|
|
87
|
+
return (
|
|
88
|
+
persistSource.annotations.parseAsTag("@").tag.text("name") ||
|
|
89
|
+
persistSource.name
|
|
90
|
+
);
|
|
91
|
+
} catch {
|
|
92
|
+
return persistSource.name;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Classify a thrown round error as cancelled (cooperative abort) or failed. */
|
|
97
|
+
function outcomeFor(
|
|
98
|
+
_err: unknown,
|
|
99
|
+
signal: AbortSignal | undefined,
|
|
100
|
+
): "failed" | "cancelled" {
|
|
101
|
+
return signal?.aborted ? "cancelled" : "failed";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a Map<name, Connection> for the names a step is about to touch.
|
|
106
|
+
* The package's MalloyConfig caches each lookup, so repeated calls are cheap.
|
|
107
|
+
* A failed lookup is logged and omitted; downstream code reports the missing
|
|
108
|
+
* connection explicitly.
|
|
57
109
|
*/
|
|
58
110
|
async function resolvePackageConnections(
|
|
59
111
|
pkg: { getMalloyConnection(name: string): Promise<MalloyConnection> },
|
|
@@ -76,87 +128,41 @@ async function resolvePackageConnections(
|
|
|
76
128
|
}
|
|
77
129
|
|
|
78
130
|
/**
|
|
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.
|
|
131
|
+
* Allowed status transitions for the two-round protocol. MANIFEST_FILE_READY,
|
|
132
|
+
* FAILED, and CANCELLED are terminal.
|
|
114
133
|
*/
|
|
115
134
|
const VALID_TRANSITIONS: Record<
|
|
116
135
|
MaterializationStatus,
|
|
117
136
|
MaterializationStatus[]
|
|
118
137
|
> = {
|
|
119
|
-
PENDING: ["
|
|
120
|
-
|
|
121
|
-
|
|
138
|
+
PENDING: ["BUILD_PLAN_READY", "FAILED", "CANCELLED"],
|
|
139
|
+
BUILD_PLAN_READY: ["MANIFEST_ROWS_READY", "FAILED", "CANCELLED"],
|
|
140
|
+
MANIFEST_ROWS_READY: ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"],
|
|
141
|
+
MANIFEST_FILE_READY: [],
|
|
122
142
|
FAILED: [],
|
|
123
143
|
CANCELLED: [],
|
|
124
144
|
};
|
|
125
145
|
|
|
126
146
|
/**
|
|
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.
|
|
147
|
+
* Orchestrates the two-round materialization protocol.
|
|
135
148
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
149
|
+
* Round 1 (on create): compile the package, compute a build plan (per-source
|
|
150
|
+
* buildId, columns, lineage inputs, connection capability) and pause at
|
|
151
|
+
* BUILD_PLAN_READY without writing any tables. Round 2 (action=build): build
|
|
152
|
+
* only the control-plane-instructed sources into their assigned physical
|
|
153
|
+
* names and assemble the manifest, which is returned inline for the control
|
|
154
|
+
* plane to persist.
|
|
140
155
|
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* single-writer until a shared lease is added — see the scope note on
|
|
146
|
-
* `DuckLakeManifestStore`.
|
|
156
|
+
* At most one active materialization per (environment, package) is enforced
|
|
157
|
+
* by the DB-level unique index on `materializations.active_key` (see
|
|
158
|
+
* {@link MaterializationRepository}). Cancellation is cooperative via
|
|
159
|
+
* AbortController.
|
|
147
160
|
*/
|
|
148
161
|
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
|
-
*/
|
|
162
|
+
/** In-flight runs, so they can be cancelled. In-process only. */
|
|
154
163
|
private runningAbortControllers = new Map<string, AbortController>();
|
|
155
164
|
|
|
156
|
-
constructor(
|
|
157
|
-
private environmentStore: EnvironmentStore,
|
|
158
|
-
private manifestService: ManifestService,
|
|
159
|
-
) {}
|
|
165
|
+
constructor(private environmentStore: EnvironmentStore) {}
|
|
160
166
|
|
|
161
167
|
private get repository(): ResourceRepository {
|
|
162
168
|
return this.environmentStore.storageManager.getRepository();
|
|
@@ -168,39 +174,32 @@ export class MaterializationService {
|
|
|
168
174
|
current: MaterializationStatus,
|
|
169
175
|
next: MaterializationStatus,
|
|
170
176
|
): void {
|
|
171
|
-
|
|
172
|
-
if (!allowed.includes(next)) {
|
|
177
|
+
if (!VALID_TRANSITIONS[current].includes(next)) {
|
|
173
178
|
throw new InvalidStateTransitionError(
|
|
174
179
|
`Cannot transition from ${current} to ${next}`,
|
|
175
180
|
);
|
|
176
181
|
}
|
|
177
182
|
}
|
|
178
183
|
|
|
179
|
-
private async
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
extra?:
|
|
183
|
-
startedAt?: Date;
|
|
184
|
-
completedAt?: Date;
|
|
185
|
-
error?: string | null;
|
|
186
|
-
metadata?: Record<string, unknown> | null;
|
|
187
|
-
},
|
|
184
|
+
private async transition(
|
|
185
|
+
id: string,
|
|
186
|
+
next: MaterializationStatus,
|
|
187
|
+
extra?: Omit<MaterializationUpdate, "status">,
|
|
188
188
|
): Promise<Materialization> {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
if (!execution) {
|
|
189
|
+
const current = await this.repository.getMaterializationById(id);
|
|
190
|
+
if (!current) {
|
|
192
191
|
throw new MaterializationNotFoundError(
|
|
193
|
-
`
|
|
192
|
+
`Materialization ${id} not found`,
|
|
194
193
|
);
|
|
195
194
|
}
|
|
196
|
-
this.validateTransition(
|
|
197
|
-
return this.repository.updateMaterialization(
|
|
198
|
-
status:
|
|
195
|
+
this.validateTransition(current.status, next);
|
|
196
|
+
return this.repository.updateMaterialization(id, {
|
|
197
|
+
status: next,
|
|
199
198
|
...extra,
|
|
200
199
|
});
|
|
201
200
|
}
|
|
202
201
|
|
|
203
|
-
// ====================
|
|
202
|
+
// ==================== QUERIES ====================
|
|
204
203
|
|
|
205
204
|
async listMaterializations(
|
|
206
205
|
environmentName: string,
|
|
@@ -218,45 +217,45 @@ export class MaterializationService {
|
|
|
218
217
|
async getMaterialization(
|
|
219
218
|
environmentName: string,
|
|
220
219
|
packageName: string,
|
|
221
|
-
|
|
220
|
+
id: string,
|
|
222
221
|
): Promise<Materialization> {
|
|
223
222
|
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
224
|
-
const
|
|
223
|
+
const m = await this.repository.getMaterializationById(id);
|
|
225
224
|
if (
|
|
226
|
-
!
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
!m ||
|
|
226
|
+
m.environmentId !== environmentId ||
|
|
227
|
+
m.packageName !== packageName
|
|
229
228
|
) {
|
|
230
229
|
throw new MaterializationNotFoundError(
|
|
231
|
-
`Materialization ${
|
|
230
|
+
`Materialization ${id} not found for package ${packageName}`,
|
|
232
231
|
);
|
|
233
232
|
}
|
|
234
|
-
return
|
|
233
|
+
return m;
|
|
235
234
|
}
|
|
236
235
|
|
|
237
|
-
// ====================
|
|
236
|
+
// ==================== ROUND 1: CREATE + PLAN ====================
|
|
238
237
|
|
|
239
238
|
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
239
|
+
* Create a materialization and start Round 1 (compile + plan) in the
|
|
240
|
+
* background. Returns the PENDING record immediately.
|
|
242
241
|
*/
|
|
243
242
|
async createMaterialization(
|
|
244
243
|
environmentName: string,
|
|
245
244
|
packageName: string,
|
|
246
|
-
options: {
|
|
245
|
+
options: {
|
|
246
|
+
forceRefresh?: boolean;
|
|
247
|
+
sourceNames?: string[];
|
|
248
|
+
pauseBetweenPhases?: boolean;
|
|
249
|
+
} = {},
|
|
247
250
|
): Promise<Materialization> {
|
|
248
251
|
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
249
252
|
|
|
250
|
-
// Verify the package exists.
|
|
251
253
|
const environment = await this.environmentStore.getEnvironment(
|
|
252
254
|
environmentName,
|
|
253
255
|
false,
|
|
254
256
|
);
|
|
255
257
|
await environment.getPackage(packageName, false);
|
|
256
258
|
|
|
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
259
|
const active = await this.repository.getActiveMaterialization(
|
|
261
260
|
environmentId,
|
|
262
261
|
packageName,
|
|
@@ -267,13 +266,17 @@ export class MaterializationService {
|
|
|
267
266
|
);
|
|
268
267
|
}
|
|
269
268
|
|
|
269
|
+
const pauseBetweenPhases = options.pauseBetweenPhases ?? false;
|
|
270
|
+
const forceRefresh = options.forceRefresh ?? false;
|
|
270
271
|
const metadata = {
|
|
271
|
-
forceRefresh
|
|
272
|
-
|
|
272
|
+
forceRefresh,
|
|
273
|
+
sourceNames: options.sourceNames ?? null,
|
|
274
|
+
pauseBetweenPhases,
|
|
273
275
|
};
|
|
274
276
|
|
|
277
|
+
let created: Materialization;
|
|
275
278
|
try {
|
|
276
|
-
|
|
279
|
+
created = await this.repository.createMaterialization(
|
|
277
280
|
environmentId,
|
|
278
281
|
packageName,
|
|
279
282
|
"PENDING",
|
|
@@ -281,8 +284,6 @@ export class MaterializationService {
|
|
|
281
284
|
);
|
|
282
285
|
} catch (err) {
|
|
283
286
|
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
287
|
const winner = await this.repository.getActiveMaterialization(
|
|
287
288
|
environmentId,
|
|
288
289
|
packageName,
|
|
@@ -295,343 +296,465 @@ export class MaterializationService {
|
|
|
295
296
|
}
|
|
296
297
|
throw err;
|
|
297
298
|
}
|
|
299
|
+
|
|
300
|
+
// Default (auto-run): compile, self-assign names, build, and auto-load in
|
|
301
|
+
// one pass. Opt-in two-round: pause at BUILD_PLAN_READY for the control
|
|
302
|
+
// plane to drive Round 2.
|
|
303
|
+
this.runInBackground(created.id, (signal) =>
|
|
304
|
+
pauseBetweenPhases
|
|
305
|
+
? this.runRound1(
|
|
306
|
+
created.id,
|
|
307
|
+
environmentName,
|
|
308
|
+
packageName,
|
|
309
|
+
options.sourceNames,
|
|
310
|
+
signal,
|
|
311
|
+
)
|
|
312
|
+
: this.runAuto(
|
|
313
|
+
created.id,
|
|
314
|
+
environmentName,
|
|
315
|
+
packageName,
|
|
316
|
+
options.sourceNames,
|
|
317
|
+
forceRefresh,
|
|
318
|
+
signal,
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return created;
|
|
298
323
|
}
|
|
299
324
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
* background. Returns the RUNNING execution immediately.
|
|
303
|
-
*/
|
|
304
|
-
async startMaterialization(
|
|
325
|
+
private async runRound1(
|
|
326
|
+
id: string,
|
|
305
327
|
environmentName: string,
|
|
306
328
|
packageName: string,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
329
|
+
sourceNames: string[] | undefined,
|
|
330
|
+
signal?: AbortSignal,
|
|
331
|
+
): Promise<void> {
|
|
332
|
+
logger.info("Materialization Round 1: compile + plan", {
|
|
333
|
+
materializationId: id,
|
|
312
334
|
packageName,
|
|
313
|
-
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
if (execution.status !== "PENDING") {
|
|
317
|
-
throw new InvalidStateTransitionError(
|
|
318
|
-
`Materialization ${buildId} is ${execution.status}, expected PENDING`,
|
|
319
|
-
);
|
|
320
|
-
}
|
|
335
|
+
});
|
|
336
|
+
const startedAt = Date.now();
|
|
321
337
|
|
|
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})`,
|
|
338
|
+
try {
|
|
339
|
+
const environment = await this.environmentStore.getEnvironment(
|
|
340
|
+
environmentName,
|
|
341
|
+
false,
|
|
330
342
|
);
|
|
331
|
-
|
|
343
|
+
const pkg = await environment.getPackage(packageName, false);
|
|
332
344
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
345
|
+
const { graphs, sources, connectionDigests } =
|
|
346
|
+
await environment.withPackageLock(packageName, () =>
|
|
347
|
+
this.compilePackageBuildPlan(pkg, signal),
|
|
348
|
+
);
|
|
336
349
|
|
|
337
|
-
|
|
350
|
+
const buildPlan = this.deriveBuildPlan(
|
|
351
|
+
graphs,
|
|
352
|
+
sources,
|
|
353
|
+
connectionDigests,
|
|
354
|
+
sourceNames,
|
|
355
|
+
);
|
|
338
356
|
|
|
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),
|
|
357
|
+
await this.transition(id, "BUILD_PLAN_READY", { buildPlan });
|
|
358
|
+
this.recordRound("round1", "success", startedAt);
|
|
359
|
+
logger.info("Materialization Round 1 complete", {
|
|
360
|
+
materializationId: id,
|
|
361
|
+
packageName,
|
|
362
|
+
sourceCount: Object.keys(buildPlan.sources).length,
|
|
363
|
+
durationMs: Date.now() - startedAt,
|
|
350
364
|
});
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
|
|
365
|
+
} catch (err) {
|
|
366
|
+
this.recordRound("round1", outcomeFor(err, signal), startedAt);
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
354
369
|
}
|
|
355
370
|
|
|
356
|
-
|
|
357
|
-
|
|
371
|
+
// ==================== AUTO-RUN (DEFAULT) ====================
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Auto-run (default, pauseBetweenPhases=false). Compile + plan, then
|
|
375
|
+
* self-assign physical table names (the `#@ persist name=` value, else the
|
|
376
|
+
* source name) with realization=COPY, skip sources whose buildId is
|
|
377
|
+
* unchanged since the most recent successful manifest (unless forceRefresh),
|
|
378
|
+
* build the rest, assemble the manifest, and auto-load it into the package
|
|
379
|
+
* models so subsequent queries resolve to the materialized tables. No pause,
|
|
380
|
+
* no second round.
|
|
381
|
+
*/
|
|
382
|
+
private async runAuto(
|
|
383
|
+
id: string,
|
|
358
384
|
environmentName: string,
|
|
359
|
-
environmentId: string,
|
|
360
385
|
packageName: string,
|
|
361
|
-
|
|
386
|
+
sourceNames: string[] | undefined,
|
|
387
|
+
forceRefresh: boolean,
|
|
388
|
+
signal: AbortSignal,
|
|
362
389
|
): Promise<void> {
|
|
363
|
-
|
|
364
|
-
|
|
390
|
+
logger.info("Materialization auto-run: compile + build + load", {
|
|
391
|
+
materializationId: id,
|
|
392
|
+
packageName,
|
|
393
|
+
});
|
|
394
|
+
const startedAt = Date.now();
|
|
365
395
|
|
|
366
396
|
try {
|
|
367
|
-
const
|
|
397
|
+
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
398
|
+
const environment = await this.environmentStore.getEnvironment(
|
|
368
399
|
environmentName,
|
|
369
|
-
|
|
370
|
-
packageName,
|
|
371
|
-
!!metadata.forceRefresh,
|
|
372
|
-
abortController.signal,
|
|
400
|
+
false,
|
|
373
401
|
);
|
|
402
|
+
const pkg = await environment.getPackage(packageName, false);
|
|
374
403
|
|
|
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
|
-
}
|
|
404
|
+
const compiled = await environment.withPackageLock(packageName, () =>
|
|
405
|
+
this.compilePackageBuildPlan(pkg, signal),
|
|
406
|
+
);
|
|
393
407
|
|
|
394
|
-
|
|
408
|
+
const buildPlan = this.deriveBuildPlan(
|
|
409
|
+
compiled.graphs,
|
|
410
|
+
compiled.sources,
|
|
411
|
+
compiled.connectionDigests,
|
|
412
|
+
sourceNames,
|
|
413
|
+
);
|
|
414
|
+
await this.transition(id, "BUILD_PLAN_READY", { buildPlan });
|
|
415
|
+
|
|
416
|
+
// Skip-if-unchanged: reuse tables from the most recent successful
|
|
417
|
+
// manifest for sources whose buildId is unchanged, unless forceRefresh.
|
|
418
|
+
const priorEntries = forceRefresh
|
|
419
|
+
? {}
|
|
420
|
+
: await this.getMostRecentManifestEntries(
|
|
421
|
+
environmentId,
|
|
422
|
+
packageName,
|
|
423
|
+
id,
|
|
424
|
+
);
|
|
425
|
+
const { instructions, carried } = this.deriveSelfInstructions(
|
|
426
|
+
compiled,
|
|
427
|
+
sourceNames,
|
|
428
|
+
priorEntries,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const entries = await this.executeInstructedBuild(
|
|
432
|
+
compiled,
|
|
433
|
+
instructions,
|
|
434
|
+
carried,
|
|
435
|
+
signal,
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
await this.transition(id, "MANIFEST_ROWS_READY");
|
|
439
|
+
|
|
440
|
+
const manifestResult: BuildManifestResult = {
|
|
441
|
+
builtAt: new Date().toISOString(),
|
|
442
|
+
entries,
|
|
443
|
+
strict: false,
|
|
444
|
+
};
|
|
445
|
+
const durationMs = Date.now() - startedAt;
|
|
446
|
+
await this.transition(id, "MANIFEST_FILE_READY", {
|
|
395
447
|
completedAt: new Date(),
|
|
396
|
-
|
|
448
|
+
manifest: manifestResult,
|
|
449
|
+
metadata: {
|
|
450
|
+
forceRefresh,
|
|
451
|
+
sourceNames: sourceNames ?? null,
|
|
452
|
+
pauseBetweenPhases: false,
|
|
453
|
+
sourcesBuilt: instructions.length,
|
|
454
|
+
sourcesReused: Object.keys(carried).length,
|
|
455
|
+
durationMs,
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
await this.autoLoadManifest(environment, packageName, entries);
|
|
460
|
+
|
|
461
|
+
this.recordRound("auto", "success", startedAt);
|
|
462
|
+
logger.info("Materialization auto-run complete", {
|
|
463
|
+
materializationId: id,
|
|
464
|
+
packageName,
|
|
465
|
+
sourcesBuilt: instructions.length,
|
|
466
|
+
sourcesReused: Object.keys(carried).length,
|
|
467
|
+
durationMs,
|
|
397
468
|
});
|
|
398
469
|
} catch (err) {
|
|
399
|
-
|
|
470
|
+
this.recordRound("auto", outcomeFor(err, signal), startedAt);
|
|
471
|
+
throw err;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
400
474
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
475
|
+
/**
|
|
476
|
+
* Derive the publisher's own build instructions for auto-run. Each persist
|
|
477
|
+
* source (respecting the optional sourceNames filter) gets a self-assigned
|
|
478
|
+
* physical table name and COPY realization, unless its buildId is unchanged
|
|
479
|
+
* since `priorEntries` — those are carried forward (reused) instead of
|
|
480
|
+
* rebuilt.
|
|
481
|
+
*/
|
|
482
|
+
private deriveSelfInstructions(
|
|
483
|
+
compiled: {
|
|
484
|
+
graphs: MalloyBuildGraph[];
|
|
485
|
+
sources: Record<string, PersistSource>;
|
|
486
|
+
connectionDigests: Record<string, string>;
|
|
487
|
+
},
|
|
488
|
+
sourceNames: string[] | undefined,
|
|
489
|
+
priorEntries: Record<string, ManifestEntry>,
|
|
490
|
+
): {
|
|
491
|
+
instructions: BuildInstruction[];
|
|
492
|
+
carried: Record<string, ManifestEntry>;
|
|
493
|
+
} {
|
|
494
|
+
const include = sourceNames ? new Set(sourceNames) : null;
|
|
495
|
+
const instructions: BuildInstruction[] = [];
|
|
496
|
+
const carried: Record<string, ManifestEntry> = {};
|
|
497
|
+
const seen = new Set<string>();
|
|
498
|
+
|
|
499
|
+
for (const graph of compiled.graphs) {
|
|
500
|
+
for (const level of graph.nodes) {
|
|
501
|
+
for (const node of level) {
|
|
502
|
+
const persistSource = compiled.sources[node.sourceID];
|
|
503
|
+
if (!persistSource) continue;
|
|
504
|
+
if (include && !include.has(persistSource.name)) continue;
|
|
505
|
+
|
|
506
|
+
const buildId = persistSource.makeBuildId(
|
|
507
|
+
compiled.connectionDigests[persistSource.connectionName],
|
|
508
|
+
persistSource.getSQL(),
|
|
509
|
+
);
|
|
510
|
+
if (seen.has(buildId)) continue;
|
|
511
|
+
seen.add(buildId);
|
|
512
|
+
|
|
513
|
+
const prior = priorEntries[buildId];
|
|
514
|
+
if (prior && prior.physicalTableName) {
|
|
515
|
+
carried[buildId] = prior;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
instructions.push({
|
|
520
|
+
buildId,
|
|
521
|
+
materializedTableId: `local-${buildId.substring(
|
|
522
|
+
0,
|
|
523
|
+
STAGING_BUILD_ID_LEN,
|
|
524
|
+
)}`,
|
|
525
|
+
physicalTableName: selfAssignTableName(persistSource),
|
|
526
|
+
realization: "COPY",
|
|
411
527
|
});
|
|
412
528
|
}
|
|
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
529
|
}
|
|
423
|
-
} finally {
|
|
424
|
-
this.runningAbortControllers.delete(executionId);
|
|
425
530
|
}
|
|
531
|
+
|
|
532
|
+
return { instructions, carried };
|
|
426
533
|
}
|
|
427
534
|
|
|
428
535
|
/**
|
|
429
|
-
*
|
|
536
|
+
* Entries of the most recent successful (MANIFEST_FILE_READY) materialization
|
|
537
|
+
* for this package, used for skip-if-unchanged. Excludes the in-flight run.
|
|
430
538
|
*/
|
|
431
|
-
async
|
|
432
|
-
|
|
539
|
+
private async getMostRecentManifestEntries(
|
|
540
|
+
environmentId: string,
|
|
433
541
|
packageName: string,
|
|
434
|
-
|
|
435
|
-
): Promise<
|
|
436
|
-
const
|
|
437
|
-
|
|
542
|
+
excludeId: string,
|
|
543
|
+
): Promise<Record<string, ManifestEntry>> {
|
|
544
|
+
const list = await this.repository.listMaterializations(
|
|
545
|
+
environmentId,
|
|
438
546
|
packageName,
|
|
439
|
-
buildId,
|
|
440
547
|
);
|
|
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
|
-
});
|
|
548
|
+
for (const m of list) {
|
|
549
|
+
if (m.id === excludeId) continue;
|
|
550
|
+
if (m.status === "MANIFEST_FILE_READY" && m.manifest?.entries) {
|
|
551
|
+
return m.manifest.entries;
|
|
552
|
+
}
|
|
464
553
|
}
|
|
554
|
+
return {};
|
|
465
555
|
}
|
|
466
556
|
|
|
467
557
|
/**
|
|
468
|
-
*
|
|
469
|
-
*
|
|
558
|
+
* Load a freshly produced manifest into the package's models so persist
|
|
559
|
+
* references resolve to the materialized tables. Best-effort: a load failure
|
|
560
|
+
* is logged, not fatal (the run already reached MANIFEST_FILE_READY).
|
|
470
561
|
*/
|
|
471
|
-
async
|
|
472
|
-
|
|
562
|
+
private async autoLoadManifest(
|
|
563
|
+
environment: {
|
|
564
|
+
reloadAllModelsForPackage(
|
|
565
|
+
packageName: string,
|
|
566
|
+
manifest: BuildManifest["entries"],
|
|
567
|
+
): Promise<void>;
|
|
568
|
+
},
|
|
473
569
|
packageName: string,
|
|
474
|
-
|
|
570
|
+
entries: Record<string, ManifestEntry>,
|
|
475
571
|
): Promise<void> {
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
572
|
+
const manifestEntries: BuildManifest["entries"] = {};
|
|
573
|
+
for (const [buildId, entry] of Object.entries(entries)) {
|
|
574
|
+
if (entry.physicalTableName) {
|
|
575
|
+
manifestEntries[buildId] = { tableName: entry.physicalTableName };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
await environment.reloadAllModelsForPackage(
|
|
580
|
+
packageName,
|
|
581
|
+
manifestEntries,
|
|
485
582
|
);
|
|
583
|
+
logger.info("Auto-run: loaded manifest into package models", {
|
|
584
|
+
packageName,
|
|
585
|
+
entryCount: Object.keys(manifestEntries).length,
|
|
586
|
+
});
|
|
587
|
+
} catch (err) {
|
|
588
|
+
logger.warn("Auto-run: failed to load manifest into package models", {
|
|
589
|
+
packageName,
|
|
590
|
+
error: err instanceof Error ? err.message : String(err),
|
|
591
|
+
});
|
|
486
592
|
}
|
|
487
|
-
|
|
488
|
-
await this.repository.deleteMaterialization(execution.id);
|
|
489
593
|
}
|
|
490
594
|
|
|
491
|
-
// ====================
|
|
595
|
+
// ==================== ROUND 2: BUILD ====================
|
|
492
596
|
|
|
493
597
|
/**
|
|
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.
|
|
598
|
+
* Round 2. Validates the instructions against the stored build plan,
|
|
599
|
+
* transitions out of BUILD_PLAN_READY, and builds the instructed sources
|
|
600
|
+
* in the background. Returns the accepted record (202).
|
|
520
601
|
*/
|
|
521
|
-
async
|
|
602
|
+
async buildMaterialization(
|
|
522
603
|
environmentName: string,
|
|
523
604
|
packageName: string,
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
605
|
+
id: string,
|
|
606
|
+
instructions: BuildInstruction[],
|
|
607
|
+
): Promise<Materialization> {
|
|
608
|
+
const m = await this.getMaterialization(environmentName, packageName, id);
|
|
527
609
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
610
|
+
if (!m.pauseBetweenPhases) {
|
|
611
|
+
throw new InvalidStateTransitionError(
|
|
612
|
+
`Materialization ${id} is an auto-run materialization; action=build does not apply (set pauseBetweenPhases=true for the two-round flow)`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (m.status !== "BUILD_PLAN_READY") {
|
|
616
|
+
throw new InvalidStateTransitionError(
|
|
617
|
+
`Materialization ${id} is ${m.status}, expected BUILD_PLAN_READY`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
const plan = m.buildPlan;
|
|
621
|
+
if (!plan) {
|
|
622
|
+
throw new InvalidStateTransitionError(
|
|
623
|
+
`Materialization ${id} has no build plan`,
|
|
535
624
|
);
|
|
536
625
|
}
|
|
537
626
|
|
|
538
|
-
|
|
539
|
-
environmentName,
|
|
540
|
-
false,
|
|
541
|
-
);
|
|
542
|
-
const pkg = await environment.getPackage(packageName, false);
|
|
543
|
-
|
|
544
|
-
const entries = await this.manifestService.listEntries(
|
|
545
|
-
environmentId,
|
|
546
|
-
packageName,
|
|
547
|
-
);
|
|
627
|
+
this.validateInstructions(plan, instructions);
|
|
548
628
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
entries.map((e) => e.connectionName),
|
|
629
|
+
this.runInBackground(id, (signal) =>
|
|
630
|
+
this.runRound2(id, environmentName, packageName, instructions, signal),
|
|
552
631
|
);
|
|
553
632
|
|
|
554
|
-
|
|
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
|
-
});
|
|
633
|
+
return m;
|
|
565
634
|
}
|
|
566
635
|
|
|
567
|
-
|
|
636
|
+
private validateInstructions(
|
|
637
|
+
plan: BuildPlan,
|
|
638
|
+
instructions: BuildInstruction[],
|
|
639
|
+
): void {
|
|
640
|
+
const plannedBuildIds = new Set<string>();
|
|
641
|
+
for (const source of Object.values(plan.sources)) {
|
|
642
|
+
plannedBuildIds.add(source.buildId);
|
|
643
|
+
}
|
|
568
644
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
645
|
+
for (const instruction of instructions) {
|
|
646
|
+
if (!plannedBuildIds.has(instruction.buildId)) {
|
|
647
|
+
throw new BadRequestError(
|
|
648
|
+
`Instruction references unknown buildId '${instruction.buildId}'`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
// v0 is COPY-only; SNAPSHOT lands once clone semantics are defined.
|
|
652
|
+
if (instruction.realization === "SNAPSHOT") {
|
|
653
|
+
throw new BadRequestError(
|
|
654
|
+
"realization=SNAPSHOT is not supported in v0 (COPY only)",
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private async runRound2(
|
|
661
|
+
id: string,
|
|
580
662
|
environmentName: string,
|
|
581
|
-
environmentId: string,
|
|
582
663
|
packageName: string,
|
|
583
|
-
|
|
664
|
+
instructions: BuildInstruction[],
|
|
584
665
|
signal: AbortSignal,
|
|
585
|
-
): Promise<
|
|
586
|
-
logger.info("
|
|
587
|
-
|
|
666
|
+
): Promise<void> {
|
|
667
|
+
logger.info("Materialization Round 2: build", {
|
|
668
|
+
materializationId: id,
|
|
588
669
|
packageName,
|
|
670
|
+
sourceCount: instructions.length,
|
|
589
671
|
});
|
|
672
|
+
const startedAt = Date.now();
|
|
590
673
|
|
|
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
|
-
);
|
|
674
|
+
try {
|
|
675
|
+
const environment = await this.environmentStore.getEnvironment(
|
|
676
|
+
environmentName,
|
|
677
|
+
false,
|
|
678
|
+
);
|
|
679
|
+
const pkg = await environment.getPackage(packageName, false);
|
|
614
680
|
|
|
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, () =>
|
|
681
|
+
const compiled = await environment.withPackageLock(packageName, () =>
|
|
623
682
|
this.compilePackageBuildPlan(pkg, signal),
|
|
624
683
|
);
|
|
625
684
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
685
|
+
const entries = await this.executeInstructedBuild(
|
|
686
|
+
compiled,
|
|
687
|
+
instructions,
|
|
688
|
+
{},
|
|
689
|
+
signal,
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
await this.transition(id, "MANIFEST_ROWS_READY");
|
|
693
|
+
|
|
694
|
+
const manifestResult: BuildManifestResult = {
|
|
695
|
+
builtAt: new Date().toISOString(),
|
|
696
|
+
entries,
|
|
697
|
+
strict: false,
|
|
698
|
+
};
|
|
699
|
+
const durationMs = Date.now() - startedAt;
|
|
700
|
+
await this.transition(id, "MANIFEST_FILE_READY", {
|
|
701
|
+
completedAt: new Date(),
|
|
702
|
+
manifest: manifestResult,
|
|
703
|
+
metadata: {
|
|
704
|
+
sourcesBuilt: Object.keys(entries).length,
|
|
705
|
+
durationMs,
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
this.recordRound("round2", "success", startedAt);
|
|
710
|
+
logger.info("Materialization Round 2 complete", {
|
|
711
|
+
materializationId: id,
|
|
712
|
+
packageName,
|
|
713
|
+
sourcesBuilt: Object.keys(entries).length,
|
|
714
|
+
durationMs,
|
|
715
|
+
});
|
|
716
|
+
} catch (err) {
|
|
717
|
+
this.recordRound("round2", outcomeFor(err, signal), startedAt);
|
|
718
|
+
throw err;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
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;
|
|
741
|
+
|
|
742
|
+
const byBuildId = new Map<string, BuildInstruction>();
|
|
743
|
+
for (const instruction of instructions) {
|
|
744
|
+
byBuildId.set(instruction.buildId, instruction);
|
|
629
745
|
}
|
|
630
746
|
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const
|
|
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,245 @@ 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;
|
|
655
771
|
|
|
656
|
-
const
|
|
772
|
+
const buildId = persistSource.makeBuildId(
|
|
773
|
+
connectionDigests[persistSource.connectionName],
|
|
774
|
+
persistSource.getSQL(),
|
|
775
|
+
);
|
|
776
|
+
const instruction = byBuildId.get(buildId);
|
|
777
|
+
if (!instruction) continue;
|
|
778
|
+
|
|
779
|
+
const entry = await this.buildOneSource(
|
|
657
780
|
persistSource,
|
|
658
|
-
|
|
781
|
+
instruction,
|
|
659
782
|
connection,
|
|
660
783
|
connectionDigests,
|
|
661
|
-
|
|
662
|
-
environmentId,
|
|
663
|
-
packageName,
|
|
664
|
-
knownMaterializedTables,
|
|
784
|
+
manifest,
|
|
665
785
|
);
|
|
666
|
-
|
|
667
|
-
sourceResults.push(result);
|
|
668
|
-
if (result.status === "built") sourcesBuilt++;
|
|
669
|
-
else sourcesSkipped++;
|
|
786
|
+
entries[buildId] = entry;
|
|
670
787
|
}
|
|
671
788
|
}
|
|
672
789
|
}
|
|
673
790
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
791
|
+
return entries;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Build a single instructed source into its assigned physical table.
|
|
796
|
+
* COPY uses a staging table + atomic rename for crash-safety; the staging
|
|
797
|
+
* name derives from the buildId. Records and returns the manifest entry.
|
|
798
|
+
*/
|
|
799
|
+
private async buildOneSource(
|
|
800
|
+
persistSource: PersistSource,
|
|
801
|
+
instruction: BuildInstruction,
|
|
802
|
+
connection: MalloyConnection,
|
|
803
|
+
connectionDigests: Record<string, string>,
|
|
804
|
+
manifest: Manifest,
|
|
805
|
+
): Promise<ManifestEntry> {
|
|
806
|
+
const buildId = instruction.buildId;
|
|
807
|
+
const physicalTableName = instruction.physicalTableName;
|
|
808
|
+
const buildSQL = persistSource.getSQL({
|
|
809
|
+
buildManifest: manifest.buildManifest,
|
|
810
|
+
connectionDigests,
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
const { bareName } = splitTablePath(physicalTableName);
|
|
814
|
+
const stagingTableName = `${physicalTableName}${stagingSuffix(buildId)}`;
|
|
815
|
+
|
|
816
|
+
const startTime = performance.now();
|
|
817
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
|
|
818
|
+
try {
|
|
819
|
+
await connection.runSQL(
|
|
820
|
+
`CREATE TABLE ${stagingTableName} AS (${buildSQL})`,
|
|
821
|
+
);
|
|
822
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${physicalTableName}`);
|
|
823
|
+
await connection.runSQL(
|
|
824
|
+
`ALTER TABLE ${stagingTableName} RENAME TO ${bareName}`,
|
|
825
|
+
);
|
|
826
|
+
} catch (err) {
|
|
827
|
+
try {
|
|
828
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
|
|
829
|
+
} catch (cleanupErr) {
|
|
830
|
+
logger.warn(
|
|
831
|
+
"Round 2: failed to clean up staging table after a failed build; physical leak",
|
|
832
|
+
{
|
|
833
|
+
stagingTableName,
|
|
834
|
+
connectionName: persistSource.connectionName,
|
|
835
|
+
cleanupError:
|
|
836
|
+
cleanupErr instanceof Error
|
|
837
|
+
? cleanupErr.message
|
|
838
|
+
: String(cleanupErr),
|
|
839
|
+
},
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
throw err;
|
|
843
|
+
}
|
|
681
844
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
845
|
+
// Make this table visible to downstream sources built later this round.
|
|
846
|
+
manifest.update(buildId, { tableName: physicalTableName });
|
|
847
|
+
|
|
848
|
+
logger.info(`Round 2 built source ${persistSource.name}`, {
|
|
849
|
+
physicalTableName,
|
|
850
|
+
durationMs: Math.round(performance.now() - startTime),
|
|
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,
|
|
695
861
|
};
|
|
696
862
|
}
|
|
697
863
|
|
|
698
|
-
// ====================
|
|
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
|
+
logger.info("Dropped materialized table on delete", {
|
|
984
|
+
materializationId: m.id,
|
|
985
|
+
physicalTableName,
|
|
986
|
+
connectionName,
|
|
987
|
+
});
|
|
988
|
+
} catch (err) {
|
|
989
|
+
logger.warn("Failed to drop materialized table on delete", {
|
|
990
|
+
materializationId: m.id,
|
|
991
|
+
physicalTableName,
|
|
992
|
+
connectionName,
|
|
993
|
+
error: err instanceof Error ? err.message : String(err),
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ==================== BUILD PLAN COMPILATION ====================
|
|
699
1000
|
|
|
700
1001
|
/**
|
|
701
1002
|
* Compile every model in the package and collect the dependency-ordered
|
|
702
|
-
* build graphs, persist sources,
|
|
1003
|
+
* build graphs, persist sources, connection digests, and resolved
|
|
1004
|
+
* connections.
|
|
703
1005
|
*/
|
|
704
1006
|
private async compilePackageBuildPlan(
|
|
705
1007
|
pkg: {
|
|
@@ -708,19 +1010,18 @@ export class MaterializationService {
|
|
|
708
1010
|
getMalloyConfig(): MalloyConfig;
|
|
709
1011
|
getMalloyConnection(name: string): Promise<MalloyConnection>;
|
|
710
1012
|
},
|
|
711
|
-
signal
|
|
1013
|
+
signal?: AbortSignal,
|
|
712
1014
|
): Promise<{
|
|
713
|
-
graphs:
|
|
1015
|
+
graphs: MalloyBuildGraph[];
|
|
714
1016
|
sources: Record<string, PersistSource>;
|
|
715
1017
|
connectionDigests: Record<string, string>;
|
|
716
1018
|
connections: Map<string, MalloyConnection>;
|
|
717
1019
|
}> {
|
|
718
|
-
const
|
|
719
|
-
const allGraphs: BuildGraph[] = [];
|
|
1020
|
+
const allGraphs: MalloyBuildGraph[] = [];
|
|
720
1021
|
const allSources: Record<string, PersistSource> = {};
|
|
721
1022
|
|
|
722
|
-
for (const modelPath of
|
|
723
|
-
if (signal
|
|
1023
|
+
for (const modelPath of pkg.getModelPaths()) {
|
|
1024
|
+
if (signal?.aborted) throw new Error("Build cancelled");
|
|
724
1025
|
|
|
725
1026
|
const { runtime, modelURL, importBaseURL } =
|
|
726
1027
|
await Model.getModelRuntime(
|
|
@@ -728,72 +1029,28 @@ export class MaterializationService {
|
|
|
728
1029
|
modelPath,
|
|
729
1030
|
pkg.getMalloyConfig(),
|
|
730
1031
|
);
|
|
1032
|
+
const malloyModel = await runtime
|
|
1033
|
+
.loadModel(modelURL, { importBaseURL })
|
|
1034
|
+
.getModel();
|
|
731
1035
|
|
|
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
|
-
|
|
1036
|
+
// getBuildPlan() returns empty graphs for models with no #@ persist
|
|
1037
|
+
// sources, so non-persist models are simply skipped below.
|
|
748
1038
|
const buildPlan = malloyModel.getBuildPlan();
|
|
749
|
-
|
|
750
1039
|
for (const msg of buildPlan.tagParseLog) {
|
|
751
1040
|
logger.warn("Persist annotation issue", {
|
|
752
1041
|
modelPath,
|
|
753
1042
|
message: msg.message,
|
|
754
1043
|
severity: msg.severity,
|
|
755
|
-
code: msg.code,
|
|
756
1044
|
});
|
|
757
1045
|
}
|
|
1046
|
+
if (buildPlan.graphs.length === 0) continue;
|
|
758
1047
|
|
|
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
|
-
}
|
|
1048
|
+
allGraphs.push(...buildPlan.graphs);
|
|
1049
|
+
for (const [sourceID, source] of Object.entries(buildPlan.sources)) {
|
|
1050
|
+
allSources[sourceID] = source;
|
|
771
1051
|
}
|
|
772
1052
|
}
|
|
773
1053
|
|
|
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
1054
|
const connections = await resolvePackageConnections(
|
|
798
1055
|
pkg,
|
|
799
1056
|
allGraphs.map((g) => g.connectionName),
|
|
@@ -814,187 +1071,90 @@ export class MaterializationService {
|
|
|
814
1071
|
};
|
|
815
1072
|
}
|
|
816
1073
|
|
|
817
|
-
/**
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
*/
|
|
822
|
-
private async buildOneSource(
|
|
823
|
-
persistSource: PersistSource,
|
|
824
|
-
manifest: Manifest,
|
|
825
|
-
connection: MalloyConnection,
|
|
1074
|
+
/** Project the Malloy build plan into the trimmed v0 wire BuildPlan. */
|
|
1075
|
+
private deriveBuildPlan(
|
|
1076
|
+
graphs: MalloyBuildGraph[],
|
|
1077
|
+
sources: Record<string, PersistSource>,
|
|
826
1078
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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;
|
|
1079
|
+
sourceNames: string[] | undefined,
|
|
1080
|
+
): BuildPlan {
|
|
1081
|
+
const include = sourceNames ? new Set(sourceNames) : null;
|
|
1082
|
+
|
|
1083
|
+
const wireGraphs: WireBuildGraph[] = graphs.map((graph) => ({
|
|
1084
|
+
connectionName: graph.connectionName,
|
|
1085
|
+
nodes: graph.nodes.map((level) =>
|
|
1086
|
+
level.map((node) => ({
|
|
1087
|
+
sourceID: node.sourceID,
|
|
1088
|
+
dependsOn: flattenDependsOn(node),
|
|
1089
|
+
})),
|
|
1090
|
+
),
|
|
1091
|
+
}));
|
|
1092
|
+
|
|
1093
|
+
const wireSources: Record<string, WirePersistSourcePlan> = {};
|
|
1094
|
+
for (const [sourceID, source] of Object.entries(sources)) {
|
|
1095
|
+
if (include && !include.has(source.name)) continue;
|
|
1096
|
+
wireSources[sourceID] = {
|
|
1097
|
+
name: source.name,
|
|
1098
|
+
sourceID: source.sourceID,
|
|
1099
|
+
connectionName: source.connectionName,
|
|
1100
|
+
dialect: source.dialectName,
|
|
1101
|
+
buildId: source.makeBuildId(
|
|
1102
|
+
connectionDigests[source.connectionName],
|
|
1103
|
+
source.getSQL(),
|
|
1104
|
+
),
|
|
1105
|
+
sql: source.getSQL(),
|
|
1106
|
+
columns: deriveColumns(source),
|
|
1107
|
+
};
|
|
919
1108
|
}
|
|
920
1109
|
|
|
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
|
-
};
|
|
1110
|
+
return { graphs: wireGraphs, sources: wireSources };
|
|
947
1111
|
}
|
|
948
1112
|
|
|
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
|
-
);
|
|
1113
|
+
// ==================== HELPERS ====================
|
|
978
1114
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1115
|
+
private recordRound(
|
|
1116
|
+
round: MaterializationRound,
|
|
1117
|
+
outcome: "success" | "failed" | "cancelled",
|
|
1118
|
+
startedAtMs: number,
|
|
1119
|
+
): void {
|
|
1120
|
+
recordMaterializationRound(round, outcome, Date.now() - startedAtMs);
|
|
1121
|
+
}
|
|
985
1122
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1123
|
+
private runInBackground(
|
|
1124
|
+
id: string,
|
|
1125
|
+
run: (signal: AbortSignal) => Promise<void>,
|
|
1126
|
+
): void {
|
|
1127
|
+
const abortController = new AbortController();
|
|
1128
|
+
this.runningAbortControllers.set(id, abortController);
|
|
1129
|
+
|
|
1130
|
+
run(abortController.signal)
|
|
1131
|
+
.catch(async (err) => {
|
|
1132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1133
|
+
const next = abortController.signal.aborted
|
|
1134
|
+
? "CANCELLED"
|
|
1135
|
+
: "FAILED";
|
|
1136
|
+
try {
|
|
1137
|
+
await this.repository.updateMaterialization(id, {
|
|
1138
|
+
status: next,
|
|
1139
|
+
completedAt: new Date(),
|
|
1140
|
+
error: abortController.signal.aborted ? "Cancelled" : message,
|
|
1141
|
+
});
|
|
1142
|
+
} catch (transitionErr) {
|
|
1143
|
+
logger.error("Failed to record materialization failure", {
|
|
1144
|
+
materializationId: id,
|
|
1145
|
+
originalError: message,
|
|
1146
|
+
transitionError:
|
|
1147
|
+
transitionErr instanceof Error
|
|
1148
|
+
? transitionErr.message
|
|
1149
|
+
: String(transitionErr),
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
})
|
|
1153
|
+
.finally(() => {
|
|
1154
|
+
this.runningAbortControllers.delete(id);
|
|
990
1155
|
});
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
return gcResult;
|
|
994
1156
|
}
|
|
995
1157
|
|
|
996
|
-
// ==================== HELPERS ====================
|
|
997
|
-
|
|
998
1158
|
private resolveEnvironmentId(environmentName: string): Promise<string> {
|
|
999
1159
|
return resolveEnvironmentId(this.repository, environmentName);
|
|
1000
1160
|
}
|