@malloy-publisher/server 0.0.225 → 0.0.227
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.md +2 -11
- package/dist/app/api-doc.yaml +29 -24
- package/dist/app/assets/{EnvironmentPage-DYTeXDll.js → EnvironmentPage-DvOJ7L_b.js} +1 -1
- package/dist/app/assets/{HomePage-pDK2BPJY.js → HomePage-CXguJsXS.js} +1 -1
- package/dist/app/assets/{LightMode-C2bwGPY1.js → LightMode-ZsshUznu.js} +1 -1
- package/dist/app/assets/{MainPage-WtBulMH_.js → MainPage-BIe0VwBa.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-hMgOtflG.js → MaterializationsPage-BuZ6UJVx.js} +1 -1
- package/dist/app/assets/{ModelPage-B2N5kYII.js → ModelPage-DsPf-s8B.js} +1 -1
- package/dist/app/assets/{PackagePage-CEN90nQG.js → PackagePage-CEVNAKZa.js} +1 -1
- package/dist/app/assets/{RouteError-BG2c5Zf0.js → RouteError-Chn7lL96.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DNfeUwEZ.js → ThemeEditorPage-DWC_FdNU.js} +1 -1
- package/dist/app/assets/{WorkbookPage-NKI1BhFS.js → WorkbookPage-CGrsFz8p.js} +1 -1
- package/dist/app/assets/{core-C6anj5c0.es-DDLHqpzt.js → core-vVgoO8IR.es-BD_THWs_.js} +1 -1
- package/dist/app/assets/{index-DMQtnaf4.js → index-BioohWQj.js} +1 -1
- package/dist/app/assets/{index-CzNqKMVl.js → index-D6YtyiJ0.js} +1 -1
- package/dist/app/assets/{index-C6gZ6sSY.js → index-DNUZpnaa.js} +4 -4
- package/dist/app/assets/{index-JXgvyZna.js → index-gEWxu09x.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +71 -15
- package/dist/package_load_worker.mjs +169 -40
- package/dist/server.mjs +782 -7050
- package/package.json +1 -4
- package/src/controller/package.controller.spec.ts +17 -17
- package/src/controller/package.controller.ts +7 -6
- package/src/health.ts +3 -8
- package/src/logger.spec.ts +193 -0
- package/src/logger.ts +115 -18
- package/src/mcp/error_messages.ts +8 -37
- package/src/mcp/handler_utils.ts +10 -129
- package/src/mcp/mcp_constants.ts +0 -16
- package/src/mcp/{agent_server.protocol.spec.ts → server.protocol.spec.ts} +14 -12
- package/src/mcp/server.ts +29 -37
- package/src/mcp/skills/build_skills_bundle.ts +1 -1
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/docs_search_tool.ts +1 -1
- package/src/mcp/tools/execute_query_tool.ts +2 -2
- package/src/mcp/tools/get_context_eval.ts +3 -3
- package/src/mcp/tools/get_context_tool.spec.ts +196 -1
- package/src/mcp/tools/get_context_tool.ts +165 -67
- package/src/package_load/package_load_pool.ts +8 -1
- package/src/package_load/package_load_worker.ts +75 -24
- package/src/package_load/protocol.ts +21 -1
- package/src/package_load/rpc_wait_accountant.spec.ts +109 -0
- package/src/package_load/rpc_wait_accountant.ts +76 -0
- package/src/package_load_metrics.spec.ts +114 -0
- package/src/package_load_metrics.ts +127 -0
- package/src/pg_helpers.spec.ts +2 -206
- package/src/pg_helpers.ts +4 -120
- package/src/server.ts +0 -16
- package/src/service/build_plan.spec.ts +42 -60
- package/src/service/build_plan.ts +37 -61
- package/src/service/environment.ts +5 -1
- package/src/service/materialization_test_fixtures.ts +6 -8
- package/src/service/package.ts +165 -86
- package/src/service/package_manifest.spec.ts +22 -1
- package/src/service/package_manifest.ts +29 -0
- package/src/service/persistence_policy.spec.ts +234 -0
- package/src/test_helpers/metrics_harness.ts +40 -0
- package/tests/harness/mcp_test_setup.ts +1 -1
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +7 -31
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -6
- package/dxt/malloy_bridge.py +0 -354
- package/dxt/manifest.json +0 -22
- package/src/dto/connection.dto.spec.ts +0 -186
- package/src/dto/connection.dto.ts +0 -308
- package/src/dto/index.ts +0 -2
- package/src/dto/package.dto.spec.ts +0 -42
- package/src/dto/package.dto.ts +0 -27
- package/src/dto/validate.spec.ts +0 -76
- package/src/dto/validate.ts +0 -31
- package/src/ducklake_version.spec.ts +0 -43
- package/src/ducklake_version.ts +0 -26
- package/src/mcp/agent_server.spec.ts +0 -18
- package/src/mcp/agent_server.ts +0 -144
- package/src/mcp/prompts/handlers.ts +0 -84
- package/src/mcp/prompts/index.ts +0 -11
- package/src/mcp/prompts/prompt_definitions.ts +0 -160
- package/src/mcp/prompts/prompt_service.ts +0 -67
- package/src/mcp/prompts/utils.ts +0 -62
- package/src/mcp/resource_metadata.ts +0 -47
- package/src/mcp/resources/environment_resource.ts +0 -187
- package/src/mcp/resources/model_resource.ts +0 -155
- package/src/mcp/resources/notebook_resource.ts +0 -137
- package/src/mcp/resources/package_resource.ts +0 -373
- package/src/mcp/resources/query_resource.ts +0 -122
- package/src/mcp/resources/source_resource.ts +0 -141
- package/src/mcp/resources/view_resource.ts +0 -136
- package/src/mcp/tools/discovery_tools.ts +0 -280
- package/src/service/materialization_cron_gate.spec.ts +0 -181
- package/src/storage/BaseRepository.ts +0 -31
- package/src/storage/StorageManager.mock.ts +0 -50
- package/tests/harness/e2e.ts +0 -96
- package/tests/harness/mocks.ts +0 -39
- package/tests/harness/uris.ts +0 -31
- package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -655
- package/tests/integration/mcp/setup.spec.ts +0 -5
- package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
- package/tests/unit/mcp/prompt_happy.test.ts +0 -51
|
@@ -85,7 +85,9 @@ import { validateAuthorizeProbes } from "../service/authorize";
|
|
|
85
85
|
import { type FilterDefinition } from "../service/filter";
|
|
86
86
|
import {
|
|
87
87
|
PackageMaterializationConfig,
|
|
88
|
+
PackageScope,
|
|
88
89
|
parsePackageMaterialization,
|
|
90
|
+
parsePackageScope,
|
|
89
91
|
} from "../service/package_manifest";
|
|
90
92
|
import {
|
|
91
93
|
extractQueriesFromModelDef,
|
|
@@ -97,6 +99,7 @@ import {
|
|
|
97
99
|
type MalloyGivenApi,
|
|
98
100
|
} from "../service/given";
|
|
99
101
|
import { ignoreDotfiles } from "../utils";
|
|
102
|
+
import { RpcWaitAccountant } from "./rpc_wait_accountant";
|
|
100
103
|
import type {
|
|
101
104
|
ConnectionMetadata,
|
|
102
105
|
ConnectionMetadataRequest,
|
|
@@ -150,6 +153,13 @@ function callMain<T>(send: (requestId: string) => void): Promise<T> {
|
|
|
150
153
|
});
|
|
151
154
|
}
|
|
152
155
|
|
|
156
|
+
// Per-load phase timing: brackets the wall-clock spent awaiting proxied schema
|
|
157
|
+
// fetches so `loadPackage` can separate connection I/O from compile CPU. The
|
|
158
|
+
// pool runs one load per worker at a time, so a single module-level instance
|
|
159
|
+
// is safe; it's still keyed by jobId to shrug off a straggler fetch from a
|
|
160
|
+
// prior, reused-worker load. See {@link RpcWaitAccountant}.
|
|
161
|
+
const schemaWait = new RpcWaitAccountant();
|
|
162
|
+
|
|
153
163
|
function dispatchMainResponse(message: MainToWorkerMessage): void {
|
|
154
164
|
if (
|
|
155
165
|
message.type === "schema-for-tables-response" ||
|
|
@@ -200,18 +210,25 @@ class ProxyConnection {
|
|
|
200
210
|
schemas: Record<string, TableSourceDef>;
|
|
201
211
|
errors: Record<string, string>;
|
|
202
212
|
}> {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
requestId
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
schemaWait.noteStart(this.jobId);
|
|
214
|
+
try {
|
|
215
|
+
const response = await callMain<SchemaForTablesResponse>(
|
|
216
|
+
(requestId) => {
|
|
217
|
+
const req: SchemaForTablesRequest = {
|
|
218
|
+
type: "schema-for-tables",
|
|
219
|
+
requestId,
|
|
220
|
+
jobId: this.jobId,
|
|
221
|
+
connectionName: this.name,
|
|
222
|
+
tables,
|
|
223
|
+
options: serializeFetchOptions(options),
|
|
224
|
+
};
|
|
225
|
+
port.postMessage(req);
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
return { schemas: response.schemas, errors: response.errors };
|
|
229
|
+
} finally {
|
|
230
|
+
schemaWait.noteSettle(this.jobId);
|
|
231
|
+
}
|
|
215
232
|
}
|
|
216
233
|
|
|
217
234
|
async fetchSchemaForSQLStruct(
|
|
@@ -221,17 +238,23 @@ class ProxyConnection {
|
|
|
221
238
|
| { structDef: SQLSourceDef; error?: undefined }
|
|
222
239
|
| { error: string; structDef?: undefined }
|
|
223
240
|
> {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
241
|
+
schemaWait.noteStart(this.jobId);
|
|
242
|
+
let response: SchemaForSqlResponse;
|
|
243
|
+
try {
|
|
244
|
+
response = await callMain<SchemaForSqlResponse>((requestId) => {
|
|
245
|
+
const req: SchemaForSqlRequest = {
|
|
246
|
+
type: "schema-for-sql",
|
|
247
|
+
requestId,
|
|
248
|
+
jobId: this.jobId,
|
|
249
|
+
connectionName: this.name,
|
|
250
|
+
sentence: sentence as unknown,
|
|
251
|
+
options: serializeFetchOptions(options),
|
|
252
|
+
};
|
|
253
|
+
port.postMessage(req);
|
|
254
|
+
});
|
|
255
|
+
} finally {
|
|
256
|
+
schemaWait.noteSettle(this.jobId);
|
|
257
|
+
}
|
|
235
258
|
if (response.error !== undefined) return { error: response.error };
|
|
236
259
|
if (response.structDef === undefined) {
|
|
237
260
|
return { error: "Empty SQL schema response from main thread" };
|
|
@@ -387,6 +410,7 @@ async function readPackageMetadata(packagePath: string): Promise<{
|
|
|
387
410
|
queryableSources?: "declared" | "all";
|
|
388
411
|
manifestLocation?: string | null;
|
|
389
412
|
materialization?: PackageMaterializationConfig | null;
|
|
413
|
+
scope?: PackageScope;
|
|
390
414
|
}> {
|
|
391
415
|
const manifestPath = path.join(packagePath, PACKAGE_MANIFEST_NAME);
|
|
392
416
|
const contents = await fs.promises.readFile(manifestPath, "utf8");
|
|
@@ -397,6 +421,7 @@ async function readPackageMetadata(packagePath: string): Promise<{
|
|
|
397
421
|
queryableSources?: unknown;
|
|
398
422
|
manifestLocation?: unknown;
|
|
399
423
|
materialization?: unknown;
|
|
424
|
+
scope?: unknown;
|
|
400
425
|
};
|
|
401
426
|
return {
|
|
402
427
|
name: parsed.name,
|
|
@@ -416,6 +441,9 @@ async function readPackageMetadata(packagePath: string): Promise<{
|
|
|
416
441
|
// Package-level Malloy Persistence policy; surfaced to the control plane,
|
|
417
442
|
// which owns scheduling. Only `schedule` is read today.
|
|
418
443
|
materialization: parsePackageMaterialization(parsed.materialization),
|
|
444
|
+
// Package-level persist scope mode; defaults to "package". An invalid
|
|
445
|
+
// value throws here and fails the load (scope is load-bearing).
|
|
446
|
+
scope: parsePackageScope(parsed.scope),
|
|
419
447
|
};
|
|
420
448
|
}
|
|
421
449
|
|
|
@@ -849,18 +877,41 @@ async function loadPackage(
|
|
|
849
877
|
const allFiles = await listPackageFiles(job.packagePath);
|
|
850
878
|
const modelPaths = filterModelPaths(allFiles);
|
|
851
879
|
|
|
880
|
+
// Bracket the compile region: only work from here on is compilation +
|
|
881
|
+
// proxied schema fetches. The setup above (manifest read + file listing)
|
|
882
|
+
// is excluded so it can't inflate the compile figure — it stays in the
|
|
883
|
+
// derivable remainder (loadDuration - compile - schemaFetch). Begin the
|
|
884
|
+
// schema-fetch accounting HERE, at the region boundary, not at load start:
|
|
885
|
+
// that keeps `compileDurationMs = compileRegion - schemaFetchWait` exact
|
|
886
|
+
// regardless of whether any fetch ever happens during setup (none do
|
|
887
|
+
// today, but this stops that assumption from silently mattering).
|
|
888
|
+
const compileRegionStart = performance.now();
|
|
889
|
+
schemaWait.begin(job.requestId);
|
|
852
890
|
const models = await Promise.all(
|
|
853
891
|
modelPaths.map((modelPath) =>
|
|
854
892
|
compileOneModel(job, malloyConfig, modelPath),
|
|
855
893
|
),
|
|
856
894
|
);
|
|
857
895
|
|
|
896
|
+
const loadEnd = performance.now();
|
|
897
|
+
const schemaFetchDurationMs = schemaWait.waitMs;
|
|
858
898
|
return {
|
|
859
899
|
type: "load-package-result",
|
|
860
900
|
requestId: job.requestId,
|
|
861
901
|
packageMetadata,
|
|
862
902
|
models,
|
|
863
|
-
loadDurationMs:
|
|
903
|
+
loadDurationMs: loadEnd - loadStart,
|
|
904
|
+
timings: {
|
|
905
|
+
// Compile-region wall minus the schema-fetch wait it contains — a
|
|
906
|
+
// conservative ceiling on compile CPU (clamped: rounding can nudge
|
|
907
|
+
// it slightly negative).
|
|
908
|
+
compileDurationMs: Math.max(
|
|
909
|
+
0,
|
|
910
|
+
loadEnd - compileRegionStart - schemaFetchDurationMs,
|
|
911
|
+
),
|
|
912
|
+
schemaFetchDurationMs,
|
|
913
|
+
schemaFetchCount: schemaWait.fetches,
|
|
914
|
+
},
|
|
864
915
|
};
|
|
865
916
|
}
|
|
866
917
|
|
|
@@ -65,7 +65,10 @@
|
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
67
|
import type { SQLSourceDef, TableSourceDef } from "@malloydata/malloy";
|
|
68
|
-
import type {
|
|
68
|
+
import type {
|
|
69
|
+
PackageMaterializationConfig,
|
|
70
|
+
PackageScope,
|
|
71
|
+
} from "../service/package_manifest";
|
|
69
72
|
|
|
70
73
|
// ──────────────────────────────────────────────────────────────────────
|
|
71
74
|
// Direction: main ──▶ worker (load-package job)
|
|
@@ -181,10 +184,27 @@ export interface LoadPackageResult {
|
|
|
181
184
|
queryableSources?: "declared" | "all";
|
|
182
185
|
manifestLocation?: string | null;
|
|
183
186
|
materialization?: PackageMaterializationConfig | null;
|
|
187
|
+
scope?: PackageScope;
|
|
184
188
|
};
|
|
185
189
|
models: SerializedModel[];
|
|
186
190
|
/** Wall-clock ms inside the worker for the full package load. */
|
|
187
191
|
loadDurationMs: number;
|
|
192
|
+
/**
|
|
193
|
+
* Per-load timing breakdown (subset of {@link loadDurationMs}; the
|
|
194
|
+
* remainder is worker setup + post-compile extraction). See
|
|
195
|
+
* `rpc_wait_accountant.ts` for how compile time is separated from I/O.
|
|
196
|
+
*/
|
|
197
|
+
timings: {
|
|
198
|
+
/**
|
|
199
|
+
* Compile-region wall-clock minus time awaiting proxied schema fetches —
|
|
200
|
+
* a conservative ceiling on Malloy compile CPU.
|
|
201
|
+
*/
|
|
202
|
+
compileDurationMs: number;
|
|
203
|
+
/** Wall-clock awaiting proxied connection schema fetches (union). */
|
|
204
|
+
schemaFetchDurationMs: number;
|
|
205
|
+
/** Number of proxied connection schema fetches the load drove. */
|
|
206
|
+
schemaFetchCount: number;
|
|
207
|
+
};
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
export interface LoadPackageError {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { RpcWaitAccountant } from "./rpc_wait_accountant";
|
|
4
|
+
|
|
5
|
+
/** Accountant driven by a settable fake clock. */
|
|
6
|
+
function withClock(): { acc: RpcWaitAccountant; at: (ms: number) => void } {
|
|
7
|
+
let t = 0;
|
|
8
|
+
const acc = new RpcWaitAccountant(() => t);
|
|
9
|
+
return { acc, at: (ms) => (t = ms) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("RpcWaitAccountant", () => {
|
|
13
|
+
it("brackets a single fetch's wait", () => {
|
|
14
|
+
const { acc, at } = withClock();
|
|
15
|
+
at(0);
|
|
16
|
+
acc.begin("A");
|
|
17
|
+
at(10);
|
|
18
|
+
acc.noteStart("A");
|
|
19
|
+
at(60);
|
|
20
|
+
acc.noteSettle("A");
|
|
21
|
+
expect(acc.waitMs).toBe(50);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("counts overlapping fetches as their union, not the sum", () => {
|
|
25
|
+
const { acc, at } = withClock();
|
|
26
|
+
at(0);
|
|
27
|
+
acc.begin("A");
|
|
28
|
+
at(0);
|
|
29
|
+
acc.noteStart("A"); // window opens
|
|
30
|
+
at(10);
|
|
31
|
+
acc.noteStart("A"); // second fetch, window stays open
|
|
32
|
+
at(40);
|
|
33
|
+
acc.noteSettle("A"); // one still outstanding
|
|
34
|
+
at(60);
|
|
35
|
+
acc.noteSettle("A"); // window closes
|
|
36
|
+
expect(acc.waitMs).toBe(60); // union [0,60], not 30+50
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accumulates disjoint fetch windows", () => {
|
|
40
|
+
const { acc, at } = withClock();
|
|
41
|
+
at(0);
|
|
42
|
+
acc.begin("A");
|
|
43
|
+
at(0);
|
|
44
|
+
acc.noteStart("A");
|
|
45
|
+
at(20);
|
|
46
|
+
acc.noteSettle("A");
|
|
47
|
+
at(50);
|
|
48
|
+
acc.noteStart("A");
|
|
49
|
+
at(70);
|
|
50
|
+
acc.noteSettle("A");
|
|
51
|
+
expect(acc.waitMs).toBe(40);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("ignores a straggler fetch from a prior reused-worker load", () => {
|
|
55
|
+
const { acc, at } = withClock();
|
|
56
|
+
at(0);
|
|
57
|
+
acc.begin("A");
|
|
58
|
+
at(0);
|
|
59
|
+
acc.noteStart("A"); // load A opens a fetch and never settles it (failed)
|
|
60
|
+
|
|
61
|
+
at(100);
|
|
62
|
+
acc.begin("B"); // worker reused for load B
|
|
63
|
+
at(110);
|
|
64
|
+
acc.noteStart("B");
|
|
65
|
+
at(130);
|
|
66
|
+
acc.noteSettle("B");
|
|
67
|
+
|
|
68
|
+
at(140);
|
|
69
|
+
acc.noteSettle("A"); // A's late response — must be a no-op
|
|
70
|
+
|
|
71
|
+
expect(acc.waitMs).toBe(20); // only B's [110,130]
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("ignores a stray start from a non-active load", () => {
|
|
75
|
+
const { acc, at } = withClock();
|
|
76
|
+
at(0);
|
|
77
|
+
acc.begin("B");
|
|
78
|
+
at(10);
|
|
79
|
+
acc.noteStart("A"); // wrong load
|
|
80
|
+
at(50);
|
|
81
|
+
acc.noteSettle("A");
|
|
82
|
+
expect(acc.waitMs).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("counts active-load fetches only", () => {
|
|
86
|
+
const { acc, at } = withClock();
|
|
87
|
+
at(0);
|
|
88
|
+
acc.begin("B");
|
|
89
|
+
acc.noteStart("B");
|
|
90
|
+
acc.noteSettle("B");
|
|
91
|
+
acc.noteStart("B");
|
|
92
|
+
acc.noteStart("A"); // straggler — not counted
|
|
93
|
+
expect(acc.fetches).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("begin() discards prior-load state", () => {
|
|
97
|
+
const { acc, at } = withClock();
|
|
98
|
+
at(0);
|
|
99
|
+
acc.begin("A");
|
|
100
|
+
at(0);
|
|
101
|
+
acc.noteStart("A");
|
|
102
|
+
at(50);
|
|
103
|
+
acc.noteSettle("A");
|
|
104
|
+
expect(acc.waitMs).toBe(50);
|
|
105
|
+
at(60);
|
|
106
|
+
acc.begin("B");
|
|
107
|
+
expect(acc.waitMs).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-load "wait" accounting for the package-load worker.
|
|
3
|
+
*
|
|
4
|
+
* A package load spends its time in two very different ways: CPU-bound Malloy
|
|
5
|
+
* compilation, and I/O waiting on connection schema fetches that are proxied
|
|
6
|
+
* back to the main thread. Today only the *total* load time is observable, so
|
|
7
|
+
* an operator seeing slow loads can't tell which half dominates. This tracks
|
|
8
|
+
* the wall-clock during which at least one proxied schema fetch is outstanding,
|
|
9
|
+
* so the load handler can report `compile = compileRegion - schemaFetchWait`
|
|
10
|
+
* alongside the fetch time itself.
|
|
11
|
+
*
|
|
12
|
+
* Why wall-minus-wait and not a CPU counter: worker threads share the host
|
|
13
|
+
* process, so `process.cpuUsage()` is process-wide and would fold in every
|
|
14
|
+
* other worker's CPU — useless for a per-load number. Wall-minus-wait is the
|
|
15
|
+
* honest per-load proxy. Treat the compute figure as a conservative *ceiling*
|
|
16
|
+
* on compile CPU: only proxied waits are subtracted, so any non-fetch stall
|
|
17
|
+
* (GC, scheduler preemption) also lands in "compute".
|
|
18
|
+
*
|
|
19
|
+
* Overlapping fetches are counted as their union (the wall-time with >=1
|
|
20
|
+
* outstanding), never the sum, so concurrent fetches during one compile don't
|
|
21
|
+
* over-subtract.
|
|
22
|
+
*
|
|
23
|
+
* Scoped by `jobId`: the pool runs one load per worker at a time, but reuses a
|
|
24
|
+
* worker across loads, and a load that fails can leave a proxied fetch in
|
|
25
|
+
* flight. Gating every start/settle on the active job makes such a straggler's
|
|
26
|
+
* late response a no-op instead of corrupting the next load's numbers. (If the
|
|
27
|
+
* one-load-per-worker rule ever changes, this stays correct per job but the
|
|
28
|
+
* compute ceiling would then also fold in a sibling load's CPU.)
|
|
29
|
+
*/
|
|
30
|
+
export class RpcWaitAccountant {
|
|
31
|
+
private inFlight = 0;
|
|
32
|
+
private waitStartMs = 0;
|
|
33
|
+
private accumMs = 0;
|
|
34
|
+
private startedCount = 0;
|
|
35
|
+
private activeJobId: string | undefined;
|
|
36
|
+
|
|
37
|
+
constructor(private readonly now: () => number = () => performance.now()) {}
|
|
38
|
+
|
|
39
|
+
/** Begin accounting for `jobId`, discarding any prior-load state. */
|
|
40
|
+
begin(jobId: string): void {
|
|
41
|
+
this.activeJobId = jobId;
|
|
42
|
+
this.inFlight = 0;
|
|
43
|
+
this.waitStartMs = 0;
|
|
44
|
+
this.accumMs = 0;
|
|
45
|
+
this.startedCount = 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A proxied fetch for `jobId` was issued. Ignored if `jobId` isn't active. */
|
|
49
|
+
noteStart(jobId: string): void {
|
|
50
|
+
if (jobId !== this.activeJobId) return;
|
|
51
|
+
if (this.inFlight === 0) this.waitStartMs = this.now();
|
|
52
|
+
this.inFlight += 1;
|
|
53
|
+
this.startedCount += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A proxied fetch for `jobId` settled (resolved or rejected). Ignored if
|
|
58
|
+
* `jobId` isn't active, so a straggler from a prior reused-worker load
|
|
59
|
+
* can't drive the counter negative or close the active load's bracket.
|
|
60
|
+
*/
|
|
61
|
+
noteSettle(jobId: string): void {
|
|
62
|
+
if (jobId !== this.activeJobId) return;
|
|
63
|
+
this.inFlight -= 1;
|
|
64
|
+
if (this.inFlight === 0) this.accumMs += this.now() - this.waitStartMs;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Accumulated wall-time with >=1 active-load fetch outstanding (union). */
|
|
68
|
+
get waitMs(): number {
|
|
69
|
+
return this.accumMs;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Number of proxied fetches started for the active load. */
|
|
73
|
+
get fetches(): number {
|
|
74
|
+
return this.startedCount;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
recordPackageLoadPhases,
|
|
5
|
+
resetPackageLoadMetricsForTesting,
|
|
6
|
+
} from "./package_load_metrics";
|
|
7
|
+
import {
|
|
8
|
+
startMetricsHarness,
|
|
9
|
+
type MetricsHarness,
|
|
10
|
+
} from "./test_helpers/metrics_harness";
|
|
11
|
+
|
|
12
|
+
describe("package_load_metrics", () => {
|
|
13
|
+
let harness: MetricsHarness;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
harness = await startMetricsHarness();
|
|
17
|
+
resetPackageLoadMetricsForTesting();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
resetPackageLoadMetricsForTesting();
|
|
22
|
+
await harness.shutdown();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("records the three phase histograms, labeled by status", async () => {
|
|
26
|
+
recordPackageLoadPhases(
|
|
27
|
+
{
|
|
28
|
+
compileDurationMs: 120,
|
|
29
|
+
schemaFetchDurationMs: 80,
|
|
30
|
+
schemaFetchCount: 4,
|
|
31
|
+
},
|
|
32
|
+
"success",
|
|
33
|
+
);
|
|
34
|
+
recordPackageLoadPhases(
|
|
35
|
+
{
|
|
36
|
+
compileDurationMs: 40,
|
|
37
|
+
schemaFetchDurationMs: 10,
|
|
38
|
+
schemaFetchCount: 1,
|
|
39
|
+
},
|
|
40
|
+
"success",
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const compile = await harness.collectHistogram(
|
|
44
|
+
"malloy_package_load_compile_duration",
|
|
45
|
+
{ status: "success" },
|
|
46
|
+
);
|
|
47
|
+
expect(compile.count).toBe(2);
|
|
48
|
+
expect(compile.sum).toBe(160);
|
|
49
|
+
|
|
50
|
+
const fetchDuration = await harness.collectHistogram(
|
|
51
|
+
"malloy_package_load_schema_fetch_duration",
|
|
52
|
+
{ status: "success" },
|
|
53
|
+
);
|
|
54
|
+
expect(fetchDuration.count).toBe(2);
|
|
55
|
+
expect(fetchDuration.sum).toBe(90);
|
|
56
|
+
|
|
57
|
+
const fetches = await harness.collectHistogram(
|
|
58
|
+
"malloy_package_load_schema_fetches",
|
|
59
|
+
{ status: "success" },
|
|
60
|
+
);
|
|
61
|
+
expect(fetches.count).toBe(2);
|
|
62
|
+
expect(fetches.sum).toBe(5);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("labels phases by terminal status, so failures are separable from successes", async () => {
|
|
66
|
+
recordPackageLoadPhases(
|
|
67
|
+
{
|
|
68
|
+
compileDurationMs: 30,
|
|
69
|
+
schemaFetchDurationMs: 5,
|
|
70
|
+
schemaFetchCount: 1,
|
|
71
|
+
},
|
|
72
|
+
"success",
|
|
73
|
+
);
|
|
74
|
+
recordPackageLoadPhases(
|
|
75
|
+
{
|
|
76
|
+
compileDurationMs: 12,
|
|
77
|
+
schemaFetchDurationMs: 3,
|
|
78
|
+
schemaFetchCount: 1,
|
|
79
|
+
},
|
|
80
|
+
"compilation_error",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const ok = await harness.collectHistogram(
|
|
84
|
+
"malloy_package_load_compile_duration",
|
|
85
|
+
{ status: "success" },
|
|
86
|
+
);
|
|
87
|
+
const failed = await harness.collectHistogram(
|
|
88
|
+
"malloy_package_load_compile_duration",
|
|
89
|
+
{ status: "compilation_error" },
|
|
90
|
+
);
|
|
91
|
+
expect(ok.count).toBe(1);
|
|
92
|
+
expect(ok.sum).toBe(30);
|
|
93
|
+
expect(failed.count).toBe(1);
|
|
94
|
+
expect(failed.sum).toBe(12);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("resolves the slow-load tail past OTel's default 10s cap", async () => {
|
|
98
|
+
// A 90s load would fall in the +Inf bucket under OTel's default
|
|
99
|
+
// boundaries (top = 10000ms); the explicit buckets must extend further.
|
|
100
|
+
recordPackageLoadPhases(
|
|
101
|
+
{
|
|
102
|
+
compileDurationMs: 90_000,
|
|
103
|
+
schemaFetchDurationMs: 0,
|
|
104
|
+
schemaFetchCount: 0,
|
|
105
|
+
},
|
|
106
|
+
"success",
|
|
107
|
+
);
|
|
108
|
+
const compile = await harness.collectHistogram(
|
|
109
|
+
"malloy_package_load_compile_duration",
|
|
110
|
+
);
|
|
111
|
+
expect(compile.boundaries.some((b) => b > 10_000)).toBe(true);
|
|
112
|
+
expect(compile.boundaries[compile.boundaries.length - 1]).toBe(300_000);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-load phase timing for package loads.
|
|
3
|
+
*
|
|
4
|
+
* `malloy_package_load_duration` already reports the *total* time to load a
|
|
5
|
+
* package, but an operator watching a slow load can't tell whether it's the
|
|
6
|
+
* Malloy compile or the connection schema fetches that dominate — the two are
|
|
7
|
+
* tuned very differently (CPU/worker sizing vs. warehouse round-trips). These
|
|
8
|
+
* histograms split the load into those phases so a single dashboard answers
|
|
9
|
+
* "where did the time go?". The worker measures the phases and reports them on
|
|
10
|
+
* its result; see `package_load/package_load_worker.ts` and
|
|
11
|
+
* `package_load/rpc_wait_accountant.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Labels: `status` only. Deliberately NOT labelled by package name — unlike
|
|
14
|
+
* `malloy_package_load_duration`, which carries `malloy_package_name` — to keep
|
|
15
|
+
* cardinality bounded on deployments with many packages. Per-package latency,
|
|
16
|
+
* when needed, is better answered by exemplars/traces than a label explosion.
|
|
17
|
+
*
|
|
18
|
+
* Lazy init for the same reason as `query_cap_metrics.ts` / `query_timeout.ts`:
|
|
19
|
+
* instruments created before `setGlobalMeterProvider` bind to a NoOp meter
|
|
20
|
+
* (https://github.com/open-telemetry/opentelemetry-js/issues/3505).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { type Histogram } from "@opentelemetry/api";
|
|
24
|
+
import { publisherMeter } from "./telemetry";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Explicit bucket boundaries (ms) for the load-duration histograms. OTel's
|
|
28
|
+
* default boundaries top out at 10 000 ms, so any load slower than 10 s falls
|
|
29
|
+
* in the `+Inf` bucket and `histogram_quantile` can't resolve the tail — which
|
|
30
|
+
* is exactly the tail (large/slow packages) worth seeing. These extend to
|
|
31
|
+
* 5 minutes while keeping sub-10 ms resolution for the fast common case.
|
|
32
|
+
* Shared with the total `malloy_package_load_duration` histogram so all
|
|
33
|
+
* load-timing histograms bucket consistently.
|
|
34
|
+
*/
|
|
35
|
+
export const LOAD_DURATION_BUCKETS_MS = [
|
|
36
|
+
1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10_000, 30_000, 60_000,
|
|
37
|
+
120_000, 300_000,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Bucket boundaries for the per-load schema-fetch count. */
|
|
41
|
+
const SCHEMA_FETCH_COUNT_BUCKETS = [0, 1, 2, 5, 10, 25, 50, 100, 250, 500];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Terminal status of a package load, mirroring the `status` label on the
|
|
45
|
+
* existing `malloy_package_load_duration` histogram so the phase metrics slice
|
|
46
|
+
* the same way. In practice the phase histograms only ever carry `success`,
|
|
47
|
+
* `compilation_error`, or `error` — a `pool_unavailable` failure happens before
|
|
48
|
+
* the worker returns any timings, so there is nothing to record for it.
|
|
49
|
+
*/
|
|
50
|
+
export type PackageLoadStatus =
|
|
51
|
+
| "success"
|
|
52
|
+
| "compilation_error"
|
|
53
|
+
| "pool_unavailable"
|
|
54
|
+
| "error";
|
|
55
|
+
|
|
56
|
+
/** Phase timings a single package load reports (subset of the total). */
|
|
57
|
+
export interface PackageLoadPhaseTimings {
|
|
58
|
+
/** Ceiling on Malloy compile CPU (compile region minus schema-fetch wait). */
|
|
59
|
+
compileDurationMs: number;
|
|
60
|
+
/** Wall-clock awaiting proxied connection schema fetches. */
|
|
61
|
+
schemaFetchDurationMs: number;
|
|
62
|
+
/** Number of proxied connection schema fetches the load drove. */
|
|
63
|
+
schemaFetchCount: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let compileDuration: Histogram | null = null;
|
|
67
|
+
let schemaFetchDuration: Histogram | null = null;
|
|
68
|
+
let schemaFetches: Histogram | null = null;
|
|
69
|
+
|
|
70
|
+
function ensureInstruments(): void {
|
|
71
|
+
if (compileDuration && schemaFetchDuration && schemaFetches) return;
|
|
72
|
+
const meter = publisherMeter();
|
|
73
|
+
compileDuration ??= meter.createHistogram(
|
|
74
|
+
"malloy_package_load_compile_duration",
|
|
75
|
+
{
|
|
76
|
+
description:
|
|
77
|
+
"Per-load Malloy compile time (compile region minus proxied schema-fetch wait; a ceiling on compile CPU). Label: status.",
|
|
78
|
+
unit: "ms",
|
|
79
|
+
advice: { explicitBucketBoundaries: LOAD_DURATION_BUCKETS_MS },
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
schemaFetchDuration ??= meter.createHistogram(
|
|
83
|
+
"malloy_package_load_schema_fetch_duration",
|
|
84
|
+
{
|
|
85
|
+
description:
|
|
86
|
+
"Per-load wall-clock awaiting proxied connection schema fetches. Label: status.",
|
|
87
|
+
unit: "ms",
|
|
88
|
+
advice: { explicitBucketBoundaries: LOAD_DURATION_BUCKETS_MS },
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
schemaFetches ??= meter.createHistogram(
|
|
92
|
+
"malloy_package_load_schema_fetches",
|
|
93
|
+
{
|
|
94
|
+
description:
|
|
95
|
+
"Per-load count of proxied connection schema fetches. Label: status.",
|
|
96
|
+
advice: { explicitBucketBoundaries: SCHEMA_FETCH_COUNT_BUCKETS },
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Record one load's phase breakdown. Call once the worker has returned timings,
|
|
103
|
+
* with the load's terminal `status` (so the phases can be sliced by outcome the
|
|
104
|
+
* same way the total duration is). The terms are meaningless for a load that
|
|
105
|
+
* never produced a worker result (e.g. a pool failure), so don't call it there.
|
|
106
|
+
*/
|
|
107
|
+
export function recordPackageLoadPhases(
|
|
108
|
+
timings: PackageLoadPhaseTimings,
|
|
109
|
+
status: PackageLoadStatus,
|
|
110
|
+
): void {
|
|
111
|
+
ensureInstruments();
|
|
112
|
+
const attrs = { status };
|
|
113
|
+
compileDuration?.record(timings.compileDurationMs, attrs);
|
|
114
|
+
schemaFetchDuration?.record(timings.schemaFetchDurationMs, attrs);
|
|
115
|
+
schemaFetches?.record(timings.schemaFetchCount, attrs);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Visible for tests. Drops cached instruments so a fresh `MeterProvider`
|
|
120
|
+
* (installed via `startMetricsHarness`) captures future emissions. Do NOT call
|
|
121
|
+
* from production code.
|
|
122
|
+
*/
|
|
123
|
+
export function resetPackageLoadMetricsForTesting(): void {
|
|
124
|
+
compileDuration = null;
|
|
125
|
+
schemaFetchDuration = null;
|
|
126
|
+
schemaFetches = null;
|
|
127
|
+
}
|