@malloy-publisher/server 0.0.227 → 0.0.229
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/dist/app/api-doc.yaml +84 -1
- package/dist/app/assets/EnvironmentPage-QOoHiVeJ.js +1 -0
- package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-C71GOfVW.js} +1 -1
- package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-CrgCAwLe.js} +1 -1
- package/dist/app/assets/MainPage-BG5__FN3.js +2 -0
- package/dist/app/assets/MaterializationsPage-DE6PnrDR.js +1 -0
- package/dist/app/assets/ModelPage-CcBjcbLm.js +1 -0
- package/dist/app/assets/PackagePage-JTy3ztkB.js +1 -0
- package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-Cymbp47a.js} +1 -1
- package/dist/app/assets/ThemeEditorPage-C_nMnHr8.js +1 -0
- package/dist/app/assets/WorkbookPage-CPQu-DQx.js +1 -0
- package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-Coi3caGs.es-CSOmajHS.js} +10 -10
- package/dist/app/assets/{index-gEWxu09x.js → index-CM2qhQCI.js} +1 -1
- package/dist/app/assets/{index-D6YtyiJ0.js → index-CcuuST2X.js} +1 -1
- package/dist/app/assets/{index-OEjKNSYb.js → index-CkmABCAw.js} +388 -241
- package/dist/app/assets/{index-BioohWQj.js → index-DlWCXghy.js} +1 -1
- package/dist/app/assets/index-DxArlgRD.js +529 -0
- package/dist/app/index.html +1 -1
- package/dist/default-publisher.config.json +7 -7
- package/dist/instrumentation.mjs +1 -1
- package/dist/package_load_worker.mjs +1 -1
- package/dist/runtime/publisher.js +5 -0
- package/dist/server.mjs +7675 -899
- package/package.json +4 -2
- 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 +89 -3
- package/src/config.ts +102 -1
- package/src/controller/materialization.controller.spec.ts +9 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.ts +62 -31
- package/src/default-publisher.config.json +7 -7
- package/src/materialization_metrics.ts +33 -0
- package/src/mcp/handler_utils.spec.ts +108 -0
- package/src/mcp/handler_utils.ts +98 -4
- package/src/mcp/server.protocol.spec.ts +58 -0
- package/src/mcp/server.ts +29 -1
- package/src/mcp/skills/build_skills_bundle.ts +29 -9
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +31 -2
- package/src/mcp/tools/compile_tool.spec.ts +207 -0
- package/src/mcp/tools/compile_tool.ts +177 -0
- package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
- package/src/mcp/tools/execute_query_tool.ts +37 -4
- package/src/mcp/tools/get_context_tool.ts +1 -1
- package/src/mcp/tools/reload_package_tool.spec.ts +232 -0
- package/src/mcp/tools/reload_package_tool.ts +158 -0
- package/src/query_param_utils.ts +11 -0
- package/src/runtime/publisher.js +5 -0
- package/src/server.ts +74 -14
- package/src/service/connection.spec.ts +8 -7
- package/src/service/cron_evaluator.spec.ts +79 -0
- package/src/service/cron_evaluator.ts +105 -0
- package/src/service/environment.ts +188 -19
- package/src/service/environment_store.spec.ts +372 -2
- package/src/service/environment_store.ts +149 -17
- package/src/service/environment_store_anchoring.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +3 -1
- package/src/service/manifest_loader.ts +9 -5
- package/src/service/materialization_scheduler.spec.ts +260 -0
- package/src/service/materialization_scheduler.ts +304 -0
- package/src/service/materialization_service.spec.ts +167 -4
- package/src/service/materialization_service.ts +121 -8
- package/src/service/materialization_test_fixtures.ts +10 -1
- package/src/service/model.spec.ts +92 -0
- package/src/service/model.ts +58 -7
- package/src/service/package.ts +121 -14
- package/src/service/package_quote_bound_tables.spec.ts +171 -0
- package/src/service/package_reload_safety.spec.ts +193 -0
- package/src/service/persistence_policy.spec.ts +31 -0
- package/src/service/quoting.spec.ts +57 -1
- package/src/service/quoting.ts +33 -0
- package/src/storage/DatabaseInterface.ts +15 -0
- package/src/storage/duckdb/DuckDBRepository.ts +17 -0
- package/src/storage/duckdb/MaterializationRepository.spec.ts +154 -0
- package/src/storage/duckdb/MaterializationRepository.ts +56 -0
- package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
- package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -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/integration/materialization/environment_list.integration.spec.ts +133 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
- package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
- 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/dist/app/assets/EnvironmentPage-DvOJ7L_b.js +0 -1
- package/dist/app/assets/MainPage-BIe0VwBa.js +0 -2
- package/dist/app/assets/MaterializationsPage-BuZ6UJVx.js +0 -1
- package/dist/app/assets/ModelPage-DsPf-s8B.js +0 -1
- package/dist/app/assets/PackagePage-CEVNAKZa.js +0 -1
- package/dist/app/assets/ThemeEditorPage-DWC_FdNU.js +0 -1
- package/dist/app/assets/WorkbookPage-CGrsFz8p.js +0 -1
- package/dist/app/assets/index-DNUZpnaa.js +0 -527
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { CronEvaluator } from "./cron_evaluator";
|
|
3
|
+
|
|
4
|
+
describe("CronEvaluator", () => {
|
|
5
|
+
const cron = new CronEvaluator();
|
|
6
|
+
|
|
7
|
+
describe("isValid", () => {
|
|
8
|
+
it("accepts 5-field UNIX crons", () => {
|
|
9
|
+
expect(cron.isValid("0 6 * * *")).toBe(true);
|
|
10
|
+
expect(cron.isValid("*/15 * * * *")).toBe(true);
|
|
11
|
+
expect(cron.isValid("0 0 1 1 *")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("rejects a 6-field (seconds) cron — the contract is 5-field", () => {
|
|
15
|
+
expect(cron.isValid("0 0 6 * * *")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("rejects garbage and empty input", () => {
|
|
19
|
+
expect(cron.isValid("not a cron")).toBe(false);
|
|
20
|
+
expect(cron.isValid("")).toBe(false);
|
|
21
|
+
expect(cron.isValid("* * *")).toBe(false);
|
|
22
|
+
// Out-of-range field.
|
|
23
|
+
expect(cron.isValid("99 * * * *")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("rejects cron-parser / Quartz extensions the UNIX parser lacks", () => {
|
|
27
|
+
// These parse under cron-parser but the control plane's cron-utils
|
|
28
|
+
// UNIX parser rejects them — pinning the grammar keeps a cron from
|
|
29
|
+
// validating locally yet silently never arming in production.
|
|
30
|
+
expect(cron.isValid("0 6 L * *")).toBe(false); // last day of month
|
|
31
|
+
expect(cron.isValid("0 6 15W * *")).toBe(false); // nearest weekday
|
|
32
|
+
expect(cron.isValid("0 6 * * 1#2")).toBe(false); // 2nd Monday
|
|
33
|
+
expect(cron.isValid("0 0 ? * *")).toBe(false); // Quartz no-specific
|
|
34
|
+
expect(cron.isValid("0 6 LW * *")).toBe(false); // last weekday
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("accepts UNIX month/day names (not mistaken for extensions)", () => {
|
|
38
|
+
expect(cron.isValid("0 6 * JUL *")).toBe(true); // JUL contains 'L'
|
|
39
|
+
expect(cron.isValid("0 6 * * WED")).toBe(true); // WED contains 'W'
|
|
40
|
+
expect(cron.isValid("0 6 * * THU")).toBe(true); // THU contains 'H'
|
|
41
|
+
expect(cron.isValid("0 6 * * MON-FRI")).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("nextAfter (UTC, strictly after)", () => {
|
|
46
|
+
it("returns the next daily fire", () => {
|
|
47
|
+
const from = new Date("2026-07-13T00:00:00Z");
|
|
48
|
+
expect(cron.nextAfter("0 6 * * *", from).toISOString()).toBe(
|
|
49
|
+
"2026-07-13T06:00:00.000Z",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rolls to the next day when the fire time has passed", () => {
|
|
54
|
+
const from = new Date("2026-07-13T07:00:00Z");
|
|
55
|
+
expect(cron.nextAfter("0 6 * * *", from).toISOString()).toBe(
|
|
56
|
+
"2026-07-14T06:00:00.000Z",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("advances strictly past a `from` that lands exactly on a fire", () => {
|
|
61
|
+
const from = new Date("2026-07-13T06:00:00Z");
|
|
62
|
+
// Strictly after: the 06:00 instant itself is excluded.
|
|
63
|
+
expect(cron.nextAfter("0 6 * * *", from).toISOString()).toBe(
|
|
64
|
+
"2026-07-14T06:00:00.000Z",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("steps every 15 minutes", () => {
|
|
69
|
+
const from = new Date("2026-07-13T09:07:00Z");
|
|
70
|
+
expect(cron.nextAfter("*/15 * * * *", from).toISOString()).toBe(
|
|
71
|
+
"2026-07-13T09:15:00.000Z",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("throws on an invalid cron (callers guard with isValid)", () => {
|
|
76
|
+
expect(() => cron.nextAfter("nope", new Date())).toThrow();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluate a 5-field UNIX cron expression, in UTC, for the standalone
|
|
3
|
+
* materialization scheduler. Mirrors the control plane's `CronEvaluator`
|
|
4
|
+
* semantics (5-field, UTC, "strictly after") so a given `materialization.schedule`
|
|
5
|
+
* fires at the same instants whether the publisher runs standalone or the control
|
|
6
|
+
* plane drives it.
|
|
7
|
+
*
|
|
8
|
+
* Thin wrapper over `cron-parser` that pins three policies:
|
|
9
|
+
* - **exactly 5 fields** — `cron-parser` also accepts an optional leading
|
|
10
|
+
* seconds field (6 fields); the manifest contract is 5-field UNIX cron, so a
|
|
11
|
+
* 6-field expression is rejected rather than silently reinterpreted.
|
|
12
|
+
* - **plain UNIX grammar** — `cron-parser` accepts extensions (`L` last,
|
|
13
|
+
* `W` nearest-weekday, `#` nth-weekday, `?` Quartz no-specific) that the
|
|
14
|
+
* control plane's `cron-utils` UNIX parser rejects. Without this pin, an
|
|
15
|
+
* expression like `0 6 L * *` would validate and fire locally but, after
|
|
16
|
+
* publish, silently never arm in production. Month/day names (JAN, MON-FRI)
|
|
17
|
+
* are still allowed.
|
|
18
|
+
* - **UTC** — schedules are absolute, never tied to the server's local zone.
|
|
19
|
+
*/
|
|
20
|
+
import { CronExpressionParser } from "cron-parser";
|
|
21
|
+
|
|
22
|
+
/** Valid 3-letter month and day-of-week names in UNIX cron. */
|
|
23
|
+
const UNIX_CRON_NAMES = new Set([
|
|
24
|
+
"JAN",
|
|
25
|
+
"FEB",
|
|
26
|
+
"MAR",
|
|
27
|
+
"APR",
|
|
28
|
+
"MAY",
|
|
29
|
+
"JUN",
|
|
30
|
+
"JUL",
|
|
31
|
+
"AUG",
|
|
32
|
+
"SEP",
|
|
33
|
+
"OCT",
|
|
34
|
+
"NOV",
|
|
35
|
+
"DEC",
|
|
36
|
+
"SUN",
|
|
37
|
+
"MON",
|
|
38
|
+
"TUE",
|
|
39
|
+
"WED",
|
|
40
|
+
"THU",
|
|
41
|
+
"FRI",
|
|
42
|
+
"SAT",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export class CronEvaluator {
|
|
46
|
+
/** True when `expr` is a syntactically valid 5-field UNIX cron. */
|
|
47
|
+
isValid(expr: string): boolean {
|
|
48
|
+
if (!this.hasFiveFields(expr) || !this.isPlainUnixGrammar(expr)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
CronExpressionParser.parse(expr, { tz: "UTC" });
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The next fire instant strictly after `from` (UTC). Throws when `expr` is
|
|
61
|
+
* not a valid 5-field cron — callers guard with {@link isValid} (the
|
|
62
|
+
* scheduler skips + logs an invalid cron rather than letting it throw in the
|
|
63
|
+
* tick).
|
|
64
|
+
*/
|
|
65
|
+
nextAfter(expr: string, from: Date): Date {
|
|
66
|
+
if (!this.hasFiveFields(expr) || !this.isPlainUnixGrammar(expr)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Expected a 5-field UNIX cron, got: ${JSON.stringify(expr)}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return CronExpressionParser.parse(expr, {
|
|
72
|
+
currentDate: from,
|
|
73
|
+
tz: "UTC",
|
|
74
|
+
})
|
|
75
|
+
.next()
|
|
76
|
+
.toDate();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whitespace-split field count is exactly 5. */
|
|
80
|
+
private hasFiveFields(expr: string): boolean {
|
|
81
|
+
return typeof expr === "string" && expr.trim().split(/\s+/).length === 5;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reject cron-parser / Quartz extensions absent from plain UNIX cron: `#`
|
|
86
|
+
* and `?` (never part of a name), and any alphabetic token that is not a
|
|
87
|
+
* UNIX month/day name — which catches `L`, `W`, `LW`, `L-3`, `15W`, etc.
|
|
88
|
+
* while still allowing named fields like `JUL` or `MON-FRI`.
|
|
89
|
+
*/
|
|
90
|
+
private isPlainUnixGrammar(expr: string): boolean {
|
|
91
|
+
for (const field of expr.trim().split(/\s+/)) {
|
|
92
|
+
if (/[#?]/.test(field)) return false;
|
|
93
|
+
for (const token of field.split(/[,\-/]/)) {
|
|
94
|
+
if (token === "" || token === "*") continue;
|
|
95
|
+
if (
|
|
96
|
+
/[A-Za-z]/.test(token) &&
|
|
97
|
+
!UNIX_CRON_NAMES.has(token.toUpperCase())
|
|
98
|
+
) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
ServiceUnavailableError,
|
|
22
22
|
} from "../errors";
|
|
23
23
|
import { logger } from "../logger";
|
|
24
|
+
import { redactPgSecrets } from "../pg_helpers";
|
|
24
25
|
import { recordManifestBind } from "../materialization_metrics";
|
|
25
26
|
import {
|
|
26
27
|
assertSafeEnvironmentPath,
|
|
@@ -138,6 +139,16 @@ export class Environment {
|
|
|
138
139
|
// AB/BA deadlock path.
|
|
139
140
|
private packageMutexes = new Map<string, Mutex>();
|
|
140
141
|
private packageStatuses: Map<string, PackageInfo> = new Map();
|
|
142
|
+
/**
|
|
143
|
+
* Configured packages that failed to load, keyed by name, with the reason.
|
|
144
|
+
*
|
|
145
|
+
* A load failure is not fatal: the package is omitted and its siblings serve
|
|
146
|
+
* on. It is also observable exactly once, because the failing load deletes
|
|
147
|
+
* the `packageStatuses` entry that `listPackages` enumerates, so the next
|
|
148
|
+
* listing no longer knows the package was ever configured. That makes this
|
|
149
|
+
* the only lasting record. Read by EnvironmentStore.getStatus.
|
|
150
|
+
*/
|
|
151
|
+
private failedPackages: Map<string, string> = new Map();
|
|
141
152
|
private malloyConfig: EnvironmentMalloyConfig;
|
|
142
153
|
private connectionMutex = new Mutex();
|
|
143
154
|
private retiredConnectionGenerations =
|
|
@@ -158,6 +169,11 @@ export class Environment {
|
|
|
158
169
|
return this.environmentPath;
|
|
159
170
|
}
|
|
160
171
|
|
|
172
|
+
/** This environment's name (the canonical key used by the API/service). */
|
|
173
|
+
public getEnvironmentName(): string {
|
|
174
|
+
return this.environmentName;
|
|
175
|
+
}
|
|
176
|
+
|
|
161
177
|
constructor(
|
|
162
178
|
environmentName: string,
|
|
163
179
|
environmentPath: string,
|
|
@@ -471,9 +487,30 @@ export class Environment {
|
|
|
471
487
|
if (includeSql && queryMaterializer) {
|
|
472
488
|
try {
|
|
473
489
|
sql = await queryMaterializer.getSQL({ givens });
|
|
474
|
-
} catch {
|
|
475
|
-
//
|
|
476
|
-
//
|
|
490
|
+
} catch (error) {
|
|
491
|
+
// A bad caller given (unknown name, wrong-typed value, finalized
|
|
492
|
+
// override, ...) surfaces as a Malloy `runtime-given-*` error.
|
|
493
|
+
// Map it to a 400 rather than silently omitting `sql` (which is
|
|
494
|
+
// indistinguishable from "no runnable query"). Duck-type on
|
|
495
|
+
// `.code`; let a MalloyError fall to the outer catch → problems.
|
|
496
|
+
// The `runtime-given-` prefix is pinned to Malloy's error codes
|
|
497
|
+
// (given_binding.ts / runtime.ts, same as model.ts) — if they're
|
|
498
|
+
// renamed upstream a bad given would silently revert to the omit
|
|
499
|
+
// branch below, so keep the two in sync.
|
|
500
|
+
const givenCode = (error as { code?: string })?.code;
|
|
501
|
+
if (
|
|
502
|
+
typeof givenCode === "string" &&
|
|
503
|
+
givenCode.startsWith("runtime-given-")
|
|
504
|
+
) {
|
|
505
|
+
throw new BadRequestError(
|
|
506
|
+
error instanceof Error ? error.message : String(error),
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (error instanceof MalloyError) {
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
// Otherwise the source may just not contain a runnable query
|
|
513
|
+
// (e.g. only source definitions) — omit the sql field.
|
|
477
514
|
}
|
|
478
515
|
}
|
|
479
516
|
|
|
@@ -568,6 +605,17 @@ export class Environment {
|
|
|
568
605
|
);
|
|
569
606
|
}
|
|
570
607
|
|
|
608
|
+
/**
|
|
609
|
+
* Snapshot of the packages currently loaded in memory (does not trigger a
|
|
610
|
+
* load or reload). Used by the standalone materialization scheduler to sweep
|
|
611
|
+
* only already-loaded packages — a not-yet-loaded package is simply not
|
|
612
|
+
* scheduled until something else loads it, so the scheduler never forces a
|
|
613
|
+
* load of its own.
|
|
614
|
+
*/
|
|
615
|
+
public getLoadedPackages(): Package[] {
|
|
616
|
+
return [...this.packages.values()];
|
|
617
|
+
}
|
|
618
|
+
|
|
571
619
|
public async listPackages(): Promise<ApiPackage[]> {
|
|
572
620
|
logger.debug("Listing packages", {
|
|
573
621
|
environmentPath: this.environmentPath,
|
|
@@ -592,6 +640,24 @@ export class Environment {
|
|
|
592
640
|
);
|
|
593
641
|
// Directory did not contain a valid package.json file -- therefore, it's not a package.
|
|
594
642
|
// Or it timed out
|
|
643
|
+
// Redact before this reaches getStatus: compiling a model
|
|
644
|
+
// resolves the package's connections, so a Postgres/DuckLake
|
|
645
|
+
// ATTACH failure surfaces here carrying the connection string
|
|
646
|
+
// (connection.ts builds it, and redacts it before logging for
|
|
647
|
+
// the same reason). A log line was the old destination; this
|
|
648
|
+
// one is an HTTP response body.
|
|
649
|
+
//
|
|
650
|
+
// Reduces the exposure, does not remove it: redactPgSecrets
|
|
651
|
+
// only covers keyword-form `password=`, which is what
|
|
652
|
+
// buildPgConnectionString emits. A URL-form connectionString
|
|
653
|
+
// supplied verbatim in config still carries its credentials
|
|
654
|
+
// through. Widen the helper rather than trusting this call.
|
|
655
|
+
this.failedPackages.set(
|
|
656
|
+
packageName,
|
|
657
|
+
redactPgSecrets(
|
|
658
|
+
error instanceof Error ? error.message : String(error),
|
|
659
|
+
),
|
|
660
|
+
);
|
|
595
661
|
return undefined;
|
|
596
662
|
}
|
|
597
663
|
}),
|
|
@@ -855,13 +921,25 @@ export class Environment {
|
|
|
855
921
|
}
|
|
856
922
|
this.packages.set(packageName, _package);
|
|
857
923
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
924
|
+
// It loaded, so any earlier failure is stale. A package that failed at
|
|
925
|
+
// boot can be fixed on disk and reloaded without a restart.
|
|
926
|
+
this.failedPackages.delete(packageName);
|
|
858
927
|
logger.debug(`Successfully loaded package ${packageName}`);
|
|
859
928
|
|
|
860
929
|
return _package;
|
|
861
930
|
} catch (error) {
|
|
862
931
|
logger.error(`Failed to load package ${packageName}`, { error });
|
|
863
|
-
|
|
864
|
-
|
|
932
|
+
if (existingPackage !== undefined && reload) {
|
|
933
|
+
// A failed RELOAD must not take down a package that is already
|
|
934
|
+
// serving. The compiled model in `packages` is still the last good
|
|
935
|
+
// one (it is only replaced on success), so keep serving it and let
|
|
936
|
+
// the caller surface the error instead of evicting the package and
|
|
937
|
+
// leaving the environment with nothing to answer from.
|
|
938
|
+
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
939
|
+
} else {
|
|
940
|
+
this.packages.delete(packageName);
|
|
941
|
+
this.packageStatuses.delete(packageName);
|
|
942
|
+
}
|
|
865
943
|
throw error;
|
|
866
944
|
}
|
|
867
945
|
}
|
|
@@ -927,6 +1005,10 @@ export class Environment {
|
|
|
927
1005
|
throw error;
|
|
928
1006
|
}
|
|
929
1007
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1008
|
+
// Same reasoning as the load and install paths: it is serving now, so an
|
|
1009
|
+
// earlier boot failure is stale. Without this, a package fixed on disk
|
|
1010
|
+
// and re-added keeps its loadError for the life of the process.
|
|
1011
|
+
this.failedPackages.delete(packageName);
|
|
930
1012
|
return this.packages.get(packageName);
|
|
931
1013
|
}
|
|
932
1014
|
|
|
@@ -1019,6 +1101,10 @@ export class Environment {
|
|
|
1019
1101
|
packageName,
|
|
1020
1102
|
canonicalPath,
|
|
1021
1103
|
() => this.malloyConfig.malloyConfig,
|
|
1104
|
+
// This tree was just staged into place by the rename above, so a
|
|
1105
|
+
// failed load leaves a half-built directory that is ours to
|
|
1106
|
+
// remove; the rollback below restores the previous one.
|
|
1107
|
+
true,
|
|
1022
1108
|
);
|
|
1023
1109
|
// Strict-reject hook (publish/update only — reload passes no
|
|
1024
1110
|
// validator and stays fail-safe). Throw INSIDE the try so the
|
|
@@ -1064,7 +1150,37 @@ export class Environment {
|
|
|
1064
1150
|
await fs.promises
|
|
1065
1151
|
.rm(stagingPath, { recursive: true, force: true })
|
|
1066
1152
|
.catch(() => {});
|
|
1067
|
-
|
|
1153
|
+
if (oldPackage && restored) {
|
|
1154
|
+
// The rollback put the old tree back and the previous package is
|
|
1155
|
+
// still in `this.packages` (it is only replaced on success
|
|
1156
|
+
// below), so it is genuinely still serving. Deleting its status
|
|
1157
|
+
// would strand it: listPackages enumerates packageStatuses, so
|
|
1158
|
+
// the package would answer getPackage while being invisible to
|
|
1159
|
+
// listings and discovery until a restart.
|
|
1160
|
+
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1161
|
+
} else {
|
|
1162
|
+
// Either there was nothing to fall back to (a first install), or
|
|
1163
|
+
// the restore did not happen: the rename-back threw, or the old
|
|
1164
|
+
// tree was never on disk to retire. The canonical path is then
|
|
1165
|
+
// missing or still holds the rejected content, so the cached
|
|
1166
|
+
// package no longer matches disk and must not be advertised as
|
|
1167
|
+
// serving. Drop both, and keep the two maps agreeing.
|
|
1168
|
+
this.deletePackageStatus(packageName);
|
|
1169
|
+
if (oldPackage) {
|
|
1170
|
+
// Retire before dropping it, the same way every other eviction
|
|
1171
|
+
// here does: once it leaves this.packages, closeAllConnections
|
|
1172
|
+
// can no longer reach its MalloyConfig, so its native handles
|
|
1173
|
+
// would never be released. Retire rather than shut down
|
|
1174
|
+
// inline, because withPackageLock does not cover queries that
|
|
1175
|
+
// already took this Package from an earlier getPackage; the
|
|
1176
|
+
// drain lets those finish first.
|
|
1177
|
+
this.retireConnectionGeneration(
|
|
1178
|
+
`package ${packageName}`,
|
|
1179
|
+
() => oldPackage.getMalloyConfig().shutdown("close"),
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
this.packages.delete(packageName);
|
|
1183
|
+
}
|
|
1068
1184
|
logger.debug("install.phase2.rollback", {
|
|
1069
1185
|
environmentName: this.environmentName,
|
|
1070
1186
|
packageName,
|
|
@@ -1081,6 +1197,8 @@ export class Environment {
|
|
|
1081
1197
|
|
|
1082
1198
|
this.packages.set(packageName, newPackage);
|
|
1083
1199
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1200
|
+
// Publishing a fixed package clears the boot failure it replaces.
|
|
1201
|
+
this.failedPackages.delete(packageName);
|
|
1084
1202
|
|
|
1085
1203
|
if (oldPackage) {
|
|
1086
1204
|
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
@@ -1248,6 +1366,8 @@ export class Environment {
|
|
|
1248
1366
|
explores?: string[];
|
|
1249
1367
|
queryableSources?: "declared" | "all";
|
|
1250
1368
|
manifestLocation?: string | null;
|
|
1369
|
+
scope?: ApiPackage["scope"];
|
|
1370
|
+
materialization?: ApiPackage["materialization"];
|
|
1251
1371
|
},
|
|
1252
1372
|
): Promise<void> {
|
|
1253
1373
|
const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
@@ -1280,6 +1400,10 @@ export class Environment {
|
|
|
1280
1400
|
...(metadata.manifestLocation !== undefined
|
|
1281
1401
|
? { manifestLocation: metadata.manifestLocation }
|
|
1282
1402
|
: {}),
|
|
1403
|
+
...(metadata.scope !== undefined ? { scope: metadata.scope } : {}),
|
|
1404
|
+
...(metadata.materialization !== undefined
|
|
1405
|
+
? { materialization: metadata.materialization }
|
|
1406
|
+
: {}),
|
|
1283
1407
|
};
|
|
1284
1408
|
|
|
1285
1409
|
// Write back to file
|
|
@@ -1332,6 +1456,29 @@ export class Environment {
|
|
|
1332
1456
|
body.manifestLocation !== undefined
|
|
1333
1457
|
? body.manifestLocation
|
|
1334
1458
|
: existing.manifestLocation;
|
|
1459
|
+
// Persist `scope` and `materialization` (the schedule cron) are
|
|
1460
|
+
// editable via the API — both are writable in the schema. When the
|
|
1461
|
+
// body carries a value, apply it; otherwise preserve the
|
|
1462
|
+
// manifest-derived one (a name/description-only PATCH must not wipe
|
|
1463
|
+
// them, and the control plane must not misread the gap as a removal).
|
|
1464
|
+
// Changing the schedule re-arms the standalone scheduler on its next
|
|
1465
|
+
// tick — no reload needed.
|
|
1466
|
+
//
|
|
1467
|
+
// A *null* scope/materialization is treated the same as omitted
|
|
1468
|
+
// (preserve), NOT a wipe: the control plane's post-build rebind PATCH
|
|
1469
|
+
// carries only name/location/manifestLocation, but a client that
|
|
1470
|
+
// serializes unset fields as explicit null must not thereby trip the
|
|
1471
|
+
// policy gate below (a rejection there fails the orchestrated run) or
|
|
1472
|
+
// reset the persisted policy. `manifestLocation` is deliberately
|
|
1473
|
+
// different — null there means "clear" (revert to live), which the
|
|
1474
|
+
// orchestrator relies on.
|
|
1475
|
+
const scopeProvided = body.scope != null;
|
|
1476
|
+
const materializationProvided = body.materialization != null;
|
|
1477
|
+
const editingPolicy = scopeProvided || materializationProvided;
|
|
1478
|
+
const scope = scopeProvided ? body.scope : existing.scope;
|
|
1479
|
+
const materialization = materializationProvided
|
|
1480
|
+
? body.materialization
|
|
1481
|
+
: existing.materialization;
|
|
1335
1482
|
_package.setPackageMetadata({
|
|
1336
1483
|
name: body.name,
|
|
1337
1484
|
description: body.description,
|
|
@@ -1340,25 +1487,27 @@ export class Environment {
|
|
|
1340
1487
|
explores,
|
|
1341
1488
|
queryableSources,
|
|
1342
1489
|
manifestLocation,
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
// this wipes the in-memory `materialization` until the next reload —
|
|
1346
|
-
// which makes a later getPackage report no schedule and lets the
|
|
1347
|
-
// control plane misread the gap as a schedule removal. It is not a
|
|
1348
|
-
// PATCH-editable field, so always preserve the existing value.
|
|
1349
|
-
materialization: existing.materialization,
|
|
1350
|
-
// Same rationale for the manifest-derived persist `scope`: not
|
|
1351
|
-
// PATCH-editable, so preserve it rather than dropping it to the
|
|
1352
|
-
// default until the next reload.
|
|
1353
|
-
scope: existing.scope,
|
|
1490
|
+
materialization,
|
|
1491
|
+
scope,
|
|
1354
1492
|
});
|
|
1355
1493
|
|
|
1356
1494
|
// Strict-reject, symmetric with the publish path
|
|
1357
1495
|
// (package.controller.addPackage): validate the resulting explores
|
|
1358
1496
|
// against the live model set and restore the prior metadata before
|
|
1359
1497
|
// rejecting, so a bad update neither persists nor mutates the served
|
|
1360
|
-
// surface.
|
|
1361
|
-
|
|
1498
|
+
// surface. When the body edits the persistence policy (scope /
|
|
1499
|
+
// materialization), also enforce the same scope/schedule/freshness/cron
|
|
1500
|
+
// rules a publish enforces — but only then, so a description-only PATCH
|
|
1501
|
+
// on a package with a pre-existing (load-tolerated) policy warning is
|
|
1502
|
+
// not newly rejected. Cron validity is one of these rules
|
|
1503
|
+
// (persistencePolicyWarnings Rule 4), so publish, PATCH, load, and the
|
|
1504
|
+
// scheduler all enforce it identically.
|
|
1505
|
+
const policyMsg = editingPolicy
|
|
1506
|
+
? _package.formatInvalidPersistencePolicy()
|
|
1507
|
+
: "";
|
|
1508
|
+
const invalidMsg = [_package.formatInvalidExplores(), policyMsg]
|
|
1509
|
+
.filter(Boolean)
|
|
1510
|
+
.join("\n");
|
|
1362
1511
|
if (invalidMsg) {
|
|
1363
1512
|
_package.setPackageMetadata(existing);
|
|
1364
1513
|
throw new BadRequestError(invalidMsg);
|
|
@@ -1370,6 +1519,13 @@ export class Environment {
|
|
|
1370
1519
|
explores: normalizedExplores,
|
|
1371
1520
|
queryableSources: body.queryableSources,
|
|
1372
1521
|
manifestLocation: body.manifestLocation,
|
|
1522
|
+
// Only write when explicitly provided (non-null): mirrors the
|
|
1523
|
+
// null-as-absent rule above, so a rebind PATCH neither wipes the
|
|
1524
|
+
// persisted policy nor writes a stray `scope: null`.
|
|
1525
|
+
scope: scopeProvided ? body.scope : undefined,
|
|
1526
|
+
materialization: materializationProvided
|
|
1527
|
+
? body.materialization
|
|
1528
|
+
: undefined,
|
|
1373
1529
|
});
|
|
1374
1530
|
|
|
1375
1531
|
// When the body changes manifestLocation, apply it now so the new
|
|
@@ -1391,6 +1547,11 @@ export class Environment {
|
|
|
1391
1547
|
return this.packageStatuses.get(packageName);
|
|
1392
1548
|
}
|
|
1393
1549
|
|
|
1550
|
+
/** Packages configured for this environment that did not load, and why. */
|
|
1551
|
+
public getFailedPackages(): ReadonlyMap<string, string> {
|
|
1552
|
+
return this.failedPackages;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1394
1555
|
public setPackageStatus(packageName: string, status: PackageStatus): void {
|
|
1395
1556
|
const currentStatus = this.packageStatuses.get(packageName);
|
|
1396
1557
|
this.packageStatuses.set(packageName, {
|
|
@@ -1407,6 +1568,14 @@ export class Environment {
|
|
|
1407
1568
|
public async deletePackage(packageName: string): Promise<void> {
|
|
1408
1569
|
assertSafePackageName(packageName);
|
|
1409
1570
|
return this.withPackageLock(packageName, async () => {
|
|
1571
|
+
// Clear the load failure before the early return, not after it. A
|
|
1572
|
+
// package that failed to load is not in `packages` (the load catch
|
|
1573
|
+
// evicts it), so deleting it takes the early return every time, while
|
|
1574
|
+
// the controller still drops its config row. Leaving the entry would
|
|
1575
|
+
// make getStatus report a loadError for a package that is no longer
|
|
1576
|
+
// configured, which is the one thing that channel must not do.
|
|
1577
|
+
this.failedPackages.delete(packageName);
|
|
1578
|
+
|
|
1410
1579
|
const _package = this.packages.get(packageName);
|
|
1411
1580
|
if (!_package) {
|
|
1412
1581
|
return;
|