@malloy-publisher/server 0.0.235 → 0.0.237
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/api-doc.yaml +83 -27
- package/dist/app/assets/{EnvironmentPage-BsAnavYN.js → EnvironmentPage-DaP6CpgK.js} +1 -1
- package/dist/app/assets/{HomePage-CADE138j.js → HomePage-C9yNw3iu.js} +1 -1
- package/dist/app/assets/{LightMode-Cfh7KzN8.js → LightMode-Be4-G-dX.js} +1 -1
- package/dist/app/assets/{MainPage-CO3pRlnV.js → MainPage-J_rU2SgI.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-p9YjkRXZ.js → MaterializationsPage-DHgPI4MO.js} +1 -1
- package/dist/app/assets/ModelPage-BzXfjTu5.js +1 -0
- package/dist/app/assets/{PackagePage-e4kN75YR.js → PackagePage-ByoSTLJ4.js} +1 -1
- package/dist/app/assets/RouteError-CrSeKN2r.js +1 -0
- package/dist/app/assets/{ThemeEditorPage-CciagFTq.js → ThemeEditorPage-Btxnmhtk.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DNWmkCXa.js → WorkbookPage-D6JLPnWO.js} +1 -1
- package/dist/app/assets/{core-Rj_4rRnA.es-BZyvITuO.js → core-BSRgt1mG.es-Bc_D2Lq4.js} +1 -1
- package/dist/app/assets/{index-CH2AcDzc.js → index-BRijWj6K.js} +15 -15
- package/dist/app/assets/{index-DQa463gC.js → index-BX4D94xw.js} +1 -1
- package/dist/app/assets/{index-VBbcc8s6.js → index-Cvy0xtb7.js} +1 -1
- package/dist/app/assets/{index-DQpV7MyA.js → index-DMAhIJNC.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +59 -20
- package/dist/server.mjs +1342 -616
- package/package.json +8 -2
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -97
- package/dist/app/assets/ModelPage-C1OSTv-x.js +0 -1
- package/dist/app/assets/RouteError-CzbfOkng.js +0 -1
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.example.bigquery.json +0 -33
- package/publisher.config.example.duckdb.json +0 -23
- package/publisher.config.json +0 -23
- package/scripts/bake-duckdb-extensions.js +0 -110
- package/src/config.spec.ts +0 -1535
- package/src/config.theme.spec.ts +0 -178
- package/src/config.ts +0 -1142
- package/src/constants.ts +0 -105
- package/src/controller/compile.controller.ts +0 -40
- package/src/controller/connection.controller.spec.ts +0 -849
- package/src/controller/connection.controller.ts +0 -901
- package/src/controller/database.controller.ts +0 -24
- package/src/controller/materialization.controller.spec.ts +0 -293
- package/src/controller/materialization.controller.ts +0 -319
- package/src/controller/model.controller.ts +0 -164
- package/src/controller/package.controller.spec.ts +0 -273
- package/src/controller/package.controller.ts +0 -306
- package/src/controller/query.controller.ts +0 -152
- package/src/controller/theme.controller.ts +0 -83
- package/src/controller/watch-mode.controller.ts +0 -243
- package/src/data_styles.ts +0 -71
- package/src/default-publisher.config.json +0 -23
- package/src/ducklake_version.spec.ts +0 -163
- package/src/ducklake_version.ts +0 -153
- package/src/errors.spec.ts +0 -119
- package/src/errors.ts +0 -237
- package/src/filter_deprecation.spec.ts +0 -64
- package/src/filter_deprecation.ts +0 -42
- package/src/health.spec.ts +0 -123
- package/src/health.ts +0 -233
- package/src/heap_check.spec.ts +0 -144
- package/src/heap_check.ts +0 -144
- package/src/instrumentation.ts +0 -214
- package/src/json_utils.spec.ts +0 -51
- package/src/json_utils.ts +0 -33
- package/src/logger.spec.ts +0 -210
- package/src/logger.ts +0 -251
- package/src/malloy_pin_prereqs.spec.ts +0 -25
- package/src/materialization_metrics.spec.ts +0 -169
- package/src/materialization_metrics.ts +0 -346
- package/src/mcp/error_messages.spec.ts +0 -35
- package/src/mcp/error_messages.ts +0 -183
- package/src/mcp/handler_utils.spec.ts +0 -108
- package/src/mcp/handler_utils.ts +0 -251
- package/src/mcp/mcp_constants.ts +0 -6
- package/src/mcp/query_envelope.spec.ts +0 -229
- package/src/mcp/query_envelope.ts +0 -240
- package/src/mcp/server.protocol.spec.ts +0 -250
- package/src/mcp/server.ts +0 -87
- package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
- package/src/mcp/skills/build_skills_bundle.ts +0 -176
- package/src/mcp/skills/skills_bundle.json +0 -1
- package/src/mcp/skills/skills_bundle.spec.ts +0 -171
- package/src/mcp/tool_response.spec.ts +0 -108
- package/src/mcp/tool_response.ts +0 -138
- package/src/mcp/tools/compile_tool.spec.ts +0 -315
- package/src/mcp/tools/compile_tool.ts +0 -208
- package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
- package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
- package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
- package/src/mcp/tools/docs_search_tool.ts +0 -128
- package/src/mcp/tools/embedding_index.spec.ts +0 -1236
- package/src/mcp/tools/embedding_index.ts +0 -808
- package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
- package/src/mcp/tools/execute_query_tool.ts +0 -316
- package/src/mcp/tools/get_context_eval.ts +0 -275
- package/src/mcp/tools/get_context_tool.spec.ts +0 -592
- package/src/mcp/tools/get_context_tool.ts +0 -585
- package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
- package/src/mcp/tools/reload_package_tool.ts +0 -132
- package/src/mcp_config.spec.ts +0 -919
- package/src/mcp_config.ts +0 -425
- package/src/oom_guards.integration.spec.ts +0 -269
- package/src/package_load/package_load_pool.spec.ts +0 -252
- package/src/package_load/package_load_pool.ts +0 -931
- package/src/package_load/package_load_worker.ts +0 -1034
- package/src/package_load/protocol.ts +0 -364
- package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
- package/src/package_load/rpc_wait_accountant.ts +0 -76
- package/src/package_load_metrics.spec.ts +0 -114
- package/src/package_load_metrics.ts +0 -127
- package/src/path_safety.spec.ts +0 -158
- package/src/path_safety.ts +0 -146
- package/src/pg_helpers.spec.ts +0 -223
- package/src/pg_helpers.ts +0 -52
- package/src/query_cap_metrics.spec.ts +0 -89
- package/src/query_cap_metrics.ts +0 -116
- package/src/query_concurrency.spec.ts +0 -247
- package/src/query_concurrency.ts +0 -237
- package/src/query_metadata_metrics.ts +0 -49
- package/src/query_param_utils.ts +0 -29
- package/src/query_timeout.spec.ts +0 -224
- package/src/query_timeout.ts +0 -179
- package/src/runtime/publisher.js +0 -328
- package/src/server-old.ts +0 -988
- package/src/server.ts +0 -2059
- package/src/service/annotations.spec.ts +0 -118
- package/src/service/annotations.ts +0 -91
- package/src/service/authorize.spec.ts +0 -154
- package/src/service/authorize.ts +0 -498
- package/src/service/authorize_integration.spec.ts +0 -2041
- package/src/service/build_plan.spec.ts +0 -685
- package/src/service/build_plan.ts +0 -797
- package/src/service/compile_authorize.spec.ts +0 -85
- package/src/service/compile_fragment_techniques.spec.ts +0 -156
- package/src/service/connection.spec.ts +0 -2360
- package/src/service/connection.ts +0 -2187
- package/src/service/connection_config.spec.ts +0 -734
- package/src/service/connection_config.ts +0 -899
- package/src/service/connection_federation.spec.ts +0 -184
- package/src/service/connection_fingerprint.spec.ts +0 -102
- package/src/service/connection_service.spec.ts +0 -711
- package/src/service/connection_service.ts +0 -291
- package/src/service/cron_evaluator.spec.ts +0 -79
- package/src/service/cron_evaluator.ts +0 -105
- package/src/service/db_utils.spec.ts +0 -1085
- package/src/service/db_utils.ts +0 -1502
- package/src/service/duckdb_instance_isolation.spec.ts +0 -137
- package/src/service/ducklake_lazy_attach.spec.ts +0 -110
- package/src/service/embedding_provider.spec.ts +0 -329
- package/src/service/embedding_provider.ts +0 -236
- package/src/service/environment.ts +0 -2191
- package/src/service/environment_admission.spec.ts +0 -344
- package/src/service/environment_compile.spec.ts +0 -197
- package/src/service/environment_store.spec.ts +0 -2274
- package/src/service/environment_store.ts +0 -2434
- package/src/service/environment_store_anchoring.spec.ts +0 -107
- package/src/service/environment_store_clone.spec.ts +0 -350
- package/src/service/explore_visibility.spec.ts +0 -434
- package/src/service/exports_probe.spec.ts +0 -107
- package/src/service/extension_fetch_policy.spec.ts +0 -256
- package/src/service/filter.spec.ts +0 -458
- package/src/service/filter.ts +0 -341
- package/src/service/filter_bypass.spec.ts +0 -418
- package/src/service/filter_integration.spec.ts +0 -1004
- package/src/service/freshness.spec.ts +0 -183
- package/src/service/freshness.ts +0 -112
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/given.ts +0 -105
- package/src/service/givens_integration.spec.ts +0 -219
- package/src/service/manifest_loader.spec.ts +0 -189
- package/src/service/manifest_loader.ts +0 -156
- package/src/service/materialization_build_session.spec.ts +0 -435
- package/src/service/materialization_build_session.ts +0 -681
- package/src/service/materialization_config_validation.spec.ts +0 -99
- package/src/service/materialization_config_validation.ts +0 -120
- package/src/service/materialization_eligibility.spec.ts +0 -158
- package/src/service/materialization_eligibility.ts +0 -305
- package/src/service/materialization_schedule_surface.spec.ts +0 -256
- package/src/service/materialization_scheduler.spec.ts +0 -289
- package/src/service/materialization_scheduler.ts +0 -304
- package/src/service/materialization_serve_transform.spec.ts +0 -1003
- package/src/service/materialization_serve_transform.ts +0 -779
- package/src/service/materialization_service.spec.ts +0 -2373
- package/src/service/materialization_service.ts +0 -2342
- package/src/service/materialization_test_fixtures.ts +0 -249
- package/src/service/model.spec.ts +0 -1157
- package/src/service/model.ts +0 -3370
- package/src/service/model_limits.spec.ts +0 -209
- package/src/service/model_limits.ts +0 -131
- package/src/service/model_storage_serve.spec.ts +0 -193
- package/src/service/model_storage_serve_joins.spec.ts +0 -193
- package/src/service/package.spec.ts +0 -612
- package/src/service/package.ts +0 -1799
- package/src/service/package_manifest.spec.ts +0 -230
- package/src/service/package_manifest.ts +0 -241
- package/src/service/package_memory_governor.spec.ts +0 -173
- package/src/service/package_memory_governor.ts +0 -233
- package/src/service/package_quote_bound_tables.spec.ts +0 -171
- package/src/service/package_race.spec.ts +0 -208
- package/src/service/package_reload_safety.spec.ts +0 -193
- package/src/service/package_rollback.spec.ts +0 -190
- package/src/service/package_worker_path.spec.ts +0 -645
- package/src/service/path_injection.spec.ts +0 -39
- package/src/service/persist_annotation_validation.spec.ts +0 -89
- package/src/service/persist_annotation_validation.ts +0 -59
- package/src/service/persistence_policy.spec.ts +0 -374
- package/src/service/proxy.spec.ts +0 -414
- package/src/service/proxy.ts +0 -248
- package/src/service/query_boundary.spec.ts +0 -470
- package/src/service/query_metadata.spec.ts +0 -408
- package/src/service/query_metadata.ts +0 -492
- package/src/service/query_metadata_identity.spec.ts +0 -149
- package/src/service/query_text.spec.ts +0 -79
- package/src/service/query_text.ts +0 -43
- package/src/service/quoting.spec.ts +0 -135
- package/src/service/quoting.ts +0 -78
- package/src/service/resolve_environment.ts +0 -15
- package/src/service/restricted_mode.spec.ts +0 -299
- package/src/service/source_extraction.ts +0 -225
- package/src/service/theme_store.ts +0 -199
- package/src/storage/DatabaseInterface.ts +0 -198
- package/src/storage/StorageManager.ts +0 -113
- package/src/storage/duckdb/ConnectionRepository.ts +0 -143
- package/src/storage/duckdb/DuckDBConnection.ts +0 -123
- package/src/storage/duckdb/DuckDBRepository.ts +0 -208
- package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
- package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
- package/src/storage/duckdb/MaterializationRepository.ts +0 -336
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/schema.ts +0 -229
- package/src/stream_helpers.spec.ts +0 -280
- package/src/stream_helpers.ts +0 -162
- package/src/telemetry.ts +0 -20
- package/src/test_helpers/metrics_harness.ts +0 -166
- package/src/theme_key_parity.spec.ts +0 -57
- package/src/utils.ts +0 -29
- package/tests/fixtures/authorize-compile/model.malloy +0 -9
- package/tests/fixtures/authorize-compile/publisher.json +0 -4
- package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
- package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/data.csv +0 -3
- package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
- package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
- package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
- package/tests/fixtures/html-pages-test/public/data.json +0 -1
- package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
- package/tests/fixtures/html-pages-test/public/index.html +0 -9
- package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
- package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
- package/tests/fixtures/html-pages-test/public/slides.html +0 -12
- package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
- package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
- package/tests/fixtures/html-pages-test/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/report.malloy +0 -1
- package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
- package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
- package/tests/fixtures/persist-multi-level/publisher.json +0 -5
- package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/fixtures/query-givens/data/orders.csv +0 -7
- package/tests/fixtures/query-givens/model.malloy +0 -34
- package/tests/fixtures/query-givens/publisher.json +0 -5
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/harness/mcp_test_setup.ts +0 -332
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
- package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
- package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
- package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
- package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
- package/tests/unit/controller/theme.controller.spec.ts +0 -141
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
- package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
- package/tests/unit/duckdb/repositories.test.ts +0 -208
- package/tests/unit/ducklake/ducklake.test.ts +0 -952
- package/tests/unit/service/theme_store.spec.ts +0 -139
- package/tsconfig.json +0 -32
|
@@ -1,2342 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Connection as MalloyConnection,
|
|
3
|
-
PersistSource,
|
|
4
|
-
} from "@malloydata/malloy";
|
|
5
|
-
import { Manifest } from "@malloydata/malloy";
|
|
6
|
-
import {
|
|
7
|
-
BadRequestError,
|
|
8
|
-
InvalidStateTransitionError,
|
|
9
|
-
MaterializationConflictError,
|
|
10
|
-
MaterializationEligibilityError,
|
|
11
|
-
MaterializationNotFoundError,
|
|
12
|
-
} from "../errors";
|
|
13
|
-
import { logger } from "../logger";
|
|
14
|
-
import {
|
|
15
|
-
MaterializationMode,
|
|
16
|
-
recordAutoLoadOutcome,
|
|
17
|
-
recordChainedStorageBuild,
|
|
18
|
-
recordDropTables,
|
|
19
|
-
recordManifestBindDegraded,
|
|
20
|
-
recordMaterializationRun,
|
|
21
|
-
recordSourceBuildDuration,
|
|
22
|
-
recordSourcesOutcome,
|
|
23
|
-
recordStorageBuildFailure,
|
|
24
|
-
} from "../materialization_metrics";
|
|
25
|
-
import {
|
|
26
|
-
BuildInstruction,
|
|
27
|
-
BuildManifestResult,
|
|
28
|
-
BuildPlan,
|
|
29
|
-
FreshnessManifest,
|
|
30
|
-
Materialization,
|
|
31
|
-
MaterializationStatus,
|
|
32
|
-
MaterializationUpdate,
|
|
33
|
-
ManifestEntry,
|
|
34
|
-
ManifestReference,
|
|
35
|
-
ResourceRepository,
|
|
36
|
-
} from "../storage/DatabaseInterface";
|
|
37
|
-
import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
|
|
38
|
-
import { errMessage } from "../utils";
|
|
39
|
-
import {
|
|
40
|
-
CompiledBuildPlan,
|
|
41
|
-
compilePackageBuildPlan,
|
|
42
|
-
computeSourceEntityId,
|
|
43
|
-
deriveAnnotationFields,
|
|
44
|
-
projectToPublicColumns,
|
|
45
|
-
iterGraphSources,
|
|
46
|
-
resolveQueryMetadata,
|
|
47
|
-
} from "./build_plan";
|
|
48
|
-
import {
|
|
49
|
-
mergeQueryMetadata,
|
|
50
|
-
type QueryContext,
|
|
51
|
-
type QueryMetadata,
|
|
52
|
-
} from "./query_metadata";
|
|
53
|
-
import type { components } from "../api";
|
|
54
|
-
import { getPersistStorageMode } from "../config";
|
|
55
|
-
import { EnvironmentStore } from "./environment_store";
|
|
56
|
-
import { assertMaterializationEligible } from "./materialization_eligibility";
|
|
57
|
-
import {
|
|
58
|
-
assertStorageServeShapeCompiles,
|
|
59
|
-
buildDownstreamIntoStorage,
|
|
60
|
-
buildSourceIntoStorage,
|
|
61
|
-
dropStorageTable,
|
|
62
|
-
type StorageBuildResult,
|
|
63
|
-
} from "./materialization_build_session";
|
|
64
|
-
import { escapeSQL } from "./connection";
|
|
65
|
-
import {
|
|
66
|
-
buildChainedStorageBuildModel,
|
|
67
|
-
buildVirtualMap,
|
|
68
|
-
deriveServeBindings,
|
|
69
|
-
type ServeBinding,
|
|
70
|
-
type SourceLocation,
|
|
71
|
-
sliceSourceRange,
|
|
72
|
-
} from "./materialization_serve_transform";
|
|
73
|
-
import type { ApiConnection } from "./model";
|
|
74
|
-
import { fetchManifestEntries, splitManifestEntries } from "./manifest_loader";
|
|
75
|
-
import { readFileSync } from "fs";
|
|
76
|
-
import { fileURLToPath } from "url";
|
|
77
|
-
import {
|
|
78
|
-
bareTableName,
|
|
79
|
-
quoteIdentifier,
|
|
80
|
-
quoteManifestTablePath,
|
|
81
|
-
quoteTablePath,
|
|
82
|
-
} from "./quoting";
|
|
83
|
-
import { resolveEnvironmentId } from "./resolve_environment";
|
|
84
|
-
import { redactPgSecrets } from "../pg_helpers";
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* The narrow environment surface the build path needs to materialize into a
|
|
88
|
-
* `storage=` destination: resolve a connection's config by name (source creds to
|
|
89
|
-
* federate, destination catalog to attach) and the environment root (to derive a
|
|
90
|
-
* plain-DuckDB destination's file path). Kept minimal to avoid coupling the
|
|
91
|
-
* materialization service to the full Environment type.
|
|
92
|
-
*/
|
|
93
|
-
interface BuildEnvironment {
|
|
94
|
-
getApiConnection(connectionName: string): ApiConnection;
|
|
95
|
-
getEnvironmentPath(): string;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* What a build needs to tag the statements it issues: the package-level layer of
|
|
100
|
-
* per-query metadata, and the run's own context. Assembled once per run — every
|
|
101
|
-
* field is constant for the run, so a source only adds its own name.
|
|
102
|
-
*/
|
|
103
|
-
interface BuildQueryMetadata {
|
|
104
|
-
packageMaterialization:
|
|
105
|
-
| components["schemas"]["PackageMaterializationConfig"]
|
|
106
|
-
| null;
|
|
107
|
-
context: QueryContext;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* A connection's two metadata layers: the overridable default and the properties
|
|
112
|
-
* the deployment enforces.
|
|
113
|
-
*
|
|
114
|
-
* Fails open — a build must not fail because a connection's config could not be
|
|
115
|
-
* read for its tags — so an unreadable connection costs the layers, never the
|
|
116
|
-
* statement.
|
|
117
|
-
*/
|
|
118
|
-
function connectionMetadataLayers(
|
|
119
|
-
environment: BuildEnvironment,
|
|
120
|
-
connectionName: string,
|
|
121
|
-
): { default: QueryMetadata | null; enforced: QueryMetadata | null } {
|
|
122
|
-
try {
|
|
123
|
-
const connection = environment.getApiConnection(connectionName);
|
|
124
|
-
return {
|
|
125
|
-
default: connection?.queryMetadata ?? null,
|
|
126
|
-
enforced: connection?.queryMetadataEnforced ?? null,
|
|
127
|
-
};
|
|
128
|
-
} catch (error) {
|
|
129
|
-
// Diagnosable rather than silent: the layer this costs is the enforced
|
|
130
|
-
// one, and it is the only drop with no metric behind it.
|
|
131
|
-
logger.debug("No query-metadata layers for connection", {
|
|
132
|
-
connectionName,
|
|
133
|
-
error,
|
|
134
|
-
});
|
|
135
|
-
return { default: null, enforced: null };
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Length of the sourceEntityId prefix used when synthesizing staging table
|
|
141
|
-
* names. 12 hex chars is 48 bits of entropy, well inside every dialect's
|
|
142
|
-
* identifier limit (Postgres is the tightest at 63).
|
|
143
|
-
*/
|
|
144
|
-
const STAGING_ID_LEN = 12;
|
|
145
|
-
|
|
146
|
-
/** Staging suffix appended to a table name while it is being built. */
|
|
147
|
-
export function stagingSuffix(sourceEntityId: string): string {
|
|
148
|
-
// Drop hyphens so the suffix stays a bare identifier fragment when the id
|
|
149
|
-
// becomes a UUID5 (a no-op for the current hex ids).
|
|
150
|
-
return `_${sourceEntityId.replace(/-/g, "").substring(0, STAGING_ID_LEN)}`;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Physical table name the publisher self-assigns in auto-run mode: the
|
|
155
|
-
* `#@ persist name=<table>` value if present, else the Malloy source name.
|
|
156
|
-
* The author owns quoting the `name=` value for the dialect.
|
|
157
|
-
*/
|
|
158
|
-
function selfAssignTableName(persistSource: PersistSource): string {
|
|
159
|
-
return deriveAnnotationFields(persistSource).name || persistSource.name;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* The build manifest for a `storage=` build, with storage-materialized entries
|
|
164
|
-
* removed. A storage build runs in the source warehouse (passthrough), so it
|
|
165
|
-
* cannot reference an upstream that landed in a DuckDB/DuckLake store — dropping
|
|
166
|
-
* those entries makes the compiler INLINE the upstream (non-strict) or raise a
|
|
167
|
-
* clean strict-miss (strict), instead of emitting a cross-engine table
|
|
168
|
-
* reference. colocated entries are kept — the warehouse build can
|
|
169
|
-
* reference them, carrying forward the DIALECT-QUOTED table path the seed loop
|
|
170
|
-
* already stamped (publisher #904's quoteSeedTablePath), so the downstream FROM
|
|
171
|
-
* resolves a case-preserved upstream on a case-folding engine. Preserves the
|
|
172
|
-
* manifest's `strict` flag.
|
|
173
|
-
*/
|
|
174
|
-
export function manifestExcludingStorage(
|
|
175
|
-
manifest: Manifest,
|
|
176
|
-
builtEntries: Record<string, ManifestEntry>,
|
|
177
|
-
): Manifest["buildManifest"] {
|
|
178
|
-
const reduced = new Manifest();
|
|
179
|
-
reduced.strict = manifest.strict;
|
|
180
|
-
// The source manifest's entries are already dialect-quoted (#904); reuse that
|
|
181
|
-
// quoting rather than the raw physical name so the kept colocated references
|
|
182
|
-
// stay canonical for the downstream FROM.
|
|
183
|
-
const quoted = manifest.buildManifest.entries;
|
|
184
|
-
for (const [id, entry] of Object.entries(builtEntries)) {
|
|
185
|
-
if (!entry.storageConnectionName && quoted[id]) {
|
|
186
|
-
reduced.update(id, { tableName: quoted[id].tableName });
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
return reduced.buildManifest;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* The `storage=` destination a source DECLARES (external-tier intent), or
|
|
194
|
-
* undefined. Independent of `PERSIST_STORAGE_MODE` — reflects author intent, so
|
|
195
|
-
* the build can tell a `storage=` source apart from a plain colocated
|
|
196
|
-
* `#@ persist` even when the tier is off.
|
|
197
|
-
*/
|
|
198
|
-
function declaredStorage(persistSource: PersistSource): string | undefined {
|
|
199
|
-
return deriveAnnotationFields(persistSource).storage?.trim() || undefined;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Resolve a persist source's `#@ persist storage=<ref>` to the EFFECTIVE
|
|
204
|
-
* destination connection name for a build, or undefined for the default
|
|
205
|
-
* colocated path (the source materializes into its own warehouse). Read
|
|
206
|
-
* publisher-side from the compiled annotation (the same `annotationFields` map
|
|
207
|
-
* the plan echoes); the reference resolves generically against registered
|
|
208
|
-
* connections. Absent `storage=` ⇒ undefined (colocated); any value names a
|
|
209
|
-
* registered connection to materialize into. Any managed-tier alias is resolved
|
|
210
|
-
* by the host upstream and set on the wire instruction's `destination` — it
|
|
211
|
-
* never reaches this publisher-side generic resolution.
|
|
212
|
-
*
|
|
213
|
-
* When `PERSIST_STORAGE_MODE=off` this returns undefined regardless of the
|
|
214
|
-
* annotation, so the feature is a runtime kill switch that never fails a
|
|
215
|
-
* package (the ignored `storage=` is surfaced as a package warning, not an
|
|
216
|
-
* error). Undefined here does NOT mean "build it colocated" for a source that
|
|
217
|
-
* declared `storage=` — `deriveSelfInstructions` skips such a source entirely so
|
|
218
|
-
* it serves live; see {@link declaredStorage}.
|
|
219
|
-
*/
|
|
220
|
-
function resolveStorageDestination(
|
|
221
|
-
persistSource: PersistSource,
|
|
222
|
-
): string | undefined {
|
|
223
|
-
if (getPersistStorageMode() === "off") return undefined;
|
|
224
|
-
return declaredStorage(persistSource);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Connection-config keys whose string values are credentials to redact. */
|
|
228
|
-
const SENSITIVE_KEY =
|
|
229
|
-
/pass(word)?|secret|private_?key|service_?account|access_?key|token|connection_?string|account/i;
|
|
230
|
-
|
|
231
|
-
/** Collect credential string values (from sensitively-named keys) in a config. */
|
|
232
|
-
function collectSensitiveValues(value: unknown, out: Set<string>): void {
|
|
233
|
-
if (value === null || typeof value !== "object") return;
|
|
234
|
-
if (Array.isArray(value)) {
|
|
235
|
-
for (const v of value) collectSensitiveValues(v, out);
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
for (const [key, v] of Object.entries(value)) {
|
|
239
|
-
if (typeof v === "string" && v.length >= 4 && SENSITIVE_KEY.test(key)) {
|
|
240
|
-
out.add(v);
|
|
241
|
-
} else {
|
|
242
|
-
collectSensitiveValues(v, out);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Redact the actual credential values (from the given connection configs) out
|
|
249
|
-
* of an error message, then surface the message. This keeps a build error
|
|
250
|
-
* legible — a "schema not found" or "table does not exist" tells the operator
|
|
251
|
-
* exactly what to fix — while never leaking the passwords / secrets / service
|
|
252
|
-
* account JSON / connection strings a federation or attach error can echo. Only
|
|
253
|
-
* the concrete secret values are removed, not the message structure.
|
|
254
|
-
*/
|
|
255
|
-
export function redactConnectionSecrets(
|
|
256
|
-
message: string,
|
|
257
|
-
...connections: unknown[]
|
|
258
|
-
): string {
|
|
259
|
-
const secrets = new Set<string>();
|
|
260
|
-
for (const c of connections) collectSensitiveValues(c, secrets);
|
|
261
|
-
let redacted = redactPgSecrets(message);
|
|
262
|
-
for (const s of secrets) {
|
|
263
|
-
redacted = redacted.split(s).join("***");
|
|
264
|
-
// A DuckDB error often echoes the offending SQL statement, in which a
|
|
265
|
-
// secret containing a single quote appears single-quote-escaped (`''`) —
|
|
266
|
-
// so the raw value won't match. Also redact the escaped form.
|
|
267
|
-
const escaped = escapeSQL(s);
|
|
268
|
-
if (escaped !== s) redacted = redacted.split(escaped).join("***");
|
|
269
|
-
}
|
|
270
|
-
return redacted;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** Classify a thrown build error as cancelled (cooperative abort) or failed. */
|
|
274
|
-
function outcomeFor(
|
|
275
|
-
_err: unknown,
|
|
276
|
-
signal: AbortSignal | undefined,
|
|
277
|
-
): "failed" | "cancelled" {
|
|
278
|
-
return signal?.aborted ? "cancelled" : "failed";
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Allowed status transitions. The build runs without an intermediate
|
|
283
|
-
* plan-ready pause: PENDING advances straight to MANIFEST_ROWS_READY once the
|
|
284
|
-
* tables are built, then to MANIFEST_FILE_READY. MANIFEST_FILE_READY, FAILED,
|
|
285
|
-
* and CANCELLED are terminal.
|
|
286
|
-
*/
|
|
287
|
-
const VALID_TRANSITIONS: Record<
|
|
288
|
-
MaterializationStatus,
|
|
289
|
-
MaterializationStatus[]
|
|
290
|
-
> = {
|
|
291
|
-
PENDING: ["MANIFEST_ROWS_READY", "FAILED", "CANCELLED"],
|
|
292
|
-
MANIFEST_ROWS_READY: ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"],
|
|
293
|
-
MANIFEST_FILE_READY: [],
|
|
294
|
-
FAILED: [],
|
|
295
|
-
CANCELLED: [],
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Orchestrates single-call materialization builds.
|
|
300
|
-
*
|
|
301
|
-
* The build plan is a deterministic property of the compiled package
|
|
302
|
-
* (`Package.buildPlan`), so there is no separate plan round-trip. On create
|
|
303
|
-
* the publisher either auto-runs (self-assigns physical names from the
|
|
304
|
-
* `#@ persist name=` annotation and builds + auto-loads every persist source)
|
|
305
|
-
* or, when the caller supplies `buildInstructions` derived from
|
|
306
|
-
* `Package.buildPlan`, builds directly into the caller-assigned names without
|
|
307
|
-
* auto-loading the manifest. Both paths build in the background and return the
|
|
308
|
-
* PENDING record immediately.
|
|
309
|
-
*
|
|
310
|
-
* At most one active materialization per (environment, package) is enforced
|
|
311
|
-
* by the DB-level unique index on `materializations.active_key` (see
|
|
312
|
-
* {@link MaterializationRepository}). Cancellation is cooperative via
|
|
313
|
-
* AbortController.
|
|
314
|
-
*/
|
|
315
|
-
export class MaterializationService {
|
|
316
|
-
/** In-flight runs, so they can be cancelled. In-process only. */
|
|
317
|
-
private runningAbortControllers = new Map<string, AbortController>();
|
|
318
|
-
|
|
319
|
-
constructor(private environmentStore: EnvironmentStore) {}
|
|
320
|
-
|
|
321
|
-
private get repository(): ResourceRepository {
|
|
322
|
-
return this.environmentStore.storageManager.getRepository();
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// ==================== STATE MACHINE ====================
|
|
326
|
-
|
|
327
|
-
private validateTransition(
|
|
328
|
-
current: MaterializationStatus,
|
|
329
|
-
next: MaterializationStatus,
|
|
330
|
-
): void {
|
|
331
|
-
if (!VALID_TRANSITIONS[current].includes(next)) {
|
|
332
|
-
throw new InvalidStateTransitionError(
|
|
333
|
-
`Cannot transition from ${current} to ${next}`,
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
private async transition(
|
|
339
|
-
id: string,
|
|
340
|
-
next: MaterializationStatus,
|
|
341
|
-
extra?: Omit<MaterializationUpdate, "status">,
|
|
342
|
-
): Promise<Materialization> {
|
|
343
|
-
const current = await this.repository.getMaterializationById(id);
|
|
344
|
-
if (!current) {
|
|
345
|
-
throw new MaterializationNotFoundError(
|
|
346
|
-
`Materialization ${id} not found`,
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
this.validateTransition(current.status, next);
|
|
350
|
-
// Terminal transitions are operationally interesting (info); the
|
|
351
|
-
// intermediate MANIFEST_ROWS_READY hop is routine bookkeeping (debug).
|
|
352
|
-
const terminal =
|
|
353
|
-
next === "MANIFEST_FILE_READY" ||
|
|
354
|
-
next === "FAILED" ||
|
|
355
|
-
next === "CANCELLED";
|
|
356
|
-
logger[terminal ? "info" : "debug"]("Materialization transition", {
|
|
357
|
-
materializationId: id,
|
|
358
|
-
packageName: current.packageName,
|
|
359
|
-
from: current.status,
|
|
360
|
-
to: next,
|
|
361
|
-
});
|
|
362
|
-
return this.repository.updateMaterialization(id, {
|
|
363
|
-
status: next,
|
|
364
|
-
...extra,
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// ==================== QUERIES ====================
|
|
369
|
-
|
|
370
|
-
async listMaterializations(
|
|
371
|
-
environmentName: string,
|
|
372
|
-
packageName: string,
|
|
373
|
-
options?: { limit?: number; offset?: number },
|
|
374
|
-
): Promise<Materialization[]> {
|
|
375
|
-
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
376
|
-
return this.repository.listMaterializations(
|
|
377
|
-
environmentId,
|
|
378
|
-
packageName,
|
|
379
|
-
options,
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Every materialization across all packages in an environment, newest first.
|
|
385
|
-
* Each record carries its `packageName`, so an env-scoped view can group or
|
|
386
|
-
* label by package without a per-package fan-out.
|
|
387
|
-
*/
|
|
388
|
-
async listEnvironmentMaterializations(
|
|
389
|
-
environmentName: string,
|
|
390
|
-
options?: { limit?: number; offset?: number },
|
|
391
|
-
): Promise<Materialization[]> {
|
|
392
|
-
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
393
|
-
return this.repository.listMaterializationsByEnvironment(
|
|
394
|
-
environmentId,
|
|
395
|
-
options,
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* `created_at` of the newest scheduler-fired materialization for a package,
|
|
401
|
-
* or null if none. The standalone scheduler uses this on its first arm to
|
|
402
|
-
* recover a fire missed during downtime (see MaterializationScheduler.arm).
|
|
403
|
-
*/
|
|
404
|
-
async getLatestScheduledFireAt(
|
|
405
|
-
environmentName: string,
|
|
406
|
-
packageName: string,
|
|
407
|
-
): Promise<Date | null> {
|
|
408
|
-
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
409
|
-
return this.repository.getLatestScheduledFireAt(
|
|
410
|
-
environmentId,
|
|
411
|
-
packageName,
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
async getMaterialization(
|
|
416
|
-
environmentName: string,
|
|
417
|
-
packageName: string,
|
|
418
|
-
id: string,
|
|
419
|
-
): Promise<Materialization> {
|
|
420
|
-
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
421
|
-
const m = await this.repository.getMaterializationById(id);
|
|
422
|
-
if (
|
|
423
|
-
!m ||
|
|
424
|
-
m.environmentId !== environmentId ||
|
|
425
|
-
m.packageName !== packageName
|
|
426
|
-
) {
|
|
427
|
-
throw new MaterializationNotFoundError(
|
|
428
|
-
`Materialization ${id} not found for package ${packageName}`,
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
return m;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// ==================== CREATE + BUILD ====================
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* Create a materialization and build it in the background. Returns the
|
|
438
|
-
* PENDING record immediately.
|
|
439
|
-
*
|
|
440
|
-
* Auto-run (default, no `buildInstructions`): self-assign physical names and
|
|
441
|
-
* build + auto-load every persist source. Orchestrated (`buildInstructions`
|
|
442
|
-
* present): build directly into the caller-assigned names from the package's
|
|
443
|
-
* build plan, without auto-loading the manifest. When `buildInstructions` is
|
|
444
|
-
* present it is validated synchronously against the package's compiled build
|
|
445
|
-
* plan so a bad instruction is rejected at create time rather than failing
|
|
446
|
-
* the background run.
|
|
447
|
-
*/
|
|
448
|
-
async createMaterialization(
|
|
449
|
-
environmentName: string,
|
|
450
|
-
packageName: string,
|
|
451
|
-
options: {
|
|
452
|
-
forceRefresh?: boolean;
|
|
453
|
-
sourceNames?: string[];
|
|
454
|
-
buildInstructions?: BuildInstruction[];
|
|
455
|
-
referenceManifest?: ManifestReference[];
|
|
456
|
-
strictUpstreams?: boolean;
|
|
457
|
-
/**
|
|
458
|
-
* What initiated this run. `ON_DEMAND` (default) = a manual/API create;
|
|
459
|
-
* `SCHEDULER` = the standalone materialization scheduler firing a
|
|
460
|
-
* package's `materialization.schedule` cron. Recorded on the run
|
|
461
|
-
* metadata so a scheduled rebuild is distinguishable from a manual one.
|
|
462
|
-
*/
|
|
463
|
-
trigger?: "ON_DEMAND" | "SCHEDULER";
|
|
464
|
-
/**
|
|
465
|
-
* What the caller knows about this run and the publisher does not,
|
|
466
|
-
* attached as query metadata to the statements the build issues. Its
|
|
467
|
-
* `trigger` also covers the case the publisher's own `trigger` cannot
|
|
468
|
-
* express (a publish), and its `runId` lets a caller's own id group the
|
|
469
|
-
* build's statements instead of the publisher's materialization id.
|
|
470
|
-
*/
|
|
471
|
-
runContext?: components["schemas"]["RunContext"] | null;
|
|
472
|
-
} = {},
|
|
473
|
-
): Promise<Materialization> {
|
|
474
|
-
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
475
|
-
|
|
476
|
-
const environment = await this.environmentStore.getEnvironment(
|
|
477
|
-
environmentName,
|
|
478
|
-
false,
|
|
479
|
-
);
|
|
480
|
-
const pkg = await environment.getPackage(packageName, false);
|
|
481
|
-
|
|
482
|
-
const buildInstructions = options.buildInstructions;
|
|
483
|
-
const orchestrated = buildInstructions !== undefined;
|
|
484
|
-
if (orchestrated) {
|
|
485
|
-
this.validateInstructions(pkg.getBuildPlan(), buildInstructions);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
const active = await this.repository.getActiveMaterialization(
|
|
489
|
-
environmentId,
|
|
490
|
-
packageName,
|
|
491
|
-
);
|
|
492
|
-
if (active) {
|
|
493
|
-
throw this.activeConflict(packageName, active.id);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const forceRefresh = options.forceRefresh ?? false;
|
|
497
|
-
const trigger = options.trigger ?? "ON_DEMAND";
|
|
498
|
-
const metadata = {
|
|
499
|
-
forceRefresh,
|
|
500
|
-
sourceNames: options.sourceNames ?? null,
|
|
501
|
-
mode: orchestrated ? "orchestrated" : "auto",
|
|
502
|
-
trigger,
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
let created: Materialization;
|
|
506
|
-
try {
|
|
507
|
-
created = await this.repository.createMaterialization(
|
|
508
|
-
environmentId,
|
|
509
|
-
packageName,
|
|
510
|
-
"PENDING",
|
|
511
|
-
metadata,
|
|
512
|
-
);
|
|
513
|
-
} catch (err) {
|
|
514
|
-
if (err instanceof DuplicateActiveMaterializationError) {
|
|
515
|
-
const winner = await this.repository.getActiveMaterialization(
|
|
516
|
-
environmentId,
|
|
517
|
-
packageName,
|
|
518
|
-
);
|
|
519
|
-
throw this.activeConflict(packageName, winner?.id);
|
|
520
|
-
}
|
|
521
|
-
throw err;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
this.runInBackground(created.id, (signal) =>
|
|
525
|
-
this.runBuild(
|
|
526
|
-
created.id,
|
|
527
|
-
environmentName,
|
|
528
|
-
packageName,
|
|
529
|
-
{
|
|
530
|
-
sourceNames: options.sourceNames,
|
|
531
|
-
forceRefresh,
|
|
532
|
-
buildInstructions,
|
|
533
|
-
referenceManifest: options.referenceManifest,
|
|
534
|
-
strictUpstreams: options.strictUpstreams,
|
|
535
|
-
trigger,
|
|
536
|
-
runContext: options.runContext ?? undefined,
|
|
537
|
-
},
|
|
538
|
-
signal,
|
|
539
|
-
),
|
|
540
|
-
);
|
|
541
|
-
|
|
542
|
-
return created;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Single-call build, shared by auto-run and orchestrated mode. Compiles the
|
|
547
|
-
* package build plan, derives the build instructions (self-assigned for
|
|
548
|
-
* auto-run; caller-supplied for orchestrated), builds the instructed sources
|
|
549
|
-
* into their physical tables, and commits the manifest. Auto-run additionally
|
|
550
|
-
* loads the fresh manifest into the package models; orchestrated leaves
|
|
551
|
-
* distribution to the caller.
|
|
552
|
-
*/
|
|
553
|
-
private async runBuild(
|
|
554
|
-
id: string,
|
|
555
|
-
environmentName: string,
|
|
556
|
-
packageName: string,
|
|
557
|
-
opts: {
|
|
558
|
-
sourceNames: string[] | undefined;
|
|
559
|
-
forceRefresh: boolean;
|
|
560
|
-
buildInstructions: BuildInstruction[] | undefined;
|
|
561
|
-
referenceManifest: ManifestReference[] | undefined;
|
|
562
|
-
strictUpstreams: boolean | undefined;
|
|
563
|
-
trigger: "ON_DEMAND" | "SCHEDULER";
|
|
564
|
-
runContext?: components["schemas"]["RunContext"];
|
|
565
|
-
},
|
|
566
|
-
signal: AbortSignal,
|
|
567
|
-
): Promise<void> {
|
|
568
|
-
const orchestrated = opts.buildInstructions !== undefined;
|
|
569
|
-
const mode: MaterializationMode = orchestrated ? "orchestrated" : "auto";
|
|
570
|
-
logger.info("Materialization build started", {
|
|
571
|
-
materializationId: id,
|
|
572
|
-
packageName,
|
|
573
|
-
mode,
|
|
574
|
-
});
|
|
575
|
-
const startedAt = Date.now();
|
|
576
|
-
|
|
577
|
-
try {
|
|
578
|
-
// Persist the run's start time so the UI can compute a duration
|
|
579
|
-
// (start -> now while in-flight, start -> completed once terminal).
|
|
580
|
-
await this.repository.updateMaterialization(id, {
|
|
581
|
-
startedAt: new Date(startedAt),
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
585
|
-
const environment = await this.environmentStore.getEnvironment(
|
|
586
|
-
environmentName,
|
|
587
|
-
false,
|
|
588
|
-
);
|
|
589
|
-
const pkg = await environment.getPackage(packageName, false);
|
|
590
|
-
|
|
591
|
-
const compiled = await environment.withPackageLock(packageName, () =>
|
|
592
|
-
compilePackageBuildPlan(pkg, signal),
|
|
593
|
-
);
|
|
594
|
-
|
|
595
|
-
// Backstop: refuse loudly if the package annotated a `#@ persist` source
|
|
596
|
-
// that Malloy's getBuildPlan() silently dropped (a shape it doesn't
|
|
597
|
-
// treat as a materializable root — see detectDroppedPersistSources).
|
|
598
|
-
// Without this the build would report success with an empty manifest and
|
|
599
|
-
// the source would serve live, contradicting the "hard refuse, never a
|
|
600
|
-
// silent fallback" contract. Scoped to the sources this build targets so
|
|
601
|
-
// a build of unrelated sources isn't blocked by a dropped sibling.
|
|
602
|
-
const relevantDropped = (compiled.droppedPersistSources ?? []).filter(
|
|
603
|
-
(d) => !opts.sourceNames || opts.sourceNames.includes(d.name),
|
|
604
|
-
);
|
|
605
|
-
if (relevantDropped.length > 0) {
|
|
606
|
-
const names = relevantDropped.map((d) => `'${d.name}'`).join(", ");
|
|
607
|
-
throw new MaterializationEligibilityError({
|
|
608
|
-
message:
|
|
609
|
-
`Source(s) ${names} are annotated '#@ persist' but were not ` +
|
|
610
|
-
`recognized as a materializable source, so nothing would be ` +
|
|
611
|
-
`built (they would be served live). Only query/aggregate ` +
|
|
612
|
-
`sources materialize; a filtered pass-through does not. Persist ` +
|
|
613
|
-
`a query source, or invoke a parameterized source with a bound ` +
|
|
614
|
-
`argument, or drop the annotation to serve live.`,
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
let instructions: BuildInstruction[];
|
|
619
|
-
let carried: Record<string, ManifestEntry>;
|
|
620
|
-
if (orchestrated) {
|
|
621
|
-
instructions = opts.buildInstructions!;
|
|
622
|
-
// Seed the build Manifest with the caller-supplied upstream
|
|
623
|
-
// references so a downstream source's persist upstream (built in a
|
|
624
|
-
// prior run, not rebuilt here) resolves to its existing physical
|
|
625
|
-
// table instead of recomputing live. The reference key is the
|
|
626
|
-
// compiler's manifest-lookup sourceEntityId (see ManifestReference).
|
|
627
|
-
carried = this.referenceManifestToEntries(opts.referenceManifest);
|
|
628
|
-
// Upstream resolution for an orchestrated build draws on three
|
|
629
|
-
// sources, in DESCENDING precedence — a reference is an IDENTITY, not
|
|
630
|
-
// a copy the caller must fully courier:
|
|
631
|
-
// 1. The explicit `referenceManifest` couriered in THIS build call.
|
|
632
|
-
// 2. The package's BOUND manifest (`manifestLocation`) — the set the
|
|
633
|
-
// orchestrator distributed to this worker. This is what makes the
|
|
634
|
-
// cross-worker flow work: a worker that never built the upstream
|
|
635
|
-
// still holds its full entry via the refreshed manifest, so a
|
|
636
|
-
// downstream can reuse the upstream's materialized table.
|
|
637
|
-
// 3. This worker's own most-recent local manifest (skip-if-unchanged
|
|
638
|
-
// cache) — same-worker reuse.
|
|
639
|
-
// Each fills the storage fields (sourceName, storageConnectionName,
|
|
640
|
-
// schema) a thin reference can't carry — required for a `storage=`
|
|
641
|
-
// upstream's stack-on-the-parent rebind. Higher-precedence
|
|
642
|
-
// fields win; each step is best-effort (a fetch/read failure leaves
|
|
643
|
-
// the entries as they are).
|
|
644
|
-
await this.seedFromBoundManifest(carried, pkg, instructions);
|
|
645
|
-
if (Object.keys(carried).length > 0) {
|
|
646
|
-
await this.resolveReferencesFromStore(
|
|
647
|
-
carried,
|
|
648
|
-
environmentId,
|
|
649
|
-
packageName,
|
|
650
|
-
id,
|
|
651
|
-
);
|
|
652
|
-
}
|
|
653
|
-
} else {
|
|
654
|
-
// Skip-if-unchanged: reuse tables from the most recent successful
|
|
655
|
-
// manifest for sources whose sourceEntityId is unchanged, unless
|
|
656
|
-
// forceRefresh.
|
|
657
|
-
const priorEntries = opts.forceRefresh
|
|
658
|
-
? {}
|
|
659
|
-
: await this.getMostRecentManifestEntries(
|
|
660
|
-
environmentId,
|
|
661
|
-
packageName,
|
|
662
|
-
id,
|
|
663
|
-
);
|
|
664
|
-
({ instructions, carried } = this.deriveSelfInstructions(
|
|
665
|
-
compiled,
|
|
666
|
-
opts.sourceNames,
|
|
667
|
-
priorEntries,
|
|
668
|
-
));
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
const entries = await this.executeInstructedBuild(
|
|
672
|
-
compiled,
|
|
673
|
-
environment,
|
|
674
|
-
instructions,
|
|
675
|
-
carried,
|
|
676
|
-
signal,
|
|
677
|
-
opts.strictUpstreams ?? false,
|
|
678
|
-
// Failure-path reclaim is ORCHESTRATED-ONLY on purpose — see
|
|
679
|
-
// reclaimStorageTablesFromFailedRun.
|
|
680
|
-
orchestrated ? { environmentId, packageName } : undefined,
|
|
681
|
-
{
|
|
682
|
-
// Optional for the same reason build_plan reads it optionally:
|
|
683
|
-
// callers that build from a lighter package surface still resolve,
|
|
684
|
-
// just without a package-level layer.
|
|
685
|
-
packageMaterialization: pkg.getMaterializationConfig?.() ?? null,
|
|
686
|
-
context: {
|
|
687
|
-
queryClass: "materialize",
|
|
688
|
-
environment: environmentName,
|
|
689
|
-
package: packageName,
|
|
690
|
-
// The caller's trigger wins because it can express a publish,
|
|
691
|
-
// which the publisher's own two-value trigger cannot.
|
|
692
|
-
trigger:
|
|
693
|
-
opts.runContext?.trigger ?? opts.trigger?.toLowerCase(),
|
|
694
|
-
// Default to the materialization id: the publisher always has a
|
|
695
|
-
// run id, so a build's statements group in the backend's query
|
|
696
|
-
// history whether or not the caller supplied one.
|
|
697
|
-
runId: opts.runContext?.runId ?? id,
|
|
698
|
-
},
|
|
699
|
-
},
|
|
700
|
-
);
|
|
701
|
-
|
|
702
|
-
const sourcesBuilt = instructions.length;
|
|
703
|
-
const sourcesReused = Object.keys(carried).length;
|
|
704
|
-
const durationMs = Date.now() - startedAt;
|
|
705
|
-
await this.commitManifest(id, entries, {
|
|
706
|
-
forceRefresh: opts.forceRefresh,
|
|
707
|
-
sourceNames: opts.sourceNames ?? null,
|
|
708
|
-
mode,
|
|
709
|
-
trigger: opts.trigger,
|
|
710
|
-
sourcesBuilt,
|
|
711
|
-
sourcesReused,
|
|
712
|
-
durationMs,
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
// Auto-run owns distribution: load the fresh manifest into the package
|
|
716
|
-
// models so subsequent queries resolve to the materialized tables.
|
|
717
|
-
// Orchestrated leaves distribution to the caller (manifestLocation).
|
|
718
|
-
if (!orchestrated) {
|
|
719
|
-
await this.autoLoadManifest(environment, packageName, entries);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
recordSourcesOutcome("built", sourcesBuilt);
|
|
723
|
-
recordSourcesOutcome("reused", sourcesReused);
|
|
724
|
-
this.recordRun(mode, "success", startedAt);
|
|
725
|
-
logger.info("Materialization build complete", {
|
|
726
|
-
materializationId: id,
|
|
727
|
-
packageName,
|
|
728
|
-
mode,
|
|
729
|
-
sourcesBuilt,
|
|
730
|
-
sourcesReused,
|
|
731
|
-
durationMs,
|
|
732
|
-
});
|
|
733
|
-
} catch (err) {
|
|
734
|
-
this.recordRun(mode, outcomeFor(err, signal), startedAt);
|
|
735
|
-
throw err;
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
/**
|
|
740
|
-
* Derive the publisher's own build instructions for auto-run. Each persist
|
|
741
|
-
* source (respecting the optional sourceNames filter) gets a self-assigned
|
|
742
|
-
* physical table name and COPY realization, unless its sourceEntityId is unchanged
|
|
743
|
-
* since `priorEntries` — those are carried forward (reused) instead of
|
|
744
|
-
* rebuilt.
|
|
745
|
-
*/
|
|
746
|
-
private deriveSelfInstructions(
|
|
747
|
-
compiled: CompiledBuildPlan,
|
|
748
|
-
sourceNames: string[] | undefined,
|
|
749
|
-
priorEntries: Record<string, ManifestEntry>,
|
|
750
|
-
): {
|
|
751
|
-
instructions: BuildInstruction[];
|
|
752
|
-
carried: Record<string, ManifestEntry>;
|
|
753
|
-
} {
|
|
754
|
-
const include = sourceNames ? new Set(sourceNames) : null;
|
|
755
|
-
const instructions: BuildInstruction[] = [];
|
|
756
|
-
const carried: Record<string, ManifestEntry> = {};
|
|
757
|
-
const seen = new Set<string>();
|
|
758
|
-
|
|
759
|
-
for (const graph of compiled.graphs) {
|
|
760
|
-
for (const persistSource of iterGraphSources(
|
|
761
|
-
graph,
|
|
762
|
-
compiled.sources,
|
|
763
|
-
)) {
|
|
764
|
-
if (include && !include.has(persistSource.name)) continue;
|
|
765
|
-
|
|
766
|
-
// Safety: a source that DECLARES `storage=` must never silently
|
|
767
|
-
// downgrade to a colocated build when the external tier is disabled
|
|
768
|
-
// (PERSIST_STORAGE_MODE=off). A colocated build writes a CTAS into the
|
|
769
|
-
// source's OWN warehouse — which the author did not intend (they asked
|
|
770
|
-
// for external storage; production grants this server read-only
|
|
771
|
-
// warehouse access) and which could fail or land in an unexpected
|
|
772
|
-
// schema. Skip it: the source is not materialized and serves LIVE, and
|
|
773
|
-
// the mode warning (Package.storageWarnings) surfaces the degraded
|
|
774
|
-
// state. A plain `#@ persist` (no `storage=`) is unaffected —
|
|
775
|
-
// colocated IS its author's intent (the v0 path, ungated by the
|
|
776
|
-
// storage kill switch).
|
|
777
|
-
if (
|
|
778
|
-
getPersistStorageMode() === "off" &&
|
|
779
|
-
declaredStorage(persistSource)
|
|
780
|
-
) {
|
|
781
|
-
continue;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
const destination = resolveStorageDestination(persistSource);
|
|
785
|
-
if (destination) {
|
|
786
|
-
// Gate BEFORE computeSourceEntityId: an unbound parameter or a
|
|
787
|
-
// given makes getSQL() (called inside computeSourceEntityId)
|
|
788
|
-
// throw opaquely, so the eligibility refusal must fire first to
|
|
789
|
-
// give a clean, actionable 422.
|
|
790
|
-
assertMaterializationEligible(persistSource);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
const sourceEntityId = computeSourceEntityId(
|
|
794
|
-
persistSource,
|
|
795
|
-
compiled.connectionDigests,
|
|
796
|
-
);
|
|
797
|
-
if (seen.has(sourceEntityId)) continue;
|
|
798
|
-
seen.add(sourceEntityId);
|
|
799
|
-
|
|
800
|
-
const prior = priorEntries[sourceEntityId];
|
|
801
|
-
// Destination-scoped reuse: carry a prior table forward only when it
|
|
802
|
-
// landed in the SAME destination. sourceEntityId is a pure content
|
|
803
|
-
// address and does NOT encode the destination, so a source that adds,
|
|
804
|
-
// drops, or switches `storage=` must rebuild — otherwise a
|
|
805
|
-
// warehouse-landed (colocated) table would be silently reused for a
|
|
806
|
-
// DuckLake serve that cannot resolve it.
|
|
807
|
-
if (
|
|
808
|
-
prior &&
|
|
809
|
-
prior.physicalTableName &&
|
|
810
|
-
(prior.storageConnectionName ?? undefined) === destination
|
|
811
|
-
) {
|
|
812
|
-
carried[sourceEntityId] = prior;
|
|
813
|
-
continue;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// Self-assign the physical name from `name=` (or the source name)
|
|
817
|
-
// verbatim for BOTH the colocated and storage
|
|
818
|
-
// destinations — the only difference between the two is which
|
|
819
|
-
// connection the table lands in. A storage build replaces the table
|
|
820
|
-
// atomically (`CREATE OR REPLACE`), so no generational decoration is
|
|
821
|
-
// needed to make a rebuild safe. An orchestrated build ignores this
|
|
822
|
-
// and trusts the host-supplied `physicalTableName`; the host owns any
|
|
823
|
-
// generational, ownership-scoped naming.
|
|
824
|
-
const logicalName = selfAssignTableName(persistSource);
|
|
825
|
-
instructions.push({
|
|
826
|
-
sourceEntityId,
|
|
827
|
-
materializedTableId: `local-${sourceEntityId.substring(
|
|
828
|
-
0,
|
|
829
|
-
STAGING_ID_LEN,
|
|
830
|
-
)}`,
|
|
831
|
-
physicalTableName: logicalName,
|
|
832
|
-
realization: "COPY",
|
|
833
|
-
...(destination ? { destination } : {}),
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
return { instructions, carried };
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* Project the caller-supplied upstream reference manifest into the seed
|
|
843
|
-
* entry map `executeInstructedBuild` consumes. Each reference is keyed by the
|
|
844
|
-
* compiler's manifest-lookup sourceEntityId and carries the physical table
|
|
845
|
-
* name plus the connection it lives on — enough for the build Manifest to
|
|
846
|
-
* resolve a downstream persist reference to the existing table, and to quote
|
|
847
|
-
* that reference for the connection's dialect so it resolves on a
|
|
848
|
-
* case-folding engine (see {@link quoteSeedTablePath}). `connectionName` is
|
|
849
|
-
* optional on the wire: an older control plane that omits it seeds unquoted,
|
|
850
|
-
* exactly as before.
|
|
851
|
-
*/
|
|
852
|
-
private referenceManifestToEntries(
|
|
853
|
-
referenceManifest: ManifestReference[] | undefined,
|
|
854
|
-
): Record<string, ManifestEntry> {
|
|
855
|
-
const entries: Record<string, ManifestEntry> = {};
|
|
856
|
-
for (const ref of referenceManifest ?? []) {
|
|
857
|
-
entries[ref.sourceEntityId] = {
|
|
858
|
-
sourceEntityId: ref.sourceEntityId,
|
|
859
|
-
physicalTableName: ref.physicalTableName,
|
|
860
|
-
connectionName: ref.connectionName,
|
|
861
|
-
};
|
|
862
|
-
}
|
|
863
|
-
return entries;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
/**
|
|
867
|
-
* Resolve upstream references against this publisher's OWN most-recent
|
|
868
|
-
* manifest, keyed by sourceEntityId (the same cache skip-if-unchanged reads).
|
|
869
|
-
* A reference is fundamentally an IDENTITY, not a copy: for each one that
|
|
870
|
-
* matches a locally persisted entry, fill in the fields a thin reference
|
|
871
|
-
* can't carry (sourceName, storageConnectionName, schema — needed for a
|
|
872
|
-
* `storage=` upstream's stack-on-the-parent rebind) from the local entry, while
|
|
873
|
-
* letting any caller-supplied field WIN (the orchestrator is authoritative across a
|
|
874
|
-
* stateless fleet). Mutates `carried` in place. Best-effort: a lookup failure
|
|
875
|
-
* leaves the references exactly as supplied, so the cross-worker courier path
|
|
876
|
-
* (a worker that never built the upstream, fed the full entry) is unaffected.
|
|
877
|
-
*/
|
|
878
|
-
private async resolveReferencesFromStore(
|
|
879
|
-
carried: Record<string, ManifestEntry>,
|
|
880
|
-
environmentId: string,
|
|
881
|
-
packageName: string,
|
|
882
|
-
excludeId: string,
|
|
883
|
-
): Promise<void> {
|
|
884
|
-
let cached: Record<string, ManifestEntry>;
|
|
885
|
-
try {
|
|
886
|
-
cached = await this.getMostRecentManifestEntries(
|
|
887
|
-
environmentId,
|
|
888
|
-
packageName,
|
|
889
|
-
excludeId,
|
|
890
|
-
);
|
|
891
|
-
} catch (err) {
|
|
892
|
-
logger.warn(
|
|
893
|
-
"Reference resolve-local lookup failed; using references as supplied",
|
|
894
|
-
{
|
|
895
|
-
packageName,
|
|
896
|
-
error: err instanceof Error ? err.message : String(err),
|
|
897
|
-
},
|
|
898
|
-
);
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
for (const [sourceEntityId, ref] of Object.entries(carried)) {
|
|
902
|
-
const local = cached[sourceEntityId];
|
|
903
|
-
if (!local) continue;
|
|
904
|
-
// Local entry is the base; caller-supplied (defined) fields override it.
|
|
905
|
-
const merged: ManifestEntry = { ...local };
|
|
906
|
-
for (const [key, value] of Object.entries(ref)) {
|
|
907
|
-
if (value !== undefined) {
|
|
908
|
-
(merged as Record<string, unknown>)[key] = value;
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
carried[sourceEntityId] = merged;
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
/**
|
|
916
|
-
* Seed upstream reuse from the package's BOUND manifest — the set the
|
|
917
|
-
* orchestrator distributed to this worker via `manifestLocation`. This is the
|
|
918
|
-
* cross-worker path: a worker that never built an upstream still holds its full
|
|
919
|
-
* entry here (`storageConnectionName` + `schema` + `sourceName`), so a
|
|
920
|
-
* downstream can reuse the upstream's materialized table instead of recomputing
|
|
921
|
-
* it from raw. Adds any bound upstream not already carried (so a build needs no
|
|
922
|
-
* explicit reference when the manifest is refreshed), and fills gaps in a
|
|
923
|
-
* thin explicit reference — but an explicit `referenceManifest` field always
|
|
924
|
-
* wins (it targets THIS build). Sources this build is producing are skipped
|
|
925
|
-
* (built fresh, not reused). Best-effort: a fetch failure is ignored (the build
|
|
926
|
-
* falls back to the local store / inline recompute). Mutates `carried`.
|
|
927
|
-
*/
|
|
928
|
-
private async seedFromBoundManifest(
|
|
929
|
-
carried: Record<string, ManifestEntry>,
|
|
930
|
-
pkg: { getPackageMetadata(): { manifestLocation?: string | null } },
|
|
931
|
-
instructions: BuildInstruction[],
|
|
932
|
-
): Promise<void> {
|
|
933
|
-
const manifestLocation = pkg.getPackageMetadata().manifestLocation;
|
|
934
|
-
if (!manifestLocation) return;
|
|
935
|
-
let fetched;
|
|
936
|
-
try {
|
|
937
|
-
fetched = await fetchManifestEntries(manifestLocation);
|
|
938
|
-
} catch (err) {
|
|
939
|
-
logger.warn(
|
|
940
|
-
"Build upstream resolution: bound manifest fetch failed; ignoring",
|
|
941
|
-
{
|
|
942
|
-
manifestLocation,
|
|
943
|
-
error: err instanceof Error ? err.message : String(err),
|
|
944
|
-
},
|
|
945
|
-
);
|
|
946
|
-
return;
|
|
947
|
-
}
|
|
948
|
-
// Reconstruct a ManifestEntry map from both tiers of the fetched manifest:
|
|
949
|
-
// storage entries carry their full shape; colocated entries reconstruct from
|
|
950
|
-
// the tableName manifest.
|
|
951
|
-
const bound: Record<string, ManifestEntry> = {
|
|
952
|
-
...fetched.storageEntries,
|
|
953
|
-
};
|
|
954
|
-
for (const [eid, e] of Object.entries(fetched.tableNameManifest)) {
|
|
955
|
-
if (!bound[eid]) {
|
|
956
|
-
bound[eid] = {
|
|
957
|
-
sourceEntityId: eid,
|
|
958
|
-
physicalTableName: e.tableName,
|
|
959
|
-
connectionName: e.connectionName,
|
|
960
|
-
};
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
const building = new Set(instructions.map((i) => i.sourceEntityId));
|
|
964
|
-
for (const [eid, entry] of Object.entries(bound)) {
|
|
965
|
-
if (building.has(eid)) continue;
|
|
966
|
-
const existing = carried[eid];
|
|
967
|
-
if (!existing) {
|
|
968
|
-
carried[eid] = entry;
|
|
969
|
-
continue;
|
|
970
|
-
}
|
|
971
|
-
// Explicit reference wins; the bound entry fills the gaps it left.
|
|
972
|
-
const merged: ManifestEntry = { ...entry };
|
|
973
|
-
for (const [key, value] of Object.entries(existing)) {
|
|
974
|
-
if (value !== undefined) {
|
|
975
|
-
(merged as Record<string, unknown>)[key] = value;
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
carried[eid] = merged;
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
/**
|
|
983
|
-
* Entries of the most recent successful (MANIFEST_FILE_READY) materialization
|
|
984
|
-
* for this package, used for skip-if-unchanged. Excludes the in-flight run.
|
|
985
|
-
*/
|
|
986
|
-
private async getMostRecentManifestEntries(
|
|
987
|
-
environmentId: string,
|
|
988
|
-
packageName: string,
|
|
989
|
-
excludeId: string,
|
|
990
|
-
): Promise<Record<string, ManifestEntry>> {
|
|
991
|
-
const list =
|
|
992
|
-
(await this.repository.listMaterializations(
|
|
993
|
-
environmentId,
|
|
994
|
-
packageName,
|
|
995
|
-
)) ?? [];
|
|
996
|
-
for (const m of list) {
|
|
997
|
-
if (m.id === excludeId) continue;
|
|
998
|
-
if (m.status === "MANIFEST_FILE_READY" && m.manifest?.entries) {
|
|
999
|
-
return m.manifest.entries;
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
return {};
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
/**
|
|
1006
|
-
* Load a freshly produced manifest into the package's models so persist
|
|
1007
|
-
* references resolve to the materialized tables. Best-effort: a load failure
|
|
1008
|
-
* is logged, not fatal (the run already reached MANIFEST_FILE_READY).
|
|
1009
|
-
*/
|
|
1010
|
-
private async autoLoadManifest(
|
|
1011
|
-
environment: {
|
|
1012
|
-
reloadAllModelsForPackage(
|
|
1013
|
-
packageName: string,
|
|
1014
|
-
manifest: FreshnessManifest,
|
|
1015
|
-
): Promise<void>;
|
|
1016
|
-
bindPackageStorageServeBindings(
|
|
1017
|
-
packageName: string,
|
|
1018
|
-
entries: Record<string, ManifestEntry>,
|
|
1019
|
-
): Promise<void>;
|
|
1020
|
-
},
|
|
1021
|
-
packageName: string,
|
|
1022
|
-
entries: Record<string, ManifestEntry>,
|
|
1023
|
-
): Promise<void> {
|
|
1024
|
-
// The post-build auto-load binds tableName-only entries: the control plane
|
|
1025
|
-
// stamps freshness (dataAsOf/window/fallback) on the wire manifest it
|
|
1026
|
-
// distributes, not on this in-memory post-build load, so these sources are
|
|
1027
|
-
// bound un-gated (always serve the freshly-built table).
|
|
1028
|
-
const manifestEntries: FreshnessManifest = {};
|
|
1029
|
-
for (const [sourceEntityId, entry] of Object.entries(entries)) {
|
|
1030
|
-
// Storage entries serve cross-connection via the virtual-source
|
|
1031
|
-
// bindings (below), NOT the same-connection manifest substitution —
|
|
1032
|
-
// putting one here would make the original model try to substitute the
|
|
1033
|
-
// source with a table on its OWN (source) connection, which doesn't
|
|
1034
|
-
// exist there. Only colocated entries go into the tableName manifest.
|
|
1035
|
-
if (entry.physicalTableName && !entry.storageConnectionName) {
|
|
1036
|
-
manifestEntries[sourceEntityId] = {
|
|
1037
|
-
tableName: entry.physicalTableName,
|
|
1038
|
-
// Carried so the bind step can quote the physical path for the
|
|
1039
|
-
// connection's dialect (Package.quoteBoundTableNames) — the
|
|
1040
|
-
// build CREATEd it quoted, so an unquoted read would miss on a
|
|
1041
|
-
// case-folding engine in the window before the control plane's
|
|
1042
|
-
// wire-manifest rebind.
|
|
1043
|
-
connectionName: entry.connectionName,
|
|
1044
|
-
};
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
try {
|
|
1048
|
-
await environment.reloadAllModelsForPackage(
|
|
1049
|
-
packageName,
|
|
1050
|
-
manifestEntries,
|
|
1051
|
-
);
|
|
1052
|
-
// Separately bind the FULL entries as storage serve bindings — sources
|
|
1053
|
-
// materialized into a storage destination serve cross-connection via
|
|
1054
|
-
// the virtual-source transform, not the tableName manifest above. No-op
|
|
1055
|
-
// for a package with no storage= sources (deriveServeBindings → []).
|
|
1056
|
-
await environment.bindPackageStorageServeBindings(
|
|
1057
|
-
packageName,
|
|
1058
|
-
entries,
|
|
1059
|
-
);
|
|
1060
|
-
recordAutoLoadOutcome("success");
|
|
1061
|
-
logger.info("Auto-run: loaded manifest into package models", {
|
|
1062
|
-
packageName,
|
|
1063
|
-
entryCount: Object.keys(manifestEntries).length,
|
|
1064
|
-
});
|
|
1065
|
-
} catch (err) {
|
|
1066
|
-
recordAutoLoadOutcome("failure");
|
|
1067
|
-
logger.warn("Auto-run: failed to load manifest into package models", {
|
|
1068
|
-
packageName,
|
|
1069
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1070
|
-
});
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/**
|
|
1075
|
-
* Validate caller-supplied build instructions against the package's compiled
|
|
1076
|
-
* build plan: every instructed sourceEntityId must be a planned source, and only
|
|
1077
|
-
* COPY realization is supported. Throws when the package declares no persist
|
|
1078
|
-
* source (no plan to build against).
|
|
1079
|
-
*/
|
|
1080
|
-
private validateInstructions(
|
|
1081
|
-
plan: BuildPlan | null,
|
|
1082
|
-
instructions: BuildInstruction[],
|
|
1083
|
-
): void {
|
|
1084
|
-
if (!plan) {
|
|
1085
|
-
throw new BadRequestError(
|
|
1086
|
-
"Package has no persist sources; buildInstructions cannot be applied",
|
|
1087
|
-
);
|
|
1088
|
-
}
|
|
1089
|
-
const plannedSourceEntityIds = new Set<string>();
|
|
1090
|
-
for (const source of Object.values(plan.sources)) {
|
|
1091
|
-
plannedSourceEntityIds.add(source.sourceEntityId);
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
for (const instruction of instructions) {
|
|
1095
|
-
if (!plannedSourceEntityIds.has(instruction.sourceEntityId)) {
|
|
1096
|
-
throw new BadRequestError(
|
|
1097
|
-
`Instruction references unknown sourceEntityId '${instruction.sourceEntityId}'`,
|
|
1098
|
-
);
|
|
1099
|
-
}
|
|
1100
|
-
// COPY-only for now; SNAPSHOT lands once clone semantics are defined.
|
|
1101
|
-
if (instruction.realization === "SNAPSHOT") {
|
|
1102
|
-
throw new BadRequestError(
|
|
1103
|
-
"realization=SNAPSHOT is not supported (COPY only)",
|
|
1104
|
-
);
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
/**
|
|
1110
|
-
* Quote a seeded upstream's physical path for the in-memory build Manifest,
|
|
1111
|
-
* mirroring the CREATE side ({@link quoteManifestTablePath}) so a downstream
|
|
1112
|
-
* persist source resolves the reference on a case-folding engine. The seed's
|
|
1113
|
-
* own connection (carried on the entry) supplies the dialect. If it can't be
|
|
1114
|
-
* resolved — an older control plane omitted `connectionName`, or the named
|
|
1115
|
-
* connection isn't part of this build — the path binds unquoted: the build
|
|
1116
|
-
* then fails loudly at the downstream CREATE on a case-folding engine (a
|
|
1117
|
-
* self-signaling miss, unlike the serve path), and is unaffected elsewhere.
|
|
1118
|
-
*/
|
|
1119
|
-
private quoteSeedTablePath(
|
|
1120
|
-
sourceEntityId: string,
|
|
1121
|
-
physicalTableName: string,
|
|
1122
|
-
connectionName: string | undefined,
|
|
1123
|
-
connections: Map<string, MalloyConnection>,
|
|
1124
|
-
): string {
|
|
1125
|
-
const connection = connectionName
|
|
1126
|
-
? connections.get(connectionName)
|
|
1127
|
-
: undefined;
|
|
1128
|
-
if (!connection) {
|
|
1129
|
-
// A named-but-absent connection is a real gap (the seed can't be
|
|
1130
|
-
// quoted); a missing name is the benign older-CP default. Only the
|
|
1131
|
-
// former is worth a signal.
|
|
1132
|
-
if (connectionName) {
|
|
1133
|
-
recordManifestBindDegraded();
|
|
1134
|
-
logger.warn(
|
|
1135
|
-
"Seeded upstream names a connection not present in this build; " +
|
|
1136
|
-
"leaving its manifest path unquoted (a downstream build will " +
|
|
1137
|
-
"fail on a case-folding engine)",
|
|
1138
|
-
{ sourceEntityId, connectionName },
|
|
1139
|
-
);
|
|
1140
|
-
}
|
|
1141
|
-
return physicalTableName;
|
|
1142
|
-
}
|
|
1143
|
-
return quoteManifestTablePath(physicalTableName, connection.dialectName);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
/**
|
|
1147
|
-
* Shared build loop for both auto-run and orchestrated builds. Seeds the
|
|
1148
|
-
* manifest with carried-forward (reused) upstream entries so downstream
|
|
1149
|
-
* references resolve, then builds each instructed source in dependency
|
|
1150
|
-
* order. Returns the full entry map (carried + freshly built). The package
|
|
1151
|
-
* was compiled by the caller; this runs outside the package lock.
|
|
1152
|
-
*/
|
|
1153
|
-
private async executeInstructedBuild(
|
|
1154
|
-
compiled: CompiledBuildPlan,
|
|
1155
|
-
environment: BuildEnvironment,
|
|
1156
|
-
instructions: BuildInstruction[],
|
|
1157
|
-
seedEntries: Record<string, ManifestEntry>,
|
|
1158
|
-
signal: AbortSignal,
|
|
1159
|
-
strict = false,
|
|
1160
|
-
// Identity of the run, used only to reclaim storage tables this run created
|
|
1161
|
-
// if it fails part-way (see reclaimStorageTablesFromFailedRun).
|
|
1162
|
-
owner?: { environmentId: string; packageName: string },
|
|
1163
|
-
buildMetadata?: BuildQueryMetadata,
|
|
1164
|
-
): Promise<Record<string, ManifestEntry>> {
|
|
1165
|
-
const { graphs, sources, connectionDigests, connections } = compiled;
|
|
1166
|
-
|
|
1167
|
-
// Index instructions by sourceID (the stable per-source handle) so the
|
|
1168
|
-
// build no longer recomputes the sourceEntityId to find an instruction.
|
|
1169
|
-
// Recomputing it here forced a caller's sourceEntityId to equal the publisher's
|
|
1170
|
-
// content hash, so a caller that derives sourceEntityIds by any other scheme
|
|
1171
|
-
// would have its sources silently skipped (the recomputed sourceEntityId would
|
|
1172
|
-
// not match the instruction). sourceEntityId is treated as opaque, caller-assigned
|
|
1173
|
-
// identity. A sourceEntityId index is kept as a fallback for instructions without
|
|
1174
|
-
// a sourceID (e.g. standalone auto-run).
|
|
1175
|
-
const bySourceID = new Map<string, BuildInstruction>();
|
|
1176
|
-
const bySourceEntityId = new Map<string, BuildInstruction>();
|
|
1177
|
-
for (const instruction of instructions) {
|
|
1178
|
-
if (instruction.sourceID) {
|
|
1179
|
-
bySourceID.set(instruction.sourceID, instruction);
|
|
1180
|
-
}
|
|
1181
|
-
bySourceEntityId.set(instruction.sourceEntityId, instruction);
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
// Accumulates physical names as sources are built so downstream sources
|
|
1185
|
-
// resolve their upstream references to the freshly-assigned tables. Seed
|
|
1186
|
-
// it with carried-forward entries so reused upstreams resolve too. In
|
|
1187
|
-
// strict mode, an upstream persist reference that is neither built here
|
|
1188
|
-
// nor seeded fails the compile (runtime-manifest-strict-miss) instead of
|
|
1189
|
-
// silently recomputing live.
|
|
1190
|
-
const manifest = new Manifest();
|
|
1191
|
-
manifest.strict = strict;
|
|
1192
|
-
const entries: Record<string, ManifestEntry> = {};
|
|
1193
|
-
for (const [sourceEntityId, entry] of Object.entries(seedEntries)) {
|
|
1194
|
-
if (entry.physicalTableName) {
|
|
1195
|
-
// The build Manifest feeds a downstream persist's `FROM` verbatim,
|
|
1196
|
-
// so a seeded upstream must carry the SAME quoting the builder
|
|
1197
|
-
// CREATEd it with — else the downstream CREATE misses the
|
|
1198
|
-
// case-preserved table on a case-folding engine. The seed keeps its
|
|
1199
|
-
// logical (unquoted) name in `entries` (the committed manifest, in
|
|
1200
|
-
// logical-name space); only the in-memory build Manifest is quoted.
|
|
1201
|
-
manifest.update(sourceEntityId, {
|
|
1202
|
-
tableName: this.quoteSeedTablePath(
|
|
1203
|
-
sourceEntityId,
|
|
1204
|
-
entry.physicalTableName,
|
|
1205
|
-
entry.connectionName,
|
|
1206
|
-
connections,
|
|
1207
|
-
),
|
|
1208
|
-
});
|
|
1209
|
-
}
|
|
1210
|
-
entries[sourceEntityId] = entry;
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
// Entries this run actually CREATED, as opposed to the seeded/carried ones
|
|
1214
|
-
// above. Only these are eligible for failure-path reclaim: a carried entry
|
|
1215
|
-
// names a table an earlier successful run built and a live manifest may still
|
|
1216
|
-
// serve, so dropping one would be data loss rather than cleanup.
|
|
1217
|
-
const builtThisRun: ManifestEntry[] = [];
|
|
1218
|
-
try {
|
|
1219
|
-
for (const graph of graphs) {
|
|
1220
|
-
const connection = connections.get(graph.connectionName);
|
|
1221
|
-
if (!connection) {
|
|
1222
|
-
throw new BadRequestError(
|
|
1223
|
-
`Connection '${graph.connectionName}' not found`,
|
|
1224
|
-
);
|
|
1225
|
-
}
|
|
1226
|
-
for (const persistSource of iterGraphSources(graph, sources)) {
|
|
1227
|
-
if (signal.aborted) throw new Error("Build cancelled");
|
|
1228
|
-
|
|
1229
|
-
// Prefer sourceID matching (so the caller's sourceEntityId scheme
|
|
1230
|
-
// stays opaque to the build); the sourceEntityId lookup below is the
|
|
1231
|
-
// fallback for instructions without a sourceID (auto-run). Resolved
|
|
1232
|
-
// before computeSourceEntityId so the eligibility gate wins: that
|
|
1233
|
-
// call invokes getSQL(), which throws opaquely for a free-parameter
|
|
1234
|
-
// or given source, losing the clean 422.
|
|
1235
|
-
const orchestratedInstruction = bySourceID.get(
|
|
1236
|
-
persistSource.sourceID,
|
|
1237
|
-
);
|
|
1238
|
-
|
|
1239
|
-
// Enforce the eligibility gate for any storage-targeted build,
|
|
1240
|
-
// including orchestrated (host-supplied) instructions — the publisher
|
|
1241
|
-
// refuses an ineligible source into the tier itself, not on trust.
|
|
1242
|
-
// Skipped when the mode is off: the kill switch ignores a
|
|
1243
|
-
// host-supplied destination too and does a colocated build.
|
|
1244
|
-
if (
|
|
1245
|
-
orchestratedInstruction?.destination &&
|
|
1246
|
-
getPersistStorageMode() !== "off"
|
|
1247
|
-
) {
|
|
1248
|
-
assertMaterializationEligible(persistSource);
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// The manifest is keyed by the content sourceEntityId — what Malloy
|
|
1252
|
-
// recomputes to resolve upstream persist references during SQL
|
|
1253
|
-
// generation — independent of the instruction's identity sourceEntityId.
|
|
1254
|
-
const sourceEntityId = computeSourceEntityId(
|
|
1255
|
-
persistSource,
|
|
1256
|
-
connectionDigests,
|
|
1257
|
-
);
|
|
1258
|
-
const instruction =
|
|
1259
|
-
orchestratedInstruction ??
|
|
1260
|
-
bySourceEntityId.get(sourceEntityId);
|
|
1261
|
-
if (!instruction) continue;
|
|
1262
|
-
|
|
1263
|
-
// Auto-run already gated pre-getSQL in deriveSelfInstructions;
|
|
1264
|
-
// re-assert (idempotent) so no path into a storage build is ungated.
|
|
1265
|
-
if (
|
|
1266
|
-
!orchestratedInstruction &&
|
|
1267
|
-
instruction.destination &&
|
|
1268
|
-
getPersistStorageMode() !== "off"
|
|
1269
|
-
) {
|
|
1270
|
-
assertMaterializationEligible(persistSource);
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
const entry = await this.buildOneSource(
|
|
1274
|
-
persistSource,
|
|
1275
|
-
instruction,
|
|
1276
|
-
connection,
|
|
1277
|
-
connectionDigests,
|
|
1278
|
-
manifest,
|
|
1279
|
-
environment,
|
|
1280
|
-
entries,
|
|
1281
|
-
buildMetadata,
|
|
1282
|
-
);
|
|
1283
|
-
entries[sourceEntityId] = entry;
|
|
1284
|
-
if (entry.storageConnectionName) builtThisRun.push(entry);
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
} catch (err) {
|
|
1288
|
-
// A run that fails part-way commits NO manifest, and manifest-driven GC
|
|
1289
|
-
// only drops names a manifest records — so a table an earlier source in
|
|
1290
|
-
// this run already wrote would be unreachable forever. Reclaim those
|
|
1291
|
-
// before rethrowing. Best-effort and non-fatal: the build's own failure is
|
|
1292
|
-
// what the caller needs to see.
|
|
1293
|
-
if (owner) {
|
|
1294
|
-
await this.reclaimStorageTablesFromFailedRun(
|
|
1295
|
-
builtThisRun,
|
|
1296
|
-
environment,
|
|
1297
|
-
owner,
|
|
1298
|
-
);
|
|
1299
|
-
}
|
|
1300
|
-
throw err;
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
return entries;
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
/**
|
|
1307
|
-
* Drop the storage tables a FAILED run created, so a partial build does not
|
|
1308
|
-
* leak an unreferenced table. For DuckLake that is data plus Parquet files at
|
|
1309
|
-
* rest, and nothing else will ever name them: the run commits no manifest, and
|
|
1310
|
-
* GC reclaims only what a manifest records.
|
|
1311
|
-
*
|
|
1312
|
-
* Three guards make this safe, and each closes a real way to destroy live data:
|
|
1313
|
-
*
|
|
1314
|
-
* - ORCHESTRATED runs only (the caller passes no `owner` for auto-run). Those
|
|
1315
|
-
* names are host-assigned and generational, so unique by construction — which
|
|
1316
|
-
* is both where the leak actually bites and the only case where a drop cannot
|
|
1317
|
-
* hit something another run owns. Auto-run's STABLE names are overwritten in
|
|
1318
|
-
* place by the next build, so skipping them forgoes little.
|
|
1319
|
-
*
|
|
1320
|
-
* This gate is what bounds the cross-environment hazard. The
|
|
1321
|
-
* still-referenced check below reads THIS environment and package only, and a
|
|
1322
|
-
* BuildID carries no environment input, so two environments sharing a
|
|
1323
|
-
* destination can resolve a source to the SAME physical name. A reclaim that
|
|
1324
|
-
* trusted a per-environment check could then drop a table another environment
|
|
1325
|
-
* is actively serving — the failure mode behind a real cross-environment
|
|
1326
|
-
* data-loss incident on the hosted side. Generational names remove the
|
|
1327
|
-
* collision rather than racing it. The durable fix is refusing a colliding
|
|
1328
|
-
* persist target at validation time; until then, do not widen this.
|
|
1329
|
-
*
|
|
1330
|
-
* - Only entries this run CREATED (never a carried-forward one).
|
|
1331
|
-
* - Only names no other MANIFEST_FILE_READY run references, the same
|
|
1332
|
-
* destination-and-name check {@link dropMaterializedTables} applies.
|
|
1333
|
-
*
|
|
1334
|
-
* Scoped to `storage=` entries. A colocated failure is left alone: those names
|
|
1335
|
-
* are stable and in the customer's own warehouse, so the next successful build
|
|
1336
|
-
* overwrites in place and a failure-path DROP there would be a far larger
|
|
1337
|
-
* blast radius for no reclaim.
|
|
1338
|
-
*/
|
|
1339
|
-
private async reclaimStorageTablesFromFailedRun(
|
|
1340
|
-
builtThisRun: ManifestEntry[],
|
|
1341
|
-
environment: BuildEnvironment,
|
|
1342
|
-
owner: { environmentId: string; packageName: string },
|
|
1343
|
-
): Promise<void> {
|
|
1344
|
-
if (builtThisRun.length === 0) return;
|
|
1345
|
-
try {
|
|
1346
|
-
const tableKey = (dest: string, table: string) => `${dest}:${table}`;
|
|
1347
|
-
const stillReferenced = new Set<string>();
|
|
1348
|
-
const others =
|
|
1349
|
-
(await this.repository.listMaterializations(
|
|
1350
|
-
owner.environmentId,
|
|
1351
|
-
owner.packageName,
|
|
1352
|
-
)) ?? [];
|
|
1353
|
-
for (const other of others) {
|
|
1354
|
-
if (other.status !== "MANIFEST_FILE_READY") continue;
|
|
1355
|
-
for (const e of Object.values(other.manifest?.entries ?? {})) {
|
|
1356
|
-
const dest = e.storageConnectionName ?? e.connectionName;
|
|
1357
|
-
if (dest && e.physicalTableName) {
|
|
1358
|
-
stillReferenced.add(tableKey(dest, e.physicalTableName));
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
for (const entry of builtThisRun) {
|
|
1364
|
-
const dest = entry.storageConnectionName;
|
|
1365
|
-
const table = entry.physicalTableName;
|
|
1366
|
-
if (!dest || !table) continue;
|
|
1367
|
-
if (stillReferenced.has(tableKey(dest, table))) {
|
|
1368
|
-
logger.info(
|
|
1369
|
-
"Keeping a table from a failed run: a live manifest still serves it",
|
|
1370
|
-
{ destinationName: dest, physicalTableName: table },
|
|
1371
|
-
);
|
|
1372
|
-
continue;
|
|
1373
|
-
}
|
|
1374
|
-
try {
|
|
1375
|
-
await dropStorageTable({
|
|
1376
|
-
destinationName: dest,
|
|
1377
|
-
destinationConnection: environment.getApiConnection(dest),
|
|
1378
|
-
physicalTableName: table,
|
|
1379
|
-
environmentPath: environment.getEnvironmentPath(),
|
|
1380
|
-
});
|
|
1381
|
-
recordDropTables("success", "storage");
|
|
1382
|
-
logger.info("Reclaimed a table stranded by a failed build", {
|
|
1383
|
-
destinationName: dest,
|
|
1384
|
-
physicalTableName: table,
|
|
1385
|
-
});
|
|
1386
|
-
} catch (dropErr) {
|
|
1387
|
-
recordDropTables("failure", "storage");
|
|
1388
|
-
logger.warn(
|
|
1389
|
-
"Failed to reclaim a table stranded by a failed build",
|
|
1390
|
-
{
|
|
1391
|
-
destinationName: dest,
|
|
1392
|
-
physicalTableName: table,
|
|
1393
|
-
error: redactConnectionSecrets(
|
|
1394
|
-
errMessage(dropErr),
|
|
1395
|
-
environment.getApiConnection(dest),
|
|
1396
|
-
),
|
|
1397
|
-
},
|
|
1398
|
-
);
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
} catch (err) {
|
|
1402
|
-
// Never let cleanup mask the build failure that triggered it.
|
|
1403
|
-
logger.warn("Failed-run table reclaim did not complete", {
|
|
1404
|
-
error: errMessage(err),
|
|
1405
|
-
});
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
/**
|
|
1410
|
-
* The `RunSQLOptions` for one source's build statements: its resolved
|
|
1411
|
-
* per-query metadata, merged under this run's context.
|
|
1412
|
-
*
|
|
1413
|
-
* Layers, least specific first: the executing connection's default, then what
|
|
1414
|
-
* the model side declared for this source (package → model-file → `#@ persist`,
|
|
1415
|
-
* already resolved by {@link resolveQueryMetadata}), then the run's context,
|
|
1416
|
-
* which names the source. There is no request layer — a build has no request.
|
|
1417
|
-
*
|
|
1418
|
-
* Fails OPEN: a build must not fail because metadata could not be assembled,
|
|
1419
|
-
* so an unresolvable connection just contributes no default, and a dropped
|
|
1420
|
-
* property is logged and metered rather than thrown.
|
|
1421
|
-
*/
|
|
1422
|
-
private buildRunSQLOptions(
|
|
1423
|
-
persistSource: PersistSource,
|
|
1424
|
-
environment: BuildEnvironment,
|
|
1425
|
-
buildMetadata: BuildQueryMetadata | undefined,
|
|
1426
|
-
): { queryMetadata?: QueryMetadata } {
|
|
1427
|
-
if (!buildMetadata) return {};
|
|
1428
|
-
const connectionLayers = connectionMetadataLayers(
|
|
1429
|
-
environment,
|
|
1430
|
-
persistSource.connectionName,
|
|
1431
|
-
);
|
|
1432
|
-
const resolved = mergeQueryMetadata({
|
|
1433
|
-
connection: connectionLayers.default,
|
|
1434
|
-
enforced: connectionLayers.enforced,
|
|
1435
|
-
model: resolveQueryMetadata(
|
|
1436
|
-
persistSource,
|
|
1437
|
-
buildMetadata.packageMaterialization,
|
|
1438
|
-
),
|
|
1439
|
-
context: { ...buildMetadata.context, source: persistSource.name },
|
|
1440
|
-
});
|
|
1441
|
-
if (resolved.drops.length > 0) {
|
|
1442
|
-
logger.warn("Dropped query-metadata properties for a build", {
|
|
1443
|
-
sourceName: persistSource.name,
|
|
1444
|
-
drops: resolved.drops,
|
|
1445
|
-
});
|
|
1446
|
-
}
|
|
1447
|
-
return resolved.metadata ? { queryMetadata: resolved.metadata } : {};
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
/**
|
|
1451
|
-
* The `RunSQLOptions` for the drops that retire a materialization's tables.
|
|
1452
|
-
* No model layer: the source's declaration described how to BUILD it, and the
|
|
1453
|
-
* source may no longer exist by the time its table is retired.
|
|
1454
|
-
*/
|
|
1455
|
-
private dropRunSQLOptions(
|
|
1456
|
-
environment: BuildEnvironment,
|
|
1457
|
-
connectionName: string,
|
|
1458
|
-
environmentName: string,
|
|
1459
|
-
packageName: string,
|
|
1460
|
-
materializationId: string,
|
|
1461
|
-
): { queryMetadata?: QueryMetadata } {
|
|
1462
|
-
const connectionLayers = connectionMetadataLayers(
|
|
1463
|
-
environment,
|
|
1464
|
-
connectionName,
|
|
1465
|
-
);
|
|
1466
|
-
const resolved = mergeQueryMetadata({
|
|
1467
|
-
connection: connectionLayers.default,
|
|
1468
|
-
enforced: connectionLayers.enforced,
|
|
1469
|
-
context: {
|
|
1470
|
-
queryClass: "ops",
|
|
1471
|
-
environment: environmentName,
|
|
1472
|
-
package: packageName,
|
|
1473
|
-
runId: materializationId,
|
|
1474
|
-
},
|
|
1475
|
-
});
|
|
1476
|
-
return resolved.metadata ? { queryMetadata: resolved.metadata } : {};
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
/**
|
|
1480
|
-
* Build a single instructed source into its assigned physical table.
|
|
1481
|
-
* COPY uses a staging table + atomic rename for crash-safety; the staging
|
|
1482
|
-
* name derives from the sourceEntityId. Records and returns the manifest entry.
|
|
1483
|
-
*/
|
|
1484
|
-
private async buildOneSource(
|
|
1485
|
-
persistSource: PersistSource,
|
|
1486
|
-
instruction: BuildInstruction,
|
|
1487
|
-
connection: MalloyConnection,
|
|
1488
|
-
connectionDigests: Record<string, string>,
|
|
1489
|
-
manifest: Manifest,
|
|
1490
|
-
environment: BuildEnvironment,
|
|
1491
|
-
builtEntries: Record<string, ManifestEntry>,
|
|
1492
|
-
buildMetadata?: BuildQueryMetadata,
|
|
1493
|
-
): Promise<ManifestEntry> {
|
|
1494
|
-
const sourceEntityId = instruction.sourceEntityId;
|
|
1495
|
-
const physicalTableName = instruction.physicalTableName;
|
|
1496
|
-
const isStorageBuild =
|
|
1497
|
-
!!instruction.destination && getPersistStorageMode() !== "off";
|
|
1498
|
-
// ANY warehouse-executed build SQL — a colocated CTAS or a storage build's
|
|
1499
|
-
// native passthrough — runs against the SOURCE warehouse, which cannot see
|
|
1500
|
-
// a storage-materialized upstream's DuckDB/DuckLake table (a different
|
|
1501
|
-
// engine). Substituting that upstream's lake table name into warehouse SQL
|
|
1502
|
-
// would reference a table the warehouse can't resolve (a confusing "table
|
|
1503
|
-
// not found"), whether the downstream is a storage build OR a colocated
|
|
1504
|
-
// source reading a storage upstream. So exclude storage-materialized
|
|
1505
|
-
// upstreams from the build manifest in BOTH cases: non-strict, they INLINE
|
|
1506
|
-
// (recompute from raw against the warehouse) so a chained source still
|
|
1507
|
-
// materializes; under `strictUpstreams` the excluded reference becomes a
|
|
1508
|
-
// clean strict-miss error (the orchestrated contract — don't silently
|
|
1509
|
-
// recompute). A stack-on-the-parent build reads the parent's lake table
|
|
1510
|
-
// instead, but via a separate DuckDB recompile that does NOT use this
|
|
1511
|
-
// warehouse buildSQL — this remains its recompute-from-raw fallback.
|
|
1512
|
-
const buildManifest = manifestExcludingStorage(manifest, builtEntries);
|
|
1513
|
-
const buildSQL = persistSource.getSQL({
|
|
1514
|
-
buildManifest,
|
|
1515
|
-
connectionDigests,
|
|
1516
|
-
});
|
|
1517
|
-
|
|
1518
|
-
// `storage=` build: materialize into a DuckDB/DuckLake destination via a
|
|
1519
|
-
// build-scoped session (never on the source or serve connection). Diverges
|
|
1520
|
-
// fully from the in-warehouse CTAS below — different engine, credential
|
|
1521
|
-
// federation, and a captured authoritative schema for the serve transform.
|
|
1522
|
-
// Gated by the kill switch: when off, ignore a destination and do a colocated build.
|
|
1523
|
-
if (isStorageBuild) {
|
|
1524
|
-
// Stack-on-the-parent detection: does the source's SQL change when storage upstreams
|
|
1525
|
-
// are PRESENT in the manifest (mapped to their lake tables) vs EXCLUDED
|
|
1526
|
-
// (inlined, the buildSQL above)? If so it reads a storage-materialized
|
|
1527
|
-
// upstream, so it can be built by reading the parent's lake table
|
|
1528
|
-
// ("stack on the parent") instead of recomputing from raw. The compare
|
|
1529
|
-
// is graph-free and self-contained; a single-source build's two SQLs are
|
|
1530
|
-
// identical, so it skips straight to the passthrough below.
|
|
1531
|
-
const dependsOnStorageUpstream =
|
|
1532
|
-
persistSource.getSQL({
|
|
1533
|
-
buildManifest: manifest.buildManifest,
|
|
1534
|
-
connectionDigests,
|
|
1535
|
-
}) !== buildSQL;
|
|
1536
|
-
// Materialize ONLY the source's PUBLIC columns. `getSQL` projects every
|
|
1537
|
-
// underlying column, including ones the source hides (`except:`, non-public
|
|
1538
|
-
// access modifiers). Query reachability is bounded by the declared
|
|
1539
|
-
// ::Shape, which the serve transform narrows to the public surface
|
|
1540
|
-
// (proven by the shape-bounds-physical-columns scenario) — so what this
|
|
1541
|
-
// prevents is the hidden column's VALUES sitting at rest in the
|
|
1542
|
-
// destination store, reachable by direct catalog access and possibly
|
|
1543
|
-
// across a trust boundary the source's visibility was meant to hold.
|
|
1544
|
-
// Refuses the build (422) if the public surface can't be determined.
|
|
1545
|
-
const publicBuildSQL = projectToPublicColumns(persistSource, buildSQL);
|
|
1546
|
-
return this.buildOneSourceIntoStorage(
|
|
1547
|
-
persistSource,
|
|
1548
|
-
instruction,
|
|
1549
|
-
manifest,
|
|
1550
|
-
environment,
|
|
1551
|
-
publicBuildSQL,
|
|
1552
|
-
builtEntries,
|
|
1553
|
-
dependsOnStorageUpstream,
|
|
1554
|
-
);
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
// Every statement of this source's build carries the same metadata, so the
|
|
1558
|
-
// warehouse's query history shows the staging CTAS, the drop and the rename
|
|
1559
|
-
// as one attributable unit of work.
|
|
1560
|
-
const runOptions = this.buildRunSQLOptions(
|
|
1561
|
-
persistSource,
|
|
1562
|
-
environment,
|
|
1563
|
-
buildMetadata,
|
|
1564
|
-
);
|
|
1565
|
-
|
|
1566
|
-
const bareName = bareTableName(physicalTableName);
|
|
1567
|
-
const stagingTableName = `${physicalTableName}${stagingSuffix(sourceEntityId)}`;
|
|
1568
|
-
// The control plane sends the logical (unquoted) physical name; dialect-
|
|
1569
|
-
// quote each identifier here so a container path or quote-requiring name
|
|
1570
|
-
// (e.g. a hyphenated BigQuery project id) produces valid DDL. The manifest
|
|
1571
|
-
// echoes the logical name (below) so the CP stays in logical-name space.
|
|
1572
|
-
const dialect = persistSource.dialectName;
|
|
1573
|
-
const quotedStaging = quoteTablePath(stagingTableName, dialect);
|
|
1574
|
-
const quotedPhysical = quoteTablePath(physicalTableName, dialect);
|
|
1575
|
-
const quotedBareName = quoteIdentifier(bareName, dialect);
|
|
1576
|
-
|
|
1577
|
-
const startTime = performance.now();
|
|
1578
|
-
await connection.runSQL(
|
|
1579
|
-
`DROP TABLE IF EXISTS ${quotedStaging}`,
|
|
1580
|
-
runOptions,
|
|
1581
|
-
);
|
|
1582
|
-
try {
|
|
1583
|
-
await connection.runSQL(
|
|
1584
|
-
`CREATE TABLE ${quotedStaging} AS (${buildSQL})`,
|
|
1585
|
-
runOptions,
|
|
1586
|
-
);
|
|
1587
|
-
await connection.runSQL(
|
|
1588
|
-
`DROP TABLE IF EXISTS ${quotedPhysical}`,
|
|
1589
|
-
runOptions,
|
|
1590
|
-
);
|
|
1591
|
-
await connection.runSQL(
|
|
1592
|
-
`ALTER TABLE ${quotedStaging} RENAME TO ${quotedBareName}`,
|
|
1593
|
-
runOptions,
|
|
1594
|
-
);
|
|
1595
|
-
} catch (err) {
|
|
1596
|
-
try {
|
|
1597
|
-
await connection.runSQL(
|
|
1598
|
-
`DROP TABLE IF EXISTS ${quotedStaging}`,
|
|
1599
|
-
runOptions,
|
|
1600
|
-
);
|
|
1601
|
-
} catch (cleanupErr) {
|
|
1602
|
-
logger.warn(
|
|
1603
|
-
"Failed to clean up staging table after a failed build; physical leak",
|
|
1604
|
-
{
|
|
1605
|
-
stagingTableName,
|
|
1606
|
-
connectionName: persistSource.connectionName,
|
|
1607
|
-
cleanupError: errMessage(cleanupErr),
|
|
1608
|
-
},
|
|
1609
|
-
);
|
|
1610
|
-
}
|
|
1611
|
-
throw err;
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
// Make this table visible to downstream sources built later in this run.
|
|
1615
|
-
// Record the SAME quoted path the CREATE used (not the logical name): the
|
|
1616
|
-
// build Manifest is pasted into a downstream `FROM` verbatim, so on a
|
|
1617
|
-
// case-folding engine an unquoted name would miss the case-preserved table
|
|
1618
|
-
// just written. The returned entry (below) keeps the logical name for the
|
|
1619
|
-
// committed manifest; only this in-memory build Manifest is quoted.
|
|
1620
|
-
manifest.update(sourceEntityId, { tableName: quotedPhysical });
|
|
1621
|
-
|
|
1622
|
-
const durationMs = Math.round(performance.now() - startTime);
|
|
1623
|
-
recordSourceBuildDuration(durationMs, "in_warehouse");
|
|
1624
|
-
logger.info(`Built materialized source ${persistSource.name}`, {
|
|
1625
|
-
physicalTableName,
|
|
1626
|
-
durationMs,
|
|
1627
|
-
});
|
|
1628
|
-
|
|
1629
|
-
return {
|
|
1630
|
-
sourceEntityId,
|
|
1631
|
-
sourceName: persistSource.name,
|
|
1632
|
-
materializedTableId: instruction.materializedTableId,
|
|
1633
|
-
physicalTableName,
|
|
1634
|
-
connectionName: persistSource.connectionName,
|
|
1635
|
-
realization: instruction.realization,
|
|
1636
|
-
rowCount: null,
|
|
1637
|
-
};
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
/**
|
|
1641
|
-
* Materialize a source into a `storage=` destination (a DuckDB/DuckLake
|
|
1642
|
-
* connection) via a native query-passthrough CTAS on a build-scoped session.
|
|
1643
|
-
* Records the destination connection and the captured authoritative DuckDB
|
|
1644
|
-
* schema on the manifest entry so the source can later be served
|
|
1645
|
-
* cross-dialect from the destination (the serve transform declares that
|
|
1646
|
-
* schema). `connectionName` still names the SOURCE warehouse (where data is
|
|
1647
|
-
* read from); `storageConnectionName` names where the table now lives.
|
|
1648
|
-
*/
|
|
1649
|
-
private async buildOneSourceIntoStorage(
|
|
1650
|
-
persistSource: PersistSource,
|
|
1651
|
-
instruction: BuildInstruction,
|
|
1652
|
-
manifest: Manifest,
|
|
1653
|
-
environment: BuildEnvironment,
|
|
1654
|
-
buildSQL: string,
|
|
1655
|
-
builtEntries: Record<string, ManifestEntry>,
|
|
1656
|
-
dependsOnStorageUpstream: boolean,
|
|
1657
|
-
): Promise<ManifestEntry> {
|
|
1658
|
-
const sourceEntityId = instruction.sourceEntityId;
|
|
1659
|
-
const physicalTableName = instruction.physicalTableName;
|
|
1660
|
-
const destinationName = instruction.destination!;
|
|
1661
|
-
const sourceConnection = environment.getApiConnection(
|
|
1662
|
-
persistSource.connectionName,
|
|
1663
|
-
);
|
|
1664
|
-
const destinationConnection =
|
|
1665
|
-
environment.getApiConnection(destinationName);
|
|
1666
|
-
|
|
1667
|
-
const startTime = performance.now();
|
|
1668
|
-
let result;
|
|
1669
|
-
|
|
1670
|
-
// Stack on the parent: a source that reads a storage-materialized
|
|
1671
|
-
// upstream is built by reading the parent's STORED lake table instead of
|
|
1672
|
-
// recomputing it from raw against the warehouse. This reuses the
|
|
1673
|
-
// parent's work and is consistent-by-construction (the downstream is a pure
|
|
1674
|
-
// function of the parent's stored rows). Attempted only when the source
|
|
1675
|
-
// actually reads a storage upstream; on any ineligibility (a parent
|
|
1676
|
-
// refinement not carried into the rebind, a live-warehouse join, a
|
|
1677
|
-
// cross-catalog parent) it throws and we fall back: recompute-from-raw when
|
|
1678
|
-
// non-strict, or a loud refusal under `strictUpstreams` (the orchestrated
|
|
1679
|
-
// contract — never silently recompute).
|
|
1680
|
-
if (dependsOnStorageUpstream) {
|
|
1681
|
-
try {
|
|
1682
|
-
result = await this.buildDownstreamViaParents(
|
|
1683
|
-
persistSource,
|
|
1684
|
-
destinationName,
|
|
1685
|
-
destinationConnection,
|
|
1686
|
-
builtEntries,
|
|
1687
|
-
environment,
|
|
1688
|
-
physicalTableName,
|
|
1689
|
-
);
|
|
1690
|
-
recordChainedStorageBuild("parent_reuse");
|
|
1691
|
-
} catch (err) {
|
|
1692
|
-
// Same redaction contract as the recompute-from-raw path below: this
|
|
1693
|
-
// branch's read-write ATTACH or CTAS can fail with the offending SQL
|
|
1694
|
-
// echoed back, catalog `password=` included. Redact before the
|
|
1695
|
-
// message reaches the thrown run `error` or the log.
|
|
1696
|
-
const safeDetail = redactConnectionSecrets(
|
|
1697
|
-
errMessage(err),
|
|
1698
|
-
sourceConnection,
|
|
1699
|
-
destinationConnection,
|
|
1700
|
-
);
|
|
1701
|
-
// Only a SHAPE failure justifies recomputing from raw: the downstream
|
|
1702
|
-
// could not be expressed over its rebound parents, so building it the
|
|
1703
|
-
// other way is a genuinely different attempt. An INFRA failure (the
|
|
1704
|
-
// read-write attach, the CTAS, the destination being down) is not —
|
|
1705
|
-
// recompute-from-raw writes to the SAME destination and fails the same
|
|
1706
|
-
// way, and metering it as `inline_fallback` files an outage in the same
|
|
1707
|
-
// bucket as a legitimate shape miss.
|
|
1708
|
-
if (!(err instanceof MaterializationEligibilityError)) {
|
|
1709
|
-
recordChainedStorageBuild("infra_failure");
|
|
1710
|
-
recordStorageBuildFailure(destinationName);
|
|
1711
|
-
throw new Error(
|
|
1712
|
-
`Failed to materialize chained source '${persistSource.name}' ` +
|
|
1713
|
-
`into storage destination '${destinationName}': ${safeDetail}`,
|
|
1714
|
-
);
|
|
1715
|
-
}
|
|
1716
|
-
if (manifest.strict) {
|
|
1717
|
-
recordChainedStorageBuild("strict_refused");
|
|
1718
|
-
recordStorageBuildFailure(destinationName);
|
|
1719
|
-
throw new Error(
|
|
1720
|
-
`Failed to materialize chained source '${persistSource.name}' ` +
|
|
1721
|
-
`into storage destination '${destinationName}' by reading ` +
|
|
1722
|
-
`its materialized upstream, and strict upstreams forbid ` +
|
|
1723
|
-
`recomputing it from raw: ${safeDetail}`,
|
|
1724
|
-
);
|
|
1725
|
-
}
|
|
1726
|
-
recordChainedStorageBuild("inline_fallback");
|
|
1727
|
-
logger.warn(
|
|
1728
|
-
"Chained storage build could not reuse the parent table; " +
|
|
1729
|
-
"recomputing the upstream from raw",
|
|
1730
|
-
{
|
|
1731
|
-
sourceName: persistSource.name,
|
|
1732
|
-
destinationName,
|
|
1733
|
-
reason: safeDetail,
|
|
1734
|
-
},
|
|
1735
|
-
);
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
// Recompute from raw (the single-source passthrough): materialize `buildSQL`
|
|
1740
|
-
// (with storage upstreams inlined) in the source warehouse and CTAS the result
|
|
1741
|
-
// into the destination. Skipped when stacking on the parent already produced
|
|
1742
|
-
// the table above.
|
|
1743
|
-
if (!result) {
|
|
1744
|
-
try {
|
|
1745
|
-
result = await buildSourceIntoStorage({
|
|
1746
|
-
destinationName,
|
|
1747
|
-
destinationConnection,
|
|
1748
|
-
sourceConnection,
|
|
1749
|
-
buildSQL,
|
|
1750
|
-
physicalTableName,
|
|
1751
|
-
environmentPath: environment.getEnvironmentPath(),
|
|
1752
|
-
});
|
|
1753
|
-
} catch (err) {
|
|
1754
|
-
// Redaction: a failed federation / passthrough / attach
|
|
1755
|
-
// error can echo source- or catalog-connection detail (connstrings,
|
|
1756
|
-
// account names, service-account JSON) from the DuckDB engine. Strip
|
|
1757
|
-
// the actual credential VALUES but keep the message, so an operator
|
|
1758
|
-
// sees a legible, actionable error (e.g. "schema 'analytics' not
|
|
1759
|
-
// found" when a `name=schema.table` target's schema wasn't
|
|
1760
|
-
// provisioned) rather than an opaque failure — without leaking
|
|
1761
|
-
// secrets into the user-visible run `error` column.
|
|
1762
|
-
const safeDetail = redactConnectionSecrets(
|
|
1763
|
-
errMessage(err),
|
|
1764
|
-
sourceConnection,
|
|
1765
|
-
destinationConnection,
|
|
1766
|
-
);
|
|
1767
|
-
recordStorageBuildFailure(destinationName);
|
|
1768
|
-
logger.warn("Storage materialization build failed", {
|
|
1769
|
-
sourceName: persistSource.name,
|
|
1770
|
-
destinationName,
|
|
1771
|
-
error: safeDetail,
|
|
1772
|
-
});
|
|
1773
|
-
throw new Error(
|
|
1774
|
-
`Failed to materialize source '${persistSource.name}' into ` +
|
|
1775
|
-
`storage destination '${destinationName}': ${safeDetail}`,
|
|
1776
|
-
);
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
// Build-time servability gate: the serve-shape must compile in DuckDB
|
|
1781
|
-
// against the authoritative post-build schema, or the build is refused
|
|
1782
|
-
// (HTTP 422) — a serve-time execution error turned into a fail-loud
|
|
1783
|
-
// build-time refusal. Outside the redaction try above so the eligibility
|
|
1784
|
-
// error surfaces as-is (it carries no connection secrets). Runs here, in
|
|
1785
|
-
// stage→validate, because it needs the captured schema.
|
|
1786
|
-
try {
|
|
1787
|
-
await assertStorageServeShapeCompiles({
|
|
1788
|
-
destinationName,
|
|
1789
|
-
sourceName: persistSource.name,
|
|
1790
|
-
virtualHandle: sourceEntityId,
|
|
1791
|
-
physicalTableName,
|
|
1792
|
-
schema: result.schema,
|
|
1793
|
-
});
|
|
1794
|
-
} catch (gateErr) {
|
|
1795
|
-
// The table was already CTAS'd before this post-build gate, and no
|
|
1796
|
-
// manifest entry records it yet — so a refusal would strand it where
|
|
1797
|
-
// manifest-driven GC (which only drops names it recorded building) can
|
|
1798
|
-
// never see it. Best-effort drop of the just-built table so a refused
|
|
1799
|
-
// build doesn't leak an orphaned table.
|
|
1800
|
-
//
|
|
1801
|
-
// Note (in-place naming): the CTAS above already replaced any prior
|
|
1802
|
-
// generation at this name, so a failed-gate rebuild has no earlier
|
|
1803
|
-
// table to fall back to — the source reverts to serving live until a
|
|
1804
|
-
// subsequent successful build. Rollback-safe regeneration (keep the
|
|
1805
|
-
// prior generation until the new one is validated) requires the
|
|
1806
|
-
// host-generational orchestrated path, not the auto-run server.
|
|
1807
|
-
try {
|
|
1808
|
-
await dropStorageTable({
|
|
1809
|
-
destinationName,
|
|
1810
|
-
destinationConnection,
|
|
1811
|
-
physicalTableName,
|
|
1812
|
-
environmentPath: environment.getEnvironmentPath(),
|
|
1813
|
-
});
|
|
1814
|
-
} catch (dropErr) {
|
|
1815
|
-
logger.warn(
|
|
1816
|
-
"Failed to drop a storage table stranded by a serve-shape gate " +
|
|
1817
|
-
"refusal (physical leak)",
|
|
1818
|
-
{
|
|
1819
|
-
sourceName: persistSource.name,
|
|
1820
|
-
destinationName,
|
|
1821
|
-
physicalTableName,
|
|
1822
|
-
// The drop runs on a read-write attach, so a failure can echo
|
|
1823
|
-
// the catalog connstring.
|
|
1824
|
-
error: redactConnectionSecrets(
|
|
1825
|
-
errMessage(dropErr),
|
|
1826
|
-
sourceConnection,
|
|
1827
|
-
destinationConnection,
|
|
1828
|
-
),
|
|
1829
|
-
},
|
|
1830
|
-
);
|
|
1831
|
-
}
|
|
1832
|
-
throw gateErr;
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
// Make this table visible to downstream sources built later in this run,
|
|
1836
|
-
// so a chained storage source can stack on it (above).
|
|
1837
|
-
manifest.update(sourceEntityId, { tableName: physicalTableName });
|
|
1838
|
-
|
|
1839
|
-
const durationMs = Math.round(performance.now() - startTime);
|
|
1840
|
-
recordSourceBuildDuration(durationMs, "storage");
|
|
1841
|
-
logger.info(
|
|
1842
|
-
`Built materialized source ${persistSource.name} into storage`,
|
|
1843
|
-
{
|
|
1844
|
-
physicalTableName,
|
|
1845
|
-
storageConnectionName: result.storageConnectionName,
|
|
1846
|
-
columns: result.schema.length,
|
|
1847
|
-
durationMs,
|
|
1848
|
-
},
|
|
1849
|
-
);
|
|
1850
|
-
|
|
1851
|
-
return {
|
|
1852
|
-
sourceEntityId,
|
|
1853
|
-
sourceName: persistSource.name,
|
|
1854
|
-
materializedTableId: instruction.materializedTableId,
|
|
1855
|
-
physicalTableName,
|
|
1856
|
-
connectionName: persistSource.connectionName,
|
|
1857
|
-
storageConnectionName: result.storageConnectionName,
|
|
1858
|
-
schema: result.schema,
|
|
1859
|
-
realization: instruction.realization,
|
|
1860
|
-
rowCount: null,
|
|
1861
|
-
};
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
/**
|
|
1865
|
-
* Stack on the parent: materialize a chained storage source by reading its
|
|
1866
|
-
* already-materialized upstream(s) from the SAME destination store. Rebinds
|
|
1867
|
-
* every same-destination materialized upstream to a virtual source (base-only,
|
|
1868
|
-
* the captured schema), re-declares the downstream over them (its definition
|
|
1869
|
-
* text lifted from the author's model), and hands the assembled transient
|
|
1870
|
-
* model to {@link buildDownstreamIntoStorage}, which compiles it against the
|
|
1871
|
-
* build session and CTASes the downstream's SQL — now reading the parents'
|
|
1872
|
-
* lake tables — into the destination.
|
|
1873
|
-
*
|
|
1874
|
-
* Throws (⇒ the caller falls back to recompute-from-raw) when there is no
|
|
1875
|
-
* same-destination upstream to build on, the definition text can't be lifted,
|
|
1876
|
-
* or the transient model doesn't compile (a parent refinement not carried, a
|
|
1877
|
-
* live-warehouse join, a cross-catalog parent).
|
|
1878
|
-
*/
|
|
1879
|
-
private async buildDownstreamViaParents(
|
|
1880
|
-
persistSource: PersistSource,
|
|
1881
|
-
destinationName: string,
|
|
1882
|
-
destinationConnection: ApiConnection,
|
|
1883
|
-
builtEntries: Record<string, ManifestEntry>,
|
|
1884
|
-
environment: BuildEnvironment,
|
|
1885
|
-
physicalTableName: string,
|
|
1886
|
-
): Promise<StorageBuildResult> {
|
|
1887
|
-
// Rebind every upstream materialized into THIS destination. A parent in a
|
|
1888
|
-
// DIFFERENT destination is absent here, so the downstream def fails to
|
|
1889
|
-
// compile against the rebind model and the caller falls back — cross-catalog
|
|
1890
|
-
// parent reuse is out of scope for the spike.
|
|
1891
|
-
const upstreams: ServeBinding[] = deriveServeBindings(
|
|
1892
|
-
builtEntries,
|
|
1893
|
-
).filter((b) => b.connectionName === destinationName);
|
|
1894
|
-
if (upstreams.length === 0) {
|
|
1895
|
-
throw new MaterializationEligibilityError({
|
|
1896
|
-
message:
|
|
1897
|
-
"no materialized upstream is available in this destination to build on",
|
|
1898
|
-
});
|
|
1899
|
-
}
|
|
1900
|
-
const downstreamDefText = this.liftDownstreamDefText(persistSource);
|
|
1901
|
-
if (!downstreamDefText) {
|
|
1902
|
-
throw new MaterializationEligibilityError({
|
|
1903
|
-
message:
|
|
1904
|
-
"could not recover the downstream source definition text from the model",
|
|
1905
|
-
});
|
|
1906
|
-
}
|
|
1907
|
-
const transientModel = buildChainedStorageBuildModel({
|
|
1908
|
-
upstreams,
|
|
1909
|
-
downstreamName: persistSource.name,
|
|
1910
|
-
downstreamDefText,
|
|
1911
|
-
destinationName,
|
|
1912
|
-
});
|
|
1913
|
-
return buildDownstreamIntoStorage({
|
|
1914
|
-
destinationName,
|
|
1915
|
-
destinationConnection,
|
|
1916
|
-
transientModel,
|
|
1917
|
-
downstreamName: persistSource.name,
|
|
1918
|
-
virtualMap: buildVirtualMap(upstreams),
|
|
1919
|
-
physicalTableName,
|
|
1920
|
-
environmentPath: environment.getEnvironmentPath(),
|
|
1921
|
-
});
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
/**
|
|
1925
|
-
* Lift the verbatim RHS of a persist source's `source: <name> is …`
|
|
1926
|
-
* declaration from the author's model file (same technique as the serve
|
|
1927
|
-
* transform's join/view lift): read the file named by the source's compiled
|
|
1928
|
-
* `location`, slice the covered range. A top-level source's range starts just
|
|
1929
|
-
* after the `source: ` keyword, so the result is `<name> is <def>` and the
|
|
1930
|
-
* transient-model assembler prepends `source: `. Returns undefined (⇒ fall
|
|
1931
|
-
* back) when there is no file-backed location or the file can't be read/sliced.
|
|
1932
|
-
*/
|
|
1933
|
-
private liftDownstreamDefText(
|
|
1934
|
-
persistSource: PersistSource,
|
|
1935
|
-
): string | undefined {
|
|
1936
|
-
const location = (
|
|
1937
|
-
persistSource._explore as unknown as { location?: SourceLocation }
|
|
1938
|
-
).location;
|
|
1939
|
-
if (!location?.url?.startsWith("file:")) return undefined;
|
|
1940
|
-
let text: string;
|
|
1941
|
-
try {
|
|
1942
|
-
text = readFileSync(fileURLToPath(location.url), "utf8");
|
|
1943
|
-
} catch {
|
|
1944
|
-
return undefined;
|
|
1945
|
-
}
|
|
1946
|
-
return sliceSourceRange(text, location.range);
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
// ==================== CANCELLATION ====================
|
|
1950
|
-
|
|
1951
|
-
/** Cancel a non-terminal materialization. */
|
|
1952
|
-
async stopMaterialization(
|
|
1953
|
-
environmentName: string,
|
|
1954
|
-
packageName: string,
|
|
1955
|
-
id: string,
|
|
1956
|
-
): Promise<Materialization> {
|
|
1957
|
-
const m = await this.getMaterialization(environmentName, packageName, id);
|
|
1958
|
-
|
|
1959
|
-
const cancellable: MaterializationStatus[] = [
|
|
1960
|
-
"PENDING",
|
|
1961
|
-
"MANIFEST_ROWS_READY",
|
|
1962
|
-
];
|
|
1963
|
-
if (!cancellable.includes(m.status)) {
|
|
1964
|
-
throw new InvalidStateTransitionError(
|
|
1965
|
-
`Materialization ${id} is ${m.status}, cannot stop`,
|
|
1966
|
-
);
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
const abortController = this.runningAbortControllers.get(id);
|
|
1970
|
-
if (abortController) {
|
|
1971
|
-
abortController.abort();
|
|
1972
|
-
return m;
|
|
1973
|
-
}
|
|
1974
|
-
return this.transition(id, "CANCELLED", {
|
|
1975
|
-
completedAt: new Date(),
|
|
1976
|
-
error: "Cancelled",
|
|
1977
|
-
});
|
|
1978
|
-
}
|
|
1979
|
-
|
|
1980
|
-
/**
|
|
1981
|
-
* Delete a materialization record. Only terminal materializations
|
|
1982
|
-
* (MANIFEST_FILE_READY, FAILED, CANCELLED) can be deleted; an active run must
|
|
1983
|
-
* be stopped first.
|
|
1984
|
-
*
|
|
1985
|
-
* By default this removes the publisher's record only — physical-table GC is
|
|
1986
|
-
* the caller's responsibility. When `dropTables` is set, the publisher
|
|
1987
|
-
* additionally drops the physical tables recorded in this run's manifest as a
|
|
1988
|
-
* best-effort cleanup (a drop failure is logged, not fatal, so the record
|
|
1989
|
-
* still deletes).
|
|
1990
|
-
*/
|
|
1991
|
-
async deleteMaterialization(
|
|
1992
|
-
environmentName: string,
|
|
1993
|
-
packageName: string,
|
|
1994
|
-
id: string,
|
|
1995
|
-
options: { dropTables?: boolean } = {},
|
|
1996
|
-
): Promise<void> {
|
|
1997
|
-
const m = await this.getMaterialization(environmentName, packageName, id);
|
|
1998
|
-
|
|
1999
|
-
const terminal: MaterializationStatus[] = [
|
|
2000
|
-
"MANIFEST_FILE_READY",
|
|
2001
|
-
"FAILED",
|
|
2002
|
-
"CANCELLED",
|
|
2003
|
-
];
|
|
2004
|
-
if (!terminal.includes(m.status)) {
|
|
2005
|
-
throw new InvalidStateTransitionError(
|
|
2006
|
-
`Cannot delete materialization ${id} while it is ${m.status}`,
|
|
2007
|
-
);
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
if (options.dropTables) {
|
|
2011
|
-
await this.dropMaterializedTables(environmentName, packageName, m);
|
|
2012
|
-
}
|
|
2013
|
-
|
|
2014
|
-
await this.repository.deleteMaterialization(id);
|
|
2015
|
-
|
|
2016
|
-
// Re-derive the package's serve routing from the latest REMAINING
|
|
2017
|
-
// successful materialization — BOTH tiers. The deleted run may have been
|
|
2018
|
-
// the one bound for serving — and with `dropTables` its tables are now gone
|
|
2019
|
-
// — so without this a query would keep routing (schema-on-faith, no
|
|
2020
|
-
// run-time fallback) to a deleted/dropped table and error until the next
|
|
2021
|
-
// reload or build. Rebinding picks the next-latest generation, or CLEARS
|
|
2022
|
-
// the bindings when none remain (empty ⇒ serve live).
|
|
2023
|
-
await this.rebindServeBindingsAfterDelete(environmentName, packageName);
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
/**
|
|
2027
|
-
* Re-derive a package's serve routing from its latest remaining successful
|
|
2028
|
-
* materialization and push it onto the loaded models — BOTH tiers, mirroring
|
|
2029
|
-
* the load-time {@link Environment.rebindServeBindingsFromLocalStore}. Called
|
|
2030
|
-
* after a delete so serving never points at a removed table; picks the
|
|
2031
|
-
* next-latest generation, or clears the bindings when none remain.
|
|
2032
|
-
* Best-effort (a failure logs and leaves the current bindings — a later
|
|
2033
|
-
* reload/build re-derives).
|
|
2034
|
-
*
|
|
2035
|
-
* The manifest is split by tier:
|
|
2036
|
-
* - **colocated** (same-connection) → re-derived regardless of
|
|
2037
|
-
* `PERSIST_STORAGE_MODE`: colocated is the v0 path and is not gated by the
|
|
2038
|
-
* storage kill switch, so a reclaimed colocated table must not be left
|
|
2039
|
-
* routed even when the tier is off.
|
|
2040
|
-
* - **storage=** (cross-connection) → re-derived only when the tier is not
|
|
2041
|
-
* `off` (its serve routing requires the tier on; an off deployment does no
|
|
2042
|
-
* extra work here).
|
|
2043
|
-
*/
|
|
2044
|
-
private async rebindServeBindingsAfterDelete(
|
|
2045
|
-
environmentName: string,
|
|
2046
|
-
packageName: string,
|
|
2047
|
-
): Promise<void> {
|
|
2048
|
-
try {
|
|
2049
|
-
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
2050
|
-
// "" excludes nothing — the deleted record is already gone from the repo.
|
|
2051
|
-
const entries = await this.getMostRecentManifestEntries(
|
|
2052
|
-
environmentId,
|
|
2053
|
-
packageName,
|
|
2054
|
-
"",
|
|
2055
|
-
);
|
|
2056
|
-
const environment = await this.environmentStore.getEnvironment(
|
|
2057
|
-
environmentName,
|
|
2058
|
-
false,
|
|
2059
|
-
);
|
|
2060
|
-
const { tableNameManifest, storageEntries } = splitManifestEntries(
|
|
2061
|
-
entries,
|
|
2062
|
-
`post-delete rebind (package ${packageName})`,
|
|
2063
|
-
);
|
|
2064
|
-
// Colocated: re-derive (or clear) regardless of mode.
|
|
2065
|
-
await environment.bindPackageColocatedServeManifest(
|
|
2066
|
-
packageName,
|
|
2067
|
-
tableNameManifest,
|
|
2068
|
-
);
|
|
2069
|
-
// Storage=: only meaningful when the tier is not off.
|
|
2070
|
-
if (getPersistStorageMode() !== "off") {
|
|
2071
|
-
await environment.bindPackageStorageServeBindings(
|
|
2072
|
-
packageName,
|
|
2073
|
-
storageEntries,
|
|
2074
|
-
);
|
|
2075
|
-
}
|
|
2076
|
-
} catch (err) {
|
|
2077
|
-
logger.warn(
|
|
2078
|
-
"Failed to rebind serve bindings after delete (leaving current " +
|
|
2079
|
-
"bindings; a reload/build will re-derive)",
|
|
2080
|
-
{ packageName, error: errMessage(err) },
|
|
2081
|
-
);
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
|
|
2085
|
-
/**
|
|
2086
|
-
* Best-effort drop of every physical table this run produced, read from the
|
|
2087
|
-
* materialization's manifest. Resolves each entry's connection by name and
|
|
2088
|
-
* issues `DROP TABLE IF EXISTS` for the physical table and its (possible)
|
|
2089
|
-
* leftover staging table. Failures are logged and swallowed so a partial
|
|
2090
|
-
* cleanup never blocks deletion of the record.
|
|
2091
|
-
*
|
|
2092
|
-
* A physical name is dropped only when NO other remaining MANIFEST_FILE_READY
|
|
2093
|
-
* run references it. Physical names are the source's `name=` verbatim (or a
|
|
2094
|
-
* host-assigned name), so multiple generations of a source share one physical
|
|
2095
|
-
* name — dropping a superseded record's table would otherwise take out the
|
|
2096
|
-
* table the current generation still serves. The skip incidentally also
|
|
2097
|
-
* protects the colocated case, where generations likewise share a
|
|
2098
|
-
* name.
|
|
2099
|
-
*/
|
|
2100
|
-
private async dropMaterializedTables(
|
|
2101
|
-
environmentName: string,
|
|
2102
|
-
packageName: string,
|
|
2103
|
-
m: Materialization,
|
|
2104
|
-
): Promise<void> {
|
|
2105
|
-
const entries = m.manifest?.entries;
|
|
2106
|
-
if (!entries || Object.keys(entries).length === 0) {
|
|
2107
|
-
return;
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
const environment = await this.environmentStore.getEnvironment(
|
|
2111
|
-
environmentName,
|
|
2112
|
-
false,
|
|
2113
|
-
);
|
|
2114
|
-
const pkg = await environment.getPackage(packageName, false);
|
|
2115
|
-
const connectionCache = new Map<string, MalloyConnection>();
|
|
2116
|
-
|
|
2117
|
-
// Physical names still referenced by ANOTHER MANIFEST_FILE_READY run for
|
|
2118
|
-
// this package (keyed destination-and-name), so a shared name is never
|
|
2119
|
-
// dropped out from under a live generation. `m` is still in the repo at
|
|
2120
|
-
// this point (deletion happens after this sweep), so exclude it by id.
|
|
2121
|
-
const tableKey = (dest: string, table: string) => `${dest}:${table}`;
|
|
2122
|
-
const stillReferenced = new Set<string>();
|
|
2123
|
-
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
2124
|
-
const others =
|
|
2125
|
-
(await this.repository.listMaterializations(
|
|
2126
|
-
environmentId,
|
|
2127
|
-
packageName,
|
|
2128
|
-
)) ?? [];
|
|
2129
|
-
for (const other of others) {
|
|
2130
|
-
if (other.id === m.id) continue;
|
|
2131
|
-
if (other.status !== "MANIFEST_FILE_READY") continue;
|
|
2132
|
-
for (const e of Object.values(other.manifest?.entries ?? {})) {
|
|
2133
|
-
const dest = e.storageConnectionName ?? e.connectionName;
|
|
2134
|
-
if (dest && e.physicalTableName) {
|
|
2135
|
-
stillReferenced.add(tableKey(dest, e.physicalTableName));
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
for (const entry of Object.values(entries)) {
|
|
2141
|
-
const connectionName = entry.connectionName;
|
|
2142
|
-
const physicalTableName = entry.physicalTableName;
|
|
2143
|
-
if (!connectionName || !physicalTableName) {
|
|
2144
|
-
logger.warn("Skipping manifest entry with no connection/table", {
|
|
2145
|
-
materializationId: m.id,
|
|
2146
|
-
sourceEntityId: entry.sourceEntityId,
|
|
2147
|
-
});
|
|
2148
|
-
continue;
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
// Do not drop a table another live generation still serves (shared
|
|
2152
|
-
// physical name — see the method doc).
|
|
2153
|
-
const destForKey = entry.storageConnectionName ?? connectionName;
|
|
2154
|
-
if (stillReferenced.has(tableKey(destForKey, physicalTableName))) {
|
|
2155
|
-
logger.info(
|
|
2156
|
-
"Skipping drop: table still referenced by another materialization",
|
|
2157
|
-
{
|
|
2158
|
-
materializationId: m.id,
|
|
2159
|
-
physicalTableName,
|
|
2160
|
-
destination: destForKey,
|
|
2161
|
-
},
|
|
2162
|
-
);
|
|
2163
|
-
continue;
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
// A storage= table lives in `storageConnectionName` (a DuckDB/DuckLake
|
|
2167
|
-
// destination), not in `connectionName` (the source warehouse), and
|
|
2168
|
-
// dropping it needs a build-scoped READ-WRITE attach — the serve attach
|
|
2169
|
-
// is read-only — so it is dropped on its own RW session rather than on
|
|
2170
|
-
// the (wrong-engine, read-only) source connection. Best-effort: a
|
|
2171
|
-
// failure is logged and the sweep continues, so one unreachable
|
|
2172
|
-
// destination never blocks reclaiming the rest.
|
|
2173
|
-
if (entry.storageConnectionName) {
|
|
2174
|
-
const destinationConnection = environment.getApiConnection(
|
|
2175
|
-
entry.storageConnectionName,
|
|
2176
|
-
);
|
|
2177
|
-
try {
|
|
2178
|
-
await dropStorageTable({
|
|
2179
|
-
destinationName: entry.storageConnectionName,
|
|
2180
|
-
destinationConnection,
|
|
2181
|
-
physicalTableName,
|
|
2182
|
-
environmentPath: environment.getEnvironmentPath(),
|
|
2183
|
-
});
|
|
2184
|
-
recordDropTables("success", "storage");
|
|
2185
|
-
logger.info("Dropped materialized storage table on delete", {
|
|
2186
|
-
materializationId: m.id,
|
|
2187
|
-
physicalTableName,
|
|
2188
|
-
storageConnectionName: entry.storageConnectionName,
|
|
2189
|
-
});
|
|
2190
|
-
} catch (err) {
|
|
2191
|
-
recordDropTables("failure", "storage");
|
|
2192
|
-
logger.warn("Failed to drop a storage-materialized table", {
|
|
2193
|
-
materializationId: m.id,
|
|
2194
|
-
physicalTableName,
|
|
2195
|
-
storageConnectionName: entry.storageConnectionName,
|
|
2196
|
-
// The drop attaches read-write, so a failure can echo the
|
|
2197
|
-
// catalog connstring.
|
|
2198
|
-
error: redactConnectionSecrets(
|
|
2199
|
-
errMessage(err),
|
|
2200
|
-
destinationConnection,
|
|
2201
|
-
),
|
|
2202
|
-
});
|
|
2203
|
-
}
|
|
2204
|
-
continue;
|
|
2205
|
-
}
|
|
2206
|
-
|
|
2207
|
-
try {
|
|
2208
|
-
let connection = connectionCache.get(connectionName);
|
|
2209
|
-
if (!connection) {
|
|
2210
|
-
connection = await pkg.getMalloyConnection(connectionName);
|
|
2211
|
-
connectionCache.set(connectionName, connection);
|
|
2212
|
-
}
|
|
2213
|
-
// Dialect-quote from the live connection, the same way
|
|
2214
|
-
// buildOneSource quoted at build time, so a name that built
|
|
2215
|
-
// successfully also drops successfully (container paths, hyphenated
|
|
2216
|
-
// BigQuery project ids, etc.).
|
|
2217
|
-
const dialect = connection.dialectName;
|
|
2218
|
-
// Dropping a materialized table is warehouse work someone will have
|
|
2219
|
-
// to account for later, so it is tagged like the build that created
|
|
2220
|
-
// it — `ops` rather than `materialize`, because this is the
|
|
2221
|
-
// lifecycle operation, not a build.
|
|
2222
|
-
const dropOptions = this.dropRunSQLOptions(
|
|
2223
|
-
environment,
|
|
2224
|
-
connectionName,
|
|
2225
|
-
environmentName,
|
|
2226
|
-
packageName,
|
|
2227
|
-
m.id,
|
|
2228
|
-
);
|
|
2229
|
-
await connection.runSQL(
|
|
2230
|
-
`DROP TABLE IF EXISTS ${quoteTablePath(
|
|
2231
|
-
physicalTableName,
|
|
2232
|
-
dialect,
|
|
2233
|
-
)}`,
|
|
2234
|
-
dropOptions,
|
|
2235
|
-
);
|
|
2236
|
-
// A crash between staging-create and rename can leave the staging
|
|
2237
|
-
// table behind; clean it up too while we hold the connection.
|
|
2238
|
-
await connection.runSQL(
|
|
2239
|
-
`DROP TABLE IF EXISTS ${quoteTablePath(
|
|
2240
|
-
`${physicalTableName}${stagingSuffix(entry.sourceEntityId)}`,
|
|
2241
|
-
dialect,
|
|
2242
|
-
)}`,
|
|
2243
|
-
dropOptions,
|
|
2244
|
-
);
|
|
2245
|
-
recordDropTables("success", "in_warehouse");
|
|
2246
|
-
logger.info("Dropped materialized table on delete", {
|
|
2247
|
-
materializationId: m.id,
|
|
2248
|
-
physicalTableName,
|
|
2249
|
-
connectionName,
|
|
2250
|
-
});
|
|
2251
|
-
} catch (err) {
|
|
2252
|
-
recordDropTables("failure", "in_warehouse");
|
|
2253
|
-
logger.warn("Failed to drop materialized table on delete", {
|
|
2254
|
-
materializationId: m.id,
|
|
2255
|
-
physicalTableName,
|
|
2256
|
-
connectionName,
|
|
2257
|
-
error: errMessage(err),
|
|
2258
|
-
});
|
|
2259
|
-
}
|
|
2260
|
-
}
|
|
2261
|
-
}
|
|
2262
|
-
|
|
2263
|
-
/**
|
|
2264
|
-
* Finalize a successful build: advance to MANIFEST_ROWS_READY then
|
|
2265
|
-
* MANIFEST_FILE_READY and persist the assembled manifest. The caller
|
|
2266
|
-
* supplies the per-run metadata. The build itself happens before this.
|
|
2267
|
-
*/
|
|
2268
|
-
private async commitManifest(
|
|
2269
|
-
id: string,
|
|
2270
|
-
entries: Record<string, ManifestEntry>,
|
|
2271
|
-
metadata: Record<string, unknown>,
|
|
2272
|
-
): Promise<void> {
|
|
2273
|
-
await this.transition(id, "MANIFEST_ROWS_READY");
|
|
2274
|
-
const manifest: BuildManifestResult = {
|
|
2275
|
-
builtAt: new Date().toISOString(),
|
|
2276
|
-
entries,
|
|
2277
|
-
strict: false,
|
|
2278
|
-
};
|
|
2279
|
-
await this.transition(id, "MANIFEST_FILE_READY", {
|
|
2280
|
-
completedAt: new Date(),
|
|
2281
|
-
manifest,
|
|
2282
|
-
metadata,
|
|
2283
|
-
});
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
// ==================== HELPERS ====================
|
|
2287
|
-
|
|
2288
|
-
/** The single-active conflict error, with the winning run's id when known. */
|
|
2289
|
-
private activeConflict(
|
|
2290
|
-
packageName: string,
|
|
2291
|
-
activeId?: string,
|
|
2292
|
-
): MaterializationConflictError {
|
|
2293
|
-
const suffix = activeId ? ` (${activeId})` : "";
|
|
2294
|
-
return new MaterializationConflictError(
|
|
2295
|
-
`Package ${packageName} already has an active materialization${suffix}`,
|
|
2296
|
-
);
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
private recordRun(
|
|
2300
|
-
mode: MaterializationMode,
|
|
2301
|
-
outcome: "success" | "failed" | "cancelled",
|
|
2302
|
-
startedAtMs: number,
|
|
2303
|
-
): void {
|
|
2304
|
-
recordMaterializationRun(mode, outcome, Date.now() - startedAtMs);
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
private runInBackground(
|
|
2308
|
-
id: string,
|
|
2309
|
-
run: (signal: AbortSignal) => Promise<void>,
|
|
2310
|
-
): void {
|
|
2311
|
-
const abortController = new AbortController();
|
|
2312
|
-
this.runningAbortControllers.set(id, abortController);
|
|
2313
|
-
|
|
2314
|
-
run(abortController.signal)
|
|
2315
|
-
.catch(async (err) => {
|
|
2316
|
-
const message = errMessage(err);
|
|
2317
|
-
const next = abortController.signal.aborted
|
|
2318
|
-
? "CANCELLED"
|
|
2319
|
-
: "FAILED";
|
|
2320
|
-
try {
|
|
2321
|
-
await this.repository.updateMaterialization(id, {
|
|
2322
|
-
status: next,
|
|
2323
|
-
completedAt: new Date(),
|
|
2324
|
-
error: abortController.signal.aborted ? "Cancelled" : message,
|
|
2325
|
-
});
|
|
2326
|
-
} catch (transitionErr) {
|
|
2327
|
-
logger.error("Failed to record materialization failure", {
|
|
2328
|
-
materializationId: id,
|
|
2329
|
-
originalError: message,
|
|
2330
|
-
transitionError: errMessage(transitionErr),
|
|
2331
|
-
});
|
|
2332
|
-
}
|
|
2333
|
-
})
|
|
2334
|
-
.finally(() => {
|
|
2335
|
-
this.runningAbortControllers.delete(id);
|
|
2336
|
-
});
|
|
2337
|
-
}
|
|
2338
|
-
|
|
2339
|
-
private resolveEnvironmentId(environmentName: string): Promise<string> {
|
|
2340
|
-
return resolveEnvironmentId(this.repository, environmentName);
|
|
2341
|
-
}
|
|
2342
|
-
}
|