@malloy-publisher/server 0.0.235 → 0.0.236
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 +47 -17
- package/dist/package_load_worker.mjs +8 -1
- package/dist/server.mjs +471 -54
- package/package.json +5 -2
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -97
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.example.bigquery.json +0 -33
- package/publisher.config.example.duckdb.json +0 -23
- package/publisher.config.json +0 -23
- package/scripts/bake-duckdb-extensions.js +0 -110
- package/src/config.spec.ts +0 -1535
- package/src/config.theme.spec.ts +0 -178
- package/src/config.ts +0 -1142
- package/src/constants.ts +0 -105
- package/src/controller/compile.controller.ts +0 -40
- package/src/controller/connection.controller.spec.ts +0 -849
- package/src/controller/connection.controller.ts +0 -901
- package/src/controller/database.controller.ts +0 -24
- package/src/controller/materialization.controller.spec.ts +0 -293
- package/src/controller/materialization.controller.ts +0 -319
- package/src/controller/model.controller.ts +0 -164
- package/src/controller/package.controller.spec.ts +0 -273
- package/src/controller/package.controller.ts +0 -306
- package/src/controller/query.controller.ts +0 -152
- package/src/controller/theme.controller.ts +0 -83
- package/src/controller/watch-mode.controller.ts +0 -243
- package/src/data_styles.ts +0 -71
- package/src/default-publisher.config.json +0 -23
- package/src/ducklake_version.spec.ts +0 -163
- package/src/ducklake_version.ts +0 -153
- package/src/errors.spec.ts +0 -119
- package/src/errors.ts +0 -237
- package/src/filter_deprecation.spec.ts +0 -64
- package/src/filter_deprecation.ts +0 -42
- package/src/health.spec.ts +0 -123
- package/src/health.ts +0 -233
- package/src/heap_check.spec.ts +0 -144
- package/src/heap_check.ts +0 -144
- package/src/instrumentation.ts +0 -214
- package/src/json_utils.spec.ts +0 -51
- package/src/json_utils.ts +0 -33
- package/src/logger.spec.ts +0 -210
- package/src/logger.ts +0 -251
- package/src/malloy_pin_prereqs.spec.ts +0 -25
- package/src/materialization_metrics.spec.ts +0 -169
- package/src/materialization_metrics.ts +0 -346
- package/src/mcp/error_messages.spec.ts +0 -35
- package/src/mcp/error_messages.ts +0 -183
- package/src/mcp/handler_utils.spec.ts +0 -108
- package/src/mcp/handler_utils.ts +0 -251
- package/src/mcp/mcp_constants.ts +0 -6
- package/src/mcp/query_envelope.spec.ts +0 -229
- package/src/mcp/query_envelope.ts +0 -240
- package/src/mcp/server.protocol.spec.ts +0 -250
- package/src/mcp/server.ts +0 -87
- package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
- package/src/mcp/skills/build_skills_bundle.ts +0 -176
- package/src/mcp/skills/skills_bundle.json +0 -1
- package/src/mcp/skills/skills_bundle.spec.ts +0 -171
- package/src/mcp/tool_response.spec.ts +0 -108
- package/src/mcp/tool_response.ts +0 -138
- package/src/mcp/tools/compile_tool.spec.ts +0 -315
- package/src/mcp/tools/compile_tool.ts +0 -208
- package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
- package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
- package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
- package/src/mcp/tools/docs_search_tool.ts +0 -128
- package/src/mcp/tools/embedding_index.spec.ts +0 -1236
- package/src/mcp/tools/embedding_index.ts +0 -808
- package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
- package/src/mcp/tools/execute_query_tool.ts +0 -316
- package/src/mcp/tools/get_context_eval.ts +0 -275
- package/src/mcp/tools/get_context_tool.spec.ts +0 -592
- package/src/mcp/tools/get_context_tool.ts +0 -585
- package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
- package/src/mcp/tools/reload_package_tool.ts +0 -132
- package/src/mcp_config.spec.ts +0 -919
- package/src/mcp_config.ts +0 -425
- package/src/oom_guards.integration.spec.ts +0 -269
- package/src/package_load/package_load_pool.spec.ts +0 -252
- package/src/package_load/package_load_pool.ts +0 -931
- package/src/package_load/package_load_worker.ts +0 -1034
- package/src/package_load/protocol.ts +0 -364
- package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
- package/src/package_load/rpc_wait_accountant.ts +0 -76
- package/src/package_load_metrics.spec.ts +0 -114
- package/src/package_load_metrics.ts +0 -127
- package/src/path_safety.spec.ts +0 -158
- package/src/path_safety.ts +0 -146
- package/src/pg_helpers.spec.ts +0 -223
- package/src/pg_helpers.ts +0 -52
- package/src/query_cap_metrics.spec.ts +0 -89
- package/src/query_cap_metrics.ts +0 -116
- package/src/query_concurrency.spec.ts +0 -247
- package/src/query_concurrency.ts +0 -237
- package/src/query_metadata_metrics.ts +0 -49
- package/src/query_param_utils.ts +0 -29
- package/src/query_timeout.spec.ts +0 -224
- package/src/query_timeout.ts +0 -179
- package/src/runtime/publisher.js +0 -328
- package/src/server-old.ts +0 -988
- package/src/server.ts +0 -2059
- package/src/service/annotations.spec.ts +0 -118
- package/src/service/annotations.ts +0 -91
- package/src/service/authorize.spec.ts +0 -154
- package/src/service/authorize.ts +0 -498
- package/src/service/authorize_integration.spec.ts +0 -2041
- package/src/service/build_plan.spec.ts +0 -685
- package/src/service/build_plan.ts +0 -797
- package/src/service/compile_authorize.spec.ts +0 -85
- package/src/service/compile_fragment_techniques.spec.ts +0 -156
- package/src/service/connection.spec.ts +0 -2360
- package/src/service/connection.ts +0 -2187
- package/src/service/connection_config.spec.ts +0 -734
- package/src/service/connection_config.ts +0 -899
- package/src/service/connection_federation.spec.ts +0 -184
- package/src/service/connection_fingerprint.spec.ts +0 -102
- package/src/service/connection_service.spec.ts +0 -711
- package/src/service/connection_service.ts +0 -291
- package/src/service/cron_evaluator.spec.ts +0 -79
- package/src/service/cron_evaluator.ts +0 -105
- package/src/service/db_utils.spec.ts +0 -1085
- package/src/service/db_utils.ts +0 -1502
- package/src/service/duckdb_instance_isolation.spec.ts +0 -137
- package/src/service/ducklake_lazy_attach.spec.ts +0 -110
- package/src/service/embedding_provider.spec.ts +0 -329
- package/src/service/embedding_provider.ts +0 -236
- package/src/service/environment.ts +0 -2191
- package/src/service/environment_admission.spec.ts +0 -344
- package/src/service/environment_compile.spec.ts +0 -197
- package/src/service/environment_store.spec.ts +0 -2274
- package/src/service/environment_store.ts +0 -2434
- package/src/service/environment_store_anchoring.spec.ts +0 -107
- package/src/service/environment_store_clone.spec.ts +0 -350
- package/src/service/explore_visibility.spec.ts +0 -434
- package/src/service/exports_probe.spec.ts +0 -107
- package/src/service/extension_fetch_policy.spec.ts +0 -256
- package/src/service/filter.spec.ts +0 -458
- package/src/service/filter.ts +0 -341
- package/src/service/filter_bypass.spec.ts +0 -418
- package/src/service/filter_integration.spec.ts +0 -1004
- package/src/service/freshness.spec.ts +0 -183
- package/src/service/freshness.ts +0 -112
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/given.ts +0 -105
- package/src/service/givens_integration.spec.ts +0 -219
- package/src/service/manifest_loader.spec.ts +0 -189
- package/src/service/manifest_loader.ts +0 -156
- package/src/service/materialization_build_session.spec.ts +0 -435
- package/src/service/materialization_build_session.ts +0 -681
- package/src/service/materialization_config_validation.spec.ts +0 -99
- package/src/service/materialization_config_validation.ts +0 -120
- package/src/service/materialization_eligibility.spec.ts +0 -158
- package/src/service/materialization_eligibility.ts +0 -305
- package/src/service/materialization_schedule_surface.spec.ts +0 -256
- package/src/service/materialization_scheduler.spec.ts +0 -289
- package/src/service/materialization_scheduler.ts +0 -304
- package/src/service/materialization_serve_transform.spec.ts +0 -1003
- package/src/service/materialization_serve_transform.ts +0 -779
- package/src/service/materialization_service.spec.ts +0 -2373
- package/src/service/materialization_service.ts +0 -2342
- package/src/service/materialization_test_fixtures.ts +0 -249
- package/src/service/model.spec.ts +0 -1157
- package/src/service/model.ts +0 -3370
- package/src/service/model_limits.spec.ts +0 -209
- package/src/service/model_limits.ts +0 -131
- package/src/service/model_storage_serve.spec.ts +0 -193
- package/src/service/model_storage_serve_joins.spec.ts +0 -193
- package/src/service/package.spec.ts +0 -612
- package/src/service/package.ts +0 -1799
- package/src/service/package_manifest.spec.ts +0 -230
- package/src/service/package_manifest.ts +0 -241
- package/src/service/package_memory_governor.spec.ts +0 -173
- package/src/service/package_memory_governor.ts +0 -233
- package/src/service/package_quote_bound_tables.spec.ts +0 -171
- package/src/service/package_race.spec.ts +0 -208
- package/src/service/package_reload_safety.spec.ts +0 -193
- package/src/service/package_rollback.spec.ts +0 -190
- package/src/service/package_worker_path.spec.ts +0 -645
- package/src/service/path_injection.spec.ts +0 -39
- package/src/service/persist_annotation_validation.spec.ts +0 -89
- package/src/service/persist_annotation_validation.ts +0 -59
- package/src/service/persistence_policy.spec.ts +0 -374
- package/src/service/proxy.spec.ts +0 -414
- package/src/service/proxy.ts +0 -248
- package/src/service/query_boundary.spec.ts +0 -470
- package/src/service/query_metadata.spec.ts +0 -408
- package/src/service/query_metadata.ts +0 -492
- package/src/service/query_metadata_identity.spec.ts +0 -149
- package/src/service/query_text.spec.ts +0 -79
- package/src/service/query_text.ts +0 -43
- package/src/service/quoting.spec.ts +0 -135
- package/src/service/quoting.ts +0 -78
- package/src/service/resolve_environment.ts +0 -15
- package/src/service/restricted_mode.spec.ts +0 -299
- package/src/service/source_extraction.ts +0 -225
- package/src/service/theme_store.ts +0 -199
- package/src/storage/DatabaseInterface.ts +0 -198
- package/src/storage/StorageManager.ts +0 -113
- package/src/storage/duckdb/ConnectionRepository.ts +0 -143
- package/src/storage/duckdb/DuckDBConnection.ts +0 -123
- package/src/storage/duckdb/DuckDBRepository.ts +0 -208
- package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
- package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
- package/src/storage/duckdb/MaterializationRepository.ts +0 -336
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/schema.ts +0 -229
- package/src/stream_helpers.spec.ts +0 -280
- package/src/stream_helpers.ts +0 -162
- package/src/telemetry.ts +0 -20
- package/src/test_helpers/metrics_harness.ts +0 -166
- package/src/theme_key_parity.spec.ts +0 -57
- package/src/utils.ts +0 -29
- package/tests/fixtures/authorize-compile/model.malloy +0 -9
- package/tests/fixtures/authorize-compile/publisher.json +0 -4
- package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
- package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/data.csv +0 -3
- package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
- package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
- package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
- package/tests/fixtures/html-pages-test/public/data.json +0 -1
- package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
- package/tests/fixtures/html-pages-test/public/index.html +0 -9
- package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
- package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
- package/tests/fixtures/html-pages-test/public/slides.html +0 -12
- package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
- package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
- package/tests/fixtures/html-pages-test/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/report.malloy +0 -1
- package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
- package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
- package/tests/fixtures/persist-multi-level/publisher.json +0 -5
- package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/fixtures/query-givens/data/orders.csv +0 -7
- package/tests/fixtures/query-givens/model.malloy +0 -34
- package/tests/fixtures/query-givens/publisher.json +0 -5
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/harness/mcp_test_setup.ts +0 -332
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
- package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
- package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
- package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
- package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
- package/tests/unit/controller/theme.controller.spec.ts +0 -141
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
- package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
- package/tests/unit/duckdb/repositories.test.ts +0 -208
- package/tests/unit/ducklake/ducklake.test.ts +0 -952
- package/tests/unit/service/theme_store.spec.ts +0 -139
- package/tsconfig.json +0 -32
|
@@ -1,2191 +0,0 @@
|
|
|
1
|
-
import type { GivenValue, LogMessage } from "@malloydata/malloy";
|
|
2
|
-
import { MalloyError, Runtime } from "@malloydata/malloy";
|
|
3
|
-
import { publisherMeter } from "../telemetry";
|
|
4
|
-
import { Mutex } from "async-mutex";
|
|
5
|
-
import crypto from "crypto";
|
|
6
|
-
import * as fs from "fs";
|
|
7
|
-
import * as path from "path";
|
|
8
|
-
import { pathToFileURL } from "url";
|
|
9
|
-
import { components } from "../api";
|
|
10
|
-
import {
|
|
11
|
-
API_PREFIX,
|
|
12
|
-
normalizeModelPath,
|
|
13
|
-
NOTEBOOK_FILE_SUFFIX,
|
|
14
|
-
README_NAME,
|
|
15
|
-
} from "../constants";
|
|
16
|
-
import {
|
|
17
|
-
BadRequestError,
|
|
18
|
-
ConnectionNotFoundError,
|
|
19
|
-
EnvironmentNotFoundError,
|
|
20
|
-
PackageNotFoundError,
|
|
21
|
-
ServiceUnavailableError,
|
|
22
|
-
} from "../errors";
|
|
23
|
-
import { getPersistStorageMode } from "../config";
|
|
24
|
-
import { logger } from "../logger";
|
|
25
|
-
import { redactPgSecrets } from "../pg_helpers";
|
|
26
|
-
import { recordManifestBind } from "../materialization_metrics";
|
|
27
|
-
import {
|
|
28
|
-
assertSafeEnvironmentPath,
|
|
29
|
-
assertSafePackageName,
|
|
30
|
-
assertSafeRelativeModelPath,
|
|
31
|
-
safeJoinUnderRoot,
|
|
32
|
-
} from "../path_safety";
|
|
33
|
-
import { FreshnessManifest, ManifestEntry } from "../storage/DatabaseInterface";
|
|
34
|
-
import { URL_READER } from "../utils";
|
|
35
|
-
import {
|
|
36
|
-
buildEnvironmentMalloyConfig,
|
|
37
|
-
deleteDuckLakeConnectionFile,
|
|
38
|
-
EnvironmentMalloyConfig,
|
|
39
|
-
InternalConnection,
|
|
40
|
-
} from "./connection";
|
|
41
|
-
import {
|
|
42
|
-
fetchManifestEntries,
|
|
43
|
-
splitManifestEntries,
|
|
44
|
-
type FetchedManifest,
|
|
45
|
-
} from "./manifest_loader";
|
|
46
|
-
import { ApiConnection } from "./model";
|
|
47
|
-
import { Package } from "./package";
|
|
48
|
-
import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Sibling dirs under `environmentPath` used by the install/delete pipeline so
|
|
52
|
-
* that long downloads do not hold the per-package mutex.
|
|
53
|
-
*
|
|
54
|
-
* - `.staging/<pkg>-<uuid>/` — a download in progress. Renamed to the
|
|
55
|
-
* canonical path under the lock once complete.
|
|
56
|
-
* - `.retired/<pkg>-<uuid>/` — the previous canonical tree, atomically
|
|
57
|
-
* renamed out of the way during a swap or delete. `fs.rm`'d asynchronously
|
|
58
|
-
* after the lock is released.
|
|
59
|
-
*
|
|
60
|
-
* Both names start with a `.` so the package walkers (which use
|
|
61
|
-
* {@link ignoreDotfiles}) skip them.
|
|
62
|
-
*/
|
|
63
|
-
const STAGING_DIR_NAME = ".staging";
|
|
64
|
-
const RETIRED_DIR_NAME = ".retired";
|
|
65
|
-
|
|
66
|
-
// How long to wait for a control-plane manifest fetch during (re)bind before
|
|
67
|
-
// giving up and serving live. Binding happens before a package is marked
|
|
68
|
-
// SERVING, so an unreachable/slow manifest store must not block the package.
|
|
69
|
-
const MANIFEST_FETCH_TIMEOUT_MS = 15_000;
|
|
70
|
-
|
|
71
|
-
export enum PackageStatus {
|
|
72
|
-
LOADING = "loading",
|
|
73
|
-
SERVING = "serving",
|
|
74
|
-
UNLOADING = "unloading",
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
interface PackageInfo {
|
|
78
|
-
name: string;
|
|
79
|
-
loadTimestamp: number;
|
|
80
|
-
status: PackageStatus;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
type ApiPackage = components["schemas"]["Package"];
|
|
84
|
-
type ApiEnvironment = components["schemas"]["Environment"];
|
|
85
|
-
type RetiredConnectionGeneration = {
|
|
86
|
-
label: string;
|
|
87
|
-
releaseConnections: () => Promise<void>;
|
|
88
|
-
timer?: ReturnType<typeof setTimeout>;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const RETIRED_CONNECTION_DRAIN_MS = 30_000;
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Module-scoped admission-rejection counters. Lazy-initialized so
|
|
95
|
-
* the OTel JS `ProxyMeter` cannot strand them on a NoOp instrument
|
|
96
|
-
* created before the SDK MeterProvider was registered (a real risk
|
|
97
|
-
* in unit tests; see comment in `query_timeout.ts`). Environment
|
|
98
|
-
* name is attached as a label so dashboards can identify hot
|
|
99
|
-
* environments without grepping logs.
|
|
100
|
-
*/
|
|
101
|
-
import { type Counter } from "@opentelemetry/api";
|
|
102
|
-
let queryAdmissionRejectionsCounter: Counter | null = null;
|
|
103
|
-
let packageAdmissionRejectionsCounter: Counter | null = null;
|
|
104
|
-
function getQueryAdmissionRejectionsCounter(): Counter {
|
|
105
|
-
if (queryAdmissionRejectionsCounter) return queryAdmissionRejectionsCounter;
|
|
106
|
-
queryAdmissionRejectionsCounter = publisherMeter().createCounter(
|
|
107
|
-
"publisher_query_admission_rejections_total",
|
|
108
|
-
{
|
|
109
|
-
description:
|
|
110
|
-
"Queries rejected with 503 because Environment.assertCanAdmitQuery() observed memory back-pressure",
|
|
111
|
-
},
|
|
112
|
-
);
|
|
113
|
-
return queryAdmissionRejectionsCounter;
|
|
114
|
-
}
|
|
115
|
-
function getPackageAdmissionRejectionsCounter(): Counter {
|
|
116
|
-
if (packageAdmissionRejectionsCounter) {
|
|
117
|
-
return packageAdmissionRejectionsCounter;
|
|
118
|
-
}
|
|
119
|
-
packageAdmissionRejectionsCounter = publisherMeter().createCounter(
|
|
120
|
-
"publisher_package_admission_rejections_total",
|
|
121
|
-
{
|
|
122
|
-
description:
|
|
123
|
-
"Package loads rejected with 503 because Environment.assertCanAdmitNewPackage() observed memory back-pressure",
|
|
124
|
-
},
|
|
125
|
-
);
|
|
126
|
-
return packageAdmissionRejectionsCounter;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Visible for tests; production code never calls this. Resets the
|
|
131
|
-
* lazy caches so a fresh MeterProvider can capture future writes.
|
|
132
|
-
*/
|
|
133
|
-
export function resetAdmissionTelemetryForTesting(): void {
|
|
134
|
-
queryAdmissionRejectionsCounter = null;
|
|
135
|
-
packageAdmissionRejectionsCounter = null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export class Environment {
|
|
139
|
-
private packages: Map<string, Package> = new Map();
|
|
140
|
-
// Lock ordering: connectionMutex (environment) MUST be acquired before any
|
|
141
|
-
// packageMutex. Connection updates may invalidate cached package
|
|
142
|
-
// MalloyConfigs and force reloads, so the environment lock is the outer one.
|
|
143
|
-
// Never acquire connectionMutex while holding a packageMutex — that's the
|
|
144
|
-
// AB/BA deadlock path.
|
|
145
|
-
private packageMutexes = new Map<string, Mutex>();
|
|
146
|
-
private packageStatuses: Map<string, PackageInfo> = new Map();
|
|
147
|
-
/**
|
|
148
|
-
* Configured packages that failed to load, keyed by name, with the reason.
|
|
149
|
-
*
|
|
150
|
-
* A load failure is not fatal: the package is omitted and its siblings serve
|
|
151
|
-
* on. It is also observable exactly once, because the failing load deletes
|
|
152
|
-
* the `packageStatuses` entry that `listPackages` enumerates, so the next
|
|
153
|
-
* listing no longer knows the package was ever configured. That makes this
|
|
154
|
-
* the only lasting record. Read by EnvironmentStore.getStatus.
|
|
155
|
-
*/
|
|
156
|
-
private failedPackages: Map<string, string> = new Map();
|
|
157
|
-
/**
|
|
158
|
-
* Why a configured package never reached the disk, keyed by package name.
|
|
159
|
-
*
|
|
160
|
-
* Separate from {@link failedPackages} because it is the more specific
|
|
161
|
-
* answer and has to win. A package whose location failed to mount is still
|
|
162
|
-
* seeded SERVING by addEnvironment, so its later lazy load fails on the
|
|
163
|
-
* manifest that was never copied and `listPackages` records that instead.
|
|
164
|
-
* Reporting "Package manifest ... does not exist." for what was really a
|
|
165
|
-
* typo'd `location` sends the reader hunting in the wrong place.
|
|
166
|
-
*/
|
|
167
|
-
private mountErrors: Map<string, string> = new Map();
|
|
168
|
-
private malloyConfig: EnvironmentMalloyConfig;
|
|
169
|
-
private connectionMutex = new Mutex();
|
|
170
|
-
private retiredConnectionGenerations =
|
|
171
|
-
new Set<RetiredConnectionGeneration>();
|
|
172
|
-
private apiConnections: ApiConnection[];
|
|
173
|
-
private environmentPath: string;
|
|
174
|
-
private environmentName: string;
|
|
175
|
-
// Resolves a package's latest persisted materialization manifest entries
|
|
176
|
-
// (the full map — colocated tableName entries AND `storage=` cross-connection
|
|
177
|
-
// entries), so serve routing for BOTH tiers is re-established when a package
|
|
178
|
-
// (re)loads — e.g. after a worker restart — instead of only when a build's
|
|
179
|
-
// auto-load runs. Injected by the EnvironmentStore, which owns the
|
|
180
|
-
// materialization repository. Undefined ⇒ no re-bind on load (routing then
|
|
181
|
-
// depends on a fresh build, the old behavior). See
|
|
182
|
-
// {@link rebindServeBindingsFromLocalStore}.
|
|
183
|
-
private storageBindingResolver?: (
|
|
184
|
-
packageName: string,
|
|
185
|
-
) => Promise<Record<string, ManifestEntry>>;
|
|
186
|
-
public metadata: ApiEnvironment;
|
|
187
|
-
// The shared memory governor that consults process RSS. Optional —
|
|
188
|
-
// when null the gate is a no-op and the environment behaves exactly
|
|
189
|
-
// like it did before the governor was introduced. Set by
|
|
190
|
-
// EnvironmentStore.setMemoryGovernor at server start so we keep the
|
|
191
|
-
// governor as the single owner of the back-pressure boolean.
|
|
192
|
-
private memoryGovernor: PackageMemoryGovernor | null = null;
|
|
193
|
-
|
|
194
|
-
/** Absolute path on disk where this environment's package files live. */
|
|
195
|
-
public getEnvironmentPath(): string {
|
|
196
|
-
return this.environmentPath;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** This environment's name (the canonical key used by the API/service). */
|
|
200
|
-
public getEnvironmentName(): string {
|
|
201
|
-
return this.environmentName;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
constructor(
|
|
205
|
-
environmentName: string,
|
|
206
|
-
environmentPath: string,
|
|
207
|
-
malloyConfig: EnvironmentMalloyConfig,
|
|
208
|
-
apiConnections: InternalConnection[],
|
|
209
|
-
) {
|
|
210
|
-
// Sanitizer barrier: every downstream `path.join(this.environmentPath,
|
|
211
|
-
// …)` site (including the static `sweepStaleInstallDirs` sweep) gets a
|
|
212
|
-
// value that has cleared an allowlist check at the gate.
|
|
213
|
-
assertSafeEnvironmentPath(environmentPath);
|
|
214
|
-
this.environmentName = environmentName;
|
|
215
|
-
this.environmentPath = environmentPath;
|
|
216
|
-
this.malloyConfig = malloyConfig;
|
|
217
|
-
this.apiConnections = apiConnections;
|
|
218
|
-
this.metadata = {
|
|
219
|
-
resource: `${API_PREFIX}/environments/${this.environmentName}`,
|
|
220
|
-
name: this.environmentName,
|
|
221
|
-
location: this.environmentPath,
|
|
222
|
-
};
|
|
223
|
-
void this.reloadEnvironmentMetadata();
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
private async writeEnvironmentReadme(readme?: string): Promise<void> {
|
|
227
|
-
if (readme === undefined) return;
|
|
228
|
-
|
|
229
|
-
const readmePath = path.join(this.environmentPath, "README.md");
|
|
230
|
-
|
|
231
|
-
try {
|
|
232
|
-
await fs.promises.writeFile(readmePath, readme, "utf-8");
|
|
233
|
-
logger.info(
|
|
234
|
-
`Updated README.md for environment ${this.environmentName}`,
|
|
235
|
-
);
|
|
236
|
-
} catch (err) {
|
|
237
|
-
logger.error(`Failed to write README.md`, { error: err });
|
|
238
|
-
throw new Error(`Failed to update environment README`);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
public async update(payload: ApiEnvironment) {
|
|
243
|
-
if (payload.readme !== undefined) {
|
|
244
|
-
this.metadata.readme = payload.readme;
|
|
245
|
-
await this.writeEnvironmentReadme(payload.readme);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Handle connections update
|
|
249
|
-
// TODO: Update environment connections should have its own API endpoint
|
|
250
|
-
if (payload.connections) {
|
|
251
|
-
const payloadConnections = payload.connections;
|
|
252
|
-
await this.runConnectionUpdateExclusive(async () => {
|
|
253
|
-
logger.info(
|
|
254
|
-
`Updating ${payloadConnections.length} connections for environment ${this.environmentName}`,
|
|
255
|
-
);
|
|
256
|
-
const isUpdateConnectionRequest = true;
|
|
257
|
-
const nextMalloyConfig = buildEnvironmentMalloyConfig(
|
|
258
|
-
payloadConnections,
|
|
259
|
-
this.environmentPath,
|
|
260
|
-
isUpdateConnectionRequest,
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
this.updateConnections(nextMalloyConfig);
|
|
264
|
-
|
|
265
|
-
logger.info(
|
|
266
|
-
`Successfully updated connections for environment ${this.environmentName}`,
|
|
267
|
-
{
|
|
268
|
-
apiConnections: this.apiConnections.length,
|
|
269
|
-
internalConnections: this.apiConnections.length,
|
|
270
|
-
},
|
|
271
|
-
);
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return this;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
static async create(
|
|
279
|
-
environmentName: string,
|
|
280
|
-
environmentPath: string,
|
|
281
|
-
connections: ApiConnection[],
|
|
282
|
-
): Promise<Environment> {
|
|
283
|
-
assertSafeEnvironmentPath(environmentPath);
|
|
284
|
-
if (!(await fs.promises.stat(environmentPath))?.isDirectory()) {
|
|
285
|
-
throw new EnvironmentNotFoundError(
|
|
286
|
-
`Environment path ${environmentPath} not found`,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
logger.info(`Creating environment with connection configuration`);
|
|
291
|
-
const malloyConfig = buildEnvironmentMalloyConfig(
|
|
292
|
-
connections,
|
|
293
|
-
environmentPath,
|
|
294
|
-
);
|
|
295
|
-
|
|
296
|
-
logger.info(
|
|
297
|
-
`Loaded ${malloyConfig.apiConnections.length} connections for environment ${environmentName}`,
|
|
298
|
-
{
|
|
299
|
-
connections: malloyConfig.apiConnections.map((c) => ({
|
|
300
|
-
name: c.name,
|
|
301
|
-
type: c.type,
|
|
302
|
-
})),
|
|
303
|
-
},
|
|
304
|
-
);
|
|
305
|
-
|
|
306
|
-
const environment = new Environment(
|
|
307
|
-
environmentName,
|
|
308
|
-
environmentPath,
|
|
309
|
-
malloyConfig,
|
|
310
|
-
malloyConfig.apiConnections,
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
// Best-effort: a previous run may have crashed mid-install or
|
|
314
|
-
// mid-delete and left orphan dirs under .staging/ or .retired/.
|
|
315
|
-
// Run against the validated constructor argument so the sink path
|
|
316
|
-
// here does NOT route through `this` (which CodeQL conservatively
|
|
317
|
-
// treats as tainted because other methods on this class touch
|
|
318
|
-
// request-derived `packageName` values).
|
|
319
|
-
await Environment.sweepStaleInstallDirs(environmentPath);
|
|
320
|
-
|
|
321
|
-
return environment;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
public async reloadEnvironmentMetadata(): Promise<ApiEnvironment> {
|
|
325
|
-
let readme = "";
|
|
326
|
-
try {
|
|
327
|
-
readme = (
|
|
328
|
-
await fs.promises.readFile(
|
|
329
|
-
safeJoinUnderRoot(this.environmentPath, README_NAME),
|
|
330
|
-
)
|
|
331
|
-
).toString();
|
|
332
|
-
} catch {
|
|
333
|
-
// Readme not found, so we'll just return an empty string
|
|
334
|
-
}
|
|
335
|
-
this.metadata = {
|
|
336
|
-
...this.metadata,
|
|
337
|
-
resource: `${API_PREFIX}/environments/${this.environmentName}`,
|
|
338
|
-
name: this.environmentName,
|
|
339
|
-
readme,
|
|
340
|
-
};
|
|
341
|
-
return this.metadata;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
public async compileSource(
|
|
345
|
-
packageName: string,
|
|
346
|
-
modelName: string,
|
|
347
|
-
source: string,
|
|
348
|
-
includeSql: boolean = false,
|
|
349
|
-
givens?: Record<string, GivenValue>,
|
|
350
|
-
): Promise<{ problems: LogMessage[]; sql?: string }> {
|
|
351
|
-
assertSafePackageName(packageName);
|
|
352
|
-
assertSafeRelativeModelPath(modelName);
|
|
353
|
-
// /compile appends the submitted source to the TARGET MODEL's content for
|
|
354
|
-
// namespace context. A notebook (.malloynb) is markdown + cells, not a
|
|
355
|
-
// model, so compiling against it only yields a confusing parse error —
|
|
356
|
-
// reject it up front with an actionable message. (Notebooks remain public
|
|
357
|
-
// for discovery/query; this is specific to the compile context.)
|
|
358
|
-
if (modelName.endsWith(NOTEBOOK_FILE_SUFFIX)) {
|
|
359
|
-
throw new BadRequestError(
|
|
360
|
-
`Cannot compile against a notebook ("${modelName}"). ` +
|
|
361
|
-
`/compile takes a .malloy model path for namespace context.`,
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
// Hold the per-package mutex for the duration of every disk read —
|
|
365
|
-
// both the explicit `fs.readFile(modelPath)` below and the implicit
|
|
366
|
-
// import resolution that `runtime.loadModel` does through the URL
|
|
367
|
-
// reader. This is mutually exclusive with `installPackage`'s Phase 2
|
|
368
|
-
// rename swap and with `deletePackage`'s rename-to-retired, so a
|
|
369
|
-
// compile can never observe a half-rewritten tree. The slow Phase 1
|
|
370
|
-
// download happens outside this lock, so a multi-second clone does
|
|
371
|
-
// not block compiles.
|
|
372
|
-
return this.withPackageLock(packageName, async () => {
|
|
373
|
-
// Sanitized join: input segments are allowlisted above; the
|
|
374
|
-
// resolve-and-contain check here is the secondary guard CodeQL's
|
|
375
|
-
// path-injection sanitizer recognises.
|
|
376
|
-
const modelPath = safeJoinUnderRoot(
|
|
377
|
-
this.environmentPath,
|
|
378
|
-
packageName,
|
|
379
|
-
modelName,
|
|
380
|
-
);
|
|
381
|
-
// Place the virtual file in the model's directory so relative imports
|
|
382
|
-
// resolve correctly. Use `pathToFileURL` rather than hand-prefixing
|
|
383
|
-
// `file://`: on Windows the latter produces a malformed URL
|
|
384
|
-
// (`file://D:\Temp\…`) that round-trips differently than the URL the
|
|
385
|
-
// Malloy runtime synthesizes from the same path, breaking the
|
|
386
|
-
// intercepting reader's string comparison below and falling through
|
|
387
|
-
// to disk for a virtual file that doesn't exist.
|
|
388
|
-
const modelDir = path.dirname(modelPath);
|
|
389
|
-
const virtualUrl = pathToFileURL(
|
|
390
|
-
path.join(modelDir, "__compile_check.malloy"),
|
|
391
|
-
);
|
|
392
|
-
const virtualUri = virtualUrl.toString();
|
|
393
|
-
|
|
394
|
-
// Read the full model file so the submitted source inherits the model's
|
|
395
|
-
// complete namespace — imports, source definitions, queries, etc.
|
|
396
|
-
let modelContent = "";
|
|
397
|
-
try {
|
|
398
|
-
modelContent = await fs.promises.readFile(modelPath, "utf8");
|
|
399
|
-
} catch {
|
|
400
|
-
// If the model file can't be read, proceed with empty content
|
|
401
|
-
// and let compilation surface any errors naturally.
|
|
402
|
-
}
|
|
403
|
-
const fullSource = modelContent
|
|
404
|
-
? `${modelContent}\n${source}`
|
|
405
|
-
: source;
|
|
406
|
-
|
|
407
|
-
// Create a URL Reader that serves the source string for the virtual file,
|
|
408
|
-
// but falls back to the disk for everything else (imports).
|
|
409
|
-
const interceptingReader = {
|
|
410
|
-
readURL: async (url: URL) => {
|
|
411
|
-
if (url.toString() === virtualUri) {
|
|
412
|
-
return fullSource;
|
|
413
|
-
}
|
|
414
|
-
return URL_READER.readURL(url);
|
|
415
|
-
},
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
// Use the locked variant — we already hold the per-package mutex.
|
|
419
|
-
const pkg = await this._loadOrGetPackageLocked(packageName);
|
|
420
|
-
|
|
421
|
-
// Authorize gate: /compile is compile-only, but it can still act
|
|
422
|
-
// as a schema oracle (a denied caller learns a gated source's columns
|
|
423
|
-
// from compile errors) and, with includeSql, leak its SQL. Gate the
|
|
424
|
-
// named source the submitted text targets BEFORE compiling — mirrors
|
|
425
|
-
// the query path's early surface-syntax gate. Unnamed/inline source
|
|
426
|
-
// text resolves to undefined, so only the model-wide file-level gate
|
|
427
|
-
// applies. The gate runs against the package's cached Model (its
|
|
428
|
-
// `given:` block + authorize annotations), independent of the virtual
|
|
429
|
-
// compile below. If the model isn't loaded, there's nothing to enforce
|
|
430
|
-
// and compilation surfaces its own error.
|
|
431
|
-
const gateModel = pkg.getModel(modelName);
|
|
432
|
-
if (gateModel) {
|
|
433
|
-
// Query boundary first (the *what* axis): /compile compiles ad-hoc
|
|
434
|
-
// text against a model, so gate it like an ad-hoc query — a
|
|
435
|
-
// non-`explores` model file, or text whose surface-resolved target
|
|
436
|
-
// is a non-curated model source (under queryableSources:
|
|
437
|
-
// "declared"), is rejected with a generic 404 before compilation
|
|
438
|
-
// can leak schema/SQL. Text the early gate can't pin is settled by
|
|
439
|
-
// the compiled backstop below.
|
|
440
|
-
gateModel.assertQueryBoundaryEarly(undefined, undefined, source);
|
|
441
|
-
await gateModel.assertAuthorizedForText(source, givens ?? {});
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// Initialize Runtime with the package's active MalloyConfig so compile
|
|
445
|
-
// checks see the same package-scoped duckdb as execution. This runtime
|
|
446
|
-
// borrows the package config; the package/environment lifecycle owns release.
|
|
447
|
-
// Thread the package's bound build manifest (when present) so the
|
|
448
|
-
// /compile preview routes persist sources to their materialized tables
|
|
449
|
-
// exactly like execution does — otherwise includeSql=true would always
|
|
450
|
-
// show base-table SQL and diverge from what executeQuery actually runs.
|
|
451
|
-
const boundManifestEntries = pkg.getBuildManifestEntries();
|
|
452
|
-
const runtime = new Runtime({
|
|
453
|
-
urlReader: interceptingReader,
|
|
454
|
-
config: pkg.getMalloyConfig(),
|
|
455
|
-
buildManifest: boundManifestEntries
|
|
456
|
-
? { entries: boundManifestEntries, strict: false }
|
|
457
|
-
: undefined,
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
// Attempt to compile
|
|
461
|
-
try {
|
|
462
|
-
const modelMaterializer = runtime.loadModel(virtualUrl);
|
|
463
|
-
const model = await modelMaterializer.getModel();
|
|
464
|
-
|
|
465
|
-
// Resolve the final query's materializer once (if there is one).
|
|
466
|
-
let queryMaterializer: ReturnType<
|
|
467
|
-
typeof modelMaterializer.loadFinalQuery
|
|
468
|
-
> | null = null;
|
|
469
|
-
try {
|
|
470
|
-
queryMaterializer = modelMaterializer.loadFinalQuery();
|
|
471
|
-
} catch {
|
|
472
|
-
// No runnable query (e.g. only source definitions) — nothing to
|
|
473
|
-
// gate or extract beyond the early text gate already applied.
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Compiled-source backstops — run REGARDLESS of includeSql. They
|
|
477
|
-
// gate the source the COMPILED final query actually reads, closing
|
|
478
|
-
// named-query / multi-statement indirection the early surface-syntax
|
|
479
|
-
// gate misses (e.g. `run: ungated\nrun: gated` — the early gate only
|
|
480
|
-
// matches the FIRST `run:`, but the LAST statement is what executes).
|
|
481
|
-
// Compiling a gated source even without SQL is a schema oracle
|
|
482
|
-
// (field-not-found errors leak its columns), so this must not be
|
|
483
|
-
// conditional on SQL extraction. (A `source: x is gated` alias makes
|
|
484
|
-
// a new ungated source — that's the documented "extend doesn't
|
|
485
|
-
// inherit authorize" footgun, the same as the query path.)
|
|
486
|
-
|
|
487
|
-
// Boundary backstop (the *what* axis, 404) before the authorize
|
|
488
|
-
// one (the *who* axis, 403). /compile text is always ad-hoc — the
|
|
489
|
-
// early gate can only positively deny, never fully clear — so the
|
|
490
|
-
// compiled final query's run target is the authority. Self-gates
|
|
491
|
-
// internally (no-ops when the boundary is inert: "all" / no
|
|
492
|
-
// explores), so it is deliberately NOT guarded by hasAuthorize().
|
|
493
|
-
// Text that compiles only source definitions (no final query) has
|
|
494
|
-
// no run target and nothing to gate.
|
|
495
|
-
if (queryMaterializer && gateModel) {
|
|
496
|
-
await gateModel.assertQueryBoundaryForRunnable(
|
|
497
|
-
queryMaterializer,
|
|
498
|
-
source,
|
|
499
|
-
);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// Authorize backstop (the *who* axis, 403). NOT guarded by
|
|
503
|
-
// hasAuthorize(): that only inspects top-level modelDef.contents
|
|
504
|
-
// sources, so a gated source reached only via a cross-file/deep
|
|
505
|
-
// join is invisible to it and this backstop would silently never
|
|
506
|
-
// run for such a model — the same bypass assertAuthorizedForRunnable
|
|
507
|
-
// itself closes on the query path (see model.ts
|
|
508
|
-
// assertAuthorizedForAllSources). The own-source probe and joined-
|
|
509
|
-
// gate walk it runs are cheap no-ops for a genuinely ungated model.
|
|
510
|
-
if (queryMaterializer && gateModel) {
|
|
511
|
-
await gateModel.assertAuthorizedForRunnable(
|
|
512
|
-
queryMaterializer,
|
|
513
|
-
givens ?? {},
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// If includeSql is requested and compilation succeeded, attempt to extract SQL
|
|
518
|
-
let sql: string | undefined;
|
|
519
|
-
if (includeSql && queryMaterializer) {
|
|
520
|
-
try {
|
|
521
|
-
sql = await queryMaterializer.getSQL({ givens });
|
|
522
|
-
} catch (error) {
|
|
523
|
-
// A bad caller given (unknown name, wrong-typed value, finalized
|
|
524
|
-
// override, ...) surfaces as a Malloy `runtime-given-*` error.
|
|
525
|
-
// Map it to a 400 rather than silently omitting `sql` (which is
|
|
526
|
-
// indistinguishable from "no runnable query"). Duck-type on
|
|
527
|
-
// `.code`; let a MalloyError fall to the outer catch → problems.
|
|
528
|
-
// The `runtime-given-` prefix is pinned to Malloy's error codes
|
|
529
|
-
// (given_binding.ts / runtime.ts, same as model.ts) — if they're
|
|
530
|
-
// renamed upstream a bad given would silently revert to the omit
|
|
531
|
-
// branch below, so keep the two in sync.
|
|
532
|
-
const givenCode = (error as { code?: string })?.code;
|
|
533
|
-
if (
|
|
534
|
-
typeof givenCode === "string" &&
|
|
535
|
-
givenCode.startsWith("runtime-given-")
|
|
536
|
-
) {
|
|
537
|
-
throw new BadRequestError(
|
|
538
|
-
error instanceof Error ? error.message : String(error),
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
if (error instanceof MalloyError) {
|
|
542
|
-
throw error;
|
|
543
|
-
}
|
|
544
|
-
// Otherwise the source may just not contain a runnable query
|
|
545
|
-
// (e.g. only source definitions) — omit the sql field.
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// If successful, return any non-fatal warnings
|
|
550
|
-
return { problems: model.problems, sql };
|
|
551
|
-
} catch (error) {
|
|
552
|
-
// If parsing/compilation fails, return the errors
|
|
553
|
-
if (error instanceof MalloyError) {
|
|
554
|
-
return { problems: error.problems };
|
|
555
|
-
}
|
|
556
|
-
// If it's a system error (e.g. file not found), throw it up
|
|
557
|
-
throw error;
|
|
558
|
-
}
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
public listApiConnections(): ApiConnection[] {
|
|
563
|
-
return this.apiConnections;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
public getApiConnection(connectionName: string): ApiConnection {
|
|
567
|
-
const connection = this.apiConnections.find(
|
|
568
|
-
(connection) => connection.name === connectionName,
|
|
569
|
-
);
|
|
570
|
-
if (!connection) {
|
|
571
|
-
throw new ConnectionNotFoundError(
|
|
572
|
-
`Connection ${connectionName} not found`,
|
|
573
|
-
);
|
|
574
|
-
}
|
|
575
|
-
return connection;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
public async getMalloyConnection(connectionName: string) {
|
|
579
|
-
return this.malloyConfig.malloyConfig.connections.lookupConnection(
|
|
580
|
-
connectionName,
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
public getEnvironmentMalloyConfig() {
|
|
585
|
-
return this.malloyConfig.malloyConfig;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
public async runConnectionUpdateExclusive<T>(
|
|
589
|
-
fn: () => Promise<T>,
|
|
590
|
-
): Promise<T> {
|
|
591
|
-
return this.connectionMutex.runExclusive(fn);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
private retireConnectionGeneration(
|
|
595
|
-
label: string,
|
|
596
|
-
releaseConnections: () => Promise<void>,
|
|
597
|
-
): void {
|
|
598
|
-
const generation: RetiredConnectionGeneration = {
|
|
599
|
-
label,
|
|
600
|
-
releaseConnections,
|
|
601
|
-
};
|
|
602
|
-
generation.timer = setTimeout(() => {
|
|
603
|
-
void this.releaseRetiredConnectionGeneration(generation);
|
|
604
|
-
}, RETIRED_CONNECTION_DRAIN_MS);
|
|
605
|
-
(
|
|
606
|
-
generation.timer as ReturnType<typeof setTimeout> & {
|
|
607
|
-
unref?: () => void;
|
|
608
|
-
}
|
|
609
|
-
).unref?.();
|
|
610
|
-
this.retiredConnectionGenerations.add(generation);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
private async releaseRetiredConnectionGeneration(
|
|
614
|
-
generation: RetiredConnectionGeneration,
|
|
615
|
-
): Promise<void> {
|
|
616
|
-
if (!this.retiredConnectionGenerations.delete(generation)) return;
|
|
617
|
-
|
|
618
|
-
if (generation.timer) {
|
|
619
|
-
clearTimeout(generation.timer);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
try {
|
|
623
|
-
await generation.releaseConnections();
|
|
624
|
-
} catch (error) {
|
|
625
|
-
logger.error(
|
|
626
|
-
`Error releasing retired connection generation ${generation.label}`,
|
|
627
|
-
{ error },
|
|
628
|
-
);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
private async releaseAllRetiredConnectionGenerations(): Promise<void> {
|
|
633
|
-
await Promise.all(
|
|
634
|
-
[...this.retiredConnectionGenerations].map((generation) =>
|
|
635
|
-
this.releaseRetiredConnectionGeneration(generation),
|
|
636
|
-
),
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* Snapshot of the packages currently loaded in memory (does not trigger a
|
|
642
|
-
* load or reload). Used by the standalone materialization scheduler to sweep
|
|
643
|
-
* only already-loaded packages — a not-yet-loaded package is simply not
|
|
644
|
-
* scheduled until something else loads it, so the scheduler never forces a
|
|
645
|
-
* load of its own.
|
|
646
|
-
*/
|
|
647
|
-
public getLoadedPackages(): Package[] {
|
|
648
|
-
return [...this.packages.values()];
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
public async listPackages(): Promise<ApiPackage[]> {
|
|
652
|
-
logger.debug("Listing packages", {
|
|
653
|
-
environmentPath: this.environmentPath,
|
|
654
|
-
});
|
|
655
|
-
try {
|
|
656
|
-
const packageMetadata = await Promise.all(
|
|
657
|
-
Array.from(this.packageStatuses.keys()).map(async (packageName) => {
|
|
658
|
-
try {
|
|
659
|
-
const packageMetadata = (
|
|
660
|
-
this.packageStatuses.get(packageName)?.status ===
|
|
661
|
-
PackageStatus.LOADING
|
|
662
|
-
? undefined
|
|
663
|
-
: await this.getPackage(packageName, false)
|
|
664
|
-
)?.getPackageMetadata();
|
|
665
|
-
if (packageMetadata) {
|
|
666
|
-
packageMetadata.name = packageName;
|
|
667
|
-
}
|
|
668
|
-
return packageMetadata;
|
|
669
|
-
} catch (error) {
|
|
670
|
-
logger.error(
|
|
671
|
-
`Failed to load package: ${packageName} due to : ${error}`,
|
|
672
|
-
);
|
|
673
|
-
// Directory did not contain a valid package.json file -- therefore, it's not a package.
|
|
674
|
-
// Or it timed out
|
|
675
|
-
// Redact before this reaches getStatus: compiling a model
|
|
676
|
-
// resolves the package's connections, so a Postgres/DuckLake
|
|
677
|
-
// ATTACH failure surfaces here carrying the connection string
|
|
678
|
-
// (connection.ts builds it, and redacts it before logging for
|
|
679
|
-
// the same reason). A log line was the old destination; this
|
|
680
|
-
// one is an HTTP response body.
|
|
681
|
-
//
|
|
682
|
-
// redactPgSecrets covers both keyword-form `password=` (what
|
|
683
|
-
// buildPgConnectionString emits) and URI userinfo
|
|
684
|
-
// (`postgres://user:pass@host`), so a URL-form connectionString
|
|
685
|
-
// supplied verbatim in config is redacted here too.
|
|
686
|
-
this.failedPackages.set(
|
|
687
|
-
packageName,
|
|
688
|
-
redactPgSecrets(
|
|
689
|
-
error instanceof Error ? error.message : String(error),
|
|
690
|
-
),
|
|
691
|
-
);
|
|
692
|
-
return undefined;
|
|
693
|
-
}
|
|
694
|
-
}),
|
|
695
|
-
);
|
|
696
|
-
// Get rid of undefined entries (i.e, directories without publisher.json files).
|
|
697
|
-
const filteredMetadata = packageMetadata.filter(
|
|
698
|
-
(metadata) => metadata,
|
|
699
|
-
) as ApiPackage[];
|
|
700
|
-
|
|
701
|
-
// Filter out packages that are being unloaded
|
|
702
|
-
const finalMetadata = filteredMetadata.filter((metadata) => {
|
|
703
|
-
const packageStatus = this.packageStatuses.get(metadata.name || "");
|
|
704
|
-
return packageStatus?.status !== PackageStatus.UNLOADING;
|
|
705
|
-
});
|
|
706
|
-
|
|
707
|
-
return finalMetadata;
|
|
708
|
-
} catch (error) {
|
|
709
|
-
logger.error("Error listing packages", { error });
|
|
710
|
-
console.error(error);
|
|
711
|
-
throw error;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/**
|
|
716
|
-
* One mutex per package name; never replace after create — replacing
|
|
717
|
-
* would allow two loads of the same package to run in parallel and
|
|
718
|
-
* race on the canonical directory.
|
|
719
|
-
*
|
|
720
|
-
* `deletePackage` intentionally leaves the entry behind: a
|
|
721
|
-
* subsequent re-install must serialize against any straggling
|
|
722
|
-
* readers from the deleted generation that are still inside
|
|
723
|
-
* `withPackageLock`. The map therefore grows by the count of
|
|
724
|
-
* *distinct* package names the environment has ever served, not by
|
|
725
|
-
* install churn, so for the publisher's expected workload
|
|
726
|
-
* (config-declared packages, occasional ad-hoc additions) this is
|
|
727
|
-
* bounded in practice. Long-lived deployments that create and
|
|
728
|
-
* delete unique package names indefinitely would need an explicit
|
|
729
|
-
* sweep; we'll add one if/when that pattern appears.
|
|
730
|
-
*/
|
|
731
|
-
private getOrCreatePackageMutex(packageName: string): Mutex {
|
|
732
|
-
let packageMutex = this.packageMutexes.get(packageName);
|
|
733
|
-
if (packageMutex === undefined) {
|
|
734
|
-
packageMutex = new Mutex();
|
|
735
|
-
this.packageMutexes.set(packageName, packageMutex);
|
|
736
|
-
}
|
|
737
|
-
return packageMutex;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
/**
|
|
741
|
-
* Run `fn` while holding the per-package mutex. This is the single
|
|
742
|
-
* synchronization primitive that protects a package directory: every
|
|
743
|
-
* code path that mutates `{environmentPath}/{packageName}/` or reads
|
|
744
|
-
* from disk under it must serialize through this lock. See the lock
|
|
745
|
-
* ordering note above the `packageMutexes` field for the wider
|
|
746
|
-
* invariant.
|
|
747
|
-
*
|
|
748
|
-
* `async-mutex` is **not reentrant** — `fn` must not call any other
|
|
749
|
-
* method that calls `withPackageLock` on the same package, or it will
|
|
750
|
-
* deadlock. Use the `_xxxLocked` variants below in that case.
|
|
751
|
-
*/
|
|
752
|
-
public async withPackageLock<T>(
|
|
753
|
-
packageName: string,
|
|
754
|
-
fn: () => Promise<T>,
|
|
755
|
-
): Promise<T> {
|
|
756
|
-
assertSafePackageName(packageName);
|
|
757
|
-
return this.getOrCreatePackageMutex(packageName).runExclusive(fn);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
private allocateStagingPath(packageName: string): string {
|
|
761
|
-
return safeJoinUnderRoot(
|
|
762
|
-
this.environmentPath,
|
|
763
|
-
STAGING_DIR_NAME,
|
|
764
|
-
`${packageName}-${crypto.randomUUID()}`,
|
|
765
|
-
);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
private allocateRetiredPath(packageName: string): string {
|
|
769
|
-
return safeJoinUnderRoot(
|
|
770
|
-
this.environmentPath,
|
|
771
|
-
RETIRED_DIR_NAME,
|
|
772
|
-
`${packageName}-${crypto.randomUUID()}`,
|
|
773
|
-
);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* Best-effort sweep of `.staging/` and `.retired/` left over from a
|
|
778
|
-
* previous run (crash, OOM, etc). Safe because both dirs are managed
|
|
779
|
-
* exclusively by `installPackage` / `deletePackage`; no in-flight
|
|
780
|
-
* operation in this process can be using them yet.
|
|
781
|
-
*
|
|
782
|
-
* Static + path-as-parameter on purpose: the sink path here must
|
|
783
|
-
* derive from the validated factory argument, not from `this`,
|
|
784
|
-
* because CodeQL's path-injection query conservatively treats every
|
|
785
|
-
* field on this class as tainted (other methods on the same class
|
|
786
|
-
* receive request-derived `packageName` values).
|
|
787
|
-
*/
|
|
788
|
-
public static async sweepStaleInstallDirs(
|
|
789
|
-
environmentPath: string,
|
|
790
|
-
): Promise<void> {
|
|
791
|
-
assertSafeEnvironmentPath(environmentPath);
|
|
792
|
-
for (const dirName of [STAGING_DIR_NAME, RETIRED_DIR_NAME]) {
|
|
793
|
-
const dir = safeJoinUnderRoot(environmentPath, dirName);
|
|
794
|
-
// Inline sanitizer barriers in the precise shape CodeQL's
|
|
795
|
-
// `js/path-injection` query recognises (regex-test +
|
|
796
|
-
// `indexOf("..") !== -1` guard) so the sink right below is
|
|
797
|
-
// covered even when the call chain feeding `environmentPath`
|
|
798
|
-
// is taint-tracked from an HTTP request handler.
|
|
799
|
-
if (dir.indexOf("..") !== -1) continue;
|
|
800
|
-
if (path.basename(dir) !== dirName) continue;
|
|
801
|
-
try {
|
|
802
|
-
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
803
|
-
} catch (err) {
|
|
804
|
-
logger.warn(`Failed to sweep stale ${dirName} dir at ${dir}`, {
|
|
805
|
-
error: err,
|
|
806
|
-
});
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
/**
|
|
812
|
-
* Attach (or detach with `null`) the memory governor that gates new
|
|
813
|
-
* package allocations. The single instance is owned by the
|
|
814
|
-
* EnvironmentStore and propagated to every Environment so the
|
|
815
|
-
* back-pressure decision is process-wide.
|
|
816
|
-
*/
|
|
817
|
-
public setMemoryGovernor(governor: PackageMemoryGovernor | null): void {
|
|
818
|
-
this.memoryGovernor = governor;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
/**
|
|
822
|
-
* Inject the resolver that fetches a package's latest persisted
|
|
823
|
-
* materialization entries — both tiers (see {@link storageBindingResolver}).
|
|
824
|
-
*/
|
|
825
|
-
public setStorageBindingResolver(
|
|
826
|
-
resolver: (packageName: string) => Promise<Record<string, ManifestEntry>>,
|
|
827
|
-
): void {
|
|
828
|
-
this.storageBindingResolver = resolver;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
/**
|
|
832
|
-
* Re-establish a package's serve routing from its latest persisted
|
|
833
|
-
* materialization when it (re)loads, so serving survives a restart — both
|
|
834
|
-
* tiers, not just one. Serve bindings are otherwise in-memory, set only by a
|
|
835
|
-
* build's post-run auto-load, so a worker restart silently reverted a
|
|
836
|
-
* materialized source to serving live until the next build. Runs beside
|
|
837
|
-
* {@link bindManifestIfConfigured} — the same "bind serve state on load" step.
|
|
838
|
-
* Best-effort: a lookup failure logs and leaves the package serving live (a
|
|
839
|
-
* subsequent build will bind it).
|
|
840
|
-
*
|
|
841
|
-
* Both tiers are restored from the SAME persisted manifest, split by
|
|
842
|
-
* {@link splitManifestEntries}, mirroring {@link bindManifest}:
|
|
843
|
-
* - **colocated** (same-connection) → {@link Package.bindColocatedServeManifest},
|
|
844
|
-
* applied as a per-query `buildManifest` override at serve time (no
|
|
845
|
-
* recompile — the load-time compile already produced flag-carrying models).
|
|
846
|
-
* Independent of `PERSIST_STORAGE_MODE`: colocated is the v0 path and is
|
|
847
|
-
* not gated by the storage kill switch (a plain `#@ persist` materializes
|
|
848
|
-
* and serves even when the tier is `off`), so its routing is restored
|
|
849
|
-
* regardless of mode.
|
|
850
|
-
* - **storage=** (cross-connection) → {@link Package.bindStorageServeBindings},
|
|
851
|
-
* the virtual-source transform. Restored only when the tier is not `off`:
|
|
852
|
-
* storage serve routing requires the tier on, so an off deployment skips it.
|
|
853
|
-
*
|
|
854
|
-
* Skipped entirely when the package has a bound `manifestLocation`: the two
|
|
855
|
-
* binding producers (this local store and the host's fetched manifest, applied
|
|
856
|
-
* by {@link bindManifest}) are mutually exclusive by manifest presence, and the
|
|
857
|
-
* host is authoritative when one is set. An orchestrated publisher STILL
|
|
858
|
-
* persists its own local materialization records (the host triggers builds
|
|
859
|
-
* through it), so without this guard the local rebind — which runs AFTER
|
|
860
|
-
* {@link bindManifestIfConfigured} on load — would overwrite the host's
|
|
861
|
-
* bindings with a possibly-staler local generation.
|
|
862
|
-
*/
|
|
863
|
-
private async rebindServeBindingsFromLocalStore(
|
|
864
|
-
pkg: Package,
|
|
865
|
-
): Promise<void> {
|
|
866
|
-
if (!this.storageBindingResolver) return;
|
|
867
|
-
// Host-authoritative: a bound manifestLocation means bindManifest already
|
|
868
|
-
// supplied both tiers' serve bindings from the host's manifest; the local
|
|
869
|
-
// store must not clobber them (mutually-exclusive binding sources).
|
|
870
|
-
if (pkg.getPackageMetadata().manifestLocation) return;
|
|
871
|
-
const packageName = pkg.getPackageName();
|
|
872
|
-
try {
|
|
873
|
-
const rawEntries = await this.storageBindingResolver(packageName);
|
|
874
|
-
if (Object.keys(rawEntries).length === 0) return;
|
|
875
|
-
const { tableNameManifest, storageEntries } = splitManifestEntries(
|
|
876
|
-
rawEntries,
|
|
877
|
-
`local store (package ${packageName})`,
|
|
878
|
-
);
|
|
879
|
-
// Colocated: restore regardless of PERSIST_STORAGE_MODE (v0 path, not
|
|
880
|
-
// gated by the storage kill switch).
|
|
881
|
-
if (Object.keys(tableNameManifest).length > 0) {
|
|
882
|
-
pkg.bindColocatedServeManifest(tableNameManifest);
|
|
883
|
-
}
|
|
884
|
-
// Storage=: only meaningful when the tier is not off (serve routing to
|
|
885
|
-
// the external store requires it). Ships dark otherwise.
|
|
886
|
-
if (
|
|
887
|
-
getPersistStorageMode() !== "off" &&
|
|
888
|
-
Object.keys(storageEntries).length > 0
|
|
889
|
-
) {
|
|
890
|
-
pkg.bindStorageServeBindings(storageEntries);
|
|
891
|
-
}
|
|
892
|
-
} catch (err) {
|
|
893
|
-
logger.warn(
|
|
894
|
-
"Failed to rebind serve bindings from local store on load",
|
|
895
|
-
{
|
|
896
|
-
packageName,
|
|
897
|
-
error: err instanceof Error ? err.message : String(err),
|
|
898
|
-
},
|
|
899
|
-
);
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* Choke-point check called from every code path that would allocate
|
|
905
|
-
* a *new* package into the in-memory map (lazy load on cache miss,
|
|
906
|
-
* explicit reload, `addPackage`). Throws HTTP 503 when the governor
|
|
907
|
-
* is back-pressured; cheap no-op when the governor is unset or
|
|
908
|
-
* happy.
|
|
909
|
-
*
|
|
910
|
-
* `allowAdmission` is the documented opt-out for read paths that
|
|
911
|
-
* genuinely cannot tolerate 503s. None of the current callers set
|
|
912
|
-
* it; the parameter exists so a future caller (e.g. a
|
|
913
|
-
* health/warmup probe) can self-document its bypass intent.
|
|
914
|
-
*/
|
|
915
|
-
private assertCanAdmitNewPackage(
|
|
916
|
-
packageName: string,
|
|
917
|
-
reason: string,
|
|
918
|
-
allowAdmission: boolean,
|
|
919
|
-
): void {
|
|
920
|
-
if (allowAdmission) return;
|
|
921
|
-
if (!this.memoryGovernor?.isBackpressured()) return;
|
|
922
|
-
// Increment *before* throwing so the metric ticks even on
|
|
923
|
-
// the not-uncommon "caught and swallowed" path. The label
|
|
924
|
-
// shape mirrors `assertCanAdmitQuery` so a dashboard panel
|
|
925
|
-
// can sum both rejection kinds by environment.
|
|
926
|
-
getPackageAdmissionRejectionsCounter().add(1, {
|
|
927
|
-
environment: this.environmentName,
|
|
928
|
-
reason,
|
|
929
|
-
});
|
|
930
|
-
throw new ServiceUnavailableError(
|
|
931
|
-
`Publisher is under memory pressure and cannot ${reason} (package "${packageName}", environment "${this.environmentName}"). Retry after the server's memory usage drops below the low-water mark (PUBLISHER_MEMORY_LOW_WATER_FRACTION of PUBLISHER_MAX_MEMORY_BYTES), or raise PUBLISHER_MAX_MEMORY_BYTES if you have headroom.`,
|
|
932
|
-
);
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
/**
|
|
936
|
-
* Reject incoming queries with HTTP 503 when the memory governor
|
|
937
|
-
* has tripped its high-water mark. Used by every query controller
|
|
938
|
-
* (connection SQL, model query, notebook cell, MCP `execute_query`)
|
|
939
|
-
* to shed load before the query runs — complementing
|
|
940
|
-
* {@link assertCanAdmitNewPackage}, which only fires on cache-miss
|
|
941
|
-
* package loads and so leaves already-loaded packages fully
|
|
942
|
-
* queryable under pressure. With this in place, "back-pressured"
|
|
943
|
-
* means "no new work of any kind" until the governor's low-water
|
|
944
|
-
* mark is crossed.
|
|
945
|
-
*
|
|
946
|
-
* Cheap O(1) boolean read; no allocation when happy.
|
|
947
|
-
*/
|
|
948
|
-
public assertCanAdmitQuery(): void {
|
|
949
|
-
if (!this.memoryGovernor?.isBackpressured()) return;
|
|
950
|
-
// Tick first so the counter reflects every rejection even
|
|
951
|
-
// when the controller's catch block swallows the error (e.g.
|
|
952
|
-
// an MCP tool surfaces it as a content payload rather than
|
|
953
|
-
// letting it bubble to the HTTP error mapper).
|
|
954
|
-
getQueryAdmissionRejectionsCounter().add(1, {
|
|
955
|
-
environment: this.environmentName,
|
|
956
|
-
});
|
|
957
|
-
throw new ServiceUnavailableError(
|
|
958
|
-
`Publisher is under memory pressure and cannot accept new queries (environment "${this.environmentName}"). Retry after the server's memory usage drops below the low-water mark (PUBLISHER_MEMORY_LOW_WATER_FRACTION of PUBLISHER_MAX_MEMORY_BYTES), or raise PUBLISHER_MAX_MEMORY_BYTES if you have headroom.`,
|
|
959
|
-
);
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
public async getPackage(
|
|
963
|
-
packageName: string,
|
|
964
|
-
reload: boolean = false,
|
|
965
|
-
options: { allowAdmission?: boolean } = {},
|
|
966
|
-
): Promise<Package> {
|
|
967
|
-
assertSafePackageName(packageName);
|
|
968
|
-
// Fast-path: serve from cache without acquiring the lock. Safe because
|
|
969
|
-
// `Package` references are immutable; the disk-reading methods that
|
|
970
|
-
// actually need protection (compileSource, getModelFileText,
|
|
971
|
-
// reloadAllModelsForPackage, ...) acquire the lock themselves.
|
|
972
|
-
//
|
|
973
|
-
// INVARIANT: callers that consume the returned Package on the fast
|
|
974
|
-
// path (notably the MCP query tools and Model.getModel()) must
|
|
975
|
-
// remain in-memory only. If any code reachable from a `Package`
|
|
976
|
-
// method ever grows new disk I/O against the canonical tree, that
|
|
977
|
-
// path needs to be bracketed by `withPackageLock`; otherwise a
|
|
978
|
-
// concurrent install/delete will race against an unlocked reader.
|
|
979
|
-
const _package = this.packages.get(packageName);
|
|
980
|
-
if (_package !== undefined && !reload) {
|
|
981
|
-
return _package;
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
// We are either reloading or about to lazy-load on a cache miss
|
|
985
|
-
// — both allocate a new package. This is the single choke point
|
|
986
|
-
// for admission control; controllers no longer need their own
|
|
987
|
-
// back-pressure check.
|
|
988
|
-
this.assertCanAdmitNewPackage(
|
|
989
|
-
packageName,
|
|
990
|
-
reload ? "reload a package" : "load a package",
|
|
991
|
-
options.allowAdmission === true,
|
|
992
|
-
);
|
|
993
|
-
|
|
994
|
-
return this.withPackageLock(packageName, () =>
|
|
995
|
-
this._loadOrGetPackageLocked(packageName, reload),
|
|
996
|
-
);
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
/**
|
|
1000
|
-
* Load (or reload) a package from its canonical disk location. Assumes
|
|
1001
|
-
* the caller holds the per-package mutex (via {@link withPackageLock}).
|
|
1002
|
-
*
|
|
1003
|
-
* Used by {@link getPackage} and by {@link compileSource} so the
|
|
1004
|
-
* cache-miss path doesn't re-enter the mutex.
|
|
1005
|
-
*/
|
|
1006
|
-
private async _loadOrGetPackageLocked(
|
|
1007
|
-
packageName: string,
|
|
1008
|
-
reload: boolean = false,
|
|
1009
|
-
): Promise<Package> {
|
|
1010
|
-
const existingPackage = this.packages.get(packageName);
|
|
1011
|
-
if (existingPackage !== undefined && !reload) {
|
|
1012
|
-
return existingPackage;
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
this.setPackageStatus(packageName, PackageStatus.LOADING);
|
|
1016
|
-
|
|
1017
|
-
try {
|
|
1018
|
-
logger.debug(`Loading package ${packageName}...`);
|
|
1019
|
-
const packagePath = safeJoinUnderRoot(
|
|
1020
|
-
this.environmentPath,
|
|
1021
|
-
packageName,
|
|
1022
|
-
);
|
|
1023
|
-
const _package = await Package.create(
|
|
1024
|
-
this.environmentName,
|
|
1025
|
-
packageName,
|
|
1026
|
-
packagePath,
|
|
1027
|
-
() => this.malloyConfig.malloyConfig,
|
|
1028
|
-
);
|
|
1029
|
-
await this.bindManifestIfConfigured(_package);
|
|
1030
|
-
await this.rebindServeBindingsFromLocalStore(_package);
|
|
1031
|
-
if (existingPackage !== undefined && reload) {
|
|
1032
|
-
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
1033
|
-
existingPackage.getMalloyConfig().shutdown("close"),
|
|
1034
|
-
);
|
|
1035
|
-
}
|
|
1036
|
-
this.packages.set(packageName, _package);
|
|
1037
|
-
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1038
|
-
// It loaded, so any earlier failure is stale. A package that failed at
|
|
1039
|
-
// boot can be fixed on disk and reloaded without a restart.
|
|
1040
|
-
this.clearPackageLoadFailure(packageName);
|
|
1041
|
-
logger.debug(`Successfully loaded package ${packageName}`);
|
|
1042
|
-
|
|
1043
|
-
return _package;
|
|
1044
|
-
} catch (error) {
|
|
1045
|
-
logger.error(`Failed to load package ${packageName}`, { error });
|
|
1046
|
-
if (existingPackage !== undefined && reload) {
|
|
1047
|
-
// A failed RELOAD must not take down a package that is already
|
|
1048
|
-
// serving. The compiled model in `packages` is still the last good
|
|
1049
|
-
// one (it is only replaced on success), so keep serving it and let
|
|
1050
|
-
// the caller surface the error instead of evicting the package and
|
|
1051
|
-
// leaving the environment with nothing to answer from.
|
|
1052
|
-
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1053
|
-
} else {
|
|
1054
|
-
this.packages.delete(packageName);
|
|
1055
|
-
this.packageStatuses.delete(packageName);
|
|
1056
|
-
}
|
|
1057
|
-
throw error;
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
public async addPackage(
|
|
1062
|
-
packageName: string,
|
|
1063
|
-
options: { allowAdmission?: boolean } = {},
|
|
1064
|
-
) {
|
|
1065
|
-
assertSafePackageName(packageName);
|
|
1066
|
-
const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
1067
|
-
if (
|
|
1068
|
-
!(await fs.promises
|
|
1069
|
-
.access(packagePath)
|
|
1070
|
-
.then(() => true)
|
|
1071
|
-
.catch(() => false)) ||
|
|
1072
|
-
!(await fs.promises.stat(packagePath))?.isDirectory()
|
|
1073
|
-
) {
|
|
1074
|
-
throw new PackageNotFoundError(`Package ${packageName} not found`);
|
|
1075
|
-
}
|
|
1076
|
-
// 404 takes precedence over 503 so a permanent "you forgot to
|
|
1077
|
-
// upload the package" failure isn't masked as a transient
|
|
1078
|
-
// "retry later" — the gate runs after the existence check.
|
|
1079
|
-
this.assertCanAdmitNewPackage(
|
|
1080
|
-
packageName,
|
|
1081
|
-
"add a new package",
|
|
1082
|
-
options.allowAdmission === true,
|
|
1083
|
-
);
|
|
1084
|
-
logger.info(
|
|
1085
|
-
`Adding package ${packageName} to environment ${this.environmentName}`,
|
|
1086
|
-
{
|
|
1087
|
-
packagePath,
|
|
1088
|
-
},
|
|
1089
|
-
);
|
|
1090
|
-
|
|
1091
|
-
return this.withPackageLock(packageName, () =>
|
|
1092
|
-
this._addPackageLocked(packageName),
|
|
1093
|
-
);
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
private async _addPackageLocked(
|
|
1097
|
-
packageName: string,
|
|
1098
|
-
): Promise<Package | undefined> {
|
|
1099
|
-
const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
1100
|
-
const existingPackage = this.packages.get(packageName);
|
|
1101
|
-
if (existingPackage !== undefined) {
|
|
1102
|
-
return existingPackage;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
this.setPackageStatus(packageName, PackageStatus.LOADING);
|
|
1106
|
-
try {
|
|
1107
|
-
this.packages.set(
|
|
1108
|
-
packageName,
|
|
1109
|
-
await Package.create(
|
|
1110
|
-
this.environmentName,
|
|
1111
|
-
packageName,
|
|
1112
|
-
packagePath,
|
|
1113
|
-
() => this.malloyConfig.malloyConfig,
|
|
1114
|
-
),
|
|
1115
|
-
);
|
|
1116
|
-
} catch (error) {
|
|
1117
|
-
logger.error("Error adding package", { error });
|
|
1118
|
-
this.deletePackageStatus(packageName);
|
|
1119
|
-
throw error;
|
|
1120
|
-
}
|
|
1121
|
-
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1122
|
-
// Same reasoning as the load and install paths: it is serving now, so an
|
|
1123
|
-
// earlier boot failure is stale. Without this, a package fixed on disk
|
|
1124
|
-
// and re-added keeps its loadError for the life of the process.
|
|
1125
|
-
this.clearPackageLoadFailure(packageName);
|
|
1126
|
-
return this.packages.get(packageName);
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
/**
|
|
1130
|
-
* Replace a package on disk via stage-and-swap, then load it.
|
|
1131
|
-
*
|
|
1132
|
-
* - Phase 1 (no lock): run `downloader(stagingPath)`, writing the new
|
|
1133
|
-
* content into a fresh sibling dir at `.staging/<pkg>-<uuid>/`. This
|
|
1134
|
-
* is where multi-second downloads (git clone, GCS pull, ...) happen.
|
|
1135
|
-
* - Phase 2 (lock held): atomically rename any existing canonical tree
|
|
1136
|
-
* out to `.retired/<pkg>-<uuid>/`, rename staging into the canonical
|
|
1137
|
-
* path, and run `Package.create` against the canonical path.
|
|
1138
|
-
* - Phase 3 (after lock release): retire the old package's connections
|
|
1139
|
-
* via the existing 30s drain and `fs.rm` the retired tree.
|
|
1140
|
-
*
|
|
1141
|
-
* Concurrent compiles / `getModelFileText` / `reloadAllModels` calls
|
|
1142
|
-
* take the same mutex and so are mutually exclusive with the Phase 2
|
|
1143
|
-
* swap, but they never queue behind a long Phase 1 download.
|
|
1144
|
-
*
|
|
1145
|
-
* On failure (Phase 1 download or Phase 2 `Package.create`), the staging
|
|
1146
|
-
* dir is removed and — if we already renamed the old tree aside — the
|
|
1147
|
-
* old tree is renamed back so the canonical path is restored.
|
|
1148
|
-
*/
|
|
1149
|
-
public async installPackage(
|
|
1150
|
-
packageName: string,
|
|
1151
|
-
downloader: (stagingPath: string) => Promise<void>,
|
|
1152
|
-
validate?: (pkg: Package) => string | undefined,
|
|
1153
|
-
): Promise<Package> {
|
|
1154
|
-
assertSafePackageName(packageName);
|
|
1155
|
-
const stagingPath = this.allocateStagingPath(packageName);
|
|
1156
|
-
await fs.promises.mkdir(path.dirname(stagingPath), { recursive: true });
|
|
1157
|
-
|
|
1158
|
-
logger.debug("install.phase1.download.started", {
|
|
1159
|
-
environmentName: this.environmentName,
|
|
1160
|
-
packageName,
|
|
1161
|
-
stagingPath,
|
|
1162
|
-
});
|
|
1163
|
-
const downloadStartedAt = performance.now();
|
|
1164
|
-
try {
|
|
1165
|
-
await downloader(stagingPath);
|
|
1166
|
-
} catch (err) {
|
|
1167
|
-
await fs.promises
|
|
1168
|
-
.rm(stagingPath, { recursive: true, force: true })
|
|
1169
|
-
.catch(() => {});
|
|
1170
|
-
throw err;
|
|
1171
|
-
}
|
|
1172
|
-
logger.debug("install.phase1.download.completed", {
|
|
1173
|
-
environmentName: this.environmentName,
|
|
1174
|
-
packageName,
|
|
1175
|
-
durationMs: performance.now() - downloadStartedAt,
|
|
1176
|
-
});
|
|
1177
|
-
|
|
1178
|
-
return this.withPackageLock(packageName, async () => {
|
|
1179
|
-
logger.debug("install.phase2.swap.started", {
|
|
1180
|
-
environmentName: this.environmentName,
|
|
1181
|
-
packageName,
|
|
1182
|
-
});
|
|
1183
|
-
const canonicalPath = safeJoinUnderRoot(
|
|
1184
|
-
this.environmentPath,
|
|
1185
|
-
packageName,
|
|
1186
|
-
);
|
|
1187
|
-
let retiredPath: string | undefined;
|
|
1188
|
-
|
|
1189
|
-
const oldPackage = this.packages.get(packageName);
|
|
1190
|
-
const oldExistsOnDisk = await fs.promises
|
|
1191
|
-
.access(canonicalPath)
|
|
1192
|
-
.then(() => true)
|
|
1193
|
-
.catch(() => false);
|
|
1194
|
-
|
|
1195
|
-
if (oldExistsOnDisk) {
|
|
1196
|
-
retiredPath = this.allocateRetiredPath(packageName);
|
|
1197
|
-
await fs.promises.mkdir(path.dirname(retiredPath), {
|
|
1198
|
-
recursive: true,
|
|
1199
|
-
});
|
|
1200
|
-
await fs.promises.rename(canonicalPath, retiredPath);
|
|
1201
|
-
logger.debug("install.phase2.retired_old", {
|
|
1202
|
-
environmentName: this.environmentName,
|
|
1203
|
-
packageName,
|
|
1204
|
-
retiredPath,
|
|
1205
|
-
});
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
let newPackage: Package;
|
|
1209
|
-
try {
|
|
1210
|
-
await fs.promises.rename(stagingPath, canonicalPath);
|
|
1211
|
-
|
|
1212
|
-
this.setPackageStatus(packageName, PackageStatus.LOADING);
|
|
1213
|
-
newPackage = await Package.create(
|
|
1214
|
-
this.environmentName,
|
|
1215
|
-
packageName,
|
|
1216
|
-
canonicalPath,
|
|
1217
|
-
() => this.malloyConfig.malloyConfig,
|
|
1218
|
-
// This tree was just staged into place by the rename above, so a
|
|
1219
|
-
// failed load leaves a half-built directory that is ours to
|
|
1220
|
-
// remove; the rollback below restores the previous one.
|
|
1221
|
-
true,
|
|
1222
|
-
);
|
|
1223
|
-
// Strict-reject hook (publish/update only — reload passes no
|
|
1224
|
-
// validator and stays fail-safe). Throw INSIDE the try so the
|
|
1225
|
-
// catch below rolls the swap back: the just-installed tree is
|
|
1226
|
-
// wiped and the retired tree (if any) is restored, so a rejected
|
|
1227
|
-
// publish/update never leaves the bad tree served on disk.
|
|
1228
|
-
const validationMsg = validate?.(newPackage);
|
|
1229
|
-
if (validationMsg) {
|
|
1230
|
-
throw new BadRequestError(validationMsg);
|
|
1231
|
-
}
|
|
1232
|
-
logger.debug("install.phase2.committed", {
|
|
1233
|
-
environmentName: this.environmentName,
|
|
1234
|
-
packageName,
|
|
1235
|
-
canonicalPath,
|
|
1236
|
-
});
|
|
1237
|
-
} catch (err) {
|
|
1238
|
-
// Rollback: clobber whatever (partial) content sits at canonical
|
|
1239
|
-
// — Package.create's own failure-cleanup may have already rm'd
|
|
1240
|
-
// the directory, so the most common outcome here is ENOENT.
|
|
1241
|
-
// `force: true` plus the `.catch(() => {})` make this a
|
|
1242
|
-
// best-effort wipe whose only job is to leave the rename-back
|
|
1243
|
-
// below a clean destination. Then put the old tree back if we
|
|
1244
|
-
// moved one aside.
|
|
1245
|
-
await fs.promises
|
|
1246
|
-
.rm(canonicalPath, { recursive: true, force: true })
|
|
1247
|
-
.catch(() => {});
|
|
1248
|
-
let restored = false;
|
|
1249
|
-
if (retiredPath) {
|
|
1250
|
-
try {
|
|
1251
|
-
await fs.promises.rename(retiredPath, canonicalPath);
|
|
1252
|
-
restored = true;
|
|
1253
|
-
} catch (restoreErr) {
|
|
1254
|
-
logger.error(
|
|
1255
|
-
"Failed to restore retired package after install rollback",
|
|
1256
|
-
{
|
|
1257
|
-
error: restoreErr,
|
|
1258
|
-
retiredPath,
|
|
1259
|
-
canonicalPath,
|
|
1260
|
-
},
|
|
1261
|
-
);
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
await fs.promises
|
|
1265
|
-
.rm(stagingPath, { recursive: true, force: true })
|
|
1266
|
-
.catch(() => {});
|
|
1267
|
-
if (oldPackage && restored) {
|
|
1268
|
-
// The rollback put the old tree back and the previous package is
|
|
1269
|
-
// still in `this.packages` (it is only replaced on success
|
|
1270
|
-
// below), so it is genuinely still serving. Deleting its status
|
|
1271
|
-
// would strand it: listPackages enumerates packageStatuses, so
|
|
1272
|
-
// the package would answer getPackage while being invisible to
|
|
1273
|
-
// listings and discovery until a restart.
|
|
1274
|
-
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1275
|
-
} else {
|
|
1276
|
-
// Either there was nothing to fall back to (a first install), or
|
|
1277
|
-
// the restore did not happen: the rename-back threw, or the old
|
|
1278
|
-
// tree was never on disk to retire. The canonical path is then
|
|
1279
|
-
// missing or still holds the rejected content, so the cached
|
|
1280
|
-
// package no longer matches disk and must not be advertised as
|
|
1281
|
-
// serving. Drop both, and keep the two maps agreeing.
|
|
1282
|
-
this.deletePackageStatus(packageName);
|
|
1283
|
-
if (oldPackage) {
|
|
1284
|
-
// Retire before dropping it, the same way every other eviction
|
|
1285
|
-
// here does: once it leaves this.packages, closeAllConnections
|
|
1286
|
-
// can no longer reach its MalloyConfig, so its native handles
|
|
1287
|
-
// would never be released. Retire rather than shut down
|
|
1288
|
-
// inline, because withPackageLock does not cover queries that
|
|
1289
|
-
// already took this Package from an earlier getPackage; the
|
|
1290
|
-
// drain lets those finish first.
|
|
1291
|
-
this.retireConnectionGeneration(
|
|
1292
|
-
`package ${packageName}`,
|
|
1293
|
-
() => oldPackage.getMalloyConfig().shutdown("close"),
|
|
1294
|
-
);
|
|
1295
|
-
}
|
|
1296
|
-
this.packages.delete(packageName);
|
|
1297
|
-
}
|
|
1298
|
-
logger.debug("install.phase2.rollback", {
|
|
1299
|
-
environmentName: this.environmentName,
|
|
1300
|
-
packageName,
|
|
1301
|
-
restored,
|
|
1302
|
-
errorName: err instanceof Error ? err.name : "Unknown",
|
|
1303
|
-
});
|
|
1304
|
-
throw err;
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
// Best-effort manifest bind happens after the swap commits, outside the
|
|
1308
|
-
// rollback window: a manifest that can't be fetched must not undo an
|
|
1309
|
-
// otherwise-successful install (the package serves live instead).
|
|
1310
|
-
await this.bindManifestIfConfigured(newPackage);
|
|
1311
|
-
await this.rebindServeBindingsFromLocalStore(newPackage);
|
|
1312
|
-
|
|
1313
|
-
this.packages.set(packageName, newPackage);
|
|
1314
|
-
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1315
|
-
// Publishing a fixed package clears the boot failure it replaces.
|
|
1316
|
-
this.clearPackageLoadFailure(packageName);
|
|
1317
|
-
|
|
1318
|
-
if (oldPackage) {
|
|
1319
|
-
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
1320
|
-
oldPackage.getMalloyConfig().shutdown("close"),
|
|
1321
|
-
);
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
if (retiredPath) {
|
|
1325
|
-
const pathToClean = retiredPath;
|
|
1326
|
-
setImmediate(() => {
|
|
1327
|
-
logger.debug("install.phase3.retired_cleanup", {
|
|
1328
|
-
environmentName: this.environmentName,
|
|
1329
|
-
packageName,
|
|
1330
|
-
retiredPath: pathToClean,
|
|
1331
|
-
});
|
|
1332
|
-
void fs.promises
|
|
1333
|
-
.rm(pathToClean, { recursive: true, force: true })
|
|
1334
|
-
.catch((err) => {
|
|
1335
|
-
logger.warn(
|
|
1336
|
-
`Failed to clean up retired package directory ${pathToClean}`,
|
|
1337
|
-
{ error: err },
|
|
1338
|
-
);
|
|
1339
|
-
});
|
|
1340
|
-
});
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
return newPackage;
|
|
1344
|
-
});
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
/**
|
|
1348
|
-
* Reload every model in a package against the supplied build manifest,
|
|
1349
|
-
* holding the per-package mutex for the duration of the disk reads.
|
|
1350
|
-
* Replaces direct `Package.reloadAllModels` calls from outside
|
|
1351
|
-
* `Environment`.
|
|
1352
|
-
*
|
|
1353
|
-
* Skips the recompile when there is nothing to substitute now AND nothing was
|
|
1354
|
-
* substituted before — the same condition {@link bindManifest} applies to a
|
|
1355
|
-
* pure-storage manifest flip, and for the same reason: a same-connection
|
|
1356
|
-
* `tableName` manifest is resolved at COMPILE time, so an empty one over an
|
|
1357
|
-
* already-empty one changes nothing a recompile could express. The previous
|
|
1358
|
-
* state has to be checked too, because a package that just dropped its last
|
|
1359
|
-
* colocated entry still has to recompile to revert the substitution.
|
|
1360
|
-
*
|
|
1361
|
-
* This matters because every materialization run lands here. Without the
|
|
1362
|
-
* guard a `storage=`-only package — or one with no persist sources at all —
|
|
1363
|
-
* paid a full package recompile per run: measured at ~1.1MB of RSS per run on
|
|
1364
|
-
* the production image, never reclaimed, against a `main` build that reclaims.
|
|
1365
|
-
*/
|
|
1366
|
-
public async reloadAllModelsForPackage(
|
|
1367
|
-
packageName: string,
|
|
1368
|
-
manifest: FreshnessManifest,
|
|
1369
|
-
): Promise<void> {
|
|
1370
|
-
assertSafePackageName(packageName);
|
|
1371
|
-
return this.withPackageLock(packageName, async () => {
|
|
1372
|
-
const pkg = this.packages.get(packageName);
|
|
1373
|
-
if (!pkg) {
|
|
1374
|
-
throw new PackageNotFoundError(
|
|
1375
|
-
`Package ${packageName} is not loaded`,
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
const has = Object.keys(manifest).length > 0;
|
|
1379
|
-
const had = pkg.hasBoundTableNameManifest();
|
|
1380
|
-
if (!has && !had) return;
|
|
1381
|
-
await pkg.reloadAllModels(manifest);
|
|
1382
|
-
});
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
/**
|
|
1386
|
-
* Bind a package's `storage=` serve bindings from a build's FULL manifest
|
|
1387
|
-
* entries (carrying `storageConnectionName` + captured `schema`), so a query
|
|
1388
|
-
* against a materialized-into-storage source can be routed through the
|
|
1389
|
-
* virtual-source serve transform. Distinct from
|
|
1390
|
-
* {@link reloadAllModelsForPackage}, which binds the tableName-only manifest
|
|
1391
|
-
* for same-connection persistence. No-op (logged) if the package isn't
|
|
1392
|
-
* loaded — best-effort, like the post-build auto-load itself.
|
|
1393
|
-
*/
|
|
1394
|
-
public async bindPackageStorageServeBindings(
|
|
1395
|
-
packageName: string,
|
|
1396
|
-
entries: Record<string, ManifestEntry>,
|
|
1397
|
-
): Promise<void> {
|
|
1398
|
-
assertSafePackageName(packageName);
|
|
1399
|
-
return this.withPackageLock(packageName, async () => {
|
|
1400
|
-
const pkg = this.packages.get(packageName);
|
|
1401
|
-
if (!pkg) {
|
|
1402
|
-
logger.warn(
|
|
1403
|
-
"Cannot bind storage serve bindings: package not loaded",
|
|
1404
|
-
{ packageName },
|
|
1405
|
-
);
|
|
1406
|
-
return;
|
|
1407
|
-
}
|
|
1408
|
-
// Host-authoritative: when a manifestLocation is bound, the host's
|
|
1409
|
-
// manifest is the sole source of storage serve bindings (see
|
|
1410
|
-
// bindManifest + rebindServeBindingsFromLocalStore' matching guard). This
|
|
1411
|
-
// method is the LOCAL-store binding path (post-build auto-load and the
|
|
1412
|
-
// post-delete rebind); on an orchestrated deployment the publisher
|
|
1413
|
-
// still writes its own local materialization records, so without this
|
|
1414
|
-
// guard a build's auto-load or a routine retire of a superseded record
|
|
1415
|
-
// would clobber the host's bindings with a possibly-staler local
|
|
1416
|
-
// generation. Standalone (no manifestLocation) is unaffected.
|
|
1417
|
-
if (pkg.getPackageMetadata().manifestLocation) {
|
|
1418
|
-
logger.debug(
|
|
1419
|
-
"Skipping local-store storage serve binding: manifestLocation " +
|
|
1420
|
-
"is bound (host authoritative)",
|
|
1421
|
-
{ packageName },
|
|
1422
|
-
);
|
|
1423
|
-
return;
|
|
1424
|
-
}
|
|
1425
|
-
pkg.bindStorageServeBindings(entries);
|
|
1426
|
-
});
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
/**
|
|
1430
|
-
* Re-establish a package's COLOCATED (same-connection) serve routing from a
|
|
1431
|
-
* FreshnessManifest, holding the package lock — the colocated analogue of
|
|
1432
|
-
* {@link bindPackageStorageServeBindings}, used by the post-delete rebind so a
|
|
1433
|
-
* reclaimed colocated table is not left routed. No-op (logged) if the package
|
|
1434
|
-
* isn't loaded, and skipped when a `manifestLocation` is bound (host
|
|
1435
|
-
* authoritative, same as the storage variant). An empty manifest reverts to
|
|
1436
|
-
* serving live.
|
|
1437
|
-
*
|
|
1438
|
-
* Unlike the storage tier — and unlike the load-time colocated rebind
|
|
1439
|
-
* ({@link Package.bindColocatedServeManifest}, which restores routing onto
|
|
1440
|
-
* FRESHLY-compiled models with a per-query overlay, no recompile) — this path
|
|
1441
|
-
* runs against models that a build's auto-load already recompiled with the
|
|
1442
|
-
* substitution BAKED IN. Clearing the per-query overlay would not strip a
|
|
1443
|
-
* baked substitution, so reverting/rebinding here requires a recompile
|
|
1444
|
-
* ({@link Package.reloadAllModels}), exactly as {@link bindManifest} does for
|
|
1445
|
-
* colocated. Skipped when there is nothing to substitute AND nothing was
|
|
1446
|
-
* previously substituted to clear (no needless recompile for a storage-only or
|
|
1447
|
-
* never-materialized package).
|
|
1448
|
-
*/
|
|
1449
|
-
public async bindPackageColocatedServeManifest(
|
|
1450
|
-
packageName: string,
|
|
1451
|
-
entries: FreshnessManifest,
|
|
1452
|
-
): Promise<void> {
|
|
1453
|
-
assertSafePackageName(packageName);
|
|
1454
|
-
return this.withPackageLock(packageName, async () => {
|
|
1455
|
-
const pkg = this.packages.get(packageName);
|
|
1456
|
-
if (!pkg) {
|
|
1457
|
-
logger.warn(
|
|
1458
|
-
"Cannot bind colocated serve manifest: package not loaded",
|
|
1459
|
-
{ packageName },
|
|
1460
|
-
);
|
|
1461
|
-
return;
|
|
1462
|
-
}
|
|
1463
|
-
if (pkg.getPackageMetadata().manifestLocation) {
|
|
1464
|
-
logger.debug(
|
|
1465
|
-
"Skipping local-store colocated serve binding: manifestLocation " +
|
|
1466
|
-
"is bound (host authoritative)",
|
|
1467
|
-
{ packageName },
|
|
1468
|
-
);
|
|
1469
|
-
return;
|
|
1470
|
-
}
|
|
1471
|
-
const hasColocated = Object.keys(entries).length > 0;
|
|
1472
|
-
const hadColocated = pkg.hasBoundTableNameManifest();
|
|
1473
|
-
if (hasColocated || hadColocated) {
|
|
1474
|
-
await pkg.reloadAllModels(entries);
|
|
1475
|
-
}
|
|
1476
|
-
});
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
/**
|
|
1480
|
-
* If the freshly-loaded package declares a `manifestLocation`, fetch the
|
|
1481
|
-
* control-plane-computed build manifest and rebind its models so persist
|
|
1482
|
-
* references resolve to the materialized tables. Best-effort: a fetch/bind
|
|
1483
|
-
* failure logs a warning and leaves the package serving live (the models are
|
|
1484
|
-
* already loaded without a manifest). Callers must hold the package lock —
|
|
1485
|
-
* this rebinds `pkg` in place rather than re-entering {@link withPackageLock}.
|
|
1486
|
-
*/
|
|
1487
|
-
private async bindManifestIfConfigured(pkg: Package): Promise<void> {
|
|
1488
|
-
const manifestLocation = pkg.getPackageMetadata().manifestLocation;
|
|
1489
|
-
if (!manifestLocation) {
|
|
1490
|
-
return;
|
|
1491
|
-
}
|
|
1492
|
-
await this.bindManifest(pkg, manifestLocation);
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
/** Fetch + bind a specific manifest URI onto an already-loaded package. */
|
|
1496
|
-
private async bindManifest(
|
|
1497
|
-
pkg: Package,
|
|
1498
|
-
manifestLocation: string,
|
|
1499
|
-
): Promise<void> {
|
|
1500
|
-
const packageName = pkg.getPackageName();
|
|
1501
|
-
try {
|
|
1502
|
-
// Bind runs before a package is marked SERVING, so a slow/unreachable
|
|
1503
|
-
// manifest store must not block serving indefinitely — bound is the
|
|
1504
|
-
// intended state, live is the degraded fallback. Race the fetch against
|
|
1505
|
-
// a timeout and fall back to live on either failure.
|
|
1506
|
-
const { tableNameManifest, storageEntries } =
|
|
1507
|
-
await this.fetchManifestEntriesWithTimeout(manifestLocation);
|
|
1508
|
-
|
|
1509
|
-
// Tier split. Storage entries (cross-connection) bind as serve bindings
|
|
1510
|
-
// WITHOUT a recompile — they apply to the already-compiled models via
|
|
1511
|
-
// Model.setServeBindings. The host is the authoritative producer here,
|
|
1512
|
-
// so this also supersedes the local-store rebind (see
|
|
1513
|
-
// rebindServeBindingsFromLocalStore, which no-ops when manifestLocation
|
|
1514
|
-
// is set).
|
|
1515
|
-
// Bind whenever there ARE storage entries OR there WERE (bindStorage-
|
|
1516
|
-
// ServeBindings with the now-empty set clears them): a manifest whose
|
|
1517
|
-
// storage entries vanished must drop the old bindings, not leave them
|
|
1518
|
-
// routing at a table the host no longer vouches for. Mirrors the
|
|
1519
|
-
// hadColocated guard below.
|
|
1520
|
-
const hasStorage = Object.keys(storageEntries).length > 0;
|
|
1521
|
-
const hadStorage = pkg.hasStorageServeBindings();
|
|
1522
|
-
if (hasStorage || hadStorage) {
|
|
1523
|
-
pkg.bindStorageServeBindings(storageEntries);
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
// colocated entries drive the same-connection tableName substitution, which
|
|
1527
|
-
// is resolved at COMPILE time — so they require a reloadAllModels
|
|
1528
|
-
// recompile (v0's existing cost). Skip the recompile for a pure-storage
|
|
1529
|
-
// manifest flip: only when there is nothing to substitute AND nothing
|
|
1530
|
-
// previously substituted to clear (otherwise a package that just dropped
|
|
1531
|
-
// its last colocated entry must still recompile to revert it).
|
|
1532
|
-
const hasColocated = Object.keys(tableNameManifest).length > 0;
|
|
1533
|
-
const hadColocated = pkg.hasBoundTableNameManifest();
|
|
1534
|
-
if (hasColocated || hadColocated) {
|
|
1535
|
-
await pkg.reloadAllModels(tableNameManifest);
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
pkg.setBoundManifestUri(manifestLocation);
|
|
1539
|
-
recordManifestBind("success");
|
|
1540
|
-
logger.info("Bound build manifest to package", {
|
|
1541
|
-
environmentName: this.environmentName,
|
|
1542
|
-
packageName,
|
|
1543
|
-
manifestLocation,
|
|
1544
|
-
tableNameEntryCount: Object.keys(tableNameManifest).length,
|
|
1545
|
-
storageEntryCount: Object.keys(storageEntries).length,
|
|
1546
|
-
recompiled: hasColocated || hadColocated,
|
|
1547
|
-
});
|
|
1548
|
-
} catch (err) {
|
|
1549
|
-
pkg.markManifestBindFailed();
|
|
1550
|
-
const timedOut =
|
|
1551
|
-
err instanceof Error && err.message.includes("Timed out after");
|
|
1552
|
-
recordManifestBind(timedOut ? "timeout" : "failure");
|
|
1553
|
-
logger.warn("Failed to bind build manifest; serving live", {
|
|
1554
|
-
environmentName: this.environmentName,
|
|
1555
|
-
packageName,
|
|
1556
|
-
manifestLocation,
|
|
1557
|
-
timedOut,
|
|
1558
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1559
|
-
});
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
/**
|
|
1564
|
-
* Fetch manifest entries, rejecting if the fetch exceeds
|
|
1565
|
-
* {@link MANIFEST_FETCH_TIMEOUT_MS}. Keeps {@link bindManifest}'s bind-before-
|
|
1566
|
-
* serve guarantee from stalling on an unreachable manifest store.
|
|
1567
|
-
*/
|
|
1568
|
-
private async fetchManifestEntriesWithTimeout(
|
|
1569
|
-
manifestLocation: string,
|
|
1570
|
-
): Promise<FetchedManifest> {
|
|
1571
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1572
|
-
const timeout = new Promise<never>((_, reject) => {
|
|
1573
|
-
timer = setTimeout(
|
|
1574
|
-
() =>
|
|
1575
|
-
reject(
|
|
1576
|
-
new Error(
|
|
1577
|
-
`Timed out after ${MANIFEST_FETCH_TIMEOUT_MS}ms fetching manifest ${manifestLocation}`,
|
|
1578
|
-
),
|
|
1579
|
-
),
|
|
1580
|
-
MANIFEST_FETCH_TIMEOUT_MS,
|
|
1581
|
-
);
|
|
1582
|
-
});
|
|
1583
|
-
try {
|
|
1584
|
-
return await Promise.race([
|
|
1585
|
-
fetchManifestEntries(manifestLocation),
|
|
1586
|
-
timeout,
|
|
1587
|
-
]);
|
|
1588
|
-
} finally {
|
|
1589
|
-
if (timer) {
|
|
1590
|
-
clearTimeout(timer);
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
/**
|
|
1596
|
-
* Read a model's source text from disk, holding the per-package mutex
|
|
1597
|
-
* so the read is serialized against {@link installPackage} /
|
|
1598
|
-
* {@link deletePackage} / {@link updatePackage}.
|
|
1599
|
-
*/
|
|
1600
|
-
public async getModelFileText(
|
|
1601
|
-
packageName: string,
|
|
1602
|
-
modelPath: string,
|
|
1603
|
-
): Promise<string> {
|
|
1604
|
-
assertSafePackageName(packageName);
|
|
1605
|
-
assertSafeRelativeModelPath(modelPath);
|
|
1606
|
-
return this.withPackageLock(packageName, async () => {
|
|
1607
|
-
const pkg = this.packages.get(packageName);
|
|
1608
|
-
if (!pkg) {
|
|
1609
|
-
throw new PackageNotFoundError(
|
|
1610
|
-
`Package ${packageName} is not loaded`,
|
|
1611
|
-
);
|
|
1612
|
-
}
|
|
1613
|
-
return pkg.getModelFileText(modelPath);
|
|
1614
|
-
});
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
private async writePackageManifest(
|
|
1618
|
-
packageName: string,
|
|
1619
|
-
metadata: {
|
|
1620
|
-
name: string;
|
|
1621
|
-
description?: string;
|
|
1622
|
-
explores?: string[];
|
|
1623
|
-
queryableSources?: "declared" | "all";
|
|
1624
|
-
manifestLocation?: string | null;
|
|
1625
|
-
scope?: ApiPackage["scope"];
|
|
1626
|
-
materialization?: ApiPackage["materialization"];
|
|
1627
|
-
},
|
|
1628
|
-
): Promise<void> {
|
|
1629
|
-
const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
1630
|
-
const manifestPath = safeJoinUnderRoot(packagePath, "publisher.json");
|
|
1631
|
-
|
|
1632
|
-
try {
|
|
1633
|
-
// Read existing manifest
|
|
1634
|
-
let existingManifest: Record<string, unknown> = {};
|
|
1635
|
-
try {
|
|
1636
|
-
const content = await fs.promises.readFile(manifestPath, "utf-8");
|
|
1637
|
-
existingManifest = JSON.parse(content);
|
|
1638
|
-
} catch (_err) {
|
|
1639
|
-
logger.warn(`Could not read manifest for ${packageName}`);
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
const onDiskMaterialization =
|
|
1643
|
-
existingManifest.materialization !== null &&
|
|
1644
|
-
typeof existingManifest.materialization === "object" &&
|
|
1645
|
-
!Array.isArray(existingManifest.materialization)
|
|
1646
|
-
? (existingManifest.materialization as Record<string, unknown>)
|
|
1647
|
-
: undefined;
|
|
1648
|
-
|
|
1649
|
-
// Scope has two homes: `materialization.scope` (canonical) and the
|
|
1650
|
-
// manifest root (deprecated). The server writes BOTH, in sync, for as
|
|
1651
|
-
// long as the root form is supported:
|
|
1652
|
-
//
|
|
1653
|
-
// - writing only the root would author a manifest this build's loader
|
|
1654
|
-
// refuses, since a root that disagrees with an existing envelope is a
|
|
1655
|
-
// conflict (see resolvePackageScope);
|
|
1656
|
-
// - writing only the envelope would silently downgrade a package read
|
|
1657
|
-
// by an older publisher, which knows only the root and would default
|
|
1658
|
-
// to `package` — cross-version table reuse for a package declared
|
|
1659
|
-
// `version`.
|
|
1660
|
-
//
|
|
1661
|
-
// A caller can only express scope through the top-level `scope` field
|
|
1662
|
-
// (the wire materialization block has no `scope`), so an envelope value
|
|
1663
|
-
// already on disk is preserved rather than dropped by a materialization
|
|
1664
|
-
// PATCH that says nothing about it.
|
|
1665
|
-
const resolvedScope =
|
|
1666
|
-
metadata.scope ??
|
|
1667
|
-
(onDiskMaterialization?.scope as ApiPackage["scope"] | undefined) ??
|
|
1668
|
-
(existingManifest.scope as ApiPackage["scope"] | undefined);
|
|
1669
|
-
|
|
1670
|
-
// A materialization PATCH replaces the block wholesale, which is right
|
|
1671
|
-
// for schedule and freshness — they are the policy the caller is
|
|
1672
|
-
// setting, and they are mutually exclusive with each other.
|
|
1673
|
-
// `queryMetadata` is orthogonal to both: a client setting a schedule
|
|
1674
|
-
// has no reason to re-send the package's tags, and dropping them
|
|
1675
|
-
// silently untags every statement the package's builds issue. So it is
|
|
1676
|
-
// preserved on omission, like `scope` above; an explicit null still
|
|
1677
|
-
// clears it, which keeps the block expressible.
|
|
1678
|
-
const preservedQueryMetadata =
|
|
1679
|
-
metadata.materialization !== undefined &&
|
|
1680
|
-
metadata.materialization?.queryMetadata === undefined &&
|
|
1681
|
-
onDiskMaterialization?.queryMetadata !== undefined
|
|
1682
|
-
? { queryMetadata: onDiskMaterialization.queryMetadata }
|
|
1683
|
-
: {};
|
|
1684
|
-
|
|
1685
|
-
const materializationBase: Record<string, unknown> | undefined =
|
|
1686
|
-
metadata.materialization !== undefined
|
|
1687
|
-
? { ...metadata.materialization, ...preservedQueryMetadata }
|
|
1688
|
-
: onDiskMaterialization !== undefined
|
|
1689
|
-
? { ...onDiskMaterialization }
|
|
1690
|
-
: undefined;
|
|
1691
|
-
const materializationBlock =
|
|
1692
|
-
resolvedScope !== undefined
|
|
1693
|
-
? { ...(materializationBase ?? {}), scope: resolvedScope }
|
|
1694
|
-
: materializationBase;
|
|
1695
|
-
|
|
1696
|
-
// Update with new metadata. `explores`/`queryableSources` are only
|
|
1697
|
-
// overwritten when the caller explicitly provides them; otherwise the
|
|
1698
|
-
// existing on-disk value is preserved via the spread (an undefined here
|
|
1699
|
-
// must not erase it).
|
|
1700
|
-
const updatedManifest = {
|
|
1701
|
-
...existingManifest,
|
|
1702
|
-
name: metadata.name,
|
|
1703
|
-
description: metadata.description,
|
|
1704
|
-
...(metadata.explores !== undefined
|
|
1705
|
-
? { explores: metadata.explores }
|
|
1706
|
-
: {}),
|
|
1707
|
-
...(metadata.queryableSources !== undefined
|
|
1708
|
-
? { queryableSources: metadata.queryableSources }
|
|
1709
|
-
: {}),
|
|
1710
|
-
...(metadata.manifestLocation !== undefined
|
|
1711
|
-
? { manifestLocation: metadata.manifestLocation }
|
|
1712
|
-
: {}),
|
|
1713
|
-
...(resolvedScope !== undefined ? { scope: resolvedScope } : {}),
|
|
1714
|
-
...(materializationBlock !== undefined
|
|
1715
|
-
? { materialization: materializationBlock }
|
|
1716
|
-
: {}),
|
|
1717
|
-
};
|
|
1718
|
-
|
|
1719
|
-
// Write back to file
|
|
1720
|
-
await fs.promises.writeFile(
|
|
1721
|
-
manifestPath,
|
|
1722
|
-
JSON.stringify(updatedManifest, null, 2),
|
|
1723
|
-
"utf-8",
|
|
1724
|
-
);
|
|
1725
|
-
|
|
1726
|
-
logger.info(`Updated publisher.json for ${packageName}`);
|
|
1727
|
-
} catch (error) {
|
|
1728
|
-
logger.error(`Failed to update publisher.json`, { error });
|
|
1729
|
-
throw new Error(`Failed to update package manifest`);
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
public async updatePackage(packageName: string, body: ApiPackage) {
|
|
1734
|
-
assertSafePackageName(packageName);
|
|
1735
|
-
return this.withPackageLock(packageName, async () => {
|
|
1736
|
-
const _package = this.packages.get(packageName);
|
|
1737
|
-
if (!_package) {
|
|
1738
|
-
throw new PackageNotFoundError(`Package ${packageName} not found`);
|
|
1739
|
-
}
|
|
1740
|
-
if (body.name) {
|
|
1741
|
-
_package.setName(body.name);
|
|
1742
|
-
}
|
|
1743
|
-
// Preserve `explores` across a metadata PATCH. `setPackageMetadata`
|
|
1744
|
-
// replaces the whole object, so a name/description-only update must
|
|
1745
|
-
// carry the existing discovery surface through — otherwise the
|
|
1746
|
-
// in-memory `explores` is wiped and `listModels()` silently starts
|
|
1747
|
-
// serving every model until the next reload. When the body explicitly
|
|
1748
|
-
// carries `explores`, honor the new set instead.
|
|
1749
|
-
const existing = _package.getPackageMetadata();
|
|
1750
|
-
// Normalize API-body explores through the same helper the worker uses
|
|
1751
|
-
// for on-disk explores, so `["./index.malloy"]` / backslash paths
|
|
1752
|
-
// validate and persist identically regardless of input channel (no
|
|
1753
|
-
// misleading publish-time 400, no publish-vs-reload divergence).
|
|
1754
|
-
const normalizedExplores = body.explores?.map(normalizeModelPath);
|
|
1755
|
-
const explores =
|
|
1756
|
-
normalizedExplores !== undefined
|
|
1757
|
-
? normalizedExplores
|
|
1758
|
-
: existing.explores;
|
|
1759
|
-
const queryableSources =
|
|
1760
|
-
body.queryableSources !== undefined
|
|
1761
|
-
? body.queryableSources
|
|
1762
|
-
: existing.queryableSources;
|
|
1763
|
-
// Preserve the existing manifestLocation unless the body explicitly
|
|
1764
|
-
// sets it (including to null, which clears it and reverts to live).
|
|
1765
|
-
const manifestLocation =
|
|
1766
|
-
body.manifestLocation !== undefined
|
|
1767
|
-
? body.manifestLocation
|
|
1768
|
-
: existing.manifestLocation;
|
|
1769
|
-
// Persist `scope` and `materialization` (the schedule cron) are
|
|
1770
|
-
// editable via the API — both are writable in the schema. When the
|
|
1771
|
-
// body carries a value, apply it; otherwise preserve the
|
|
1772
|
-
// manifest-derived one (a name/description-only PATCH must not wipe
|
|
1773
|
-
// them, and the control plane must not misread the gap as a removal).
|
|
1774
|
-
// Changing the schedule re-arms the standalone scheduler on its next
|
|
1775
|
-
// tick — no reload needed.
|
|
1776
|
-
//
|
|
1777
|
-
// A *null* scope/materialization is treated the same as omitted
|
|
1778
|
-
// (preserve), NOT a wipe: the control plane's post-build rebind PATCH
|
|
1779
|
-
// carries only name/location/manifestLocation, but a client that
|
|
1780
|
-
// serializes unset fields as explicit null must not thereby trip the
|
|
1781
|
-
// policy gate below (a rejection there fails the orchestrated run) or
|
|
1782
|
-
// reset the persisted policy. `manifestLocation` is deliberately
|
|
1783
|
-
// different — null there means "clear" (revert to live), which the
|
|
1784
|
-
// caller's orchestrated build path relies on.
|
|
1785
|
-
const scopeProvided = body.scope != null;
|
|
1786
|
-
const materializationProvided = body.materialization != null;
|
|
1787
|
-
const editingPolicy = scopeProvided || materializationProvided;
|
|
1788
|
-
const scope = scopeProvided ? body.scope : existing.scope;
|
|
1789
|
-
const materialization = materializationProvided
|
|
1790
|
-
? body.materialization
|
|
1791
|
-
: existing.materialization;
|
|
1792
|
-
_package.setPackageMetadata({
|
|
1793
|
-
name: body.name,
|
|
1794
|
-
description: body.description,
|
|
1795
|
-
resource: body.resource,
|
|
1796
|
-
location: body.location,
|
|
1797
|
-
explores,
|
|
1798
|
-
queryableSources,
|
|
1799
|
-
manifestLocation,
|
|
1800
|
-
materialization,
|
|
1801
|
-
scope,
|
|
1802
|
-
});
|
|
1803
|
-
|
|
1804
|
-
// Strict-reject, symmetric with the publish path
|
|
1805
|
-
// (package.controller.addPackage): validate the resulting explores
|
|
1806
|
-
// against the live model set and restore the prior metadata before
|
|
1807
|
-
// rejecting, so a bad update neither persists nor mutates the served
|
|
1808
|
-
// surface. When the body edits the persistence policy (scope /
|
|
1809
|
-
// materialization), also enforce the same scope/schedule/freshness/cron
|
|
1810
|
-
// rules a publish enforces — but only then, so a description-only PATCH
|
|
1811
|
-
// on a package with a pre-existing (load-tolerated) policy warning is
|
|
1812
|
-
// not newly rejected. Cron validity is one of these rules
|
|
1813
|
-
// (persistencePolicyWarnings Rule 4), so publish, PATCH, load, and the
|
|
1814
|
-
// scheduler all enforce it identically.
|
|
1815
|
-
const policyMsg = editingPolicy
|
|
1816
|
-
? _package.formatInvalidPersistencePolicy()
|
|
1817
|
-
: "";
|
|
1818
|
-
const invalidMsg = [_package.formatInvalidExplores(), policyMsg]
|
|
1819
|
-
.filter(Boolean)
|
|
1820
|
-
.join("\n");
|
|
1821
|
-
if (invalidMsg) {
|
|
1822
|
-
_package.setPackageMetadata(existing);
|
|
1823
|
-
throw new BadRequestError(invalidMsg);
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
await this.writePackageManifest(packageName, {
|
|
1827
|
-
name: packageName,
|
|
1828
|
-
description: body.description,
|
|
1829
|
-
explores: normalizedExplores,
|
|
1830
|
-
queryableSources: body.queryableSources,
|
|
1831
|
-
manifestLocation: body.manifestLocation,
|
|
1832
|
-
// Only write when explicitly provided (non-null): mirrors the
|
|
1833
|
-
// null-as-absent rule above, so a rebind PATCH neither wipes the
|
|
1834
|
-
// persisted policy nor writes a stray `scope: null`.
|
|
1835
|
-
scope: scopeProvided ? body.scope : undefined,
|
|
1836
|
-
materialization: materializationProvided
|
|
1837
|
-
? body.materialization
|
|
1838
|
-
: undefined,
|
|
1839
|
-
});
|
|
1840
|
-
|
|
1841
|
-
// When the body changes manifestLocation, apply it now so the new
|
|
1842
|
-
// binding takes effect without a separate reload: a URI rebinds models
|
|
1843
|
-
// to the materialized tables; null/empty reverts the package to live.
|
|
1844
|
-
if (body.manifestLocation !== undefined) {
|
|
1845
|
-
if (body.manifestLocation) {
|
|
1846
|
-
await this.bindManifest(_package, body.manifestLocation);
|
|
1847
|
-
} else {
|
|
1848
|
-
// Revert to live: drop the colocated tableName substitution AND the
|
|
1849
|
-
// cross-connection storage serve bindings the prior bindManifest
|
|
1850
|
-
// applied, so no query still routes to a materialized table after
|
|
1851
|
-
// the operator explicitly cleared the manifest.
|
|
1852
|
-
await _package.reloadAllModels({});
|
|
1853
|
-
_package.bindStorageServeBindings({});
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
return _package.getPackageMetadata();
|
|
1858
|
-
});
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
public getPackageStatus(packageName: string): PackageInfo | undefined {
|
|
1862
|
-
return this.packageStatuses.get(packageName);
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
/**
|
|
1866
|
-
* Packages this environment is actually serving: registered statuses minus
|
|
1867
|
-
* any recorded as failed or un-mounted. Disjoint from getFailedPackages()
|
|
1868
|
-
* by construction, so the readiness line's packages= and load_errors=
|
|
1869
|
-
* cannot double-count a package that is seeded SERVING at boot and only
|
|
1870
|
-
* pruned later by a side-effect load (which a transient DB or memory-
|
|
1871
|
-
* pressure error can skip). Cheap: no package load is triggered.
|
|
1872
|
-
*/
|
|
1873
|
-
public getServingPackageCount(): number {
|
|
1874
|
-
const failed = this.getFailedPackages();
|
|
1875
|
-
let serving = 0;
|
|
1876
|
-
for (const name of this.packageStatuses.keys()) {
|
|
1877
|
-
if (!failed.has(name)) serving += 1;
|
|
1878
|
-
}
|
|
1879
|
-
return serving;
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
/**
|
|
1883
|
-
* Record why a configured package's location never mounted, so /status can
|
|
1884
|
-
* name the real cause instead of the missing-manifest fallout it produces.
|
|
1885
|
-
* Called by EnvironmentStore right after the environment is created.
|
|
1886
|
-
*/
|
|
1887
|
-
public setPackageMountError(packageName: string, message: string): void {
|
|
1888
|
-
this.mountErrors.set(packageName, message);
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
/** Forget any recorded failure for a package, whatever its cause. */
|
|
1892
|
-
private clearPackageLoadFailure(packageName: string): void {
|
|
1893
|
-
this.failedPackages.delete(packageName);
|
|
1894
|
-
this.mountErrors.delete(packageName);
|
|
1895
|
-
}
|
|
1896
|
-
|
|
1897
|
-
/** Packages configured for this environment that did not load, and why. */
|
|
1898
|
-
public getFailedPackages(): ReadonlyMap<string, string> {
|
|
1899
|
-
if (this.mountErrors.size === 0) return this.failedPackages;
|
|
1900
|
-
// Mount errors last, so the specific cause overwrites the generic
|
|
1901
|
-
// manifest error that the un-mounted package produces on its lazy load.
|
|
1902
|
-
return new Map([...this.failedPackages, ...this.mountErrors]);
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
public setPackageStatus(packageName: string, status: PackageStatus): void {
|
|
1906
|
-
const currentStatus = this.packageStatuses.get(packageName);
|
|
1907
|
-
this.packageStatuses.set(packageName, {
|
|
1908
|
-
name: packageName,
|
|
1909
|
-
loadTimestamp: currentStatus?.loadTimestamp || Date.now(),
|
|
1910
|
-
status: status,
|
|
1911
|
-
});
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
public deletePackageStatus(packageName: string): void {
|
|
1915
|
-
this.packageStatuses.delete(packageName);
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
public async deletePackage(packageName: string): Promise<void> {
|
|
1919
|
-
assertSafePackageName(packageName);
|
|
1920
|
-
return this.withPackageLock(packageName, async () => {
|
|
1921
|
-
// Clear the load failure before the early return, not after it. A
|
|
1922
|
-
// package that failed to load is not in `packages` (the load catch
|
|
1923
|
-
// evicts it), so deleting it takes the early return every time, while
|
|
1924
|
-
// the controller still drops its config row. Leaving the entry would
|
|
1925
|
-
// make getStatus report a loadError for a package that is no longer
|
|
1926
|
-
// configured, which is the one thing that channel must not do.
|
|
1927
|
-
this.clearPackageLoadFailure(packageName);
|
|
1928
|
-
|
|
1929
|
-
const _package = this.packages.get(packageName);
|
|
1930
|
-
if (!_package) {
|
|
1931
|
-
return;
|
|
1932
|
-
}
|
|
1933
|
-
const packageStatus = this.packageStatuses.get(packageName);
|
|
1934
|
-
|
|
1935
|
-
// The mutex now serializes load/install/compile against delete, so
|
|
1936
|
-
// the LOADING-state guard is mostly vestigial — left in place for
|
|
1937
|
-
// backwards-compatible error messaging in case anything bypasses
|
|
1938
|
-
// the lock.
|
|
1939
|
-
if (packageStatus?.status === PackageStatus.LOADING) {
|
|
1940
|
-
logger.error("Package loading. Can't unload.", {
|
|
1941
|
-
environmentName: this.environmentName,
|
|
1942
|
-
packageName,
|
|
1943
|
-
});
|
|
1944
|
-
throw new Error(
|
|
1945
|
-
"Package loading. Can't unload. " +
|
|
1946
|
-
this.environmentName +
|
|
1947
|
-
" " +
|
|
1948
|
-
packageName,
|
|
1949
|
-
);
|
|
1950
|
-
} else if (packageStatus?.status === PackageStatus.SERVING) {
|
|
1951
|
-
this.setPackageStatus(packageName, PackageStatus.UNLOADING);
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
// Retire the package's connections via the existing 30s drain so
|
|
1955
|
-
// any in-flight queries that already acquired a connection finish
|
|
1956
|
-
// before the underlying duckdb handle is released.
|
|
1957
|
-
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
1958
|
-
_package.getMalloyConfig().shutdown("close"),
|
|
1959
|
-
);
|
|
1960
|
-
|
|
1961
|
-
// Atomically rename the canonical tree out of the way so no reader
|
|
1962
|
-
// can stat into it after the lock is released. The actual fs.rm is
|
|
1963
|
-
// deferred to setImmediate to keep the lock-hold time at one
|
|
1964
|
-
// rename rather than a (potentially slow) recursive remove.
|
|
1965
|
-
const canonicalPath = safeJoinUnderRoot(
|
|
1966
|
-
this.environmentPath,
|
|
1967
|
-
packageName,
|
|
1968
|
-
);
|
|
1969
|
-
const retiredPath = this.allocateRetiredPath(packageName);
|
|
1970
|
-
let renamed = false;
|
|
1971
|
-
try {
|
|
1972
|
-
await fs.promises.mkdir(path.dirname(retiredPath), {
|
|
1973
|
-
recursive: true,
|
|
1974
|
-
});
|
|
1975
|
-
await fs.promises.rename(canonicalPath, retiredPath);
|
|
1976
|
-
renamed = true;
|
|
1977
|
-
} catch (err) {
|
|
1978
|
-
logger.error(
|
|
1979
|
-
"Error renaming package directory to retired during unload",
|
|
1980
|
-
{
|
|
1981
|
-
error: err,
|
|
1982
|
-
environmentName: this.environmentName,
|
|
1983
|
-
packageName,
|
|
1984
|
-
},
|
|
1985
|
-
);
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
this.packages.delete(packageName);
|
|
1989
|
-
this.packageStatuses.delete(packageName);
|
|
1990
|
-
|
|
1991
|
-
if (renamed) {
|
|
1992
|
-
setImmediate(() => {
|
|
1993
|
-
void fs.promises
|
|
1994
|
-
.rm(retiredPath, { recursive: true, force: true })
|
|
1995
|
-
.catch((err) => {
|
|
1996
|
-
logger.warn(
|
|
1997
|
-
`Failed to clean up retired package directory ${retiredPath}`,
|
|
1998
|
-
{ error: err },
|
|
1999
|
-
);
|
|
2000
|
-
});
|
|
2001
|
-
});
|
|
2002
|
-
}
|
|
2003
|
-
});
|
|
2004
|
-
}
|
|
2005
|
-
|
|
2006
|
-
/**
|
|
2007
|
-
* Evict a package from the in-memory caches WITHOUT touching its on-disk
|
|
2008
|
-
* directory — the non-destructive counterpart to {@link deletePackage}.
|
|
2009
|
-
*
|
|
2010
|
-
* Used to roll back a no-location `addPackage` (which registers a
|
|
2011
|
-
* *pre-existing*, user-owned directory) when post-load validation rejects
|
|
2012
|
-
* it: deleting the tree there would destroy content the publisher never
|
|
2013
|
-
* created. This still drains and closes the connections the just-created
|
|
2014
|
-
* `Package` opened, so the duckdb handle isn't leaked.
|
|
2015
|
-
*/
|
|
2016
|
-
public async unloadPackage(packageName: string): Promise<void> {
|
|
2017
|
-
assertSafePackageName(packageName);
|
|
2018
|
-
return this.withPackageLock(packageName, async () => {
|
|
2019
|
-
const _package = this.packages.get(packageName);
|
|
2020
|
-
if (!_package) {
|
|
2021
|
-
return;
|
|
2022
|
-
}
|
|
2023
|
-
if (
|
|
2024
|
-
this.packageStatuses.get(packageName)?.status ===
|
|
2025
|
-
PackageStatus.SERVING
|
|
2026
|
-
) {
|
|
2027
|
-
this.setPackageStatus(packageName, PackageStatus.UNLOADING);
|
|
2028
|
-
}
|
|
2029
|
-
// Same 30s connection drain as deletePackage — just no fs rename/rm.
|
|
2030
|
-
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
2031
|
-
_package.getMalloyConfig().shutdown("close"),
|
|
2032
|
-
);
|
|
2033
|
-
this.packages.delete(packageName);
|
|
2034
|
-
this.packageStatuses.delete(packageName);
|
|
2035
|
-
});
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
public updateConnections(
|
|
2039
|
-
malloyConfig: EnvironmentMalloyConfig,
|
|
2040
|
-
_apiConnections?: ApiConnection[],
|
|
2041
|
-
afterPreviousRelease?: () => Promise<void>,
|
|
2042
|
-
): void {
|
|
2043
|
-
const previousMalloyConfig = this.malloyConfig;
|
|
2044
|
-
this.malloyConfig = malloyConfig;
|
|
2045
|
-
this.apiConnections = malloyConfig.apiConnections;
|
|
2046
|
-
|
|
2047
|
-
if (previousMalloyConfig !== malloyConfig) {
|
|
2048
|
-
this.retireConnectionGeneration(
|
|
2049
|
-
`environment ${this.environmentName}`,
|
|
2050
|
-
async () => {
|
|
2051
|
-
await previousMalloyConfig.releaseConnections();
|
|
2052
|
-
await afterPreviousRelease?.();
|
|
2053
|
-
},
|
|
2054
|
-
);
|
|
2055
|
-
} else {
|
|
2056
|
-
void afterPreviousRelease?.();
|
|
2057
|
-
}
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
public async deleteConnection(connectionName: string): Promise<void> {
|
|
2061
|
-
const index = this.apiConnections.findIndex(
|
|
2062
|
-
(conn) => conn.name === connectionName,
|
|
2063
|
-
);
|
|
2064
|
-
|
|
2065
|
-
if (index !== -1) {
|
|
2066
|
-
this.apiConnections.splice(index, 1);
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
if (index !== -1) {
|
|
2070
|
-
logger.info(
|
|
2071
|
-
`Removed connection ${connectionName} from environment ${this.environmentName}`,
|
|
2072
|
-
);
|
|
2073
|
-
} else {
|
|
2074
|
-
logger.warn(
|
|
2075
|
-
`Connection ${connectionName} not found in environment ${this.environmentName}`,
|
|
2076
|
-
);
|
|
2077
|
-
}
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
|
-
public async closeAllConnections(): Promise<void> {
|
|
2081
|
-
// Release the package-scoped MalloyConfigs (each holds the package's own
|
|
2082
|
-
// sandbox `duckdb` connection) before tearing down the environment config
|
|
2083
|
-
// they wrap. Without this, hard unload leaks per-package DuckDB handles.
|
|
2084
|
-
const packageReleases = await Promise.allSettled(
|
|
2085
|
-
Array.from(this.packages.values(), (pkg) =>
|
|
2086
|
-
pkg.getMalloyConfig().shutdown("close"),
|
|
2087
|
-
),
|
|
2088
|
-
);
|
|
2089
|
-
for (const result of packageReleases) {
|
|
2090
|
-
if (result.status === "rejected") {
|
|
2091
|
-
logger.error(
|
|
2092
|
-
`Error closing package connections for environment ${this.environmentName}`,
|
|
2093
|
-
{ error: result.reason },
|
|
2094
|
-
);
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
this.packages.clear();
|
|
2098
|
-
this.packageStatuses.clear();
|
|
2099
|
-
|
|
2100
|
-
try {
|
|
2101
|
-
await this.malloyConfig.releaseConnections();
|
|
2102
|
-
} catch (error) {
|
|
2103
|
-
logger.error(
|
|
2104
|
-
`Error closing connections for environment ${this.environmentName}`,
|
|
2105
|
-
{ error },
|
|
2106
|
-
);
|
|
2107
|
-
}
|
|
2108
|
-
await this.releaseAllRetiredConnectionGenerations();
|
|
2109
|
-
|
|
2110
|
-
this.apiConnections = [];
|
|
2111
|
-
|
|
2112
|
-
logger.info(
|
|
2113
|
-
`Closed all connections for environment ${this.environmentName}`,
|
|
2114
|
-
);
|
|
2115
|
-
}
|
|
2116
|
-
|
|
2117
|
-
public async serialize(): Promise<ApiEnvironment> {
|
|
2118
|
-
return {
|
|
2119
|
-
...this.metadata,
|
|
2120
|
-
connections: this.listApiConnections(),
|
|
2121
|
-
packages: await this.listPackages(),
|
|
2122
|
-
};
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
public async deleteDuckDBConnection(connectionName: string): Promise<void> {
|
|
2126
|
-
const duckdbPath = path.join(
|
|
2127
|
-
this.environmentPath,
|
|
2128
|
-
`${connectionName}.duckdb`,
|
|
2129
|
-
);
|
|
2130
|
-
try {
|
|
2131
|
-
await fs.promises.rm(duckdbPath, { force: true });
|
|
2132
|
-
logger.info(
|
|
2133
|
-
`Removed DuckDB connection file ${connectionName} from environment ${this.environmentName}`,
|
|
2134
|
-
);
|
|
2135
|
-
} catch (error) {
|
|
2136
|
-
logger.error(
|
|
2137
|
-
`Failed to remove DuckDB connection file ${connectionName} from environment ${this.environmentName}`,
|
|
2138
|
-
{ error },
|
|
2139
|
-
);
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2143
|
-
public async deleteDuckLakeConnection(
|
|
2144
|
-
connectionName: string,
|
|
2145
|
-
): Promise<void> {
|
|
2146
|
-
await deleteDuckLakeConnectionFile(connectionName, this.environmentPath);
|
|
2147
|
-
logger.info(
|
|
2148
|
-
`Removed DuckLake connection ${connectionName} from environment ${this.environmentName}`,
|
|
2149
|
-
);
|
|
2150
|
-
}
|
|
2151
|
-
}
|
|
2152
|
-
|
|
2153
|
-
/**
|
|
2154
|
-
* Extracts the preamble from a Malloy model file — the leading block of
|
|
2155
|
-
* `##!` pragmas, `import` statements, blank lines, and comments that appear
|
|
2156
|
-
* before any `source:`, `query:`, or `run:` definition. This allows a
|
|
2157
|
-
* submitted query to inherit the model's import context.
|
|
2158
|
-
*/
|
|
2159
|
-
export async function extractPreamble(modelPath: string): Promise<string> {
|
|
2160
|
-
try {
|
|
2161
|
-
const content = await fs.promises.readFile(modelPath, "utf8");
|
|
2162
|
-
return extractPreambleFromSource(content);
|
|
2163
|
-
} catch {
|
|
2164
|
-
// If the model file can't be read, return empty preamble
|
|
2165
|
-
// and let the compilation surface any import errors naturally.
|
|
2166
|
-
return "";
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
/**
|
|
2171
|
-
* Extracts the preamble from Malloy source text. Exported for testing.
|
|
2172
|
-
*/
|
|
2173
|
-
export function extractPreambleFromSource(content: string): string {
|
|
2174
|
-
const lines = content.split("\n");
|
|
2175
|
-
const preambleLines: string[] = [];
|
|
2176
|
-
|
|
2177
|
-
for (const line of lines) {
|
|
2178
|
-
const trimmed = line.trim();
|
|
2179
|
-
// Stop at the first source/query/run definition
|
|
2180
|
-
if (
|
|
2181
|
-
trimmed.startsWith("source:") ||
|
|
2182
|
-
trimmed.startsWith("query:") ||
|
|
2183
|
-
trimmed.startsWith("run:")
|
|
2184
|
-
) {
|
|
2185
|
-
break;
|
|
2186
|
-
}
|
|
2187
|
-
preambleLines.push(line);
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
return preambleLines.join("\n").trimEnd();
|
|
2191
|
-
}
|