@malloy-publisher/server 0.0.231 → 0.0.232
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +74 -3
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
- package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
- package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +3186 -433
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/errors.ts +19 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +295 -2
- package/src/mcp/tools/get_context_tool.ts +159 -10
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/connection.ts +263 -7
- package/src/service/connection_config.spec.ts +48 -0
- package/src/service/connection_config.ts +21 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +540 -52
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
package/src/service/model.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Connection,
|
|
5
5
|
FixedConnectionMap,
|
|
6
6
|
GivenValue,
|
|
7
|
+
InMemoryURLReader,
|
|
7
8
|
isBasicArray,
|
|
8
9
|
isJoined,
|
|
9
10
|
isRepeatedRecord,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
Runtime,
|
|
20
21
|
type FieldDef,
|
|
21
22
|
type SourceDef,
|
|
23
|
+
type VirtualMap,
|
|
22
24
|
} from "@malloydata/malloy";
|
|
23
25
|
import * as Malloy from "@malloydata/malloy-interfaces";
|
|
24
26
|
import {
|
|
@@ -27,9 +29,15 @@ import {
|
|
|
27
29
|
} from "@malloydata/malloy-sql";
|
|
28
30
|
import { DataStyles } from "@malloydata/render";
|
|
29
31
|
import { publisherMeter } from "../telemetry";
|
|
32
|
+
import {
|
|
33
|
+
recordServeShapeTierDrop,
|
|
34
|
+
recordStorageServeRouting,
|
|
35
|
+
} from "../materialization_metrics";
|
|
30
36
|
import * as fs from "fs/promises";
|
|
37
|
+
import { readFileSync } from "fs";
|
|
31
38
|
import { createRequire } from "module";
|
|
32
39
|
import * as path from "path";
|
|
40
|
+
import { fileURLToPath } from "url";
|
|
33
41
|
import { components } from "../api";
|
|
34
42
|
import {
|
|
35
43
|
getDefaultQueryRowLimit,
|
|
@@ -46,7 +54,20 @@ import {
|
|
|
46
54
|
NotQueryableError,
|
|
47
55
|
PayloadTooLargeError,
|
|
48
56
|
} from "../errors";
|
|
57
|
+
import { getPersistStorageMode } from "../config";
|
|
49
58
|
import { logger } from "../logger";
|
|
59
|
+
import {
|
|
60
|
+
buildServeShapeModelForBindings,
|
|
61
|
+
buildVirtualMap,
|
|
62
|
+
extractJoins,
|
|
63
|
+
extractRefinements,
|
|
64
|
+
extractViews,
|
|
65
|
+
narrowSchemaToPublic,
|
|
66
|
+
sliceSourceRange,
|
|
67
|
+
type ServeBinding,
|
|
68
|
+
type SourceLocation,
|
|
69
|
+
} from "./materialization_serve_transform";
|
|
70
|
+
import { evaluateManifestFreshness } from "./freshness";
|
|
50
71
|
import { deserializeError } from "../package_load/package_load_pool";
|
|
51
72
|
import type {
|
|
52
73
|
SerializedModel,
|
|
@@ -164,6 +185,20 @@ export class Model {
|
|
|
164
185
|
private modelMaterializer: ModelMaterializer | undefined;
|
|
165
186
|
private modelDef: ModelDef | undefined;
|
|
166
187
|
private modelInfo: Malloy.ModelInfo | undefined;
|
|
188
|
+
/**
|
|
189
|
+
* Connection config to compile a transient serve-shape model against, when a
|
|
190
|
+
* query is routed through the `storage=` virtual-source transform. Captured
|
|
191
|
+
* at hydration (fromSerialized) so serve can build a fresh Runtime.
|
|
192
|
+
*/
|
|
193
|
+
private serveMalloyConfig?: ModelConnectionInput;
|
|
194
|
+
/**
|
|
195
|
+
* The package's `storage=` serve bindings, set by the owning Package when a
|
|
196
|
+
* build/manifest binds materialized-into-storage sources. Empty ⇒ no serve
|
|
197
|
+
* routing (the common case; the serve path is unchanged).
|
|
198
|
+
*/
|
|
199
|
+
private serveBindings: ServeBinding[] = [];
|
|
200
|
+
/** Memoized serve-shape materializer, keyed by the bound source set. */
|
|
201
|
+
private serveShapeCache?: { key: string; materializer: ModelMaterializer };
|
|
167
202
|
private sources: ApiSource[] | undefined;
|
|
168
203
|
private queries: ApiQuery[] | undefined;
|
|
169
204
|
private sourceInfos: Malloy.SourceInfo[] | undefined;
|
|
@@ -1275,7 +1310,7 @@ export class Model {
|
|
|
1275
1310
|
? hydrateNotebookCells(runtime, data.notebookCells)
|
|
1276
1311
|
: undefined;
|
|
1277
1312
|
|
|
1278
|
-
|
|
1313
|
+
const model = new Model(
|
|
1279
1314
|
packageName,
|
|
1280
1315
|
data.modelPath,
|
|
1281
1316
|
dataStyles,
|
|
@@ -1291,6 +1326,10 @@ export class Model {
|
|
|
1291
1326
|
givens,
|
|
1292
1327
|
modelInfo,
|
|
1293
1328
|
);
|
|
1329
|
+
// Capture the config so a storage= serve query can compile a transient
|
|
1330
|
+
// serve-shape model against the same connections.
|
|
1331
|
+
model.setServeMalloyConfig(malloyConfig);
|
|
1332
|
+
return model;
|
|
1294
1333
|
}
|
|
1295
1334
|
|
|
1296
1335
|
/**
|
|
@@ -1724,6 +1763,282 @@ export class Model {
|
|
|
1724
1763
|
}
|
|
1725
1764
|
}
|
|
1726
1765
|
|
|
1766
|
+
/** Capture the config used to compile a transient serve-shape model. */
|
|
1767
|
+
public setServeMalloyConfig(config: ModelConnectionInput): void {
|
|
1768
|
+
this.serveMalloyConfig = config;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* Set (or clear) this model's `storage=` serve bindings. Invalidates the
|
|
1773
|
+
* memoized serve-shape materializer so the next routed query recompiles
|
|
1774
|
+
* against the new binding set.
|
|
1775
|
+
*/
|
|
1776
|
+
public setServeBindings(bindings: ServeBinding[]): void {
|
|
1777
|
+
this.serveBindings = bindings;
|
|
1778
|
+
this.serveShapeCache = undefined;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* Compile an untrusted query against the transient serve-shape model that
|
|
1783
|
+
* rebinds this package's materialized-into-storage sources to virtual sources
|
|
1784
|
+
* on their storage connection. Returns the runnable and the `virtualMap` that
|
|
1785
|
+
* binds each virtual handle to its physical table.
|
|
1786
|
+
*
|
|
1787
|
+
* Throwing is the eligibility signal: a query that references a refinement
|
|
1788
|
+
* the serve shape does not reproduce (a measure/dim/join defined on the
|
|
1789
|
+
* source in the author's model, or a source with no binding) fails to compile
|
|
1790
|
+
* here, and {@link getQueryResults} falls back to serving it live. The
|
|
1791
|
+
* compiled materializer is memoized per binding set (the serve-variant cache).
|
|
1792
|
+
*/
|
|
1793
|
+
/**
|
|
1794
|
+
* The serve bindings that may serve from their materialized table right NOW:
|
|
1795
|
+
* fresh, un-gated, or stale-under-`stale_ok`. A stale binding whose fallback is
|
|
1796
|
+
* `live`/`fail` evaluates to `serve_live` and is dropped, so the serve shape
|
|
1797
|
+
* omits that source and any query touching it falls through to live — the SAME
|
|
1798
|
+
* freshness gate the colocated serve applies (`getFreshBuildManifest`
|
|
1799
|
+
* → `evaluateManifestFreshness`). Placement (`storage=`) is orthogonal to
|
|
1800
|
+
* freshness: flip a colocated build ↔ `storage=lake` and this behaves identically.
|
|
1801
|
+
*/
|
|
1802
|
+
private freshServeBindings(now: number): ServeBinding[] {
|
|
1803
|
+
const at = new Date(now);
|
|
1804
|
+
return this.serveBindings.filter(
|
|
1805
|
+
(b) =>
|
|
1806
|
+
evaluateManifestFreshness(
|
|
1807
|
+
{
|
|
1808
|
+
tableName: b.tablePath,
|
|
1809
|
+
dataAsOf: b.freshAsOf,
|
|
1810
|
+
freshnessWindowSeconds: b.freshnessWindowSeconds,
|
|
1811
|
+
freshnessFallback: b.freshnessFallback,
|
|
1812
|
+
},
|
|
1813
|
+
at,
|
|
1814
|
+
) === "serve_table",
|
|
1815
|
+
);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
private async loadServeShapeQuery(queryString: string): Promise<{
|
|
1819
|
+
runnable: QueryMaterializer;
|
|
1820
|
+
virtualMap: VirtualMap;
|
|
1821
|
+
/**
|
|
1822
|
+
* The fresh bindings the shape was built from. Returned so a caller making
|
|
1823
|
+
* a per-binding decision reads the set that actually produced this shape
|
|
1824
|
+
* rather than the package-wide field.
|
|
1825
|
+
*/
|
|
1826
|
+
bindings: ServeBinding[];
|
|
1827
|
+
}> {
|
|
1828
|
+
// Gate by freshness first: only bindings that should serve their table now
|
|
1829
|
+
// enter the shape. Keying the cache on the FRESH subset means it recompiles
|
|
1830
|
+
// when a binding crosses its window (drops out) — the storage analogue of
|
|
1831
|
+
// the colocated path's memoized getFreshBuildManifest.
|
|
1832
|
+
const freshBindings = this.freshServeBindings(Date.now());
|
|
1833
|
+
if (freshBindings.length === 0) {
|
|
1834
|
+
// Every bound source is stale past its window with a live/fail fallback —
|
|
1835
|
+
// nothing to serve from storage; fall through to live (caller's catch).
|
|
1836
|
+
throw new Error("no fresh storage serve bindings for this query");
|
|
1837
|
+
}
|
|
1838
|
+
const key = freshBindings
|
|
1839
|
+
.map((b) => `${b.sourceName}@${b.connectionName}/${b.virtualHandle}`)
|
|
1840
|
+
.sort()
|
|
1841
|
+
.join("|");
|
|
1842
|
+
if (!this.serveShapeCache || this.serveShapeCache.key !== key) {
|
|
1843
|
+
this.serveShapeCache = {
|
|
1844
|
+
key,
|
|
1845
|
+
materializer: await this.compileServeShape(
|
|
1846
|
+
this.serveBindingsWithRefinements(freshBindings),
|
|
1847
|
+
),
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
const virtualMap = buildVirtualMap(freshBindings);
|
|
1851
|
+
const runnable =
|
|
1852
|
+
this.serveShapeCache.materializer.loadRestrictedQuery(queryString);
|
|
1853
|
+
// Compile eagerly so ineligibility (a refinement the serve shape lacks, an
|
|
1854
|
+
// unbound source, a bad connection) surfaces HERE — Malloy compiles lazily,
|
|
1855
|
+
// so without this the error would escape at prepare/run instead of at the
|
|
1856
|
+
// caller's try, defeating the safe fallback. Cheap relative to the run. The
|
|
1857
|
+
// serve shape is pure virtual sources, so no buildManifest is needed.
|
|
1858
|
+
await runnable.getSQL({ virtualMap });
|
|
1859
|
+
return { runnable, virtualMap, bindings: freshBindings };
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/**
|
|
1863
|
+
* Compile the serve-shape materializer for a binding set, degrading
|
|
1864
|
+
* gracefully if the richest shape does not compile. A single un-carriable
|
|
1865
|
+
* refinement (a join or view that reaches a non-materialized source, a
|
|
1866
|
+
* non-portable expression) would otherwise fail the WHOLE shape's compile and
|
|
1867
|
+
* disable storage serving for every source in the package. So the shape is
|
|
1868
|
+
* validated once (per binding set — the result is cached) and, on failure,
|
|
1869
|
+
* the riskiest refinement category is dropped and it retries: full → drop
|
|
1870
|
+
* views → drop views + joins → base-only. Base-only is pure virtual sources
|
|
1871
|
+
* and always compiles, so it is the guaranteed floor. Each surviving tier
|
|
1872
|
+
* still serves everything it can; the per-query eager compile in
|
|
1873
|
+
* {@link loadServeShapeQuery} remains the final net for query-specific
|
|
1874
|
+
* ineligibility.
|
|
1875
|
+
*/
|
|
1876
|
+
private async compileServeShape(
|
|
1877
|
+
enriched: ServeBinding[],
|
|
1878
|
+
): Promise<ModelMaterializer> {
|
|
1879
|
+
// Richest first; each predicate keeps fewer refinement kinds than the last.
|
|
1880
|
+
const keepKinds: Array<ReadonlySet<string>> = [
|
|
1881
|
+
new Set(["join", "dimension", "measure", "view"]),
|
|
1882
|
+
new Set(["join", "dimension", "measure"]),
|
|
1883
|
+
new Set(["dimension", "measure"]),
|
|
1884
|
+
new Set(),
|
|
1885
|
+
];
|
|
1886
|
+
// Skip escalation entirely when nothing beyond the base is carried.
|
|
1887
|
+
const hasRefinements = enriched.some(
|
|
1888
|
+
(b) => (b.refinements ?? []).length > 0,
|
|
1889
|
+
);
|
|
1890
|
+
const lastTier = keepKinds.length - 1;
|
|
1891
|
+
for (let tier = 0; tier <= lastTier; tier++) {
|
|
1892
|
+
const keep = keepKinds[tier];
|
|
1893
|
+
const shaped =
|
|
1894
|
+
tier === 0
|
|
1895
|
+
? enriched
|
|
1896
|
+
: enriched.map((b) =>
|
|
1897
|
+
b.refinements
|
|
1898
|
+
? {
|
|
1899
|
+
...b,
|
|
1900
|
+
refinements: b.refinements.filter((r) =>
|
|
1901
|
+
keep.has(r.kind),
|
|
1902
|
+
),
|
|
1903
|
+
}
|
|
1904
|
+
: b,
|
|
1905
|
+
);
|
|
1906
|
+
const materializer = this.buildServeShapeMaterializer(shaped);
|
|
1907
|
+
// Base-only (last tier) always compiles; trust it without a probe. And
|
|
1908
|
+
// when there are no refinements at all, tier 0 IS the base — skip too.
|
|
1909
|
+
if (tier === lastTier || (tier === 0 && !hasRefinements)) {
|
|
1910
|
+
return materializer;
|
|
1911
|
+
}
|
|
1912
|
+
try {
|
|
1913
|
+
await materializer.getModel();
|
|
1914
|
+
return materializer;
|
|
1915
|
+
} catch (err) {
|
|
1916
|
+
recordServeShapeTierDrop(tier);
|
|
1917
|
+
logger.warn(
|
|
1918
|
+
"Storage serve shape failed to compile; dropping the riskiest refinement category and retrying",
|
|
1919
|
+
{
|
|
1920
|
+
model: this.modelPath,
|
|
1921
|
+
tier,
|
|
1922
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1923
|
+
},
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
// Unreachable: the last tier returns above. Satisfy the type checker.
|
|
1928
|
+
return this.buildServeShapeMaterializer(
|
|
1929
|
+
enriched.map((b) => ({ ...b, refinements: [] })),
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/** Build the transient serve-shape materializer for a set of bindings. */
|
|
1934
|
+
private buildServeShapeMaterializer(
|
|
1935
|
+
bindings: ServeBinding[],
|
|
1936
|
+
): ModelMaterializer {
|
|
1937
|
+
const { modelText } = buildServeShapeModelForBindings(bindings);
|
|
1938
|
+
const root = "file:///storage-serve-shape/";
|
|
1939
|
+
const url = `${root}shape.malloy`;
|
|
1940
|
+
const runtime = new Runtime({
|
|
1941
|
+
urlReader: new InMemoryURLReader(new Map([[url, modelText]])),
|
|
1942
|
+
config: Model.toMalloyConfig(this.serveMalloyConfig!),
|
|
1943
|
+
});
|
|
1944
|
+
return runtime.loadModel(new URL(url), {
|
|
1945
|
+
importBaseURL: new URL(root),
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* The serve bindings enriched with the refinements to re-declare on each
|
|
1951
|
+
* virtual base — the source's dimensions/measures (computed from the stored
|
|
1952
|
+
* columns), its joins whose target source is ALSO materialized (the join runs
|
|
1953
|
+
* over the stored tables), and its views (turtles). All are read from this
|
|
1954
|
+
* model's compiled definition. Analytic source-fields are not carried; a
|
|
1955
|
+
* query using one falls back to live.
|
|
1956
|
+
*
|
|
1957
|
+
* Join and view declaration text is lifted verbatim from the author's source
|
|
1958
|
+
* files by location (read once per file, best-effort — an unreadable file
|
|
1959
|
+
* just drops that source's joins/views, which fall back). The join
|
|
1960
|
+
* materialization gate is a `sourceID`-keyed lookup, so it never depends on
|
|
1961
|
+
* parsing that text; views are emitted optimistically and pruned by the
|
|
1962
|
+
* shape-compile escalation in {@link compileServeShape} if they don't hold.
|
|
1963
|
+
*/
|
|
1964
|
+
private serveBindingsWithRefinements(
|
|
1965
|
+
bindings: ServeBinding[] = this.serveBindings,
|
|
1966
|
+
): ServeBinding[] {
|
|
1967
|
+
const contents = (
|
|
1968
|
+
this.modelDef as
|
|
1969
|
+
| {
|
|
1970
|
+
contents?: Record<
|
|
1971
|
+
string,
|
|
1972
|
+
{ sourceID?: unknown; fields?: unknown[] }
|
|
1973
|
+
>;
|
|
1974
|
+
}
|
|
1975
|
+
| undefined
|
|
1976
|
+
)?.contents;
|
|
1977
|
+
// sourceID -> author source name, for the join materialization gate.
|
|
1978
|
+
const sourceNameById = new Map<string, string>();
|
|
1979
|
+
for (const [name, def] of Object.entries(contents ?? {})) {
|
|
1980
|
+
if (typeof def?.sourceID === "string") {
|
|
1981
|
+
sourceNameById.set(def.sourceID, name);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
const materializedSourceNames = new Set(
|
|
1985
|
+
bindings.map((b) => b.sourceName),
|
|
1986
|
+
);
|
|
1987
|
+
// Cache each source file's text (or null when unreadable) across bindings.
|
|
1988
|
+
const fileCache = new Map<string, string | null>();
|
|
1989
|
+
const liftText = (location: SourceLocation): string | undefined => {
|
|
1990
|
+
if (!location?.url?.startsWith("file:")) return undefined;
|
|
1991
|
+
if (!fileCache.has(location.url)) {
|
|
1992
|
+
try {
|
|
1993
|
+
fileCache.set(
|
|
1994
|
+
location.url,
|
|
1995
|
+
readFileSync(fileURLToPath(location.url), "utf8"),
|
|
1996
|
+
);
|
|
1997
|
+
} catch {
|
|
1998
|
+
fileCache.set(location.url, null);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
const text = fileCache.get(location.url);
|
|
2002
|
+
return text ? sliceSourceRange(text, location.range) : undefined;
|
|
2003
|
+
};
|
|
2004
|
+
return (
|
|
2005
|
+
bindings
|
|
2006
|
+
.map((b) => {
|
|
2007
|
+
const fields = contents?.[b.sourceName]?.fields;
|
|
2008
|
+
// Narrow the declared ::Shape to the source's PUBLIC columns: the
|
|
2009
|
+
// build materializes every projected column (incl. `except:`-ed /
|
|
2010
|
+
// access-restricted ones), so the captured schema can be wider
|
|
2011
|
+
// than the source's public surface. Declaring a hidden column
|
|
2012
|
+
// would expose it over storage when live hides it — always applied
|
|
2013
|
+
// (even with no refinements), so the serve surface never widens
|
|
2014
|
+
// the source's.
|
|
2015
|
+
const schema = narrowSchemaToPublic(b.schema, fields);
|
|
2016
|
+
const refinements = [
|
|
2017
|
+
...extractJoins(fields, {
|
|
2018
|
+
sourceNameById,
|
|
2019
|
+
materializedSourceNames,
|
|
2020
|
+
liftText,
|
|
2021
|
+
}),
|
|
2022
|
+
...extractRefinements(fields),
|
|
2023
|
+
...extractViews(fields, liftText),
|
|
2024
|
+
];
|
|
2025
|
+
return { ...b, schema, refinements };
|
|
2026
|
+
})
|
|
2027
|
+
// Drop any binding whose public schema is empty. Bindings are pushed
|
|
2028
|
+
// to EVERY model in the package, so a model receives bindings for
|
|
2029
|
+
// sources it doesn't define (defined in a sibling model) — those have
|
|
2030
|
+
// no field list here, hence an empty narrowed schema. An empty
|
|
2031
|
+
// `type: X__shape is {}` is a Malloy parse error that would fail the
|
|
2032
|
+
// ENTIRE serve-shape model (breaking the base-only-always-compiles
|
|
2033
|
+
// fallback invariant) and silently drop storage serving for this
|
|
2034
|
+
// model's own sources too. Omitting them sends queries on an
|
|
2035
|
+
// undefined source to live (where this model refuses them anyway) and
|
|
2036
|
+
// keeps a source from being served through a model that doesn't
|
|
2037
|
+
// declare it.
|
|
2038
|
+
.filter((b) => b.schema.length > 0)
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
1727
2042
|
public async getQueryResults(
|
|
1728
2043
|
sourceName?: string,
|
|
1729
2044
|
queryName?: string,
|
|
@@ -1761,6 +2076,23 @@ export class Model {
|
|
|
1761
2076
|
}
|
|
1762
2077
|
|
|
1763
2078
|
let runnable: QueryMaterializer;
|
|
2079
|
+
let liveRunnable: QueryMaterializer | undefined;
|
|
2080
|
+
// Set when this query is routed through the `storage=` serve-shape
|
|
2081
|
+
// transform; threaded into prepare + run so the virtual sources resolve to
|
|
2082
|
+
// their physical tables. Undefined ⇒ served live (the default path).
|
|
2083
|
+
let serveVirtualMap: VirtualMap | undefined;
|
|
2084
|
+
// The bindings that actually PRODUCED the serve shape — the fresh subset,
|
|
2085
|
+
// not the package's whole set. Per-binding decisions (freshnessFallback)
|
|
2086
|
+
// must read this: `this.serveBindings` is pushed package-wide, so a
|
|
2087
|
+
// sibling source's value would otherwise decide this query's behavior.
|
|
2088
|
+
let serveShapeBindings: ServeBinding[] = [];
|
|
2089
|
+
// How the answer was ultimately produced, for the query histogram. Absent
|
|
2090
|
+
// when the query never routed (an off/live deployment's histogram is
|
|
2091
|
+
// unchanged); "storage" when served from a materialized table;
|
|
2092
|
+
// "live_fallback" when it routed but a run-time store failure degraded it
|
|
2093
|
+
// to live — which must NOT count as a storage hit, since the hit rate is
|
|
2094
|
+
// the tier's headline KPI and would otherwise rise while the tier is down.
|
|
2095
|
+
let servedFrom: "storage" | "live_fallback" | undefined;
|
|
1764
2096
|
if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
|
|
1765
2097
|
throw new BadRequestError("Model has no queryable entities.");
|
|
1766
2098
|
|
|
@@ -1852,6 +2184,51 @@ export class Model {
|
|
|
1852
2184
|
// `sourceName`/`queryName` pair are untrusted, so both compile here;
|
|
1853
2185
|
// only author-curated notebook cells use the unrestricted `loadQuery`.
|
|
1854
2186
|
runnable = this.modelMaterializer.loadRestrictedQuery(queryString);
|
|
2187
|
+
// Kept so a routed query can still be answered live if the store fails
|
|
2188
|
+
// underneath it at RUN time (see the freshnessFallback retry below).
|
|
2189
|
+
liveRunnable = runnable;
|
|
2190
|
+
|
|
2191
|
+
// storage= serve routing: when enabled and this package has sources
|
|
2192
|
+
// materialized into a storage destination, try compiling the query
|
|
2193
|
+
// against the transient serve-shape model (materialized sources rebound
|
|
2194
|
+
// to virtual sources on their storage connection). If it compiles, we
|
|
2195
|
+
// serve from the materialized tables via the virtualMap; if it does not
|
|
2196
|
+
// (a refinement the shape lacks, or an unbound source), we keep the
|
|
2197
|
+
// original runnable and serve live — safe fallback, no behavior change
|
|
2198
|
+
// for anything the transform can't yet reproduce. Off / write-only and
|
|
2199
|
+
// packages with no storage bindings skip this entirely.
|
|
2200
|
+
if (
|
|
2201
|
+
getPersistStorageMode() === "on" &&
|
|
2202
|
+
this.serveBindings.length > 0 &&
|
|
2203
|
+
this.serveMalloyConfig
|
|
2204
|
+
) {
|
|
2205
|
+
try {
|
|
2206
|
+
const shaped = await this.loadServeShapeQuery(queryString);
|
|
2207
|
+
runnable = shaped.runnable;
|
|
2208
|
+
serveVirtualMap = shaped.virtualMap;
|
|
2209
|
+
serveShapeBindings = shaped.bindings;
|
|
2210
|
+
servedFrom = "storage";
|
|
2211
|
+
recordStorageServeRouting("storage");
|
|
2212
|
+
logger.info("Serving query from storage tier (virtual-source)", {
|
|
2213
|
+
modelPath: this.modelPath,
|
|
2214
|
+
// The sources in the SHAPE, not the package's whole binding set:
|
|
2215
|
+
// a stale binding is dropped before the shape is built.
|
|
2216
|
+
storageSources: shaped.bindings.map((b) => b.sourceName),
|
|
2217
|
+
});
|
|
2218
|
+
} catch (shapeErr) {
|
|
2219
|
+
recordStorageServeRouting("live_fallback");
|
|
2220
|
+
logger.debug(
|
|
2221
|
+
"storage serve-shape ineligible for this query; serving live",
|
|
2222
|
+
{
|
|
2223
|
+
modelPath: this.modelPath,
|
|
2224
|
+
error:
|
|
2225
|
+
shapeErr instanceof Error
|
|
2226
|
+
? shapeErr.message
|
|
2227
|
+
: String(shapeErr),
|
|
2228
|
+
},
|
|
2229
|
+
);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
1855
2232
|
} catch (error) {
|
|
1856
2233
|
// Re-throw BadRequestError as-is
|
|
1857
2234
|
if (error instanceof BadRequestError) {
|
|
@@ -1926,6 +2303,14 @@ export class Model {
|
|
|
1926
2303
|
// (for the row limit) and the run so a stale persist source falls back per
|
|
1927
2304
|
// its declared policy — and prep/run agree on the same substitution.
|
|
1928
2305
|
const buildManifest = this.resolveFreshBuildManifest();
|
|
2306
|
+
// The serve-shape runnable resolves its tables through `virtualMap`, not
|
|
2307
|
+
// the same-connection build manifest, and its transient model carries no
|
|
2308
|
+
// `##! experimental.persistence` — so passing a non-empty buildManifest to
|
|
2309
|
+
// it errors. When routing through the shape, suppress the manifest; the
|
|
2310
|
+
// original (live) runnable still gets it.
|
|
2311
|
+
const effectiveBuildManifest = serveVirtualMap
|
|
2312
|
+
? undefined
|
|
2313
|
+
: buildManifest;
|
|
1929
2314
|
|
|
1930
2315
|
// Prepare INSIDE the run try/catch: a bad-given / value-type throw at
|
|
1931
2316
|
// prepare time (getPreparedResult binds the givens) gets the same
|
|
@@ -1940,12 +2325,19 @@ export class Model {
|
|
|
1940
2325
|
// the real query if this model doesn't itself surface them — see
|
|
1941
2326
|
// filterGivensToModelSurface.
|
|
1942
2327
|
const querySurfaceGivens = this.filterGivensToModelSurface(givens);
|
|
2328
|
+
// Same reason as effectiveBuildManifest: the serve shape is built from
|
|
2329
|
+
// given-FREE sources, so it surfaces no `given:` and Malloy rejects any
|
|
2330
|
+
// supplied name with "unknown given" — a spurious 400, past the routing
|
|
2331
|
+
// fallback, on a query that should just serve from storage. Nothing in the
|
|
2332
|
+
// shape can read them; the authorize gate above already saw the full set.
|
|
2333
|
+
const effectiveGivens = serveVirtualMap ? undefined : querySurfaceGivens;
|
|
1943
2334
|
try {
|
|
1944
2335
|
rowLimit = resolveModelQueryRowLimit(
|
|
1945
2336
|
(
|
|
1946
2337
|
await runnable.getPreparedResult({
|
|
1947
|
-
givens:
|
|
1948
|
-
buildManifest,
|
|
2338
|
+
givens: effectiveGivens,
|
|
2339
|
+
buildManifest: effectiveBuildManifest,
|
|
2340
|
+
virtualMap: serveVirtualMap,
|
|
1949
2341
|
})
|
|
1950
2342
|
).resultExplore.limit,
|
|
1951
2343
|
{ defaultLimit: getDefaultQueryRowLimit(), maxRows },
|
|
@@ -1954,65 +2346,155 @@ export class Model {
|
|
|
1954
2346
|
|
|
1955
2347
|
queryResults = await runnable.run({
|
|
1956
2348
|
rowLimit,
|
|
1957
|
-
givens:
|
|
2349
|
+
givens: effectiveGivens,
|
|
1958
2350
|
abortSignal,
|
|
1959
|
-
buildManifest,
|
|
2351
|
+
buildManifest: effectiveBuildManifest,
|
|
2352
|
+
virtualMap: serveVirtualMap,
|
|
1960
2353
|
});
|
|
1961
2354
|
} catch (error) {
|
|
1962
|
-
//
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2355
|
+
// A binding that declares `freshnessFallback=live` is saying the tier is
|
|
2356
|
+
// a performance optimisation, not a dependency — so a store that fails
|
|
2357
|
+
// UNDER a routed query should degrade to serving live, the same answer
|
|
2358
|
+
// the compile-time ladder already gives when the shape can't be built.
|
|
2359
|
+
// Without this the store is a hard dependency the moment a query routes:
|
|
2360
|
+
// a not-yet-converged rebind or an over-eager GC turns into a user-facing
|
|
2361
|
+
// error for a source explicitly marked as safe to serve live.
|
|
2362
|
+
//
|
|
2363
|
+
// Deliberately narrow:
|
|
2364
|
+
// - only when the query actually routed to storage (`serveVirtualMap`);
|
|
2365
|
+
// - only when every binding THAT PRODUCED THIS SHAPE says `live` —
|
|
2366
|
+
// `fail` (and the `stale_ok` default) keep surfacing, so one
|
|
2367
|
+
// fail-closed source is not degraded by a permissive neighbour.
|
|
2368
|
+
// Read off the shape's own bindings, not the package's: bindings are
|
|
2369
|
+
// pushed package-wide and `freshnessFallback` is per entry, so a
|
|
2370
|
+
// mixed set is normal and a sibling must not decide this query;
|
|
2371
|
+
// - never for a client error (a bad given) or an abort, where a retry
|
|
2372
|
+
// would just reproduce it or defy the caller;
|
|
2373
|
+
// - the retry re-supplies the REAL givens. The storage path suppresses
|
|
2374
|
+
// them because the shape is built from given-free sources; the live
|
|
2375
|
+
// source may filter on them, and running it without them would serve
|
|
2376
|
+
// unfiltered rows.
|
|
2377
|
+
const canDegradeToLive =
|
|
2378
|
+
!!serveVirtualMap &&
|
|
2379
|
+
!!liveRunnable &&
|
|
2380
|
+
!abortSignal?.aborted &&
|
|
2381
|
+
!String((error as { code?: string })?.code ?? "").startsWith(
|
|
2382
|
+
"runtime-given-",
|
|
2383
|
+
) &&
|
|
2384
|
+
serveShapeBindings.length > 0 &&
|
|
2385
|
+
serveShapeBindings.every((b) => b.freshnessFallback === "live");
|
|
2386
|
+
// Both the original failure and a failure OF THE RETRY end here: record
|
|
2387
|
+
// the error metric, then map the error. A broad outage takes the source
|
|
2388
|
+
// warehouse down alongside the store, so the retry failing is ordinary —
|
|
2389
|
+
// and without this it would escape the given-mapping, the MalloyError
|
|
2390
|
+
// rethrow and the error metric, turning a clean 400 into an untracked 500.
|
|
2391
|
+
// Annotated on the CONST, not just the arrow: control-flow analysis only
|
|
2392
|
+
// treats a call as never-returning when the callee has an explicit type
|
|
2393
|
+
// annotation, and the fall-through below depends on that narrowing.
|
|
2394
|
+
const failQuery: (err: unknown) => never = (err) => {
|
|
2395
|
+
// Record error metrics
|
|
2396
|
+
const errorEndTime = performance.now();
|
|
2397
|
+
const errorExecutionTime = errorEndTime - startTime;
|
|
2398
|
+
this.queryExecutionHistogram.record(errorExecutionTime, {
|
|
2399
|
+
"malloy.model.path": this.modelPath,
|
|
2400
|
+
"malloy.model.query.name": queryName,
|
|
2401
|
+
"malloy.model.query.source": sourceName,
|
|
2402
|
+
"malloy.model.query.query": query,
|
|
2403
|
+
"malloy.model.query.status": "error",
|
|
2404
|
+
// Ships dark: only tag queries that routed. A live/off query gets
|
|
2405
|
+
// no new attribute, so an off deployment's histogram is unchanged.
|
|
2406
|
+
...(servedFrom
|
|
2407
|
+
? { "malloy.model.query.served_from": servedFrom }
|
|
2408
|
+
: {}),
|
|
2409
|
+
});
|
|
1972
2410
|
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
2411
|
+
// Bad client-supplied givens (unknown name, wrong-typed value, an
|
|
2412
|
+
// operator-finalized override, ...) all surface as a Malloy
|
|
2413
|
+
// `runtime-given-*` error. Malloy is the single validator; the publisher
|
|
2414
|
+
// just maps its rejection to a clean 400. Duck-type on `.code`
|
|
2415
|
+
// (MalloyCompileError extends Error, not MalloyError, and isn't
|
|
2416
|
+
// root-exported). The `runtime-given-` prefix is a pinned coupling to
|
|
2417
|
+
// Malloy's error codes (@malloydata/malloy given_binding.ts / runtime.ts);
|
|
2418
|
+
// if they're renamed upstream, update it here (and in environment.ts) —
|
|
2419
|
+
// otherwise these fall through to the generic 400 below with a worse
|
|
2420
|
+
// message, and the /compile path silently omits `sql`.
|
|
2421
|
+
const givenCode = (err as { code?: string })?.code;
|
|
2422
|
+
if (
|
|
2423
|
+
typeof givenCode === "string" &&
|
|
2424
|
+
givenCode.startsWith("runtime-given-")
|
|
2425
|
+
) {
|
|
2426
|
+
logger.debug("Rejected client-supplied given", {
|
|
2427
|
+
environmentName: this.packageName,
|
|
2428
|
+
modelPath: this.modelPath,
|
|
2429
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2430
|
+
});
|
|
2431
|
+
throw new BadRequestError(
|
|
2432
|
+
err instanceof Error ? err.message : String(err),
|
|
2433
|
+
);
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
// Re-throw Malloy errors as-is (they will be handled by error handler)
|
|
2437
|
+
if (err instanceof MalloyError) {
|
|
2438
|
+
throw err;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// For other runtime errors (like divide by zero), throw as BadRequestError
|
|
2442
|
+
const errorMessage =
|
|
2443
|
+
err instanceof Error ? err.message : String(err);
|
|
2444
|
+
logger.error("Query execution error", {
|
|
2445
|
+
error: err,
|
|
2446
|
+
errorMessage,
|
|
1989
2447
|
environmentName: this.packageName,
|
|
1990
2448
|
modelPath: this.modelPath,
|
|
1991
|
-
|
|
2449
|
+
query,
|
|
2450
|
+
queryName,
|
|
2451
|
+
sourceName,
|
|
1992
2452
|
});
|
|
1993
2453
|
throw new BadRequestError(
|
|
1994
|
-
|
|
2454
|
+
`Query execution failed: ${errorMessage}`,
|
|
1995
2455
|
);
|
|
2456
|
+
};
|
|
2457
|
+
if (!canDegradeToLive) failQuery(error);
|
|
2458
|
+
logger.warn(
|
|
2459
|
+
"Storage-served query failed at run time; falling back to live (freshnessFallback=live)",
|
|
2460
|
+
{
|
|
2461
|
+
modelPath: this.modelPath,
|
|
2462
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2463
|
+
},
|
|
2464
|
+
);
|
|
2465
|
+
recordStorageServeRouting("runtime_live_fallback");
|
|
2466
|
+
try {
|
|
2467
|
+
// Re-derive the row cap from the LIVE shape. `rowLimit` is assigned
|
|
2468
|
+
// inside the try above, from the storage prepare — so a store failure
|
|
2469
|
+
// that surfaces AT PREPARE (a manifest naming a table that is missing
|
|
2470
|
+
// or malformed resolves through `virtualMap` there) leaves it 0, which
|
|
2471
|
+
// the connector reads as a hard cap and stops before the first row: a
|
|
2472
|
+
// successful, EMPTY answer. Asking the live shape is also the honest
|
|
2473
|
+
// limit, since the live shape is what runs.
|
|
2474
|
+
rowLimit = resolveModelQueryRowLimit(
|
|
2475
|
+
(
|
|
2476
|
+
await liveRunnable!.getPreparedResult({
|
|
2477
|
+
givens: querySurfaceGivens,
|
|
2478
|
+
buildManifest,
|
|
2479
|
+
})
|
|
2480
|
+
).resultExplore.limit,
|
|
2481
|
+
{ defaultLimit: getDefaultQueryRowLimit(), maxRows },
|
|
2482
|
+
);
|
|
2483
|
+
queryResults = await liveRunnable!.run({
|
|
2484
|
+
rowLimit,
|
|
2485
|
+
givens: querySurfaceGivens,
|
|
2486
|
+
abortSignal,
|
|
2487
|
+
buildManifest,
|
|
2488
|
+
});
|
|
2489
|
+
} catch (retryError) {
|
|
2490
|
+
failQuery(retryError);
|
|
1996
2491
|
}
|
|
1997
|
-
|
|
1998
|
-
//
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
// For other runtime errors (like divide by zero), throw as BadRequestError
|
|
2004
|
-
const errorMessage =
|
|
2005
|
-
error instanceof Error ? error.message : String(error);
|
|
2006
|
-
logger.error("Query execution error", {
|
|
2007
|
-
error,
|
|
2008
|
-
errorMessage,
|
|
2009
|
-
environmentName: this.packageName,
|
|
2010
|
-
modelPath: this.modelPath,
|
|
2011
|
-
query,
|
|
2012
|
-
queryName,
|
|
2013
|
-
sourceName,
|
|
2014
|
-
});
|
|
2015
|
-
throw new BadRequestError(`Query execution failed: ${errorMessage}`);
|
|
2492
|
+
// The answer came from the live warehouse, so it is NOT a storage hit —
|
|
2493
|
+
// `runtime_live_fallback` above is the signal that the tier is degraded.
|
|
2494
|
+
servedFrom = "live_fallback";
|
|
2495
|
+
executionTime = performance.now() - startTime;
|
|
2496
|
+
// Fall through: `queryResults` is set, so the normal post-run path
|
|
2497
|
+
// wraps and returns it exactly as a live query would.
|
|
2016
2498
|
}
|
|
2017
2499
|
|
|
2018
2500
|
const wrappedResult = API.util.wrapResult(queryResults);
|
|
@@ -2042,6 +2524,12 @@ export class Model {
|
|
|
2042
2524
|
"malloy.model.query.rows_total": queryResults.totalRows,
|
|
2043
2525
|
"malloy.model.query.connection": queryResults.connectionName,
|
|
2044
2526
|
"malloy.model.query.status": "success",
|
|
2527
|
+
// Ships dark: only tag queries that routed (see the error path).
|
|
2528
|
+
// "live_fallback" here is a SUCCESS answered by the live warehouse, so
|
|
2529
|
+
// it must not inflate the storage hit rate.
|
|
2530
|
+
...(servedFrom
|
|
2531
|
+
? { "malloy.model.query.served_from": servedFrom }
|
|
2532
|
+
: {}),
|
|
2045
2533
|
});
|
|
2046
2534
|
return {
|
|
2047
2535
|
result: wrappedResult,
|