@malloy-publisher/server 0.0.228 → 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/dist/app/api-doc.yaml +84 -1
- package/dist/app/assets/EnvironmentPage-QOoHiVeJ.js +1 -0
- package/dist/app/assets/{HomePage-Bkwc9Woc.js → HomePage-C71GOfVW.js} +1 -1
- package/dist/app/assets/{LightMode-Bum_KBpN.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-DmJLpLXm.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-CEDZMHV1.es-_yGzNgNH.js → core-Coi3caGs.es-CSOmajHS.js} +10 -10
- package/dist/app/assets/{index-DDMrjIT3.js → index-CM2qhQCI.js} +1 -1
- package/dist/app/assets/{index-CE9xhdra.js → index-CcuuST2X.js} +1 -1
- package/dist/app/assets/{index-OEjKNSYb.js → index-CkmABCAw.js} +388 -241
- package/dist/app/assets/{index-CdmFub34.js → index-DlWCXghy.js} +1 -1
- package/dist/app/assets/index-DxArlgRD.js +529 -0
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +1 -1
- package/dist/package_load_worker.mjs +1 -1
- package/dist/server.mjs +6934 -36
- package/package.json +4 -2
- package/src/config.spec.ts +87 -1
- 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/materialization_metrics.ts +33 -0
- 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/query_param_utils.ts +11 -0
- package/src/server.ts +67 -9
- 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 +118 -13
- 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/package.ts +90 -1
- package/src/service/package_quote_bound_tables.spec.ts +171 -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/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/dist/app/assets/EnvironmentPage-EW2lbGvb.js +0 -1
- package/dist/app/assets/MainPage-oiEy7TNM.js +0 -2
- package/dist/app/assets/MaterializationsPage-C_VJsTgU.js +0 -1
- package/dist/app/assets/ModelPage-z8REqAmk.js +0 -1
- package/dist/app/assets/PackagePage-C2Vtt1Ln.js +0 -1
- package/dist/app/assets/ThemeEditorPage-BywFjC7A.js +0 -1
- package/dist/app/assets/WorkbookPage-DCMizDMR.js +0 -1
- package/dist/app/assets/index-EqslXZ44.js +0 -527
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { buildSkills, type SkillEntry } from "./build_skills_bundle";
|
|
2
4
|
import bundle from "./skills_bundle.json";
|
|
3
5
|
|
|
4
6
|
const skills = (
|
|
@@ -7,9 +9,36 @@ const skills = (
|
|
|
7
9
|
}
|
|
8
10
|
).skills;
|
|
9
11
|
|
|
12
|
+
/** The repo's top-level skills/, which this bundle is generated from. */
|
|
13
|
+
const sourceDir = path.join(
|
|
14
|
+
import.meta.dir,
|
|
15
|
+
"..",
|
|
16
|
+
"..",
|
|
17
|
+
"..",
|
|
18
|
+
"..",
|
|
19
|
+
"..",
|
|
20
|
+
"skills",
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Codepoint order. The committed bundle is sorted with localeCompare, which
|
|
25
|
+
* depends on the runtime's locale, and this suite runs on three platforms.
|
|
26
|
+
* Membership and content are what matter here; file order is cosmetic.
|
|
27
|
+
*/
|
|
28
|
+
const byName = (a: SkillEntry, b: SkillEntry) =>
|
|
29
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
30
|
+
|
|
10
31
|
describe("skills_bundle.json (generated dual-channel asset)", () => {
|
|
11
|
-
it("
|
|
12
|
-
|
|
32
|
+
it("is in sync with skills/", () => {
|
|
33
|
+
// Regenerate with:
|
|
34
|
+
// bun run src/mcp/skills/build_skills_bundle.ts ../../skills
|
|
35
|
+
expect([...skills].sort(byName)).toEqual(
|
|
36
|
+
[...buildSkills(sourceDir)].sort(byName),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("is not empty", () => {
|
|
41
|
+
expect(skills.length).toBeGreaterThan(0);
|
|
13
42
|
});
|
|
14
43
|
|
|
15
44
|
it("every skill has a nonempty name, description, and body", () => {
|
package/src/query_param_utils.ts
CHANGED
|
@@ -16,3 +16,14 @@ export function normalizeQueryArray(value: unknown): string[] | undefined {
|
|
|
16
16
|
if (Array.isArray(value)) return value.map(String);
|
|
17
17
|
return [String(value)];
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse an Express query param as a non-negative integer, or `undefined` when
|
|
22
|
+
* it is absent or not a finite integer. Degrades a garbage value (`?limit=abc`)
|
|
23
|
+
* to "unset" rather than passing `NaN` down into a SQL `LIMIT`/`OFFSET` bind.
|
|
24
|
+
*/
|
|
25
|
+
export function parseNonNegativeIntParam(value: unknown): number | undefined {
|
|
26
|
+
if (value === undefined || value === null) return undefined;
|
|
27
|
+
const parsed = parseInt(String(value), 10);
|
|
28
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
|
29
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -34,7 +34,10 @@ import {
|
|
|
34
34
|
} from "./instrumentation";
|
|
35
35
|
import { logger, loggerMiddleware } from "./logger";
|
|
36
36
|
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
getMaterializationSchedulerConfig,
|
|
39
|
+
getMemoryGovernorConfig,
|
|
40
|
+
} from "./config";
|
|
38
41
|
import { setFilterDeprecationHeaders } from "./filter_deprecation";
|
|
39
42
|
import { checkHeapConfiguration } from "./heap_check";
|
|
40
43
|
import { queryConcurrency } from "./query_concurrency";
|
|
@@ -43,8 +46,12 @@ import { ThemeController } from "./controller/theme.controller";
|
|
|
43
46
|
import { initializeMcpServer } from "./mcp/server";
|
|
44
47
|
import { registerLegacyRoutes } from "./server-old";
|
|
45
48
|
import { EnvironmentStore } from "./service/environment_store";
|
|
49
|
+
import { MaterializationScheduler } from "./service/materialization_scheduler";
|
|
46
50
|
import { MaterializationService } from "./service/materialization_service";
|
|
47
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
normalizeQueryArray,
|
|
53
|
+
parseNonNegativeIntParam,
|
|
54
|
+
} from "./query_param_utils";
|
|
48
55
|
import { PackageMemoryGovernor } from "./service/package_memory_governor";
|
|
49
56
|
import { ThemeStore } from "./service/theme_store";
|
|
50
57
|
import { assertSafePackageName, safeJoinUnderRoot } from "./path_safety";
|
|
@@ -183,7 +190,7 @@ app.use(httpMetricsMiddleware);
|
|
|
183
190
|
// looks low for the default caps" advisory so operators don't
|
|
184
191
|
// chase OOMKills before checking the obvious config.
|
|
185
192
|
checkHeapConfiguration();
|
|
186
|
-
const environmentStore = new EnvironmentStore(SERVER_ROOT);
|
|
193
|
+
export const environmentStore = new EnvironmentStore(SERVER_ROOT);
|
|
187
194
|
const watchModeController = new WatchModeController(environmentStore);
|
|
188
195
|
const connectionController = new ConnectionController(environmentStore);
|
|
189
196
|
const modelController = new ModelController(environmentStore);
|
|
@@ -206,6 +213,32 @@ const materializationService = new MaterializationService(environmentStore);
|
|
|
206
213
|
const materializationController = new MaterializationController(
|
|
207
214
|
materializationService,
|
|
208
215
|
);
|
|
216
|
+
/**
|
|
217
|
+
* Construct and start the standalone materialization scheduler from environment
|
|
218
|
+
* config, or return null when the feature is disabled
|
|
219
|
+
* (`PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` unset/false — the default, so an
|
|
220
|
+
* orchestrated deployment never runs it). Extracted from the module body so a
|
|
221
|
+
* test can drive the real env-var → {@link getMaterializationSchedulerConfig} →
|
|
222
|
+
* construct → `start()`/`unref()` → timer path an operator uses; the
|
|
223
|
+
* module-level singleton below is armed once at import and can't be re-created
|
|
224
|
+
* per test.
|
|
225
|
+
*/
|
|
226
|
+
export function startMaterializationSchedulerFromEnv(
|
|
227
|
+
store: EnvironmentStore,
|
|
228
|
+
service: MaterializationService,
|
|
229
|
+
): MaterializationScheduler | null {
|
|
230
|
+
const config = getMaterializationSchedulerConfig();
|
|
231
|
+
if (!config) return null;
|
|
232
|
+
const scheduler = new MaterializationScheduler(store, service, config);
|
|
233
|
+
scheduler.start();
|
|
234
|
+
return scheduler;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Standalone materialization scheduler: opt-in via
|
|
238
|
+
// PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER (default off, so an orchestrated
|
|
239
|
+
// deployment — whose control plane drives materialization — never runs it). The
|
|
240
|
+
// sweep timer is `unref`'d, so it never keeps the process alive on shutdown.
|
|
241
|
+
startMaterializationSchedulerFromEnv(environmentStore, materializationService);
|
|
209
242
|
const themeStore = new ThemeStore(environmentStore.storageManager, SERVER_ROOT);
|
|
210
243
|
const themeController = new ThemeController(themeStore, SERVER_ROOT);
|
|
211
244
|
|
|
@@ -1319,6 +1352,31 @@ app.post(
|
|
|
1319
1352
|
},
|
|
1320
1353
|
);
|
|
1321
1354
|
|
|
1355
|
+
// Environment-scoped aggregate: every materialization across all packages in
|
|
1356
|
+
// the env, newest first. Nested under `/packages` as the collection-level
|
|
1357
|
+
// sibling of the per-package `/packages/:packageName/materializations` list.
|
|
1358
|
+
// MUST stay registered ahead of `/packages/:packageName` below so the literal
|
|
1359
|
+
// `materializations` segment wins the match; consequently `materializations` is
|
|
1360
|
+
// a reserved package name at this position (a package can never be named that).
|
|
1361
|
+
app.get(
|
|
1362
|
+
`${API_PREFIX}/environments/:environmentName/packages/materializations`,
|
|
1363
|
+
async (req, res) => {
|
|
1364
|
+
try {
|
|
1365
|
+
const limit = parseNonNegativeIntParam(req.query.limit);
|
|
1366
|
+
const offset = parseNonNegativeIntParam(req.query.offset);
|
|
1367
|
+
const builds =
|
|
1368
|
+
await materializationController.listEnvironmentMaterializations(
|
|
1369
|
+
req.params.environmentName,
|
|
1370
|
+
{ limit, offset },
|
|
1371
|
+
);
|
|
1372
|
+
res.status(200).json(builds);
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1375
|
+
res.status(status).json(json);
|
|
1376
|
+
}
|
|
1377
|
+
},
|
|
1378
|
+
);
|
|
1379
|
+
|
|
1322
1380
|
app.get(
|
|
1323
1381
|
`${API_PREFIX}/environments/:environmentName/packages/:packageName`,
|
|
1324
1382
|
async (req, res) => {
|
|
@@ -1636,6 +1694,10 @@ app.post(
|
|
|
1636
1694
|
);
|
|
1637
1695
|
|
|
1638
1696
|
// ==================== MATERIALIZATION ROUTES ====================
|
|
1697
|
+
// The environment-scoped aggregate list (every materialization across all
|
|
1698
|
+
// packages) is registered up in the package routes as
|
|
1699
|
+
// `/packages/materializations`, ahead of `/packages/:packageName`, so the
|
|
1700
|
+
// literal wins the match — see that route for the ordering contract.
|
|
1639
1701
|
|
|
1640
1702
|
app.post(
|
|
1641
1703
|
`${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations`,
|
|
@@ -1658,12 +1720,8 @@ app.get(
|
|
|
1658
1720
|
`${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations`,
|
|
1659
1721
|
async (req, res) => {
|
|
1660
1722
|
try {
|
|
1661
|
-
const limit = req.query.limit
|
|
1662
|
-
|
|
1663
|
-
: undefined;
|
|
1664
|
-
const offset = req.query.offset
|
|
1665
|
-
? parseInt(req.query.offset as string, 10)
|
|
1666
|
-
: undefined;
|
|
1723
|
+
const limit = parseNonNegativeIntParam(req.query.limit);
|
|
1724
|
+
const offset = parseNonNegativeIntParam(req.query.offset);
|
|
1667
1725
|
const builds = await materializationController.listMaterializations(
|
|
1668
1726
|
req.params.environmentName,
|
|
1669
1727
|
req.params.packageName,
|
|
@@ -868,15 +868,16 @@ describe("connection integration tests", () => {
|
|
|
868
868
|
const envStore = new EnvironmentStore(tempServerRoot);
|
|
869
869
|
await envStore.finishedInitialization;
|
|
870
870
|
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
//
|
|
871
|
+
// initialize() swallows top-level errors and just calls
|
|
872
|
+
// markNotReady(), so finishedInitialization always resolves
|
|
873
|
+
// regardless of success; operationalState is what says it
|
|
874
|
+
// succeeded. It does NOT imply every configured environment
|
|
875
|
+
// and package loaded: a load failure is skipped and still
|
|
876
|
+
// reports "serving", which is why loadErrors exists. Assert
|
|
877
|
+
// on both, and on the package list below.
|
|
878
878
|
const status = await envStore.getStatus();
|
|
879
879
|
expect(status.operationalState).toBe("serving");
|
|
880
|
+
expect(status.loadErrors).toBeUndefined();
|
|
880
881
|
|
|
881
882
|
const env = await envStore.getEnvironment("malloy-samples");
|
|
882
883
|
const apiPackages = await env.listPackages();
|
|
@@ -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,
|
|
@@ -589,6 +605,17 @@ export class Environment {
|
|
|
589
605
|
);
|
|
590
606
|
}
|
|
591
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
|
+
|
|
592
619
|
public async listPackages(): Promise<ApiPackage[]> {
|
|
593
620
|
logger.debug("Listing packages", {
|
|
594
621
|
environmentPath: this.environmentPath,
|
|
@@ -613,6 +640,24 @@ export class Environment {
|
|
|
613
640
|
);
|
|
614
641
|
// Directory did not contain a valid package.json file -- therefore, it's not a package.
|
|
615
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
|
+
);
|
|
616
661
|
return undefined;
|
|
617
662
|
}
|
|
618
663
|
}),
|
|
@@ -876,6 +921,9 @@ export class Environment {
|
|
|
876
921
|
}
|
|
877
922
|
this.packages.set(packageName, _package);
|
|
878
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);
|
|
879
927
|
logger.debug(`Successfully loaded package ${packageName}`);
|
|
880
928
|
|
|
881
929
|
return _package;
|
|
@@ -957,6 +1005,10 @@ export class Environment {
|
|
|
957
1005
|
throw error;
|
|
958
1006
|
}
|
|
959
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);
|
|
960
1012
|
return this.packages.get(packageName);
|
|
961
1013
|
}
|
|
962
1014
|
|
|
@@ -1145,6 +1197,8 @@ export class Environment {
|
|
|
1145
1197
|
|
|
1146
1198
|
this.packages.set(packageName, newPackage);
|
|
1147
1199
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1200
|
+
// Publishing a fixed package clears the boot failure it replaces.
|
|
1201
|
+
this.failedPackages.delete(packageName);
|
|
1148
1202
|
|
|
1149
1203
|
if (oldPackage) {
|
|
1150
1204
|
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
@@ -1312,6 +1366,8 @@ export class Environment {
|
|
|
1312
1366
|
explores?: string[];
|
|
1313
1367
|
queryableSources?: "declared" | "all";
|
|
1314
1368
|
manifestLocation?: string | null;
|
|
1369
|
+
scope?: ApiPackage["scope"];
|
|
1370
|
+
materialization?: ApiPackage["materialization"];
|
|
1315
1371
|
},
|
|
1316
1372
|
): Promise<void> {
|
|
1317
1373
|
const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
@@ -1344,6 +1400,10 @@ export class Environment {
|
|
|
1344
1400
|
...(metadata.manifestLocation !== undefined
|
|
1345
1401
|
? { manifestLocation: metadata.manifestLocation }
|
|
1346
1402
|
: {}),
|
|
1403
|
+
...(metadata.scope !== undefined ? { scope: metadata.scope } : {}),
|
|
1404
|
+
...(metadata.materialization !== undefined
|
|
1405
|
+
? { materialization: metadata.materialization }
|
|
1406
|
+
: {}),
|
|
1347
1407
|
};
|
|
1348
1408
|
|
|
1349
1409
|
// Write back to file
|
|
@@ -1396,6 +1456,29 @@ export class Environment {
|
|
|
1396
1456
|
body.manifestLocation !== undefined
|
|
1397
1457
|
? body.manifestLocation
|
|
1398
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;
|
|
1399
1482
|
_package.setPackageMetadata({
|
|
1400
1483
|
name: body.name,
|
|
1401
1484
|
description: body.description,
|
|
@@ -1404,25 +1487,27 @@ export class Environment {
|
|
|
1404
1487
|
explores,
|
|
1405
1488
|
queryableSources,
|
|
1406
1489
|
manifestLocation,
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
// this wipes the in-memory `materialization` until the next reload —
|
|
1410
|
-
// which makes a later getPackage report no schedule and lets the
|
|
1411
|
-
// control plane misread the gap as a schedule removal. It is not a
|
|
1412
|
-
// PATCH-editable field, so always preserve the existing value.
|
|
1413
|
-
materialization: existing.materialization,
|
|
1414
|
-
// Same rationale for the manifest-derived persist `scope`: not
|
|
1415
|
-
// PATCH-editable, so preserve it rather than dropping it to the
|
|
1416
|
-
// default until the next reload.
|
|
1417
|
-
scope: existing.scope,
|
|
1490
|
+
materialization,
|
|
1491
|
+
scope,
|
|
1418
1492
|
});
|
|
1419
1493
|
|
|
1420
1494
|
// Strict-reject, symmetric with the publish path
|
|
1421
1495
|
// (package.controller.addPackage): validate the resulting explores
|
|
1422
1496
|
// against the live model set and restore the prior metadata before
|
|
1423
1497
|
// rejecting, so a bad update neither persists nor mutates the served
|
|
1424
|
-
// surface.
|
|
1425
|
-
|
|
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");
|
|
1426
1511
|
if (invalidMsg) {
|
|
1427
1512
|
_package.setPackageMetadata(existing);
|
|
1428
1513
|
throw new BadRequestError(invalidMsg);
|
|
@@ -1434,6 +1519,13 @@ export class Environment {
|
|
|
1434
1519
|
explores: normalizedExplores,
|
|
1435
1520
|
queryableSources: body.queryableSources,
|
|
1436
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,
|
|
1437
1529
|
});
|
|
1438
1530
|
|
|
1439
1531
|
// When the body changes manifestLocation, apply it now so the new
|
|
@@ -1455,6 +1547,11 @@ export class Environment {
|
|
|
1455
1547
|
return this.packageStatuses.get(packageName);
|
|
1456
1548
|
}
|
|
1457
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
|
+
|
|
1458
1555
|
public setPackageStatus(packageName: string, status: PackageStatus): void {
|
|
1459
1556
|
const currentStatus = this.packageStatuses.get(packageName);
|
|
1460
1557
|
this.packageStatuses.set(packageName, {
|
|
@@ -1471,6 +1568,14 @@ export class Environment {
|
|
|
1471
1568
|
public async deletePackage(packageName: string): Promise<void> {
|
|
1472
1569
|
assertSafePackageName(packageName);
|
|
1473
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
|
+
|
|
1474
1579
|
const _package = this.packages.get(packageName);
|
|
1475
1580
|
if (!_package) {
|
|
1476
1581
|
return;
|