@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
|
@@ -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.
|
package/src/service/model.ts
CHANGED
|
@@ -92,14 +92,49 @@ import {
|
|
|
92
92
|
import { malloyGivenToApi, type MalloyGiven } from "./given";
|
|
93
93
|
import {
|
|
94
94
|
assertWithinModelResponseLimits,
|
|
95
|
+
type QueryRowLimitSource,
|
|
96
|
+
queryRowLimitSource,
|
|
95
97
|
resolveModelQueryRowLimit,
|
|
96
98
|
} from "./model_limits";
|
|
97
99
|
import { buildSourceAliasMap, extractRunTargetSourceName } from "./query_text";
|
|
100
|
+
import {
|
|
101
|
+
mergeQueryMetadata,
|
|
102
|
+
type QueryClass,
|
|
103
|
+
type QueryMetadata,
|
|
104
|
+
} from "./query_metadata";
|
|
98
105
|
import {
|
|
99
106
|
extractQueriesFromModelDef,
|
|
100
107
|
extractSourcesFromModelDef,
|
|
101
108
|
} from "./source_extraction";
|
|
102
109
|
|
|
110
|
+
/**
|
|
111
|
+
* What a request boundary contributes to a model query's per-query metadata: the
|
|
112
|
+
* caller's own properties and class, the environment the query runs in, and a
|
|
113
|
+
* reader for the executing connection's default (the controller owns the
|
|
114
|
+
* environment, so it supplies the lookup rather than the model reaching for it).
|
|
115
|
+
*/
|
|
116
|
+
export interface ModelQueryMetadataInput {
|
|
117
|
+
request?: QueryMetadata;
|
|
118
|
+
queryClass?: QueryClass;
|
|
119
|
+
environment?: string;
|
|
120
|
+
version?: string;
|
|
121
|
+
/**
|
|
122
|
+
* The id this query is correlated by, minted by the boundary that returns it
|
|
123
|
+
* (see `mintCorrelationId`). Omitted by callers with no response field to
|
|
124
|
+
* carry it, which is why it is not minted here.
|
|
125
|
+
*/
|
|
126
|
+
correlationId?: string;
|
|
127
|
+
/**
|
|
128
|
+
* The executing connection's two metadata layers: its overridable default and
|
|
129
|
+
* the properties the deployment enforces. Supplied by the controller, which
|
|
130
|
+
* owns the environment; the model only knows the connection by name.
|
|
131
|
+
*/
|
|
132
|
+
connectionMetadata?: (connectionName: string) => {
|
|
133
|
+
default?: QueryMetadata | null;
|
|
134
|
+
enforced?: QueryMetadata | null;
|
|
135
|
+
} | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
103
138
|
type ApiCompiledModel = components["schemas"]["CompiledModel"];
|
|
104
139
|
type ApiNotebookCell = components["schemas"]["NotebookCell"];
|
|
105
140
|
type ApiRawNotebook = components["schemas"]["RawNotebook"];
|
|
@@ -2054,11 +2089,28 @@ export class Model {
|
|
|
2054
2089
|
// background callers (materialization, tests) that own their
|
|
2055
2090
|
// own deadline.
|
|
2056
2091
|
abortSignal?: AbortSignal,
|
|
2092
|
+
/**
|
|
2093
|
+
* Per-query metadata inputs from the request boundary (see
|
|
2094
|
+
* {@link ModelQueryMetadataInput}). Omitting it still tags the query with
|
|
2095
|
+
* the server context; what is lost is the caller's own properties, the
|
|
2096
|
+
* connection default, and the correlation id.
|
|
2097
|
+
*/
|
|
2098
|
+
queryMetadataInput?: ModelQueryMetadataInput,
|
|
2057
2099
|
): Promise<{
|
|
2058
2100
|
result: Malloy.Result;
|
|
2059
2101
|
compactResult: QueryData;
|
|
2060
2102
|
modelInfo: Malloy.ModelInfo;
|
|
2061
2103
|
dataStyles: DataStyles;
|
|
2104
|
+
/** Row cap pushed into the SQL: the query's own LIMIT, else the default. */
|
|
2105
|
+
rowLimit: number;
|
|
2106
|
+
/** Which of those two the cap came from. */
|
|
2107
|
+
rowLimitSource: QueryRowLimitSource;
|
|
2108
|
+
/**
|
|
2109
|
+
* The `query_id` property attached to this query's statements, which is the
|
|
2110
|
+
* caller's join key into the backend's own query record. Null when no
|
|
2111
|
+
* metadata was attached.
|
|
2112
|
+
*/
|
|
2113
|
+
queryCorrelationId: string | null;
|
|
2062
2114
|
}> {
|
|
2063
2115
|
const startTime = performance.now();
|
|
2064
2116
|
if (this.compilationError) {
|
|
@@ -2318,8 +2370,10 @@ export class Model {
|
|
|
2318
2370
|
// a 500. `executionTime` is still captured after prepare and before run,
|
|
2319
2371
|
// preserving the pre-existing timing recorded by the success histogram.
|
|
2320
2372
|
let rowLimit = 0;
|
|
2373
|
+
let rowLimitSource: QueryRowLimitSource = "server_default";
|
|
2321
2374
|
let executionTime = 0;
|
|
2322
2375
|
let queryResults;
|
|
2376
|
+
let appliedQueryMetadata: QueryMetadata | undefined;
|
|
2323
2377
|
// Givens supplied only so a joined source's authorize gate could see
|
|
2324
2378
|
// them (checked above, against the full unfiltered set) must not reach
|
|
2325
2379
|
// the real query if this model doesn't itself surface them — see
|
|
@@ -2332,15 +2386,23 @@ export class Model {
|
|
|
2332
2386
|
// shape can read them; the authorize gate above already saw the full set.
|
|
2333
2387
|
const effectiveGivens = serveVirtualMap ? undefined : querySurfaceGivens;
|
|
2334
2388
|
try {
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2389
|
+
// The prepared result is also where the executing connection's name
|
|
2390
|
+
// comes from, which is what makes the connection's default metadata
|
|
2391
|
+
// layer resolvable BEFORE the statement is issued.
|
|
2392
|
+
const preparedResult = await runnable.getPreparedResult({
|
|
2393
|
+
givens: effectiveGivens,
|
|
2394
|
+
buildManifest: effectiveBuildManifest,
|
|
2395
|
+
virtualMap: serveVirtualMap,
|
|
2396
|
+
});
|
|
2397
|
+
const preparedLimit = preparedResult.resultExplore.limit;
|
|
2398
|
+
rowLimitSource = queryRowLimitSource(preparedLimit);
|
|
2399
|
+
rowLimit = resolveModelQueryRowLimit(preparedLimit, {
|
|
2400
|
+
defaultLimit: getDefaultQueryRowLimit(),
|
|
2401
|
+
maxRows,
|
|
2402
|
+
});
|
|
2403
|
+
appliedQueryMetadata = this.resolveQueryMetadata(
|
|
2404
|
+
queryMetadataInput,
|
|
2405
|
+
preparedResult.connectionName,
|
|
2344
2406
|
);
|
|
2345
2407
|
executionTime = performance.now() - startTime;
|
|
2346
2408
|
|
|
@@ -2350,6 +2412,7 @@ export class Model {
|
|
|
2350
2412
|
abortSignal,
|
|
2351
2413
|
buildManifest: effectiveBuildManifest,
|
|
2352
2414
|
virtualMap: serveVirtualMap,
|
|
2415
|
+
queryMetadata: appliedQueryMetadata,
|
|
2353
2416
|
});
|
|
2354
2417
|
} catch (error) {
|
|
2355
2418
|
// A binding that declares `freshnessFallback=live` is saying the tier is
|
|
@@ -2471,20 +2534,32 @@ export class Model {
|
|
|
2471
2534
|
// the connector reads as a hard cap and stops before the first row: a
|
|
2472
2535
|
// successful, EMPTY answer. Asking the live shape is also the honest
|
|
2473
2536
|
// limit, since the live shape is what runs.
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2537
|
+
const livePrepared = await liveRunnable!.getPreparedResult({
|
|
2538
|
+
givens: querySurfaceGivens,
|
|
2539
|
+
buildManifest,
|
|
2540
|
+
});
|
|
2541
|
+
const livePreparedLimit = livePrepared.resultExplore.limit;
|
|
2542
|
+
rowLimitSource = queryRowLimitSource(livePreparedLimit);
|
|
2543
|
+
rowLimit = resolveModelQueryRowLimit(livePreparedLimit, {
|
|
2544
|
+
defaultLimit: getDefaultQueryRowLimit(),
|
|
2545
|
+
maxRows,
|
|
2546
|
+
});
|
|
2547
|
+
// Re-resolve rather than reuse: the bag above was resolved against
|
|
2548
|
+
// the STORAGE connection, which on this tier is routinely not the
|
|
2549
|
+
// one the live shape runs on, so reusing it would stamp another
|
|
2550
|
+
// connection's default and enforced layers on this statement. The
|
|
2551
|
+
// correlation id rides in the input, so the retry keeps the id the
|
|
2552
|
+
// response returns — one API call, one join key, two statements.
|
|
2553
|
+
appliedQueryMetadata = this.resolveQueryMetadata(
|
|
2554
|
+
queryMetadataInput,
|
|
2555
|
+
livePrepared.connectionName,
|
|
2482
2556
|
);
|
|
2483
2557
|
queryResults = await liveRunnable!.run({
|
|
2484
2558
|
rowLimit,
|
|
2485
2559
|
givens: querySurfaceGivens,
|
|
2486
2560
|
abortSignal,
|
|
2487
2561
|
buildManifest,
|
|
2562
|
+
queryMetadata: appliedQueryMetadata,
|
|
2488
2563
|
});
|
|
2489
2564
|
} catch (retryError) {
|
|
2490
2565
|
failQuery(retryError);
|
|
@@ -2536,9 +2611,74 @@ export class Model {
|
|
|
2536
2611
|
compactResult: queryResults.data.value,
|
|
2537
2612
|
modelInfo: this.modelInfo,
|
|
2538
2613
|
dataStyles: this.dataStyles,
|
|
2614
|
+
// The cap actually pushed into the SQL. A caller cannot otherwise tell
|
|
2615
|
+
// a complete result from one the row limit cut off: with no LIMIT of
|
|
2616
|
+
// its own a query silently gets DEFAULT_QUERY_ROW_LIMIT rows, and that
|
|
2617
|
+
// is under maxRows, so assertWithinModelResponseLimits raises nothing.
|
|
2618
|
+
// Returning the number lets a caller compare it against the row count
|
|
2619
|
+
// and say so, with no extra query.
|
|
2620
|
+
rowLimit,
|
|
2621
|
+
// Whether that cap was the author's own limit:/top: or the silent
|
|
2622
|
+
// default. Only the second means rows were probably left behind; a
|
|
2623
|
+
// deliberate `top: 10` returning 10 rows is a complete answer.
|
|
2624
|
+
rowLimitSource,
|
|
2625
|
+
// The id from the bag that was actually attached, not the one supplied:
|
|
2626
|
+
// a bag shed under budget pressure sheds context last, but a caller
|
|
2627
|
+
// should be told the truth about what it can look up.
|
|
2628
|
+
queryCorrelationId: appliedQueryMetadata?.query_id ?? null,
|
|
2539
2629
|
};
|
|
2540
2630
|
}
|
|
2541
2631
|
|
|
2632
|
+
/**
|
|
2633
|
+
* The per-query metadata for one model query: the executing connection's
|
|
2634
|
+
* default, the caller's request override, and the server's context (which
|
|
2635
|
+
* package, which model, which class of work), merged most-specific-wins.
|
|
2636
|
+
*
|
|
2637
|
+
* There is no model-side layer here. `materialization.queryMetadata` describes
|
|
2638
|
+
* how a persist source is BUILT; a live query against the model is a different
|
|
2639
|
+
* unit of work, and inheriting a build's tags would attribute interactive
|
|
2640
|
+
* traffic to the build that happens to share the source.
|
|
2641
|
+
*
|
|
2642
|
+
* Fails open, like every other metadata path: a connection whose config can't
|
|
2643
|
+
* be read contributes no default rather than failing the query.
|
|
2644
|
+
*/
|
|
2645
|
+
private resolveQueryMetadata(
|
|
2646
|
+
input: ModelQueryMetadataInput | undefined,
|
|
2647
|
+
connectionName: string | undefined,
|
|
2648
|
+
): QueryMetadata | undefined {
|
|
2649
|
+
let connectionLayers: {
|
|
2650
|
+
default?: QueryMetadata | null;
|
|
2651
|
+
enforced?: QueryMetadata | null;
|
|
2652
|
+
} | null = null;
|
|
2653
|
+
if (connectionName && input?.connectionMetadata) {
|
|
2654
|
+
try {
|
|
2655
|
+
connectionLayers = input.connectionMetadata(connectionName);
|
|
2656
|
+
} catch {
|
|
2657
|
+
connectionLayers = null;
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
const resolved = mergeQueryMetadata({
|
|
2661
|
+
connection: connectionLayers?.default,
|
|
2662
|
+
enforced: connectionLayers?.enforced,
|
|
2663
|
+
request: input?.request,
|
|
2664
|
+
context: {
|
|
2665
|
+
queryClass: input?.queryClass ?? "interactive",
|
|
2666
|
+
environment: input?.environment,
|
|
2667
|
+
package: this.packageName,
|
|
2668
|
+
model: this.modelPath,
|
|
2669
|
+
version: input?.version,
|
|
2670
|
+
correlationId: input?.correlationId,
|
|
2671
|
+
},
|
|
2672
|
+
});
|
|
2673
|
+
if (resolved.drops.length > 0) {
|
|
2674
|
+
logger.warn("Dropped query-metadata properties for a query", {
|
|
2675
|
+
modelPath: this.modelPath,
|
|
2676
|
+
drops: resolved.drops,
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2679
|
+
return resolved.metadata;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2542
2682
|
private getStandardModel(): ApiCompiledModel {
|
|
2543
2683
|
return {
|
|
2544
2684
|
type: "source",
|
|
@@ -2633,6 +2773,11 @@ export class Model {
|
|
|
2633
2773
|
// See `getQueryResults`: forwarded into `runnable.run` so the
|
|
2634
2774
|
// publisher's wall-clock timeout actually cancels the query.
|
|
2635
2775
|
abortSignal?: AbortSignal,
|
|
2776
|
+
// A notebook cell issues real backend SQL on an interactive path, so it is
|
|
2777
|
+
// tagged like any other query. No correlation id: the cell response has no
|
|
2778
|
+
// field to hand one back on, and an id nobody can read costs the result
|
|
2779
|
+
// cache for nothing.
|
|
2780
|
+
queryMetadataInput?: ModelQueryMetadataInput,
|
|
2636
2781
|
): Promise<{
|
|
2637
2782
|
type: "code" | "markdown";
|
|
2638
2783
|
text: string;
|
|
@@ -2712,13 +2857,12 @@ export class Model {
|
|
|
2712
2857
|
// See getQueryResults / filterGivensToModelSurface: the gate
|
|
2713
2858
|
// above already saw the full unfiltered givens.
|
|
2714
2859
|
const cellSurfaceGivens = this.filterGivensToModelSurface(givens);
|
|
2860
|
+
const preparedCell = await runnableToExecute.getPreparedResult({
|
|
2861
|
+
givens: cellSurfaceGivens,
|
|
2862
|
+
buildManifest,
|
|
2863
|
+
});
|
|
2715
2864
|
const rowLimit = resolveModelQueryRowLimit(
|
|
2716
|
-
|
|
2717
|
-
await runnableToExecute.getPreparedResult({
|
|
2718
|
-
givens: cellSurfaceGivens,
|
|
2719
|
-
buildManifest,
|
|
2720
|
-
})
|
|
2721
|
-
).resultExplore.limit,
|
|
2865
|
+
preparedCell.resultExplore.limit,
|
|
2722
2866
|
{
|
|
2723
2867
|
defaultLimit: getDefaultQueryRowLimit(),
|
|
2724
2868
|
maxRows: cellMaxRows,
|
|
@@ -2729,6 +2873,10 @@ export class Model {
|
|
|
2729
2873
|
givens: cellSurfaceGivens,
|
|
2730
2874
|
abortSignal,
|
|
2731
2875
|
buildManifest,
|
|
2876
|
+
queryMetadata: this.resolveQueryMetadata(
|
|
2877
|
+
queryMetadataInput,
|
|
2878
|
+
preparedCell.connectionName,
|
|
2879
|
+
),
|
|
2732
2880
|
});
|
|
2733
2881
|
const query = (await runnableToExecute.getPreparedQuery())._query;
|
|
2734
2882
|
queryName = (query as NamedQueryDef).as || query.name;
|
|
@@ -3,6 +3,7 @@ import { describe, expect, it } from "bun:test";
|
|
|
3
3
|
import { PayloadTooLargeError } from "../errors";
|
|
4
4
|
import {
|
|
5
5
|
assertWithinModelResponseLimits,
|
|
6
|
+
queryRowLimitSource,
|
|
6
7
|
resolveModelQueryRowLimit,
|
|
7
8
|
} from "./model_limits";
|
|
8
9
|
|
|
@@ -179,3 +180,30 @@ describe("assertWithinModelResponseLimits", () => {
|
|
|
179
180
|
).not.toThrow();
|
|
180
181
|
});
|
|
181
182
|
});
|
|
183
|
+
|
|
184
|
+
describe("queryRowLimitSource", () => {
|
|
185
|
+
/**
|
|
186
|
+
* Must mirror resolveModelQueryRowLimit's own `requested` condition: if the
|
|
187
|
+
* two ever disagree, the reported source describes a different limit than
|
|
188
|
+
* the one actually pushed into the SQL.
|
|
189
|
+
*/
|
|
190
|
+
it("reports the query when it carried its own positive limit", () => {
|
|
191
|
+
expect(queryRowLimitSource(10)).toBe("query");
|
|
192
|
+
expect(queryRowLimitSource(1)).toBe("query");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("reports the server default when the query carried none", () => {
|
|
196
|
+
expect(queryRowLimitSource(undefined)).toBe("server_default");
|
|
197
|
+
expect(queryRowLimitSource(0)).toBe("server_default");
|
|
198
|
+
expect(queryRowLimitSource(-1)).toBe("server_default");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("agrees with which limit resolveModelQueryRowLimit actually used", () => {
|
|
202
|
+
const config = { defaultLimit: 1000, maxRows: 0 };
|
|
203
|
+
for (const userLimit of [undefined, 0, -1, 1, 10, 5000]) {
|
|
204
|
+
const used = resolveModelQueryRowLimit(userLimit, config);
|
|
205
|
+
const cameFromQuery = queryRowLimitSource(userLimit) === "query";
|
|
206
|
+
expect(cameFromQuery).toBe(used !== config.defaultLimit);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -67,6 +67,27 @@ export function resolveModelQueryRowLimit(
|
|
|
67
67
|
return Math.min(requested, maxRows + 1);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Whether the cap came from the query itself or from the server default. */
|
|
71
|
+
export type QueryRowLimitSource = "query" | "server_default";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Which of the two the cap in {@link resolveModelQueryRowLimit} came from.
|
|
75
|
+
*
|
|
76
|
+
* The distinction is the whole difference between "the database cut this off
|
|
77
|
+
* and you were not told" and "you asked for exactly this many". A `limit:` or
|
|
78
|
+
* `top:` the author wrote is deliberate and complete for what it asked; only the
|
|
79
|
+
* silently-applied default means rows were probably left behind.
|
|
80
|
+
*
|
|
81
|
+
* The condition deliberately mirrors `requested` above and must stay in step
|
|
82
|
+
* with it, so a change to which limit wins cannot leave the reported source
|
|
83
|
+
* describing the other one.
|
|
84
|
+
*/
|
|
85
|
+
export function queryRowLimitSource(
|
|
86
|
+
userLimit: number | undefined,
|
|
87
|
+
): QueryRowLimitSource {
|
|
88
|
+
return userLimit && userLimit > 0 ? "query" : "server_default";
|
|
89
|
+
}
|
|
90
|
+
|
|
70
91
|
export interface ModelResponseLimitsConfig {
|
|
71
92
|
/** Result of {@link getMaxQueryRows}. `0` disables the row cap. */
|
|
72
93
|
maxRows: number;
|
package/src/service/package.ts
CHANGED
|
@@ -50,6 +50,7 @@ import { errMessage, ignoreDotfiles } from "../utils";
|
|
|
50
50
|
import { getPersistCollisionEnforce, getPersistStorageMode } from "../config";
|
|
51
51
|
import { deriveServeBindings } from "./materialization_serve_transform";
|
|
52
52
|
import { computePackageBuildPlan, SourceEligibility } from "./build_plan";
|
|
53
|
+
import { materializationConfigWarnings } from "./materialization_config_validation";
|
|
53
54
|
import { CronEvaluator } from "./cron_evaluator";
|
|
54
55
|
import { filterFreshManifest } from "./freshness";
|
|
55
56
|
import { isQuotedIdentifierPath, quoteManifestTablePath } from "./quoting";
|
|
@@ -175,6 +176,12 @@ export class Package {
|
|
|
175
176
|
// tag does not fail the load (see Model.validateRenderTags); this is the
|
|
176
177
|
// response-level signal that a tag is misconfigured.
|
|
177
178
|
private renderTagWarnings: ApiPackageWarning[] = [];
|
|
179
|
+
/**
|
|
180
|
+
* Manifest-shape deprecations the load tolerated (a root-level `scope`), kept
|
|
181
|
+
* so publish can report a still-parsing-but-outdated manifest. Not on the wire
|
|
182
|
+
* package: it is a property of the manifest text, not of the loaded package.
|
|
183
|
+
*/
|
|
184
|
+
private manifestWarnings: string[] = [];
|
|
178
185
|
private static meter = publisherMeter();
|
|
179
186
|
private static packageLoadHistogram = this.meter.createHistogram(
|
|
180
187
|
"malloy_package_load_duration",
|
|
@@ -550,6 +557,7 @@ export class Package {
|
|
|
550
557
|
malloyConfig,
|
|
551
558
|
);
|
|
552
559
|
pkg.renderTagWarnings = renderTagWarnings;
|
|
560
|
+
pkg.manifestWarnings = outcome.packageMetadata.manifestWarnings ?? [];
|
|
553
561
|
// Install the per-query freshness resolver on the freshly-built models.
|
|
554
562
|
// At create time no manifest is bound yet, so the resolver returns
|
|
555
563
|
// undefined (serve live) until a subsequent bindManifest → reloadAllModels.
|
|
@@ -688,6 +696,16 @@ export class Package {
|
|
|
688
696
|
// (alongside the load-path log) so an operator can see it on the status
|
|
689
697
|
// API like the other persist warnings — see persistenceCollisionWarnings.
|
|
690
698
|
...this.persistenceCollisionWarnings().map((message) => ({ message })),
|
|
699
|
+
// Materialization-config findings: a queryMetadata property that will
|
|
700
|
+
// not do what it says, and manifest shapes that still parse but are
|
|
701
|
+
// deprecated. Advisory by design — none of these blocks a publish.
|
|
702
|
+
...materializationConfigWarnings({
|
|
703
|
+
packageMaterialization: this.packageMetadata.materialization,
|
|
704
|
+
sources: this.buildPlan?.sources
|
|
705
|
+
? Object.values(this.buildPlan.sources)
|
|
706
|
+
: [],
|
|
707
|
+
manifestWarnings: this.manifestWarnings,
|
|
708
|
+
}),
|
|
691
709
|
];
|
|
692
710
|
if (allWarnings.length > 0) {
|
|
693
711
|
metadata.warnings = allWarnings;
|
|
@@ -1059,6 +1077,10 @@ export class Package {
|
|
|
1059
1077
|
}
|
|
1060
1078
|
|
|
1061
1079
|
public persistencePolicyWarnings(): string[] {
|
|
1080
|
+
// REJECTION rules only: a non-empty result fails a publish and disarms the
|
|
1081
|
+
// scheduler. Advisory findings (queryMetadata problems, a deprecated
|
|
1082
|
+
// manifest shape) go to the operator warnings array instead — see
|
|
1083
|
+
// materializationConfigWarnings.
|
|
1062
1084
|
const warnings: string[] = [];
|
|
1063
1085
|
const sources = this.buildPlan?.sources
|
|
1064
1086
|
? Object.values(this.buildPlan.sources)
|
|
@@ -1075,7 +1097,7 @@ export class Package {
|
|
|
1075
1097
|
warnings.push(
|
|
1076
1098
|
`#@ persist source "${source.name}" declares sharing=... which is ` +
|
|
1077
1099
|
`no longer supported: scope is a single package-level mode. Set ` +
|
|
1078
|
-
`
|
|
1100
|
+
`"materialization": { "scope": "version" | "package" } in ` +
|
|
1079
1101
|
`${PACKAGE_MANIFEST_NAME} instead.`,
|
|
1080
1102
|
);
|
|
1081
1103
|
}
|
|
@@ -1480,6 +1502,7 @@ export class Package {
|
|
|
1480
1502
|
// package's current storage= bindings so a reload preserves serve routing.
|
|
1481
1503
|
this.pushStorageServeBindingsToModels();
|
|
1482
1504
|
this.renderTagWarnings = renderTagWarnings;
|
|
1505
|
+
this.manifestWarnings = outcome.packageMetadata.manifestWarnings ?? [];
|
|
1483
1506
|
// A reload re-reads publisher.json in the worker; pick up any change to
|
|
1484
1507
|
// the explore set and query-boundary mode so listModels()/the gate
|
|
1485
1508
|
// reflect edited explores without a full Package.create.
|