@malloy-publisher/server 0.0.206 → 0.0.207
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 +31 -0
- package/dist/app/assets/{EnvironmentPage-BYwBeC2F.js → EnvironmentPage-BScgHmkw.js} +1 -1
- package/dist/app/assets/{HomePage-ivu4vdpj.js → HomePage-CGedji_w.js} +1 -1
- package/dist/app/assets/{MainPage-B2DnHEDU.js → MainPage-DWfF4jXW.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-BZEuwF9P.js → MaterializationsPage-B9PDlk8c.js} +1 -1
- package/dist/app/assets/{ModelPage-Dpu3bfPg.js → ModelPage-BiNOgK_e.js} +1 -1
- package/dist/app/assets/{PackagePage-B8PwcRHt.js → PackagePage-DAN5V7gu.js} +1 -1
- package/dist/app/assets/{RouteError-BhbywAeC.js → RouteError-CEnIzuKN.js} +1 -1
- package/dist/app/assets/{WorkbookPage-C-JXsJG0.js → WorkbookPage-gA1ceqHP.js} +1 -1
- package/dist/app/assets/{core-pPlPr7jK.es-CNEOlxKB.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
- package/dist/app/assets/{index-BHEm8Egc.js → index-DGGe8UpP.js} +1 -1
- package/dist/app/assets/{index-ChR1fKR2.js → index-DtlPzNxc.js} +3 -3
- package/dist/app/assets/{index-BsvDrV14.js → index-uu6UpHd2.js} +1 -1
- package/dist/app/assets/{index.umd-BVLPYNuj.js → index.umd-DDq93YX4.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +182 -96
- package/package.json +1 -1
- package/src/materialization_metrics.ts +55 -0
- package/src/service/environment.ts +59 -1
- package/src/service/materialization_service.spec.ts +272 -0
- package/src/service/materialization_service.ts +67 -45
- package/src/service/model.ts +19 -2
- package/src/service/package.ts +92 -22
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +25 -6
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +94 -0
package/src/service/package.ts
CHANGED
|
@@ -48,8 +48,6 @@ type PackageConnectionInput =
|
|
|
48
48
|
| Map<string, Connection>
|
|
49
49
|
| (() => MalloyConfig);
|
|
50
50
|
|
|
51
|
-
const ENABLE_LIST_MODEL_COMPILATION = true;
|
|
52
|
-
|
|
53
51
|
export class Package {
|
|
54
52
|
private environmentName: string;
|
|
55
53
|
private packageName: string;
|
|
@@ -58,6 +56,17 @@ export class Package {
|
|
|
58
56
|
private models: Map<string, Model> = new Map();
|
|
59
57
|
private packagePath: string;
|
|
60
58
|
private malloyConfig: MalloyConfig;
|
|
59
|
+
// Build-manifest binding state (Malloy Persistence v0). When bound, these
|
|
60
|
+
// entries (buildId -> { tableName }) are what served queries use to route
|
|
61
|
+
// persist sources to their materialized physical tables; they are also reused
|
|
62
|
+
// by the /compile preview so previewed SQL matches executed SQL. Surfaced on
|
|
63
|
+
// /status (via getPackageMetadata) so the control plane can confirm a worker
|
|
64
|
+
// actually bound the distributed manifest instead of inferring it from logs.
|
|
65
|
+
private buildManifestEntries: BuildManifest["entries"] | undefined;
|
|
66
|
+
private manifestBindingStatus: "unbound" | "bound" | "live_fallback" =
|
|
67
|
+
"unbound";
|
|
68
|
+
private manifestEntryCount = 0;
|
|
69
|
+
private boundManifestUri: string | null = null;
|
|
61
70
|
private static meter = metrics.getMeter("publisher");
|
|
62
71
|
private static packageLoadHistogram = this.meter.createHistogram(
|
|
63
72
|
"malloy_package_load_duration",
|
|
@@ -389,14 +398,76 @@ export class Package {
|
|
|
389
398
|
}
|
|
390
399
|
|
|
391
400
|
public getPackageMetadata(): ApiPackage {
|
|
392
|
-
//
|
|
393
|
-
// fail-safe — the package still
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
401
|
+
// Overlay the server-computed fields onto the stored metadata: the
|
|
402
|
+
// explores misconfig warnings (loading is fail-safe — the package still
|
|
403
|
+
// serves with the bad entry hidden — so this is the only non-log signal
|
|
404
|
+
// that it's broken) and the manifest-binding state (so /status reflects
|
|
405
|
+
// whether persist sources are actually routed to materialized tables).
|
|
406
|
+
// Always returns a copy so these overlays never mutate the stored
|
|
407
|
+
// metadata; the binding fields are authoritative from the private state.
|
|
408
|
+
//
|
|
409
|
+
// `name` is the registered package name (the environment's identity for
|
|
410
|
+
// this package), not the value read from the package's own manifest —
|
|
411
|
+
// those can differ (e.g. a package installed under a different name than
|
|
412
|
+
// its publisher.json declares). `listPackages` already overrides it to
|
|
413
|
+
// the registered name; surfacing it here keeps the single-package GET
|
|
414
|
+
// consistent without relying on a returned-reference mutation.
|
|
415
|
+
const metadata: ApiPackage = {
|
|
416
|
+
...this.packageMetadata,
|
|
417
|
+
name: this.packageName,
|
|
418
|
+
manifestBindingStatus: this.manifestBindingStatus,
|
|
419
|
+
manifestEntryCount: this.manifestEntryCount,
|
|
420
|
+
boundManifestUri: this.boundManifestUri,
|
|
421
|
+
};
|
|
397
422
|
const warnings = this.exploreWarnings();
|
|
398
|
-
if (warnings.length
|
|
399
|
-
|
|
423
|
+
if (warnings.length > 0) {
|
|
424
|
+
metadata.exploresWarnings = warnings;
|
|
425
|
+
}
|
|
426
|
+
return metadata;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The currently-bound build-manifest entries (buildId -> { tableName }), or
|
|
431
|
+
* undefined when the package is serving live. Reused by the /compile preview
|
|
432
|
+
* so previewed SQL gets the same persist-source -> physical-table routing as
|
|
433
|
+
* execution.
|
|
434
|
+
*/
|
|
435
|
+
public getBuildManifestEntries(): BuildManifest["entries"] | undefined {
|
|
436
|
+
return this.buildManifestEntries;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Record the URI whose manifest is currently bound to the served models. May
|
|
441
|
+
* differ from `manifestLocation` after an in-memory auto-load following a
|
|
442
|
+
* materialization build (no URI), in which case it stays null.
|
|
443
|
+
*/
|
|
444
|
+
public setBoundManifestUri(uri: string | null): void {
|
|
445
|
+
this.boundManifestUri = uri;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Mark that a configured `manifestLocation` could not be fetched/bound, so
|
|
450
|
+
* the package is serving live despite intending to be materialized-routed.
|
|
451
|
+
*/
|
|
452
|
+
public markManifestBindFailed(): void {
|
|
453
|
+
this.manifestBindingStatus = "live_fallback";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Store the entries applied by the latest (re)bind for reuse (/compile) and
|
|
458
|
+
* observability (/status). An empty map means the manifest was cleared, so
|
|
459
|
+
* the package reverts to live (unbound).
|
|
460
|
+
*/
|
|
461
|
+
private recordManifestBinding(
|
|
462
|
+
buildManifest: BuildManifest["entries"],
|
|
463
|
+
): void {
|
|
464
|
+
const count = Object.keys(buildManifest).length;
|
|
465
|
+
this.buildManifestEntries = count > 0 ? buildManifest : undefined;
|
|
466
|
+
this.manifestEntryCount = count;
|
|
467
|
+
this.manifestBindingStatus = count > 0 ? "bound" : "unbound";
|
|
468
|
+
if (count === 0) {
|
|
469
|
+
this.boundManifestUri = null;
|
|
470
|
+
}
|
|
400
471
|
}
|
|
401
472
|
|
|
402
473
|
/**
|
|
@@ -595,6 +666,7 @@ export class Package {
|
|
|
595
666
|
this.packagePath,
|
|
596
667
|
this.malloyConfig,
|
|
597
668
|
sm,
|
|
669
|
+
{ buildManifest },
|
|
598
670
|
);
|
|
599
671
|
// Validate renderer tags here too (loadViaWorker does it for the
|
|
600
672
|
// create path). Reload keeps per-model placeholders rather than
|
|
@@ -636,6 +708,9 @@ export class Package {
|
|
|
636
708
|
outcome.packageMetadata.manifestLocation ?? null;
|
|
637
709
|
this.applyDiscoveryPolicyToModels();
|
|
638
710
|
this.applyQueryBoundaryToModels();
|
|
711
|
+
// Remember what we just bound so /compile can route identically and
|
|
712
|
+
// /status can report the binding. An empty map reverts to live (unbound).
|
|
713
|
+
this.recordManifestBinding(buildManifest);
|
|
639
714
|
// Re-run the fail-safe warning against the refreshed model set: an edit
|
|
640
715
|
// to publisher.json that introduces a bad entry should surface in the
|
|
641
716
|
// logs on reload too, not only at initial load (loadViaWorker).
|
|
@@ -672,15 +747,13 @@ export class Package {
|
|
|
672
747
|
})
|
|
673
748
|
.map(async (modelPath) => {
|
|
674
749
|
let error: string | undefined;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
modelError
|
|
681
|
-
|
|
682
|
-
: undefined;
|
|
683
|
-
}
|
|
750
|
+
try {
|
|
751
|
+
await this.models.get(modelPath)?.getModel();
|
|
752
|
+
} catch (modelError) {
|
|
753
|
+
error =
|
|
754
|
+
modelError instanceof Error
|
|
755
|
+
? modelError.message
|
|
756
|
+
: undefined;
|
|
684
757
|
}
|
|
685
758
|
return {
|
|
686
759
|
environmentName: this.environmentName,
|
|
@@ -700,10 +773,7 @@ export class Package {
|
|
|
700
773
|
return modelPath.endsWith(NOTEBOOK_FILE_SUFFIX);
|
|
701
774
|
})
|
|
702
775
|
.map(async (modelPath) => {
|
|
703
|
-
|
|
704
|
-
if (ENABLE_LIST_MODEL_COMPILATION) {
|
|
705
|
-
error = this.models.get(modelPath)?.getNotebookError();
|
|
706
|
-
}
|
|
776
|
+
const error = this.models.get(modelPath)?.getNotebookError();
|
|
707
777
|
return {
|
|
708
778
|
environmentName: this.environmentName,
|
|
709
779
|
packageName: this.packageName,
|
|
@@ -133,25 +133,38 @@ describe("Manifest binding via Package.manifestLocation (E2E)", () => {
|
|
|
133
133
|
async () => {
|
|
134
134
|
const manifestFile = await writeManifest();
|
|
135
135
|
|
|
136
|
-
// Accept on update; the response echoes the bound location
|
|
136
|
+
// Accept on update; the response echoes the bound location and the
|
|
137
|
+
// server-computed binding observability fields (surfaced on /status so
|
|
138
|
+
// the control plane can confirm the worker actually bound the manifest).
|
|
137
139
|
const patchRes = await patchPackage({
|
|
138
140
|
manifestLocation: manifestFile,
|
|
139
141
|
});
|
|
140
142
|
expect(patchRes.status).toBe(200);
|
|
141
|
-
|
|
143
|
+
const patched = await patchRes.json();
|
|
144
|
+
expect(patched.manifestLocation).toBe(manifestFile);
|
|
145
|
+
expect(patched.manifestBindingStatus).toBe("bound");
|
|
146
|
+
expect(patched.manifestEntryCount).toBe(1);
|
|
147
|
+
expect(patched.boundManifestUri).toBe(manifestFile);
|
|
142
148
|
|
|
143
149
|
// In-memory metadata reflects it; binding did not break serving.
|
|
144
|
-
|
|
150
|
+
const bound = await getPackage();
|
|
151
|
+
expect(bound.manifestLocation).toBe(manifestFile);
|
|
152
|
+
expect(bound.manifestBindingStatus).toBe("bound");
|
|
153
|
+
expect(bound.manifestEntryCount).toBe(1);
|
|
145
154
|
expect(await queryOrderSummaryStatus()).toBe(200);
|
|
146
155
|
|
|
147
156
|
// Persisted to publisher.json: a reload re-reads and re-binds it.
|
|
148
157
|
expect((await getPackage(true)).manifestLocation).toBe(manifestFile);
|
|
149
158
|
expect(await queryOrderSummaryStatus()).toBe(200);
|
|
150
159
|
|
|
151
|
-
// Clearing reverts to live and survives a reload.
|
|
160
|
+
// Clearing reverts to live (unbound) and survives a reload.
|
|
152
161
|
const clearRes = await patchPackage({ manifestLocation: null });
|
|
153
162
|
expect(clearRes.status).toBe(200);
|
|
154
|
-
|
|
163
|
+
const cleared = await clearRes.json();
|
|
164
|
+
expect(cleared.manifestLocation ?? null).toBeNull();
|
|
165
|
+
expect(cleared.manifestBindingStatus).toBe("unbound");
|
|
166
|
+
expect(cleared.manifestEntryCount).toBe(0);
|
|
167
|
+
expect(cleared.boundManifestUri ?? null).toBeNull();
|
|
155
168
|
expect((await getPackage(true)).manifestLocation ?? null).toBeNull();
|
|
156
169
|
expect(await queryOrderSummaryStatus()).toBe(200);
|
|
157
170
|
},
|
|
@@ -163,7 +176,13 @@ describe("Manifest binding via Package.manifestLocation (E2E)", () => {
|
|
|
163
176
|
|
|
164
177
|
const patchRes = await patchPackage({ manifestLocation: missing });
|
|
165
178
|
expect(patchRes.status).toBe(200);
|
|
166
|
-
|
|
179
|
+
const patched = await patchRes.json();
|
|
180
|
+
expect(patched.manifestLocation).toBe(missing);
|
|
181
|
+
// The configured location is retained, but the binding is reported as a
|
|
182
|
+
// degraded live fallback (not "bound") with nothing actually bound.
|
|
183
|
+
expect(patched.manifestBindingStatus).toBe("live_fallback");
|
|
184
|
+
expect(patched.manifestEntryCount).toBe(0);
|
|
185
|
+
expect(patched.boundManifestUri ?? null).toBeNull();
|
|
167
186
|
|
|
168
187
|
// A fetch failure must not brick the package — it still serves live.
|
|
169
188
|
expect(await queryOrderSummaryStatus()).toBe(200);
|
|
@@ -342,6 +342,100 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
|
|
|
342
342
|
);
|
|
343
343
|
});
|
|
344
344
|
|
|
345
|
+
// ── Group A1: Serve-time routing (the v0 payoff) ─────────────────
|
|
346
|
+
//
|
|
347
|
+
// Materialization only pays off if *served* queries scan the materialized
|
|
348
|
+
// table instead of recomputing from the base table. The auto-run path builds
|
|
349
|
+
// the table and auto-loads the manifest into the serving models in one
|
|
350
|
+
// process, so routing is provable in-process: capture the live SQL (scans the
|
|
351
|
+
// base CSV), run an auto-run materialization, then assert both the executed
|
|
352
|
+
// query SQL and the /compile preview SQL now scan the physical table and no
|
|
353
|
+
// longer touch the base CSV. The /compile half guards the parity fix
|
|
354
|
+
// (Environment.compileSource threads the package's bound manifest).
|
|
355
|
+
describe("serve-time routing (auto-load)", () => {
|
|
356
|
+
const MODEL_PATH = "persist_test.malloy";
|
|
357
|
+
const QUERY = "run: order_summary -> { aggregate: c is count() }";
|
|
358
|
+
|
|
359
|
+
async function executedSql(): Promise<string> {
|
|
360
|
+
const res = await fetch(
|
|
361
|
+
`${baseUrl}${API}/models/${MODEL_PATH}/query`,
|
|
362
|
+
{
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: { "Content-Type": "application/json" },
|
|
365
|
+
body: JSON.stringify({ query: QUERY }),
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
expect(res.status).toBe(200);
|
|
369
|
+
const body = (await res.json()) as { result: string };
|
|
370
|
+
return (JSON.parse(body.result) as { sql: string }).sql;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function compiledSql(): Promise<string> {
|
|
374
|
+
const res = await fetch(
|
|
375
|
+
`${baseUrl}${API}/models/${MODEL_PATH}/compile`,
|
|
376
|
+
{
|
|
377
|
+
method: "POST",
|
|
378
|
+
headers: { "Content-Type": "application/json" },
|
|
379
|
+
body: JSON.stringify({ source: QUERY, includeSql: true }),
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
expect(res.status).toBe(200);
|
|
383
|
+
const body = (await res.json()) as { status: string; sql?: string };
|
|
384
|
+
expect(body.status).toBe("success");
|
|
385
|
+
expect(body.sql).toBeDefined();
|
|
386
|
+
return body.sql as string;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
it(
|
|
390
|
+
"routes served + compiled queries to the materialized table after auto-load",
|
|
391
|
+
async () => {
|
|
392
|
+
// Reset to live: a prior group may have left an in-memory binding.
|
|
393
|
+
// Auto-load never writes manifestLocation to publisher.json, so a
|
|
394
|
+
// reload re-reads the live model.
|
|
395
|
+
await fetch(`${baseUrl}${API}?reload=true`);
|
|
396
|
+
|
|
397
|
+
// Baseline: with nothing materialized, both paths recompute from the
|
|
398
|
+
// base CSV.
|
|
399
|
+
expect(await executedSql()).toContain("data/orders.csv");
|
|
400
|
+
expect(await compiledSql()).toContain("data/orders.csv");
|
|
401
|
+
|
|
402
|
+
// Build + auto-load in one pass (self-assigns physicalTableName =
|
|
403
|
+
// "order_summary" from `#@ persist name="order_summary"`).
|
|
404
|
+
const createRes = await createMaterialization({
|
|
405
|
+
pauseBetweenPhases: false,
|
|
406
|
+
});
|
|
407
|
+
expect(createRes.status).toBe(201);
|
|
408
|
+
const { id } = (await createRes.json()) as { id: string };
|
|
409
|
+
const built = await pollUntil(
|
|
410
|
+
id,
|
|
411
|
+
(s) => TERMINAL_STATUSES.includes(s),
|
|
412
|
+
90_000,
|
|
413
|
+
);
|
|
414
|
+
expect(built.status).toBe("MANIFEST_FILE_READY");
|
|
415
|
+
|
|
416
|
+
// The payoff: the served query now scans the materialized table and
|
|
417
|
+
// no longer recomputes from the base CSV, and /compile previews the
|
|
418
|
+
// same routed SQL it would execute.
|
|
419
|
+
const routedExecuted = await executedSql();
|
|
420
|
+
const routedCompiled = await compiledSql();
|
|
421
|
+
expect(routedExecuted).not.toContain("data/orders.csv");
|
|
422
|
+
expect(routedExecuted).toContain("order_summary");
|
|
423
|
+
expect(routedCompiled).not.toContain("data/orders.csv");
|
|
424
|
+
expect(routedCompiled).toContain("order_summary");
|
|
425
|
+
|
|
426
|
+
// Cleanup: drop the table + record, then reload back to live so the
|
|
427
|
+
// dangling binding doesn't leak into later groups.
|
|
428
|
+
const deleteRes = await fetch(
|
|
429
|
+
url(`/materializations/${id}?dropTables=true`),
|
|
430
|
+
{ method: "DELETE" },
|
|
431
|
+
);
|
|
432
|
+
expect(deleteRes.status).toBe(204);
|
|
433
|
+
await fetch(`${baseUrl}${API}?reload=true`);
|
|
434
|
+
},
|
|
435
|
+
{ timeout: 120_000 },
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
345
439
|
// ── Group B: State machine and error cases ───────────────────────
|
|
346
440
|
|
|
347
441
|
describe("state machine and errors", () => {
|