@malloy-publisher/server 0.0.226 → 0.0.228
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 +8 -8
- package/README.md +2 -11
- package/dist/app/assets/{EnvironmentPage-DvOJ7L_b.js → EnvironmentPage-EW2lbGvb.js} +1 -1
- package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-Bkwc9Woc.js} +1 -1
- package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-Bum_KBpN.js} +1 -1
- package/dist/app/assets/{MainPage-BIe0VwBa.js → MainPage-oiEy7TNM.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-BuZ6UJVx.js → MaterializationsPage-C_VJsTgU.js} +1 -1
- package/dist/app/assets/{ModelPage-DsPf-s8B.js → ModelPage-z8REqAmk.js} +1 -1
- package/dist/app/assets/{PackagePage-CEVNAKZa.js → PackagePage-C2Vtt1Ln.js} +1 -1
- package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-DmJLpLXm.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DWC_FdNU.js → ThemeEditorPage-BywFjC7A.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CGrsFz8p.js → WorkbookPage-DCMizDMR.js} +1 -1
- package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-CEDZMHV1.es-_yGzNgNH.js} +1 -1
- package/dist/app/assets/{index-D6YtyiJ0.js → index-CE9xhdra.js} +1 -1
- package/dist/app/assets/{index-BioohWQj.js → index-CdmFub34.js} +1 -1
- package/dist/app/assets/{index-gEWxu09x.js → index-DDMrjIT3.js} +1 -1
- package/dist/app/assets/{index-DNUZpnaa.js → index-EqslXZ44.js} +4 -4
- package/dist/app/index.html +1 -1
- package/dist/default-publisher.config.json +7 -7
- package/dist/package_load_worker.mjs +86 -24
- package/dist/runtime/publisher.js +5 -0
- package/dist/server.mjs +1415 -7876
- package/package.json +1 -4
- package/publisher.config.example.bigquery.json +7 -7
- package/publisher.config.example.duckdb.json +7 -7
- package/publisher.config.json +7 -11
- package/src/config.spec.ts +2 -2
- package/src/controller/package.controller.ts +62 -31
- package/src/default-publisher.config.json +7 -7
- package/src/health.ts +3 -8
- package/src/mcp/error_messages.ts +8 -37
- package/src/mcp/handler_utils.spec.ts +108 -0
- package/src/mcp/handler_utils.ts +99 -124
- package/src/mcp/mcp_constants.ts +0 -16
- package/src/mcp/server.protocol.spec.ts +138 -0
- package/src/mcp/server.ts +57 -37
- package/src/mcp/skills/build_skills_bundle.ts +1 -1
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/compile_tool.spec.ts +207 -0
- package/src/mcp/tools/compile_tool.ts +177 -0
- package/src/mcp/tools/docs_search_tool.ts +1 -1
- package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
- package/src/mcp/tools/execute_query_tool.ts +39 -6
- 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/mcp/tools/reload_package_tool.spec.ts +232 -0
- package/src/mcp/tools/reload_package_tool.ts +158 -0
- package/src/package_load/package_load_pool.ts +3 -0
- package/src/package_load/package_load_worker.ts +68 -24
- package/src/package_load/protocol.ts +16 -0
- 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/runtime/publisher.js +5 -0
- package/src/server.ts +7 -21
- package/src/service/environment.ts +71 -7
- package/src/service/model.spec.ts +92 -0
- package/src/service/model.ts +58 -7
- package/src/service/package.ts +113 -55
- package/src/service/package_reload_safety.spec.ts +193 -0
- package/src/test_helpers/metrics_harness.ts +40 -0
- package/tests/fixtures/query-givens/data/orders.csv +7 -0
- package/tests/fixtures/query-givens/model.malloy +34 -0
- package/tests/fixtures/query-givens/publisher.json +5 -0
- package/tests/harness/mcp_test_setup.ts +1 -1
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +7 -31
- package/tests/integration/query_givens/query_givens.integration.spec.ts +146 -0
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +121 -0
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +110 -0
- 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.protocol.spec.ts +0 -78
- 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/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
|
@@ -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
|
+
}
|
package/src/pg_helpers.spec.ts
CHANGED
|
@@ -1,105 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
classifyPgError,
|
|
5
|
-
handlePgAttachError,
|
|
6
|
-
pgConnectTimeoutSeconds,
|
|
7
|
-
redactPgSecrets,
|
|
8
|
-
withPgConnectTimeout,
|
|
9
|
-
} from "./pg_helpers";
|
|
10
|
-
|
|
11
|
-
describe("pgConnectTimeoutSeconds", () => {
|
|
12
|
-
const ORIGINAL_TIMEOUT = process.env.PG_CONNECT_TIMEOUT_SECONDS;
|
|
13
|
-
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
if (ORIGINAL_TIMEOUT === undefined) {
|
|
16
|
-
delete process.env.PG_CONNECT_TIMEOUT_SECONDS;
|
|
17
|
-
} else {
|
|
18
|
-
process.env.PG_CONNECT_TIMEOUT_SECONDS = ORIGINAL_TIMEOUT;
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("defaults to 5 when env unset", () => {
|
|
23
|
-
delete process.env.PG_CONNECT_TIMEOUT_SECONDS;
|
|
24
|
-
expect(pgConnectTimeoutSeconds()).toBe(5);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("honors PG_CONNECT_TIMEOUT_SECONDS override", () => {
|
|
28
|
-
process.env.PG_CONNECT_TIMEOUT_SECONDS = "12";
|
|
29
|
-
expect(pgConnectTimeoutSeconds()).toBe(12);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("falls back to 5 when env value is invalid", () => {
|
|
33
|
-
process.env.PG_CONNECT_TIMEOUT_SECONDS = "not-a-number";
|
|
34
|
-
expect(pgConnectTimeoutSeconds()).toBe(5);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("falls back to 5 when env value is zero or negative", () => {
|
|
38
|
-
process.env.PG_CONNECT_TIMEOUT_SECONDS = "0";
|
|
39
|
-
expect(pgConnectTimeoutSeconds()).toBe(5);
|
|
40
|
-
process.env.PG_CONNECT_TIMEOUT_SECONDS = "-3";
|
|
41
|
-
expect(pgConnectTimeoutSeconds()).toBe(5);
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe("withPgConnectTimeout", () => {
|
|
46
|
-
it("appends to keyword form when missing", () => {
|
|
47
|
-
expect(withPgConnectTimeout("host=h dbname=d user=u password=p", 5)).toBe(
|
|
48
|
-
"host=h dbname=d user=u password=p connect_timeout=5",
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("appends to postgres: keyword form (DuckLake catalogUrl shape)", () => {
|
|
53
|
-
expect(
|
|
54
|
-
withPgConnectTimeout("postgres:host=h user=u password=p dbname=d", 5),
|
|
55
|
-
).toBe("postgres:host=h user=u password=p dbname=d connect_timeout=5");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("does not override a user-supplied connect_timeout in keyword form", () => {
|
|
59
|
-
expect(withPgConnectTimeout("host=h connect_timeout=30", 99)).toBe(
|
|
60
|
-
"host=h connect_timeout=30",
|
|
61
|
-
);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("appends to URI form with no query", () => {
|
|
65
|
-
expect(withPgConnectTimeout("postgresql://u:p@h:5432/d", 5)).toBe(
|
|
66
|
-
"postgresql://u:p@h:5432/d?connect_timeout=5",
|
|
67
|
-
);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("appends to URI form with existing query", () => {
|
|
71
|
-
expect(
|
|
72
|
-
withPgConnectTimeout("postgresql://u:p@h/d?sslmode=require", 5),
|
|
73
|
-
).toBe("postgresql://u:p@h/d?sslmode=require&connect_timeout=5");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("appends to URI with bare trailing ?", () => {
|
|
77
|
-
expect(withPgConnectTimeout("postgresql://h/d?", 5)).toBe(
|
|
78
|
-
"postgresql://h/d?connect_timeout=5",
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("does not double-append when URI already has connect_timeout (?-style)", () => {
|
|
83
|
-
expect(
|
|
84
|
-
withPgConnectTimeout("postgresql://h/d?connect_timeout=10", 5),
|
|
85
|
-
).toBe("postgresql://h/d?connect_timeout=10");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("does not double-append when URI already has connect_timeout (&-style)", () => {
|
|
89
|
-
expect(
|
|
90
|
-
withPgConnectTimeout(
|
|
91
|
-
"postgresql://h/d?sslmode=require&connect_timeout=10",
|
|
92
|
-
5,
|
|
93
|
-
),
|
|
94
|
-
).toBe("postgresql://h/d?sslmode=require&connect_timeout=10");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("recognizes postgres:// (alternative scheme) as URI form", () => {
|
|
98
|
-
expect(withPgConnectTimeout("postgres://u@h/d", 5)).toBe(
|
|
99
|
-
"postgres://u@h/d?connect_timeout=5",
|
|
100
|
-
);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { redactPgSecrets } from "./pg_helpers";
|
|
103
3
|
|
|
104
4
|
describe("redactPgSecrets", () => {
|
|
105
5
|
it("redacts bare password values", () => {
|
|
@@ -120,107 +20,3 @@ describe("redactPgSecrets", () => {
|
|
|
120
20
|
);
|
|
121
21
|
});
|
|
122
22
|
});
|
|
123
|
-
|
|
124
|
-
describe("classifyPgError", () => {
|
|
125
|
-
it.each([
|
|
126
|
-
'password authentication failed for user "alice"',
|
|
127
|
-
"no pg_hba.conf entry for host",
|
|
128
|
-
'role "alice" does not exist',
|
|
129
|
-
'database "billing" does not exist',
|
|
130
|
-
"permission denied for relation foo",
|
|
131
|
-
])("classifies '%s' as auth error", (msg) => {
|
|
132
|
-
const result = classifyPgError(new Error(msg), "PG attach");
|
|
133
|
-
expect(result).toBeInstanceOf(ConnectionAuthError);
|
|
134
|
-
expect(result?.message).toContain("PG attach:");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("returns undefined for unrelated errors", () => {
|
|
138
|
-
expect(
|
|
139
|
-
classifyPgError(
|
|
140
|
-
new Error('relation "users" does not exist'),
|
|
141
|
-
"PG attach",
|
|
142
|
-
),
|
|
143
|
-
).toBeUndefined();
|
|
144
|
-
expect(
|
|
145
|
-
classifyPgError(new Error("connection reset by peer"), "PG attach"),
|
|
146
|
-
).toBeUndefined();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("returns undefined for non-Error values", () => {
|
|
150
|
-
expect(
|
|
151
|
-
classifyPgError("password authentication failed", "ctx"),
|
|
152
|
-
).toBeUndefined();
|
|
153
|
-
expect(classifyPgError(undefined, "ctx")).toBeUndefined();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("redacts embedded passwords in the wrapped message", () => {
|
|
157
|
-
const result = classifyPgError(
|
|
158
|
-
new Error(
|
|
159
|
-
"password authentication failed: tried host=h password=hunter2",
|
|
160
|
-
),
|
|
161
|
-
"DuckLake attach",
|
|
162
|
-
);
|
|
163
|
-
expect(result?.message).toContain("password=***");
|
|
164
|
-
expect(result?.message).not.toContain("hunter2");
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
describe("handlePgAttachError", () => {
|
|
169
|
-
it("swallows 'already exists' errors", () => {
|
|
170
|
-
const outcome = handlePgAttachError(
|
|
171
|
-
new Error('database "db_x" already exists'),
|
|
172
|
-
"ctx",
|
|
173
|
-
);
|
|
174
|
-
expect(outcome.action).toBe("swallow");
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it("swallows 'already attached' errors", () => {
|
|
178
|
-
const outcome = handlePgAttachError(
|
|
179
|
-
new Error("DuckLake catalog db_x is already attached"),
|
|
180
|
-
"ctx",
|
|
181
|
-
);
|
|
182
|
-
expect(outcome.action).toBe("swallow");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("classifies libpq auth failures as ConnectionAuthError", () => {
|
|
186
|
-
const outcome = handlePgAttachError(
|
|
187
|
-
new Error('password authentication failed for user "alice"'),
|
|
188
|
-
"PG attach db_x",
|
|
189
|
-
);
|
|
190
|
-
expect(outcome.action).toBe("throw");
|
|
191
|
-
if (outcome.action === "throw") {
|
|
192
|
-
expect(outcome.error).toBeInstanceOf(ConnectionAuthError);
|
|
193
|
-
expect(outcome.error.message).toContain("PG attach db_x:");
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("passes through unrelated Error instances unchanged", () => {
|
|
198
|
-
const original = new Error("network unreachable");
|
|
199
|
-
const outcome = handlePgAttachError(original, "ctx");
|
|
200
|
-
expect(outcome.action).toBe("throw");
|
|
201
|
-
if (outcome.action === "throw") {
|
|
202
|
-
expect(outcome.error).toBe(original);
|
|
203
|
-
expect(outcome.error).not.toBeInstanceOf(ConnectionAuthError);
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("wraps non-Error throwables so callers always get an Error", () => {
|
|
208
|
-
const outcome = handlePgAttachError("a string was thrown", "ctx");
|
|
209
|
-
expect(outcome.action).toBe("throw");
|
|
210
|
-
if (outcome.action === "throw") {
|
|
211
|
-
expect(outcome.error).toBeInstanceOf(Error);
|
|
212
|
-
expect(outcome.error.message).toBe("a string was thrown");
|
|
213
|
-
}
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("prefers 'already attached' over auth classification when both keywords appear", () => {
|
|
217
|
-
// Defensive: if a future DuckDB version emits a combined message,
|
|
218
|
-
// 'already attached' wins so we don't bubble up a false auth failure
|
|
219
|
-
// on what is actually a benign idempotent re-attach.
|
|
220
|
-
const outcome = handlePgAttachError(
|
|
221
|
-
new Error("already attached; permission denied tail"),
|
|
222
|
-
"ctx",
|
|
223
|
-
);
|
|
224
|
-
expect(outcome.action).toBe("swallow");
|
|
225
|
-
});
|
|
226
|
-
});
|
package/src/pg_helpers.ts
CHANGED
|
@@ -1,64 +1,7 @@
|
|
|
1
|
-
// Postgres / libpq
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// matters.
|
|
6
|
-
import { ConnectionAuthError } from "./errors";
|
|
7
|
-
|
|
8
|
-
// Default Postgres connect_timeout (seconds), used by the materialization
|
|
9
|
-
// storage catalog ATTACH so a slow or wedged libpq handshake fails the
|
|
10
|
-
// caller in seconds instead of stalling the worker until the K8s liveness
|
|
11
|
-
// probe trips.
|
|
12
|
-
//
|
|
13
|
-
// libpq enforces a documented minimum of 2 seconds — values below 2
|
|
14
|
-
// effectively round up to ~2s wall clock.
|
|
15
|
-
export function pgConnectTimeoutSeconds(): number {
|
|
16
|
-
const raw = process.env.PG_CONNECT_TIMEOUT_SECONDS;
|
|
17
|
-
if (!raw) return 5;
|
|
18
|
-
const parsed = Number.parseInt(raw, 10);
|
|
19
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// libpq accepts both keyword=value form ("host=h dbname=d") and URI form
|
|
23
|
-
// ("postgresql://u:p@h/d?param=v"). The materialization-storage catalogUrl
|
|
24
|
-
// can also arrive as `postgres:<keyword=value>` (no `//`). We detect URI
|
|
25
|
-
// form (with `//`) so we know whether to append a new parameter using
|
|
26
|
-
// `?`/`&` or a leading space.
|
|
27
|
-
const URI_FORM_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
28
|
-
|
|
29
|
-
// Match an existing connect_timeout key in either form. URI form uses
|
|
30
|
-
// `?key=` or `&key=`; keyword form uses whitespace separation or start-of-
|
|
31
|
-
// string. Without the `[?&]` alternatives a URI-form user-supplied timeout
|
|
32
|
-
// would be missed and we'd double-append, producing an invalid URL.
|
|
33
|
-
const HAS_CONNECT_TIMEOUT_RE = /[?&\s]connect_timeout=|^connect_timeout=/;
|
|
34
|
-
|
|
35
|
-
// Append `connect_timeout=N` to a libpq-compatible connection string if
|
|
36
|
-
// the caller hasn't already set one. Handles keyword form ("host=h ..."),
|
|
37
|
-
// URI form ("postgresql://..."), and the `postgres:host=h ...` keyword
|
|
38
|
-
// form with a scheme prefix used by DuckLake catalogUrls.
|
|
39
|
-
export function withPgConnectTimeout(
|
|
40
|
-
connectionString: string,
|
|
41
|
-
timeout: number,
|
|
42
|
-
): string {
|
|
43
|
-
if (HAS_CONNECT_TIMEOUT_RE.test(connectionString)) {
|
|
44
|
-
return connectionString;
|
|
45
|
-
}
|
|
46
|
-
if (URI_FORM_RE.test(connectionString)) {
|
|
47
|
-
// URI form: append as query parameter. `?` if no query string yet,
|
|
48
|
-
// `&` otherwise. A bare trailing `?` (empty query) gets no extra
|
|
49
|
-
// separator. We don't try to handle URL fragments — libpq URIs don't
|
|
50
|
-
// use them.
|
|
51
|
-
if (!connectionString.includes("?")) {
|
|
52
|
-
return `${connectionString}?connect_timeout=${timeout}`;
|
|
53
|
-
}
|
|
54
|
-
if (connectionString.endsWith("?")) {
|
|
55
|
-
return `${connectionString}connect_timeout=${timeout}`;
|
|
56
|
-
}
|
|
57
|
-
return `${connectionString}&connect_timeout=${timeout}`;
|
|
58
|
-
}
|
|
59
|
-
// Keyword=value form (with or without `postgres:` scheme prefix).
|
|
60
|
-
return `${connectionString} connect_timeout=${timeout}`;
|
|
61
|
-
}
|
|
1
|
+
// Postgres / libpq helper shared between `service/` (user-facing connections)
|
|
2
|
+
// and `storage/` (materialization-storage catalog). Lives at `src/` root so
|
|
3
|
+
// neither layer takes a dependency on the other — see CLAUDE.md's "Two
|
|
4
|
+
// parallel DuckLake/PG attach paths" note for why this matters.
|
|
62
5
|
|
|
63
6
|
// Redact libpq `password=...` values from a string before it goes into a
|
|
64
7
|
// log line or HTTP response body. Handles bare and quoted values.
|
|
@@ -68,62 +11,3 @@ export function withPgConnectTimeout(
|
|
|
68
11
|
export function redactPgSecrets(s: string): string {
|
|
69
12
|
return s.replace(/password=('[^']*'|"[^"]*"|\S+)/gi, "password=***");
|
|
70
13
|
}
|
|
71
|
-
|
|
72
|
-
// Substring-match libpq error patterns that indicate a non-retryable
|
|
73
|
-
// auth/permission failure. Returns a ConnectionAuthError when matched so
|
|
74
|
-
// callers can fast-fail with HTTP 422 (semantically "the supplied creds
|
|
75
|
-
// are bad; don't retry") instead of letting the raw error fall through to
|
|
76
|
-
// a generic 500 that retry loops treat as transient.
|
|
77
|
-
export function classifyPgError(
|
|
78
|
-
error: unknown,
|
|
79
|
-
context: string,
|
|
80
|
-
): ConnectionAuthError | undefined {
|
|
81
|
-
if (!(error instanceof Error)) return undefined;
|
|
82
|
-
const msg = error.message;
|
|
83
|
-
const patterns = [
|
|
84
|
-
/password authentication failed/i,
|
|
85
|
-
/pg_hba\.conf/i,
|
|
86
|
-
/role ".*" does not exist/i,
|
|
87
|
-
/database ".*" does not exist/i,
|
|
88
|
-
/permission denied/i,
|
|
89
|
-
];
|
|
90
|
-
if (!patterns.some((p) => p.test(msg))) return undefined;
|
|
91
|
-
return new ConnectionAuthError(`${context}: ${redactPgSecrets(msg)}`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Outcome of inspecting an error thrown by an `ATTACH` call:
|
|
95
|
-
// - `{ action: "swallow" }`: DuckDB reported the db is already attached
|
|
96
|
-
// (idempotent re-attach); caller should log and continue.
|
|
97
|
-
// - `{ action: "throw", error: ConnectionAuthError }`: classified as a
|
|
98
|
-
// non-retryable auth failure; caller should warn-log and throw it.
|
|
99
|
-
// - `{ action: "throw", error: <original> }`: unrecognized; caller
|
|
100
|
-
// should rethrow as-is to preserve the original cause for diagnosis.
|
|
101
|
-
//
|
|
102
|
-
// Extracted so the decision tree gets a direct unit test without needing
|
|
103
|
-
// to stub DuckDB or run a real ATTACH.
|
|
104
|
-
export type PgAttachErrorOutcome =
|
|
105
|
-
| { action: "swallow" }
|
|
106
|
-
| { action: "throw"; error: Error };
|
|
107
|
-
|
|
108
|
-
export function handlePgAttachError(
|
|
109
|
-
error: unknown,
|
|
110
|
-
context: string,
|
|
111
|
-
): PgAttachErrorOutcome {
|
|
112
|
-
if (
|
|
113
|
-
error instanceof Error &&
|
|
114
|
-
(error.message.includes("already exists") ||
|
|
115
|
-
error.message.includes("already attached"))
|
|
116
|
-
) {
|
|
117
|
-
return { action: "swallow" };
|
|
118
|
-
}
|
|
119
|
-
const authErr = classifyPgError(error, context);
|
|
120
|
-
if (authErr) {
|
|
121
|
-
return { action: "throw", error: authErr };
|
|
122
|
-
}
|
|
123
|
-
if (error instanceof Error) {
|
|
124
|
-
return { action: "throw", error };
|
|
125
|
-
}
|
|
126
|
-
// Non-Error thrown values get wrapped so the catch contract stays
|
|
127
|
-
// (always throws an Error).
|
|
128
|
-
return { action: "throw", error: new Error(String(error)) };
|
|
129
|
-
}
|
package/src/runtime/publisher.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
// Exposes window.Publisher with:
|
|
6
6
|
// - Publisher.query(model, malloy, opts?) → Promise<rows[]>
|
|
7
7
|
// - Publisher.queryFull(model, malloy, opts?) → Promise<MalloyResult> (envelope for <malloy-render>)
|
|
8
|
+
// opts: { environment?, package?, sourceName?, queryName?, filterParams?,
|
|
9
|
+
// bypassFilters?, givens? }. givens is a name→value map bound as
|
|
10
|
+
// Malloy given: runtime parameters for this query (safe parameterization,
|
|
11
|
+
// not string interpolation) — see the malloy-html-data-app-runtime skill.
|
|
8
12
|
// - Publisher.embed(selector, { src, height?, token? })
|
|
9
13
|
// - Publisher.context ({ environment, package } inferred from URL)
|
|
10
14
|
// - Publisher.setToken(token) (override Bearer token; default uses cookies)
|
|
@@ -92,6 +96,7 @@
|
|
|
92
96
|
if (opts.queryName) body.queryName = opts.queryName;
|
|
93
97
|
if (opts.filterParams) body.filterParams = opts.filterParams;
|
|
94
98
|
if (opts.bypassFilters) body.bypassFilters = true;
|
|
99
|
+
if (opts.givens) body.givens = opts.givens;
|
|
95
100
|
|
|
96
101
|
var headers = Object.assign(
|
|
97
102
|
{ "content-type": "application/json" },
|