@malloy-publisher/server 0.0.232 → 0.0.234
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 +1 -0
- package/dist/app/api-doc.yaml +269 -10
- package/dist/app/assets/{EnvironmentPage-DXEaZIPx.js → EnvironmentPage-DTZQ4Gxc.js} +1 -1
- package/dist/app/assets/{HomePage-kofsqpZt.js → HomePage-C5mlDPXK.js} +1 -1
- package/dist/app/assets/{LightMode-CNhIlIlJ.js → LightMode-DGNmhG0u.js} +1 -1
- package/dist/app/assets/{MainPage-Bgqo8jCy.js → MainPage-CVL_wmP4.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-CgBlgGz2.js → MaterializationsPage-DmzMBCpy.js} +1 -1
- package/dist/app/assets/{ModelPage-B0TjoDtf.js → ModelPage-Dbvf4QbB.js} +1 -1
- package/dist/app/assets/{PackagePage-BL8vnFj1.js → PackagePage-DxdHc2Qs.js} +1 -1
- package/dist/app/assets/{RouteError-BzPby0X2.js → RouteError-OJdT4tCd.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTEP_9r3.js → ThemeEditorPage-Bk7s0KXY.js} +1 -1
- package/dist/app/assets/{WorkbookPage-BwM3BmKw.js → WorkbookPage-j_vCWdN3.js} +1 -1
- package/dist/app/assets/{core-CK68iv6w.es-CpRxXBt7.js → core-Rj_4rRnA.es-DoIfLxDJ.js} +1 -1
- package/dist/app/assets/{index-B33zGctF.js → index-B_jKMR35.js} +4 -4
- package/dist/app/assets/{index-CmkW1MiE.js → index-D-rDyK11.js} +1 -1
- package/dist/app/assets/{index-tXJXwdyj.js → index-DWIe_hK0.js} +1 -1
- package/dist/app/assets/{index-BkiWKaAF.js → index-hw-xn0X7.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +53 -3
- package/dist/server.mjs +20277 -925
- package/package.json +1 -1
- package/src/config.ts +35 -1
- package/src/controller/connection.controller.spec.ts +46 -0
- package/src/controller/connection.controller.ts +105 -2
- package/src/controller/materialization.controller.spec.ts +25 -0
- package/src/controller/materialization.controller.ts +60 -0
- package/src/controller/model.controller.ts +24 -0
- package/src/controller/query.controller.ts +83 -10
- package/src/json_utils.spec.ts +51 -0
- package/src/json_utils.ts +33 -0
- package/src/mcp/handler_utils.ts +10 -2
- package/src/mcp/query_envelope.spec.ts +229 -0
- package/src/mcp/query_envelope.ts +240 -0
- package/src/mcp/server.protocol.spec.ts +128 -16
- package/src/mcp/skills/build_skills_bundle.ts +94 -4
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +113 -4
- package/src/mcp/tool_response.spec.ts +108 -0
- package/src/mcp/tool_response.ts +138 -0
- package/src/mcp/tools/compile_tool.spec.ts +112 -4
- package/src/mcp/tools/compile_tool.ts +61 -30
- package/src/mcp/tools/docs_search_tool.ts +6 -16
- package/src/mcp/tools/execute_query_tool.spec.ts +154 -3
- package/src/mcp/tools/execute_query_tool.ts +131 -155
- package/src/mcp/tools/get_context_tool.spec.ts +63 -3
- package/src/mcp/tools/get_context_tool.ts +43 -46
- package/src/mcp/tools/reload_package_tool.ts +3 -29
- package/src/mcp_config.spec.ts +919 -0
- package/src/mcp_config.ts +425 -0
- package/src/oom_guards.integration.spec.ts +11 -3
- package/src/package_load/package_load_pool.ts +2 -0
- package/src/package_load/package_load_worker.ts +17 -5
- package/src/package_load/protocol.ts +6 -0
- package/src/query_metadata_metrics.ts +49 -0
- package/src/server.ts +99 -3
- package/src/service/build_plan.spec.ts +125 -0
- package/src/service/build_plan.ts +108 -7
- package/src/service/compile_fragment_techniques.spec.ts +156 -0
- package/src/service/connection.spec.ts +371 -1
- package/src/service/connection.ts +77 -14
- package/src/service/connection_config.spec.ts +60 -0
- package/src/service/connection_config.ts +75 -0
- package/src/service/duckdb_instance_isolation.spec.ts +137 -0
- package/src/service/environment.ts +57 -3
- package/src/service/materialization_config_validation.spec.ts +99 -0
- package/src/service/materialization_config_validation.ts +120 -0
- package/src/service/materialization_schedule_surface.spec.ts +124 -0
- package/src/service/materialization_service.spec.ts +119 -0
- package/src/service/materialization_service.ts +186 -3
- package/src/service/materialization_test_fixtures.ts +86 -21
- package/src/service/model.spec.ts +45 -1
- package/src/service/model.ts +171 -23
- package/src/service/model_limits.spec.ts +28 -0
- package/src/service/model_limits.ts +21 -0
- package/src/service/package.ts +24 -1
- package/src/service/package_manifest.spec.ts +137 -4
- package/src/service/package_manifest.ts +140 -5
- package/src/service/persist_annotation_validation.spec.ts +12 -0
- package/src/service/persist_annotation_validation.ts +9 -4
- package/src/service/query_metadata.spec.ts +408 -0
- package/src/service/query_metadata.ts +492 -0
- package/src/service/query_metadata_identity.spec.ts +149 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
|
@@ -39,6 +39,23 @@ import {
|
|
|
39
39
|
|
|
40
40
|
type MockRepo = sinon.SinonStubbedInstance<ResourceRepository>;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Turn per-query metadata on for one test and put the environment back after.
|
|
44
|
+
* The feature ships dark, so a test that asserts what a statement carries has
|
|
45
|
+
* to enable it — and saying so per test keeps the default honest here: a test
|
|
46
|
+
* that forgets is testing the shipped default, which is "nothing attached".
|
|
47
|
+
*/
|
|
48
|
+
function withQueryMetadataOn(): void {
|
|
49
|
+
const original = process.env.PUBLISHER_QUERY_METADATA;
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
process.env.PUBLISHER_QUERY_METADATA = "on";
|
|
52
|
+
});
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
if (original === undefined) delete process.env.PUBLISHER_QUERY_METADATA;
|
|
55
|
+
else process.env.PUBLISHER_QUERY_METADATA = original;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
describe("redactConnectionSecrets", () => {
|
|
43
60
|
it("strips credential values but keeps the message legible", () => {
|
|
44
61
|
const source = {
|
|
@@ -877,6 +894,7 @@ describe("MaterializationService", () => {
|
|
|
877
894
|
describe("deleteMaterialization telemetry", () => {
|
|
878
895
|
let ctx: ReturnType<typeof createMocks>;
|
|
879
896
|
let harness: MetricsHarness;
|
|
897
|
+
withQueryMetadataOn();
|
|
880
898
|
|
|
881
899
|
beforeEach(async () => {
|
|
882
900
|
ctx = createMocks();
|
|
@@ -936,6 +954,27 @@ describe("deleteMaterialization telemetry", () => {
|
|
|
936
954
|
).toBe(0);
|
|
937
955
|
});
|
|
938
956
|
|
|
957
|
+
it("tags the retiring drops as ops work", async () => {
|
|
958
|
+
// A drop is warehouse work someone accounts for later, and it is the one
|
|
959
|
+
// statement an operator goes looking for after a table disappears.
|
|
960
|
+
const runSQL = sinon.stub().resolves();
|
|
961
|
+
dropTablesFixture(runSQL);
|
|
962
|
+
|
|
963
|
+
await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
|
|
964
|
+
dropTables: true,
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
expect(runSQL.callCount).toBeGreaterThan(0);
|
|
968
|
+
for (const call of runSQL.getCalls()) {
|
|
969
|
+
expect(call.args[1]?.queryMetadata).toMatchObject({
|
|
970
|
+
class: "ops",
|
|
971
|
+
environment: "my-env",
|
|
972
|
+
package: "pkg",
|
|
973
|
+
run_id: "mat-1",
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
|
|
939
978
|
it("meters a failed drop as outcome=failure and still deletes the record", async () => {
|
|
940
979
|
dropTablesFixture(sinon.stub().rejects(new Error("drop boom")));
|
|
941
980
|
|
|
@@ -1472,6 +1511,7 @@ describe("executeInstructedBuild", () => {
|
|
|
1472
1511
|
|
|
1473
1512
|
describe("buildOneSource", () => {
|
|
1474
1513
|
let ctx: ReturnType<typeof createMocks>;
|
|
1514
|
+
withQueryMetadataOn();
|
|
1475
1515
|
beforeEach(() => {
|
|
1476
1516
|
ctx = createMocks();
|
|
1477
1517
|
});
|
|
@@ -1481,12 +1521,19 @@ describe("buildOneSource", () => {
|
|
|
1481
1521
|
physicalTableName: string,
|
|
1482
1522
|
dialectName?: string,
|
|
1483
1523
|
manifest: Manifest = new Manifest(),
|
|
1524
|
+
metadata?: {
|
|
1525
|
+
source?: Record<string, string>;
|
|
1526
|
+
package?: Record<string, string>;
|
|
1527
|
+
connection?: Record<string, string>;
|
|
1528
|
+
context?: Record<string, string | undefined>;
|
|
1529
|
+
},
|
|
1484
1530
|
): Promise<{ sourceEntityId: string; physicalTableName: string }> {
|
|
1485
1531
|
const source = fakeSource({
|
|
1486
1532
|
name: "orders",
|
|
1487
1533
|
sourceEntityId: "abcdef1234567890",
|
|
1488
1534
|
sql: "SELECT * FROM t",
|
|
1489
1535
|
dialectName,
|
|
1536
|
+
queryMetadata: metadata?.source,
|
|
1490
1537
|
});
|
|
1491
1538
|
const instruction: BuildInstruction = {
|
|
1492
1539
|
sourceEntityId: "abcdef1234567890",
|
|
@@ -1504,6 +1551,7 @@ describe("buildOneSource", () => {
|
|
|
1504
1551
|
m: Manifest,
|
|
1505
1552
|
env: unknown,
|
|
1506
1553
|
built: Record<string, unknown>,
|
|
1554
|
+
buildMetadata?: unknown,
|
|
1507
1555
|
) => Promise<{ sourceEntityId: string; physicalTableName: string }>;
|
|
1508
1556
|
}
|
|
1509
1557
|
).buildOneSource(
|
|
@@ -1514,14 +1562,85 @@ describe("buildOneSource", () => {
|
|
|
1514
1562
|
manifest,
|
|
1515
1563
|
{
|
|
1516
1564
|
getApiConnection: () => {
|
|
1565
|
+
if (metadata?.connection) {
|
|
1566
|
+
return { queryMetadata: metadata.connection };
|
|
1567
|
+
}
|
|
1517
1568
|
throw new Error("no connection config in this colocated test");
|
|
1518
1569
|
},
|
|
1519
1570
|
getEnvironmentPath: () => "/tmp/env",
|
|
1520
1571
|
},
|
|
1521
1572
|
{}, // builtEntries — colocated build with no prior storage upstreams
|
|
1573
|
+
metadata
|
|
1574
|
+
? {
|
|
1575
|
+
packageMaterialization: metadata.package
|
|
1576
|
+
? {
|
|
1577
|
+
schedule: null,
|
|
1578
|
+
freshness: null,
|
|
1579
|
+
queryMetadata: metadata.package,
|
|
1580
|
+
}
|
|
1581
|
+
: null,
|
|
1582
|
+
context: metadata.context ?? { queryClass: "materialize" },
|
|
1583
|
+
}
|
|
1584
|
+
: undefined,
|
|
1522
1585
|
);
|
|
1523
1586
|
}
|
|
1524
1587
|
|
|
1588
|
+
it("tags every statement of the build with the resolved metadata", async () => {
|
|
1589
|
+
// One unit of work: the warehouse's query history has to show the staging
|
|
1590
|
+
// drop, the CTAS, the swap and the rename as the same build.
|
|
1591
|
+
const runSQL = sinon.stub().resolves();
|
|
1592
|
+
await callBuildOneSource({ runSQL }, "orders_v1", undefined, undefined, {
|
|
1593
|
+
connection: { deployment: "eu" },
|
|
1594
|
+
package: { team: "finance", tier: "bronze" },
|
|
1595
|
+
source: { tier: "gold" },
|
|
1596
|
+
context: { queryClass: "materialize", runId: "run-7" },
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
const bags = runSQL.getCalls().map((c) => c.args[1]?.queryMetadata);
|
|
1600
|
+
expect(bags).toHaveLength(4);
|
|
1601
|
+
for (const bag of bags) {
|
|
1602
|
+
expect(bag).toEqual({
|
|
1603
|
+
deployment: "eu",
|
|
1604
|
+
team: "finance",
|
|
1605
|
+
// The source's own declaration wins over the package's.
|
|
1606
|
+
tier: "gold",
|
|
1607
|
+
class: "materialize",
|
|
1608
|
+
run_id: "run-7",
|
|
1609
|
+
source: "orders",
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
it("issues untagged statements when nothing declares metadata", async () => {
|
|
1615
|
+
// No metadata context (an internal caller) means the statements are exactly
|
|
1616
|
+
// what they were before this existed.
|
|
1617
|
+
const runSQL = sinon.stub().resolves();
|
|
1618
|
+
await callBuildOneSource({ runSQL }, "orders_v1");
|
|
1619
|
+
for (const call of runSQL.getCalls()) {
|
|
1620
|
+
expect(call.args[1]?.queryMetadata).toBeUndefined();
|
|
1621
|
+
}
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
it("tags the cleanup drop after a failed build", async () => {
|
|
1625
|
+
// The cleanup statement belongs to the same build, and it is the one an
|
|
1626
|
+
// operator goes looking for after a failure.
|
|
1627
|
+
const runSQL = sinon.stub();
|
|
1628
|
+
runSQL.onCall(0).resolves();
|
|
1629
|
+
runSQL.onCall(1).rejects(new Error("create boom"));
|
|
1630
|
+
runSQL.onCall(2).resolves();
|
|
1631
|
+
await expect(
|
|
1632
|
+
callBuildOneSource({ runSQL }, "orders_v1", undefined, undefined, {
|
|
1633
|
+
source: { team: "finance" },
|
|
1634
|
+
context: { queryClass: "materialize" },
|
|
1635
|
+
}),
|
|
1636
|
+
).rejects.toThrow("create boom");
|
|
1637
|
+
expect(runSQL.lastCall.args[1]?.queryMetadata).toEqual({
|
|
1638
|
+
team: "finance",
|
|
1639
|
+
class: "materialize",
|
|
1640
|
+
source: "orders",
|
|
1641
|
+
});
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1525
1644
|
it("stages, swaps, and renames in a crash-safe order", async () => {
|
|
1526
1645
|
const runSQL = sinon.stub().resolves();
|
|
1527
1646
|
const entry = await callBuildOneSource({ runSQL }, "orders_v1");
|
|
@@ -43,7 +43,14 @@ import {
|
|
|
43
43
|
deriveAnnotationFields,
|
|
44
44
|
projectToPublicColumns,
|
|
45
45
|
iterGraphSources,
|
|
46
|
+
resolveQueryMetadata,
|
|
46
47
|
} from "./build_plan";
|
|
48
|
+
import {
|
|
49
|
+
mergeQueryMetadata,
|
|
50
|
+
type QueryContext,
|
|
51
|
+
type QueryMetadata,
|
|
52
|
+
} from "./query_metadata";
|
|
53
|
+
import type { components } from "../api";
|
|
47
54
|
import { getPersistStorageMode } from "../config";
|
|
48
55
|
import { EnvironmentStore } from "./environment_store";
|
|
49
56
|
import { assertMaterializationEligible } from "./materialization_eligibility";
|
|
@@ -88,6 +95,47 @@ interface BuildEnvironment {
|
|
|
88
95
|
getEnvironmentPath(): string;
|
|
89
96
|
}
|
|
90
97
|
|
|
98
|
+
/**
|
|
99
|
+
* What a build needs to tag the statements it issues: the package-level layer of
|
|
100
|
+
* per-query metadata, and the run's own context. Assembled once per run — every
|
|
101
|
+
* field is constant for the run, so a source only adds its own name.
|
|
102
|
+
*/
|
|
103
|
+
interface BuildQueryMetadata {
|
|
104
|
+
packageMaterialization:
|
|
105
|
+
| components["schemas"]["PackageMaterializationConfig"]
|
|
106
|
+
| null;
|
|
107
|
+
context: QueryContext;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A connection's two metadata layers: the overridable default and the properties
|
|
112
|
+
* the deployment enforces.
|
|
113
|
+
*
|
|
114
|
+
* Fails open — a build must not fail because a connection's config could not be
|
|
115
|
+
* read for its tags — so an unreadable connection costs the layers, never the
|
|
116
|
+
* statement.
|
|
117
|
+
*/
|
|
118
|
+
function connectionMetadataLayers(
|
|
119
|
+
environment: BuildEnvironment,
|
|
120
|
+
connectionName: string,
|
|
121
|
+
): { default: QueryMetadata | null; enforced: QueryMetadata | null } {
|
|
122
|
+
try {
|
|
123
|
+
const connection = environment.getApiConnection(connectionName);
|
|
124
|
+
return {
|
|
125
|
+
default: connection?.queryMetadata ?? null,
|
|
126
|
+
enforced: connection?.queryMetadataEnforced ?? null,
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// Diagnosable rather than silent: the layer this costs is the enforced
|
|
130
|
+
// one, and it is the only drop with no metric behind it.
|
|
131
|
+
logger.debug("No query-metadata layers for connection", {
|
|
132
|
+
connectionName,
|
|
133
|
+
error,
|
|
134
|
+
});
|
|
135
|
+
return { default: null, enforced: null };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
91
139
|
/**
|
|
92
140
|
* Length of the sourceEntityId prefix used when synthesizing staging table
|
|
93
141
|
* names. 12 hex chars is 48 bits of entropy, well inside every dialect's
|
|
@@ -413,6 +461,14 @@ export class MaterializationService {
|
|
|
413
461
|
* metadata so a scheduled rebuild is distinguishable from a manual one.
|
|
414
462
|
*/
|
|
415
463
|
trigger?: "ON_DEMAND" | "SCHEDULER";
|
|
464
|
+
/**
|
|
465
|
+
* What the caller knows about this run and the publisher does not,
|
|
466
|
+
* attached as query metadata to the statements the build issues. Its
|
|
467
|
+
* `trigger` also covers the case the publisher's own `trigger` cannot
|
|
468
|
+
* express (a publish), and its `runId` lets a caller's own id group the
|
|
469
|
+
* build's statements instead of the publisher's materialization id.
|
|
470
|
+
*/
|
|
471
|
+
runContext?: components["schemas"]["RunContext"] | null;
|
|
416
472
|
} = {},
|
|
417
473
|
): Promise<Materialization> {
|
|
418
474
|
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
@@ -477,6 +533,7 @@ export class MaterializationService {
|
|
|
477
533
|
referenceManifest: options.referenceManifest,
|
|
478
534
|
strictUpstreams: options.strictUpstreams,
|
|
479
535
|
trigger,
|
|
536
|
+
runContext: options.runContext ?? undefined,
|
|
480
537
|
},
|
|
481
538
|
signal,
|
|
482
539
|
),
|
|
@@ -504,6 +561,7 @@ export class MaterializationService {
|
|
|
504
561
|
referenceManifest: ManifestReference[] | undefined;
|
|
505
562
|
strictUpstreams: boolean | undefined;
|
|
506
563
|
trigger: "ON_DEMAND" | "SCHEDULER";
|
|
564
|
+
runContext?: components["schemas"]["RunContext"];
|
|
507
565
|
},
|
|
508
566
|
signal: AbortSignal,
|
|
509
567
|
): Promise<void> {
|
|
@@ -620,6 +678,25 @@ export class MaterializationService {
|
|
|
620
678
|
// Failure-path reclaim is ORCHESTRATED-ONLY on purpose — see
|
|
621
679
|
// reclaimStorageTablesFromFailedRun.
|
|
622
680
|
orchestrated ? { environmentId, packageName } : undefined,
|
|
681
|
+
{
|
|
682
|
+
// Optional for the same reason build_plan reads it optionally:
|
|
683
|
+
// callers that build from a lighter package surface still resolve,
|
|
684
|
+
// just without a package-level layer.
|
|
685
|
+
packageMaterialization: pkg.getMaterializationConfig?.() ?? null,
|
|
686
|
+
context: {
|
|
687
|
+
queryClass: "materialize",
|
|
688
|
+
environment: environmentName,
|
|
689
|
+
package: packageName,
|
|
690
|
+
// The caller's trigger wins because it can express a publish,
|
|
691
|
+
// which the publisher's own two-value trigger cannot.
|
|
692
|
+
trigger:
|
|
693
|
+
opts.runContext?.trigger ?? opts.trigger?.toLowerCase(),
|
|
694
|
+
// Default to the materialization id: the publisher always has a
|
|
695
|
+
// run id, so a build's statements group in the backend's query
|
|
696
|
+
// history whether or not the caller supplied one.
|
|
697
|
+
runId: opts.runContext?.runId ?? id,
|
|
698
|
+
},
|
|
699
|
+
},
|
|
623
700
|
);
|
|
624
701
|
|
|
625
702
|
const sourcesBuilt = instructions.length;
|
|
@@ -1083,6 +1160,7 @@ export class MaterializationService {
|
|
|
1083
1160
|
// Identity of the run, used only to reclaim storage tables this run created
|
|
1084
1161
|
// if it fails part-way (see reclaimStorageTablesFromFailedRun).
|
|
1085
1162
|
owner?: { environmentId: string; packageName: string },
|
|
1163
|
+
buildMetadata?: BuildQueryMetadata,
|
|
1086
1164
|
): Promise<Record<string, ManifestEntry>> {
|
|
1087
1165
|
const { graphs, sources, connectionDigests, connections } = compiled;
|
|
1088
1166
|
|
|
@@ -1200,6 +1278,7 @@ export class MaterializationService {
|
|
|
1200
1278
|
manifest,
|
|
1201
1279
|
environment,
|
|
1202
1280
|
entries,
|
|
1281
|
+
buildMetadata,
|
|
1203
1282
|
);
|
|
1204
1283
|
entries[sourceEntityId] = entry;
|
|
1205
1284
|
if (entry.storageConnectionName) builtThisRun.push(entry);
|
|
@@ -1327,6 +1406,76 @@ export class MaterializationService {
|
|
|
1327
1406
|
}
|
|
1328
1407
|
}
|
|
1329
1408
|
|
|
1409
|
+
/**
|
|
1410
|
+
* The `RunSQLOptions` for one source's build statements: its resolved
|
|
1411
|
+
* per-query metadata, merged under this run's context.
|
|
1412
|
+
*
|
|
1413
|
+
* Layers, least specific first: the executing connection's default, then what
|
|
1414
|
+
* the model side declared for this source (package → model-file → `#@ persist`,
|
|
1415
|
+
* already resolved by {@link resolveQueryMetadata}), then the run's context,
|
|
1416
|
+
* which names the source. There is no request layer — a build has no request.
|
|
1417
|
+
*
|
|
1418
|
+
* Fails OPEN: a build must not fail because metadata could not be assembled,
|
|
1419
|
+
* so an unresolvable connection just contributes no default, and a dropped
|
|
1420
|
+
* property is logged and metered rather than thrown.
|
|
1421
|
+
*/
|
|
1422
|
+
private buildRunSQLOptions(
|
|
1423
|
+
persistSource: PersistSource,
|
|
1424
|
+
environment: BuildEnvironment,
|
|
1425
|
+
buildMetadata: BuildQueryMetadata | undefined,
|
|
1426
|
+
): { queryMetadata?: QueryMetadata } {
|
|
1427
|
+
if (!buildMetadata) return {};
|
|
1428
|
+
const connectionLayers = connectionMetadataLayers(
|
|
1429
|
+
environment,
|
|
1430
|
+
persistSource.connectionName,
|
|
1431
|
+
);
|
|
1432
|
+
const resolved = mergeQueryMetadata({
|
|
1433
|
+
connection: connectionLayers.default,
|
|
1434
|
+
enforced: connectionLayers.enforced,
|
|
1435
|
+
model: resolveQueryMetadata(
|
|
1436
|
+
persistSource,
|
|
1437
|
+
buildMetadata.packageMaterialization,
|
|
1438
|
+
),
|
|
1439
|
+
context: { ...buildMetadata.context, source: persistSource.name },
|
|
1440
|
+
});
|
|
1441
|
+
if (resolved.drops.length > 0) {
|
|
1442
|
+
logger.warn("Dropped query-metadata properties for a build", {
|
|
1443
|
+
sourceName: persistSource.name,
|
|
1444
|
+
drops: resolved.drops,
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
return resolved.metadata ? { queryMetadata: resolved.metadata } : {};
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* The `RunSQLOptions` for the drops that retire a materialization's tables.
|
|
1452
|
+
* No model layer: the source's declaration described how to BUILD it, and the
|
|
1453
|
+
* source may no longer exist by the time its table is retired.
|
|
1454
|
+
*/
|
|
1455
|
+
private dropRunSQLOptions(
|
|
1456
|
+
environment: BuildEnvironment,
|
|
1457
|
+
connectionName: string,
|
|
1458
|
+
environmentName: string,
|
|
1459
|
+
packageName: string,
|
|
1460
|
+
materializationId: string,
|
|
1461
|
+
): { queryMetadata?: QueryMetadata } {
|
|
1462
|
+
const connectionLayers = connectionMetadataLayers(
|
|
1463
|
+
environment,
|
|
1464
|
+
connectionName,
|
|
1465
|
+
);
|
|
1466
|
+
const resolved = mergeQueryMetadata({
|
|
1467
|
+
connection: connectionLayers.default,
|
|
1468
|
+
enforced: connectionLayers.enforced,
|
|
1469
|
+
context: {
|
|
1470
|
+
queryClass: "ops",
|
|
1471
|
+
environment: environmentName,
|
|
1472
|
+
package: packageName,
|
|
1473
|
+
runId: materializationId,
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
return resolved.metadata ? { queryMetadata: resolved.metadata } : {};
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1330
1479
|
/**
|
|
1331
1480
|
* Build a single instructed source into its assigned physical table.
|
|
1332
1481
|
* COPY uses a staging table + atomic rename for crash-safety; the staging
|
|
@@ -1340,6 +1489,7 @@ export class MaterializationService {
|
|
|
1340
1489
|
manifest: Manifest,
|
|
1341
1490
|
environment: BuildEnvironment,
|
|
1342
1491
|
builtEntries: Record<string, ManifestEntry>,
|
|
1492
|
+
buildMetadata?: BuildQueryMetadata,
|
|
1343
1493
|
): Promise<ManifestEntry> {
|
|
1344
1494
|
const sourceEntityId = instruction.sourceEntityId;
|
|
1345
1495
|
const physicalTableName = instruction.physicalTableName;
|
|
@@ -1404,6 +1554,15 @@ export class MaterializationService {
|
|
|
1404
1554
|
);
|
|
1405
1555
|
}
|
|
1406
1556
|
|
|
1557
|
+
// Every statement of this source's build carries the same metadata, so the
|
|
1558
|
+
// warehouse's query history shows the staging CTAS, the drop and the rename
|
|
1559
|
+
// as one attributable unit of work.
|
|
1560
|
+
const runOptions = this.buildRunSQLOptions(
|
|
1561
|
+
persistSource,
|
|
1562
|
+
environment,
|
|
1563
|
+
buildMetadata,
|
|
1564
|
+
);
|
|
1565
|
+
|
|
1407
1566
|
const bareName = bareTableName(physicalTableName);
|
|
1408
1567
|
const stagingTableName = `${physicalTableName}${stagingSuffix(sourceEntityId)}`;
|
|
1409
1568
|
// The control plane sends the logical (unquoted) physical name; dialect-
|
|
@@ -1416,18 +1575,29 @@ export class MaterializationService {
|
|
|
1416
1575
|
const quotedBareName = quoteIdentifier(bareName, dialect);
|
|
1417
1576
|
|
|
1418
1577
|
const startTime = performance.now();
|
|
1419
|
-
await connection.runSQL(
|
|
1578
|
+
await connection.runSQL(
|
|
1579
|
+
`DROP TABLE IF EXISTS ${quotedStaging}`,
|
|
1580
|
+
runOptions,
|
|
1581
|
+
);
|
|
1420
1582
|
try {
|
|
1421
1583
|
await connection.runSQL(
|
|
1422
1584
|
`CREATE TABLE ${quotedStaging} AS (${buildSQL})`,
|
|
1585
|
+
runOptions,
|
|
1586
|
+
);
|
|
1587
|
+
await connection.runSQL(
|
|
1588
|
+
`DROP TABLE IF EXISTS ${quotedPhysical}`,
|
|
1589
|
+
runOptions,
|
|
1423
1590
|
);
|
|
1424
|
-
await connection.runSQL(`DROP TABLE IF EXISTS ${quotedPhysical}`);
|
|
1425
1591
|
await connection.runSQL(
|
|
1426
1592
|
`ALTER TABLE ${quotedStaging} RENAME TO ${quotedBareName}`,
|
|
1593
|
+
runOptions,
|
|
1427
1594
|
);
|
|
1428
1595
|
} catch (err) {
|
|
1429
1596
|
try {
|
|
1430
|
-
await connection.runSQL(
|
|
1597
|
+
await connection.runSQL(
|
|
1598
|
+
`DROP TABLE IF EXISTS ${quotedStaging}`,
|
|
1599
|
+
runOptions,
|
|
1600
|
+
);
|
|
1431
1601
|
} catch (cleanupErr) {
|
|
1432
1602
|
logger.warn(
|
|
1433
1603
|
"Failed to clean up staging table after a failed build; physical leak",
|
|
@@ -2045,11 +2215,23 @@ export class MaterializationService {
|
|
|
2045
2215
|
// successfully also drops successfully (container paths, hyphenated
|
|
2046
2216
|
// BigQuery project ids, etc.).
|
|
2047
2217
|
const dialect = connection.dialectName;
|
|
2218
|
+
// Dropping a materialized table is warehouse work someone will have
|
|
2219
|
+
// to account for later, so it is tagged like the build that created
|
|
2220
|
+
// it — `ops` rather than `materialize`, because this is the
|
|
2221
|
+
// lifecycle operation, not a build.
|
|
2222
|
+
const dropOptions = this.dropRunSQLOptions(
|
|
2223
|
+
environment,
|
|
2224
|
+
connectionName,
|
|
2225
|
+
environmentName,
|
|
2226
|
+
packageName,
|
|
2227
|
+
m.id,
|
|
2228
|
+
);
|
|
2048
2229
|
await connection.runSQL(
|
|
2049
2230
|
`DROP TABLE IF EXISTS ${quoteTablePath(
|
|
2050
2231
|
physicalTableName,
|
|
2051
2232
|
dialect,
|
|
2052
2233
|
)}`,
|
|
2234
|
+
dropOptions,
|
|
2053
2235
|
);
|
|
2054
2236
|
// A crash between staging-create and rename can leave the staging
|
|
2055
2237
|
// table behind; clean it up too while we hold the connection.
|
|
@@ -2058,6 +2240,7 @@ export class MaterializationService {
|
|
|
2058
2240
|
`${physicalTableName}${stagingSuffix(entry.sourceEntityId)}`,
|
|
2059
2241
|
dialect,
|
|
2060
2242
|
)}`,
|
|
2243
|
+
dropOptions,
|
|
2061
2244
|
);
|
|
2062
2245
|
recordDropTables("success", "in_warehouse");
|
|
2063
2246
|
logger.info("Dropped materialized table on delete", {
|
|
@@ -80,34 +80,78 @@ interface FakeFreshnessSchedule {
|
|
|
80
80
|
freshness?: { window?: string; fallback?: string };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** A nested tag tree: scalar leaves and property collections. */
|
|
84
|
+
type FakeTagTree = { [key: string]: string | FakeTagTree };
|
|
85
|
+
|
|
83
86
|
/**
|
|
84
|
-
* Build a fake Malloy `Tag` supporting
|
|
85
|
-
* `entries()` (scalar `#@ persist` key=value pairs, for
|
|
86
|
-
*
|
|
87
|
-
* resolveFreshness)
|
|
87
|
+
* Build a fake Malloy `Tag` over a nested tree, supporting all three readers the
|
|
88
|
+
* build plan uses: `entries()` (scalar `#@ persist` key=value pairs, for
|
|
89
|
+
* deriveAnnotationFields), the path-based `text(...at)` (dotted
|
|
90
|
+
* `freshness.window`, for resolveFreshness) and `tag(...at)` (a property
|
|
91
|
+
* collection — `queryMetadata { … }`, or the `materialization` envelope).
|
|
88
92
|
*/
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
) {
|
|
93
|
+
function fakeTagFromTree(tree: FakeTagTree) {
|
|
94
|
+
const walk = (at: string[]): string | FakeTagTree | undefined => {
|
|
95
|
+
let node: string | FakeTagTree | undefined = tree;
|
|
96
|
+
for (const segment of at) {
|
|
97
|
+
if (node === undefined || typeof node !== "object") return undefined;
|
|
98
|
+
node = node[segment];
|
|
99
|
+
}
|
|
100
|
+
return node;
|
|
101
|
+
};
|
|
93
102
|
return {
|
|
94
|
-
*entries() {
|
|
95
|
-
for (const [key, value] of Object.entries(
|
|
96
|
-
yield [
|
|
103
|
+
*entries(): Generator<[string, { text(): string | undefined }]> {
|
|
104
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
105
|
+
yield [
|
|
106
|
+
key,
|
|
107
|
+
{ text: () => (typeof value === "string" ? value : undefined) },
|
|
108
|
+
];
|
|
97
109
|
}
|
|
98
110
|
},
|
|
99
111
|
text(...at: string[]): string | undefined {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
const node = walk(at);
|
|
113
|
+
return typeof node === "string" ? node : undefined;
|
|
114
|
+
},
|
|
115
|
+
tag(...at: string[]) {
|
|
116
|
+
const node = walk(at);
|
|
117
|
+
return node !== undefined && typeof node === "object"
|
|
118
|
+
? fakeTagFromTree(node)
|
|
119
|
+
: undefined;
|
|
107
120
|
},
|
|
108
121
|
};
|
|
109
122
|
}
|
|
110
123
|
|
|
124
|
+
/** Assemble one tag layer's tree from the pieces a fake source declares. */
|
|
125
|
+
function tagTree(pieces: {
|
|
126
|
+
fields?: Record<string, string>;
|
|
127
|
+
freshness?: FakeFreshnessSchedule;
|
|
128
|
+
queryMetadata?: Record<string, string>;
|
|
129
|
+
/** Contents of the `materialization` envelope (model-file layer). */
|
|
130
|
+
materialization?: {
|
|
131
|
+
freshness?: FakeFreshnessSchedule;
|
|
132
|
+
queryMetadata?: Record<string, string>;
|
|
133
|
+
};
|
|
134
|
+
}): FakeTagTree {
|
|
135
|
+
const tree: FakeTagTree = { ...(pieces.fields ?? {}) };
|
|
136
|
+
if (pieces.freshness?.freshness) {
|
|
137
|
+
tree.freshness = { ...pieces.freshness.freshness } as FakeTagTree;
|
|
138
|
+
}
|
|
139
|
+
if (pieces.queryMetadata) tree.queryMetadata = { ...pieces.queryMetadata };
|
|
140
|
+
if (pieces.materialization) {
|
|
141
|
+
const envelope: FakeTagTree = {};
|
|
142
|
+
if (pieces.materialization.freshness?.freshness) {
|
|
143
|
+
envelope.freshness = {
|
|
144
|
+
...pieces.materialization.freshness.freshness,
|
|
145
|
+
} as FakeTagTree;
|
|
146
|
+
}
|
|
147
|
+
if (pieces.materialization.queryMetadata) {
|
|
148
|
+
envelope.queryMetadata = { ...pieces.materialization.queryMetadata };
|
|
149
|
+
}
|
|
150
|
+
tree.materialization = envelope;
|
|
151
|
+
}
|
|
152
|
+
return tree;
|
|
153
|
+
}
|
|
154
|
+
|
|
111
155
|
export function fakeSource(opts: {
|
|
112
156
|
name: string;
|
|
113
157
|
sourceEntityId: string;
|
|
@@ -118,8 +162,17 @@ export function fakeSource(opts: {
|
|
|
118
162
|
annotationFields?: Record<string, string>;
|
|
119
163
|
/** Source-level (`#@`) freshness (dotted keys). */
|
|
120
164
|
freshnessSchedule?: FakeFreshnessSchedule;
|
|
121
|
-
/** Model-file-level (`##`) freshness default. */
|
|
165
|
+
/** Model-file-level (`##`) freshness default, in the deprecated bare form. */
|
|
122
166
|
modelFreshnessSchedule?: FakeFreshnessSchedule;
|
|
167
|
+
/** Model-file-level (`## materialization.*`) envelope declarations. */
|
|
168
|
+
modelMaterialization?: {
|
|
169
|
+
freshness?: FakeFreshnessSchedule;
|
|
170
|
+
queryMetadata?: Record<string, string>;
|
|
171
|
+
};
|
|
172
|
+
/** Source-level (`#@ persist queryMetadata.*`) per-query metadata. */
|
|
173
|
+
queryMetadata?: Record<string, string>;
|
|
174
|
+
/** Model-file-level bare (`## queryMetadata.*`) per-query metadata. */
|
|
175
|
+
modelQueryMetadata?: Record<string, string>;
|
|
123
176
|
/**
|
|
124
177
|
* Spy on the args Malloy's SQL generation is handed — chiefly the
|
|
125
178
|
* `buildManifest` that resolves upstream persist references — so a test can
|
|
@@ -147,12 +200,24 @@ export function fakeSource(opts: {
|
|
|
147
200
|
},
|
|
148
201
|
annotations: {
|
|
149
202
|
parseAsTag: () => ({
|
|
150
|
-
tag:
|
|
203
|
+
tag: fakeTagFromTree(
|
|
204
|
+
tagTree({
|
|
205
|
+
fields,
|
|
206
|
+
freshness: opts.freshnessSchedule,
|
|
207
|
+
queryMetadata: opts.queryMetadata,
|
|
208
|
+
}),
|
|
209
|
+
),
|
|
151
210
|
}),
|
|
152
211
|
},
|
|
153
212
|
modelAnnotations: {
|
|
154
213
|
parseAsTag: () => ({
|
|
155
|
-
tag:
|
|
214
|
+
tag: fakeTagFromTree(
|
|
215
|
+
tagTree({
|
|
216
|
+
freshness: opts.modelFreshnessSchedule,
|
|
217
|
+
queryMetadata: opts.modelQueryMetadata,
|
|
218
|
+
materialization: opts.modelMaterialization,
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
156
221
|
}),
|
|
157
222
|
},
|
|
158
223
|
} as unknown as PersistSource;
|