@malloy-publisher/server 0.0.206 → 0.0.207
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/api-doc.yaml +31 -0
- package/dist/app/assets/{EnvironmentPage-BYwBeC2F.js → EnvironmentPage-BScgHmkw.js} +1 -1
- package/dist/app/assets/{HomePage-ivu4vdpj.js → HomePage-CGedji_w.js} +1 -1
- package/dist/app/assets/{MainPage-B2DnHEDU.js → MainPage-DWfF4jXW.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-BZEuwF9P.js → MaterializationsPage-B9PDlk8c.js} +1 -1
- package/dist/app/assets/{ModelPage-Dpu3bfPg.js → ModelPage-BiNOgK_e.js} +1 -1
- package/dist/app/assets/{PackagePage-B8PwcRHt.js → PackagePage-DAN5V7gu.js} +1 -1
- package/dist/app/assets/{RouteError-BhbywAeC.js → RouteError-CEnIzuKN.js} +1 -1
- package/dist/app/assets/{WorkbookPage-C-JXsJG0.js → WorkbookPage-gA1ceqHP.js} +1 -1
- package/dist/app/assets/{core-pPlPr7jK.es-CNEOlxKB.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
- package/dist/app/assets/{index-BHEm8Egc.js → index-DGGe8UpP.js} +1 -1
- package/dist/app/assets/{index-ChR1fKR2.js → index-DtlPzNxc.js} +3 -3
- package/dist/app/assets/{index-BsvDrV14.js → index-uu6UpHd2.js} +1 -1
- package/dist/app/assets/{index.umd-BVLPYNuj.js → index.umd-DDq93YX4.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +182 -96
- package/package.json +1 -1
- package/src/materialization_metrics.ts +55 -0
- package/src/service/environment.ts +59 -1
- package/src/service/materialization_service.spec.ts +272 -0
- package/src/service/materialization_service.ts +67 -45
- package/src/service/model.ts +19 -2
- package/src/service/package.ts +92 -22
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +25 -6
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +94 -0
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
ServiceUnavailableError,
|
|
22
22
|
} from "../errors";
|
|
23
23
|
import { logger } from "../logger";
|
|
24
|
+
import { recordManifestBind } from "../materialization_metrics";
|
|
24
25
|
import {
|
|
25
26
|
assertSafeEnvironmentPath,
|
|
26
27
|
assertSafePackageName,
|
|
@@ -56,6 +57,11 @@ import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
|
56
57
|
const STAGING_DIR_NAME = ".staging";
|
|
57
58
|
const RETIRED_DIR_NAME = ".retired";
|
|
58
59
|
|
|
60
|
+
// How long to wait for a control-plane manifest fetch during (re)bind before
|
|
61
|
+
// giving up and serving live. Binding happens before a package is marked
|
|
62
|
+
// SERVING, so an unreachable/slow manifest store must not block the package.
|
|
63
|
+
const MANIFEST_FETCH_TIMEOUT_MS = 15_000;
|
|
64
|
+
|
|
59
65
|
export enum PackageStatus {
|
|
60
66
|
LOADING = "loading",
|
|
61
67
|
SERVING = "serving",
|
|
@@ -393,9 +399,17 @@ export class Environment {
|
|
|
393
399
|
// Initialize Runtime with the package's active MalloyConfig so compile
|
|
394
400
|
// checks see the same package-scoped duckdb as execution. This runtime
|
|
395
401
|
// borrows the package config; the package/environment lifecycle owns release.
|
|
402
|
+
// Thread the package's bound build manifest (when present) so the
|
|
403
|
+
// /compile preview routes persist sources to their materialized tables
|
|
404
|
+
// exactly like execution does — otherwise includeSql=true would always
|
|
405
|
+
// show base-table SQL and diverge from what executeQuery actually runs.
|
|
406
|
+
const boundManifestEntries = pkg.getBuildManifestEntries();
|
|
396
407
|
const runtime = new Runtime({
|
|
397
408
|
urlReader: interceptingReader,
|
|
398
409
|
config: pkg.getMalloyConfig(),
|
|
410
|
+
buildManifest: boundManifestEntries
|
|
411
|
+
? { entries: boundManifestEntries, strict: false }
|
|
412
|
+
: undefined,
|
|
399
413
|
});
|
|
400
414
|
|
|
401
415
|
// Attempt to compile
|
|
@@ -1140,8 +1154,15 @@ export class Environment {
|
|
|
1140
1154
|
): Promise<void> {
|
|
1141
1155
|
const packageName = pkg.getPackageName();
|
|
1142
1156
|
try {
|
|
1143
|
-
|
|
1157
|
+
// Bind runs before a package is marked SERVING, so a slow/unreachable
|
|
1158
|
+
// manifest store must not block serving indefinitely — bound is the
|
|
1159
|
+
// intended state, live is the degraded fallback. Race the fetch against
|
|
1160
|
+
// a timeout and fall back to live on either failure.
|
|
1161
|
+
const entries =
|
|
1162
|
+
await this.fetchManifestEntriesWithTimeout(manifestLocation);
|
|
1144
1163
|
await pkg.reloadAllModels(entries);
|
|
1164
|
+
pkg.setBoundManifestUri(manifestLocation);
|
|
1165
|
+
recordManifestBind("success");
|
|
1145
1166
|
logger.info("Bound build manifest to package", {
|
|
1146
1167
|
environmentName: this.environmentName,
|
|
1147
1168
|
packageName,
|
|
@@ -1149,15 +1170,52 @@ export class Environment {
|
|
|
1149
1170
|
entryCount: Object.keys(entries).length,
|
|
1150
1171
|
});
|
|
1151
1172
|
} catch (err) {
|
|
1173
|
+
pkg.markManifestBindFailed();
|
|
1174
|
+
const timedOut =
|
|
1175
|
+
err instanceof Error && err.message.includes("Timed out after");
|
|
1176
|
+
recordManifestBind(timedOut ? "timeout" : "failure");
|
|
1152
1177
|
logger.warn("Failed to bind build manifest; serving live", {
|
|
1153
1178
|
environmentName: this.environmentName,
|
|
1154
1179
|
packageName,
|
|
1155
1180
|
manifestLocation,
|
|
1181
|
+
timedOut,
|
|
1156
1182
|
error: err instanceof Error ? err.message : String(err),
|
|
1157
1183
|
});
|
|
1158
1184
|
}
|
|
1159
1185
|
}
|
|
1160
1186
|
|
|
1187
|
+
/**
|
|
1188
|
+
* Fetch manifest entries, rejecting if the fetch exceeds
|
|
1189
|
+
* {@link MANIFEST_FETCH_TIMEOUT_MS}. Keeps {@link bindManifest}'s bind-before-
|
|
1190
|
+
* serve guarantee from stalling on an unreachable manifest store.
|
|
1191
|
+
*/
|
|
1192
|
+
private async fetchManifestEntriesWithTimeout(
|
|
1193
|
+
manifestLocation: string,
|
|
1194
|
+
): Promise<BuildManifest["entries"]> {
|
|
1195
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1196
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
1197
|
+
timer = setTimeout(
|
|
1198
|
+
() =>
|
|
1199
|
+
reject(
|
|
1200
|
+
new Error(
|
|
1201
|
+
`Timed out after ${MANIFEST_FETCH_TIMEOUT_MS}ms fetching manifest ${manifestLocation}`,
|
|
1202
|
+
),
|
|
1203
|
+
),
|
|
1204
|
+
MANIFEST_FETCH_TIMEOUT_MS,
|
|
1205
|
+
);
|
|
1206
|
+
});
|
|
1207
|
+
try {
|
|
1208
|
+
return await Promise.race([
|
|
1209
|
+
fetchManifestEntries(manifestLocation),
|
|
1210
|
+
timeout,
|
|
1211
|
+
]);
|
|
1212
|
+
} finally {
|
|
1213
|
+
if (timer) {
|
|
1214
|
+
clearTimeout(timer);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1161
1219
|
/**
|
|
1162
1220
|
* Read a model's source text from disk, holding the per-package mutex
|
|
1163
1221
|
* so the read is serialized against {@link installPackage} /
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Manifest } from "@malloydata/malloy";
|
|
1
2
|
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
3
|
import * as sinon from "sinon";
|
|
3
4
|
import {
|
|
@@ -498,3 +499,274 @@ describe("stagingSuffix", () => {
|
|
|
498
499
|
expect(stagingSuffix("abcdef1234567890")).toBe("_abcdef123456");
|
|
499
500
|
});
|
|
500
501
|
});
|
|
502
|
+
|
|
503
|
+
// Characterization (Pass 0): lock in the auto-run instruction-derivation and
|
|
504
|
+
// the single-source build SQL sequence before the simplify pass moves them.
|
|
505
|
+
// deriveSelfInstructions / buildOneSource are private; we exercise them via a
|
|
506
|
+
// typed cast rather than the heavyweight runtime path.
|
|
507
|
+
|
|
508
|
+
// A minimal stand-in for a Malloy PersistSource exposing only what the build
|
|
509
|
+
// internals touch.
|
|
510
|
+
function fakeSource(opts: {
|
|
511
|
+
name: string;
|
|
512
|
+
buildId: string;
|
|
513
|
+
sql?: string;
|
|
514
|
+
connectionName?: string;
|
|
515
|
+
}): unknown {
|
|
516
|
+
return {
|
|
517
|
+
name: opts.name,
|
|
518
|
+
sourceID: opts.name,
|
|
519
|
+
connectionName: opts.connectionName ?? "duckdb",
|
|
520
|
+
makeBuildId: () => opts.buildId,
|
|
521
|
+
getSQL: () => opts.sql ?? "SELECT 1",
|
|
522
|
+
annotations: {
|
|
523
|
+
parseAsTag: () => ({ tag: { text: () => undefined } }),
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
describe("deriveSelfInstructions (characterization)", () => {
|
|
529
|
+
let ctx: ReturnType<typeof createMocks>;
|
|
530
|
+
beforeEach(() => {
|
|
531
|
+
ctx = createMocks();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
function compiledWith(sources: Record<string, unknown>, levels: string[][]) {
|
|
535
|
+
return {
|
|
536
|
+
graphs: [
|
|
537
|
+
{
|
|
538
|
+
connectionName: "duckdb",
|
|
539
|
+
nodes: levels.map((level) =>
|
|
540
|
+
level.map((sourceID) => ({ sourceID, dependsOn: [] })),
|
|
541
|
+
),
|
|
542
|
+
},
|
|
543
|
+
],
|
|
544
|
+
sources,
|
|
545
|
+
connectionDigests: { duckdb: "dig" },
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
it("carries forward unchanged buildIds and builds the rest (deduping repeats)", () => {
|
|
550
|
+
const compiled = compiledWith(
|
|
551
|
+
{
|
|
552
|
+
s1: fakeSource({ name: "s1", buildId: "b1aaaaaaaaaaaaaa" }),
|
|
553
|
+
s2: fakeSource({ name: "s2", buildId: "b2bbbbbbbbbbbbbb" }),
|
|
554
|
+
},
|
|
555
|
+
[["s1", "s2"], ["s2"]],
|
|
556
|
+
);
|
|
557
|
+
const priorEntries = {
|
|
558
|
+
b1aaaaaaaaaaaaaa: {
|
|
559
|
+
buildId: "b1aaaaaaaaaaaaaa",
|
|
560
|
+
physicalTableName: "s1_prev",
|
|
561
|
+
connectionName: "duckdb",
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const { instructions, carried } = (
|
|
566
|
+
ctx.service as unknown as {
|
|
567
|
+
deriveSelfInstructions: (
|
|
568
|
+
c: unknown,
|
|
569
|
+
n: string[] | undefined,
|
|
570
|
+
p: unknown,
|
|
571
|
+
) => { instructions: BuildInstruction[]; carried: unknown };
|
|
572
|
+
}
|
|
573
|
+
).deriveSelfInstructions(compiled, undefined, priorEntries);
|
|
574
|
+
|
|
575
|
+
expect(instructions).toHaveLength(1);
|
|
576
|
+
expect(instructions[0].buildId).toBe("b2bbbbbbbbbbbbbb");
|
|
577
|
+
expect(instructions[0].physicalTableName).toBe("s2");
|
|
578
|
+
expect(instructions[0].realization).toBe("COPY");
|
|
579
|
+
expect(instructions[0].materializedTableId).toBe("local-b2bbbbbbbbbb");
|
|
580
|
+
expect(
|
|
581
|
+
(carried as Record<string, unknown>)["b1aaaaaaaaaaaaaa"],
|
|
582
|
+
).toBeDefined();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("honors the sourceNames filter (excluded sources are neither built nor carried)", () => {
|
|
586
|
+
const compiled = compiledWith(
|
|
587
|
+
{
|
|
588
|
+
s1: fakeSource({ name: "s1", buildId: "b1aaaaaaaaaaaaaa" }),
|
|
589
|
+
s2: fakeSource({ name: "s2", buildId: "b2bbbbbbbbbbbbbb" }),
|
|
590
|
+
},
|
|
591
|
+
[["s1", "s2"]],
|
|
592
|
+
);
|
|
593
|
+
const { instructions, carried } = (
|
|
594
|
+
ctx.service as unknown as {
|
|
595
|
+
deriveSelfInstructions: (
|
|
596
|
+
c: unknown,
|
|
597
|
+
n: string[] | undefined,
|
|
598
|
+
p: unknown,
|
|
599
|
+
) => { instructions: BuildInstruction[]; carried: unknown };
|
|
600
|
+
}
|
|
601
|
+
).deriveSelfInstructions(compiled, ["s2"], {});
|
|
602
|
+
|
|
603
|
+
expect(instructions).toHaveLength(1);
|
|
604
|
+
expect(instructions[0].buildId).toBe("b2bbbbbbbbbbbbbb");
|
|
605
|
+
expect(Object.keys(carried as Record<string, unknown>)).toHaveLength(0);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
describe("getMostRecentManifestEntries (skip-if-unchanged)", () => {
|
|
610
|
+
let ctx: ReturnType<typeof createMocks>;
|
|
611
|
+
beforeEach(() => {
|
|
612
|
+
ctx = createMocks();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("returns entries from the most recent successful run, excluding the in-flight one", async () => {
|
|
616
|
+
const entries = {
|
|
617
|
+
b1: {
|
|
618
|
+
buildId: "b1",
|
|
619
|
+
physicalTableName: "orders_v1",
|
|
620
|
+
connectionName: "duckdb",
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
ctx.repository.listMaterializations.resolves([
|
|
624
|
+
makeMaterialization({
|
|
625
|
+
id: "in-flight",
|
|
626
|
+
status: "MANIFEST_ROWS_READY",
|
|
627
|
+
}),
|
|
628
|
+
makeMaterialization({
|
|
629
|
+
id: "old-success",
|
|
630
|
+
status: "MANIFEST_FILE_READY",
|
|
631
|
+
manifest: { builtAt: "t", strict: false, entries },
|
|
632
|
+
}),
|
|
633
|
+
]);
|
|
634
|
+
|
|
635
|
+
const result = await (
|
|
636
|
+
ctx.service as unknown as {
|
|
637
|
+
getMostRecentManifestEntries: (
|
|
638
|
+
e: string,
|
|
639
|
+
p: string,
|
|
640
|
+
x: string,
|
|
641
|
+
) => Promise<unknown>;
|
|
642
|
+
}
|
|
643
|
+
).getMostRecentManifestEntries("env-1", "pkg", "in-flight");
|
|
644
|
+
|
|
645
|
+
expect(result).toEqual(entries);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("returns {} when the only successful run is the excluded one", async () => {
|
|
649
|
+
ctx.repository.listMaterializations.resolves([
|
|
650
|
+
makeMaterialization({
|
|
651
|
+
id: "self",
|
|
652
|
+
status: "MANIFEST_FILE_READY",
|
|
653
|
+
manifest: {
|
|
654
|
+
builtAt: "t",
|
|
655
|
+
strict: false,
|
|
656
|
+
entries: { b1: { buildId: "b1", physicalTableName: "t1" } },
|
|
657
|
+
},
|
|
658
|
+
}),
|
|
659
|
+
]);
|
|
660
|
+
|
|
661
|
+
const result = await (
|
|
662
|
+
ctx.service as unknown as {
|
|
663
|
+
getMostRecentManifestEntries: (
|
|
664
|
+
e: string,
|
|
665
|
+
p: string,
|
|
666
|
+
x: string,
|
|
667
|
+
) => Promise<unknown>;
|
|
668
|
+
}
|
|
669
|
+
).getMostRecentManifestEntries("env-1", "pkg", "self");
|
|
670
|
+
|
|
671
|
+
expect(result).toEqual({});
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
describe("stopMaterialization (in-flight)", () => {
|
|
676
|
+
let ctx: ReturnType<typeof createMocks>;
|
|
677
|
+
beforeEach(() => {
|
|
678
|
+
ctx = createMocks();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("aborts a running build cooperatively without forcing a transition", async () => {
|
|
682
|
+
ctx.repository.getMaterializationById.resolves(
|
|
683
|
+
makeMaterialization({ status: "MANIFEST_ROWS_READY" }),
|
|
684
|
+
);
|
|
685
|
+
const controller = new AbortController();
|
|
686
|
+
(
|
|
687
|
+
ctx.service as unknown as {
|
|
688
|
+
runningAbortControllers: Map<string, AbortController>;
|
|
689
|
+
}
|
|
690
|
+
).runningAbortControllers.set("mat-1", controller);
|
|
691
|
+
|
|
692
|
+
const result = await ctx.service.stopMaterialization(
|
|
693
|
+
"my-env",
|
|
694
|
+
"pkg",
|
|
695
|
+
"mat-1",
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
expect(controller.signal.aborted).toBe(true);
|
|
699
|
+
// The background run records the terminal state; stop must not also write.
|
|
700
|
+
expect(ctx.repository.updateMaterialization.called).toBe(false);
|
|
701
|
+
expect(result.status).toBe("MANIFEST_ROWS_READY");
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
describe("buildOneSource (characterization)", () => {
|
|
706
|
+
let ctx: ReturnType<typeof createMocks>;
|
|
707
|
+
beforeEach(() => {
|
|
708
|
+
ctx = createMocks();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
function callBuildOneSource(
|
|
712
|
+
connection: { runSQL: sinon.SinonStub },
|
|
713
|
+
physicalTableName: string,
|
|
714
|
+
): Promise<{ buildId: string; physicalTableName: string }> {
|
|
715
|
+
const source = fakeSource({
|
|
716
|
+
name: "orders",
|
|
717
|
+
buildId: "abcdef1234567890",
|
|
718
|
+
sql: "SELECT * FROM t",
|
|
719
|
+
});
|
|
720
|
+
const instruction: BuildInstruction = {
|
|
721
|
+
buildId: "abcdef1234567890",
|
|
722
|
+
materializedTableId: "mt-1",
|
|
723
|
+
physicalTableName,
|
|
724
|
+
realization: "COPY",
|
|
725
|
+
};
|
|
726
|
+
return (
|
|
727
|
+
ctx.service as unknown as {
|
|
728
|
+
buildOneSource: (
|
|
729
|
+
s: unknown,
|
|
730
|
+
i: BuildInstruction,
|
|
731
|
+
c: unknown,
|
|
732
|
+
d: Record<string, string>,
|
|
733
|
+
m: Manifest,
|
|
734
|
+
) => Promise<{ buildId: string; physicalTableName: string }>;
|
|
735
|
+
}
|
|
736
|
+
).buildOneSource(
|
|
737
|
+
source,
|
|
738
|
+
instruction,
|
|
739
|
+
connection,
|
|
740
|
+
{ duckdb: "dig" },
|
|
741
|
+
new Manifest(),
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
it("stages, swaps, and renames in a crash-safe order", async () => {
|
|
746
|
+
const runSQL = sinon.stub().resolves();
|
|
747
|
+
const entry = await callBuildOneSource({ runSQL }, "orders_v1");
|
|
748
|
+
|
|
749
|
+
const sql = runSQL.getCalls().map((c) => c.args[0] as string);
|
|
750
|
+
expect(sql).toEqual([
|
|
751
|
+
"DROP TABLE IF EXISTS orders_v1_abcdef123456",
|
|
752
|
+
"CREATE TABLE orders_v1_abcdef123456 AS (SELECT * FROM t)",
|
|
753
|
+
"DROP TABLE IF EXISTS orders_v1",
|
|
754
|
+
"ALTER TABLE orders_v1_abcdef123456 RENAME TO orders_v1",
|
|
755
|
+
]);
|
|
756
|
+
expect(entry.physicalTableName).toBe("orders_v1");
|
|
757
|
+
expect(entry.buildId).toBe("abcdef1234567890");
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("drops the staging table and rethrows when the build SQL fails", async () => {
|
|
761
|
+
const runSQL = sinon.stub();
|
|
762
|
+
runSQL.onCall(0).resolves(); // initial staging drop
|
|
763
|
+
runSQL.onCall(1).rejects(new Error("create boom")); // CREATE TABLE AS
|
|
764
|
+
runSQL.onCall(2).resolves(); // cleanup staging drop
|
|
765
|
+
await expect(callBuildOneSource({ runSQL }, "orders_v1")).rejects.toThrow(
|
|
766
|
+
"create boom",
|
|
767
|
+
);
|
|
768
|
+
expect(runSQL.lastCall.args[0]).toBe(
|
|
769
|
+
"DROP TABLE IF EXISTS orders_v1_abcdef123456",
|
|
770
|
+
);
|
|
771
|
+
});
|
|
772
|
+
});
|
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
import { logger } from "../logger";
|
|
17
17
|
import {
|
|
18
18
|
MaterializationRound,
|
|
19
|
+
recordDropTables,
|
|
19
20
|
recordMaterializationRound,
|
|
21
|
+
recordSourceBuildDuration,
|
|
20
22
|
} from "../materialization_metrics";
|
|
21
23
|
import {
|
|
22
24
|
BuildInstruction,
|
|
@@ -77,6 +79,21 @@ function flattenDependsOn(node: {
|
|
|
77
79
|
return node.dependsOn.map((d) => d.sourceID);
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The buildId for a persist source: a stable digest of its connection identity
|
|
84
|
+
* and canonical SQL. Centralizes the (source, connectionDigests) call shape so
|
|
85
|
+
* planning, self-instruction, and build all agree on the same id.
|
|
86
|
+
*/
|
|
87
|
+
function computeBuildId(
|
|
88
|
+
source: PersistSource,
|
|
89
|
+
connectionDigests: Record<string, string>,
|
|
90
|
+
): string {
|
|
91
|
+
return source.makeBuildId(
|
|
92
|
+
connectionDigests[source.connectionName],
|
|
93
|
+
source.getSQL(),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
/**
|
|
81
98
|
* Physical table name the publisher self-assigns in auto-run mode: the
|
|
82
99
|
* `#@ persist name=<table>` value if present, else the Malloy source name.
|
|
@@ -193,6 +210,11 @@ export class MaterializationService {
|
|
|
193
210
|
);
|
|
194
211
|
}
|
|
195
212
|
this.validateTransition(current.status, next);
|
|
213
|
+
logger.info("Materialization transition", {
|
|
214
|
+
materializationId: id,
|
|
215
|
+
from: current.status,
|
|
216
|
+
to: next,
|
|
217
|
+
});
|
|
196
218
|
return this.repository.updateMaterialization(id, {
|
|
197
219
|
status: next,
|
|
198
220
|
...extra,
|
|
@@ -435,25 +457,14 @@ export class MaterializationService {
|
|
|
435
457
|
signal,
|
|
436
458
|
);
|
|
437
459
|
|
|
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
460
|
const durationMs = Date.now() - startedAt;
|
|
446
|
-
await this.
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
sourcesBuilt: instructions.length,
|
|
454
|
-
sourcesReused: Object.keys(carried).length,
|
|
455
|
-
durationMs,
|
|
456
|
-
},
|
|
461
|
+
await this.commitManifest(id, entries, {
|
|
462
|
+
forceRefresh,
|
|
463
|
+
sourceNames: sourceNames ?? null,
|
|
464
|
+
pauseBetweenPhases: false,
|
|
465
|
+
sourcesBuilt: instructions.length,
|
|
466
|
+
sourcesReused: Object.keys(carried).length,
|
|
467
|
+
durationMs,
|
|
457
468
|
});
|
|
458
469
|
|
|
459
470
|
await this.autoLoadManifest(environment, packageName, entries);
|
|
@@ -503,9 +514,9 @@ export class MaterializationService {
|
|
|
503
514
|
if (!persistSource) continue;
|
|
504
515
|
if (include && !include.has(persistSource.name)) continue;
|
|
505
516
|
|
|
506
|
-
const buildId =
|
|
507
|
-
|
|
508
|
-
|
|
517
|
+
const buildId = computeBuildId(
|
|
518
|
+
persistSource,
|
|
519
|
+
compiled.connectionDigests,
|
|
509
520
|
);
|
|
510
521
|
if (seen.has(buildId)) continue;
|
|
511
522
|
seen.add(buildId);
|
|
@@ -689,21 +700,10 @@ export class MaterializationService {
|
|
|
689
700
|
signal,
|
|
690
701
|
);
|
|
691
702
|
|
|
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
703
|
const durationMs = Date.now() - startedAt;
|
|
700
|
-
await this.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
metadata: {
|
|
704
|
-
sourcesBuilt: Object.keys(entries).length,
|
|
705
|
-
durationMs,
|
|
706
|
-
},
|
|
704
|
+
await this.commitManifest(id, entries, {
|
|
705
|
+
sourcesBuilt: Object.keys(entries).length,
|
|
706
|
+
durationMs,
|
|
707
707
|
});
|
|
708
708
|
|
|
709
709
|
this.recordRound("round2", "success", startedAt);
|
|
@@ -769,10 +769,7 @@ export class MaterializationService {
|
|
|
769
769
|
const persistSource = sources[node.sourceID];
|
|
770
770
|
if (!persistSource) continue;
|
|
771
771
|
|
|
772
|
-
const buildId = persistSource
|
|
773
|
-
connectionDigests[persistSource.connectionName],
|
|
774
|
-
persistSource.getSQL(),
|
|
775
|
-
);
|
|
772
|
+
const buildId = computeBuildId(persistSource, connectionDigests);
|
|
776
773
|
const instruction = byBuildId.get(buildId);
|
|
777
774
|
if (!instruction) continue;
|
|
778
775
|
|
|
@@ -845,9 +842,12 @@ export class MaterializationService {
|
|
|
845
842
|
// Make this table visible to downstream sources built later this round.
|
|
846
843
|
manifest.update(buildId, { tableName: physicalTableName });
|
|
847
844
|
|
|
848
|
-
|
|
845
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
846
|
+
recordSourceBuildDuration(durationMs);
|
|
847
|
+
// Shared by auto-run and Round 2, so the message is path-neutral.
|
|
848
|
+
logger.info(`Built materialized source ${persistSource.name}`, {
|
|
849
849
|
physicalTableName,
|
|
850
|
-
durationMs
|
|
850
|
+
durationMs,
|
|
851
851
|
});
|
|
852
852
|
|
|
853
853
|
return {
|
|
@@ -980,12 +980,14 @@ export class MaterializationService {
|
|
|
980
980
|
entry.buildId,
|
|
981
981
|
)}`,
|
|
982
982
|
);
|
|
983
|
+
recordDropTables("success");
|
|
983
984
|
logger.info("Dropped materialized table on delete", {
|
|
984
985
|
materializationId: m.id,
|
|
985
986
|
physicalTableName,
|
|
986
987
|
connectionName,
|
|
987
988
|
});
|
|
988
989
|
} catch (err) {
|
|
990
|
+
recordDropTables("failure");
|
|
989
991
|
logger.warn("Failed to drop materialized table on delete", {
|
|
990
992
|
materializationId: m.id,
|
|
991
993
|
physicalTableName,
|
|
@@ -996,6 +998,29 @@ export class MaterializationService {
|
|
|
996
998
|
}
|
|
997
999
|
}
|
|
998
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* Finalize a successful build: advance MANIFEST_ROWS_READY -> FILE_READY and
|
|
1003
|
+
* persist the assembled manifest. Shared by auto-run and Round 2; the caller
|
|
1004
|
+
* supplies the per-path metadata. The build itself happens before this.
|
|
1005
|
+
*/
|
|
1006
|
+
private async commitManifest(
|
|
1007
|
+
id: string,
|
|
1008
|
+
entries: Record<string, ManifestEntry>,
|
|
1009
|
+
metadata: Record<string, unknown>,
|
|
1010
|
+
): Promise<void> {
|
|
1011
|
+
await this.transition(id, "MANIFEST_ROWS_READY");
|
|
1012
|
+
const manifest: BuildManifestResult = {
|
|
1013
|
+
builtAt: new Date().toISOString(),
|
|
1014
|
+
entries,
|
|
1015
|
+
strict: false,
|
|
1016
|
+
};
|
|
1017
|
+
await this.transition(id, "MANIFEST_FILE_READY", {
|
|
1018
|
+
completedAt: new Date(),
|
|
1019
|
+
manifest,
|
|
1020
|
+
metadata,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
999
1024
|
// ==================== BUILD PLAN COMPILATION ====================
|
|
1000
1025
|
|
|
1001
1026
|
/**
|
|
@@ -1098,10 +1123,7 @@ export class MaterializationService {
|
|
|
1098
1123
|
sourceID: source.sourceID,
|
|
1099
1124
|
connectionName: source.connectionName,
|
|
1100
1125
|
dialect: source.dialectName,
|
|
1101
|
-
buildId: source
|
|
1102
|
-
connectionDigests[source.connectionName],
|
|
1103
|
-
source.getSQL(),
|
|
1104
|
-
),
|
|
1126
|
+
buildId: computeBuildId(source, connectionDigests),
|
|
1105
1127
|
sql: source.getSQL(),
|
|
1106
1128
|
columns: deriveColumns(source),
|
|
1107
1129
|
};
|
package/src/service/model.ts
CHANGED
|
@@ -606,6 +606,7 @@ export class Model {
|
|
|
606
606
|
_packagePath: string,
|
|
607
607
|
malloyConfig: ModelConnectionInput,
|
|
608
608
|
data: SerializedModel,
|
|
609
|
+
options?: { buildManifest?: BuildManifest["entries"] },
|
|
609
610
|
): Model {
|
|
610
611
|
const modelDef = data.modelDef as ModelDef | undefined;
|
|
611
612
|
const modelInfo = data.modelInfo as Malloy.ModelInfo | undefined;
|
|
@@ -645,7 +646,10 @@ export class Model {
|
|
|
645
646
|
);
|
|
646
647
|
}
|
|
647
648
|
|
|
648
|
-
const runtime = makeHydrationRuntime(
|
|
649
|
+
const runtime = makeHydrationRuntime(
|
|
650
|
+
malloyConfig,
|
|
651
|
+
options?.buildManifest,
|
|
652
|
+
);
|
|
649
653
|
const modelMaterializer = runtime._loadModelFromModelDef(modelDef);
|
|
650
654
|
const runnableNotebookCells =
|
|
651
655
|
data.modelType === "notebook"
|
|
@@ -1921,6 +1925,7 @@ type HydrationMaterializer = ModelMaterializer & {
|
|
|
1921
1925
|
|
|
1922
1926
|
function makeHydrationRuntime(
|
|
1923
1927
|
malloyConfig: ModelConnectionInput,
|
|
1928
|
+
buildManifest?: BuildManifest["entries"],
|
|
1924
1929
|
): HydrationRuntime {
|
|
1925
1930
|
const urlReader = new HackyDataStylesAccumulator(URL_READER);
|
|
1926
1931
|
const config =
|
|
@@ -1933,7 +1938,19 @@ function makeHydrationRuntime(
|
|
|
1933
1938
|
);
|
|
1934
1939
|
return c;
|
|
1935
1940
|
})();
|
|
1936
|
-
|
|
1941
|
+
// Thread the package's bound build manifest into the *serve* runtime. Malloy
|
|
1942
|
+
// substitutes a persisted source for its materialized table at query
|
|
1943
|
+
// (getSQL) time, gated on `prepareResultOptions.buildManifest`; without this
|
|
1944
|
+
// the hydrated model always recomputes from the base tables even though the
|
|
1945
|
+
// manifest was bound at load. `strict: false` keeps serving live for any
|
|
1946
|
+
// source whose buildId is absent from the manifest.
|
|
1947
|
+
return new Runtime({
|
|
1948
|
+
urlReader,
|
|
1949
|
+
config,
|
|
1950
|
+
buildManifest: buildManifest
|
|
1951
|
+
? { entries: buildManifest, strict: false }
|
|
1952
|
+
: undefined,
|
|
1953
|
+
}) as HydrationRuntime;
|
|
1937
1954
|
}
|
|
1938
1955
|
|
|
1939
1956
|
/**
|