@malloy-publisher/server 0.0.231 → 0.0.232
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +74 -3
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
- package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
- package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +3186 -433
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/errors.ts +19 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +295 -2
- package/src/mcp/tools/get_context_tool.ts +159 -10
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/connection.ts +263 -7
- package/src/service/connection_config.spec.ts +48 -0
- package/src/service/connection_config.ts +21 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +540 -52
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
|
@@ -7,17 +7,20 @@ import {
|
|
|
7
7
|
BadRequestError,
|
|
8
8
|
InvalidStateTransitionError,
|
|
9
9
|
MaterializationConflictError,
|
|
10
|
+
MaterializationEligibilityError,
|
|
10
11
|
MaterializationNotFoundError,
|
|
11
12
|
} from "../errors";
|
|
12
13
|
import { logger } from "../logger";
|
|
13
14
|
import {
|
|
14
15
|
MaterializationMode,
|
|
15
16
|
recordAutoLoadOutcome,
|
|
17
|
+
recordChainedStorageBuild,
|
|
16
18
|
recordDropTables,
|
|
17
19
|
recordManifestBindDegraded,
|
|
18
20
|
recordMaterializationRun,
|
|
19
21
|
recordSourceBuildDuration,
|
|
20
22
|
recordSourcesOutcome,
|
|
23
|
+
recordStorageBuildFailure,
|
|
21
24
|
} from "../materialization_metrics";
|
|
22
25
|
import {
|
|
23
26
|
BuildInstruction,
|
|
@@ -38,9 +41,32 @@ import {
|
|
|
38
41
|
compilePackageBuildPlan,
|
|
39
42
|
computeSourceEntityId,
|
|
40
43
|
deriveAnnotationFields,
|
|
44
|
+
projectToPublicColumns,
|
|
41
45
|
iterGraphSources,
|
|
42
46
|
} from "./build_plan";
|
|
47
|
+
import { getPersistStorageMode } from "../config";
|
|
43
48
|
import { EnvironmentStore } from "./environment_store";
|
|
49
|
+
import { assertMaterializationEligible } from "./materialization_eligibility";
|
|
50
|
+
import {
|
|
51
|
+
assertStorageServeShapeCompiles,
|
|
52
|
+
buildDownstreamIntoStorage,
|
|
53
|
+
buildSourceIntoStorage,
|
|
54
|
+
dropStorageTable,
|
|
55
|
+
type StorageBuildResult,
|
|
56
|
+
} from "./materialization_build_session";
|
|
57
|
+
import { escapeSQL } from "./connection";
|
|
58
|
+
import {
|
|
59
|
+
buildChainedStorageBuildModel,
|
|
60
|
+
buildVirtualMap,
|
|
61
|
+
deriveServeBindings,
|
|
62
|
+
type ServeBinding,
|
|
63
|
+
type SourceLocation,
|
|
64
|
+
sliceSourceRange,
|
|
65
|
+
} from "./materialization_serve_transform";
|
|
66
|
+
import type { ApiConnection } from "./model";
|
|
67
|
+
import { fetchManifestEntries, splitManifestEntries } from "./manifest_loader";
|
|
68
|
+
import { readFileSync } from "fs";
|
|
69
|
+
import { fileURLToPath } from "url";
|
|
44
70
|
import {
|
|
45
71
|
bareTableName,
|
|
46
72
|
quoteIdentifier,
|
|
@@ -48,6 +74,19 @@ import {
|
|
|
48
74
|
quoteTablePath,
|
|
49
75
|
} from "./quoting";
|
|
50
76
|
import { resolveEnvironmentId } from "./resolve_environment";
|
|
77
|
+
import { redactPgSecrets } from "../pg_helpers";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The narrow environment surface the build path needs to materialize into a
|
|
81
|
+
* `storage=` destination: resolve a connection's config by name (source creds to
|
|
82
|
+
* federate, destination catalog to attach) and the environment root (to derive a
|
|
83
|
+
* plain-DuckDB destination's file path). Kept minimal to avoid coupling the
|
|
84
|
+
* materialization service to the full Environment type.
|
|
85
|
+
*/
|
|
86
|
+
interface BuildEnvironment {
|
|
87
|
+
getApiConnection(connectionName: string): ApiConnection;
|
|
88
|
+
getEnvironmentPath(): string;
|
|
89
|
+
}
|
|
51
90
|
|
|
52
91
|
/**
|
|
53
92
|
* Length of the sourceEntityId prefix used when synthesizing staging table
|
|
@@ -72,6 +111,117 @@ function selfAssignTableName(persistSource: PersistSource): string {
|
|
|
72
111
|
return deriveAnnotationFields(persistSource).name || persistSource.name;
|
|
73
112
|
}
|
|
74
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The build manifest for a `storage=` build, with storage-materialized entries
|
|
116
|
+
* removed. A storage build runs in the source warehouse (passthrough), so it
|
|
117
|
+
* cannot reference an upstream that landed in a DuckDB/DuckLake store — dropping
|
|
118
|
+
* those entries makes the compiler INLINE the upstream (non-strict) or raise a
|
|
119
|
+
* clean strict-miss (strict), instead of emitting a cross-engine table
|
|
120
|
+
* reference. colocated entries are kept — the warehouse build can
|
|
121
|
+
* reference them, carrying forward the DIALECT-QUOTED table path the seed loop
|
|
122
|
+
* already stamped (publisher #904's quoteSeedTablePath), so the downstream FROM
|
|
123
|
+
* resolves a case-preserved upstream on a case-folding engine. Preserves the
|
|
124
|
+
* manifest's `strict` flag.
|
|
125
|
+
*/
|
|
126
|
+
export function manifestExcludingStorage(
|
|
127
|
+
manifest: Manifest,
|
|
128
|
+
builtEntries: Record<string, ManifestEntry>,
|
|
129
|
+
): Manifest["buildManifest"] {
|
|
130
|
+
const reduced = new Manifest();
|
|
131
|
+
reduced.strict = manifest.strict;
|
|
132
|
+
// The source manifest's entries are already dialect-quoted (#904); reuse that
|
|
133
|
+
// quoting rather than the raw physical name so the kept colocated references
|
|
134
|
+
// stay canonical for the downstream FROM.
|
|
135
|
+
const quoted = manifest.buildManifest.entries;
|
|
136
|
+
for (const [id, entry] of Object.entries(builtEntries)) {
|
|
137
|
+
if (!entry.storageConnectionName && quoted[id]) {
|
|
138
|
+
reduced.update(id, { tableName: quoted[id].tableName });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return reduced.buildManifest;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The `storage=` destination a source DECLARES (external-tier intent), or
|
|
146
|
+
* undefined. Independent of `PERSIST_STORAGE_MODE` — reflects author intent, so
|
|
147
|
+
* the build can tell a `storage=` source apart from a plain colocated
|
|
148
|
+
* `#@ persist` even when the tier is off.
|
|
149
|
+
*/
|
|
150
|
+
function declaredStorage(persistSource: PersistSource): string | undefined {
|
|
151
|
+
return deriveAnnotationFields(persistSource).storage?.trim() || undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve a persist source's `#@ persist storage=<ref>` to the EFFECTIVE
|
|
156
|
+
* destination connection name for a build, or undefined for the default
|
|
157
|
+
* colocated path (the source materializes into its own warehouse). Read
|
|
158
|
+
* publisher-side from the compiled annotation (the same `annotationFields` map
|
|
159
|
+
* the plan echoes); the reference resolves generically against registered
|
|
160
|
+
* connections. Absent `storage=` ⇒ undefined (colocated); any value names a
|
|
161
|
+
* registered connection to materialize into. Any managed-tier alias is resolved
|
|
162
|
+
* by the host upstream and set on the wire instruction's `destination` — it
|
|
163
|
+
* never reaches this publisher-side generic resolution.
|
|
164
|
+
*
|
|
165
|
+
* When `PERSIST_STORAGE_MODE=off` this returns undefined regardless of the
|
|
166
|
+
* annotation, so the feature is a runtime kill switch that never fails a
|
|
167
|
+
* package (the ignored `storage=` is surfaced as a package warning, not an
|
|
168
|
+
* error). Undefined here does NOT mean "build it colocated" for a source that
|
|
169
|
+
* declared `storage=` — `deriveSelfInstructions` skips such a source entirely so
|
|
170
|
+
* it serves live; see {@link declaredStorage}.
|
|
171
|
+
*/
|
|
172
|
+
function resolveStorageDestination(
|
|
173
|
+
persistSource: PersistSource,
|
|
174
|
+
): string | undefined {
|
|
175
|
+
if (getPersistStorageMode() === "off") return undefined;
|
|
176
|
+
return declaredStorage(persistSource);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Connection-config keys whose string values are credentials to redact. */
|
|
180
|
+
const SENSITIVE_KEY =
|
|
181
|
+
/pass(word)?|secret|private_?key|service_?account|access_?key|token|connection_?string|account/i;
|
|
182
|
+
|
|
183
|
+
/** Collect credential string values (from sensitively-named keys) in a config. */
|
|
184
|
+
function collectSensitiveValues(value: unknown, out: Set<string>): void {
|
|
185
|
+
if (value === null || typeof value !== "object") return;
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
for (const v of value) collectSensitiveValues(v, out);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (const [key, v] of Object.entries(value)) {
|
|
191
|
+
if (typeof v === "string" && v.length >= 4 && SENSITIVE_KEY.test(key)) {
|
|
192
|
+
out.add(v);
|
|
193
|
+
} else {
|
|
194
|
+
collectSensitiveValues(v, out);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Redact the actual credential values (from the given connection configs) out
|
|
201
|
+
* of an error message, then surface the message. This keeps a build error
|
|
202
|
+
* legible — a "schema not found" or "table does not exist" tells the operator
|
|
203
|
+
* exactly what to fix — while never leaking the passwords / secrets / service
|
|
204
|
+
* account JSON / connection strings a federation or attach error can echo. Only
|
|
205
|
+
* the concrete secret values are removed, not the message structure.
|
|
206
|
+
*/
|
|
207
|
+
export function redactConnectionSecrets(
|
|
208
|
+
message: string,
|
|
209
|
+
...connections: unknown[]
|
|
210
|
+
): string {
|
|
211
|
+
const secrets = new Set<string>();
|
|
212
|
+
for (const c of connections) collectSensitiveValues(c, secrets);
|
|
213
|
+
let redacted = redactPgSecrets(message);
|
|
214
|
+
for (const s of secrets) {
|
|
215
|
+
redacted = redacted.split(s).join("***");
|
|
216
|
+
// A DuckDB error often echoes the offending SQL statement, in which a
|
|
217
|
+
// secret containing a single quote appears single-quote-escaped (`''`) —
|
|
218
|
+
// so the raw value won't match. Also redact the escaped form.
|
|
219
|
+
const escaped = escapeSQL(s);
|
|
220
|
+
if (escaped !== s) redacted = redacted.split(escaped).join("***");
|
|
221
|
+
}
|
|
222
|
+
return redacted;
|
|
223
|
+
}
|
|
224
|
+
|
|
75
225
|
/** Classify a thrown build error as cancelled (cooperative abort) or failed. */
|
|
76
226
|
function outcomeFor(
|
|
77
227
|
_err: unknown,
|
|
@@ -384,6 +534,29 @@ export class MaterializationService {
|
|
|
384
534
|
compilePackageBuildPlan(pkg, signal),
|
|
385
535
|
);
|
|
386
536
|
|
|
537
|
+
// Backstop: refuse loudly if the package annotated a `#@ persist` source
|
|
538
|
+
// that Malloy's getBuildPlan() silently dropped (a shape it doesn't
|
|
539
|
+
// treat as a materializable root — see detectDroppedPersistSources).
|
|
540
|
+
// Without this the build would report success with an empty manifest and
|
|
541
|
+
// the source would serve live, contradicting the "hard refuse, never a
|
|
542
|
+
// silent fallback" contract. Scoped to the sources this build targets so
|
|
543
|
+
// a build of unrelated sources isn't blocked by a dropped sibling.
|
|
544
|
+
const relevantDropped = (compiled.droppedPersistSources ?? []).filter(
|
|
545
|
+
(d) => !opts.sourceNames || opts.sourceNames.includes(d.name),
|
|
546
|
+
);
|
|
547
|
+
if (relevantDropped.length > 0) {
|
|
548
|
+
const names = relevantDropped.map((d) => `'${d.name}'`).join(", ");
|
|
549
|
+
throw new MaterializationEligibilityError({
|
|
550
|
+
message:
|
|
551
|
+
`Source(s) ${names} are annotated '#@ persist' but were not ` +
|
|
552
|
+
`recognized as a materializable source, so nothing would be ` +
|
|
553
|
+
`built (they would be served live). Only query/aggregate ` +
|
|
554
|
+
`sources materialize; a filtered pass-through does not. Persist ` +
|
|
555
|
+
`a query source, or invoke a parameterized source with a bound ` +
|
|
556
|
+
`argument, or drop the annotation to serve live.`,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
387
560
|
let instructions: BuildInstruction[];
|
|
388
561
|
let carried: Record<string, ManifestEntry>;
|
|
389
562
|
if (orchestrated) {
|
|
@@ -394,6 +567,31 @@ export class MaterializationService {
|
|
|
394
567
|
// table instead of recomputing live. The reference key is the
|
|
395
568
|
// compiler's manifest-lookup sourceEntityId (see ManifestReference).
|
|
396
569
|
carried = this.referenceManifestToEntries(opts.referenceManifest);
|
|
570
|
+
// Upstream resolution for an orchestrated build draws on three
|
|
571
|
+
// sources, in DESCENDING precedence — a reference is an IDENTITY, not
|
|
572
|
+
// a copy the caller must fully courier:
|
|
573
|
+
// 1. The explicit `referenceManifest` couriered in THIS build call.
|
|
574
|
+
// 2. The package's BOUND manifest (`manifestLocation`) — the set the
|
|
575
|
+
// orchestrator distributed to this worker. This is what makes the
|
|
576
|
+
// cross-worker flow work: a worker that never built the upstream
|
|
577
|
+
// still holds its full entry via the refreshed manifest, so a
|
|
578
|
+
// downstream can reuse the upstream's materialized table.
|
|
579
|
+
// 3. This worker's own most-recent local manifest (skip-if-unchanged
|
|
580
|
+
// cache) — same-worker reuse.
|
|
581
|
+
// Each fills the storage fields (sourceName, storageConnectionName,
|
|
582
|
+
// schema) a thin reference can't carry — required for a `storage=`
|
|
583
|
+
// upstream's stack-on-the-parent rebind. Higher-precedence
|
|
584
|
+
// fields win; each step is best-effort (a fetch/read failure leaves
|
|
585
|
+
// the entries as they are).
|
|
586
|
+
await this.seedFromBoundManifest(carried, pkg, instructions);
|
|
587
|
+
if (Object.keys(carried).length > 0) {
|
|
588
|
+
await this.resolveReferencesFromStore(
|
|
589
|
+
carried,
|
|
590
|
+
environmentId,
|
|
591
|
+
packageName,
|
|
592
|
+
id,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
397
595
|
} else {
|
|
398
596
|
// Skip-if-unchanged: reuse tables from the most recent successful
|
|
399
597
|
// manifest for sources whose sourceEntityId is unchanged, unless
|
|
@@ -414,10 +612,14 @@ export class MaterializationService {
|
|
|
414
612
|
|
|
415
613
|
const entries = await this.executeInstructedBuild(
|
|
416
614
|
compiled,
|
|
615
|
+
environment,
|
|
417
616
|
instructions,
|
|
418
617
|
carried,
|
|
419
618
|
signal,
|
|
420
619
|
opts.strictUpstreams ?? false,
|
|
620
|
+
// Failure-path reclaim is ORCHESTRATED-ONLY on purpose — see
|
|
621
|
+
// reclaimStorageTablesFromFailedRun.
|
|
622
|
+
orchestrated ? { environmentId, packageName } : undefined,
|
|
421
623
|
);
|
|
422
624
|
|
|
423
625
|
const sourcesBuilt = instructions.length;
|
|
@@ -484,6 +686,33 @@ export class MaterializationService {
|
|
|
484
686
|
)) {
|
|
485
687
|
if (include && !include.has(persistSource.name)) continue;
|
|
486
688
|
|
|
689
|
+
// Safety: a source that DECLARES `storage=` must never silently
|
|
690
|
+
// downgrade to a colocated build when the external tier is disabled
|
|
691
|
+
// (PERSIST_STORAGE_MODE=off). A colocated build writes a CTAS into the
|
|
692
|
+
// source's OWN warehouse — which the author did not intend (they asked
|
|
693
|
+
// for external storage; production grants this server read-only
|
|
694
|
+
// warehouse access) and which could fail or land in an unexpected
|
|
695
|
+
// schema. Skip it: the source is not materialized and serves LIVE, and
|
|
696
|
+
// the mode warning (Package.storageWarnings) surfaces the degraded
|
|
697
|
+
// state. A plain `#@ persist` (no `storage=`) is unaffected —
|
|
698
|
+
// colocated IS its author's intent (the v0 path, ungated by the
|
|
699
|
+
// storage kill switch).
|
|
700
|
+
if (
|
|
701
|
+
getPersistStorageMode() === "off" &&
|
|
702
|
+
declaredStorage(persistSource)
|
|
703
|
+
) {
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const destination = resolveStorageDestination(persistSource);
|
|
708
|
+
if (destination) {
|
|
709
|
+
// Gate BEFORE computeSourceEntityId: an unbound parameter or a
|
|
710
|
+
// given makes getSQL() (called inside computeSourceEntityId)
|
|
711
|
+
// throw opaquely, so the eligibility refusal must fire first to
|
|
712
|
+
// give a clean, actionable 422.
|
|
713
|
+
assertMaterializationEligible(persistSource);
|
|
714
|
+
}
|
|
715
|
+
|
|
487
716
|
const sourceEntityId = computeSourceEntityId(
|
|
488
717
|
persistSource,
|
|
489
718
|
compiled.connectionDigests,
|
|
@@ -492,19 +721,39 @@ export class MaterializationService {
|
|
|
492
721
|
seen.add(sourceEntityId);
|
|
493
722
|
|
|
494
723
|
const prior = priorEntries[sourceEntityId];
|
|
495
|
-
|
|
724
|
+
// Destination-scoped reuse: carry a prior table forward only when it
|
|
725
|
+
// landed in the SAME destination. sourceEntityId is a pure content
|
|
726
|
+
// address and does NOT encode the destination, so a source that adds,
|
|
727
|
+
// drops, or switches `storage=` must rebuild — otherwise a
|
|
728
|
+
// warehouse-landed (colocated) table would be silently reused for a
|
|
729
|
+
// DuckLake serve that cannot resolve it.
|
|
730
|
+
if (
|
|
731
|
+
prior &&
|
|
732
|
+
prior.physicalTableName &&
|
|
733
|
+
(prior.storageConnectionName ?? undefined) === destination
|
|
734
|
+
) {
|
|
496
735
|
carried[sourceEntityId] = prior;
|
|
497
736
|
continue;
|
|
498
737
|
}
|
|
499
738
|
|
|
739
|
+
// Self-assign the physical name from `name=` (or the source name)
|
|
740
|
+
// verbatim for BOTH the colocated and storage
|
|
741
|
+
// destinations — the only difference between the two is which
|
|
742
|
+
// connection the table lands in. A storage build replaces the table
|
|
743
|
+
// atomically (`CREATE OR REPLACE`), so no generational decoration is
|
|
744
|
+
// needed to make a rebuild safe. An orchestrated build ignores this
|
|
745
|
+
// and trusts the host-supplied `physicalTableName`; the host owns any
|
|
746
|
+
// generational, ownership-scoped naming.
|
|
747
|
+
const logicalName = selfAssignTableName(persistSource);
|
|
500
748
|
instructions.push({
|
|
501
749
|
sourceEntityId,
|
|
502
750
|
materializedTableId: `local-${sourceEntityId.substring(
|
|
503
751
|
0,
|
|
504
752
|
STAGING_ID_LEN,
|
|
505
753
|
)}`,
|
|
506
|
-
physicalTableName:
|
|
754
|
+
physicalTableName: logicalName,
|
|
507
755
|
realization: "COPY",
|
|
756
|
+
...(destination ? { destination } : {}),
|
|
508
757
|
});
|
|
509
758
|
}
|
|
510
759
|
}
|
|
@@ -537,6 +786,122 @@ export class MaterializationService {
|
|
|
537
786
|
return entries;
|
|
538
787
|
}
|
|
539
788
|
|
|
789
|
+
/**
|
|
790
|
+
* Resolve upstream references against this publisher's OWN most-recent
|
|
791
|
+
* manifest, keyed by sourceEntityId (the same cache skip-if-unchanged reads).
|
|
792
|
+
* A reference is fundamentally an IDENTITY, not a copy: for each one that
|
|
793
|
+
* matches a locally persisted entry, fill in the fields a thin reference
|
|
794
|
+
* can't carry (sourceName, storageConnectionName, schema — needed for a
|
|
795
|
+
* `storage=` upstream's stack-on-the-parent rebind) from the local entry, while
|
|
796
|
+
* letting any caller-supplied field WIN (the orchestrator is authoritative across a
|
|
797
|
+
* stateless fleet). Mutates `carried` in place. Best-effort: a lookup failure
|
|
798
|
+
* leaves the references exactly as supplied, so the cross-worker courier path
|
|
799
|
+
* (a worker that never built the upstream, fed the full entry) is unaffected.
|
|
800
|
+
*/
|
|
801
|
+
private async resolveReferencesFromStore(
|
|
802
|
+
carried: Record<string, ManifestEntry>,
|
|
803
|
+
environmentId: string,
|
|
804
|
+
packageName: string,
|
|
805
|
+
excludeId: string,
|
|
806
|
+
): Promise<void> {
|
|
807
|
+
let cached: Record<string, ManifestEntry>;
|
|
808
|
+
try {
|
|
809
|
+
cached = await this.getMostRecentManifestEntries(
|
|
810
|
+
environmentId,
|
|
811
|
+
packageName,
|
|
812
|
+
excludeId,
|
|
813
|
+
);
|
|
814
|
+
} catch (err) {
|
|
815
|
+
logger.warn(
|
|
816
|
+
"Reference resolve-local lookup failed; using references as supplied",
|
|
817
|
+
{
|
|
818
|
+
packageName,
|
|
819
|
+
error: err instanceof Error ? err.message : String(err),
|
|
820
|
+
},
|
|
821
|
+
);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
for (const [sourceEntityId, ref] of Object.entries(carried)) {
|
|
825
|
+
const local = cached[sourceEntityId];
|
|
826
|
+
if (!local) continue;
|
|
827
|
+
// Local entry is the base; caller-supplied (defined) fields override it.
|
|
828
|
+
const merged: ManifestEntry = { ...local };
|
|
829
|
+
for (const [key, value] of Object.entries(ref)) {
|
|
830
|
+
if (value !== undefined) {
|
|
831
|
+
(merged as Record<string, unknown>)[key] = value;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
carried[sourceEntityId] = merged;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Seed upstream reuse from the package's BOUND manifest — the set the
|
|
840
|
+
* orchestrator distributed to this worker via `manifestLocation`. This is the
|
|
841
|
+
* cross-worker path: a worker that never built an upstream still holds its full
|
|
842
|
+
* entry here (`storageConnectionName` + `schema` + `sourceName`), so a
|
|
843
|
+
* downstream can reuse the upstream's materialized table instead of recomputing
|
|
844
|
+
* it from raw. Adds any bound upstream not already carried (so a build needs no
|
|
845
|
+
* explicit reference when the manifest is refreshed), and fills gaps in a
|
|
846
|
+
* thin explicit reference — but an explicit `referenceManifest` field always
|
|
847
|
+
* wins (it targets THIS build). Sources this build is producing are skipped
|
|
848
|
+
* (built fresh, not reused). Best-effort: a fetch failure is ignored (the build
|
|
849
|
+
* falls back to the local store / inline recompute). Mutates `carried`.
|
|
850
|
+
*/
|
|
851
|
+
private async seedFromBoundManifest(
|
|
852
|
+
carried: Record<string, ManifestEntry>,
|
|
853
|
+
pkg: { getPackageMetadata(): { manifestLocation?: string | null } },
|
|
854
|
+
instructions: BuildInstruction[],
|
|
855
|
+
): Promise<void> {
|
|
856
|
+
const manifestLocation = pkg.getPackageMetadata().manifestLocation;
|
|
857
|
+
if (!manifestLocation) return;
|
|
858
|
+
let fetched;
|
|
859
|
+
try {
|
|
860
|
+
fetched = await fetchManifestEntries(manifestLocation);
|
|
861
|
+
} catch (err) {
|
|
862
|
+
logger.warn(
|
|
863
|
+
"Build upstream resolution: bound manifest fetch failed; ignoring",
|
|
864
|
+
{
|
|
865
|
+
manifestLocation,
|
|
866
|
+
error: err instanceof Error ? err.message : String(err),
|
|
867
|
+
},
|
|
868
|
+
);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
// Reconstruct a ManifestEntry map from both tiers of the fetched manifest:
|
|
872
|
+
// storage entries carry their full shape; colocated entries reconstruct from
|
|
873
|
+
// the tableName manifest.
|
|
874
|
+
const bound: Record<string, ManifestEntry> = {
|
|
875
|
+
...fetched.storageEntries,
|
|
876
|
+
};
|
|
877
|
+
for (const [eid, e] of Object.entries(fetched.tableNameManifest)) {
|
|
878
|
+
if (!bound[eid]) {
|
|
879
|
+
bound[eid] = {
|
|
880
|
+
sourceEntityId: eid,
|
|
881
|
+
physicalTableName: e.tableName,
|
|
882
|
+
connectionName: e.connectionName,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const building = new Set(instructions.map((i) => i.sourceEntityId));
|
|
887
|
+
for (const [eid, entry] of Object.entries(bound)) {
|
|
888
|
+
if (building.has(eid)) continue;
|
|
889
|
+
const existing = carried[eid];
|
|
890
|
+
if (!existing) {
|
|
891
|
+
carried[eid] = entry;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
// Explicit reference wins; the bound entry fills the gaps it left.
|
|
895
|
+
const merged: ManifestEntry = { ...entry };
|
|
896
|
+
for (const [key, value] of Object.entries(existing)) {
|
|
897
|
+
if (value !== undefined) {
|
|
898
|
+
(merged as Record<string, unknown>)[key] = value;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
carried[eid] = merged;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
540
905
|
/**
|
|
541
906
|
* Entries of the most recent successful (MANIFEST_FILE_READY) materialization
|
|
542
907
|
* for this package, used for skip-if-unchanged. Excludes the in-flight run.
|
|
@@ -546,10 +911,11 @@ export class MaterializationService {
|
|
|
546
911
|
packageName: string,
|
|
547
912
|
excludeId: string,
|
|
548
913
|
): Promise<Record<string, ManifestEntry>> {
|
|
549
|
-
const list =
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
914
|
+
const list =
|
|
915
|
+
(await this.repository.listMaterializations(
|
|
916
|
+
environmentId,
|
|
917
|
+
packageName,
|
|
918
|
+
)) ?? [];
|
|
553
919
|
for (const m of list) {
|
|
554
920
|
if (m.id === excludeId) continue;
|
|
555
921
|
if (m.status === "MANIFEST_FILE_READY" && m.manifest?.entries) {
|
|
@@ -570,6 +936,10 @@ export class MaterializationService {
|
|
|
570
936
|
packageName: string,
|
|
571
937
|
manifest: FreshnessManifest,
|
|
572
938
|
): Promise<void>;
|
|
939
|
+
bindPackageStorageServeBindings(
|
|
940
|
+
packageName: string,
|
|
941
|
+
entries: Record<string, ManifestEntry>,
|
|
942
|
+
): Promise<void>;
|
|
573
943
|
},
|
|
574
944
|
packageName: string,
|
|
575
945
|
entries: Record<string, ManifestEntry>,
|
|
@@ -580,7 +950,12 @@ export class MaterializationService {
|
|
|
580
950
|
// bound un-gated (always serve the freshly-built table).
|
|
581
951
|
const manifestEntries: FreshnessManifest = {};
|
|
582
952
|
for (const [sourceEntityId, entry] of Object.entries(entries)) {
|
|
583
|
-
|
|
953
|
+
// Storage entries serve cross-connection via the virtual-source
|
|
954
|
+
// bindings (below), NOT the same-connection manifest substitution —
|
|
955
|
+
// putting one here would make the original model try to substitute the
|
|
956
|
+
// source with a table on its OWN (source) connection, which doesn't
|
|
957
|
+
// exist there. Only colocated entries go into the tableName manifest.
|
|
958
|
+
if (entry.physicalTableName && !entry.storageConnectionName) {
|
|
584
959
|
manifestEntries[sourceEntityId] = {
|
|
585
960
|
tableName: entry.physicalTableName,
|
|
586
961
|
// Carried so the bind step can quote the physical path for the
|
|
@@ -597,6 +972,14 @@ export class MaterializationService {
|
|
|
597
972
|
packageName,
|
|
598
973
|
manifestEntries,
|
|
599
974
|
);
|
|
975
|
+
// Separately bind the FULL entries as storage serve bindings — sources
|
|
976
|
+
// materialized into a storage destination serve cross-connection via
|
|
977
|
+
// the virtual-source transform, not the tableName manifest above. No-op
|
|
978
|
+
// for a package with no storage= sources (deriveServeBindings → []).
|
|
979
|
+
await environment.bindPackageStorageServeBindings(
|
|
980
|
+
packageName,
|
|
981
|
+
entries,
|
|
982
|
+
);
|
|
600
983
|
recordAutoLoadOutcome("success");
|
|
601
984
|
logger.info("Auto-run: loaded manifest into package models", {
|
|
602
985
|
packageName,
|
|
@@ -692,10 +1075,14 @@ export class MaterializationService {
|
|
|
692
1075
|
*/
|
|
693
1076
|
private async executeInstructedBuild(
|
|
694
1077
|
compiled: CompiledBuildPlan,
|
|
1078
|
+
environment: BuildEnvironment,
|
|
695
1079
|
instructions: BuildInstruction[],
|
|
696
1080
|
seedEntries: Record<string, ManifestEntry>,
|
|
697
1081
|
signal: AbortSignal,
|
|
698
1082
|
strict = false,
|
|
1083
|
+
// Identity of the run, used only to reclaim storage tables this run created
|
|
1084
|
+
// if it fails part-way (see reclaimStorageTablesFromFailedRun).
|
|
1085
|
+
owner?: { environmentId: string; packageName: string },
|
|
699
1086
|
): Promise<Record<string, ManifestEntry>> {
|
|
700
1087
|
const { graphs, sources, connectionDigests, connections } = compiled;
|
|
701
1088
|
|
|
@@ -745,45 +1132,201 @@ export class MaterializationService {
|
|
|
745
1132
|
entries[sourceEntityId] = entry;
|
|
746
1133
|
}
|
|
747
1134
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1135
|
+
// Entries this run actually CREATED, as opposed to the seeded/carried ones
|
|
1136
|
+
// above. Only these are eligible for failure-path reclaim: a carried entry
|
|
1137
|
+
// names a table an earlier successful run built and a live manifest may still
|
|
1138
|
+
// serve, so dropping one would be data loss rather than cleanup.
|
|
1139
|
+
const builtThisRun: ManifestEntry[] = [];
|
|
1140
|
+
try {
|
|
1141
|
+
for (const graph of graphs) {
|
|
1142
|
+
const connection = connections.get(graph.connectionName);
|
|
1143
|
+
if (!connection) {
|
|
1144
|
+
throw new BadRequestError(
|
|
1145
|
+
`Connection '${graph.connectionName}' not found`,
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
for (const persistSource of iterGraphSources(graph, sources)) {
|
|
1149
|
+
if (signal.aborted) throw new Error("Build cancelled");
|
|
1150
|
+
|
|
1151
|
+
// Prefer sourceID matching (so the caller's sourceEntityId scheme
|
|
1152
|
+
// stays opaque to the build); the sourceEntityId lookup below is the
|
|
1153
|
+
// fallback for instructions without a sourceID (auto-run). Resolved
|
|
1154
|
+
// before computeSourceEntityId so the eligibility gate wins: that
|
|
1155
|
+
// call invokes getSQL(), which throws opaquely for a free-parameter
|
|
1156
|
+
// or given source, losing the clean 422.
|
|
1157
|
+
const orchestratedInstruction = bySourceID.get(
|
|
1158
|
+
persistSource.sourceID,
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
// Enforce the eligibility gate for any storage-targeted build,
|
|
1162
|
+
// including orchestrated (host-supplied) instructions — the publisher
|
|
1163
|
+
// refuses an ineligible source into the tier itself, not on trust.
|
|
1164
|
+
// Skipped when the mode is off: the kill switch ignores a
|
|
1165
|
+
// host-supplied destination too and does a colocated build.
|
|
1166
|
+
if (
|
|
1167
|
+
orchestratedInstruction?.destination &&
|
|
1168
|
+
getPersistStorageMode() !== "off"
|
|
1169
|
+
) {
|
|
1170
|
+
assertMaterializationEligible(persistSource);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// The manifest is keyed by the content sourceEntityId — what Malloy
|
|
1174
|
+
// recomputes to resolve upstream persist references during SQL
|
|
1175
|
+
// generation — independent of the instruction's identity sourceEntityId.
|
|
1176
|
+
const sourceEntityId = computeSourceEntityId(
|
|
1177
|
+
persistSource,
|
|
1178
|
+
connectionDigests,
|
|
1179
|
+
);
|
|
1180
|
+
const instruction =
|
|
1181
|
+
orchestratedInstruction ??
|
|
1182
|
+
bySourceEntityId.get(sourceEntityId);
|
|
1183
|
+
if (!instruction) continue;
|
|
1184
|
+
|
|
1185
|
+
// Auto-run already gated pre-getSQL in deriveSelfInstructions;
|
|
1186
|
+
// re-assert (idempotent) so no path into a storage build is ungated.
|
|
1187
|
+
if (
|
|
1188
|
+
!orchestratedInstruction &&
|
|
1189
|
+
instruction.destination &&
|
|
1190
|
+
getPersistStorageMode() !== "off"
|
|
1191
|
+
) {
|
|
1192
|
+
assertMaterializationEligible(persistSource);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const entry = await this.buildOneSource(
|
|
1196
|
+
persistSource,
|
|
1197
|
+
instruction,
|
|
1198
|
+
connection,
|
|
1199
|
+
connectionDigests,
|
|
1200
|
+
manifest,
|
|
1201
|
+
environment,
|
|
1202
|
+
entries,
|
|
1203
|
+
);
|
|
1204
|
+
entries[sourceEntityId] = entry;
|
|
1205
|
+
if (entry.storageConnectionName) builtThisRun.push(entry);
|
|
1206
|
+
}
|
|
754
1207
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
// opaque to the build); fall back to sourceEntityId for instructions
|
|
767
|
-
// without a sourceID (auto-run).
|
|
768
|
-
const instruction =
|
|
769
|
-
bySourceID.get(persistSource.sourceID) ??
|
|
770
|
-
bySourceEntityId.get(sourceEntityId);
|
|
771
|
-
if (!instruction) continue;
|
|
772
|
-
|
|
773
|
-
const entry = await this.buildOneSource(
|
|
774
|
-
persistSource,
|
|
775
|
-
instruction,
|
|
776
|
-
connection,
|
|
777
|
-
connectionDigests,
|
|
778
|
-
manifest,
|
|
1208
|
+
} catch (err) {
|
|
1209
|
+
// A run that fails part-way commits NO manifest, and manifest-driven GC
|
|
1210
|
+
// only drops names a manifest records — so a table an earlier source in
|
|
1211
|
+
// this run already wrote would be unreachable forever. Reclaim those
|
|
1212
|
+
// before rethrowing. Best-effort and non-fatal: the build's own failure is
|
|
1213
|
+
// what the caller needs to see.
|
|
1214
|
+
if (owner) {
|
|
1215
|
+
await this.reclaimStorageTablesFromFailedRun(
|
|
1216
|
+
builtThisRun,
|
|
1217
|
+
environment,
|
|
1218
|
+
owner,
|
|
779
1219
|
);
|
|
780
|
-
entries[sourceEntityId] = entry;
|
|
781
1220
|
}
|
|
1221
|
+
throw err;
|
|
782
1222
|
}
|
|
783
1223
|
|
|
784
1224
|
return entries;
|
|
785
1225
|
}
|
|
786
1226
|
|
|
1227
|
+
/**
|
|
1228
|
+
* Drop the storage tables a FAILED run created, so a partial build does not
|
|
1229
|
+
* leak an unreferenced table. For DuckLake that is data plus Parquet files at
|
|
1230
|
+
* rest, and nothing else will ever name them: the run commits no manifest, and
|
|
1231
|
+
* GC reclaims only what a manifest records.
|
|
1232
|
+
*
|
|
1233
|
+
* Three guards make this safe, and each closes a real way to destroy live data:
|
|
1234
|
+
*
|
|
1235
|
+
* - ORCHESTRATED runs only (the caller passes no `owner` for auto-run). Those
|
|
1236
|
+
* names are host-assigned and generational, so unique by construction — which
|
|
1237
|
+
* is both where the leak actually bites and the only case where a drop cannot
|
|
1238
|
+
* hit something another run owns. Auto-run's STABLE names are overwritten in
|
|
1239
|
+
* place by the next build, so skipping them forgoes little.
|
|
1240
|
+
*
|
|
1241
|
+
* This gate is what bounds the cross-environment hazard. The
|
|
1242
|
+
* still-referenced check below reads THIS environment and package only, and a
|
|
1243
|
+
* BuildID carries no environment input, so two environments sharing a
|
|
1244
|
+
* destination can resolve a source to the SAME physical name. A reclaim that
|
|
1245
|
+
* trusted a per-environment check could then drop a table another environment
|
|
1246
|
+
* is actively serving — the failure mode behind a real cross-environment
|
|
1247
|
+
* data-loss incident on the hosted side. Generational names remove the
|
|
1248
|
+
* collision rather than racing it. The durable fix is refusing a colliding
|
|
1249
|
+
* persist target at validation time; until then, do not widen this.
|
|
1250
|
+
*
|
|
1251
|
+
* - Only entries this run CREATED (never a carried-forward one).
|
|
1252
|
+
* - Only names no other MANIFEST_FILE_READY run references, the same
|
|
1253
|
+
* destination-and-name check {@link dropMaterializedTables} applies.
|
|
1254
|
+
*
|
|
1255
|
+
* Scoped to `storage=` entries. A colocated failure is left alone: those names
|
|
1256
|
+
* are stable and in the customer's own warehouse, so the next successful build
|
|
1257
|
+
* overwrites in place and a failure-path DROP there would be a far larger
|
|
1258
|
+
* blast radius for no reclaim.
|
|
1259
|
+
*/
|
|
1260
|
+
private async reclaimStorageTablesFromFailedRun(
|
|
1261
|
+
builtThisRun: ManifestEntry[],
|
|
1262
|
+
environment: BuildEnvironment,
|
|
1263
|
+
owner: { environmentId: string; packageName: string },
|
|
1264
|
+
): Promise<void> {
|
|
1265
|
+
if (builtThisRun.length === 0) return;
|
|
1266
|
+
try {
|
|
1267
|
+
const tableKey = (dest: string, table: string) => `${dest}:${table}`;
|
|
1268
|
+
const stillReferenced = new Set<string>();
|
|
1269
|
+
const others =
|
|
1270
|
+
(await this.repository.listMaterializations(
|
|
1271
|
+
owner.environmentId,
|
|
1272
|
+
owner.packageName,
|
|
1273
|
+
)) ?? [];
|
|
1274
|
+
for (const other of others) {
|
|
1275
|
+
if (other.status !== "MANIFEST_FILE_READY") continue;
|
|
1276
|
+
for (const e of Object.values(other.manifest?.entries ?? {})) {
|
|
1277
|
+
const dest = e.storageConnectionName ?? e.connectionName;
|
|
1278
|
+
if (dest && e.physicalTableName) {
|
|
1279
|
+
stillReferenced.add(tableKey(dest, e.physicalTableName));
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
for (const entry of builtThisRun) {
|
|
1285
|
+
const dest = entry.storageConnectionName;
|
|
1286
|
+
const table = entry.physicalTableName;
|
|
1287
|
+
if (!dest || !table) continue;
|
|
1288
|
+
if (stillReferenced.has(tableKey(dest, table))) {
|
|
1289
|
+
logger.info(
|
|
1290
|
+
"Keeping a table from a failed run: a live manifest still serves it",
|
|
1291
|
+
{ destinationName: dest, physicalTableName: table },
|
|
1292
|
+
);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
try {
|
|
1296
|
+
await dropStorageTable({
|
|
1297
|
+
destinationName: dest,
|
|
1298
|
+
destinationConnection: environment.getApiConnection(dest),
|
|
1299
|
+
physicalTableName: table,
|
|
1300
|
+
environmentPath: environment.getEnvironmentPath(),
|
|
1301
|
+
});
|
|
1302
|
+
recordDropTables("success", "storage");
|
|
1303
|
+
logger.info("Reclaimed a table stranded by a failed build", {
|
|
1304
|
+
destinationName: dest,
|
|
1305
|
+
physicalTableName: table,
|
|
1306
|
+
});
|
|
1307
|
+
} catch (dropErr) {
|
|
1308
|
+
recordDropTables("failure", "storage");
|
|
1309
|
+
logger.warn(
|
|
1310
|
+
"Failed to reclaim a table stranded by a failed build",
|
|
1311
|
+
{
|
|
1312
|
+
destinationName: dest,
|
|
1313
|
+
physicalTableName: table,
|
|
1314
|
+
error: redactConnectionSecrets(
|
|
1315
|
+
errMessage(dropErr),
|
|
1316
|
+
environment.getApiConnection(dest),
|
|
1317
|
+
),
|
|
1318
|
+
},
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
} catch (err) {
|
|
1323
|
+
// Never let cleanup mask the build failure that triggered it.
|
|
1324
|
+
logger.warn("Failed-run table reclaim did not complete", {
|
|
1325
|
+
error: errMessage(err),
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
787
1330
|
/**
|
|
788
1331
|
* Build a single instructed source into its assigned physical table.
|
|
789
1332
|
* COPY uses a staging table + atomic rename for crash-safety; the staging
|
|
@@ -795,14 +1338,72 @@ export class MaterializationService {
|
|
|
795
1338
|
connection: MalloyConnection,
|
|
796
1339
|
connectionDigests: Record<string, string>,
|
|
797
1340
|
manifest: Manifest,
|
|
1341
|
+
environment: BuildEnvironment,
|
|
1342
|
+
builtEntries: Record<string, ManifestEntry>,
|
|
798
1343
|
): Promise<ManifestEntry> {
|
|
799
1344
|
const sourceEntityId = instruction.sourceEntityId;
|
|
800
1345
|
const physicalTableName = instruction.physicalTableName;
|
|
1346
|
+
const isStorageBuild =
|
|
1347
|
+
!!instruction.destination && getPersistStorageMode() !== "off";
|
|
1348
|
+
// ANY warehouse-executed build SQL — a colocated CTAS or a storage build's
|
|
1349
|
+
// native passthrough — runs against the SOURCE warehouse, which cannot see
|
|
1350
|
+
// a storage-materialized upstream's DuckDB/DuckLake table (a different
|
|
1351
|
+
// engine). Substituting that upstream's lake table name into warehouse SQL
|
|
1352
|
+
// would reference a table the warehouse can't resolve (a confusing "table
|
|
1353
|
+
// not found"), whether the downstream is a storage build OR a colocated
|
|
1354
|
+
// source reading a storage upstream. So exclude storage-materialized
|
|
1355
|
+
// upstreams from the build manifest in BOTH cases: non-strict, they INLINE
|
|
1356
|
+
// (recompute from raw against the warehouse) so a chained source still
|
|
1357
|
+
// materializes; under `strictUpstreams` the excluded reference becomes a
|
|
1358
|
+
// clean strict-miss error (the orchestrated contract — don't silently
|
|
1359
|
+
// recompute). A stack-on-the-parent build reads the parent's lake table
|
|
1360
|
+
// instead, but via a separate DuckDB recompile that does NOT use this
|
|
1361
|
+
// warehouse buildSQL — this remains its recompute-from-raw fallback.
|
|
1362
|
+
const buildManifest = manifestExcludingStorage(manifest, builtEntries);
|
|
801
1363
|
const buildSQL = persistSource.getSQL({
|
|
802
|
-
buildManifest
|
|
1364
|
+
buildManifest,
|
|
803
1365
|
connectionDigests,
|
|
804
1366
|
});
|
|
805
1367
|
|
|
1368
|
+
// `storage=` build: materialize into a DuckDB/DuckLake destination via a
|
|
1369
|
+
// build-scoped session (never on the source or serve connection). Diverges
|
|
1370
|
+
// fully from the in-warehouse CTAS below — different engine, credential
|
|
1371
|
+
// federation, and a captured authoritative schema for the serve transform.
|
|
1372
|
+
// Gated by the kill switch: when off, ignore a destination and do a colocated build.
|
|
1373
|
+
if (isStorageBuild) {
|
|
1374
|
+
// Stack-on-the-parent detection: does the source's SQL change when storage upstreams
|
|
1375
|
+
// are PRESENT in the manifest (mapped to their lake tables) vs EXCLUDED
|
|
1376
|
+
// (inlined, the buildSQL above)? If so it reads a storage-materialized
|
|
1377
|
+
// upstream, so it can be built by reading the parent's lake table
|
|
1378
|
+
// ("stack on the parent") instead of recomputing from raw. The compare
|
|
1379
|
+
// is graph-free and self-contained; a single-source build's two SQLs are
|
|
1380
|
+
// identical, so it skips straight to the passthrough below.
|
|
1381
|
+
const dependsOnStorageUpstream =
|
|
1382
|
+
persistSource.getSQL({
|
|
1383
|
+
buildManifest: manifest.buildManifest,
|
|
1384
|
+
connectionDigests,
|
|
1385
|
+
}) !== buildSQL;
|
|
1386
|
+
// Materialize ONLY the source's PUBLIC columns. `getSQL` projects every
|
|
1387
|
+
// underlying column, including ones the source hides (`except:`, non-public
|
|
1388
|
+
// access modifiers). Query reachability is bounded by the declared
|
|
1389
|
+
// ::Shape, which the serve transform narrows to the public surface
|
|
1390
|
+
// (proven by the shape-bounds-physical-columns scenario) — so what this
|
|
1391
|
+
// prevents is the hidden column's VALUES sitting at rest in the
|
|
1392
|
+
// destination store, reachable by direct catalog access and possibly
|
|
1393
|
+
// across a trust boundary the source's visibility was meant to hold.
|
|
1394
|
+
// Refuses the build (422) if the public surface can't be determined.
|
|
1395
|
+
const publicBuildSQL = projectToPublicColumns(persistSource, buildSQL);
|
|
1396
|
+
return this.buildOneSourceIntoStorage(
|
|
1397
|
+
persistSource,
|
|
1398
|
+
instruction,
|
|
1399
|
+
manifest,
|
|
1400
|
+
environment,
|
|
1401
|
+
publicBuildSQL,
|
|
1402
|
+
builtEntries,
|
|
1403
|
+
dependsOnStorageUpstream,
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
806
1407
|
const bareName = bareTableName(physicalTableName);
|
|
807
1408
|
const stagingTableName = `${physicalTableName}${stagingSuffix(sourceEntityId)}`;
|
|
808
1409
|
// The control plane sends the logical (unquoted) physical name; dialect-
|
|
@@ -849,7 +1450,7 @@ export class MaterializationService {
|
|
|
849
1450
|
manifest.update(sourceEntityId, { tableName: quotedPhysical });
|
|
850
1451
|
|
|
851
1452
|
const durationMs = Math.round(performance.now() - startTime);
|
|
852
|
-
recordSourceBuildDuration(durationMs);
|
|
1453
|
+
recordSourceBuildDuration(durationMs, "in_warehouse");
|
|
853
1454
|
logger.info(`Built materialized source ${persistSource.name}`, {
|
|
854
1455
|
physicalTableName,
|
|
855
1456
|
durationMs,
|
|
@@ -866,6 +1467,315 @@ export class MaterializationService {
|
|
|
866
1467
|
};
|
|
867
1468
|
}
|
|
868
1469
|
|
|
1470
|
+
/**
|
|
1471
|
+
* Materialize a source into a `storage=` destination (a DuckDB/DuckLake
|
|
1472
|
+
* connection) via a native query-passthrough CTAS on a build-scoped session.
|
|
1473
|
+
* Records the destination connection and the captured authoritative DuckDB
|
|
1474
|
+
* schema on the manifest entry so the source can later be served
|
|
1475
|
+
* cross-dialect from the destination (the serve transform declares that
|
|
1476
|
+
* schema). `connectionName` still names the SOURCE warehouse (where data is
|
|
1477
|
+
* read from); `storageConnectionName` names where the table now lives.
|
|
1478
|
+
*/
|
|
1479
|
+
private async buildOneSourceIntoStorage(
|
|
1480
|
+
persistSource: PersistSource,
|
|
1481
|
+
instruction: BuildInstruction,
|
|
1482
|
+
manifest: Manifest,
|
|
1483
|
+
environment: BuildEnvironment,
|
|
1484
|
+
buildSQL: string,
|
|
1485
|
+
builtEntries: Record<string, ManifestEntry>,
|
|
1486
|
+
dependsOnStorageUpstream: boolean,
|
|
1487
|
+
): Promise<ManifestEntry> {
|
|
1488
|
+
const sourceEntityId = instruction.sourceEntityId;
|
|
1489
|
+
const physicalTableName = instruction.physicalTableName;
|
|
1490
|
+
const destinationName = instruction.destination!;
|
|
1491
|
+
const sourceConnection = environment.getApiConnection(
|
|
1492
|
+
persistSource.connectionName,
|
|
1493
|
+
);
|
|
1494
|
+
const destinationConnection =
|
|
1495
|
+
environment.getApiConnection(destinationName);
|
|
1496
|
+
|
|
1497
|
+
const startTime = performance.now();
|
|
1498
|
+
let result;
|
|
1499
|
+
|
|
1500
|
+
// Stack on the parent: a source that reads a storage-materialized
|
|
1501
|
+
// upstream is built by reading the parent's STORED lake table instead of
|
|
1502
|
+
// recomputing it from raw against the warehouse. This reuses the
|
|
1503
|
+
// parent's work and is consistent-by-construction (the downstream is a pure
|
|
1504
|
+
// function of the parent's stored rows). Attempted only when the source
|
|
1505
|
+
// actually reads a storage upstream; on any ineligibility (a parent
|
|
1506
|
+
// refinement not carried into the rebind, a live-warehouse join, a
|
|
1507
|
+
// cross-catalog parent) it throws and we fall back: recompute-from-raw when
|
|
1508
|
+
// non-strict, or a loud refusal under `strictUpstreams` (the orchestrated
|
|
1509
|
+
// contract — never silently recompute).
|
|
1510
|
+
if (dependsOnStorageUpstream) {
|
|
1511
|
+
try {
|
|
1512
|
+
result = await this.buildDownstreamViaParents(
|
|
1513
|
+
persistSource,
|
|
1514
|
+
destinationName,
|
|
1515
|
+
destinationConnection,
|
|
1516
|
+
builtEntries,
|
|
1517
|
+
environment,
|
|
1518
|
+
physicalTableName,
|
|
1519
|
+
);
|
|
1520
|
+
recordChainedStorageBuild("parent_reuse");
|
|
1521
|
+
} catch (err) {
|
|
1522
|
+
// Same redaction contract as the recompute-from-raw path below: this
|
|
1523
|
+
// branch's read-write ATTACH or CTAS can fail with the offending SQL
|
|
1524
|
+
// echoed back, catalog `password=` included. Redact before the
|
|
1525
|
+
// message reaches the thrown run `error` or the log.
|
|
1526
|
+
const safeDetail = redactConnectionSecrets(
|
|
1527
|
+
errMessage(err),
|
|
1528
|
+
sourceConnection,
|
|
1529
|
+
destinationConnection,
|
|
1530
|
+
);
|
|
1531
|
+
// Only a SHAPE failure justifies recomputing from raw: the downstream
|
|
1532
|
+
// could not be expressed over its rebound parents, so building it the
|
|
1533
|
+
// other way is a genuinely different attempt. An INFRA failure (the
|
|
1534
|
+
// read-write attach, the CTAS, the destination being down) is not —
|
|
1535
|
+
// recompute-from-raw writes to the SAME destination and fails the same
|
|
1536
|
+
// way, and metering it as `inline_fallback` files an outage in the same
|
|
1537
|
+
// bucket as a legitimate shape miss.
|
|
1538
|
+
if (!(err instanceof MaterializationEligibilityError)) {
|
|
1539
|
+
recordChainedStorageBuild("infra_failure");
|
|
1540
|
+
recordStorageBuildFailure(destinationName);
|
|
1541
|
+
throw new Error(
|
|
1542
|
+
`Failed to materialize chained source '${persistSource.name}' ` +
|
|
1543
|
+
`into storage destination '${destinationName}': ${safeDetail}`,
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
if (manifest.strict) {
|
|
1547
|
+
recordChainedStorageBuild("strict_refused");
|
|
1548
|
+
recordStorageBuildFailure(destinationName);
|
|
1549
|
+
throw new Error(
|
|
1550
|
+
`Failed to materialize chained source '${persistSource.name}' ` +
|
|
1551
|
+
`into storage destination '${destinationName}' by reading ` +
|
|
1552
|
+
`its materialized upstream, and strict upstreams forbid ` +
|
|
1553
|
+
`recomputing it from raw: ${safeDetail}`,
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
recordChainedStorageBuild("inline_fallback");
|
|
1557
|
+
logger.warn(
|
|
1558
|
+
"Chained storage build could not reuse the parent table; " +
|
|
1559
|
+
"recomputing the upstream from raw",
|
|
1560
|
+
{
|
|
1561
|
+
sourceName: persistSource.name,
|
|
1562
|
+
destinationName,
|
|
1563
|
+
reason: safeDetail,
|
|
1564
|
+
},
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// Recompute from raw (the single-source passthrough): materialize `buildSQL`
|
|
1570
|
+
// (with storage upstreams inlined) in the source warehouse and CTAS the result
|
|
1571
|
+
// into the destination. Skipped when stacking on the parent already produced
|
|
1572
|
+
// the table above.
|
|
1573
|
+
if (!result) {
|
|
1574
|
+
try {
|
|
1575
|
+
result = await buildSourceIntoStorage({
|
|
1576
|
+
destinationName,
|
|
1577
|
+
destinationConnection,
|
|
1578
|
+
sourceConnection,
|
|
1579
|
+
buildSQL,
|
|
1580
|
+
physicalTableName,
|
|
1581
|
+
environmentPath: environment.getEnvironmentPath(),
|
|
1582
|
+
});
|
|
1583
|
+
} catch (err) {
|
|
1584
|
+
// Redaction: a failed federation / passthrough / attach
|
|
1585
|
+
// error can echo source- or catalog-connection detail (connstrings,
|
|
1586
|
+
// account names, service-account JSON) from the DuckDB engine. Strip
|
|
1587
|
+
// the actual credential VALUES but keep the message, so an operator
|
|
1588
|
+
// sees a legible, actionable error (e.g. "schema 'analytics' not
|
|
1589
|
+
// found" when a `name=schema.table` target's schema wasn't
|
|
1590
|
+
// provisioned) rather than an opaque failure — without leaking
|
|
1591
|
+
// secrets into the user-visible run `error` column.
|
|
1592
|
+
const safeDetail = redactConnectionSecrets(
|
|
1593
|
+
errMessage(err),
|
|
1594
|
+
sourceConnection,
|
|
1595
|
+
destinationConnection,
|
|
1596
|
+
);
|
|
1597
|
+
recordStorageBuildFailure(destinationName);
|
|
1598
|
+
logger.warn("Storage materialization build failed", {
|
|
1599
|
+
sourceName: persistSource.name,
|
|
1600
|
+
destinationName,
|
|
1601
|
+
error: safeDetail,
|
|
1602
|
+
});
|
|
1603
|
+
throw new Error(
|
|
1604
|
+
`Failed to materialize source '${persistSource.name}' into ` +
|
|
1605
|
+
`storage destination '${destinationName}': ${safeDetail}`,
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Build-time servability gate: the serve-shape must compile in DuckDB
|
|
1611
|
+
// against the authoritative post-build schema, or the build is refused
|
|
1612
|
+
// (HTTP 422) — a serve-time execution error turned into a fail-loud
|
|
1613
|
+
// build-time refusal. Outside the redaction try above so the eligibility
|
|
1614
|
+
// error surfaces as-is (it carries no connection secrets). Runs here, in
|
|
1615
|
+
// stage→validate, because it needs the captured schema.
|
|
1616
|
+
try {
|
|
1617
|
+
await assertStorageServeShapeCompiles({
|
|
1618
|
+
destinationName,
|
|
1619
|
+
sourceName: persistSource.name,
|
|
1620
|
+
virtualHandle: sourceEntityId,
|
|
1621
|
+
physicalTableName,
|
|
1622
|
+
schema: result.schema,
|
|
1623
|
+
});
|
|
1624
|
+
} catch (gateErr) {
|
|
1625
|
+
// The table was already CTAS'd before this post-build gate, and no
|
|
1626
|
+
// manifest entry records it yet — so a refusal would strand it where
|
|
1627
|
+
// manifest-driven GC (which only drops names it recorded building) can
|
|
1628
|
+
// never see it. Best-effort drop of the just-built table so a refused
|
|
1629
|
+
// build doesn't leak an orphaned table.
|
|
1630
|
+
//
|
|
1631
|
+
// Note (in-place naming): the CTAS above already replaced any prior
|
|
1632
|
+
// generation at this name, so a failed-gate rebuild has no earlier
|
|
1633
|
+
// table to fall back to — the source reverts to serving live until a
|
|
1634
|
+
// subsequent successful build. Rollback-safe regeneration (keep the
|
|
1635
|
+
// prior generation until the new one is validated) requires the
|
|
1636
|
+
// host-generational orchestrated path, not the auto-run server.
|
|
1637
|
+
try {
|
|
1638
|
+
await dropStorageTable({
|
|
1639
|
+
destinationName,
|
|
1640
|
+
destinationConnection,
|
|
1641
|
+
physicalTableName,
|
|
1642
|
+
environmentPath: environment.getEnvironmentPath(),
|
|
1643
|
+
});
|
|
1644
|
+
} catch (dropErr) {
|
|
1645
|
+
logger.warn(
|
|
1646
|
+
"Failed to drop a storage table stranded by a serve-shape gate " +
|
|
1647
|
+
"refusal (physical leak)",
|
|
1648
|
+
{
|
|
1649
|
+
sourceName: persistSource.name,
|
|
1650
|
+
destinationName,
|
|
1651
|
+
physicalTableName,
|
|
1652
|
+
// The drop runs on a read-write attach, so a failure can echo
|
|
1653
|
+
// the catalog connstring.
|
|
1654
|
+
error: redactConnectionSecrets(
|
|
1655
|
+
errMessage(dropErr),
|
|
1656
|
+
sourceConnection,
|
|
1657
|
+
destinationConnection,
|
|
1658
|
+
),
|
|
1659
|
+
},
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
throw gateErr;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// Make this table visible to downstream sources built later in this run,
|
|
1666
|
+
// so a chained storage source can stack on it (above).
|
|
1667
|
+
manifest.update(sourceEntityId, { tableName: physicalTableName });
|
|
1668
|
+
|
|
1669
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
1670
|
+
recordSourceBuildDuration(durationMs, "storage");
|
|
1671
|
+
logger.info(
|
|
1672
|
+
`Built materialized source ${persistSource.name} into storage`,
|
|
1673
|
+
{
|
|
1674
|
+
physicalTableName,
|
|
1675
|
+
storageConnectionName: result.storageConnectionName,
|
|
1676
|
+
columns: result.schema.length,
|
|
1677
|
+
durationMs,
|
|
1678
|
+
},
|
|
1679
|
+
);
|
|
1680
|
+
|
|
1681
|
+
return {
|
|
1682
|
+
sourceEntityId,
|
|
1683
|
+
sourceName: persistSource.name,
|
|
1684
|
+
materializedTableId: instruction.materializedTableId,
|
|
1685
|
+
physicalTableName,
|
|
1686
|
+
connectionName: persistSource.connectionName,
|
|
1687
|
+
storageConnectionName: result.storageConnectionName,
|
|
1688
|
+
schema: result.schema,
|
|
1689
|
+
realization: instruction.realization,
|
|
1690
|
+
rowCount: null,
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* Stack on the parent: materialize a chained storage source by reading its
|
|
1696
|
+
* already-materialized upstream(s) from the SAME destination store. Rebinds
|
|
1697
|
+
* every same-destination materialized upstream to a virtual source (base-only,
|
|
1698
|
+
* the captured schema), re-declares the downstream over them (its definition
|
|
1699
|
+
* text lifted from the author's model), and hands the assembled transient
|
|
1700
|
+
* model to {@link buildDownstreamIntoStorage}, which compiles it against the
|
|
1701
|
+
* build session and CTASes the downstream's SQL — now reading the parents'
|
|
1702
|
+
* lake tables — into the destination.
|
|
1703
|
+
*
|
|
1704
|
+
* Throws (⇒ the caller falls back to recompute-from-raw) when there is no
|
|
1705
|
+
* same-destination upstream to build on, the definition text can't be lifted,
|
|
1706
|
+
* or the transient model doesn't compile (a parent refinement not carried, a
|
|
1707
|
+
* live-warehouse join, a cross-catalog parent).
|
|
1708
|
+
*/
|
|
1709
|
+
private async buildDownstreamViaParents(
|
|
1710
|
+
persistSource: PersistSource,
|
|
1711
|
+
destinationName: string,
|
|
1712
|
+
destinationConnection: ApiConnection,
|
|
1713
|
+
builtEntries: Record<string, ManifestEntry>,
|
|
1714
|
+
environment: BuildEnvironment,
|
|
1715
|
+
physicalTableName: string,
|
|
1716
|
+
): Promise<StorageBuildResult> {
|
|
1717
|
+
// Rebind every upstream materialized into THIS destination. A parent in a
|
|
1718
|
+
// DIFFERENT destination is absent here, so the downstream def fails to
|
|
1719
|
+
// compile against the rebind model and the caller falls back — cross-catalog
|
|
1720
|
+
// parent reuse is out of scope for the spike.
|
|
1721
|
+
const upstreams: ServeBinding[] = deriveServeBindings(
|
|
1722
|
+
builtEntries,
|
|
1723
|
+
).filter((b) => b.connectionName === destinationName);
|
|
1724
|
+
if (upstreams.length === 0) {
|
|
1725
|
+
throw new MaterializationEligibilityError({
|
|
1726
|
+
message:
|
|
1727
|
+
"no materialized upstream is available in this destination to build on",
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
const downstreamDefText = this.liftDownstreamDefText(persistSource);
|
|
1731
|
+
if (!downstreamDefText) {
|
|
1732
|
+
throw new MaterializationEligibilityError({
|
|
1733
|
+
message:
|
|
1734
|
+
"could not recover the downstream source definition text from the model",
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
const transientModel = buildChainedStorageBuildModel({
|
|
1738
|
+
upstreams,
|
|
1739
|
+
downstreamName: persistSource.name,
|
|
1740
|
+
downstreamDefText,
|
|
1741
|
+
destinationName,
|
|
1742
|
+
});
|
|
1743
|
+
return buildDownstreamIntoStorage({
|
|
1744
|
+
destinationName,
|
|
1745
|
+
destinationConnection,
|
|
1746
|
+
transientModel,
|
|
1747
|
+
downstreamName: persistSource.name,
|
|
1748
|
+
virtualMap: buildVirtualMap(upstreams),
|
|
1749
|
+
physicalTableName,
|
|
1750
|
+
environmentPath: environment.getEnvironmentPath(),
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/**
|
|
1755
|
+
* Lift the verbatim RHS of a persist source's `source: <name> is …`
|
|
1756
|
+
* declaration from the author's model file (same technique as the serve
|
|
1757
|
+
* transform's join/view lift): read the file named by the source's compiled
|
|
1758
|
+
* `location`, slice the covered range. A top-level source's range starts just
|
|
1759
|
+
* after the `source: ` keyword, so the result is `<name> is <def>` and the
|
|
1760
|
+
* transient-model assembler prepends `source: `. Returns undefined (⇒ fall
|
|
1761
|
+
* back) when there is no file-backed location or the file can't be read/sliced.
|
|
1762
|
+
*/
|
|
1763
|
+
private liftDownstreamDefText(
|
|
1764
|
+
persistSource: PersistSource,
|
|
1765
|
+
): string | undefined {
|
|
1766
|
+
const location = (
|
|
1767
|
+
persistSource._explore as unknown as { location?: SourceLocation }
|
|
1768
|
+
).location;
|
|
1769
|
+
if (!location?.url?.startsWith("file:")) return undefined;
|
|
1770
|
+
let text: string;
|
|
1771
|
+
try {
|
|
1772
|
+
text = readFileSync(fileURLToPath(location.url), "utf8");
|
|
1773
|
+
} catch {
|
|
1774
|
+
return undefined;
|
|
1775
|
+
}
|
|
1776
|
+
return sliceSourceRange(text, location.range);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
869
1779
|
// ==================== CANCELLATION ====================
|
|
870
1780
|
|
|
871
1781
|
/** Cancel a non-terminal materialization. */
|
|
@@ -932,6 +1842,74 @@ export class MaterializationService {
|
|
|
932
1842
|
}
|
|
933
1843
|
|
|
934
1844
|
await this.repository.deleteMaterialization(id);
|
|
1845
|
+
|
|
1846
|
+
// Re-derive the package's serve routing from the latest REMAINING
|
|
1847
|
+
// successful materialization — BOTH tiers. The deleted run may have been
|
|
1848
|
+
// the one bound for serving — and with `dropTables` its tables are now gone
|
|
1849
|
+
// — so without this a query would keep routing (schema-on-faith, no
|
|
1850
|
+
// run-time fallback) to a deleted/dropped table and error until the next
|
|
1851
|
+
// reload or build. Rebinding picks the next-latest generation, or CLEARS
|
|
1852
|
+
// the bindings when none remain (empty ⇒ serve live).
|
|
1853
|
+
await this.rebindServeBindingsAfterDelete(environmentName, packageName);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* Re-derive a package's serve routing from its latest remaining successful
|
|
1858
|
+
* materialization and push it onto the loaded models — BOTH tiers, mirroring
|
|
1859
|
+
* the load-time {@link Environment.rebindServeBindingsFromLocalStore}. Called
|
|
1860
|
+
* after a delete so serving never points at a removed table; picks the
|
|
1861
|
+
* next-latest generation, or clears the bindings when none remain.
|
|
1862
|
+
* Best-effort (a failure logs and leaves the current bindings — a later
|
|
1863
|
+
* reload/build re-derives).
|
|
1864
|
+
*
|
|
1865
|
+
* The manifest is split by tier:
|
|
1866
|
+
* - **colocated** (same-connection) → re-derived regardless of
|
|
1867
|
+
* `PERSIST_STORAGE_MODE`: colocated is the v0 path and is not gated by the
|
|
1868
|
+
* storage kill switch, so a reclaimed colocated table must not be left
|
|
1869
|
+
* routed even when the tier is off.
|
|
1870
|
+
* - **storage=** (cross-connection) → re-derived only when the tier is not
|
|
1871
|
+
* `off` (its serve routing requires the tier on; an off deployment does no
|
|
1872
|
+
* extra work here).
|
|
1873
|
+
*/
|
|
1874
|
+
private async rebindServeBindingsAfterDelete(
|
|
1875
|
+
environmentName: string,
|
|
1876
|
+
packageName: string,
|
|
1877
|
+
): Promise<void> {
|
|
1878
|
+
try {
|
|
1879
|
+
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
1880
|
+
// "" excludes nothing — the deleted record is already gone from the repo.
|
|
1881
|
+
const entries = await this.getMostRecentManifestEntries(
|
|
1882
|
+
environmentId,
|
|
1883
|
+
packageName,
|
|
1884
|
+
"",
|
|
1885
|
+
);
|
|
1886
|
+
const environment = await this.environmentStore.getEnvironment(
|
|
1887
|
+
environmentName,
|
|
1888
|
+
false,
|
|
1889
|
+
);
|
|
1890
|
+
const { tableNameManifest, storageEntries } = splitManifestEntries(
|
|
1891
|
+
entries,
|
|
1892
|
+
`post-delete rebind (package ${packageName})`,
|
|
1893
|
+
);
|
|
1894
|
+
// Colocated: re-derive (or clear) regardless of mode.
|
|
1895
|
+
await environment.bindPackageColocatedServeManifest(
|
|
1896
|
+
packageName,
|
|
1897
|
+
tableNameManifest,
|
|
1898
|
+
);
|
|
1899
|
+
// Storage=: only meaningful when the tier is not off.
|
|
1900
|
+
if (getPersistStorageMode() !== "off") {
|
|
1901
|
+
await environment.bindPackageStorageServeBindings(
|
|
1902
|
+
packageName,
|
|
1903
|
+
storageEntries,
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
} catch (err) {
|
|
1907
|
+
logger.warn(
|
|
1908
|
+
"Failed to rebind serve bindings after delete (leaving current " +
|
|
1909
|
+
"bindings; a reload/build will re-derive)",
|
|
1910
|
+
{ packageName, error: errMessage(err) },
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
935
1913
|
}
|
|
936
1914
|
|
|
937
1915
|
/**
|
|
@@ -940,6 +1918,14 @@ export class MaterializationService {
|
|
|
940
1918
|
* issues `DROP TABLE IF EXISTS` for the physical table and its (possible)
|
|
941
1919
|
* leftover staging table. Failures are logged and swallowed so a partial
|
|
942
1920
|
* cleanup never blocks deletion of the record.
|
|
1921
|
+
*
|
|
1922
|
+
* A physical name is dropped only when NO other remaining MANIFEST_FILE_READY
|
|
1923
|
+
* run references it. Physical names are the source's `name=` verbatim (or a
|
|
1924
|
+
* host-assigned name), so multiple generations of a source share one physical
|
|
1925
|
+
* name — dropping a superseded record's table would otherwise take out the
|
|
1926
|
+
* table the current generation still serves. The skip incidentally also
|
|
1927
|
+
* protects the colocated case, where generations likewise share a
|
|
1928
|
+
* name.
|
|
943
1929
|
*/
|
|
944
1930
|
private async dropMaterializedTables(
|
|
945
1931
|
environmentName: string,
|
|
@@ -958,6 +1944,29 @@ export class MaterializationService {
|
|
|
958
1944
|
const pkg = await environment.getPackage(packageName, false);
|
|
959
1945
|
const connectionCache = new Map<string, MalloyConnection>();
|
|
960
1946
|
|
|
1947
|
+
// Physical names still referenced by ANOTHER MANIFEST_FILE_READY run for
|
|
1948
|
+
// this package (keyed destination-and-name), so a shared name is never
|
|
1949
|
+
// dropped out from under a live generation. `m` is still in the repo at
|
|
1950
|
+
// this point (deletion happens after this sweep), so exclude it by id.
|
|
1951
|
+
const tableKey = (dest: string, table: string) => `${dest}:${table}`;
|
|
1952
|
+
const stillReferenced = new Set<string>();
|
|
1953
|
+
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
1954
|
+
const others =
|
|
1955
|
+
(await this.repository.listMaterializations(
|
|
1956
|
+
environmentId,
|
|
1957
|
+
packageName,
|
|
1958
|
+
)) ?? [];
|
|
1959
|
+
for (const other of others) {
|
|
1960
|
+
if (other.id === m.id) continue;
|
|
1961
|
+
if (other.status !== "MANIFEST_FILE_READY") continue;
|
|
1962
|
+
for (const e of Object.values(other.manifest?.entries ?? {})) {
|
|
1963
|
+
const dest = e.storageConnectionName ?? e.connectionName;
|
|
1964
|
+
if (dest && e.physicalTableName) {
|
|
1965
|
+
stillReferenced.add(tableKey(dest, e.physicalTableName));
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
961
1970
|
for (const entry of Object.values(entries)) {
|
|
962
1971
|
const connectionName = entry.connectionName;
|
|
963
1972
|
const physicalTableName = entry.physicalTableName;
|
|
@@ -969,6 +1978,62 @@ export class MaterializationService {
|
|
|
969
1978
|
continue;
|
|
970
1979
|
}
|
|
971
1980
|
|
|
1981
|
+
// Do not drop a table another live generation still serves (shared
|
|
1982
|
+
// physical name — see the method doc).
|
|
1983
|
+
const destForKey = entry.storageConnectionName ?? connectionName;
|
|
1984
|
+
if (stillReferenced.has(tableKey(destForKey, physicalTableName))) {
|
|
1985
|
+
logger.info(
|
|
1986
|
+
"Skipping drop: table still referenced by another materialization",
|
|
1987
|
+
{
|
|
1988
|
+
materializationId: m.id,
|
|
1989
|
+
physicalTableName,
|
|
1990
|
+
destination: destForKey,
|
|
1991
|
+
},
|
|
1992
|
+
);
|
|
1993
|
+
continue;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// A storage= table lives in `storageConnectionName` (a DuckDB/DuckLake
|
|
1997
|
+
// destination), not in `connectionName` (the source warehouse), and
|
|
1998
|
+
// dropping it needs a build-scoped READ-WRITE attach — the serve attach
|
|
1999
|
+
// is read-only — so it is dropped on its own RW session rather than on
|
|
2000
|
+
// the (wrong-engine, read-only) source connection. Best-effort: a
|
|
2001
|
+
// failure is logged and the sweep continues, so one unreachable
|
|
2002
|
+
// destination never blocks reclaiming the rest.
|
|
2003
|
+
if (entry.storageConnectionName) {
|
|
2004
|
+
const destinationConnection = environment.getApiConnection(
|
|
2005
|
+
entry.storageConnectionName,
|
|
2006
|
+
);
|
|
2007
|
+
try {
|
|
2008
|
+
await dropStorageTable({
|
|
2009
|
+
destinationName: entry.storageConnectionName,
|
|
2010
|
+
destinationConnection,
|
|
2011
|
+
physicalTableName,
|
|
2012
|
+
environmentPath: environment.getEnvironmentPath(),
|
|
2013
|
+
});
|
|
2014
|
+
recordDropTables("success", "storage");
|
|
2015
|
+
logger.info("Dropped materialized storage table on delete", {
|
|
2016
|
+
materializationId: m.id,
|
|
2017
|
+
physicalTableName,
|
|
2018
|
+
storageConnectionName: entry.storageConnectionName,
|
|
2019
|
+
});
|
|
2020
|
+
} catch (err) {
|
|
2021
|
+
recordDropTables("failure", "storage");
|
|
2022
|
+
logger.warn("Failed to drop a storage-materialized table", {
|
|
2023
|
+
materializationId: m.id,
|
|
2024
|
+
physicalTableName,
|
|
2025
|
+
storageConnectionName: entry.storageConnectionName,
|
|
2026
|
+
// The drop attaches read-write, so a failure can echo the
|
|
2027
|
+
// catalog connstring.
|
|
2028
|
+
error: redactConnectionSecrets(
|
|
2029
|
+
errMessage(err),
|
|
2030
|
+
destinationConnection,
|
|
2031
|
+
),
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
continue;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
972
2037
|
try {
|
|
973
2038
|
let connection = connectionCache.get(connectionName);
|
|
974
2039
|
if (!connection) {
|
|
@@ -994,14 +2059,14 @@ export class MaterializationService {
|
|
|
994
2059
|
dialect,
|
|
995
2060
|
)}`,
|
|
996
2061
|
);
|
|
997
|
-
recordDropTables("success");
|
|
2062
|
+
recordDropTables("success", "in_warehouse");
|
|
998
2063
|
logger.info("Dropped materialized table on delete", {
|
|
999
2064
|
materializationId: m.id,
|
|
1000
2065
|
physicalTableName,
|
|
1001
2066
|
connectionName,
|
|
1002
2067
|
});
|
|
1003
2068
|
} catch (err) {
|
|
1004
|
-
recordDropTables("failure");
|
|
2069
|
+
recordDropTables("failure", "in_warehouse");
|
|
1005
2070
|
logger.warn("Failed to drop materialized table on delete", {
|
|
1006
2071
|
materializationId: m.id,
|
|
1007
2072
|
physicalTableName,
|