@malloy-publisher/server 0.0.208 → 0.0.210
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 +84 -66
- package/dist/app/assets/{EnvironmentPage-DDRxo015.js → EnvironmentPage-BRMCY9d8.js} +1 -1
- package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
- package/dist/app/assets/{MainPage-DHVFRXPc.js → MainPage-sZdUjUcu.js} +2 -2
- package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
- package/dist/app/assets/{ModelPage-B8tF_hYc.js → ModelPage-Bh62OIEq.js} +1 -1
- package/dist/app/assets/{PackagePage-LzaaviPn.js → PackagePage-DJW4xjLp.js} +1 -1
- package/dist/app/assets/{RouteError-vAYvRAi3.js → RouteError-Vi5yGs_F.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CTjs2hXr.js → WorkbookPage-BvJMi21d.js} +1 -1
- package/dist/app/assets/{core-AOmIKwkc.es-B29cca-e.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
- package/dist/app/assets/{index-Dj4uKosi.js → index-CCcHdeew.js} +1 -1
- package/dist/app/assets/{index-CVGIZdxd.js → index-CNFX-CGL.js} +1 -1
- package/dist/app/assets/{index-Db2wvjL3.js → index-DuqTjxM_.js} +114 -114
- package/dist/app/assets/{index.umd-BRRXibWA.js → index.umd-GgEb4WfT.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +5986 -470
- package/package.json +2 -1
- package/src/controller/materialization.controller.spec.ts +125 -0
- package/src/controller/materialization.controller.ts +23 -27
- package/src/materialization_metrics.ts +117 -34
- package/src/server-old.ts +2 -11
- package/src/server.ts +2 -10
- package/src/service/build_plan.spec.ts +116 -0
- package/src/service/build_plan.ts +238 -0
- package/src/service/connection.ts +4 -0
- package/src/service/connection_config.spec.ts +182 -1
- package/src/service/connection_config.ts +70 -0
- package/src/service/db_utils.spec.ts +159 -1
- package/src/service/db_utils.ts +131 -0
- package/src/service/materialization_service.spec.ts +388 -184
- package/src/service/materialization_service.ts +156 -442
- package/src/service/materialization_test_fixtures.ts +119 -0
- package/src/service/model.ts +25 -25
- package/src/service/package.ts +46 -6
- package/src/service/package_worker_path.spec.ts +65 -55
- package/src/storage/DatabaseInterface.ts +5 -13
- package/src/storage/duckdb/MaterializationRepository.ts +5 -14
- package/src/storage/duckdb/schema.ts +4 -5
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
- package/dist/app/assets/HomePage-BeIoPOVO.js +0 -1
- package/dist/app/assets/MaterializationsPage-BYnr56IV.js +0 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloy-publisher/server",
|
|
3
3
|
"description": "Malloy Publisher Server",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.210",
|
|
5
5
|
"main": "dist/server.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"malloy-publisher": "dist/server.mjs"
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"@malloydata/db-duckdb": "^0.0.415",
|
|
42
42
|
"@malloydata/db-mysql": "^0.0.415",
|
|
43
43
|
"@malloydata/db-postgres": "^0.0.415",
|
|
44
|
+
"@malloydata/db-publisher": "^0.0.415",
|
|
44
45
|
"@malloydata/db-snowflake": "^0.0.415",
|
|
45
46
|
"@malloydata/db-trino": "^0.0.415",
|
|
46
47
|
"@malloydata/malloy": "^0.0.415",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import * as sinon from "sinon";
|
|
3
|
+
import { BadRequestError } from "../errors";
|
|
4
|
+
import type { MaterializationService } from "../service/materialization_service";
|
|
5
|
+
import { MaterializationController } from "./materialization.controller";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Unit tests for {@link MaterializationController.createMaterialization}'s body
|
|
9
|
+
* validation. The service is stubbed so each test asserts the parsed options
|
|
10
|
+
* the controller forwards (or the rejection for a malformed body).
|
|
11
|
+
*/
|
|
12
|
+
function build() {
|
|
13
|
+
const createMaterialization = sinon.stub().resolves({ id: "m1" });
|
|
14
|
+
const service = {
|
|
15
|
+
createMaterialization,
|
|
16
|
+
} as unknown as MaterializationService;
|
|
17
|
+
const controller = new MaterializationController(service);
|
|
18
|
+
return { controller, createMaterialization };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parsed options the controller forwards for a given request body. */
|
|
22
|
+
async function parse(body: Record<string, unknown>) {
|
|
23
|
+
const { controller, createMaterialization } = build();
|
|
24
|
+
await controller.createMaterialization("env", "pkg", body);
|
|
25
|
+
return createMaterialization.firstCall.args[2];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("MaterializationController.createMaterialization validation", () => {
|
|
29
|
+
it("forwards an empty body as empty options", async () => {
|
|
30
|
+
expect(await parse({})).toEqual({});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("passes through forceRefresh and sourceNames", async () => {
|
|
34
|
+
expect(
|
|
35
|
+
await parse({ forceRefresh: true, sourceNames: ["a", "b"] }),
|
|
36
|
+
).toEqual({ forceRefresh: true, sourceNames: ["a", "b"] });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a non-boolean forceRefresh", async () => {
|
|
40
|
+
const { controller } = build();
|
|
41
|
+
await expect(
|
|
42
|
+
controller.createMaterialization("env", "pkg", {
|
|
43
|
+
forceRefresh: "yes",
|
|
44
|
+
}),
|
|
45
|
+
).rejects.toThrow(BadRequestError);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects sourceNames that is not an array of strings", async () => {
|
|
49
|
+
const { controller } = build();
|
|
50
|
+
await expect(
|
|
51
|
+
controller.createMaterialization("env", "pkg", {
|
|
52
|
+
sourceNames: [1, 2],
|
|
53
|
+
}),
|
|
54
|
+
).rejects.toThrow(BadRequestError);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("flattens buildInstructions.sources into the instruction list", async () => {
|
|
58
|
+
const parsed = await parse({
|
|
59
|
+
buildInstructions: {
|
|
60
|
+
sources: [
|
|
61
|
+
{
|
|
62
|
+
buildId: "b1",
|
|
63
|
+
sourceID: "orders@m",
|
|
64
|
+
materializedTableId: "mt-1",
|
|
65
|
+
physicalTableName: "orders_v1",
|
|
66
|
+
realization: "COPY",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
expect(parsed).toEqual({
|
|
72
|
+
buildInstructions: [
|
|
73
|
+
{
|
|
74
|
+
buildId: "b1",
|
|
75
|
+
sourceID: "orders@m",
|
|
76
|
+
materializedTableId: "mt-1",
|
|
77
|
+
physicalTableName: "orders_v1",
|
|
78
|
+
realization: "COPY",
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("treats null buildInstructions as absent (auto-run)", async () => {
|
|
85
|
+
expect(await parse({ buildInstructions: null })).toEqual({});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects buildInstructions without a non-empty sources array", async () => {
|
|
89
|
+
const { controller } = build();
|
|
90
|
+
await expect(
|
|
91
|
+
controller.createMaterialization("env", "pkg", {
|
|
92
|
+
buildInstructions: { sources: [] },
|
|
93
|
+
}),
|
|
94
|
+
).rejects.toThrow(BadRequestError);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("rejects an instruction missing a required field", async () => {
|
|
98
|
+
const { controller } = build();
|
|
99
|
+
await expect(
|
|
100
|
+
controller.createMaterialization("env", "pkg", {
|
|
101
|
+
buildInstructions: {
|
|
102
|
+
sources: [{ buildId: "b1", materializedTableId: "mt-1" }],
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
).rejects.toThrow(BadRequestError);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects an unrecognized realization", async () => {
|
|
109
|
+
const { controller } = build();
|
|
110
|
+
await expect(
|
|
111
|
+
controller.createMaterialization("env", "pkg", {
|
|
112
|
+
buildInstructions: {
|
|
113
|
+
sources: [
|
|
114
|
+
{
|
|
115
|
+
buildId: "b1",
|
|
116
|
+
materializedTableId: "mt-1",
|
|
117
|
+
physicalTableName: "orders_v1",
|
|
118
|
+
realization: "MERGE",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
).rejects.toThrow(BadRequestError);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -20,18 +20,20 @@ export class MaterializationController {
|
|
|
20
20
|
private validateCreateBody(body: Record<string, unknown>): {
|
|
21
21
|
forceRefresh?: boolean;
|
|
22
22
|
sourceNames?: string[];
|
|
23
|
-
|
|
23
|
+
buildInstructions?: BuildInstruction[];
|
|
24
24
|
} {
|
|
25
25
|
const result: {
|
|
26
26
|
forceRefresh?: boolean;
|
|
27
27
|
sourceNames?: string[];
|
|
28
|
-
|
|
28
|
+
buildInstructions?: BuildInstruction[];
|
|
29
29
|
} = {};
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
result.
|
|
30
|
+
if (
|
|
31
|
+
body.buildInstructions !== undefined &&
|
|
32
|
+
body.buildInstructions !== null
|
|
33
|
+
) {
|
|
34
|
+
result.buildInstructions = this.validateBuildInstructions(
|
|
35
|
+
body.buildInstructions,
|
|
36
|
+
);
|
|
35
37
|
}
|
|
36
38
|
if (body.forceRefresh !== undefined) {
|
|
37
39
|
if (typeof body.forceRefresh !== "boolean") {
|
|
@@ -53,30 +55,24 @@ export class MaterializationController {
|
|
|
53
55
|
return result;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.validateBuildBody(body),
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
private validateBuildBody(
|
|
71
|
-
body: Record<string, unknown>,
|
|
72
|
-
): BuildInstruction[] {
|
|
73
|
-
const sources = body.sources;
|
|
58
|
+
/**
|
|
59
|
+
* Validate the orchestrated `buildInstructions` payload (BuildInstructions:
|
|
60
|
+
* `{ sources: BuildInstruction[] }`) and flatten it to the instruction list
|
|
61
|
+
* the service consumes.
|
|
62
|
+
*/
|
|
63
|
+
private validateBuildInstructions(raw: unknown): BuildInstruction[] {
|
|
64
|
+
if (typeof raw !== "object" || raw === null) {
|
|
65
|
+
throw new BadRequestError("buildInstructions must be an object");
|
|
66
|
+
}
|
|
67
|
+
const sources = (raw as Record<string, unknown>).sources;
|
|
74
68
|
if (!Array.isArray(sources) || sources.length === 0) {
|
|
75
69
|
throw new BadRequestError(
|
|
76
|
-
"
|
|
70
|
+
"buildInstructions requires a non-empty 'sources' array of BuildInstruction",
|
|
77
71
|
);
|
|
78
72
|
}
|
|
79
|
-
return sources.map((
|
|
73
|
+
return sources.map((instruction) =>
|
|
74
|
+
this.validateInstruction(instruction),
|
|
75
|
+
);
|
|
80
76
|
}
|
|
81
77
|
|
|
82
78
|
private validateInstruction(raw: unknown): BuildInstruction {
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Centralized telemetry for
|
|
2
|
+
* Centralized telemetry for materialization builds.
|
|
3
3
|
*
|
|
4
|
-
* Operators need to answer "are builds completing, how long do
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* side by side.
|
|
4
|
+
* Operators need to answer "are builds completing, how long do they take, and
|
|
5
|
+
* where are they failing?" without grepping logs. The run counter carries
|
|
6
|
+
* `mode` (`auto`|`orchestrated`) and `outcome` (`success`|`failed`|`cancelled`);
|
|
7
|
+
* the run-duration histogram carries `mode` so a dashboard can render auto-run
|
|
8
|
+
* vs orchestrated build latency side by side.
|
|
10
9
|
*
|
|
11
10
|
* Lazy init for the same reason as {@link ./query_cap_metrics}: instruments
|
|
12
11
|
* created before `setGlobalMeterProvider` bind to a NoOp meter
|
|
@@ -15,58 +14,138 @@
|
|
|
15
14
|
|
|
16
15
|
import { metrics, type Counter, type Histogram } from "@opentelemetry/api";
|
|
17
16
|
|
|
18
|
-
export type
|
|
17
|
+
export type MaterializationMode = "auto" | "orchestrated";
|
|
19
18
|
export type MaterializationOutcome = "success" | "failed" | "cancelled";
|
|
20
19
|
/** Manifest bind outcome: timeout is split out from generic failure on purpose. */
|
|
21
20
|
export type ManifestBindOutcome = "success" | "failure" | "timeout";
|
|
22
21
|
|
|
23
|
-
let
|
|
24
|
-
let
|
|
22
|
+
let runCounter: Counter | null = null;
|
|
23
|
+
let runDuration: Histogram | null = null;
|
|
24
|
+
let sourcesCounter: Counter | null = null;
|
|
25
|
+
let buildPlanComputeDuration: Histogram | null = null;
|
|
26
|
+
let autoLoadCounter: Counter | null = null;
|
|
27
|
+
let connectionDigestSkipCounter: Counter | null = null;
|
|
25
28
|
let manifestBindCounter: Counter | null = null;
|
|
26
29
|
let sourceBuildDuration: Histogram | null = null;
|
|
27
30
|
let dropTablesCounter: Counter | null = null;
|
|
28
31
|
|
|
29
32
|
function ensureTelemetry(): { counter: Counter; duration: Histogram } {
|
|
30
|
-
if (
|
|
31
|
-
return { counter:
|
|
33
|
+
if (runCounter && runDuration) {
|
|
34
|
+
return { counter: runCounter, duration: runDuration };
|
|
32
35
|
}
|
|
33
36
|
const meter = metrics.getMeter("publisher");
|
|
34
|
-
if (!
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"Materialization rounds completed. Labels: round ('round1'|'round2'|'auto'), outcome ('success'|'failed'|'cancelled').",
|
|
40
|
-
},
|
|
41
|
-
);
|
|
37
|
+
if (!runCounter) {
|
|
38
|
+
runCounter = meter.createCounter("publisher_materialization_runs_total", {
|
|
39
|
+
description:
|
|
40
|
+
"Materialization builds completed. Labels: mode ('auto'|'orchestrated'), outcome ('success'|'failed'|'cancelled').",
|
|
41
|
+
});
|
|
42
42
|
}
|
|
43
|
-
if (!
|
|
44
|
-
|
|
45
|
-
"
|
|
43
|
+
if (!runDuration) {
|
|
44
|
+
runDuration = meter.createHistogram(
|
|
45
|
+
"publisher_materialization_run_duration_ms",
|
|
46
46
|
{
|
|
47
47
|
description:
|
|
48
|
-
"Wall-clock duration of a materialization
|
|
48
|
+
"Wall-clock duration of a materialization build. Label: mode ('auto'|'orchestrated').",
|
|
49
49
|
unit: "ms",
|
|
50
50
|
},
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
|
-
return { counter:
|
|
53
|
+
return { counter: runCounter, duration: runDuration };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/** Record the outcome and duration of a materialization
|
|
57
|
-
export function
|
|
58
|
-
|
|
56
|
+
/** Record the outcome and duration of a materialization build. */
|
|
57
|
+
export function recordMaterializationRun(
|
|
58
|
+
mode: MaterializationMode,
|
|
59
59
|
outcome: MaterializationOutcome,
|
|
60
60
|
durationMs: number,
|
|
61
61
|
): void {
|
|
62
62
|
const { counter, duration } = ensureTelemetry();
|
|
63
|
-
counter.add(1, {
|
|
64
|
-
duration.record(durationMs, {
|
|
63
|
+
counter.add(1, { mode, outcome });
|
|
64
|
+
duration.record(durationMs, { mode });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Record how many persist sources a run built vs. reused (carried forward
|
|
69
|
+
* unchanged via skip-if-unchanged). Lets a dashboard show the reuse ratio,
|
|
70
|
+
* the main lever on materialization cost.
|
|
71
|
+
*/
|
|
72
|
+
export function recordSourcesOutcome(
|
|
73
|
+
outcome: "built" | "reused",
|
|
74
|
+
count: number,
|
|
75
|
+
): void {
|
|
76
|
+
if (count <= 0) return;
|
|
77
|
+
if (!sourcesCounter) {
|
|
78
|
+
sourcesCounter = metrics
|
|
79
|
+
.getMeter("publisher")
|
|
80
|
+
.createCounter("publisher_materialization_sources_total", {
|
|
81
|
+
description:
|
|
82
|
+
"Persist sources processed by a materialization run. Label: outcome ('built'|'reused').",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
sourcesCounter.add(count, { outcome });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Record the wall-clock cost of compiling a package's build plan
|
|
90
|
+
* (`Package.buildPlan`). This recompiles models at load, so it is worth
|
|
91
|
+
* tracking as a discrete cost separate from the build itself.
|
|
92
|
+
*/
|
|
93
|
+
export function recordBuildPlanComputeDuration(durationMs: number): void {
|
|
94
|
+
if (!buildPlanComputeDuration) {
|
|
95
|
+
buildPlanComputeDuration = metrics
|
|
96
|
+
.getMeter("publisher")
|
|
97
|
+
.createHistogram(
|
|
98
|
+
"publisher_materialization_build_plan_compute_duration_ms",
|
|
99
|
+
{
|
|
100
|
+
description:
|
|
101
|
+
"Wall-clock duration of compiling a package's build plan (Package.buildPlan).",
|
|
102
|
+
unit: "ms",
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
buildPlanComputeDuration.record(durationMs);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Record an auto-run manifest auto-load attempt. Auto-load is best-effort (a
|
|
111
|
+
* failure does not fail the run), so a failure here is otherwise invisible:
|
|
112
|
+
* the run is MANIFEST_FILE_READY but queries still resolve to the old tables.
|
|
113
|
+
*/
|
|
114
|
+
export function recordAutoLoadOutcome(outcome: "success" | "failure"): void {
|
|
115
|
+
if (!autoLoadCounter) {
|
|
116
|
+
autoLoadCounter = metrics
|
|
117
|
+
.getMeter("publisher")
|
|
118
|
+
.createCounter("publisher_materialization_auto_load_total", {
|
|
119
|
+
description:
|
|
120
|
+
"Auto-run manifest auto-load attempts. Label: outcome ('success'|'failure').",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
autoLoadCounter.add(1, { outcome });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Record that a connection digest could not be computed during build-plan
|
|
128
|
+
* compilation because the connection failed to resolve. The resulting buildIds
|
|
129
|
+
* are computed without a digest, so this is a correctness signal, not just noise.
|
|
130
|
+
*/
|
|
131
|
+
export function recordConnectionDigestSkipped(): void {
|
|
132
|
+
if (!connectionDigestSkipCounter) {
|
|
133
|
+
connectionDigestSkipCounter = metrics
|
|
134
|
+
.getMeter("publisher")
|
|
135
|
+
.createCounter(
|
|
136
|
+
"publisher_materialization_connection_digest_skipped_total",
|
|
137
|
+
{
|
|
138
|
+
description:
|
|
139
|
+
"Connection digests skipped during build-plan compile because the connection did not resolve.",
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
connectionDigestSkipCounter.add(1);
|
|
65
144
|
}
|
|
66
145
|
|
|
67
146
|
/**
|
|
68
|
-
* Record a manifest-bind attempt (publisher binding a
|
|
69
|
-
*
|
|
147
|
+
* Record a manifest-bind attempt (publisher binding a configured manifest to a
|
|
148
|
+
* package at load). `timeout` is distinguished from `failure` so operators can
|
|
70
149
|
* tell an unreachable/slow manifest store from a malformed manifest.
|
|
71
150
|
*/
|
|
72
151
|
export function recordManifestBind(outcome: ManifestBindOutcome): void {
|
|
@@ -113,8 +192,12 @@ export function recordDropTables(outcome: "success" | "failure"): void {
|
|
|
113
192
|
|
|
114
193
|
/** Visible for tests. Drops cached instruments so a fresh MeterProvider can capture emissions. */
|
|
115
194
|
export function resetMaterializationTelemetryForTesting(): void {
|
|
116
|
-
|
|
117
|
-
|
|
195
|
+
runCounter = null;
|
|
196
|
+
runDuration = null;
|
|
197
|
+
sourcesCounter = null;
|
|
198
|
+
buildPlanComputeDuration = null;
|
|
199
|
+
autoLoadCounter = null;
|
|
200
|
+
connectionDigestSkipCounter = null;
|
|
118
201
|
manifestBindCounter = null;
|
|
119
202
|
sourceBuildDuration = null;
|
|
120
203
|
dropTablesCounter = null;
|
package/src/server-old.ts
CHANGED
|
@@ -944,16 +944,7 @@ export function registerLegacyRoutes(
|
|
|
944
944
|
async (req, res) => {
|
|
945
945
|
try {
|
|
946
946
|
const action = req.query.action;
|
|
947
|
-
if (action === "
|
|
948
|
-
const build =
|
|
949
|
-
await materializationController.buildMaterialization(
|
|
950
|
-
req.params.projectName,
|
|
951
|
-
req.params.packageName,
|
|
952
|
-
req.params.materializationId,
|
|
953
|
-
req.body || {},
|
|
954
|
-
);
|
|
955
|
-
res.status(202).json(remapMaterializationResponse(build));
|
|
956
|
-
} else if (action === "stop") {
|
|
947
|
+
if (action === "stop") {
|
|
957
948
|
const build =
|
|
958
949
|
await materializationController.stopMaterialization(
|
|
959
950
|
req.params.projectName,
|
|
@@ -963,7 +954,7 @@ export function registerLegacyRoutes(
|
|
|
963
954
|
res.status(200).json(remapMaterializationResponse(build));
|
|
964
955
|
} else {
|
|
965
956
|
throw new BadRequestError(
|
|
966
|
-
`Unsupported action '${String(action ?? "")}'. Expected '
|
|
957
|
+
`Unsupported action '${String(action ?? "")}'. Expected 'stop'.`,
|
|
967
958
|
);
|
|
968
959
|
}
|
|
969
960
|
} catch (error) {
|
package/src/server.ts
CHANGED
|
@@ -1592,15 +1592,7 @@ app.post(
|
|
|
1592
1592
|
async (req, res) => {
|
|
1593
1593
|
try {
|
|
1594
1594
|
const action = req.query.action;
|
|
1595
|
-
if (action === "
|
|
1596
|
-
const build = await materializationController.buildMaterialization(
|
|
1597
|
-
req.params.environmentName,
|
|
1598
|
-
req.params.packageName,
|
|
1599
|
-
req.params.materializationId,
|
|
1600
|
-
req.body || {},
|
|
1601
|
-
);
|
|
1602
|
-
res.status(202).json(build);
|
|
1603
|
-
} else if (action === "stop") {
|
|
1595
|
+
if (action === "stop") {
|
|
1604
1596
|
const build = await materializationController.stopMaterialization(
|
|
1605
1597
|
req.params.environmentName,
|
|
1606
1598
|
req.params.packageName,
|
|
@@ -1609,7 +1601,7 @@ app.post(
|
|
|
1609
1601
|
res.status(200).json(build);
|
|
1610
1602
|
} else {
|
|
1611
1603
|
throw new BadRequestError(
|
|
1612
|
-
`Unsupported action '${String(action ?? "")}'. Expected '
|
|
1604
|
+
`Unsupported action '${String(action ?? "")}'. Expected 'stop'.`,
|
|
1613
1605
|
);
|
|
1614
1606
|
}
|
|
1615
1607
|
} catch (error) {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { PersistSource } from "@malloydata/malloy";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import * as sinon from "sinon";
|
|
4
|
+
import {
|
|
5
|
+
computeBuildId,
|
|
6
|
+
computePackageBuildPlan,
|
|
7
|
+
deriveBuildPlan,
|
|
8
|
+
flattenDependsOn,
|
|
9
|
+
resolvePackageConnections,
|
|
10
|
+
} from "./build_plan";
|
|
11
|
+
import { fakeSource } from "./materialization_test_fixtures";
|
|
12
|
+
|
|
13
|
+
describe("flattenDependsOn", () => {
|
|
14
|
+
it("maps nested dependsOn entries to a flat sourceID list", () => {
|
|
15
|
+
expect(
|
|
16
|
+
flattenDependsOn({
|
|
17
|
+
dependsOn: [{ sourceID: "a" }, { sourceID: "b" }],
|
|
18
|
+
}),
|
|
19
|
+
).toEqual(["a", "b"]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("computeBuildId", () => {
|
|
24
|
+
it("delegates to PersistSource.makeBuildId with the connection digest and SQL", () => {
|
|
25
|
+
const makeBuildId = sinon.stub().returns("computed-id");
|
|
26
|
+
const source = {
|
|
27
|
+
connectionName: "duckdb",
|
|
28
|
+
makeBuildId,
|
|
29
|
+
getSQL: () => "SELECT 7",
|
|
30
|
+
} as unknown as PersistSource;
|
|
31
|
+
|
|
32
|
+
const id = computeBuildId(source, { duckdb: "dig-1" });
|
|
33
|
+
|
|
34
|
+
expect(id).toBe("computed-id");
|
|
35
|
+
expect(makeBuildId.calledOnceWithExactly("dig-1", "SELECT 7")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("resolvePackageConnections", () => {
|
|
40
|
+
it("resolves each unique name once and omits failures", async () => {
|
|
41
|
+
const getMalloyConnection = sinon.stub();
|
|
42
|
+
getMalloyConnection.withArgs("ok").resolves({ id: "ok-conn" });
|
|
43
|
+
getMalloyConnection.withArgs("bad").rejects(new Error("nope"));
|
|
44
|
+
|
|
45
|
+
const map = await resolvePackageConnections({ getMalloyConnection }, [
|
|
46
|
+
"ok",
|
|
47
|
+
"ok",
|
|
48
|
+
"bad",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
expect(map.has("ok")).toBe(true);
|
|
52
|
+
expect(map.has("bad")).toBe(false);
|
|
53
|
+
// "ok" requested twice but resolved once (dedupe).
|
|
54
|
+
expect(getMalloyConnection.withArgs("ok").callCount).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("deriveBuildPlan", () => {
|
|
59
|
+
it("projects graphs and sources into the wire build plan", () => {
|
|
60
|
+
const orders = fakeSource({
|
|
61
|
+
name: "orders",
|
|
62
|
+
buildId: "bid-orders",
|
|
63
|
+
sql: "SELECT 1",
|
|
64
|
+
});
|
|
65
|
+
const plan = deriveBuildPlan(
|
|
66
|
+
[
|
|
67
|
+
{
|
|
68
|
+
connectionName: "duckdb",
|
|
69
|
+
nodes: [[{ sourceID: "orders@m", dependsOn: [] }]],
|
|
70
|
+
},
|
|
71
|
+
] as unknown as Parameters<typeof deriveBuildPlan>[0],
|
|
72
|
+
{ "orders@m": orders },
|
|
73
|
+
{ duckdb: "dig" },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(plan.graphs[0].connectionName).toBe("duckdb");
|
|
77
|
+
expect(plan.sources["orders@m"]).toMatchObject({
|
|
78
|
+
name: "orders",
|
|
79
|
+
connectionName: "duckdb",
|
|
80
|
+
buildId: "bid-orders",
|
|
81
|
+
sql: "SELECT 1",
|
|
82
|
+
columns: [],
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("honors the sourceNames filter", () => {
|
|
87
|
+
const a = fakeSource({ name: "a", buildId: "bid-a" });
|
|
88
|
+
const b = fakeSource({ name: "b", buildId: "bid-b" });
|
|
89
|
+
const plan = deriveBuildPlan(
|
|
90
|
+
[
|
|
91
|
+
{
|
|
92
|
+
connectionName: "duckdb",
|
|
93
|
+
nodes: [[{ sourceID: "a@m", dependsOn: [] }]],
|
|
94
|
+
},
|
|
95
|
+
] as unknown as Parameters<typeof deriveBuildPlan>[0],
|
|
96
|
+
{ "a@m": a, "b@m": b },
|
|
97
|
+
{ duckdb: "dig" },
|
|
98
|
+
["a"],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(Object.keys(plan.sources)).toEqual(["a@m"]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("computePackageBuildPlan", () => {
|
|
106
|
+
it("returns null when the package declares no persist sources", async () => {
|
|
107
|
+
const pkg = {
|
|
108
|
+
getModelPaths: () => [],
|
|
109
|
+
getPackagePath: () => "/test",
|
|
110
|
+
getMalloyConfig: () => ({}),
|
|
111
|
+
getMalloyConnection: async () => ({}),
|
|
112
|
+
} as unknown as Parameters<typeof computePackageBuildPlan>[0];
|
|
113
|
+
|
|
114
|
+
expect(await computePackageBuildPlan(pkg)).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|