@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.
Files changed (301) hide show
  1. package/dist/app/api-doc.yaml +47 -17
  2. package/dist/package_load_worker.mjs +8 -1
  3. package/dist/server.mjs +471 -54
  4. package/package.json +5 -2
  5. package/.eslintrc.json +0 -44
  6. package/.prettierignore +0 -1
  7. package/.prettierrc +0 -4
  8. package/build.ts +0 -97
  9. package/eslint.config.mjs +0 -76
  10. package/k6-tests/README.md +0 -144
  11. package/k6-tests/bun.lock +0 -797
  12. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  13. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  14. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  15. package/k6-tests/load-test/load-test-crud.ts +0 -142
  16. package/k6-tests/load-test/load-test.ts +0 -784
  17. package/k6-tests/package.json +0 -27
  18. package/k6-tests/scripts/run-with-otel.sh +0 -50
  19. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  20. package/k6-tests/tsconfig.json +0 -29
  21. package/k6-tests/utils/client_factory.ts +0 -128
  22. package/k6-tests/utils/common.ts +0 -489
  23. package/k6-tests/utils/logger.ts +0 -62
  24. package/k6-tests/utils/types.d.ts +0 -35
  25. package/malloy_mcp.dxt +0 -0
  26. package/publisher.config.example.bigquery.json +0 -33
  27. package/publisher.config.example.duckdb.json +0 -23
  28. package/publisher.config.json +0 -23
  29. package/scripts/bake-duckdb-extensions.js +0 -110
  30. package/src/config.spec.ts +0 -1535
  31. package/src/config.theme.spec.ts +0 -178
  32. package/src/config.ts +0 -1142
  33. package/src/constants.ts +0 -105
  34. package/src/controller/compile.controller.ts +0 -40
  35. package/src/controller/connection.controller.spec.ts +0 -849
  36. package/src/controller/connection.controller.ts +0 -901
  37. package/src/controller/database.controller.ts +0 -24
  38. package/src/controller/materialization.controller.spec.ts +0 -293
  39. package/src/controller/materialization.controller.ts +0 -319
  40. package/src/controller/model.controller.ts +0 -164
  41. package/src/controller/package.controller.spec.ts +0 -273
  42. package/src/controller/package.controller.ts +0 -306
  43. package/src/controller/query.controller.ts +0 -152
  44. package/src/controller/theme.controller.ts +0 -83
  45. package/src/controller/watch-mode.controller.ts +0 -243
  46. package/src/data_styles.ts +0 -71
  47. package/src/default-publisher.config.json +0 -23
  48. package/src/ducklake_version.spec.ts +0 -163
  49. package/src/ducklake_version.ts +0 -153
  50. package/src/errors.spec.ts +0 -119
  51. package/src/errors.ts +0 -237
  52. package/src/filter_deprecation.spec.ts +0 -64
  53. package/src/filter_deprecation.ts +0 -42
  54. package/src/health.spec.ts +0 -123
  55. package/src/health.ts +0 -233
  56. package/src/heap_check.spec.ts +0 -144
  57. package/src/heap_check.ts +0 -144
  58. package/src/instrumentation.ts +0 -214
  59. package/src/json_utils.spec.ts +0 -51
  60. package/src/json_utils.ts +0 -33
  61. package/src/logger.spec.ts +0 -210
  62. package/src/logger.ts +0 -251
  63. package/src/malloy_pin_prereqs.spec.ts +0 -25
  64. package/src/materialization_metrics.spec.ts +0 -169
  65. package/src/materialization_metrics.ts +0 -346
  66. package/src/mcp/error_messages.spec.ts +0 -35
  67. package/src/mcp/error_messages.ts +0 -183
  68. package/src/mcp/handler_utils.spec.ts +0 -108
  69. package/src/mcp/handler_utils.ts +0 -251
  70. package/src/mcp/mcp_constants.ts +0 -6
  71. package/src/mcp/query_envelope.spec.ts +0 -229
  72. package/src/mcp/query_envelope.ts +0 -240
  73. package/src/mcp/server.protocol.spec.ts +0 -250
  74. package/src/mcp/server.ts +0 -87
  75. package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
  76. package/src/mcp/skills/build_skills_bundle.ts +0 -176
  77. package/src/mcp/skills/skills_bundle.json +0 -1
  78. package/src/mcp/skills/skills_bundle.spec.ts +0 -171
  79. package/src/mcp/tool_response.spec.ts +0 -108
  80. package/src/mcp/tool_response.ts +0 -138
  81. package/src/mcp/tools/compile_tool.spec.ts +0 -315
  82. package/src/mcp/tools/compile_tool.ts +0 -208
  83. package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
  84. package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
  85. package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
  86. package/src/mcp/tools/docs_search_tool.ts +0 -128
  87. package/src/mcp/tools/embedding_index.spec.ts +0 -1236
  88. package/src/mcp/tools/embedding_index.ts +0 -808
  89. package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
  90. package/src/mcp/tools/execute_query_tool.ts +0 -316
  91. package/src/mcp/tools/get_context_eval.ts +0 -275
  92. package/src/mcp/tools/get_context_tool.spec.ts +0 -592
  93. package/src/mcp/tools/get_context_tool.ts +0 -585
  94. package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
  95. package/src/mcp/tools/reload_package_tool.ts +0 -132
  96. package/src/mcp_config.spec.ts +0 -919
  97. package/src/mcp_config.ts +0 -425
  98. package/src/oom_guards.integration.spec.ts +0 -269
  99. package/src/package_load/package_load_pool.spec.ts +0 -252
  100. package/src/package_load/package_load_pool.ts +0 -931
  101. package/src/package_load/package_load_worker.ts +0 -1034
  102. package/src/package_load/protocol.ts +0 -364
  103. package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
  104. package/src/package_load/rpc_wait_accountant.ts +0 -76
  105. package/src/package_load_metrics.spec.ts +0 -114
  106. package/src/package_load_metrics.ts +0 -127
  107. package/src/path_safety.spec.ts +0 -158
  108. package/src/path_safety.ts +0 -146
  109. package/src/pg_helpers.spec.ts +0 -223
  110. package/src/pg_helpers.ts +0 -52
  111. package/src/query_cap_metrics.spec.ts +0 -89
  112. package/src/query_cap_metrics.ts +0 -116
  113. package/src/query_concurrency.spec.ts +0 -247
  114. package/src/query_concurrency.ts +0 -237
  115. package/src/query_metadata_metrics.ts +0 -49
  116. package/src/query_param_utils.ts +0 -29
  117. package/src/query_timeout.spec.ts +0 -224
  118. package/src/query_timeout.ts +0 -179
  119. package/src/runtime/publisher.js +0 -328
  120. package/src/server-old.ts +0 -988
  121. package/src/server.ts +0 -2059
  122. package/src/service/annotations.spec.ts +0 -118
  123. package/src/service/annotations.ts +0 -91
  124. package/src/service/authorize.spec.ts +0 -154
  125. package/src/service/authorize.ts +0 -498
  126. package/src/service/authorize_integration.spec.ts +0 -2041
  127. package/src/service/build_plan.spec.ts +0 -685
  128. package/src/service/build_plan.ts +0 -797
  129. package/src/service/compile_authorize.spec.ts +0 -85
  130. package/src/service/compile_fragment_techniques.spec.ts +0 -156
  131. package/src/service/connection.spec.ts +0 -2360
  132. package/src/service/connection.ts +0 -2187
  133. package/src/service/connection_config.spec.ts +0 -734
  134. package/src/service/connection_config.ts +0 -899
  135. package/src/service/connection_federation.spec.ts +0 -184
  136. package/src/service/connection_fingerprint.spec.ts +0 -102
  137. package/src/service/connection_service.spec.ts +0 -711
  138. package/src/service/connection_service.ts +0 -291
  139. package/src/service/cron_evaluator.spec.ts +0 -79
  140. package/src/service/cron_evaluator.ts +0 -105
  141. package/src/service/db_utils.spec.ts +0 -1085
  142. package/src/service/db_utils.ts +0 -1502
  143. package/src/service/duckdb_instance_isolation.spec.ts +0 -137
  144. package/src/service/ducklake_lazy_attach.spec.ts +0 -110
  145. package/src/service/embedding_provider.spec.ts +0 -329
  146. package/src/service/embedding_provider.ts +0 -236
  147. package/src/service/environment.ts +0 -2191
  148. package/src/service/environment_admission.spec.ts +0 -344
  149. package/src/service/environment_compile.spec.ts +0 -197
  150. package/src/service/environment_store.spec.ts +0 -2274
  151. package/src/service/environment_store.ts +0 -2434
  152. package/src/service/environment_store_anchoring.spec.ts +0 -107
  153. package/src/service/environment_store_clone.spec.ts +0 -350
  154. package/src/service/explore_visibility.spec.ts +0 -434
  155. package/src/service/exports_probe.spec.ts +0 -107
  156. package/src/service/extension_fetch_policy.spec.ts +0 -256
  157. package/src/service/filter.spec.ts +0 -458
  158. package/src/service/filter.ts +0 -341
  159. package/src/service/filter_bypass.spec.ts +0 -418
  160. package/src/service/filter_integration.spec.ts +0 -1004
  161. package/src/service/freshness.spec.ts +0 -183
  162. package/src/service/freshness.ts +0 -112
  163. package/src/service/gcs_s3_utils.ts +0 -427
  164. package/src/service/given.ts +0 -105
  165. package/src/service/givens_integration.spec.ts +0 -219
  166. package/src/service/manifest_loader.spec.ts +0 -189
  167. package/src/service/manifest_loader.ts +0 -156
  168. package/src/service/materialization_build_session.spec.ts +0 -435
  169. package/src/service/materialization_build_session.ts +0 -681
  170. package/src/service/materialization_config_validation.spec.ts +0 -99
  171. package/src/service/materialization_config_validation.ts +0 -120
  172. package/src/service/materialization_eligibility.spec.ts +0 -158
  173. package/src/service/materialization_eligibility.ts +0 -305
  174. package/src/service/materialization_schedule_surface.spec.ts +0 -256
  175. package/src/service/materialization_scheduler.spec.ts +0 -289
  176. package/src/service/materialization_scheduler.ts +0 -304
  177. package/src/service/materialization_serve_transform.spec.ts +0 -1003
  178. package/src/service/materialization_serve_transform.ts +0 -779
  179. package/src/service/materialization_service.spec.ts +0 -2373
  180. package/src/service/materialization_service.ts +0 -2342
  181. package/src/service/materialization_test_fixtures.ts +0 -249
  182. package/src/service/model.spec.ts +0 -1157
  183. package/src/service/model.ts +0 -3370
  184. package/src/service/model_limits.spec.ts +0 -209
  185. package/src/service/model_limits.ts +0 -131
  186. package/src/service/model_storage_serve.spec.ts +0 -193
  187. package/src/service/model_storage_serve_joins.spec.ts +0 -193
  188. package/src/service/package.spec.ts +0 -612
  189. package/src/service/package.ts +0 -1799
  190. package/src/service/package_manifest.spec.ts +0 -230
  191. package/src/service/package_manifest.ts +0 -241
  192. package/src/service/package_memory_governor.spec.ts +0 -173
  193. package/src/service/package_memory_governor.ts +0 -233
  194. package/src/service/package_quote_bound_tables.spec.ts +0 -171
  195. package/src/service/package_race.spec.ts +0 -208
  196. package/src/service/package_reload_safety.spec.ts +0 -193
  197. package/src/service/package_rollback.spec.ts +0 -190
  198. package/src/service/package_worker_path.spec.ts +0 -645
  199. package/src/service/path_injection.spec.ts +0 -39
  200. package/src/service/persist_annotation_validation.spec.ts +0 -89
  201. package/src/service/persist_annotation_validation.ts +0 -59
  202. package/src/service/persistence_policy.spec.ts +0 -374
  203. package/src/service/proxy.spec.ts +0 -414
  204. package/src/service/proxy.ts +0 -248
  205. package/src/service/query_boundary.spec.ts +0 -470
  206. package/src/service/query_metadata.spec.ts +0 -408
  207. package/src/service/query_metadata.ts +0 -492
  208. package/src/service/query_metadata_identity.spec.ts +0 -149
  209. package/src/service/query_text.spec.ts +0 -79
  210. package/src/service/query_text.ts +0 -43
  211. package/src/service/quoting.spec.ts +0 -135
  212. package/src/service/quoting.ts +0 -78
  213. package/src/service/resolve_environment.ts +0 -15
  214. package/src/service/restricted_mode.spec.ts +0 -299
  215. package/src/service/source_extraction.ts +0 -225
  216. package/src/service/theme_store.ts +0 -199
  217. package/src/storage/DatabaseInterface.ts +0 -198
  218. package/src/storage/StorageManager.ts +0 -113
  219. package/src/storage/duckdb/ConnectionRepository.ts +0 -143
  220. package/src/storage/duckdb/DuckDBConnection.ts +0 -123
  221. package/src/storage/duckdb/DuckDBRepository.ts +0 -208
  222. package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
  223. package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
  224. package/src/storage/duckdb/MaterializationRepository.ts +0 -336
  225. package/src/storage/duckdb/PackageRepository.ts +0 -135
  226. package/src/storage/duckdb/schema.ts +0 -229
  227. package/src/stream_helpers.spec.ts +0 -280
  228. package/src/stream_helpers.ts +0 -162
  229. package/src/telemetry.ts +0 -20
  230. package/src/test_helpers/metrics_harness.ts +0 -166
  231. package/src/theme_key_parity.spec.ts +0 -57
  232. package/src/utils.ts +0 -29
  233. package/tests/fixtures/authorize-compile/model.malloy +0 -9
  234. package/tests/fixtures/authorize-compile/publisher.json +0 -4
  235. package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
  236. package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
  237. package/tests/fixtures/html-pages-test/data.csv +0 -3
  238. package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
  239. package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
  240. package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
  241. package/tests/fixtures/html-pages-test/public/data.json +0 -1
  242. package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
  243. package/tests/fixtures/html-pages-test/public/index.html +0 -9
  244. package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
  245. package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
  246. package/tests/fixtures/html-pages-test/public/slides.html +0 -12
  247. package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
  248. package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
  249. package/tests/fixtures/html-pages-test/publisher.json +0 -5
  250. package/tests/fixtures/html-pages-test/report.malloy +0 -1
  251. package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
  252. package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
  253. package/tests/fixtures/persist-multi-level/publisher.json +0 -5
  254. package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
  255. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
  256. package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
  257. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
  258. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
  259. package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
  260. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  261. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  262. package/tests/fixtures/persist-test/publisher.json +0 -5
  263. package/tests/fixtures/publisher.config.json +0 -15
  264. package/tests/fixtures/query-givens/data/orders.csv +0 -7
  265. package/tests/fixtures/query-givens/model.malloy +0 -34
  266. package/tests/fixtures/query-givens/publisher.json +0 -5
  267. package/tests/fixtures/xlsx/database.xlsx +0 -0
  268. package/tests/harness/mcp_test_setup.ts +0 -332
  269. package/tests/harness/rest_e2e.ts +0 -68
  270. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
  271. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
  272. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
  273. package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
  274. package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
  275. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
  276. package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
  277. package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
  278. package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
  279. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
  280. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
  281. package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
  282. package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
  283. package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
  284. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
  285. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
  286. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
  287. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
  288. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
  289. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
  290. package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
  291. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
  292. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
  293. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
  294. package/tests/unit/controller/theme.controller.spec.ts +0 -141
  295. package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
  296. package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
  297. package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
  298. package/tests/unit/duckdb/repositories.test.ts +0 -208
  299. package/tests/unit/ducklake/ducklake.test.ts +0 -952
  300. package/tests/unit/service/theme_store.spec.ts +0 -139
  301. 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
- }