@malloy-publisher/server 0.0.233 → 0.0.235
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 +208 -0
- package/dist/app/assets/{EnvironmentPage-DutP7T8h.js → EnvironmentPage-BsAnavYN.js} +1 -1
- package/dist/app/assets/{HomePage-BcxDrBfl.js → HomePage-CADE138j.js} +1 -1
- package/dist/app/assets/{LightMode-BJukGxgz.js → LightMode-Cfh7KzN8.js} +1 -1
- package/dist/app/assets/{MainPage-DXbwlMeF.js → MainPage-CO3pRlnV.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-BBQksmTU.js → MaterializationsPage-p9YjkRXZ.js} +1 -1
- package/dist/app/assets/{ModelPage-C6tK51uU.js → ModelPage-C1OSTv-x.js} +1 -1
- package/dist/app/assets/{PackagePage-Bo3cwwZE.js → PackagePage-e4kN75YR.js} +1 -1
- package/dist/app/assets/{RouteError-BufkcAKE.js → RouteError-CzbfOkng.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DICvvKpa.js → ThemeEditorPage-CciagFTq.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Dkwt75Nj.js → WorkbookPage-DNWmkCXa.js} +1 -1
- package/dist/app/assets/{core-C0nunIQT.es-DlMLKZBK.js → core-Rj_4rRnA.es-BZyvITuO.js} +1 -1
- package/dist/app/assets/{index-CmEVVe-8.js → index-CH2AcDzc.js} +4 -4
- package/dist/app/assets/{index-qnhU9CGo.js → index-DQa463gC.js} +2 -2
- package/dist/app/assets/{index-Cs4WVm2z.js → index-DQpV7MyA.js} +1 -1
- package/dist/app/assets/{index-BabP-V-S.js → index-DzaYbhnD.js} +1 -1
- package/dist/app/assets/{index-BusxL5Pt.js → index-VBbcc8s6.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +53 -3
- package/dist/server.mjs +835 -97
- package/package.json +12 -12
- 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 -15
- package/src/mcp/handler_utils.ts +10 -2
- package/src/mcp/query_envelope.ts +10 -0
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/execute_query_tool.spec.ts +131 -0
- package/src/mcp/tools/execute_query_tool.ts +62 -25
- 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/connection_config.ts +49 -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 +145 -19
- 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
|
@@ -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;
|
|
@@ -895,12 +895,14 @@ describe("service/model", () => {
|
|
|
895
895
|
describe("runtime storage failure → freshnessFallback=live", () => {
|
|
896
896
|
const originalMode = process.env.PERSIST_STORAGE_MODE;
|
|
897
897
|
const originalDefaultRows = process.env.PUBLISHER_DEFAULT_QUERY_ROW_LIMIT;
|
|
898
|
+
const originalMetadata = process.env.PUBLISHER_QUERY_METADATA;
|
|
898
899
|
|
|
899
900
|
afterEach(() => {
|
|
900
901
|
sinon.restore();
|
|
901
902
|
for (const [name, original] of [
|
|
902
903
|
["PERSIST_STORAGE_MODE", originalMode],
|
|
903
904
|
["PUBLISHER_DEFAULT_QUERY_ROW_LIMIT", originalDefaultRows],
|
|
905
|
+
["PUBLISHER_QUERY_METADATA", originalMetadata],
|
|
904
906
|
] as const) {
|
|
905
907
|
if (original === undefined) delete process.env[name];
|
|
906
908
|
else process.env[name] = original;
|
|
@@ -936,7 +938,10 @@ describe("service/model", () => {
|
|
|
936
938
|
getPreparedResult:
|
|
937
939
|
opts.storageFailsAt === "prepare"
|
|
938
940
|
? sinon.stub().rejects(storageErr)
|
|
939
|
-
: sinon.stub().resolves({
|
|
941
|
+
: sinon.stub().resolves({
|
|
942
|
+
resultExplore: { limit: 0 },
|
|
943
|
+
connectionName: "lake",
|
|
944
|
+
}),
|
|
940
945
|
run:
|
|
941
946
|
opts.storageFailsAt === "run"
|
|
942
947
|
? sinon.stub().rejects(storageErr)
|
|
@@ -954,6 +959,7 @@ describe("service/model", () => {
|
|
|
954
959
|
const liveRunnable = {
|
|
955
960
|
getPreparedResult: sinon.stub().resolves({
|
|
956
961
|
resultExplore: { limit: opts.livePreparedLimit ?? 0 },
|
|
962
|
+
connectionName: "live_pg",
|
|
957
963
|
}),
|
|
958
964
|
run: liveRun,
|
|
959
965
|
};
|
|
@@ -1020,6 +1026,44 @@ describe("service/model", () => {
|
|
|
1020
1026
|
expect(liveRun.firstCall.args[0].rowLimit).toBe(250);
|
|
1021
1027
|
});
|
|
1022
1028
|
|
|
1029
|
+
it("tags the live retry, with the LIVE connection's layers and the same id", async () => {
|
|
1030
|
+
// Two failures in one: the statement that actually answered the query
|
|
1031
|
+
// carried no attribution at all, so the full-cost fallback — the one an
|
|
1032
|
+
// operator goes looking for on a bill — landed in the untagged bucket;
|
|
1033
|
+
// and the response still returned the id from the bag resolved before
|
|
1034
|
+
// the storage attempt, pointing a caller at the statement that FAILED.
|
|
1035
|
+
// The layers must come from the live connection: on this tier the store
|
|
1036
|
+
// is routinely a different connection, with different enforced
|
|
1037
|
+
// properties.
|
|
1038
|
+
process.env.PUBLISHER_QUERY_METADATA = "on";
|
|
1039
|
+
const { model, liveRun } = routedModel({
|
|
1040
|
+
shapeBindings: [binding("daily", "live")],
|
|
1041
|
+
storageFailsAt: "run",
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
const result = await model.getQueryResults(
|
|
1045
|
+
undefined,
|
|
1046
|
+
undefined,
|
|
1047
|
+
"run: daily -> x",
|
|
1048
|
+
undefined,
|
|
1049
|
+
undefined,
|
|
1050
|
+
undefined,
|
|
1051
|
+
undefined,
|
|
1052
|
+
{
|
|
1053
|
+
correlationId: "corr-1",
|
|
1054
|
+
connectionMetadata: (connectionName: string) =>
|
|
1055
|
+
connectionName === "live_pg"
|
|
1056
|
+
? { default: null, enforced: { tenant: "acme" } }
|
|
1057
|
+
: { default: null, enforced: { tenant: "wrong" } },
|
|
1058
|
+
},
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
const attached = liveRun.firstCall.args[0].queryMetadata;
|
|
1062
|
+
expect(attached.tenant).toBe("acme");
|
|
1063
|
+
expect(attached.query_id).toBe("corr-1");
|
|
1064
|
+
expect(result.queryCorrelationId).toBe("corr-1");
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1023
1067
|
it("does not record a live-served answer as a storage hit", async () => {
|
|
1024
1068
|
// The hit rate is the tier's headline KPI; counting a fallback as a hit
|
|
1025
1069
|
// makes it RISE while the tier is broken.
|