kiri-cli 1.0.0b3__py3-none-any.whl
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.
Potentially problematic release.
This version of kiri-cli might be problematic. Click here for more details.
- kiri_adapters/__init__.py +193 -0
- kiri_adapters/backends/__init__.py +0 -0
- kiri_adapters/backends/databricks.manifest.yml +55 -0
- kiri_adapters/backends/databricks.py +2980 -0
- kiri_adapters/backends/fabric_dw.manifest.yml +17 -0
- kiri_adapters/backends/fabric_dw.py +307 -0
- kiri_adapters/backends/fabric_lakehouse.manifest.yml +19 -0
- kiri_adapters/backends/fabric_lakehouse.py +365 -0
- kiri_adapters/backends/iceberg.manifest.yml +17 -0
- kiri_adapters/backends/iceberg.py +451 -0
- kiri_adapters/backends/localduckdb.manifest.yml +43 -0
- kiri_adapters/backends/localduckdb.py +1295 -0
- kiri_adapters/backends/mssql.manifest.yml +17 -0
- kiri_adapters/backends/mssql.py +366 -0
- kiri_adapters/backends/postgres.manifest.yml +17 -0
- kiri_adapters/backends/postgres.py +434 -0
- kiri_adapters/backends/trino.manifest.yml +17 -0
- kiri_adapters/backends/trino.py +542 -0
- kiri_adapters/base.py +892 -0
- kiri_adapters/doctor_checks.py +81 -0
- kiri_adapters/dv_planner.py +1205 -0
- kiri_adapters/dv_planner_bridge.py +265 -0
- kiri_adapters/dv_planner_pit.py +265 -0
- kiri_adapters/dv_planner_ref.py +403 -0
- kiri_adapters/dv_planner_sat_variants.py +668 -0
- kiri_adapters/framework.py +112 -0
- kiri_adapters/incident/__init__.py +30 -0
- kiri_adapters/incident/_http.py +121 -0
- kiri_adapters/incident/base.py +89 -0
- kiri_adapters/incident/jira.py +365 -0
- kiri_adapters/incident/servicenow.py +314 -0
- kiri_adapters/incident/zendesk.py +285 -0
- kiri_adapters/manifest.py +466 -0
- kiri_adapters/orchestration/__init__.py +30 -0
- kiri_adapters/orchestration/airflow.py +117 -0
- kiri_adapters/orchestration/artifact_emitter.py +99 -0
- kiri_adapters/orchestration/autoloader_emitter.py +141 -0
- kiri_adapters/orchestration/azure_data_factory.py +137 -0
- kiri_adapters/orchestration/base.py +626 -0
- kiri_adapters/orchestration/databricks_workflows.py +1768 -0
- kiri_adapters/orchestration/k8s_cron.py +555 -0
- kiri_adapters/orchestration/lifecycle.py +222 -0
- kiri_adapters/orchestration/pipeline_compat.py +145 -0
- kiri_adapters/orchestration/runtime_python.py +188 -0
- kiri_adapters/paths.py +38 -0
- kiri_adapters/protocols/__init__.py +64 -0
- kiri_adapters/protocols/ai_service/__init__.py +7 -0
- kiri_adapters/protocols/ai_service/conformance.py +120 -0
- kiri_adapters/protocols/base.py +220 -0
- kiri_adapters/protocols/catalog/__init__.py +7 -0
- kiri_adapters/protocols/catalog/conformance.py +138 -0
- kiri_adapters/protocols/framework/__init__.py +7 -0
- kiri_adapters/protocols/framework/conformance.py +98 -0
- kiri_adapters/protocols/incident/__init__.py +7 -0
- kiri_adapters/protocols/incident/conformance.py +64 -0
- kiri_adapters/protocols/orchestration/__init__.py +7 -0
- kiri_adapters/protocols/orchestration/conformance.py +84 -0
- kiri_adapters/protocols/platform/__init__.py +7 -0
- kiri_adapters/protocols/platform/conformance.py +120 -0
- kiri_adapters/protocols/sql_dialect/__init__.py +7 -0
- kiri_adapters/protocols/sql_dialect/conformance.py +91 -0
- kiri_adapters/protocols/vault/__init__.py +7 -0
- kiri_adapters/protocols/vault/conformance.py +65 -0
- kiri_adapters/py.typed +0 -0
- kiri_adapters/registry.py +104 -0
- kiri_adapters/semantic/__init__.py +60 -0
- kiri_adapters/semantic/apply.py +274 -0
- kiri_adapters/semantic/base.py +530 -0
- kiri_adapters/semantic/cube.py +280 -0
- kiri_adapters/semantic/dbt_semantic.py +92 -0
- kiri_adapters/semantic/metric_views.py +249 -0
- kiri_adapters/semantic/metricflow.py +345 -0
- kiri_adapters/semantic/snowflake_views.py +75 -0
- kiri_adapters/sql_dialect.py +1592 -0
- kiri_adapters/tsql_to_spark.py +653 -0
- kiri_ai_gateway/__init__.py +147 -0
- kiri_ai_gateway/audit.py +334 -0
- kiri_ai_gateway/batch.py +293 -0
- kiri_ai_gateway/config.py +165 -0
- kiri_ai_gateway/gateway.py +642 -0
- kiri_ai_gateway/placeholder_guard.py +145 -0
- kiri_ai_gateway/prices/__init__.py +77 -0
- kiri_ai_gateway/prices/prices.yaml +64 -0
- kiri_ai_gateway/prompts/__init__.py +54 -0
- kiri_ai_gateway/prompts/airbyte_source_setup.yaml +212 -0
- kiri_ai_gateway/prompts/bdv_translation.yaml +90 -0
- kiri_ai_gateway/prompts/contract_draft_mvp.yaml +117 -0
- kiri_ai_gateway/prompts/describe_table.yaml +19 -0
- kiri_ai_gateway/prompts/design_gold_from_question.yaml +130 -0
- kiri_ai_gateway/prompts/detect_pii.yaml +59 -0
- kiri_ai_gateway/prompts/discover_classify_column.yaml +69 -0
- kiri_ai_gateway/prompts/discover_describe_table.yaml +61 -0
- kiri_ai_gateway/prompts/dv_quality_summary.yaml +36 -0
- kiri_ai_gateway/prompts/incident_assist.yaml +58 -0
- kiri_ai_gateway/prompts/infer_source_schema.yaml +31 -0
- kiri_ai_gateway/prompts/ingest_backend_chooser.yaml +116 -0
- kiri_ai_gateway/prompts/interactive_source_edit.yaml +94 -0
- kiri_ai_gateway/prompts/map_needed_entity_to_source.yaml +72 -0
- kiri_ai_gateway/prompts/meet_kiri.yaml +127 -0
- kiri_ai_gateway/prompts/native_source_setup.yaml +211 -0
- kiri_ai_gateway/prompts/source_grouping_reason.yaml +29 -0
- kiri_ai_gateway/prompts/suggest_attribute_classification.yaml +42 -0
- kiri_ai_gateway/prompts/suggest_data_tests.yaml +71 -0
- kiri_ai_gateway/prompts/suggest_gold_dimension.yaml +108 -0
- kiri_ai_gateway/prompts/suggest_gold_fact.yaml +107 -0
- kiri_ai_gateway/prompts/suggest_gold_model.yaml +70 -0
- kiri_ai_gateway/prompts/suggest_semantic_intent.yaml +91 -0
- kiri_ai_gateway/prompts/suggest_silver_model.yaml +140 -0
- kiri_ai_gateway/providers/__init__.py +7 -0
- kiri_ai_gateway/providers/anthropic_provider.py +26 -0
- kiri_ai_gateway/providers/gateway_provider.py +188 -0
- kiri_ai_gateway/providers/local_provider.py +177 -0
- kiri_ai_gateway/py.typed +0 -0
- kiri_ai_gateway/service.py +283 -0
- kiri_ai_gateway/transport.py +103 -0
- kiri_ai_gateway/transports/__init__.py +12 -0
- kiri_ai_gateway/transports/anthropic_transport.py +73 -0
- kiri_ai_gateway/transports/ollama_transport.py +82 -0
- kiri_ai_gateway/transports/openai_compatible_transport.py +125 -0
- kiri_ai_gateway/workspace_policy.py +52 -0
- kiri_catalog/__init__.py +199 -0
- kiri_catalog/annotation_plan.py +183 -0
- kiri_catalog/appearances.py +155 -0
- kiri_catalog/backends/__init__.py +79 -0
- kiri_catalog/backends/base.py +167 -0
- kiri_catalog/backends/memory.py +241 -0
- kiri_catalog/backends/postgres.py +497 -0
- kiri_catalog/backends/redacting.py +195 -0
- kiri_catalog/backends/sqlite.py +480 -0
- kiri_catalog/context.py +94 -0
- kiri_catalog/federation/__init__.py +114 -0
- kiri_catalog/federation/api.py +200 -0
- kiri_catalog/federation/cache.py +149 -0
- kiri_catalog/federation/emit.py +209 -0
- kiri_catalog/federation/fs_resolver.py +295 -0
- kiri_catalog/federation/http_resolver.py +486 -0
- kiri_catalog/federation/metrics.py +123 -0
- kiri_catalog/federation/release.py +342 -0
- kiri_catalog/federation/resolver.py +332 -0
- kiri_catalog/federation/schema.py +460 -0
- kiri_catalog/importer.py +668 -0
- kiri_catalog/lineage_drift.py +280 -0
- kiri_catalog/lineage_graph.py +176 -0
- kiri_catalog/lineage_graph_explain.py +96 -0
- kiri_catalog/lineage_graph_mermaid.py +230 -0
- kiri_catalog/observed_uc_lineage.py +216 -0
- kiri_catalog/pii_detect.py +176 -0
- kiri_catalog/push.py +122 -0
- kiri_catalog/py.typed +0 -0
- kiri_catalog/redact.py +101 -0
- kiri_catalog/review_actions.py +119 -0
- kiri_catalog/schema/__init__.py +70 -0
- kiri_catalog/schema/asset.py +142 -0
- kiri_catalog/schema/column.py +247 -0
- kiri_catalog/schema/enums.py +195 -0
- kiri_catalog/schema/job.py +51 -0
- kiri_catalog/schema/lineage.py +33 -0
- kiri_catalog/schema/policy.py +69 -0
- kiri_catalog/schema/primitives.py +90 -0
- kiri_catalog/schema/review_state.py +111 -0
- kiri_catalog/schema/urn.py +45 -0
- kiri_catalog/schema_discovery.py +160 -0
- kiri_catalog/sinks/__init__.py +64 -0
- kiri_catalog/sinks/base.py +178 -0
- kiri_catalog/sinks/horizon.py +195 -0
- kiri_catalog/sinks/purview.py +216 -0
- kiri_catalog/sinks/unity.py +352 -0
- kiri_catalog/sinks/unity_executor.py +543 -0
- kiri_catalog/sources/__init__.py +42 -0
- kiri_catalog/sources/base.py +104 -0
- kiri_catalog/sources/unity.py +465 -0
- kiri_catalog/steward_writeback.py +158 -0
- kiri_catalog/tag_sql.py +99 -0
- kiri_cli/LICENSE +105 -0
- kiri_cli/__init__.py +1 -0
- kiri_cli/_apply_cli.py +63 -0
- kiri_cli/_apply_reporter.py +38 -0
- kiri_cli/_closed_menu.py +36 -0
- kiri_cli/_fixture_option.py +18 -0
- kiri_cli/_layer_detect.py +158 -0
- kiri_cli/_project_option.py +79 -0
- kiri_cli/adapter_cmd.py +192 -0
- kiri_cli/ai_batch_cmd.py +258 -0
- kiri_cli/ai_cmd.py +352 -0
- kiri_cli/approve_cmd.py +659 -0
- kiri_cli/attr_cmd.py +575 -0
- kiri_cli/audit_cmd.py +348 -0
- kiri_cli/audit_correlate_cmd.py +648 -0
- kiri_cli/audit_join.py +437 -0
- kiri_cli/audit_join_cmd.py +160 -0
- kiri_cli/auto_cmd.py +97 -0
- kiri_cli/azure_cmd.py +243 -0
- kiri_cli/catalog.py +1293 -0
- kiri_cli/cli_reference_live.py +147 -0
- kiri_cli/compile_cmd.py +493 -0
- kiri_cli/compliance_cmd.py +221 -0
- kiri_cli/contract_approval_cmd.py +94 -0
- kiri_cli/contract_cmd.py +64 -0
- kiri_cli/contract_codeowners_cmd.py +162 -0
- kiri_cli/contract_diff.py +111 -0
- kiri_cli/contract_emit.py +90 -0
- kiri_cli/contract_export.py +192 -0
- kiri_cli/contract_lint.py +1505 -0
- kiri_cli/contract_merge_cmd.py +264 -0
- kiri_cli/contract_new.py +326 -0
- kiri_cli/contract_promote.py +216 -0
- kiri_cli/contract_scaffold_cmd.py +134 -0
- kiri_cli/contract_schema_export.py +69 -0
- kiri_cli/contract_show.py +150 -0
- kiri_cli/contract_stubs.py +60 -0
- kiri_cli/contract_sync_from_live.py +327 -0
- kiri_cli/contract_validate.py +154 -0
- kiri_cli/contract_verify_live.py +242 -0
- kiri_cli/cost_cmd.py +243 -0
- kiri_cli/databricks_health.py +1254 -0
- kiri_cli/databricks_setup_cmd.py +712 -0
- kiri_cli/discover_cmd.py +600 -0
- kiri_cli/docs_cmd.py +35 -0
- kiri_cli/doctor_cmd.py +145 -0
- kiri_cli/dv_cmd.py +814 -0
- kiri_cli/dv_conformance_cmd.py +154 -0
- kiri_cli/dv_install.py +493 -0
- kiri_cli/dv_migrate_cmd.py +183 -0
- kiri_cli/dv_test_cmd.py +175 -0
- kiri_cli/federation_cmd.py +315 -0
- kiri_cli/fk_cmd.py +160 -0
- kiri_cli/flows_cmd.py +910 -0
- kiri_cli/governance_cmd.py +358 -0
- kiri_cli/incident_api_cmd.py +180 -0
- kiri_cli/incident_cmd.py +410 -0
- kiri_cli/incident_setup_cmd.py +239 -0
- kiri_cli/ingest.py +427 -0
- kiri_cli/ingest_discover.py +480 -0
- kiri_cli/ingest_new_source.py +386 -0
- kiri_cli/intent_cmd.py +122 -0
- kiri_cli/invariants_cmd.py +118 -0
- kiri_cli/kiri_banner.py +161 -0
- kiri_cli/kpi_cmd.py +222 -0
- kiri_cli/layer_cmd.py +464 -0
- kiri_cli/lineage.py +601 -0
- kiri_cli/main.py +2472 -0
- kiri_cli/maturity_cmd.py +222 -0
- kiri_cli/migrate_cmd.py +1347 -0
- kiri_cli/onboard_cmd.py +223 -0
- kiri_cli/pack_cmd.py +496 -0
- kiri_cli/pipeline_cmd.py +127 -0
- kiri_cli/pre_classify_cmd.py +274 -0
- kiri_cli/project_cmd.py +592 -0
- kiri_cli/py.typed +0 -0
- kiri_cli/quality_cmd.py +295 -0
- kiri_cli/rbac.py +139 -0
- kiri_cli/rbac_cmd.py +291 -0
- kiri_cli/release.py +850 -0
- kiri_cli/release_semver.py +265 -0
- kiri_cli/repo_cmd.py +155 -0
- kiri_cli/retention_cmd.py +333 -0
- kiri_cli/sample_data/__init__.py +12 -0
- kiri_cli/sample_data/customers.contract.yml +47 -0
- kiri_cli/sample_data/customers.csv +25 -0
- kiri_cli/scaffold_cmd.py +721 -0
- kiri_cli/schema_cmd.py +185 -0
- kiri_cli/semantic_cmd.py +605 -0
- kiri_cli/silver_cmd.py +348 -0
- kiri_cli/skills_cmd.py +243 -0
- kiri_cli/skills_data/README.md +51 -0
- kiri_cli/skills_data/add-source/SKILL.md +116 -0
- kiri_cli/skills_data/azure-deploy/SKILL.md +261 -0
- kiri_cli/skills_data/azure-status/SKILL.md +111 -0
- kiri_cli/skills_data/compliance-check/SKILL.md +99 -0
- kiri_cli/skills_data/databricks-build-pipeline/SKILL.md +112 -0
- kiri_cli/skills_data/databricks-project-setup/SKILL.md +157 -0
- kiri_cli/skills_data/debug-apply/SKILL.md +186 -0
- kiri_cli/skills_data/draft-gold-model/SKILL.md +203 -0
- kiri_cli/skills_data/explain-kpi/SKILL.md +158 -0
- kiri_cli/skills_data/incident-coach/SKILL.md +135 -0
- kiri_cli/skills_data/incident-setup/SKILL.md +112 -0
- kiri_cli/skills_data/maturity-coach/SKILL.md +120 -0
- kiri_cli/skills_data/promote-contract/SKILL.md +91 -0
- kiri_cli/skills_data/review-attributes/SKILL.md +94 -0
- kiri_cli/sla_cmd.py +769 -0
- kiri_cli/source_cmd.py +909 -0
- kiri_cli/templates/__init__.py +6 -0
- kiri_cli/templates/dv4dbt_packages.yml.j2 +11 -0
- kiri_cli/upgrade_cmd.py +896 -0
- kiri_cli/use_cmd.py +120 -0
- kiri_cli/vault_cmd.py +241 -0
- kiri_cli/verify_generated.py +241 -0
- kiri_cli-1.0.0b3.dist-info/METADATA +212 -0
- kiri_cli-1.0.0b3.dist-info/RECORD +743 -0
- kiri_cli-1.0.0b3.dist-info/WHEEL +4 -0
- kiri_cli-1.0.0b3.dist-info/entry_points.txt +4 -0
- kiri_compliance/__init__.py +33 -0
- kiri_compliance/audit_coverage.py +354 -0
- kiri_compliance/controls.py +763 -0
- kiri_compliance/evidence.py +155 -0
- kiri_compliance/py.typed +0 -0
- kiri_compliance/quality_engine.py +246 -0
- kiri_compliance/renderer.py +195 -0
- kiri_compliance/report.py +44 -0
- kiri_compliance/schema.py +129 -0
- kiri_core/__init__.py +185 -0
- kiri_core/adapter_surfaces.py +149 -0
- kiri_core/ai_query_gate/__init__.py +92 -0
- kiri_core/ai_query_gate/audit.py +153 -0
- kiri_core/ai_query_gate/evaluator.py +484 -0
- kiri_core/ai_query_gate/models.py +162 -0
- kiri_core/ai_query_gate/parser.py +436 -0
- kiri_core/ai_query_gate/resolver.py +274 -0
- kiri_core/ai_query_gate/store.py +234 -0
- kiri_core/ai_query_gate/store_postgres.py +409 -0
- kiri_core/api_keys.py +243 -0
- kiri_core/apply/__init__.py +27 -0
- kiri_core/apply/conformance.py +99 -0
- kiri_core/apply/incident_router.py +176 -0
- kiri_core/apply/models.py +140 -0
- kiri_core/apply/reporter.py +46 -0
- kiri_core/apply/runner.py +3083 -0
- kiri_core/apply_log_store.py +155 -0
- kiri_core/approval_flow/__init__.py +133 -0
- kiri_core/approval_flow/integration.py +156 -0
- kiri_core/approval_flow/models.py +443 -0
- kiri_core/approval_flow/notifier.py +263 -0
- kiri_core/approval_flow/orchestrator.py +660 -0
- kiri_core/approval_flow/reconcile_signoff.py +53 -0
- kiri_core/approval_flow/store.py +220 -0
- kiri_core/approval_flow/store_jsonl.py +217 -0
- kiri_core/approval_flow/store_postgres.py +580 -0
- kiri_core/approval_policy.py +116 -0
- kiri_core/asgi_trace.py +160 -0
- kiri_core/compliance.py +802 -0
- kiri_core/cutover_approval.py +187 -0
- kiri_core/discovery/__init__.py +109 -0
- kiri_core/discovery/ai/__init__.py +37 -0
- kiri_core/discovery/ai/_payload.py +115 -0
- kiri_core/discovery/ai/budget.py +83 -0
- kiri_core/discovery/ai/classify.py +223 -0
- kiri_core/discovery/ai/describe.py +200 -0
- kiri_core/discovery/ai/errors.py +49 -0
- kiri_core/discovery/discover.py +648 -0
- kiri_core/discovery/heuristics/__init__.py +42 -0
- kiri_core/discovery/heuristics/classification.py +174 -0
- kiri_core/discovery/heuristics/medallion.py +157 -0
- kiri_core/discovery/heuristics/pii.py +92 -0
- kiri_core/discovery/introspector.py +636 -0
- kiri_core/discovery/introspector_databricks.py +396 -0
- kiri_core/discovery/introspector_postgres.py +394 -0
- kiri_core/discovery/introspector_snowflake.py +383 -0
- kiri_core/discovery/reporting/__init__.py +17 -0
- kiri_core/discovery/reporting/discovery_report.py +190 -0
- kiri_core/discovery/scaffold/__init__.py +41 -0
- kiri_core/discovery/scaffold/contract_writer.py +632 -0
- kiri_core/discovery/scaffold/provenance_overlay.py +143 -0
- kiri_core/discovery/scaffold/source_writer.py +211 -0
- kiri_core/discovery/scaffold/todo_marker.py +98 -0
- kiri_core/domain_policy.py +122 -0
- kiri_core/goal_coverage.py +82 -0
- kiri_core/gold_registry.py +211 -0
- kiri_core/health.py +192 -0
- kiri_core/incident.py +204 -0
- kiri_core/incident_builders.py +256 -0
- kiri_core/incident_log.py +418 -0
- kiri_core/intent/__init__.py +46 -0
- kiri_core/intent/agent_runner.py +82 -0
- kiri_core/intent/project_dv.py +261 -0
- kiri_core/intent/propose.py +132 -0
- kiri_core/intent/semantic_intent.py +446 -0
- kiri_core/invariants.py +301 -0
- kiri_core/kpi.py +373 -0
- kiri_core/latency.py +150 -0
- kiri_core/lifecycle/__init__.py +555 -0
- kiri_core/lifecycle/ai_index.py +453 -0
- kiri_core/lifecycle/auto_mode.py +172 -0
- kiri_core/lifecycle/bootstrap.py +316 -0
- kiri_core/lifecycle/cli_reference.py +1064 -0
- kiri_core/lifecycle/commit.py +258 -0
- kiri_core/lifecycle/compile_dispatch.py +296 -0
- kiri_core/lifecycle/compile_range.py +665 -0
- kiri_core/lifecycle/contract_inventory.py +354 -0
- kiri_core/lifecycle/contracts.py +250 -0
- kiri_core/lifecycle/doctor.py +412 -0
- kiri_core/lifecycle/git_remote.py +948 -0
- kiri_core/lifecycle/identity.py +147 -0
- kiri_core/lifecycle/ingestion_modes.py +215 -0
- kiri_core/lifecycle/layer_contract.py +452 -0
- kiri_core/lifecycle/layer_contract_migrate.py +969 -0
- kiri_core/lifecycle/layer_contract_parser.py +436 -0
- kiri_core/lifecycle/layer_contract_validator.py +2291 -0
- kiri_core/lifecycle/layout.py +298 -0
- kiri_core/lifecycle/onboarding.py +306 -0
- kiri_core/lifecycle/openlineage_emit.py +153 -0
- kiri_core/lifecycle/patch.py +231 -0
- kiri_core/lifecycle/plan.py +241 -0
- kiri_core/lifecycle/probe.py +204 -0
- kiri_core/lifecycle/project_context.py +334 -0
- kiri_core/lifecycle/project_status.py +532 -0
- kiri_core/lifecycle/scaffold.py +455 -0
- kiri_core/lifecycle/source_grouping.py +725 -0
- kiri_core/lifecycle/source_introspection.py +488 -0
- kiri_core/lifecycle/source_inventory.py +561 -0
- kiri_core/lifecycle/source_patch.py +353 -0
- kiri_core/lifecycle/sources.py +470 -0
- kiri_core/lifecycle/sync_from_live.py +727 -0
- kiri_core/lifecycle/validate.py +116 -0
- kiri_core/lifecycle/verify_annotations.py +391 -0
- kiri_core/lifecycle/verify_live.py +389 -0
- kiri_core/lifecycle/verify_metric_views.py +321 -0
- kiri_core/lifecycle/watermark.py +371 -0
- kiri_core/lifecycle/watermark_apply.py +656 -0
- kiri_core/lineage.py +418 -0
- kiri_core/loader.py +377 -0
- kiri_core/logging.py +374 -0
- kiri_core/maturity.py +592 -0
- kiri_core/migration_layer_policy.py +285 -0
- kiri_core/names.py +163 -0
- kiri_core/observed_lineage.py +62 -0
- kiri_core/odcs/__init__.py +15 -0
- kiri_core/otel_bootstrap.py +326 -0
- kiri_core/pipeline_ir.py +332 -0
- kiri_core/pipeline_loader.py +82 -0
- kiri_core/planning.py +110 -0
- kiri_core/project.py +1576 -0
- kiri_core/project_docs.py +199 -0
- kiri_core/py.typed +0 -0
- kiri_core/rate_limit.py +456 -0
- kiri_core/release_state/__init__.py +37 -0
- kiri_core/release_state/models.py +108 -0
- kiri_core/release_state/store.py +634 -0
- kiri_core/resilience/__init__.py +288 -0
- kiri_core/resilience/retry.py +173 -0
- kiri_core/runtime.py +40 -0
- kiri_core/schema/__init__.py +62 -0
- kiri_core/schema/column.py +140 -0
- kiri_core/schema/ingestion.py +665 -0
- kiri_core/schema/model.py +44 -0
- kiri_core/schema/reporting_goal.py +160 -0
- kiri_core/schema/source.py +188 -0
- kiri_core/silver/__init__.py +148 -0
- kiri_core/silver/apply_runner.py +830 -0
- kiri_core/silver/backfill.py +280 -0
- kiri_core/silver/dv_backend.py +426 -0
- kiri_core/silver/dv_graph.py +932 -0
- kiri_core/silver/dv_graph_mermaid.py +443 -0
- kiri_core/silver/dv_graph_payload.py +79 -0
- kiri_core/silver/dv_graph_views.py +416 -0
- kiri_core/silver/graph_projector.py +566 -0
- kiri_core/silver/hash_canonicaliser.py +172 -0
- kiri_core/silver/hash_policy.py +195 -0
- kiri_core/silver/load_ts_policy.py +202 -0
- kiri_core/silver/schema_evolution.py +410 -0
- kiri_core/silver/zone_rules.py +81 -0
- kiri_core/sizing.py +134 -0
- kiri_core/storage_paths.py +130 -0
- kiri_core/technique_validator.py +700 -0
- kiri_core/trace.py +409 -0
- kiri_core/types.py +41 -0
- kiri_core/urn.py +161 -0
- kiri_dbt_bridge/__init__.py +22 -0
- kiri_dbt_bridge/_schema_resolve.py +38 -0
- kiri_dbt_bridge/bridge.py +94 -0
- kiri_dbt_bridge/dbt.manifest.yml +26 -0
- kiri_dbt_bridge/dv4dbt/__init__.py +143 -0
- kiri_dbt_bridge/dv4dbt/backend.py +204 -0
- kiri_dbt_bridge/dv4dbt/bridge_generator.py +289 -0
- kiri_dbt_bridge/dv4dbt/compat.py +232 -0
- kiri_dbt_bridge/dv4dbt/hub_generator.py +282 -0
- kiri_dbt_bridge/dv4dbt/link_generator.py +306 -0
- kiri_dbt_bridge/dv4dbt/manifest.py +203 -0
- kiri_dbt_bridge/dv4dbt/pit_generator.py +318 -0
- kiri_dbt_bridge/dv4dbt/policy_translation.py +249 -0
- kiri_dbt_bridge/dv4dbt/project_writer.py +588 -0
- kiri_dbt_bridge/dv4dbt/ref_generator.py +404 -0
- kiri_dbt_bridge/dv4dbt/render.py +180 -0
- kiri_dbt_bridge/dv4dbt/sat_generator.py +480 -0
- kiri_dbt_bridge/dv4dbt/sat_variant_generator.py +529 -0
- kiri_dbt_bridge/dv4dbt/templates/__init__.py +16 -0
- kiri_dbt_bridge/dv4dbt/templates/bridge.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/dbt_project.yml.j2 +37 -0
- kiri_dbt_bridge/dv4dbt/templates/hub.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/link.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/packages.yml.j2 +10 -0
- kiri_dbt_bridge/dv4dbt/templates/pit.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/ref_historised.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/ref_non_historised.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat_effectivity.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat_multi_active.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat_non_historised.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat_record_tracking.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat_virtual_end_dated.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sources.yml.j2 +39 -0
- kiri_dbt_bridge/framework_adapter.py +310 -0
- kiri_dbt_bridge/generate.py +372 -0
- kiri_dbt_bridge/gold/__init__.py +21 -0
- kiri_dbt_bridge/gold/_sql_emit.py +128 -0
- kiri_dbt_bridge/gold/assemble.py +90 -0
- kiri_dbt_bridge/gold/canonical_columns.py +168 -0
- kiri_dbt_bridge/gold/computed_expressions.py +179 -0
- kiri_dbt_bridge/gold/dialects/__init__.py +69 -0
- kiri_dbt_bridge/gold/dialects/databricks.py +150 -0
- kiri_dbt_bridge/gold/dialects/duckdb.py +120 -0
- kiri_dbt_bridge/gold/dialects/trino.py +136 -0
- kiri_dbt_bridge/gold/dim_generator.py +688 -0
- kiri_dbt_bridge/gold/dimension_refs.py +255 -0
- kiri_dbt_bridge/gold/dispatch.py +69 -0
- kiri_dbt_bridge/gold/fact_generator.py +534 -0
- kiri_dbt_bridge/gold/flat_generator.py +144 -0
- kiri_dbt_bridge/gold/lineage.py +484 -0
- kiri_dbt_bridge/gold/localized_twins.py +218 -0
- kiri_dbt_bridge/gold/measures.py +49 -0
- kiri_dbt_bridge/gold/role_playing.py +75 -0
- kiri_dbt_bridge/gold/surrogate_key.py +114 -0
- kiri_dbt_bridge/macros/kiri_scd1_dim.sql +57 -0
- kiri_dbt_bridge/macros/kiri_scd2_dim.sql +188 -0
- kiri_dbt_bridge/macros/kiri_sha256.sql +32 -0
- kiri_dbt_bridge/macros/kiri_string_cast.sql +37 -0
- kiri_dbt_bridge/macros/kiri_surrogate_key.sql +43 -0
- kiri_dbt_bridge/profiles.py +162 -0
- kiri_dbt_bridge/py.typed +0 -0
- kiri_dbt_bridge/safe_ident.py +44 -0
- kiri_dbt_bridge/silver_conformance.py +188 -0
- kiri_dbt_bridge/silver_kimball/__init__.py +27 -0
- kiri_dbt_bridge/silver_kimball/assemble.py +174 -0
- kiri_dbt_bridge/silver_kimball/dim_generator.py +602 -0
- kiri_dbt_bridge/silver_kimball/dispatch.py +86 -0
- kiri_dbt_bridge/silver_kimball/fact_generator.py +320 -0
- kiri_dbt_bridge/silver_kimball/flat_generator.py +156 -0
- kiri_dbt_bridge/silver_kimball/lineage.py +464 -0
- kiri_dbt_bridge/silver_kimball/watermark_window.py +199 -0
- kiri_dbt_bridge/silver_kimball/zone.py +68 -0
- kiri_dbt_bridge/silver_scd2_curated/__init__.py +46 -0
- kiri_dbt_bridge/silver_scd2_curated/assemble.py +213 -0
- kiri_dbt_bridge/silver_scd2_curated/curated_generator.py +351 -0
- kiri_dbt_bridge/silver_scd2_curated/standardized_generator.py +261 -0
- kiri_dbt_bridge/silver_scd2_curated/standardized_plan.py +254 -0
- kiri_iceberg/__init__.py +11 -0
- kiri_iceberg/catalog.py +128 -0
- kiri_iceberg/profile.py +131 -0
- kiri_iceberg/py.typed +0 -0
- kiri_ingestion/__init__.py +63 -0
- kiri_ingestion/airbyte_client.py +428 -0
- kiri_ingestion/database_fetcher.py +224 -0
- kiri_ingestion/layout.py +71 -0
- kiri_ingestion/mssql_introspect.py +565 -0
- kiri_ingestion/py.typed +0 -0
- kiri_ingestion/rest_fetcher.py +472 -0
- kiri_ingestion/runner.py +341 -0
- kiri_ingestion/schema_drift.py +222 -0
- kiri_ingestion/storage.py +621 -0
- kiri_mcp/__init__.py +5 -0
- kiri_mcp/audit.py +99 -0
- kiri_mcp/audit_decorators.py +252 -0
- kiri_mcp/main.py +22 -0
- kiri_mcp/py.typed +0 -0
- kiri_mcp/rbac.py +110 -0
- kiri_mcp/server.py +766 -0
- kiri_mcp/skills.py +297 -0
- kiri_mcp/tools/__init__.py +70 -0
- kiri_mcp/tools/_shared.py +58 -0
- kiri_mcp/tools/adapter.py +150 -0
- kiri_mcp/tools/cli_reference.py +59 -0
- kiri_mcp/tools/compliance.py +119 -0
- kiri_mcp/tools/contract.py +374 -0
- kiri_mcp/tools/dv.py +62 -0
- kiri_mcp/tools/federation.py +152 -0
- kiri_mcp/tools/intent.py +71 -0
- kiri_mcp/tools/lineage.py +204 -0
- kiri_mcp/tools/lineage_drift.py +75 -0
- kiri_mcp/tools/plan.py +66 -0
- kiri_mcp/tools/project.py +311 -0
- kiri_migration/__init__.py +56 -0
- kiri_migration/name_preservation.py +271 -0
- kiri_migration/predeploy.py +311 -0
- kiri_migration/py.typed +0 -0
- kiri_migration/reconcile.py +1891 -0
- kiri_migration/scaffold/__init__.py +90 -0
- kiri_migration/scaffold/_audit_columns.py +74 -0
- kiri_migration/scaffold/_classify.py +100 -0
- kiri_migration/scaffold/_typemap.py +71 -0
- kiri_migration/scaffold/_yaml.py +33 -0
- kiri_migration/scaffold/bronze.py +279 -0
- kiri_migration/scaffold/compile_targets/__init__.py +271 -0
- kiri_migration/scaffold/compile_targets/_common.py +193 -0
- kiri_migration/scaffold/compile_targets/copy_into.py +33 -0
- kiri_migration/scaffold/compile_targets/dlt.py +125 -0
- kiri_migration/scaffold/compile_targets/dlt_with_append_flows.py +163 -0
- kiri_migration/scaffold/compile_targets/dlt_with_apply_changes.py +192 -0
- kiri_migration/scaffold/compile_targets/dlt_with_expectations.py +276 -0
- kiri_migration/scaffold/compile_targets/layer_dispatch.py +258 -0
- kiri_migration/scaffold/compile_targets/sql_full_snapshot.py +385 -0
- kiri_migration/scaffold/compile_targets/streaming_table.py +37 -0
- kiri_migration/scaffold/mart.py +234 -0
- kiri_migration/scaffold/plans/__init__.py +49 -0
- kiri_migration/scaffold/plans/_registry.py +308 -0
- kiri_migration/scaffold/plans/minimal.py +550 -0
- kiri_migration/scaffold/plans/plan_b_layer.py +640 -0
- kiri_migration/scaffold/silver_from_marts.py +256 -0
- kiri_migration/scaffold/silver_pattern.py +451 -0
- kiri_migration/sp_translator.py +398 -0
- kiri_odcs/LICENSE +204 -0
- kiri_odcs/__init__.py +234 -0
- kiri_odcs/ai_trust_tier.py +475 -0
- kiri_odcs/canonical.py +263 -0
- kiri_odcs/diff.py +63 -0
- kiri_odcs/errors.py +51 -0
- kiri_odcs/invariant_sql.py +136 -0
- kiri_odcs/kiri_bridge.py +71 -0
- kiri_odcs/kiri_ext.py +3166 -0
- kiri_odcs/kiri_fk.py +447 -0
- kiri_odcs/kiri_pit.py +121 -0
- kiri_odcs/kiri_ref.py +57 -0
- kiri_odcs/openlineage_adapter.py +105 -0
- kiri_odcs/parser.py +1213 -0
- kiri_odcs/pii_guardrails.py +159 -0
- kiri_odcs/py.typed +0 -0
- kiri_odcs/schema_export.py +223 -0
- kiri_openlineage/__init__.py +58 -0
- kiri_openlineage/builder.py +146 -0
- kiri_openlineage/cost.py +190 -0
- kiri_openlineage/emitter.py +165 -0
- kiri_openlineage/py.typed +0 -0
- kiri_openlineage/schema.py +121 -0
- kiri_packs/__init__.py +67 -0
- kiri_packs/installer.py +500 -0
- kiri_packs/manifest.py +134 -0
- kiri_packs/publisher.py +605 -0
- kiri_packs/py.typed +0 -0
- kiri_packs/validator.py +295 -0
- kiri_quality/__init__.py +108 -0
- kiri_quality/ai_audit_correlator.py +293 -0
- kiri_quality/ai_audit_correlator_apply.py +526 -0
- kiri_quality/conformance_evidence.py +272 -0
- kiri_quality/dbt_test_collector.py +214 -0
- kiri_quality/dq_codegen.py +300 -0
- kiri_quality/dv_checks.py +393 -0
- kiri_quality/dv_column_lineage.py +279 -0
- kiri_quality/dv_column_lineage_apply.py +856 -0
- kiri_quality/dv_conformance.py +841 -0
- kiri_quality/dv_fixture_runner.py +478 -0
- kiri_quality/dv_invariants.py +986 -0
- kiri_quality/dv_kiri.py +97 -0
- kiri_quality/dv_markdown.py +169 -0
- kiri_quality/dv_observed_uc_reader.py +187 -0
- kiri_quality/dv_runner.py +389 -0
- kiri_quality/evidence_status.py +337 -0
- kiri_quality/fk_checks.py +800 -0
- kiri_quality/fk_ddl.py +402 -0
- kiri_quality/fk_pit_contract.py +168 -0
- kiri_quality/fk_runner.py +950 -0
- kiri_quality/kernel.py +376 -0
- kiri_quality/pii.py +60 -0
- kiri_quality/pre_classify/__init__.py +55 -0
- kiri_quality/pre_classify/engine.py +346 -0
- kiri_quality/pre_classify/locale_packs/en.yaml +117 -0
- kiri_quality/pre_classify/locale_packs/sv.yaml +73 -0
- kiri_quality/pre_classify/merge.py +297 -0
- kiri_quality/pre_classify/model.py +163 -0
- kiri_quality/py.typed +0 -0
- kiri_quality/reject_lane.py +261 -0
- kiri_quality/rule_runner.py +565 -0
- kiri_quality/schema_evolution_check.py +88 -0
- kiri_quality/silver_quality.py +494 -0
- kiri_quality/silver_quality_markdown.py +187 -0
- kiri_quality/uc_security_compile.py +951 -0
- kiri_quality/uc_security_compile_apply.py +1186 -0
- kiri_rbac/__init__.py +87 -0
- kiri_rbac/access.py +371 -0
- kiri_rbac/loader.py +104 -0
- kiri_rbac/pr_gate.py +213 -0
- kiri_rbac/providers/__init__.py +29 -0
- kiri_rbac/providers/_jwt.py +121 -0
- kiri_rbac/providers/azure_ad.py +93 -0
- kiri_rbac/providers/base.py +107 -0
- kiri_rbac/providers/factory.py +48 -0
- kiri_rbac/providers/github.py +134 -0
- kiri_rbac/providers/oidc_generic.py +99 -0
- kiri_rbac/py.typed +0 -0
- kiri_rbac/resolver.py +118 -0
- kiri_rbac/schema.py +219 -0
- kiri_schema_registry/__init__.py +22 -0
- kiri_schema_registry/avro.py +127 -0
- kiri_schema_registry/base.py +71 -0
- kiri_schema_registry/confluent.py +184 -0
- kiri_schema_registry/glue.py +178 -0
- kiri_schema_registry/py.typed +0 -0
- kiri_slack_bot/__init__.py +24 -0
- kiri_slack_bot/handler.py +248 -0
- kiri_slack_bot/py.typed +0 -0
- kiri_slack_bot/queries.py +90 -0
- kiri_slack_bot/signature.py +94 -0
- kiri_vault/__init__.py +59 -0
- kiri_vault/audit.py +64 -0
- kiri_vault/aws_vault.py +123 -0
- kiri_vault/azure_vault.py +236 -0
- kiri_vault/boundaries.py +219 -0
- kiri_vault/client.py +54 -0
- kiri_vault/databricks_vault.py +292 -0
- kiri_vault/env_vault.py +77 -0
- kiri_vault/py.typed +0 -0
- kiri_vault/ref.py +84 -0
- kirimana_automation/__init__.py +0 -0
- kirimana_automation/business_calendar.py +179 -0
- kirimana_automation/dag_snapshot.py +252 -0
- kirimana_automation/db/__init__.py +5 -0
- kirimana_automation/db/migrate.py +571 -0
- kirimana_automation/db/migrations/0001_release_state.down.duckdb.sql +29 -0
- kirimana_automation/db/migrations/0001_release_state.down.postgres.sql +37 -0
- kirimana_automation/db/migrations/0001_release_state.duckdb.sql +170 -0
- kirimana_automation/db/migrations/0001_release_state.postgres.sql +176 -0
- kirimana_automation/db/migrations/0002_rate_limit_buckets.down.duckdb.sql +9 -0
- kirimana_automation/db/migrations/0002_rate_limit_buckets.down.postgres.sql +9 -0
- kirimana_automation/db/migrations/0002_rate_limit_buckets.duckdb.sql +14 -0
- kirimana_automation/db/migrations/0002_rate_limit_buckets.postgres.sql +18 -0
- kirimana_automation/db/migrations/0003_flow_runs.down.duckdb.sql +18 -0
- kirimana_automation/db/migrations/0003_flow_runs.down.postgres.sql +19 -0
- kirimana_automation/db/migrations/0003_flow_runs.duckdb.sql +85 -0
- kirimana_automation/db/migrations/0003_flow_runs.postgres.sql +102 -0
- kirimana_automation/db/migrations/0004_flow_dag_snapshots.down.duckdb.sql +11 -0
- kirimana_automation/db/migrations/0004_flow_dag_snapshots.down.postgres.sql +11 -0
- kirimana_automation/db/migrations/0004_flow_dag_snapshots.duckdb.sql +15 -0
- kirimana_automation/db/migrations/0004_flow_dag_snapshots.postgres.sql +31 -0
- kirimana_automation/db/migrations/0005_calendar_skip.down.duckdb.sql +109 -0
- kirimana_automation/db/migrations/0005_calendar_skip.down.postgres.sql +42 -0
- kirimana_automation/db/migrations/0005_calendar_skip.duckdb.sql +113 -0
- kirimana_automation/db/migrations/0005_calendar_skip.postgres.sql +42 -0
- kirimana_automation/db/migrations/0006_flow_apply_log.down.duckdb.sql +9 -0
- kirimana_automation/db/migrations/0006_flow_apply_log.down.postgres.sql +11 -0
- kirimana_automation/db/migrations/0006_flow_apply_log.duckdb.sql +45 -0
- kirimana_automation/db/migrations/0006_flow_apply_log.postgres.sql +49 -0
- kirimana_automation/db/migrations/0007_flow_task_error_redacted.down.duckdb.sql +14 -0
- kirimana_automation/db/migrations/0007_flow_task_error_redacted.down.postgres.sql +2 -0
- kirimana_automation/db/migrations/0007_flow_task_error_redacted.duckdb.sql +7 -0
- kirimana_automation/db/migrations/0007_flow_task_error_redacted.postgres.sql +11 -0
- kirimana_automation/db/migrations/README.md +84 -0
- kirimana_automation/flow_loader.py +220 -0
- kirimana_automation/freeze.py +133 -0
- kirimana_automation/kiri_recommender.py +313 -0
- kirimana_automation/main.py +195 -0
- kirimana_automation/py.typed +0 -0
- kirimana_automation/run_history.py +593 -0
- kirimana_automation/run_history_analysis.py +607 -0
- kirimana_automation/schedule.py +343 -0
- kirimana_automation/schedule_validator.py +572 -0
- kirimana_automation/workflow_compiler.py +1288 -0
|
@@ -0,0 +1,2980 @@
|
|
|
1
|
+
"""Databricks adapter.
|
|
2
|
+
|
|
3
|
+
Talks to Databricks via the Workspace SDK + Statement Execution API.
|
|
4
|
+
|
|
5
|
+
Two ingestion modes, matching the table's metadata:
|
|
6
|
+
|
|
7
|
+
- **fixture_file** — legacy demo path. A single CSV per table is uploaded to a
|
|
8
|
+
Unity Catalog volume and appended via ``COPY INTO``.
|
|
9
|
+
- **landing_zone** — the production path (ADR 0008 + 0009 + 0046). Files
|
|
10
|
+
reach the landing storage primitive (ADLS in production, LocalLandingStorage
|
|
11
|
+
in dev/tests). Per file we ``move_to_raw`` (atomic on HNS-enabled ADLS)
|
|
12
|
+
and run a native SQL ``MERGE`` with ``_kiri_*`` columns injected by a
|
|
13
|
+
SELECT wrapper. The warehouse reads bytes directly from the storage URL
|
|
14
|
+
(``read_files('abfss://...')``) — no pod-mediated upload step.
|
|
15
|
+
|
|
16
|
+
Required environment / profile (landing_zone):
|
|
17
|
+
- ``DATABRICKS_HOST`` (profile ``host`` overrides)
|
|
18
|
+
- ``DATABRICKS_TOKEN``
|
|
19
|
+
- ``DATABRICKS_WAREHOUSE_ID`` or profile ``warehouse_id``
|
|
20
|
+
- profile ``catalog`` (default: ``main``)
|
|
21
|
+
- profile ``ingest_volume`` (default: ``main.ops.ingest`` — used by
|
|
22
|
+
fixture mode only)
|
|
23
|
+
- profile ``landing_storage`` block (ADR 0046 phase 2) — points at ADLS in
|
|
24
|
+
production. When omitted, the adapter falls back to
|
|
25
|
+
:class:`LocalLandingStorage` for development; warehouse-side read of
|
|
26
|
+
``file://`` URLs requires a colocated runtime, so production deploys
|
|
27
|
+
must configure the block.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import time
|
|
37
|
+
import uuid
|
|
38
|
+
from contextlib import suppress
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from datetime import UTC, date, datetime
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
43
|
+
|
|
44
|
+
from kiri_core.ai_query_gate.audit import _redact_preview
|
|
45
|
+
from kiri_core.schema import IngestionMode, Source
|
|
46
|
+
from kiri_core.trace import current_trace
|
|
47
|
+
from kiri_core.types import HealthStatus, RunStatus
|
|
48
|
+
|
|
49
|
+
from kiri_adapters.base import (
|
|
50
|
+
ApplyLogEntry,
|
|
51
|
+
AssetWatermark,
|
|
52
|
+
ColumnDescription,
|
|
53
|
+
Credential,
|
|
54
|
+
IngestionPlan,
|
|
55
|
+
LiveColumnAnnotation,
|
|
56
|
+
LiveTableAnnotations,
|
|
57
|
+
NamingLockError,
|
|
58
|
+
PlatformAdapter,
|
|
59
|
+
ProjectManifest,
|
|
60
|
+
ProjectTargetCollisionError,
|
|
61
|
+
QueryResult,
|
|
62
|
+
ReleaseManifestEntry,
|
|
63
|
+
RetentionAction,
|
|
64
|
+
RetentionReport,
|
|
65
|
+
RunResult,
|
|
66
|
+
StorageResolution,
|
|
67
|
+
TableDescription,
|
|
68
|
+
TableRef,
|
|
69
|
+
WatermarkStoreError,
|
|
70
|
+
)
|
|
71
|
+
from kiri_adapters.sql_dialect import DeltaDialect, SqlDialect
|
|
72
|
+
|
|
73
|
+
if TYPE_CHECKING: # pragma: no cover — type-only to avoid import cost at runtime
|
|
74
|
+
from kiri_odcs.kiri_ext import StorageBinding
|
|
75
|
+
|
|
76
|
+
_logger = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
DEFAULT_CATALOG = "main"
|
|
79
|
+
DEFAULT_INGEST_VOLUME = "main.ops.ingest"
|
|
80
|
+
|
|
81
|
+
# ADR 0014 / 0017: reserved schema + tables for governance metadata.
|
|
82
|
+
# Live under the project's Unity Catalog, keeping all kiri.* state
|
|
83
|
+
# under one ACL boundary.
|
|
84
|
+
_META_SCHEMA = "_kiri_meta"
|
|
85
|
+
_MANIFEST_TABLE = "_kiri_project_manifest"
|
|
86
|
+
_APPLY_LOG_TABLE = "_kiri_apply_log"
|
|
87
|
+
# ADR 0041: release-manifest mirror — three tables alongside the apply log.
|
|
88
|
+
_RELEASE_STATE_TABLE = "_kiri_release_state"
|
|
89
|
+
_ACTIVE_CONTRACT_VERSIONS_TABLE = "_kiri_active_contract_versions"
|
|
90
|
+
_PROMOTION_HISTORY_TABLE = "_kiri_promotion_history"
|
|
91
|
+
# ADR 0080: asset-level watermark store — one row per (asset_urn, target_env).
|
|
92
|
+
_ASSET_WATERMARKS_TABLE = "_kiri_asset_watermarks"
|
|
93
|
+
|
|
94
|
+
_TRUTHY_AUDIT = {"1", "on", "true"}
|
|
95
|
+
_FALSY_AUDIT = {"0", "off", "false"}
|
|
96
|
+
_TRACE_COMMENT_PREFIX = "/* kiri_trace_id="
|
|
97
|
+
_SQL_TOKEN_RE = re.compile(
|
|
98
|
+
r"`(?:``|[^`])+`|\"(?:\"\"|[^\"])+\"|'(?:''|[^'])+'|[A-Za-z_][\w$]*|[.,();]"
|
|
99
|
+
)
|
|
100
|
+
_WITH_TOP_LEVEL_VERBS = {"SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "CREATE"}
|
|
101
|
+
_AUDIT_DISABLED_DESTINATIONS: set[tuple[str, str]] = set()
|
|
102
|
+
_McpAuditStatus = Literal["OK", "FAILED", "DENIED", "success", "failed"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class _AuditPaths:
|
|
107
|
+
jsonl: Path
|
|
108
|
+
line: Path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class _AuditRecord:
|
|
113
|
+
ts: str
|
|
114
|
+
trace_id: str
|
|
115
|
+
apply_id: str | None
|
|
116
|
+
caller: str
|
|
117
|
+
via: str
|
|
118
|
+
user: str
|
|
119
|
+
event: str
|
|
120
|
+
target: str
|
|
121
|
+
duration_ms: int
|
|
122
|
+
attempts: int
|
|
123
|
+
status: str
|
|
124
|
+
warehouse_id: object
|
|
125
|
+
statement_preview: str
|
|
126
|
+
error_class: str | None = None
|
|
127
|
+
reason: str | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---- pure SQL builders (tested in isolation) ------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _quote_ident(name: str) -> str:
|
|
134
|
+
"""Databricks backtick-quoting."""
|
|
135
|
+
return "`" + name.replace("`", "``") + "`"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _fq(catalog: str, schema: str, table: str) -> str:
|
|
139
|
+
return f"{_quote_ident(catalog)}.{_quote_ident(schema)}.{_quote_ident(table)}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_table_or_schema_not_found(exc: BaseException) -> bool:
|
|
143
|
+
"""True when ``exc`` reports a missing table/view/schema (not a real fault).
|
|
144
|
+
|
|
145
|
+
Databricks/Spark surface absence through the SQL error message rather than a
|
|
146
|
+
distinct exception type, so we match the stable error-class tokens
|
|
147
|
+
(``TABLE_OR_VIEW_NOT_FOUND`` / ``SCHEMA_NOT_FOUND`` and the
|
|
148
|
+
``... does not exist`` / ``cannot be found`` phrasings). Used by
|
|
149
|
+
:meth:`read_watermark` to map only genuine absence to ``None`` while every
|
|
150
|
+
other failure raises :class:`WatermarkStoreError` (ADR 0080 §2 — a real read
|
|
151
|
+
error must never look like an empty cursor).
|
|
152
|
+
|
|
153
|
+
Delegates to the cross-dialect :func:`kiri_adapters.base.is_relation_absent_error`
|
|
154
|
+
so the not-found token logic lives in ONE place (shared with the DV freeze
|
|
155
|
+
fail-closed path, ADR 0080 §4); the Databricks-specific narrowing is the
|
|
156
|
+
same set of stable Spark error-class tokens.
|
|
157
|
+
"""
|
|
158
|
+
from kiri_adapters.base import is_relation_absent_error
|
|
159
|
+
|
|
160
|
+
return is_relation_absent_error(exc)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _sql_str_lit(value: str) -> str:
|
|
164
|
+
"""Single-quote a SQL string literal, escaping embedded quotes."""
|
|
165
|
+
return "'" + value.replace("'", "''") + "'"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _render_describe_external_location(name: str) -> str:
|
|
169
|
+
"""ADR 0132 §3 read primitive: ``DESCRIBE EXTERNAL LOCATION `<name>` ``.
|
|
170
|
+
|
|
171
|
+
Resolves the location NAME → its ``url`` + ``credential_name`` (and owner).
|
|
172
|
+
"""
|
|
173
|
+
return f"DESCRIBE EXTERNAL LOCATION {_quote_ident(name)}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _render_create_external_location(name: str, url: str, credential: str) -> str:
|
|
177
|
+
"""ADR 0132 §3 provision DDL: idempotent ``CREATE EXTERNAL LOCATION``.
|
|
178
|
+
|
|
179
|
+
UC requires ``WITH (STORAGE CREDENTIAL <name>)``; the credential name is
|
|
180
|
+
backtick-quoted, the url single-quote-escaped.
|
|
181
|
+
"""
|
|
182
|
+
return (
|
|
183
|
+
f"CREATE EXTERNAL LOCATION IF NOT EXISTS {_quote_ident(name)} "
|
|
184
|
+
f"URL {_sql_str_lit(url)} "
|
|
185
|
+
f"WITH (STORAGE CREDENTIAL {_quote_ident(credential)})"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _render_create_schema_managed_location(catalog: str, schema: str, location: str) -> str:
|
|
190
|
+
"""ADR 0132 §3 provision DDL: idempotent per-schema ``MANAGED LOCATION``."""
|
|
191
|
+
return (
|
|
192
|
+
f"CREATE SCHEMA IF NOT EXISTS "
|
|
193
|
+
f"{_quote_ident(catalog)}.{_quote_ident(schema)} "
|
|
194
|
+
f"MANAGED LOCATION {_sql_str_lit(location)}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# Issue #366 — ADR 0124 renamed meta columns ``_dca_*`` → ``_kiri_*``.
|
|
199
|
+
# A target table created by a pre-rename build still carries the old
|
|
200
|
+
# names; the rewritten ``build_merge_sql`` emits ``_kiri_*`` and
|
|
201
|
+
# ``WHEN MATCHED THEN UPDATE SET *`` expands against the target's
|
|
202
|
+
# columns, so the MERGE fails with ``DELTA_MERGE_UNRESOLVED_EXPRESSION``
|
|
203
|
+
# and the per-file loop swallows it as "0 rows" → silver/gold cascade
|
|
204
|
+
# empty. We detect this state before MERGE and refuse to proceed.
|
|
205
|
+
_STALE_META_PREFIX = "_dca_"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class StaleSchemaError(RuntimeError):
|
|
209
|
+
"""Target table predates the ADR 0124 meta-column rename.
|
|
210
|
+
|
|
211
|
+
Auto-migration via ``ALTER TABLE ... RENAME COLUMN`` was rejected:
|
|
212
|
+
Delta rewrites the file footprint on rename and the operation is
|
|
213
|
+
not rollback-safe. Pre-1.0 the breaking-change rename had no
|
|
214
|
+
upgrade script by design (see ADR 0124); this error surfaces the
|
|
215
|
+
one-time mismatch with an actionable hint instead of letting it
|
|
216
|
+
cascade through silver and gold as empty rows.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(self, target_fq: str, stale_columns: list[str]) -> None:
|
|
220
|
+
self.target_fq = target_fq
|
|
221
|
+
self.stale_columns = list(stale_columns)
|
|
222
|
+
rename_hints = "; ".join(
|
|
223
|
+
f"ALTER TABLE {target_fq} RENAME COLUMN {col} TO "
|
|
224
|
+
f"{col.replace(_STALE_META_PREFIX, '_kiri_', 1)}"
|
|
225
|
+
for col in stale_columns
|
|
226
|
+
)
|
|
227
|
+
super().__init__(
|
|
228
|
+
f"Target {target_fq} has pre-ADR-0124 meta columns "
|
|
229
|
+
f"({', '.join(stale_columns)}). ADR 0124 renamed _dca_* → "
|
|
230
|
+
f"_kiri_*; the MERGE cannot reconcile. Drop and recreate "
|
|
231
|
+
f"the table (DROP TABLE {target_fq}) before re-running "
|
|
232
|
+
f"apply, or rename the columns explicitly: "
|
|
233
|
+
f"{rename_hints}."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _stale_meta_columns_from_describe(
|
|
238
|
+
describe_rows: list[tuple[Any, ...]] | None,
|
|
239
|
+
) -> list[str]:
|
|
240
|
+
"""Return pre-rename ``_dca_*`` column names from ``DESCRIBE TABLE`` output.
|
|
241
|
+
|
|
242
|
+
Databricks' ``DESCRIBE TABLE`` returns ``(col_name, data_type,
|
|
243
|
+
comment)`` rows for each column, then a delimiter (empty
|
|
244
|
+
``col_name``) and a ``# Detailed Table Information`` section.
|
|
245
|
+
Stop at the delimiter — only column rows precede.
|
|
246
|
+
"""
|
|
247
|
+
if not describe_rows:
|
|
248
|
+
return []
|
|
249
|
+
stale: list[str] = []
|
|
250
|
+
for row in describe_rows:
|
|
251
|
+
if not row:
|
|
252
|
+
continue
|
|
253
|
+
col_name = str(row[0])
|
|
254
|
+
# Delimiter row (blank) or section header ("# Detailed Table
|
|
255
|
+
# Information", "# Partition Information", …) marks end of
|
|
256
|
+
# the column list.
|
|
257
|
+
if not col_name.strip() or col_name.startswith("#"):
|
|
258
|
+
break
|
|
259
|
+
if col_name.startswith(_STALE_META_PREFIX):
|
|
260
|
+
stale.append(col_name)
|
|
261
|
+
return stale
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _strip_leading_trace_comment(sql: str) -> str:
|
|
265
|
+
body = sql.strip()
|
|
266
|
+
if body.startswith(_TRACE_COMMENT_PREFIX):
|
|
267
|
+
end = body.find("*/")
|
|
268
|
+
if end != -1:
|
|
269
|
+
return body[end + 2 :].strip()
|
|
270
|
+
return body
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _normalise_identifier(token: str) -> str:
|
|
274
|
+
token = token.rstrip(",;")
|
|
275
|
+
if len(token) >= 2 and token[0] == "`" and token[-1] == "`":
|
|
276
|
+
return token[1:-1].replace("``", "`")
|
|
277
|
+
if len(token) >= 2 and token[0] == '"' and token[-1] == '"':
|
|
278
|
+
return token[1:-1].replace('""', '"')
|
|
279
|
+
return token
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _sql_tokens(sql: str) -> list[str]:
|
|
283
|
+
return [m.group(0) for m in _SQL_TOKEN_RE.finditer(_strip_leading_trace_comment(sql))]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _keyword(token: str) -> str:
|
|
287
|
+
if token.startswith(("`", '"', "'")):
|
|
288
|
+
return ""
|
|
289
|
+
return token.upper()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _read_identifier(tokens: list[str], start: int) -> str:
|
|
293
|
+
parts: list[str] = []
|
|
294
|
+
i = start
|
|
295
|
+
while i < len(tokens):
|
|
296
|
+
tok = tokens[i]
|
|
297
|
+
if tok == ".":
|
|
298
|
+
i += 1
|
|
299
|
+
continue
|
|
300
|
+
if tok in {"(", ")", ",", ";"}:
|
|
301
|
+
break
|
|
302
|
+
if _keyword(tok) in {
|
|
303
|
+
"AS",
|
|
304
|
+
"USING",
|
|
305
|
+
"WHERE",
|
|
306
|
+
"SET",
|
|
307
|
+
"VALUES",
|
|
308
|
+
"SELECT",
|
|
309
|
+
"COMMENT",
|
|
310
|
+
"LOCATION",
|
|
311
|
+
"PARTITIONED",
|
|
312
|
+
"TBLPROPERTIES",
|
|
313
|
+
"WITH",
|
|
314
|
+
"FROM",
|
|
315
|
+
"TO",
|
|
316
|
+
"ON",
|
|
317
|
+
}:
|
|
318
|
+
break
|
|
319
|
+
if tok.startswith("'"):
|
|
320
|
+
break
|
|
321
|
+
parts.append(_normalise_identifier(tok))
|
|
322
|
+
if i + 1 >= len(tokens) or tokens[i + 1] != ".":
|
|
323
|
+
break
|
|
324
|
+
i += 2
|
|
325
|
+
return ".".join(p for p in parts if p) or "?"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _skip_if_not_exists(words: list[str], start: int) -> int:
|
|
329
|
+
if words[start : start + 3] == ["IF", "NOT", "EXISTS"]:
|
|
330
|
+
return start + 3
|
|
331
|
+
return start
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _skip_optional_table(words: list[str], start: int) -> int:
|
|
335
|
+
if start < len(words) and words[start] == "TABLE":
|
|
336
|
+
return start + 1
|
|
337
|
+
return start
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _cte_key(name: str) -> str:
|
|
341
|
+
return _normalise_identifier(name).lower()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _with_ctes_and_top_level_verb(tokens: list[str]) -> tuple[dict[str, list[str]], int | None]:
|
|
345
|
+
ctes: dict[str, list[str]] = {}
|
|
346
|
+
i = 1
|
|
347
|
+
if i < len(tokens) and _keyword(tokens[i]) == "RECURSIVE":
|
|
348
|
+
i += 1
|
|
349
|
+
while i < len(tokens):
|
|
350
|
+
name = tokens[i]
|
|
351
|
+
if name in {"(", ")", ",", ";"} or _keyword(name) in _WITH_TOP_LEVEL_VERBS | {"AS"}:
|
|
352
|
+
break
|
|
353
|
+
cte_name = _cte_key(name)
|
|
354
|
+
i += 1
|
|
355
|
+
|
|
356
|
+
if i < len(tokens) and tokens[i] == "(":
|
|
357
|
+
depth = 1
|
|
358
|
+
i += 1
|
|
359
|
+
while i < len(tokens) and depth > 0:
|
|
360
|
+
if tokens[i] == "(":
|
|
361
|
+
depth += 1
|
|
362
|
+
elif tokens[i] == ")":
|
|
363
|
+
depth -= 1
|
|
364
|
+
i += 1
|
|
365
|
+
|
|
366
|
+
if i >= len(tokens) or _keyword(tokens[i]) != "AS":
|
|
367
|
+
break
|
|
368
|
+
i += 1
|
|
369
|
+
if i >= len(tokens) or tokens[i] != "(":
|
|
370
|
+
break
|
|
371
|
+
body_start = i + 1
|
|
372
|
+
depth = 1
|
|
373
|
+
i += 1
|
|
374
|
+
while i < len(tokens) and depth > 0:
|
|
375
|
+
if tokens[i] == "(":
|
|
376
|
+
depth += 1
|
|
377
|
+
elif tokens[i] == ")":
|
|
378
|
+
depth -= 1
|
|
379
|
+
i += 1
|
|
380
|
+
if depth != 0:
|
|
381
|
+
return ctes, None
|
|
382
|
+
ctes[cte_name] = tokens[body_start : i - 1]
|
|
383
|
+
|
|
384
|
+
if i < len(tokens) and tokens[i] == ",":
|
|
385
|
+
i += 1
|
|
386
|
+
continue
|
|
387
|
+
break
|
|
388
|
+
|
|
389
|
+
for j in range(i, len(tokens)):
|
|
390
|
+
if _keyword(tokens[j]) in _WITH_TOP_LEVEL_VERBS:
|
|
391
|
+
return ctes, j
|
|
392
|
+
return ctes, None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _audit_event_from_tokens(tokens: list[str], start: int) -> str:
|
|
396
|
+
words = [_keyword(t) for t in tokens]
|
|
397
|
+
if start >= len(words) or not words[start]:
|
|
398
|
+
return "OTHER"
|
|
399
|
+
first = words[start]
|
|
400
|
+
if first == "CREATE":
|
|
401
|
+
i = start + 1
|
|
402
|
+
while i < len(words) and words[i] in {
|
|
403
|
+
"OR",
|
|
404
|
+
"REPLACE",
|
|
405
|
+
"TEMP",
|
|
406
|
+
"TEMPORARY",
|
|
407
|
+
"EXTERNAL",
|
|
408
|
+
"LIVE",
|
|
409
|
+
"STREAMING",
|
|
410
|
+
}:
|
|
411
|
+
i += 1
|
|
412
|
+
if i < len(words) and words[i] in {"CATALOG", "SCHEMA", "DATABASE", "TABLE", "VIEW"}:
|
|
413
|
+
kind = "SCHEMA" if words[i] == "DATABASE" else words[i]
|
|
414
|
+
return f"CREATE_{kind}"
|
|
415
|
+
return "CREATE"
|
|
416
|
+
if first in {"DROP", "ALTER"} and start + 1 < len(words):
|
|
417
|
+
kind = "SCHEMA" if words[start + 1] == "DATABASE" else words[start + 1]
|
|
418
|
+
if kind in {"CATALOG", "SCHEMA", "TABLE", "VIEW"}:
|
|
419
|
+
return f"{first}_{kind}"
|
|
420
|
+
if first == "DESCRIBE":
|
|
421
|
+
return "DESCRIBE"
|
|
422
|
+
if first in {
|
|
423
|
+
"INSERT",
|
|
424
|
+
"MERGE",
|
|
425
|
+
"UPDATE",
|
|
426
|
+
"DELETE",
|
|
427
|
+
"SELECT",
|
|
428
|
+
"SHOW",
|
|
429
|
+
"GRANT",
|
|
430
|
+
"REVOKE",
|
|
431
|
+
"OPTIMIZE",
|
|
432
|
+
"VACUUM",
|
|
433
|
+
"TRUNCATE",
|
|
434
|
+
"REFRESH",
|
|
435
|
+
}:
|
|
436
|
+
return first
|
|
437
|
+
return "OTHER"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def databricks_audit_event(sql: str) -> str:
|
|
441
|
+
"""Derive ADR 0092 event name from a Databricks SQL statement."""
|
|
442
|
+
tokens = _sql_tokens(sql)
|
|
443
|
+
if not tokens:
|
|
444
|
+
return "OTHER"
|
|
445
|
+
if _keyword(tokens[0]) == "WITH":
|
|
446
|
+
_, start = _with_ctes_and_top_level_verb(tokens)
|
|
447
|
+
return "OTHER" if start is None else _audit_event_from_tokens(tokens, start)
|
|
448
|
+
return _audit_event_from_tokens(tokens, 0)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _audit_target_from_tokens(tokens: list[str], start: int) -> str:
|
|
452
|
+
words = [_keyword(t) for t in tokens]
|
|
453
|
+
if start >= len(words):
|
|
454
|
+
return "?"
|
|
455
|
+
first = words[start]
|
|
456
|
+
if first == "CREATE":
|
|
457
|
+
i = start + 1
|
|
458
|
+
while i < len(words) and words[i] in {
|
|
459
|
+
"OR",
|
|
460
|
+
"REPLACE",
|
|
461
|
+
"TEMP",
|
|
462
|
+
"TEMPORARY",
|
|
463
|
+
"EXTERNAL",
|
|
464
|
+
"LIVE",
|
|
465
|
+
"STREAMING",
|
|
466
|
+
}:
|
|
467
|
+
i += 1
|
|
468
|
+
if i < len(words) and words[i] in {"CATALOG", "SCHEMA", "DATABASE", "TABLE", "VIEW"}:
|
|
469
|
+
return _read_identifier(tokens, _skip_if_not_exists(words, i + 1))
|
|
470
|
+
if first in {"DROP", "ALTER"} and start + 2 < len(tokens):
|
|
471
|
+
return _read_identifier(tokens, start + 2)
|
|
472
|
+
if first == "INSERT":
|
|
473
|
+
for i in range(start + 1, len(words)):
|
|
474
|
+
word = words[i]
|
|
475
|
+
if word in {"INTO", "OVERWRITE"}:
|
|
476
|
+
return _read_identifier(tokens, _skip_optional_table(words, i + 1))
|
|
477
|
+
if first == "MERGE" and start + 1 < len(words) and words[start + 1] == "INTO":
|
|
478
|
+
return _read_identifier(tokens, start + 2)
|
|
479
|
+
if first == "UPDATE":
|
|
480
|
+
return _read_identifier(tokens, start + 1)
|
|
481
|
+
if first == "DELETE":
|
|
482
|
+
target_start = (
|
|
483
|
+
start + 2 if start + 1 < len(words) and words[start + 1] == "FROM" else start + 1
|
|
484
|
+
)
|
|
485
|
+
return _read_identifier(tokens, target_start)
|
|
486
|
+
if first in {"DESCRIBE", "TRUNCATE", "REFRESH"}:
|
|
487
|
+
target_start = (
|
|
488
|
+
start + 2
|
|
489
|
+
if start + 1 < len(words)
|
|
490
|
+
and words[start + 1] in {"TABLE", "VIEW", "EXTENDED", "FORMATTED"}
|
|
491
|
+
else start + 1
|
|
492
|
+
)
|
|
493
|
+
return _read_identifier(tokens, target_start)
|
|
494
|
+
if first == "SELECT":
|
|
495
|
+
depth = 0
|
|
496
|
+
for i in range(start + 1, len(words)):
|
|
497
|
+
tok = tokens[i]
|
|
498
|
+
if tok == "(":
|
|
499
|
+
depth += 1
|
|
500
|
+
continue
|
|
501
|
+
if tok == ")":
|
|
502
|
+
depth = max(0, depth - 1)
|
|
503
|
+
continue
|
|
504
|
+
word = words[i]
|
|
505
|
+
if word == "FROM":
|
|
506
|
+
if depth > 0:
|
|
507
|
+
continue
|
|
508
|
+
return _read_identifier(tokens, i + 1)
|
|
509
|
+
if first == "GRANT":
|
|
510
|
+
for i in range(start + 1, len(words)):
|
|
511
|
+
word = words[i]
|
|
512
|
+
if word == "ON":
|
|
513
|
+
start = i + 2 if i + 2 < len(tokens) else i + 1
|
|
514
|
+
return _read_identifier(tokens, start)
|
|
515
|
+
if first == "SHOW":
|
|
516
|
+
if "IN" in words[start + 1 :]:
|
|
517
|
+
return _read_identifier(tokens, words.index("IN", start + 1) + 1)
|
|
518
|
+
if "FROM" in words[start + 1 :]:
|
|
519
|
+
return _read_identifier(tokens, words.index("FROM", start + 1) + 1)
|
|
520
|
+
return "?"
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _resolve_cte_audit_target(
|
|
524
|
+
target: str,
|
|
525
|
+
ctes: dict[str, list[str]],
|
|
526
|
+
*,
|
|
527
|
+
via_cte: bool = False,
|
|
528
|
+
seen: set[str] | None = None,
|
|
529
|
+
) -> str:
|
|
530
|
+
if target == "?":
|
|
531
|
+
return "?"
|
|
532
|
+
seen = set() if seen is None else seen
|
|
533
|
+
current = target
|
|
534
|
+
for _ in range(len(ctes)):
|
|
535
|
+
key = _cte_key(current)
|
|
536
|
+
if key in seen:
|
|
537
|
+
return "?"
|
|
538
|
+
body = ctes.get(key)
|
|
539
|
+
if body is None:
|
|
540
|
+
return current if not via_cte or "." in current else "?"
|
|
541
|
+
seen.add(key)
|
|
542
|
+
if body and _keyword(body[0]) == "WITH":
|
|
543
|
+
nested_ctes, start = _with_ctes_and_top_level_verb(body)
|
|
544
|
+
if start is None:
|
|
545
|
+
return "?"
|
|
546
|
+
nested_target = _audit_target_from_tokens(body, start)
|
|
547
|
+
resolved_nested = _resolve_cte_audit_target(
|
|
548
|
+
nested_target,
|
|
549
|
+
nested_ctes,
|
|
550
|
+
via_cte=True,
|
|
551
|
+
)
|
|
552
|
+
current = resolved_nested
|
|
553
|
+
else:
|
|
554
|
+
current = _audit_target_from_tokens(body, 0)
|
|
555
|
+
if current == "?":
|
|
556
|
+
return "?"
|
|
557
|
+
return current if "." in current else "?"
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def databricks_audit_target(sql: str) -> str:
|
|
561
|
+
"""Best-effort target object extraction for the ADR 0092 one-liner."""
|
|
562
|
+
|
|
563
|
+
tokens = _sql_tokens(sql)
|
|
564
|
+
if not tokens:
|
|
565
|
+
return "?"
|
|
566
|
+
if _keyword(tokens[0]) == "WITH":
|
|
567
|
+
ctes, start = _with_ctes_and_top_level_verb(tokens)
|
|
568
|
+
if start is None:
|
|
569
|
+
return "?"
|
|
570
|
+
return _resolve_cte_audit_target(_audit_target_from_tokens(tokens, start), ctes)
|
|
571
|
+
return _audit_target_from_tokens(tokens, 0)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def databricks_audit_enabled(profile: dict[str, Any]) -> tuple[bool, str | None]:
|
|
575
|
+
"""Resolve ADR 0092 Databricks audit enablement.
|
|
576
|
+
|
|
577
|
+
``KIRIMANA_DATABRICKS_AUDIT`` is the runtime override. The profile value is
|
|
578
|
+
loader-threaded from ``audit.databricks`` and defaults to enabled.
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
raw_env = os.environ.get("KIRIMANA_DATABRICKS_AUDIT")
|
|
582
|
+
if raw_env is not None:
|
|
583
|
+
value = raw_env.strip().lower()
|
|
584
|
+
if value in _TRUTHY_AUDIT:
|
|
585
|
+
return True, None
|
|
586
|
+
if value in _FALSY_AUDIT:
|
|
587
|
+
return False, "env:KIRIMANA_DATABRICKS_AUDIT"
|
|
588
|
+
raw_profile = profile.get("databricks_audit_enabled", True)
|
|
589
|
+
if isinstance(raw_profile, str):
|
|
590
|
+
value = raw_profile.strip().lower()
|
|
591
|
+
if value in _FALSY_AUDIT:
|
|
592
|
+
return False, "config:audit.databricks=false"
|
|
593
|
+
if value in _TRUTHY_AUDIT:
|
|
594
|
+
return True, None
|
|
595
|
+
if raw_profile is False:
|
|
596
|
+
return False, "config:audit.databricks=false"
|
|
597
|
+
return True, None
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _line_status(status: str, error_class: str | None) -> str:
|
|
601
|
+
if status == "denied":
|
|
602
|
+
return "DENIED"
|
|
603
|
+
if error_class and error_class.lower() in {"permissiondenied", "permission_denied", "denied"}:
|
|
604
|
+
return "DENIED"
|
|
605
|
+
return "OK" if status == "success" else "FAILED"
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _kv(value: object) -> str:
|
|
609
|
+
text = "?" if value is None else str(value)
|
|
610
|
+
return text.replace("\n", " ").replace("\r", " ").replace(" ", "_") or "?"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _render_audit_line(record: _AuditRecord) -> str:
|
|
614
|
+
parts = [
|
|
615
|
+
record.ts,
|
|
616
|
+
f"user={_kv(record.user)}",
|
|
617
|
+
f"event={_kv(record.event)}",
|
|
618
|
+
f"target={_kv(record.target)}",
|
|
619
|
+
f"status={_line_status(record.status, record.error_class)}",
|
|
620
|
+
f"dur={record.duration_ms / 1000:.1f}s",
|
|
621
|
+
f"trace={_kv(record.trace_id)}",
|
|
622
|
+
f"warehouse={_kv(record.warehouse_id)}",
|
|
623
|
+
f"via={_kv(record.via)}",
|
|
624
|
+
]
|
|
625
|
+
if record.reason is not None:
|
|
626
|
+
parts.append(f"reason={_kv(record.reason)}")
|
|
627
|
+
return " ".join(parts)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _resolve_databricks_audit_paths(profile: dict[str, Any]) -> _AuditPaths:
|
|
631
|
+
explicit = profile.get("databricks_audit_log_path")
|
|
632
|
+
if explicit:
|
|
633
|
+
jsonl = Path(str(explicit))
|
|
634
|
+
return _AuditPaths(jsonl=jsonl, line=jsonl.with_name("databricks-audit.log"))
|
|
635
|
+
default_dir = Path.cwd() / "logs" / "databricks-audit"
|
|
636
|
+
base = Path(str(profile.get("databricks_audit_log_dir") or default_dir))
|
|
637
|
+
return _AuditPaths(
|
|
638
|
+
jsonl=base / "databricks-audit.jsonl",
|
|
639
|
+
line=base / "databricks-audit.log",
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _audit_destination_key(paths: _AuditPaths) -> tuple[str, str]:
|
|
644
|
+
return (str(paths.jsonl.resolve()), str(paths.line.resolve()))
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _claim_audit_disabled_emit(paths: _AuditPaths) -> bool:
|
|
648
|
+
key = _audit_destination_key(paths)
|
|
649
|
+
if key in _AUDIT_DISABLED_DESTINATIONS:
|
|
650
|
+
return False
|
|
651
|
+
_AUDIT_DISABLED_DESTINATIONS.add(key)
|
|
652
|
+
return True
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _build_audit_record(
|
|
656
|
+
*,
|
|
657
|
+
event: str,
|
|
658
|
+
target: str,
|
|
659
|
+
status: str,
|
|
660
|
+
duration_ms: int,
|
|
661
|
+
attempts: int,
|
|
662
|
+
warehouse_id: str | None,
|
|
663
|
+
via: str,
|
|
664
|
+
user: str | None,
|
|
665
|
+
error_class: str | None,
|
|
666
|
+
reason: str | None,
|
|
667
|
+
statement_preview: str,
|
|
668
|
+
) -> _AuditRecord:
|
|
669
|
+
ctx = current_trace()
|
|
670
|
+
return _AuditRecord(
|
|
671
|
+
ts=datetime.now(UTC).isoformat(timespec="seconds"),
|
|
672
|
+
trace_id=ctx.kiri_trace_id,
|
|
673
|
+
apply_id=ctx.apply_id,
|
|
674
|
+
caller=ctx.caller,
|
|
675
|
+
via=via,
|
|
676
|
+
user=user or "unknown",
|
|
677
|
+
event=event,
|
|
678
|
+
target=target,
|
|
679
|
+
duration_ms=duration_ms,
|
|
680
|
+
attempts=attempts,
|
|
681
|
+
status=status,
|
|
682
|
+
warehouse_id=warehouse_id,
|
|
683
|
+
statement_preview=statement_preview,
|
|
684
|
+
error_class=error_class,
|
|
685
|
+
reason=reason,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _write_audit_line(paths: _AuditPaths, record: _AuditRecord) -> None:
|
|
690
|
+
path = paths.line
|
|
691
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
692
|
+
with path.open("a", encoding="utf-8") as f:
|
|
693
|
+
f.write(_render_audit_line(record) + "\n")
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _write_audit_jsonl(paths: _AuditPaths, record: _AuditRecord) -> None:
|
|
697
|
+
path = paths.jsonl
|
|
698
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
699
|
+
# Keep existing JSONL keys and status vocabulary for audit_join
|
|
700
|
+
# back-compat. ADR 0092's OK/FAILED/DENIED vocabulary is only
|
|
701
|
+
# the one-line projection.
|
|
702
|
+
row: dict[str, Any] = {
|
|
703
|
+
"ts": record.ts,
|
|
704
|
+
"trace_id": record.trace_id,
|
|
705
|
+
"apply_id": record.apply_id,
|
|
706
|
+
"caller": record.caller,
|
|
707
|
+
"duration_ms": record.duration_ms,
|
|
708
|
+
"attempts": record.attempts,
|
|
709
|
+
"status": "failed" if record.status == "denied" else record.status,
|
|
710
|
+
"warehouse_id": record.warehouse_id,
|
|
711
|
+
"statement_preview": record.statement_preview,
|
|
712
|
+
"user": record.user,
|
|
713
|
+
"event": record.event,
|
|
714
|
+
"via": record.via,
|
|
715
|
+
}
|
|
716
|
+
if record.error_class is not None:
|
|
717
|
+
row["error_class"] = record.error_class
|
|
718
|
+
if record.reason is not None:
|
|
719
|
+
row["reason"] = record.reason
|
|
720
|
+
with path.open("a", encoding="utf-8") as f:
|
|
721
|
+
f.write(json.dumps(row, default=str) + "\n")
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _write_audit_record(paths: _AuditPaths, record: _AuditRecord) -> None:
|
|
725
|
+
with suppress(Exception):
|
|
726
|
+
_write_audit_line(paths, record)
|
|
727
|
+
try:
|
|
728
|
+
_write_audit_jsonl(paths, record)
|
|
729
|
+
except Exception:
|
|
730
|
+
# Never break Databricks work because the client-side audit-log
|
|
731
|
+
# filesystem hiccupped. The Databricks-side audit log (ADR 0049) is
|
|
732
|
+
# the authoritative trail; this client-side emission is a complement.
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _emit_databricks_audit_record(
|
|
737
|
+
*,
|
|
738
|
+
profile: dict[str, Any],
|
|
739
|
+
paths: _AuditPaths,
|
|
740
|
+
event: str,
|
|
741
|
+
target: str,
|
|
742
|
+
status: str,
|
|
743
|
+
duration_ms: int,
|
|
744
|
+
attempts: int,
|
|
745
|
+
warehouse_id: str | None,
|
|
746
|
+
via: str,
|
|
747
|
+
user: str | None,
|
|
748
|
+
error_class: str | None,
|
|
749
|
+
statement_preview: str,
|
|
750
|
+
disabled_reason: str | None = None,
|
|
751
|
+
) -> None:
|
|
752
|
+
enabled, resolved_disabled_reason = databricks_audit_enabled(profile)
|
|
753
|
+
if not enabled:
|
|
754
|
+
if _claim_audit_disabled_emit(paths):
|
|
755
|
+
record = _build_audit_record(
|
|
756
|
+
event="AUDIT_DISABLED",
|
|
757
|
+
target="?",
|
|
758
|
+
status="failed",
|
|
759
|
+
duration_ms=0,
|
|
760
|
+
attempts=0,
|
|
761
|
+
warehouse_id=warehouse_id,
|
|
762
|
+
via=via,
|
|
763
|
+
user=user,
|
|
764
|
+
error_class=None,
|
|
765
|
+
reason=disabled_reason
|
|
766
|
+
or resolved_disabled_reason
|
|
767
|
+
or "config:audit.databricks=false",
|
|
768
|
+
statement_preview=statement_preview,
|
|
769
|
+
)
|
|
770
|
+
_write_audit_record(paths, record)
|
|
771
|
+
return
|
|
772
|
+
record = _build_audit_record(
|
|
773
|
+
event=event,
|
|
774
|
+
target=target,
|
|
775
|
+
status=status,
|
|
776
|
+
duration_ms=duration_ms,
|
|
777
|
+
attempts=attempts,
|
|
778
|
+
warehouse_id=warehouse_id,
|
|
779
|
+
via=via,
|
|
780
|
+
user=user,
|
|
781
|
+
error_class=error_class,
|
|
782
|
+
reason=None,
|
|
783
|
+
statement_preview=statement_preview,
|
|
784
|
+
)
|
|
785
|
+
_write_audit_record(paths, record)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _normalize_mcp_audit_status(status: str) -> str:
|
|
789
|
+
value = status.strip().lower()
|
|
790
|
+
if value in {"ok", "success"}:
|
|
791
|
+
return "success"
|
|
792
|
+
if value == "denied":
|
|
793
|
+
return "denied"
|
|
794
|
+
return "failed"
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def emit_databricks_mcp_audit(
|
|
798
|
+
*,
|
|
799
|
+
event: str,
|
|
800
|
+
target: str,
|
|
801
|
+
status: _McpAuditStatus,
|
|
802
|
+
duration_ms: int,
|
|
803
|
+
warehouse_id: str | None,
|
|
804
|
+
user: str | None = None,
|
|
805
|
+
error_class: str | None = None,
|
|
806
|
+
profile: dict[str, Any] | None = None,
|
|
807
|
+
statement_preview: str | None = None,
|
|
808
|
+
) -> None:
|
|
809
|
+
"""Emit the ADR 0092 audit record at the Kiri→Databricks-MCP boundary."""
|
|
810
|
+
|
|
811
|
+
normalized_status = _normalize_mcp_audit_status(status)
|
|
812
|
+
resolved_profile = profile or {}
|
|
813
|
+
paths = _resolve_databricks_audit_paths(resolved_profile)
|
|
814
|
+
_emit_databricks_audit_record(
|
|
815
|
+
profile=resolved_profile,
|
|
816
|
+
paths=paths,
|
|
817
|
+
event=event,
|
|
818
|
+
target=target,
|
|
819
|
+
status=normalized_status,
|
|
820
|
+
duration_ms=duration_ms,
|
|
821
|
+
attempts=1,
|
|
822
|
+
warehouse_id=warehouse_id,
|
|
823
|
+
via="mcp",
|
|
824
|
+
user=user,
|
|
825
|
+
error_class=error_class,
|
|
826
|
+
statement_preview=_redact_preview(statement_preview or f"{event} {target}"),
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _extract_current_user(response: Any) -> str | None:
|
|
831
|
+
data = getattr(getattr(response, "result", None), "data_array", None)
|
|
832
|
+
if not data:
|
|
833
|
+
return None
|
|
834
|
+
first_row = data[0]
|
|
835
|
+
if not first_row:
|
|
836
|
+
return None
|
|
837
|
+
return str(first_row[0])
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def build_staging_select_sql(
|
|
841
|
+
*,
|
|
842
|
+
volume_path: str,
|
|
843
|
+
fmt: str,
|
|
844
|
+
source_file: str,
|
|
845
|
+
trace_id: str,
|
|
846
|
+
deleted_flag: str | None,
|
|
847
|
+
today: date,
|
|
848
|
+
json_shape: str = "array",
|
|
849
|
+
) -> str:
|
|
850
|
+
"""SELECT wrapper that reads a file from a volume and adds `_kiri_*` columns.
|
|
851
|
+
|
|
852
|
+
Path, trace_id, source_file are the only user-ish inputs; we embed them as
|
|
853
|
+
string literals (Databricks parameters don't survive inside ``read_files``
|
|
854
|
+
options cleanly). All three come from our own metadata / runtime and
|
|
855
|
+
never from end-user SQL.
|
|
856
|
+
|
|
857
|
+
``json_shape`` mirrors ``IngestionConfig.json_shape``:
|
|
858
|
+
|
|
859
|
+
- ``array`` / ``single`` → ``multiLine => 'true'`` (Spark reads the whole
|
|
860
|
+
file as one JSON document — array elements become rows, single object
|
|
861
|
+
becomes a one-row table).
|
|
862
|
+
- ``ndjson`` → ``multiLine => 'false'`` (newline-delimited; one row per
|
|
863
|
+
line). This is the shape most integration platforms push.
|
|
864
|
+
|
|
865
|
+
Ignored when ``fmt != "json"``.
|
|
866
|
+
"""
|
|
867
|
+
if fmt not in {"json", "csv"}:
|
|
868
|
+
raise ValueError(f"Unsupported fmt: {fmt}")
|
|
869
|
+
if fmt == "json" and json_shape not in {"array", "ndjson", "single"}:
|
|
870
|
+
raise ValueError(
|
|
871
|
+
f"Unsupported json_shape: {json_shape!r}; expected one of 'array', 'ndjson', 'single'."
|
|
872
|
+
)
|
|
873
|
+
# ``recursiveFileLookup => 'true'`` disables Hive-style partition
|
|
874
|
+
# discovery in Databricks' ``read_files``: the raw landing path is
|
|
875
|
+
# ``…/_raw/_kiri_ingested_date=YYYY-MM-DD/<file>``; with discovery
|
|
876
|
+
# on, Spark synthesises an ``_kiri_ingested_date`` partition column
|
|
877
|
+
# that collides with the literal one projected below
|
|
878
|
+
# (AMBIGUOUS_COLUMN_OR_FIELD).
|
|
879
|
+
if fmt == "json":
|
|
880
|
+
multi_line = "false" if json_shape == "ndjson" else "true"
|
|
881
|
+
fmt_opts = f"format => 'json', multiLine => '{multi_line}', recursiveFileLookup => 'true'"
|
|
882
|
+
else:
|
|
883
|
+
fmt_opts = "format => 'csv', header => 'true', recursiveFileLookup => 'true'"
|
|
884
|
+
# Literal escaping — single-quote-in-SQL = double the quote.
|
|
885
|
+
v = volume_path.replace("'", "''")
|
|
886
|
+
sf = source_file.replace("'", "''")
|
|
887
|
+
tid = trace_id.replace("'", "''")
|
|
888
|
+
d_iso = today.isoformat()
|
|
889
|
+
|
|
890
|
+
if deleted_flag:
|
|
891
|
+
tombstone = (
|
|
892
|
+
f"CAST(CASE WHEN CAST({_quote_ident(deleted_flag)} AS BOOLEAN) "
|
|
893
|
+
f"THEN CURRENT_TIMESTAMP() ELSE NULL END AS TIMESTAMP)"
|
|
894
|
+
)
|
|
895
|
+
else:
|
|
896
|
+
tombstone = "CAST(NULL AS TIMESTAMP)"
|
|
897
|
+
|
|
898
|
+
return (
|
|
899
|
+
f"SELECT "
|
|
900
|
+
f"src.*, "
|
|
901
|
+
f"CURRENT_TIMESTAMP() AS _kiri_ingested_at, "
|
|
902
|
+
f"'{sf}' AS _kiri_source_file, "
|
|
903
|
+
f"'{tid}' AS _kiri_trace_id, "
|
|
904
|
+
f"{tombstone} AS _kiri_deleted_at, "
|
|
905
|
+
f"DATE '{d_iso}' AS _kiri_ingested_date "
|
|
906
|
+
f"FROM read_files('{v}', {fmt_opts}) AS src"
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def build_merge_sql(
|
|
911
|
+
*,
|
|
912
|
+
target_fq: str,
|
|
913
|
+
source_select: str,
|
|
914
|
+
dedupe_key: list[str],
|
|
915
|
+
) -> str:
|
|
916
|
+
"""SQL MERGE: last-write-wins on _kiri_ingested_at per dedupe_key.
|
|
917
|
+
|
|
918
|
+
The source is de-duplicated on ``dedupe_key`` first (QUALIFY ROW_NUMBER,
|
|
919
|
+
keeping the latest ``_kiri_ingested_at``). Without it, a source batch that
|
|
920
|
+
repeats a key — e.g. the same business key landed twice in one fetch —
|
|
921
|
+
makes Delta reject the MERGE with
|
|
922
|
+
``DELTA_MULTIPLE_SOURCE_ROW_MATCHING_TARGET_ROW_IN_MERGE`` (#268). This
|
|
923
|
+
mirrors the local runner's arrow-side dedup so both backends agree.
|
|
924
|
+
"""
|
|
925
|
+
if not dedupe_key:
|
|
926
|
+
raise ValueError("dedupe_key is required for MERGE")
|
|
927
|
+
predicate = " AND ".join(
|
|
928
|
+
f"target.{_quote_ident(k)} = source.{_quote_ident(k)}" for k in dedupe_key
|
|
929
|
+
)
|
|
930
|
+
partition = ", ".join(_quote_ident(k) for k in dedupe_key)
|
|
931
|
+
deduped_source = (
|
|
932
|
+
f"SELECT * FROM ({source_select}) "
|
|
933
|
+
f"QUALIFY ROW_NUMBER() OVER ("
|
|
934
|
+
f"PARTITION BY {partition} ORDER BY _kiri_ingested_at DESC) = 1"
|
|
935
|
+
)
|
|
936
|
+
return (
|
|
937
|
+
f"MERGE INTO {target_fq} AS target "
|
|
938
|
+
f"USING ({deduped_source}) AS source "
|
|
939
|
+
f"ON {predicate} "
|
|
940
|
+
f"WHEN MATCHED AND source._kiri_ingested_at > target._kiri_ingested_at "
|
|
941
|
+
f" THEN UPDATE SET * "
|
|
942
|
+
f"WHEN NOT MATCHED THEN INSERT *"
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def build_append_sql(*, target_fq: str, source_select: str) -> str:
|
|
947
|
+
"""SQL INSERT when there is no dedupe_key."""
|
|
948
|
+
return f"INSERT INTO {target_fq} {source_select}"
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def build_create_table_sql(*, target_fq: str, source_select: str) -> str:
|
|
952
|
+
"""First-time CREATE TABLE AS SELECT (CTAS) — Delta by default on UC."""
|
|
953
|
+
return (
|
|
954
|
+
f"CREATE TABLE IF NOT EXISTS {target_fq} "
|
|
955
|
+
f"PARTITIONED BY (_kiri_ingested_date) "
|
|
956
|
+
f"AS {source_select} WHERE 1 = 0"
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
# ---- adapter --------------------------------------------------------------
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
class DatabricksAdapter(PlatformAdapter):
|
|
964
|
+
"""Talks to Databricks via the Workspace SDK + Statement Execution API."""
|
|
965
|
+
|
|
966
|
+
name = "databricks"
|
|
967
|
+
|
|
968
|
+
def __init__(self, profile: dict[str, Any]) -> None:
|
|
969
|
+
super().__init__(profile)
|
|
970
|
+
self._client: Any | None = None
|
|
971
|
+
# Per-run cache for the landing-storage handle. Built lazily on
|
|
972
|
+
# first ``landing_storage()`` call so the adapter stays usable
|
|
973
|
+
# in profiles that don't declare an ADLS block (legacy tests).
|
|
974
|
+
self._landing_storage_cache: Any | None = None
|
|
975
|
+
# S8: simple per-adapter token-bucket gating Statement Execution
|
|
976
|
+
# API calls. Off by default; operators set ``statement_rps_limit``
|
|
977
|
+
# in profile to throttle concurrent applies that share a
|
|
978
|
+
# warehouse. Single-process — multi-replica deployments need
|
|
979
|
+
# per-warehouse coordination (out of scope for v1).
|
|
980
|
+
self._rps_limit: float | None = None
|
|
981
|
+
rps = profile.get("statement_rps_limit")
|
|
982
|
+
if rps is not None:
|
|
983
|
+
try:
|
|
984
|
+
rps_val = float(rps)
|
|
985
|
+
except (TypeError, ValueError) as e:
|
|
986
|
+
raise ValueError(f"statement_rps_limit must be a number; got {rps!r}") from e
|
|
987
|
+
if rps_val <= 0:
|
|
988
|
+
raise ValueError(f"statement_rps_limit must be > 0; got {rps_val}")
|
|
989
|
+
self._rps_limit = rps_val
|
|
990
|
+
self._last_call_at: float = 0.0
|
|
991
|
+
|
|
992
|
+
# N1: retry tunables. Operators with very fast warehouses tighten
|
|
993
|
+
# the backoff; ones on a noisy network ease it up. Defaults match
|
|
994
|
+
# the legacy hardcoded values so existing deploys see no change.
|
|
995
|
+
self._retry_attempts = int(profile.get("retry_attempts", 3))
|
|
996
|
+
if self._retry_attempts < 1:
|
|
997
|
+
raise ValueError(f"retry_attempts must be ≥ 1; got {self._retry_attempts}")
|
|
998
|
+
self._retry_backoff_base = float(profile.get("retry_backoff_base_seconds", 0.5))
|
|
999
|
+
if self._retry_backoff_base < 0:
|
|
1000
|
+
raise ValueError(
|
|
1001
|
+
f"retry_backoff_base_seconds must be ≥ 0; got {self._retry_backoff_base}"
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
# ADR 0092: per-_execute audit is on by default. A legacy explicit
|
|
1005
|
+
# JSONL path is still honoured exactly; otherwise both projections
|
|
1006
|
+
# live under the project-threaded default audit dir.
|
|
1007
|
+
self._audit_paths = self._resolve_audit_paths(profile)
|
|
1008
|
+
self._audit_disabled_emitted = False
|
|
1009
|
+
self._audit_user: str | None = None
|
|
1010
|
+
|
|
1011
|
+
# ---- public API ------------------------------------------------------
|
|
1012
|
+
|
|
1013
|
+
def required_credentials(self) -> list[Credential]:
|
|
1014
|
+
return [
|
|
1015
|
+
Credential(
|
|
1016
|
+
env_var="DATABRICKS_HOST",
|
|
1017
|
+
description="Workspace URL, e.g. https://adb-123.azuredatabricks.net",
|
|
1018
|
+
),
|
|
1019
|
+
Credential(
|
|
1020
|
+
env_var="DATABRICKS_TOKEN",
|
|
1021
|
+
description="PAT or OAuth token with SQL + Files API access",
|
|
1022
|
+
),
|
|
1023
|
+
Credential(
|
|
1024
|
+
env_var="DATABRICKS_WAREHOUSE_ID",
|
|
1025
|
+
description="SQL warehouse ID used for statements and dbt",
|
|
1026
|
+
required=False,
|
|
1027
|
+
),
|
|
1028
|
+
]
|
|
1029
|
+
|
|
1030
|
+
def health_check(self) -> HealthStatus:
|
|
1031
|
+
try:
|
|
1032
|
+
result = self._execute("SELECT 1 AS ok")
|
|
1033
|
+
except Exception:
|
|
1034
|
+
return HealthStatus.UNHEALTHY
|
|
1035
|
+
return HealthStatus.HEALTHY if result is not None else HealthStatus.UNHEALTHY
|
|
1036
|
+
|
|
1037
|
+
def dialect(self) -> SqlDialect:
|
|
1038
|
+
"""Return ``DeltaDialect`` for Unity Catalog Delta tables (#149).
|
|
1039
|
+
|
|
1040
|
+
``DatabricksDwDialect`` (Photon ANSI SQL Warehouse) is still future
|
|
1041
|
+
work — when it lands the adapter will switch on a profile flag.
|
|
1042
|
+
"""
|
|
1043
|
+
return DeltaDialect()
|
|
1044
|
+
|
|
1045
|
+
def landing_storage(self, vault: Any | None = None) -> Any:
|
|
1046
|
+
"""Return the ADLS-backed landing storage (ADR 0046 phase 2).
|
|
1047
|
+
|
|
1048
|
+
Built once per adapter instance (= per ``kiri apply`` run), so
|
|
1049
|
+
the SDK client + credential lookup happen exactly once even
|
|
1050
|
+
when many tables share the same target. When the profile
|
|
1051
|
+
carries a ``${vault:...}`` reference for the credential, the
|
|
1052
|
+
caller MUST pass a ``vault`` :class:`kiri_vault.VaultClient` to
|
|
1053
|
+
resolve it; literal credential strings (test fixtures, dev)
|
|
1054
|
+
flow through unchanged.
|
|
1055
|
+
|
|
1056
|
+
Profile shape::
|
|
1057
|
+
|
|
1058
|
+
targets:
|
|
1059
|
+
prod:
|
|
1060
|
+
adapter: databricks
|
|
1061
|
+
landing_storage:
|
|
1062
|
+
scheme: adls
|
|
1063
|
+
account: kirimanalanding
|
|
1064
|
+
container: landing
|
|
1065
|
+
credential: ${vault:azure:landing-sas}
|
|
1066
|
+
|
|
1067
|
+
When ``landing_storage`` is absent from the profile, the
|
|
1068
|
+
adapter falls back to :class:`LocalLandingStorage` rooted at
|
|
1069
|
+
``bronze_raw_root`` so non-Databricks development still works
|
|
1070
|
+
and existing tests stay green.
|
|
1071
|
+
"""
|
|
1072
|
+
if self._landing_storage_cache is not None:
|
|
1073
|
+
return self._landing_storage_cache
|
|
1074
|
+
|
|
1075
|
+
block = self.profile.get("landing_storage")
|
|
1076
|
+
if not block:
|
|
1077
|
+
ingest_volume = self.profile.get("ingest_volume")
|
|
1078
|
+
if ingest_volume:
|
|
1079
|
+
# Preferred: write landing bytes straight into a UC
|
|
1080
|
+
# Volume the warehouse can ``read_files()`` from. The
|
|
1081
|
+
# source contract drives the fetch; the storage is the
|
|
1082
|
+
# adapter's choice (this branch is the Databricks one).
|
|
1083
|
+
from kiri_ingestion.storage import DatabricksVolumeLandingStorage
|
|
1084
|
+
|
|
1085
|
+
self._landing_storage_cache = DatabricksVolumeLandingStorage(
|
|
1086
|
+
workspace_client=self._workspace(),
|
|
1087
|
+
volume=str(ingest_volume),
|
|
1088
|
+
)
|
|
1089
|
+
return self._landing_storage_cache
|
|
1090
|
+
|
|
1091
|
+
# Back-compat: pre-ADR-0046 profiles drove ingest off the
|
|
1092
|
+
# control-plane FS. Kept for tests and adapters whose
|
|
1093
|
+
# warehouse can in fact read local paths (e.g. an in-pod
|
|
1094
|
+
# dev SQL endpoint); a real Databricks SQL warehouse
|
|
1095
|
+
# cannot read ``file://``, so set ``ingest_volume`` (or a
|
|
1096
|
+
# cloud ``landing_storage`` block) for production runs.
|
|
1097
|
+
from kiri_ingestion.storage import LocalLandingStorage
|
|
1098
|
+
|
|
1099
|
+
# Canonical key is ``bronze_root`` — the kiri.yml target, the
|
|
1100
|
+
# fetch stage and _resolve_profile_paths all use it. Keep
|
|
1101
|
+
# ``bronze_raw_root`` as a back-compat fallback.
|
|
1102
|
+
root_value = (
|
|
1103
|
+
self.profile.get("bronze_root")
|
|
1104
|
+
or self.profile.get("bronze_raw_root")
|
|
1105
|
+
or "./data/bronze"
|
|
1106
|
+
)
|
|
1107
|
+
root = Path(str(root_value)).resolve()
|
|
1108
|
+
self._landing_storage_cache = LocalLandingStorage(root)
|
|
1109
|
+
return self._landing_storage_cache
|
|
1110
|
+
|
|
1111
|
+
scheme = block.get("scheme", "adls")
|
|
1112
|
+
if scheme != "adls":
|
|
1113
|
+
raise ValueError(
|
|
1114
|
+
f"DatabricksAdapter.landing_storage: unsupported scheme {scheme!r}; "
|
|
1115
|
+
"only 'adls' is supported in phase 2."
|
|
1116
|
+
)
|
|
1117
|
+
account = block.get("account")
|
|
1118
|
+
container = block.get("container")
|
|
1119
|
+
credential = block.get("credential")
|
|
1120
|
+
if not (account and container and credential):
|
|
1121
|
+
raise ValueError(
|
|
1122
|
+
"DatabricksAdapter.landing_storage requires account, "
|
|
1123
|
+
"container, and credential in profile.landing_storage."
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
resolved = self._resolve_credential(str(credential), vault)
|
|
1127
|
+
|
|
1128
|
+
from kiri_ingestion.storage import AdlsLandingStorage
|
|
1129
|
+
|
|
1130
|
+
self._landing_storage_cache = AdlsLandingStorage.from_credential(
|
|
1131
|
+
account=str(account),
|
|
1132
|
+
container=str(container),
|
|
1133
|
+
credential=resolved,
|
|
1134
|
+
)
|
|
1135
|
+
return self._landing_storage_cache
|
|
1136
|
+
|
|
1137
|
+
@staticmethod
|
|
1138
|
+
def _resolve_credential(value: str, vault: Any | None) -> str:
|
|
1139
|
+
"""Resolve a profile credential string.
|
|
1140
|
+
|
|
1141
|
+
- ``${vault:<id>:<key>}`` requires a vault client; raises if
|
|
1142
|
+
one isn't passed.
|
|
1143
|
+
- Anything else is returned verbatim (literal SAS, env-var
|
|
1144
|
+
token, etc).
|
|
1145
|
+
"""
|
|
1146
|
+
from kiri_vault.ref import VaultRef
|
|
1147
|
+
|
|
1148
|
+
parsed = VaultRef.parse(value)
|
|
1149
|
+
if parsed is None:
|
|
1150
|
+
return value
|
|
1151
|
+
if vault is None:
|
|
1152
|
+
raise RuntimeError(
|
|
1153
|
+
"DatabricksAdapter.landing_storage: credential is a "
|
|
1154
|
+
f"vault reference ({value!r}) but no vault client was "
|
|
1155
|
+
"provided. Pass project.vault_client() to landing_storage()."
|
|
1156
|
+
)
|
|
1157
|
+
return str(vault.get(parsed.secret_id, parsed.key))
|
|
1158
|
+
|
|
1159
|
+
def claim_target(self, manifest: ProjectManifest) -> None:
|
|
1160
|
+
"""Idempotently claim the Unity Catalog for ``(project, env)``.
|
|
1161
|
+
|
|
1162
|
+
Mirrors the LocalDuckDB implementation of ADRs 0014 + 0017. Not
|
|
1163
|
+
currently exercised by CI (requires a live workspace); the code
|
|
1164
|
+
below codifies the SQL shape so the invariant cannot drift.
|
|
1165
|
+
|
|
1166
|
+
- Row-per-(project, env). Same project + different envs coexist;
|
|
1167
|
+
different projects in the same env collide.
|
|
1168
|
+
- Legacy 5-column manifest migrated in-place with ALTER TABLE
|
|
1169
|
+
(Delta supports ``ADD COLUMNS``).
|
|
1170
|
+
- Re-apply refreshes release_sha/tag/ts when the caller brought
|
|
1171
|
+
them (kiri release apply path); plain apply preserves existing.
|
|
1172
|
+
"""
|
|
1173
|
+
|
|
1174
|
+
def esc(s: str | None) -> str:
|
|
1175
|
+
if s is None:
|
|
1176
|
+
return "NULL"
|
|
1177
|
+
return "'" + s.replace("'", "''") + "'"
|
|
1178
|
+
|
|
1179
|
+
catalog = self._catalog()
|
|
1180
|
+
fq_meta = f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}"
|
|
1181
|
+
fq_table = f"{fq_meta}.{_quote_ident(_MANIFEST_TABLE)}"
|
|
1182
|
+
self._execute(f"CREATE SCHEMA IF NOT EXISTS {fq_meta}")
|
|
1183
|
+
self._execute(
|
|
1184
|
+
f"CREATE TABLE IF NOT EXISTS {fq_table} ("
|
|
1185
|
+
" project_name STRING NOT NULL,"
|
|
1186
|
+
" project_version STRING NOT NULL,"
|
|
1187
|
+
" silver_technique STRING NOT NULL,"
|
|
1188
|
+
" first_write STRING NOT NULL,"
|
|
1189
|
+
" kirimana_version STRING NOT NULL,"
|
|
1190
|
+
" environment STRING NOT NULL,"
|
|
1191
|
+
" release_sha STRING,"
|
|
1192
|
+
" release_tag STRING,"
|
|
1193
|
+
" release_ts STRING"
|
|
1194
|
+
") USING DELTA"
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
# ADR 0017 migration: legacy 0014-shape tables get the missing
|
|
1198
|
+
# columns bolted on, existing rows backfilled env='dev'. Databricks
|
|
1199
|
+
# SQL has no ``ADD COLUMNS IF NOT EXISTS`` (parse error on a
|
|
1200
|
+
# second apply / on the full-schema table the CREATE above just
|
|
1201
|
+
# made), so introspect the live columns and add only the genuine
|
|
1202
|
+
# gaps — no-op when the table already carries them.
|
|
1203
|
+
existing_rows = self._execute(
|
|
1204
|
+
"SELECT column_name FROM system.information_schema.columns "
|
|
1205
|
+
"WHERE table_catalog = ? AND table_schema = ? AND table_name = ?",
|
|
1206
|
+
parameters=[catalog, _META_SCHEMA, _MANIFEST_TABLE],
|
|
1207
|
+
)
|
|
1208
|
+
existing = {str(r[0]).lower() for r in (existing_rows or [])}
|
|
1209
|
+
# ADR 0020 naming-lock columns join the 0017 migration set. The live
|
|
1210
|
+
# _kiri_project_manifest predates them, so they must be bolted on
|
|
1211
|
+
# before the INSERT/SELECT below references them. Databricks SQL has
|
|
1212
|
+
# no reliable ``ADD COLUMNS IF NOT EXISTS`` (parse error on the
|
|
1213
|
+
# full-schema table the CREATE above just made), so the same
|
|
1214
|
+
# information_schema introspection that guards the 0017 columns guards
|
|
1215
|
+
# these too — emitting only the genuine gaps, a no-op once present.
|
|
1216
|
+
migration_columns = [
|
|
1217
|
+
("environment", "STRING"),
|
|
1218
|
+
("release_sha", "STRING"),
|
|
1219
|
+
("release_tag", "STRING"),
|
|
1220
|
+
("release_ts", "STRING"),
|
|
1221
|
+
("naming_locked_at", "STRING"),
|
|
1222
|
+
("naming_snapshot", "STRING"),
|
|
1223
|
+
]
|
|
1224
|
+
missing = [(c, t) for c, t in migration_columns if c.lower() not in existing]
|
|
1225
|
+
if missing:
|
|
1226
|
+
cols_sql = ", ".join(f"{c} {t}" for c, t in missing)
|
|
1227
|
+
self._execute(f"ALTER TABLE {fq_table} ADD COLUMNS ({cols_sql})")
|
|
1228
|
+
self._execute(f"UPDATE {fq_table} SET environment = 'dev' WHERE environment IS NULL")
|
|
1229
|
+
|
|
1230
|
+
# Slot lookup. Reads the locked naming_snapshot too (ADR 0020) so the
|
|
1231
|
+
# enforce/backfill decision below reuses this round-trip — no second
|
|
1232
|
+
# SELECT.
|
|
1233
|
+
slot_result = self._execute(
|
|
1234
|
+
f"SELECT project_name, project_version, silver_technique, "
|
|
1235
|
+
f"first_write, naming_snapshot FROM {fq_table} "
|
|
1236
|
+
f"WHERE project_name = {esc(manifest.project_name)} "
|
|
1237
|
+
f"AND environment = {esc(manifest.environment)}"
|
|
1238
|
+
)
|
|
1239
|
+
slot_rows = list(slot_result) if slot_result is not None else []
|
|
1240
|
+
|
|
1241
|
+
# Cross-project collision in the same env.
|
|
1242
|
+
other_result = self._execute(
|
|
1243
|
+
f"SELECT project_name, project_version, silver_technique, "
|
|
1244
|
+
f"first_write FROM {fq_table} "
|
|
1245
|
+
f"WHERE environment = {esc(manifest.environment)} "
|
|
1246
|
+
f"AND project_name <> {esc(manifest.project_name)}"
|
|
1247
|
+
)
|
|
1248
|
+
other_rows = list(other_result) if other_result is not None else []
|
|
1249
|
+
if other_rows:
|
|
1250
|
+
other_name, other_ver, other_tech, other_first = other_rows[0]
|
|
1251
|
+
raise ProjectTargetCollisionError(
|
|
1252
|
+
f"Environment {manifest.environment!r} in catalog "
|
|
1253
|
+
f"{catalog!r} is owned by project {other_name!r} "
|
|
1254
|
+
f"(version {other_ver}, silver technique {other_tech!r}, "
|
|
1255
|
+
f"first write {other_first}). Cannot apply from project "
|
|
1256
|
+
f"{manifest.project_name!r}. Either pick a different "
|
|
1257
|
+
"target/environment in kiri.yml or coordinate with the "
|
|
1258
|
+
"owning project."
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
if not slot_rows:
|
|
1262
|
+
self._execute(
|
|
1263
|
+
f"INSERT INTO {fq_table} "
|
|
1264
|
+
f"(project_name, project_version, silver_technique, "
|
|
1265
|
+
f"first_write, kirimana_version, environment, "
|
|
1266
|
+
f"release_sha, release_tag, release_ts, "
|
|
1267
|
+
f"naming_locked_at, naming_snapshot) "
|
|
1268
|
+
f"VALUES ("
|
|
1269
|
+
f"{esc(manifest.project_name)}, "
|
|
1270
|
+
f"{esc(manifest.project_version)}, "
|
|
1271
|
+
f"{esc(manifest.silver_technique)}, "
|
|
1272
|
+
f"{esc(manifest.first_write)}, "
|
|
1273
|
+
f"{esc(manifest.kirimana_version)}, "
|
|
1274
|
+
f"{esc(manifest.environment)}, "
|
|
1275
|
+
f"{esc(manifest.release_sha)}, "
|
|
1276
|
+
f"{esc(manifest.release_tag)}, "
|
|
1277
|
+
f"{esc(manifest.release_ts)}, "
|
|
1278
|
+
f"{esc(manifest.naming_locked_at)}, "
|
|
1279
|
+
f"{esc(manifest.naming_snapshot)})"
|
|
1280
|
+
)
|
|
1281
|
+
return
|
|
1282
|
+
|
|
1283
|
+
if len(slot_rows) > 1:
|
|
1284
|
+
raise ProjectTargetCollisionError(
|
|
1285
|
+
f"{fq_table} contains {len(slot_rows)} rows for "
|
|
1286
|
+
f"(project={manifest.project_name!r}, "
|
|
1287
|
+
f"env={manifest.environment!r}) — catalog metadata is "
|
|
1288
|
+
"corrupted. Inspect and resolve."
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
# ADR 0020 naming-lock enforcement (same-slot re-apply). The locked
|
|
1292
|
+
# snapshot was read in the slot SELECT above (column 5).
|
|
1293
|
+
stored_snapshot = slot_rows[0][4]
|
|
1294
|
+
incoming_snapshot = manifest.naming_snapshot
|
|
1295
|
+
if ProjectManifest.naming_snapshot_diverges(stored_snapshot, incoming_snapshot):
|
|
1296
|
+
raise NamingLockError(
|
|
1297
|
+
f"naming block for project {manifest.project_name!r} "
|
|
1298
|
+
f"(environment {manifest.environment!r}) diverges from the "
|
|
1299
|
+
"locked naming snapshot recorded on first write.\n"
|
|
1300
|
+
f" locked: {stored_snapshot}\n"
|
|
1301
|
+
f" incoming: {incoming_snapshot}\n"
|
|
1302
|
+
"Naming conventions are locked after the first apply "
|
|
1303
|
+
"(ADR 0020). Restore the locked naming in kiri.yml, or run "
|
|
1304
|
+
"`kiri naming migrate` (future) to rewrite the warehouse "
|
|
1305
|
+
"objects and re-lock. Refusing to apply against live data "
|
|
1306
|
+
"with a changed naming scheme."
|
|
1307
|
+
)
|
|
1308
|
+
if stored_snapshot is None and incoming_snapshot is not None:
|
|
1309
|
+
# Pre-upgrade manifest row written before this feature: no snapshot
|
|
1310
|
+
# was ever recorded. Backfill it (graceful upgrade, never a
|
|
1311
|
+
# failure) so the lock takes effect from now on. The
|
|
1312
|
+
# `naming_snapshot IS NULL` predicate makes this a compare-and-set:
|
|
1313
|
+
# if a concurrent apply locked a snapshot between the slot SELECT
|
|
1314
|
+
# and here, this UPDATE is a no-op rather than clobbering the lock.
|
|
1315
|
+
self._execute(
|
|
1316
|
+
f"UPDATE {fq_table} SET "
|
|
1317
|
+
f"naming_locked_at = {esc(manifest.naming_locked_at)}, "
|
|
1318
|
+
f"naming_snapshot = {esc(incoming_snapshot)} "
|
|
1319
|
+
f"WHERE project_name = {esc(manifest.project_name)} "
|
|
1320
|
+
f"AND environment = {esc(manifest.environment)} "
|
|
1321
|
+
f"AND naming_snapshot IS NULL"
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
# Same slot — idempotent reclaim. Refresh release fields if the
|
|
1325
|
+
# caller supplied them (kiri release apply post-success).
|
|
1326
|
+
if (
|
|
1327
|
+
manifest.release_sha is not None
|
|
1328
|
+
or manifest.release_tag is not None
|
|
1329
|
+
or manifest.release_ts is not None
|
|
1330
|
+
):
|
|
1331
|
+
self._execute(
|
|
1332
|
+
f"UPDATE {fq_table} SET "
|
|
1333
|
+
f"release_sha = {esc(manifest.release_sha)}, "
|
|
1334
|
+
f"release_tag = {esc(manifest.release_tag)}, "
|
|
1335
|
+
f"release_ts = {esc(manifest.release_ts)}, "
|
|
1336
|
+
f"project_version = {esc(manifest.project_version)}, "
|
|
1337
|
+
f"kirimana_version = {esc(manifest.kirimana_version)} "
|
|
1338
|
+
f"WHERE project_name = {esc(manifest.project_name)} "
|
|
1339
|
+
f"AND environment = {esc(manifest.environment)}"
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
def read_manifest(self, project_name: str, environment: str) -> ProjectManifest | None:
|
|
1343
|
+
"""Return the manifest row for (project, env), or None if absent.
|
|
1344
|
+
|
|
1345
|
+
Delta mirror of the LocalDuckDB path. Not CI-tested (requires a
|
|
1346
|
+
live workspace); catches every exception and maps to None so
|
|
1347
|
+
`kiri release status` on a workspace without the schema/table
|
|
1348
|
+
reports "not applied yet" rather than blowing up.
|
|
1349
|
+
"""
|
|
1350
|
+
|
|
1351
|
+
def esc(s: str) -> str:
|
|
1352
|
+
return "'" + s.replace("'", "''") + "'"
|
|
1353
|
+
|
|
1354
|
+
catalog = self._catalog()
|
|
1355
|
+
fq_table = (
|
|
1356
|
+
f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}.{_quote_ident(_MANIFEST_TABLE)}"
|
|
1357
|
+
)
|
|
1358
|
+
try:
|
|
1359
|
+
result = self._execute(
|
|
1360
|
+
f"SELECT project_name, project_version, silver_technique, "
|
|
1361
|
+
f"first_write, kirimana_version, environment, "
|
|
1362
|
+
f"release_sha, release_tag, release_ts, "
|
|
1363
|
+
f"naming_locked_at, naming_snapshot "
|
|
1364
|
+
f"FROM {fq_table} "
|
|
1365
|
+
f"WHERE project_name = {esc(project_name)} "
|
|
1366
|
+
f"AND environment = {esc(environment)}"
|
|
1367
|
+
)
|
|
1368
|
+
except Exception:
|
|
1369
|
+
return None
|
|
1370
|
+
rows = list(result) if result is not None else []
|
|
1371
|
+
if not rows:
|
|
1372
|
+
return None
|
|
1373
|
+
row = rows[0]
|
|
1374
|
+
return ProjectManifest(
|
|
1375
|
+
project_name=row[0],
|
|
1376
|
+
project_version=row[1],
|
|
1377
|
+
silver_technique=row[2],
|
|
1378
|
+
first_write=row[3],
|
|
1379
|
+
kirimana_version=row[4],
|
|
1380
|
+
environment=row[5],
|
|
1381
|
+
release_sha=row[6],
|
|
1382
|
+
release_tag=row[7],
|
|
1383
|
+
release_ts=row[8],
|
|
1384
|
+
naming_locked_at=row[9],
|
|
1385
|
+
naming_snapshot=row[10],
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
def log_apply(self, entry: ApplyLogEntry) -> None:
|
|
1389
|
+
"""Upsert one row of ``_kiri_meta._kiri_apply_log`` (ADR 0017).
|
|
1390
|
+
|
|
1391
|
+
Delta Lake shape mirrors the LocalDuckDB implementation. Not
|
|
1392
|
+
CI-tested (requires a live workspace); SQL form codifies the
|
|
1393
|
+
invariant so it cannot drift.
|
|
1394
|
+
"""
|
|
1395
|
+
|
|
1396
|
+
def esc(s: str | None) -> str:
|
|
1397
|
+
if s is None:
|
|
1398
|
+
return "NULL"
|
|
1399
|
+
return "'" + s.replace("'", "''") + "'"
|
|
1400
|
+
|
|
1401
|
+
catalog = self._catalog()
|
|
1402
|
+
fq_meta = f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}"
|
|
1403
|
+
fq_log = f"{fq_meta}.{_quote_ident(_APPLY_LOG_TABLE)}"
|
|
1404
|
+
self._execute(f"CREATE SCHEMA IF NOT EXISTS {fq_meta}")
|
|
1405
|
+
self._execute(
|
|
1406
|
+
f"CREATE TABLE IF NOT EXISTS {fq_log} ("
|
|
1407
|
+
" apply_id STRING NOT NULL,"
|
|
1408
|
+
" project_name STRING NOT NULL,"
|
|
1409
|
+
" environment STRING NOT NULL,"
|
|
1410
|
+
" release_sha STRING,"
|
|
1411
|
+
" release_tag STRING,"
|
|
1412
|
+
" release_ts STRING,"
|
|
1413
|
+
" kirimana_version STRING NOT NULL,"
|
|
1414
|
+
" started_at STRING NOT NULL,"
|
|
1415
|
+
" ended_at STRING,"
|
|
1416
|
+
" status STRING NOT NULL,"
|
|
1417
|
+
" error_message STRING"
|
|
1418
|
+
") USING DELTA"
|
|
1419
|
+
)
|
|
1420
|
+
existing_result = self._execute(
|
|
1421
|
+
f"SELECT apply_id FROM {fq_log} WHERE apply_id = {esc(entry.apply_id)}"
|
|
1422
|
+
)
|
|
1423
|
+
existing_rows = list(existing_result) if existing_result is not None else []
|
|
1424
|
+
if not existing_rows:
|
|
1425
|
+
self._execute(
|
|
1426
|
+
f"INSERT INTO {fq_log} "
|
|
1427
|
+
f"(apply_id, project_name, environment, release_sha, "
|
|
1428
|
+
f" release_tag, release_ts, kirimana_version, started_at, "
|
|
1429
|
+
f" ended_at, status, error_message) VALUES ("
|
|
1430
|
+
f"{esc(entry.apply_id)}, {esc(entry.project_name)}, "
|
|
1431
|
+
f"{esc(entry.environment)}, {esc(entry.release_sha)}, "
|
|
1432
|
+
f"{esc(entry.release_tag)}, {esc(entry.release_ts)}, "
|
|
1433
|
+
f"{esc(entry.kirimana_version)}, {esc(entry.started_at)}, "
|
|
1434
|
+
f"{esc(entry.ended_at)}, {esc(entry.status)}, "
|
|
1435
|
+
f"{esc(entry.error_message)})"
|
|
1436
|
+
)
|
|
1437
|
+
else:
|
|
1438
|
+
self._execute(
|
|
1439
|
+
f"UPDATE {fq_log} SET "
|
|
1440
|
+
f"release_sha = {esc(entry.release_sha)}, "
|
|
1441
|
+
f"release_tag = {esc(entry.release_tag)}, "
|
|
1442
|
+
f"release_ts = {esc(entry.release_ts)}, "
|
|
1443
|
+
f"ended_at = {esc(entry.ended_at)}, "
|
|
1444
|
+
f"status = {esc(entry.status)}, "
|
|
1445
|
+
f"error_message = {esc(entry.error_message)} "
|
|
1446
|
+
f"WHERE apply_id = {esc(entry.apply_id)}"
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
def read_watermark(self, asset_urn: str, environment: str) -> AssetWatermark | None:
|
|
1450
|
+
"""Return the watermark row for (asset_urn, env), or None if absent.
|
|
1451
|
+
|
|
1452
|
+
Delta mirror of the LocalDuckDB path (ADR 0080 §2). Not CI-tested
|
|
1453
|
+
(requires a live workspace). Only the "table or schema does not exist"
|
|
1454
|
+
failure maps to None ("no cursor yet" → first run); EVERY other failure
|
|
1455
|
+
(permission / transport / malformed row) is wrapped in
|
|
1456
|
+
:class:`WatermarkStoreError` and raised. Flattening a real read error to
|
|
1457
|
+
None would silently reprocess the full history (P0 data-correctness
|
|
1458
|
+
hazard). The rendered SQL is pinned by a golden-SQL unit test.
|
|
1459
|
+
"""
|
|
1460
|
+
|
|
1461
|
+
def esc(s: str) -> str:
|
|
1462
|
+
return "'" + s.replace("'", "''") + "'"
|
|
1463
|
+
|
|
1464
|
+
catalog = self._catalog()
|
|
1465
|
+
fq_table = (
|
|
1466
|
+
f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}."
|
|
1467
|
+
f"{_quote_ident(_ASSET_WATERMARKS_TABLE)}"
|
|
1468
|
+
)
|
|
1469
|
+
try:
|
|
1470
|
+
result = self._execute(
|
|
1471
|
+
f"SELECT asset_urn, target_env, grain, last_processed_value, "
|
|
1472
|
+
f"last_run_id, updated_at "
|
|
1473
|
+
f"FROM {fq_table} "
|
|
1474
|
+
f"WHERE asset_urn = {esc(asset_urn)} "
|
|
1475
|
+
f"AND target_env = {esc(environment)}"
|
|
1476
|
+
)
|
|
1477
|
+
except Exception as exc:
|
|
1478
|
+
if _is_table_or_schema_not_found(exc):
|
|
1479
|
+
# Legitimate "no watermark store yet" → first run / no cursor.
|
|
1480
|
+
return None
|
|
1481
|
+
# Any other failure is NOT absence — fail closed so a transient read
|
|
1482
|
+
# error is never mistaken for an empty cursor (full reprocess).
|
|
1483
|
+
raise WatermarkStoreError(
|
|
1484
|
+
f"read_watermark failed for asset_urn={asset_urn!r} env={environment!r}: {exc}"
|
|
1485
|
+
) from exc
|
|
1486
|
+
rows = list(result) if result is not None else []
|
|
1487
|
+
if not rows:
|
|
1488
|
+
return None
|
|
1489
|
+
row = rows[0]
|
|
1490
|
+
return AssetWatermark(
|
|
1491
|
+
asset_urn=row[0],
|
|
1492
|
+
target_env=row[1],
|
|
1493
|
+
grain=row[2],
|
|
1494
|
+
last_processed_value=row[3],
|
|
1495
|
+
last_run_id=row[4],
|
|
1496
|
+
updated_at=row[5],
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
def advance_watermark(
|
|
1500
|
+
self,
|
|
1501
|
+
asset_urn: str,
|
|
1502
|
+
environment: str,
|
|
1503
|
+
*,
|
|
1504
|
+
grain: str,
|
|
1505
|
+
last_processed_value: str,
|
|
1506
|
+
last_run_id: str | None,
|
|
1507
|
+
) -> None:
|
|
1508
|
+
"""Upsert one row of ``_kiri_meta._kiri_asset_watermarks`` (ADR 0080 §2).
|
|
1509
|
+
|
|
1510
|
+
Delta Lake shape mirroring the LocalDuckDB implementation and the
|
|
1511
|
+
``_kiri_release_state`` sink: CREATE SCHEMA / TABLE IF NOT EXISTS, then
|
|
1512
|
+
the established atomic upsert — an idempotent ``UPDATE ... WHERE <key>``
|
|
1513
|
+
(0 rows when absent) followed by a guarded
|
|
1514
|
+
``INSERT ... SELECT <literals> WHERE NOT EXISTS (SELECT 1 ...)`` (no-op
|
|
1515
|
+
when present). Never MERGE, never a Python-side read-then-write branch.
|
|
1516
|
+
Idempotent on ``(asset_urn, target_env)`` — one row per slot.
|
|
1517
|
+
|
|
1518
|
+
Delta has no enforced PRIMARY KEY, so the guarded-insert idiom is itself
|
|
1519
|
+
the backstop against a duplicate row — exactly as the other ``_kiri_meta``
|
|
1520
|
+
Delta tables (``_kiri_release_state`` / ``_kiri_active_contract_versions``)
|
|
1521
|
+
do. Not CI-tested against a live cluster; the rendered SQL form is pinned
|
|
1522
|
+
by a golden-SQL unit test so it cannot drift.
|
|
1523
|
+
"""
|
|
1524
|
+
|
|
1525
|
+
def esc(s: str | None) -> str:
|
|
1526
|
+
if s is None:
|
|
1527
|
+
return "NULL"
|
|
1528
|
+
return "'" + s.replace("'", "''") + "'"
|
|
1529
|
+
|
|
1530
|
+
from datetime import datetime
|
|
1531
|
+
|
|
1532
|
+
updated_at = datetime.now(UTC).isoformat()
|
|
1533
|
+
catalog = self._catalog()
|
|
1534
|
+
fq_meta = f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}"
|
|
1535
|
+
fq_table = f"{fq_meta}.{_quote_ident(_ASSET_WATERMARKS_TABLE)}"
|
|
1536
|
+
self._execute(f"CREATE SCHEMA IF NOT EXISTS {fq_meta}")
|
|
1537
|
+
self._execute(
|
|
1538
|
+
f"CREATE TABLE IF NOT EXISTS {fq_table} ("
|
|
1539
|
+
" asset_urn STRING NOT NULL,"
|
|
1540
|
+
" target_env STRING NOT NULL,"
|
|
1541
|
+
" grain STRING NOT NULL,"
|
|
1542
|
+
" last_processed_value STRING,"
|
|
1543
|
+
" last_run_id STRING,"
|
|
1544
|
+
" updated_at STRING NOT NULL"
|
|
1545
|
+
") USING DELTA"
|
|
1546
|
+
)
|
|
1547
|
+
wm_key = f"asset_urn = {esc(asset_urn)} AND target_env = {esc(environment)}"
|
|
1548
|
+
# Atomic: idempotent UPDATE (0 rows when absent) then a guarded INSERT
|
|
1549
|
+
# (no-op when present) — the duplicate-row backstop on Delta (no PK).
|
|
1550
|
+
self._execute(
|
|
1551
|
+
f"UPDATE {fq_table} SET "
|
|
1552
|
+
f"grain = {esc(grain)}, "
|
|
1553
|
+
f"last_processed_value = {esc(last_processed_value)}, "
|
|
1554
|
+
f"last_run_id = {esc(last_run_id)}, "
|
|
1555
|
+
f"updated_at = {esc(updated_at)} "
|
|
1556
|
+
f"WHERE {wm_key}"
|
|
1557
|
+
)
|
|
1558
|
+
self._execute(
|
|
1559
|
+
f"INSERT INTO {fq_table} "
|
|
1560
|
+
f"(asset_urn, target_env, grain, last_processed_value, "
|
|
1561
|
+
f" last_run_id, updated_at) "
|
|
1562
|
+
f"SELECT {esc(asset_urn)}, {esc(environment)}, {esc(grain)}, "
|
|
1563
|
+
f"{esc(last_processed_value)}, {esc(last_run_id)}, {esc(updated_at)} "
|
|
1564
|
+
f"WHERE NOT EXISTS (SELECT 1 FROM {fq_table} WHERE {wm_key})"
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
def mirror_release_manifest(self, entry: ReleaseManifestEntry) -> None:
|
|
1568
|
+
"""Mirror one apply's release manifest into ``_kiri_meta`` (ADR 0041).
|
|
1569
|
+
|
|
1570
|
+
Delta Lake shape, validated live against ``kirimana_mvp``. CREATE
|
|
1571
|
+
SCHEMA/TABLE IF NOT EXISTS, then an atomic conditional-insert upsert
|
|
1572
|
+
(never MERGE, never a Python-side read-then-write branch): a guarded
|
|
1573
|
+
``UPDATE ... WHERE <key>`` followed by an
|
|
1574
|
+
``INSERT ... SELECT <literals> WHERE NOT EXISTS (SELECT 1 ...)``. The
|
|
1575
|
+
FROM-less ``SELECT`` + ``WHERE NOT EXISTS`` was validated live on Delta
|
|
1576
|
+
and is byte-for-byte the same logical pattern the DuckDB backend uses,
|
|
1577
|
+
so the two backends converge. Not CI-tested against a live cluster; the
|
|
1578
|
+
rendered SQL form is pinned by a golden-SQL unit test so it cannot
|
|
1579
|
+
drift.
|
|
1580
|
+
|
|
1581
|
+
- ``_kiri_release_state``: keyed on ``environment`` (UPDATE-then-guarded
|
|
1582
|
+
INSERT).
|
|
1583
|
+
- ``_kiri_active_contract_versions``: keyed on ``(environment, urn)``
|
|
1584
|
+
(UPDATE-then-guarded INSERT per changed contract).
|
|
1585
|
+
- ``_kiri_promotion_history``: append-only, single guarded INSERT on
|
|
1586
|
+
``audit_id`` so re-applying the same apply does not duplicate a row.
|
|
1587
|
+
|
|
1588
|
+
Self-fail-soft: the whole body is wrapped in ``try/except`` and logs a
|
|
1589
|
+
best-effort warning on any error — this governance sink must never raise
|
|
1590
|
+
out of the adapter and block a successful apply. The SQL itself is
|
|
1591
|
+
idempotent so a repeated call against the same audit_id is a no-op for
|
|
1592
|
+
history and an UPDATE for state/versions.
|
|
1593
|
+
"""
|
|
1594
|
+
try:
|
|
1595
|
+
self._mirror_release_manifest_unguarded(entry)
|
|
1596
|
+
except Exception as exc: # governance sink must never raise
|
|
1597
|
+
_logger.warning(
|
|
1598
|
+
"mirror_release_manifest failed for audit_id=%s (governance sink, best-effort): %s",
|
|
1599
|
+
entry.audit_id,
|
|
1600
|
+
exc,
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
def _mirror_release_manifest_unguarded(self, entry: ReleaseManifestEntry) -> None:
|
|
1604
|
+
def esc(s: str | None) -> str:
|
|
1605
|
+
if s is None:
|
|
1606
|
+
return "NULL"
|
|
1607
|
+
return "'" + s.replace("'", "''") + "'"
|
|
1608
|
+
|
|
1609
|
+
def boollit(b: bool) -> str:
|
|
1610
|
+
return "TRUE" if b else "FALSE"
|
|
1611
|
+
|
|
1612
|
+
catalog = self._catalog()
|
|
1613
|
+
fq_meta = f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}"
|
|
1614
|
+
fq_state = f"{fq_meta}.{_quote_ident(_RELEASE_STATE_TABLE)}"
|
|
1615
|
+
fq_versions = f"{fq_meta}.{_quote_ident(_ACTIVE_CONTRACT_VERSIONS_TABLE)}"
|
|
1616
|
+
fq_history = f"{fq_meta}.{_quote_ident(_PROMOTION_HISTORY_TABLE)}"
|
|
1617
|
+
|
|
1618
|
+
self._execute(f"CREATE SCHEMA IF NOT EXISTS {fq_meta}")
|
|
1619
|
+
self._execute(
|
|
1620
|
+
f"CREATE TABLE IF NOT EXISTS {fq_state} ("
|
|
1621
|
+
" environment STRING NOT NULL,"
|
|
1622
|
+
" release_sha STRING NOT NULL,"
|
|
1623
|
+
" ordinal INT,"
|
|
1624
|
+
" last_applied_at STRING NOT NULL,"
|
|
1625
|
+
" last_applied_by STRING NOT NULL,"
|
|
1626
|
+
" last_audit_id STRING NOT NULL,"
|
|
1627
|
+
" last_apply_status STRING NOT NULL,"
|
|
1628
|
+
" updated_at STRING"
|
|
1629
|
+
") USING DELTA"
|
|
1630
|
+
)
|
|
1631
|
+
self._execute(
|
|
1632
|
+
f"CREATE TABLE IF NOT EXISTS {fq_versions} ("
|
|
1633
|
+
" environment STRING NOT NULL,"
|
|
1634
|
+
" contract_urn STRING NOT NULL,"
|
|
1635
|
+
" contract_version STRING NOT NULL,"
|
|
1636
|
+
" applied_at STRING NOT NULL,"
|
|
1637
|
+
" applied_by STRING NOT NULL,"
|
|
1638
|
+
" audit_id STRING NOT NULL,"
|
|
1639
|
+
" is_breaking BOOLEAN,"
|
|
1640
|
+
" is_hotfix BOOLEAN,"
|
|
1641
|
+
" previous_version STRING"
|
|
1642
|
+
") USING DELTA"
|
|
1643
|
+
)
|
|
1644
|
+
self._execute(
|
|
1645
|
+
f"CREATE TABLE IF NOT EXISTS {fq_history} ("
|
|
1646
|
+
" audit_id STRING NOT NULL,"
|
|
1647
|
+
" environment STRING NOT NULL,"
|
|
1648
|
+
" release_sha STRING NOT NULL,"
|
|
1649
|
+
" initiated_at STRING NOT NULL,"
|
|
1650
|
+
" completed_at STRING,"
|
|
1651
|
+
" initiated_by STRING NOT NULL,"
|
|
1652
|
+
" status STRING NOT NULL,"
|
|
1653
|
+
" changed_contracts STRING,"
|
|
1654
|
+
" breaking_ack BOOLEAN,"
|
|
1655
|
+
" hotfix BOOLEAN"
|
|
1656
|
+
") USING DELTA"
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
ordinal_lit = "NULL" if entry.ordinal is None else str(entry.ordinal)
|
|
1660
|
+
|
|
1661
|
+
# release_state — one row per environment. Atomic: idempotent UPDATE
|
|
1662
|
+
# (0 rows when absent) then a guarded INSERT (no-op when present).
|
|
1663
|
+
state_key = f"environment = {esc(entry.environment)}"
|
|
1664
|
+
self._execute(
|
|
1665
|
+
f"UPDATE {fq_state} SET "
|
|
1666
|
+
f"release_sha = {esc(entry.release_sha)}, "
|
|
1667
|
+
f"ordinal = {ordinal_lit}, "
|
|
1668
|
+
f"last_applied_at = {esc(entry.applied_at)}, "
|
|
1669
|
+
f"last_applied_by = {esc(entry.applied_by)}, "
|
|
1670
|
+
f"last_audit_id = {esc(entry.audit_id)}, "
|
|
1671
|
+
f"last_apply_status = {esc(entry.status)}, "
|
|
1672
|
+
f"updated_at = {esc(entry.applied_at)} "
|
|
1673
|
+
f"WHERE {state_key}"
|
|
1674
|
+
)
|
|
1675
|
+
self._execute(
|
|
1676
|
+
f"INSERT INTO {fq_state} "
|
|
1677
|
+
f"(environment, release_sha, ordinal, last_applied_at, "
|
|
1678
|
+
f" last_applied_by, last_audit_id, last_apply_status, updated_at) "
|
|
1679
|
+
f"SELECT {esc(entry.environment)}, {esc(entry.release_sha)}, "
|
|
1680
|
+
f"{ordinal_lit}, {esc(entry.applied_at)}, {esc(entry.applied_by)}, "
|
|
1681
|
+
f"{esc(entry.audit_id)}, {esc(entry.status)}, {esc(entry.applied_at)} "
|
|
1682
|
+
f"WHERE NOT EXISTS (SELECT 1 FROM {fq_state} WHERE {state_key})"
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
# active_contract_versions — one row per (environment, contract_urn).
|
|
1686
|
+
# Same atomic UPDATE-then-guarded-INSERT pattern.
|
|
1687
|
+
for change in entry.changed_contracts:
|
|
1688
|
+
ver_key = f"environment = {esc(entry.environment)} AND contract_urn = {esc(change.urn)}"
|
|
1689
|
+
self._execute(
|
|
1690
|
+
f"UPDATE {fq_versions} SET "
|
|
1691
|
+
f"contract_version = {esc(change.to_version)}, "
|
|
1692
|
+
f"applied_at = {esc(entry.applied_at)}, "
|
|
1693
|
+
f"applied_by = {esc(entry.applied_by)}, "
|
|
1694
|
+
f"audit_id = {esc(entry.audit_id)}, "
|
|
1695
|
+
f"is_breaking = {boollit(change.breaking)}, "
|
|
1696
|
+
f"is_hotfix = {boollit(change.hotfix)}, "
|
|
1697
|
+
f"previous_version = {esc(change.from_version)} "
|
|
1698
|
+
f"WHERE {ver_key}"
|
|
1699
|
+
)
|
|
1700
|
+
self._execute(
|
|
1701
|
+
f"INSERT INTO {fq_versions} "
|
|
1702
|
+
f"(environment, contract_urn, contract_version, applied_at, "
|
|
1703
|
+
f" applied_by, audit_id, is_breaking, is_hotfix, previous_version) "
|
|
1704
|
+
f"SELECT {esc(entry.environment)}, {esc(change.urn)}, "
|
|
1705
|
+
f"{esc(change.to_version)}, {esc(entry.applied_at)}, "
|
|
1706
|
+
f"{esc(entry.applied_by)}, {esc(entry.audit_id)}, "
|
|
1707
|
+
f"{boollit(change.breaking)}, {boollit(change.hotfix)}, "
|
|
1708
|
+
f"{esc(change.from_version)} "
|
|
1709
|
+
f"WHERE NOT EXISTS (SELECT 1 FROM {fq_versions} WHERE {ver_key})"
|
|
1710
|
+
)
|
|
1711
|
+
|
|
1712
|
+
# promotion_history — append-only, single guarded INSERT on audit_id.
|
|
1713
|
+
changed_json = json.dumps(
|
|
1714
|
+
[
|
|
1715
|
+
{
|
|
1716
|
+
"urn": c.urn,
|
|
1717
|
+
"from_version": c.from_version,
|
|
1718
|
+
"to_version": c.to_version,
|
|
1719
|
+
"breaking": c.breaking,
|
|
1720
|
+
"hotfix": c.hotfix,
|
|
1721
|
+
}
|
|
1722
|
+
for c in entry.changed_contracts
|
|
1723
|
+
]
|
|
1724
|
+
)
|
|
1725
|
+
breaking_ack = any(c.breaking for c in entry.changed_contracts)
|
|
1726
|
+
hotfix = any(c.hotfix for c in entry.changed_contracts)
|
|
1727
|
+
self._execute(
|
|
1728
|
+
f"INSERT INTO {fq_history} "
|
|
1729
|
+
f"(audit_id, environment, release_sha, initiated_at, "
|
|
1730
|
+
f" completed_at, initiated_by, status, changed_contracts, "
|
|
1731
|
+
f" breaking_ack, hotfix) "
|
|
1732
|
+
f"SELECT {esc(entry.audit_id)}, {esc(entry.environment)}, "
|
|
1733
|
+
f"{esc(entry.release_sha)}, {esc(entry.applied_at)}, "
|
|
1734
|
+
f"{esc(entry.applied_at)}, {esc(entry.applied_by)}, "
|
|
1735
|
+
f"{esc(entry.status)}, {esc(changed_json)}, "
|
|
1736
|
+
f"{boollit(breaking_ack)}, {boollit(hotfix)} "
|
|
1737
|
+
f"WHERE NOT EXISTS (SELECT 1 FROM {fq_history} "
|
|
1738
|
+
f"WHERE audit_id = {esc(entry.audit_id)})"
|
|
1739
|
+
)
|
|
1740
|
+
|
|
1741
|
+
def resolve_storage(
|
|
1742
|
+
self,
|
|
1743
|
+
binding: StorageBinding,
|
|
1744
|
+
*,
|
|
1745
|
+
live: bool = False,
|
|
1746
|
+
provision: bool = False,
|
|
1747
|
+
) -> StorageResolution:
|
|
1748
|
+
"""Map a per-layer storage binding to live Unity Catalog (ADR 0132 §3).
|
|
1749
|
+
|
|
1750
|
+
Read+validate (when ``live``) then optional operator-gated provision.
|
|
1751
|
+
Provisioning DDL runs **only** when ``provision is True`` AND this adapter
|
|
1752
|
+
``supports("unity-catalog")`` — a bare ``live=True`` read+validates but
|
|
1753
|
+
never mutates. Fully fail-soft — every UC interaction is wrapped so auth /
|
|
1754
|
+
permission / not-found errors land in ``issues`` and the method never
|
|
1755
|
+
raises out (the apply stage treats the result as advisory; the hard gate
|
|
1756
|
+
is the author-time ``validate_project``).
|
|
1757
|
+
"""
|
|
1758
|
+
do_provision = provision and self.supports("unity-catalog")
|
|
1759
|
+
try:
|
|
1760
|
+
return self._resolve_storage_unguarded(binding, live=live, provision=do_provision)
|
|
1761
|
+
except Exception as exc: # storage resolve must never raise out
|
|
1762
|
+
_logger.warning(
|
|
1763
|
+
"resolve_storage failed for external_location=%s (advisory, best-effort): %s",
|
|
1764
|
+
binding.external_location,
|
|
1765
|
+
exc,
|
|
1766
|
+
)
|
|
1767
|
+
return StorageResolution(
|
|
1768
|
+
supported=True,
|
|
1769
|
+
dry_run=not do_provision,
|
|
1770
|
+
issues=[
|
|
1771
|
+
f"resolve_storage failed for external location "
|
|
1772
|
+
f"{binding.external_location!r} (error category: "
|
|
1773
|
+
f"{type(exc).__name__})"
|
|
1774
|
+
],
|
|
1775
|
+
)
|
|
1776
|
+
|
|
1777
|
+
def _resolve_storage_unguarded(
|
|
1778
|
+
self,
|
|
1779
|
+
binding: StorageBinding,
|
|
1780
|
+
*,
|
|
1781
|
+
live: bool,
|
|
1782
|
+
provision: bool,
|
|
1783
|
+
) -> StorageResolution:
|
|
1784
|
+
from kiri_core.storage_paths import (
|
|
1785
|
+
is_strict_subpath,
|
|
1786
|
+
is_vault_ref,
|
|
1787
|
+
parse_location,
|
|
1788
|
+
)
|
|
1789
|
+
|
|
1790
|
+
issues: list[str] = []
|
|
1791
|
+
provisioned: list[str] = []
|
|
1792
|
+
|
|
1793
|
+
# ---- Read: DESCRIBE EXTERNAL LOCATION `<name>` -----------------------
|
|
1794
|
+
# Vault-ref external locations are opaque (resolved elsewhere); we cannot
|
|
1795
|
+
# DESCRIBE a secret, so skip the live read and record nothing — author-
|
|
1796
|
+
# time §4.4 covers resolvability.
|
|
1797
|
+
url: str | None = None
|
|
1798
|
+
credential_name: str | None = None
|
|
1799
|
+
location_exists = False
|
|
1800
|
+
if live and not is_vault_ref(binding.external_location):
|
|
1801
|
+
rows = self._execute(_render_describe_external_location(binding.external_location))
|
|
1802
|
+
# DESCRIBE EXTERNAL LOCATION returns columns
|
|
1803
|
+
# (name, url, credential_name, owner, ...). Read positionally: the
|
|
1804
|
+
# first three columns are stable across runtimes we target.
|
|
1805
|
+
if rows:
|
|
1806
|
+
location_exists = True
|
|
1807
|
+
first = rows[0]
|
|
1808
|
+
url = str(first[1]) if len(first) > 1 and first[1] is not None else None
|
|
1809
|
+
credential_name = str(first[2]) if len(first) > 2 and first[2] is not None else None
|
|
1810
|
+
else:
|
|
1811
|
+
# ADR-006 footgun: the external location named by the binding is
|
|
1812
|
+
# absent live. Record it (do not raise) — this is exactly the
|
|
1813
|
+
# missing-location case caught at apply time.
|
|
1814
|
+
issues.append(
|
|
1815
|
+
f"external location {binding.external_location!r} does not "
|
|
1816
|
+
f"exist (DESCRIBE EXTERNAL LOCATION returned no rows)"
|
|
1817
|
+
)
|
|
1818
|
+
|
|
1819
|
+
# ---- Validate: managed/schema roots are sub-paths of url -------------
|
|
1820
|
+
if url is not None:
|
|
1821
|
+
ext = parse_location(url)
|
|
1822
|
+
if ext is None:
|
|
1823
|
+
issues.append(
|
|
1824
|
+
f"external location url {url!r} is not a parseable scheme://authority/path URL"
|
|
1825
|
+
)
|
|
1826
|
+
else:
|
|
1827
|
+
candidates: list[tuple[str, str]] = [("managed_location", binding.managed_location)]
|
|
1828
|
+
candidates.extend(
|
|
1829
|
+
(f"schema_locations.{schema}", value)
|
|
1830
|
+
for schema, value in binding.schema_locations.items()
|
|
1831
|
+
)
|
|
1832
|
+
for field_path, value in candidates:
|
|
1833
|
+
if is_vault_ref(value):
|
|
1834
|
+
continue
|
|
1835
|
+
loc = parse_location(value)
|
|
1836
|
+
if loc is None:
|
|
1837
|
+
issues.append(
|
|
1838
|
+
f"{field_path} {value!r} is not a parseable scheme://authority/path URL"
|
|
1839
|
+
)
|
|
1840
|
+
elif not is_strict_subpath(loc, ext):
|
|
1841
|
+
issues.append(
|
|
1842
|
+
f"{field_path} {value!r} is not a strict sub-path of "
|
|
1843
|
+
f"the external location url {url!r}"
|
|
1844
|
+
)
|
|
1845
|
+
if not credential_name:
|
|
1846
|
+
issues.append(
|
|
1847
|
+
f"external location {binding.external_location!r} has no storage credential"
|
|
1848
|
+
)
|
|
1849
|
+
|
|
1850
|
+
# ---- Provision (opt-in): caller-gated provision flag -----------------
|
|
1851
|
+
# The caller (CLI) has already computed the full four-part operator gate
|
|
1852
|
+
# (binding.provision AND KIRIMANA_STORAGE_PROVISION_LIVE AND
|
|
1853
|
+
# supports(unity-catalog)); resolve_storage re-checks supports() for
|
|
1854
|
+
# defense-in-depth before passing ``provision`` here.
|
|
1855
|
+
do_provision = provision
|
|
1856
|
+
if do_provision:
|
|
1857
|
+
catalog = self._catalog()
|
|
1858
|
+
# External location: only create when it is genuinely absent and we
|
|
1859
|
+
# have an explicit root URL plus a credential to back it. The common
|
|
1860
|
+
# case is a pre-existing location (creation is the rare opt-in). The
|
|
1861
|
+
# external_location field is a NAME, never a URL — we render the
|
|
1862
|
+
# CREATE from the distinct ``external_location_url``. When the URL is
|
|
1863
|
+
# missing we record an issue and skip the CREATE rather than guess.
|
|
1864
|
+
if not location_exists and not is_vault_ref(binding.external_location):
|
|
1865
|
+
if binding.external_location_url and binding.storage_credential:
|
|
1866
|
+
ddl = _render_create_external_location(
|
|
1867
|
+
binding.external_location,
|
|
1868
|
+
binding.external_location_url,
|
|
1869
|
+
binding.storage_credential,
|
|
1870
|
+
)
|
|
1871
|
+
self._execute(ddl)
|
|
1872
|
+
provisioned.append(ddl)
|
|
1873
|
+
elif not binding.external_location_url:
|
|
1874
|
+
issues.append(
|
|
1875
|
+
f"external location {binding.external_location!r} absent "
|
|
1876
|
+
"and no external_location_url to create it"
|
|
1877
|
+
)
|
|
1878
|
+
else:
|
|
1879
|
+
issues.append(
|
|
1880
|
+
f"external location {binding.external_location!r} absent "
|
|
1881
|
+
"and no storage_credential to create it"
|
|
1882
|
+
)
|
|
1883
|
+
# Per-schema MANAGED LOCATION — idempotent CREATE SCHEMA.
|
|
1884
|
+
for schema, location in binding.schema_locations.items():
|
|
1885
|
+
if is_vault_ref(location):
|
|
1886
|
+
continue
|
|
1887
|
+
ddl = _render_create_schema_managed_location(catalog, schema, location)
|
|
1888
|
+
self._execute(ddl)
|
|
1889
|
+
provisioned.append(ddl)
|
|
1890
|
+
|
|
1891
|
+
return StorageResolution(
|
|
1892
|
+
supported=True,
|
|
1893
|
+
external_location_url=url,
|
|
1894
|
+
credential_name=credential_name,
|
|
1895
|
+
validated=(live and not is_vault_ref(binding.external_location)) and not issues,
|
|
1896
|
+
issues=issues,
|
|
1897
|
+
provisioned=provisioned,
|
|
1898
|
+
dry_run=not do_provision,
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1901
|
+
def create_schema(self, schema_name: str) -> RunResult:
|
|
1902
|
+
start = time.monotonic()
|
|
1903
|
+
catalog = self._catalog()
|
|
1904
|
+
try:
|
|
1905
|
+
self._execute(
|
|
1906
|
+
f"CREATE SCHEMA IF NOT EXISTS {_quote_ident(catalog)}.{_quote_ident(schema_name)}"
|
|
1907
|
+
)
|
|
1908
|
+
except Exception as e:
|
|
1909
|
+
return RunResult(
|
|
1910
|
+
status=RunStatus.FAILED,
|
|
1911
|
+
message=f"create_schema failed: {e}",
|
|
1912
|
+
)
|
|
1913
|
+
return RunResult(
|
|
1914
|
+
status=RunStatus.SUCCESS,
|
|
1915
|
+
message=f"schema {catalog}.{schema_name} ready",
|
|
1916
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
1917
|
+
)
|
|
1918
|
+
|
|
1919
|
+
def ingest_bronze(self, source: Source, plan: IngestionPlan) -> RunResult:
|
|
1920
|
+
"""Dispatch on the table's ingestion.mode, mirroring LocalDuckDB."""
|
|
1921
|
+
ingestion = plan.effective_ingestion()
|
|
1922
|
+
mode = ingestion.mode if ingestion else IngestionMode.FIXTURE_FILE
|
|
1923
|
+
# REST_API / DATABASE / SOAP fetchers drop files into _landing/
|
|
1924
|
+
# just like an external system would for LANDING_ZONE — from here
|
|
1925
|
+
# all four are ingested identically (mirrors LocalDuckDB).
|
|
1926
|
+
if mode in {
|
|
1927
|
+
IngestionMode.LANDING_ZONE,
|
|
1928
|
+
IngestionMode.REST_API,
|
|
1929
|
+
IngestionMode.DATABASE,
|
|
1930
|
+
IngestionMode.SOAP,
|
|
1931
|
+
}:
|
|
1932
|
+
return self._ingest_landing_zone(source, plan)
|
|
1933
|
+
return self._ingest_fixture(source, plan)
|
|
1934
|
+
|
|
1935
|
+
# ---- fixture mode (legacy demo path) ---------------------------------
|
|
1936
|
+
|
|
1937
|
+
def _ingest_fixture(self, source: Source, plan: IngestionPlan) -> RunResult:
|
|
1938
|
+
fixture = self._resolve_fixture(source, plan)
|
|
1939
|
+
if fixture is None:
|
|
1940
|
+
return RunResult(
|
|
1941
|
+
status=RunStatus.FAILED,
|
|
1942
|
+
message="no fixture source configured for this Databricks adapter instance",
|
|
1943
|
+
)
|
|
1944
|
+
if not fixture.exists():
|
|
1945
|
+
return RunResult(
|
|
1946
|
+
status=RunStatus.FAILED,
|
|
1947
|
+
message=f"fixture not found: {fixture}",
|
|
1948
|
+
)
|
|
1949
|
+
|
|
1950
|
+
start = time.monotonic()
|
|
1951
|
+
catalog = self._catalog()
|
|
1952
|
+
schema = plan.target.schema_name
|
|
1953
|
+
table = plan.target.table_name
|
|
1954
|
+
target_fq = _fq(catalog, schema, table)
|
|
1955
|
+
volume = self.profile.get("ingest_volume", DEFAULT_INGEST_VOLUME)
|
|
1956
|
+
|
|
1957
|
+
try:
|
|
1958
|
+
self.create_schema(schema)
|
|
1959
|
+
remote_path = self._upload_to_volume(fixture, volume, source.name, fixture.name)
|
|
1960
|
+
self._execute(
|
|
1961
|
+
f"CREATE TABLE IF NOT EXISTS {target_fq} "
|
|
1962
|
+
f"AS SELECT * FROM read_files(?, format => 'csv', header => true) LIMIT 0",
|
|
1963
|
+
parameters=[remote_path],
|
|
1964
|
+
)
|
|
1965
|
+
self._execute(
|
|
1966
|
+
f"COPY INTO {target_fq} "
|
|
1967
|
+
f"FROM (SELECT * FROM read_files(?, format => 'csv', header => true)) "
|
|
1968
|
+
f"FILEFORMAT = CSV "
|
|
1969
|
+
f"COPY_OPTIONS ('mergeSchema' = 'true')",
|
|
1970
|
+
parameters=[remote_path],
|
|
1971
|
+
)
|
|
1972
|
+
rows = self._scalar(f"SELECT COUNT(*) FROM {target_fq}")
|
|
1973
|
+
except Exception as e:
|
|
1974
|
+
return RunResult(
|
|
1975
|
+
status=RunStatus.FAILED,
|
|
1976
|
+
message=f"ingest failed: {e}",
|
|
1977
|
+
)
|
|
1978
|
+
|
|
1979
|
+
return RunResult(
|
|
1980
|
+
status=RunStatus.SUCCESS,
|
|
1981
|
+
message=f"ingested {rows} rows into {catalog}.{schema}.{table}",
|
|
1982
|
+
rows_affected=int(rows) if rows is not None else None,
|
|
1983
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
1984
|
+
)
|
|
1985
|
+
|
|
1986
|
+
# ---- landing_zone mode (real production path) -----------------------
|
|
1987
|
+
|
|
1988
|
+
def _ingest_landing_zone(self, source: Source, plan: IngestionPlan) -> RunResult:
|
|
1989
|
+
"""Drive bronze ingest off :class:`LandingStorage` (ADR 0046 phase 3).
|
|
1990
|
+
|
|
1991
|
+
The warehouse reads the landing bytes directly via ``read_files()``
|
|
1992
|
+
against the URL the storage layer hands back — no pod-mediated
|
|
1993
|
+
upload to Unity Catalog Volumes. ``move_to_raw`` rotates the file
|
|
1994
|
+
atomically (rename on HNS-ADLS); the URL the warehouse consumes
|
|
1995
|
+
is the post-rotation one, so the read targets a stable path.
|
|
1996
|
+
"""
|
|
1997
|
+
ingestion = plan.effective_ingestion()
|
|
1998
|
+
assert ingestion is not None
|
|
1999
|
+
|
|
2000
|
+
storage = self.landing_storage()
|
|
2001
|
+
refs = list(storage.list(source.name, plan.table.physical_name))
|
|
2002
|
+
if not refs:
|
|
2003
|
+
return RunResult(
|
|
2004
|
+
status=RunStatus.SKIPPED,
|
|
2005
|
+
message=(f"no landing files for {source.name}/{plan.table.physical_name}"),
|
|
2006
|
+
)
|
|
2007
|
+
|
|
2008
|
+
start = time.monotonic()
|
|
2009
|
+
catalog = self._catalog()
|
|
2010
|
+
schema = plan.target.schema_name
|
|
2011
|
+
table = plan.target.table_name
|
|
2012
|
+
target_fq = _fq(catalog, schema, table)
|
|
2013
|
+
today = datetime.now(UTC).date()
|
|
2014
|
+
|
|
2015
|
+
self.create_schema(schema)
|
|
2016
|
+
|
|
2017
|
+
# Issue #366 — refuse to MERGE into a pre-ADR-0124 target.
|
|
2018
|
+
# ``WHEN MATCHED THEN UPDATE SET *`` expands against the
|
|
2019
|
+
# target's columns; a target carrying ``_dca_*`` meta cols
|
|
2020
|
+
# would fail every file's MERGE and let bronze report 0 rows
|
|
2021
|
+
# silently. The per-file ``except Exception`` loop in
|
|
2022
|
+
# ``runner.py`` would catch and continue, masking the cause.
|
|
2023
|
+
stale_meta = self._detect_stale_meta_columns(target_fq)
|
|
2024
|
+
if stale_meta:
|
|
2025
|
+
return RunResult(
|
|
2026
|
+
status=RunStatus.FAILED,
|
|
2027
|
+
message=str(StaleSchemaError(target_fq, stale_meta)),
|
|
2028
|
+
)
|
|
2029
|
+
|
|
2030
|
+
files_ingested = 0
|
|
2031
|
+
total_rows = 0
|
|
2032
|
+
errors: list[str] = []
|
|
2033
|
+
|
|
2034
|
+
for ref in sorted(refs, key=lambda r: r.name):
|
|
2035
|
+
try:
|
|
2036
|
+
rows = self._merge_one_file(
|
|
2037
|
+
storage=storage,
|
|
2038
|
+
ref=ref,
|
|
2039
|
+
target_fq=target_fq,
|
|
2040
|
+
fmt=ingestion.format.value,
|
|
2041
|
+
dedupe_key=list(ingestion.dedupe_key),
|
|
2042
|
+
deleted_flag=ingestion.deleted_flag,
|
|
2043
|
+
today=today,
|
|
2044
|
+
json_shape=ingestion.json_shape,
|
|
2045
|
+
)
|
|
2046
|
+
except Exception as e:
|
|
2047
|
+
errors.append(f"{ref.name}: {e}")
|
|
2048
|
+
continue
|
|
2049
|
+
files_ingested += 1
|
|
2050
|
+
total_rows += rows
|
|
2051
|
+
|
|
2052
|
+
status = RunStatus.SUCCESS if not errors else RunStatus.FAILED
|
|
2053
|
+
return RunResult(
|
|
2054
|
+
status=status,
|
|
2055
|
+
message=(
|
|
2056
|
+
f"ingested {files_ingested} files ({total_rows} rows) "
|
|
2057
|
+
f"into {catalog}.{schema}.{table}"
|
|
2058
|
+
+ (f" — errors: {len(errors)}; first: {errors[0]}" if errors else "")
|
|
2059
|
+
),
|
|
2060
|
+
rows_affected=total_rows,
|
|
2061
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
2062
|
+
)
|
|
2063
|
+
|
|
2064
|
+
def _merge_one_file(
|
|
2065
|
+
self,
|
|
2066
|
+
*,
|
|
2067
|
+
storage: Any,
|
|
2068
|
+
ref: Any,
|
|
2069
|
+
target_fq: str,
|
|
2070
|
+
fmt: str,
|
|
2071
|
+
dedupe_key: list[str],
|
|
2072
|
+
deleted_flag: str | None,
|
|
2073
|
+
today: date,
|
|
2074
|
+
json_shape: str = "array",
|
|
2075
|
+
) -> int:
|
|
2076
|
+
"""Move landing → _raw, MERGE (or INSERT) the warehouse-native URL.
|
|
2077
|
+
|
|
2078
|
+
Phase 3: the warehouse reads from
|
|
2079
|
+
``storage.url_for_native_read(post_move_ref)`` directly. No
|
|
2080
|
+
upload-to-volume — the bytes never traverse the control-plane
|
|
2081
|
+
pod for ADLS-backed storage. The original landing basename is
|
|
2082
|
+
preserved as ``source_file`` for audit attribution.
|
|
2083
|
+
|
|
2084
|
+
``json_shape`` selects between ``multiLine=true`` (array /
|
|
2085
|
+
single-object files) and ``multiLine=false`` (NDJSON, the
|
|
2086
|
+
default integration-platform output). See
|
|
2087
|
+
:func:`build_staging_select_sql` for the full mapping.
|
|
2088
|
+
"""
|
|
2089
|
+
landing_name = ref.name
|
|
2090
|
+
trace_id = uuid.uuid4().hex
|
|
2091
|
+
raw_ref = storage.move_to_raw(ref, day=today)
|
|
2092
|
+
remote = storage.url_for_native_read(raw_ref)
|
|
2093
|
+
|
|
2094
|
+
source_select = build_staging_select_sql(
|
|
2095
|
+
volume_path=remote,
|
|
2096
|
+
fmt=fmt,
|
|
2097
|
+
source_file=landing_name,
|
|
2098
|
+
trace_id=trace_id,
|
|
2099
|
+
deleted_flag=deleted_flag,
|
|
2100
|
+
today=today,
|
|
2101
|
+
json_shape=json_shape,
|
|
2102
|
+
)
|
|
2103
|
+
|
|
2104
|
+
# First run creates an empty table with the right schema.
|
|
2105
|
+
self._execute(
|
|
2106
|
+
build_create_table_sql(
|
|
2107
|
+
target_fq=target_fq,
|
|
2108
|
+
source_select=source_select,
|
|
2109
|
+
)
|
|
2110
|
+
)
|
|
2111
|
+
|
|
2112
|
+
if dedupe_key:
|
|
2113
|
+
sql = build_merge_sql(
|
|
2114
|
+
target_fq=target_fq,
|
|
2115
|
+
source_select=source_select,
|
|
2116
|
+
dedupe_key=dedupe_key,
|
|
2117
|
+
)
|
|
2118
|
+
else:
|
|
2119
|
+
sql = build_append_sql(
|
|
2120
|
+
target_fq=target_fq,
|
|
2121
|
+
source_select=source_select,
|
|
2122
|
+
)
|
|
2123
|
+
self._execute(sql)
|
|
2124
|
+
|
|
2125
|
+
rows = self._scalar(f"SELECT COUNT(*) FROM ({source_select}) s")
|
|
2126
|
+
return int(rows) if rows is not None else 0
|
|
2127
|
+
|
|
2128
|
+
def query(self, sql: str, parameters: list[Any] | None = None) -> QueryResult:
|
|
2129
|
+
"""Run a read-only query via the Statement Execution API.
|
|
2130
|
+
|
|
2131
|
+
Column names aren't returned by the SDK's simple data_array path;
|
|
2132
|
+
we fall back to `col_1, col_2, …` if column metadata isn't exposed.
|
|
2133
|
+
"""
|
|
2134
|
+
from databricks.sdk.service.sql import StatementState
|
|
2135
|
+
|
|
2136
|
+
ws = self._workspace()
|
|
2137
|
+
response = ws.statement_execution.execute_statement(
|
|
2138
|
+
statement=sql,
|
|
2139
|
+
warehouse_id=self._warehouse_id(),
|
|
2140
|
+
parameters=[{"value": p, "type": "STRING"} for p in (parameters or [])] or None,
|
|
2141
|
+
wait_timeout="30s",
|
|
2142
|
+
)
|
|
2143
|
+
state = response.status.state if response.status else None
|
|
2144
|
+
if state != StatementState.SUCCEEDED:
|
|
2145
|
+
raise RuntimeError(
|
|
2146
|
+
f"Query did not succeed: state={state}, "
|
|
2147
|
+
f"error={getattr(response.status, 'error', None)}"
|
|
2148
|
+
)
|
|
2149
|
+
|
|
2150
|
+
# Try to pull column names from the manifest; fall back gracefully.
|
|
2151
|
+
col_names: list[str]
|
|
2152
|
+
manifest = getattr(response, "manifest", None)
|
|
2153
|
+
if manifest and manifest.schema and manifest.schema.columns:
|
|
2154
|
+
col_names = [c.name for c in manifest.schema.columns]
|
|
2155
|
+
else:
|
|
2156
|
+
width = (
|
|
2157
|
+
len(response.result.data_array[0])
|
|
2158
|
+
if response.result and response.result.data_array
|
|
2159
|
+
else 0
|
|
2160
|
+
)
|
|
2161
|
+
col_names = [f"col_{i}" for i in range(width)]
|
|
2162
|
+
|
|
2163
|
+
data = [tuple(r) for r in (response.result.data_array or [])] if response.result else []
|
|
2164
|
+
return QueryResult(columns=col_names, rows=data)
|
|
2165
|
+
|
|
2166
|
+
def execute(self, sql: str, parameters: list[Any] | None = None) -> RunResult:
|
|
2167
|
+
"""Run a write statement via the Statement Execution API."""
|
|
2168
|
+
start = time.monotonic()
|
|
2169
|
+
try:
|
|
2170
|
+
self._execute(sql, parameters=parameters)
|
|
2171
|
+
except Exception as e:
|
|
2172
|
+
return RunResult(
|
|
2173
|
+
status=RunStatus.FAILED,
|
|
2174
|
+
message=f"execute failed: {e}",
|
|
2175
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
2176
|
+
)
|
|
2177
|
+
return RunResult(
|
|
2178
|
+
status=RunStatus.SUCCESS,
|
|
2179
|
+
message="ok",
|
|
2180
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
2181
|
+
)
|
|
2182
|
+
|
|
2183
|
+
def describe_table(self, ref: TableRef) -> TableDescription:
|
|
2184
|
+
catalog = self._catalog()
|
|
2185
|
+
cols_rows = self._execute(
|
|
2186
|
+
"SELECT column_name, full_data_type, is_nullable "
|
|
2187
|
+
"FROM system.information_schema.columns "
|
|
2188
|
+
"WHERE table_catalog = ? AND table_schema = ? AND table_name = ? "
|
|
2189
|
+
"ORDER BY ordinal_position",
|
|
2190
|
+
parameters=[catalog, ref.schema_name, ref.table_name],
|
|
2191
|
+
)
|
|
2192
|
+
columns = [
|
|
2193
|
+
ColumnDescription(name=n, data_type=t, nullable=(nullable == "YES"))
|
|
2194
|
+
for (n, t, nullable) in (cols_rows or [])
|
|
2195
|
+
]
|
|
2196
|
+
fq_table = _fq(catalog, ref.schema_name, ref.table_name)
|
|
2197
|
+
row_count = self._row_count_describe_detail(fq_table)
|
|
2198
|
+
if row_count is None:
|
|
2199
|
+
row_count = self._row_count_count_star(fq_table)
|
|
2200
|
+
return TableDescription(ref=ref, row_count=row_count, columns=columns)
|
|
2201
|
+
|
|
2202
|
+
def describe_annotations(self, ref: TableRef) -> LiveTableAnnotations:
|
|
2203
|
+
"""Read live comments + tags from Unity Catalog (ADR 0131 verify).
|
|
2204
|
+
|
|
2205
|
+
All four reads go through ``system.information_schema`` (the same
|
|
2206
|
+
metastore view ``describe_table`` uses), so no new auth surface and
|
|
2207
|
+
no Spark-cluster dependency — a SQL warehouse is enough:
|
|
2208
|
+
|
|
2209
|
+
* ``tables.comment`` / ``columns.comment`` → comments
|
|
2210
|
+
* ``table_tags`` / ``column_tags`` → governance tags
|
|
2211
|
+
|
|
2212
|
+
Tags are returned verbatim; the verify engine scopes the comparison
|
|
2213
|
+
to the contract-managed ``kiri_*`` keys so unrelated operator tags are
|
|
2214
|
+
never mistaken for drift. ``tags_supported`` is always ``True`` here.
|
|
2215
|
+
"""
|
|
2216
|
+
catalog = self._catalog()
|
|
2217
|
+
|
|
2218
|
+
table_comment: str | None = None
|
|
2219
|
+
tbl_rows = self._execute(
|
|
2220
|
+
"SELECT comment FROM system.information_schema.tables "
|
|
2221
|
+
"WHERE table_catalog = ? AND table_schema = ? AND table_name = ?",
|
|
2222
|
+
parameters=[catalog, ref.schema_name, ref.table_name],
|
|
2223
|
+
)
|
|
2224
|
+
if tbl_rows and tbl_rows[0]:
|
|
2225
|
+
table_comment = tbl_rows[0][0]
|
|
2226
|
+
|
|
2227
|
+
col_rows = self._execute(
|
|
2228
|
+
"SELECT column_name, comment FROM system.information_schema.columns "
|
|
2229
|
+
"WHERE table_catalog = ? AND table_schema = ? AND table_name = ?",
|
|
2230
|
+
parameters=[catalog, ref.schema_name, ref.table_name],
|
|
2231
|
+
)
|
|
2232
|
+
columns: dict[str, LiveColumnAnnotation] = {}
|
|
2233
|
+
for name, comment in col_rows or []:
|
|
2234
|
+
columns[name] = LiveColumnAnnotation(comment=comment, tags={})
|
|
2235
|
+
|
|
2236
|
+
tbl_tag_rows = self._execute(
|
|
2237
|
+
"SELECT tag_name, tag_value FROM system.information_schema.table_tags "
|
|
2238
|
+
"WHERE catalog_name = ? AND schema_name = ? AND table_name = ?",
|
|
2239
|
+
parameters=[catalog, ref.schema_name, ref.table_name],
|
|
2240
|
+
)
|
|
2241
|
+
table_tags = {k: ("" if v is None else str(v)) for (k, v) in (tbl_tag_rows or [])}
|
|
2242
|
+
|
|
2243
|
+
col_tag_rows = self._execute(
|
|
2244
|
+
"SELECT column_name, tag_name, tag_value "
|
|
2245
|
+
"FROM system.information_schema.column_tags "
|
|
2246
|
+
"WHERE catalog_name = ? AND schema_name = ? AND table_name = ?",
|
|
2247
|
+
parameters=[catalog, ref.schema_name, ref.table_name],
|
|
2248
|
+
)
|
|
2249
|
+
for col_name, tag_name, tag_value in col_tag_rows or []:
|
|
2250
|
+
existing = columns.get(col_name)
|
|
2251
|
+
tag_val = "" if tag_value is None else str(tag_value)
|
|
2252
|
+
if existing is None:
|
|
2253
|
+
# A column with a tag but no row in the columns read is
|
|
2254
|
+
# unexpected, but record it rather than drop the tag silently.
|
|
2255
|
+
columns[col_name] = LiveColumnAnnotation(comment=None, tags={tag_name: tag_val})
|
|
2256
|
+
else:
|
|
2257
|
+
columns[col_name] = existing.model_copy(
|
|
2258
|
+
update={"tags": {**existing.tags, tag_name: tag_val}}
|
|
2259
|
+
)
|
|
2260
|
+
|
|
2261
|
+
return LiveTableAnnotations(
|
|
2262
|
+
ref=ref,
|
|
2263
|
+
table_comment=table_comment,
|
|
2264
|
+
table_tags=table_tags,
|
|
2265
|
+
columns=columns,
|
|
2266
|
+
tags_supported=True,
|
|
2267
|
+
)
|
|
2268
|
+
|
|
2269
|
+
def _row_count_describe_detail(self, fq_table: str) -> int | None:
|
|
2270
|
+
"""N3: prefer ``DESCRIBE DETAIL`` when ``numRecords`` is present.
|
|
2271
|
+
|
|
2272
|
+
Recent Databricks runtimes (and Delta tables with stats
|
|
2273
|
+
collection enabled) include ``numRecords`` in
|
|
2274
|
+
``DESCRIBE DETAIL``'s output. That's a metadata read — bounded
|
|
2275
|
+
cost regardless of table size, vs ``COUNT(*)`` which can scan
|
|
2276
|
+
billions of files on stats-disabled tables. Falls back to
|
|
2277
|
+
``COUNT(*)`` when the field is absent.
|
|
2278
|
+
|
|
2279
|
+
Returns ``None`` when DESCRIBE DETAIL doesn't yield a row,
|
|
2280
|
+
the column is missing, or the value is ``NULL`` — caller
|
|
2281
|
+
falls through to ``_row_count_count_star``.
|
|
2282
|
+
"""
|
|
2283
|
+
try:
|
|
2284
|
+
rows = self._execute(f"DESCRIBE DETAIL {fq_table}")
|
|
2285
|
+
except Exception:
|
|
2286
|
+
return None
|
|
2287
|
+
if not rows or not rows[0]:
|
|
2288
|
+
return None
|
|
2289
|
+
# The result columns vary by Databricks Runtime version.
|
|
2290
|
+
# We can't introspect column names from the SDK's data_array
|
|
2291
|
+
# response without manifest metadata; instead we look for an
|
|
2292
|
+
# int-typed value with magnitude consistent with a row count.
|
|
2293
|
+
# The shape we expect is: (format, id, name, description,
|
|
2294
|
+
# location, ..., numFiles, sizeInBytes, ..., numRecords?).
|
|
2295
|
+
# Newer DBR returns ``numRecords`` as a typed long.
|
|
2296
|
+
# Heuristic: scan the row for a non-negative integer that's
|
|
2297
|
+
# reasonably-bounded (< 2^60); pick the *last* such value
|
|
2298
|
+
# because numRecords (when present) is appended after older
|
|
2299
|
+
# columns. False positives are caught by the fallback path —
|
|
2300
|
+
# COUNT(*) is the source of truth, this is just an
|
|
2301
|
+
# optimisation when we can avoid it cheaply.
|
|
2302
|
+
candidate: int | None = None
|
|
2303
|
+
for value in rows[0]:
|
|
2304
|
+
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
|
2305
|
+
candidate = value
|
|
2306
|
+
return candidate
|
|
2307
|
+
|
|
2308
|
+
def _row_count_count_star(self, fq_table: str) -> int | None:
|
|
2309
|
+
"""Slow-path row count — Delta optimises ``SELECT COUNT(*)``
|
|
2310
|
+
to a metadata aggregation in most cases, but very large
|
|
2311
|
+
non-stats-collected tables still pay file-scan cost. Wrapped
|
|
2312
|
+
so a permission denial doesn't block ``describe_table``.
|
|
2313
|
+
"""
|
|
2314
|
+
try:
|
|
2315
|
+
value = self._scalar(f"SELECT COUNT(*) FROM {fq_table}")
|
|
2316
|
+
return int(value) if value is not None else None
|
|
2317
|
+
except Exception:
|
|
2318
|
+
return None
|
|
2319
|
+
|
|
2320
|
+
def supports(self, capability: str) -> bool:
|
|
2321
|
+
return capability in {
|
|
2322
|
+
"unity-catalog",
|
|
2323
|
+
"delta-lake",
|
|
2324
|
+
"managed-volumes",
|
|
2325
|
+
"sql-merge",
|
|
2326
|
+
"time-travel",
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
# ---- maintenance -----------------------------------------------------
|
|
2330
|
+
|
|
2331
|
+
def optimize(self, source_name: str, table_physical_name: str) -> RunResult:
|
|
2332
|
+
catalog = self._catalog()
|
|
2333
|
+
target = _fq(catalog, f"bronze_{source_name}", table_physical_name)
|
|
2334
|
+
start = time.monotonic()
|
|
2335
|
+
try:
|
|
2336
|
+
self._execute(f"OPTIMIZE {target}")
|
|
2337
|
+
except Exception as e:
|
|
2338
|
+
return RunResult(status=RunStatus.FAILED, message=f"OPTIMIZE failed: {e}")
|
|
2339
|
+
return RunResult(
|
|
2340
|
+
status=RunStatus.SUCCESS,
|
|
2341
|
+
message=f"optimized {target}",
|
|
2342
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
2343
|
+
)
|
|
2344
|
+
|
|
2345
|
+
def vacuum(
|
|
2346
|
+
self,
|
|
2347
|
+
source_name: str,
|
|
2348
|
+
table_physical_name: str,
|
|
2349
|
+
*,
|
|
2350
|
+
retention_hours: int = 168,
|
|
2351
|
+
dry_run: bool = False,
|
|
2352
|
+
enforce_retention: bool = True,
|
|
2353
|
+
) -> RunResult:
|
|
2354
|
+
catalog = self._catalog()
|
|
2355
|
+
target = _fq(catalog, f"bronze_{source_name}", table_physical_name)
|
|
2356
|
+
clause = f"RETAIN {retention_hours} HOURS"
|
|
2357
|
+
suffix = " DRY RUN" if dry_run else ""
|
|
2358
|
+
start = time.monotonic()
|
|
2359
|
+
# N4: ``SET spark.databricks.delta.retentionDurationCheck.enabled
|
|
2360
|
+
# = false`` is a session-scoped flag — without an explicit
|
|
2361
|
+
# restore, the next statement that re-uses the warehouse
|
|
2362
|
+
# session inherits the relaxed check. Always restore the
|
|
2363
|
+
# default ``true`` after a vacuum that flipped it, even when
|
|
2364
|
+
# the VACUUM itself raises.
|
|
2365
|
+
flag_was_flipped = False
|
|
2366
|
+
try:
|
|
2367
|
+
if not enforce_retention:
|
|
2368
|
+
self._execute("SET spark.databricks.delta.retentionDurationCheck.enabled = false")
|
|
2369
|
+
flag_was_flipped = True
|
|
2370
|
+
self._execute(f"VACUUM {target} {clause}{suffix}")
|
|
2371
|
+
except Exception as e:
|
|
2372
|
+
return RunResult(status=RunStatus.FAILED, message=f"VACUUM failed: {e}")
|
|
2373
|
+
finally:
|
|
2374
|
+
if flag_was_flipped:
|
|
2375
|
+
# Best-effort restore — a failure here is logged via
|
|
2376
|
+
# ``_execute``'s normal path but does not turn a
|
|
2377
|
+
# successful vacuum into a failed RunResult.
|
|
2378
|
+
import contextlib
|
|
2379
|
+
|
|
2380
|
+
with contextlib.suppress(Exception):
|
|
2381
|
+
self._execute(
|
|
2382
|
+
"SET spark.databricks.delta.retentionDurationCheck.enabled = true"
|
|
2383
|
+
)
|
|
2384
|
+
verb = "would vacuum" if dry_run else "vacuumed"
|
|
2385
|
+
return RunResult(
|
|
2386
|
+
status=RunStatus.SUCCESS,
|
|
2387
|
+
message=f"{verb} {target} (retention={retention_hours}h)",
|
|
2388
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
2389
|
+
)
|
|
2390
|
+
|
|
2391
|
+
def apply_retention(
|
|
2392
|
+
self,
|
|
2393
|
+
table_fqn: str,
|
|
2394
|
+
days: int,
|
|
2395
|
+
*,
|
|
2396
|
+
dry_run: bool = False,
|
|
2397
|
+
) -> RetentionReport:
|
|
2398
|
+
"""Dispatch Delta VACUUM + ``delta.logRetentionDuration`` (ADR 0114 §4).
|
|
2399
|
+
|
|
2400
|
+
``table_fqn`` is the canonical ``<schema>.<table>``; Databricks
|
|
2401
|
+
runs in the configured catalog (``profile['catalog']``) so the
|
|
2402
|
+
adapter prefixes it on the way to SQL. Retention is recorded
|
|
2403
|
+
twice — as a TBLPROPERTIES setting so Delta's own expiry math
|
|
2404
|
+
respects the contract value, and as a VACUUM that physically
|
|
2405
|
+
removes the now-out-of-window files. Both are idempotent.
|
|
2406
|
+
"""
|
|
2407
|
+
if days <= 0:
|
|
2408
|
+
raise ValueError(f"retention days must be positive, got {days!r}")
|
|
2409
|
+
catalog = self._catalog()
|
|
2410
|
+
# Accept either ``schema.table`` or a pre-qualified
|
|
2411
|
+
# ``catalog.schema.table`` — the latter is what operators
|
|
2412
|
+
# might paste from describe output.
|
|
2413
|
+
parts = table_fqn.split(".")
|
|
2414
|
+
if len(parts) == 2:
|
|
2415
|
+
qualified = f"`{catalog}`.`{parts[0]}`.`{parts[1]}`"
|
|
2416
|
+
elif len(parts) == 3:
|
|
2417
|
+
qualified = ".".join(f"`{p}`" for p in parts)
|
|
2418
|
+
else:
|
|
2419
|
+
raise ValueError(
|
|
2420
|
+
f"table_fqn must be 'schema.table' or 'catalog.schema.table', got {table_fqn!r}"
|
|
2421
|
+
)
|
|
2422
|
+
retention_hours = days * 24
|
|
2423
|
+
set_tblprops = (
|
|
2424
|
+
f"ALTER TABLE {qualified} SET TBLPROPERTIES "
|
|
2425
|
+
f"('delta.logRetentionDuration' = 'interval {days} days')"
|
|
2426
|
+
)
|
|
2427
|
+
vacuum_stmt = f"VACUUM {qualified} RETAIN {retention_hours} HOURS"
|
|
2428
|
+
if dry_run:
|
|
2429
|
+
vacuum_stmt += " DRY RUN"
|
|
2430
|
+
commands = [set_tblprops, vacuum_stmt]
|
|
2431
|
+
if dry_run:
|
|
2432
|
+
return RetentionReport(
|
|
2433
|
+
action=RetentionAction.PREVIEWED,
|
|
2434
|
+
mechanism="VACUUM",
|
|
2435
|
+
table_fqn=table_fqn,
|
|
2436
|
+
days=days,
|
|
2437
|
+
commands=commands,
|
|
2438
|
+
message=f"would apply retention={days}d ({retention_hours}h) on {table_fqn}",
|
|
2439
|
+
)
|
|
2440
|
+
start = time.monotonic()
|
|
2441
|
+
try:
|
|
2442
|
+
self._execute(set_tblprops)
|
|
2443
|
+
self._execute(vacuum_stmt)
|
|
2444
|
+
except Exception as e:
|
|
2445
|
+
return RetentionReport(
|
|
2446
|
+
action=RetentionAction.FAILED,
|
|
2447
|
+
mechanism="VACUUM",
|
|
2448
|
+
table_fqn=table_fqn,
|
|
2449
|
+
days=days,
|
|
2450
|
+
commands=commands,
|
|
2451
|
+
message=f"retention dispatch failed: {e}",
|
|
2452
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
2453
|
+
)
|
|
2454
|
+
return RetentionReport(
|
|
2455
|
+
action=RetentionAction.APPLIED,
|
|
2456
|
+
mechanism="VACUUM",
|
|
2457
|
+
table_fqn=table_fqn,
|
|
2458
|
+
days=days,
|
|
2459
|
+
commands=commands,
|
|
2460
|
+
message=f"applied retention={days}d ({retention_hours}h) on {table_fqn}",
|
|
2461
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
2462
|
+
)
|
|
2463
|
+
|
|
2464
|
+
def history(
|
|
2465
|
+
self, source_name: str, table_physical_name: str, limit: int = 10
|
|
2466
|
+
) -> list[dict[str, Any]]:
|
|
2467
|
+
"""Return DESCRIBE HISTORY rows as dicts."""
|
|
2468
|
+
catalog = self._catalog()
|
|
2469
|
+
target = _fq(catalog, f"bronze_{source_name}", table_physical_name)
|
|
2470
|
+
try:
|
|
2471
|
+
rows = self._execute(f"DESCRIBE HISTORY {target} LIMIT {int(limit)}")
|
|
2472
|
+
except Exception:
|
|
2473
|
+
return []
|
|
2474
|
+
if not rows:
|
|
2475
|
+
return []
|
|
2476
|
+
# DESCRIBE HISTORY returns named columns; the statement API doesn't
|
|
2477
|
+
# give us names here, so we shape the rows generically.
|
|
2478
|
+
return [
|
|
2479
|
+
{"version": r[0], "timestamp": r[1], "operation": r[5]} if len(r) >= 6 else {"row": r}
|
|
2480
|
+
for r in rows
|
|
2481
|
+
]
|
|
2482
|
+
|
|
2483
|
+
# ---- internals -------------------------------------------------------
|
|
2484
|
+
|
|
2485
|
+
def _catalog(self) -> str:
|
|
2486
|
+
return str(self.profile.get("catalog", DEFAULT_CATALOG))
|
|
2487
|
+
|
|
2488
|
+
def _warehouse_id(self) -> str:
|
|
2489
|
+
wh = self.profile.get("warehouse_id") or os.environ.get("DATABRICKS_WAREHOUSE_ID")
|
|
2490
|
+
if not wh:
|
|
2491
|
+
raise RuntimeError(
|
|
2492
|
+
"Databricks adapter requires warehouse_id in profile "
|
|
2493
|
+
"or DATABRICKS_WAREHOUSE_ID in env."
|
|
2494
|
+
)
|
|
2495
|
+
return str(wh)
|
|
2496
|
+
|
|
2497
|
+
def _workspace(self) -> Any:
|
|
2498
|
+
"""Construct a WorkspaceClient with one of three auth modes.
|
|
2499
|
+
|
|
2500
|
+
Priority (per profile / env):
|
|
2501
|
+
|
|
2502
|
+
1. **OAuth M2M** (service principal) — ``client_id`` + ``client_secret``.
|
|
2503
|
+
Production-grade; rotation lives in the IdP.
|
|
2504
|
+
2. **PAT** (legacy) — ``token``. Easiest dev setup; rotation
|
|
2505
|
+
manual via Databricks UI.
|
|
2506
|
+
3. **SDK default auth chain** — neither in profile nor env;
|
|
2507
|
+
SDK falls back to azure-cli / databricks-cli / managed
|
|
2508
|
+
identity. Useful for AKS pods running with workload
|
|
2509
|
+
identity bound to the SP.
|
|
2510
|
+
|
|
2511
|
+
S9: when ``KIRIMANA_DATABRICKS_AUTH_MODE`` is set, the
|
|
2512
|
+
constructor asserts that the auto-picked mode matches and
|
|
2513
|
+
raises if it doesn't. Lets operators express the expected
|
|
2514
|
+
deploy-time auth posture (e.g. "this Helm release MUST run
|
|
2515
|
+
OAuth M2M, never PAT") and catch drift early.
|
|
2516
|
+
"""
|
|
2517
|
+
if self._client is None:
|
|
2518
|
+
from databricks.sdk import WorkspaceClient
|
|
2519
|
+
|
|
2520
|
+
picked = self._auth_mode()
|
|
2521
|
+
expected = os.environ.get("KIRIMANA_DATABRICKS_AUTH_MODE")
|
|
2522
|
+
if expected and expected != picked:
|
|
2523
|
+
raise RuntimeError(
|
|
2524
|
+
f"KIRIMANA_DATABRICKS_AUTH_MODE={expected!r} but adapter "
|
|
2525
|
+
f"would use {picked!r}. Either remove the env, change the "
|
|
2526
|
+
"profile / env credentials to match, or set the env to "
|
|
2527
|
+
f"{picked!r} after verifying."
|
|
2528
|
+
)
|
|
2529
|
+
|
|
2530
|
+
host = self.profile.get("host") or os.environ.get("DATABRICKS_HOST")
|
|
2531
|
+
client_id = self.profile.get("client_id") or os.environ.get("DATABRICKS_CLIENT_ID")
|
|
2532
|
+
client_secret = self.profile.get("client_secret") or os.environ.get(
|
|
2533
|
+
"DATABRICKS_CLIENT_SECRET"
|
|
2534
|
+
)
|
|
2535
|
+
token = self.profile.get("token") or os.environ.get("DATABRICKS_TOKEN")
|
|
2536
|
+
|
|
2537
|
+
if client_id and client_secret:
|
|
2538
|
+
self._client = WorkspaceClient(
|
|
2539
|
+
host=host,
|
|
2540
|
+
client_id=client_id,
|
|
2541
|
+
client_secret=client_secret,
|
|
2542
|
+
)
|
|
2543
|
+
elif token:
|
|
2544
|
+
self._client = WorkspaceClient(host=host, token=token)
|
|
2545
|
+
else:
|
|
2546
|
+
# Default chain: azure-cli, workload identity, ~/.databrickscfg.
|
|
2547
|
+
self._client = WorkspaceClient(host=host)
|
|
2548
|
+
return self._client
|
|
2549
|
+
|
|
2550
|
+
def _wait_timeout(self) -> str:
|
|
2551
|
+
"""Configurable per-statement wait timeout.
|
|
2552
|
+
|
|
2553
|
+
Default 30s preserves legacy behaviour. Big MERGE on 10M+ rows
|
|
2554
|
+
easily exceeds — operators raise this in kiri.yml profile to
|
|
2555
|
+
match their warehouse + data size:
|
|
2556
|
+
|
|
2557
|
+
targets:
|
|
2558
|
+
prod:
|
|
2559
|
+
adapter: databricks
|
|
2560
|
+
wait_timeout: "180s" # 3 minutes
|
|
2561
|
+
|
|
2562
|
+
Databricks API caps at ``50s`` for ``wait_timeout`` parameter
|
|
2563
|
+
on /statement-execution; longer values trigger async-mode
|
|
2564
|
+
statements where the SDK polls under the hood. The SDK
|
|
2565
|
+
handles the polling transparently.
|
|
2566
|
+
"""
|
|
2567
|
+
value = self.profile.get("wait_timeout", "30s")
|
|
2568
|
+
# Tolerate plain ints — convert to seconds.
|
|
2569
|
+
if isinstance(value, int | float):
|
|
2570
|
+
return f"{int(value)}s"
|
|
2571
|
+
return str(value)
|
|
2572
|
+
|
|
2573
|
+
@staticmethod
|
|
2574
|
+
def _db_type_for(value: Any) -> str:
|
|
2575
|
+
"""Map a Python value to Databricks Statement API parameter type.
|
|
2576
|
+
|
|
2577
|
+
Falls back to STRING for unknown types — Databricks coerces
|
|
2578
|
+
most things server-side, but typed ints/booleans/dates avoid
|
|
2579
|
+
a class of MERGE-predicate bugs (``"5" = 5`` is false in
|
|
2580
|
+
strict mode).
|
|
2581
|
+
"""
|
|
2582
|
+
# bool first — bool is a subclass of int.
|
|
2583
|
+
if isinstance(value, bool):
|
|
2584
|
+
return "BOOLEAN"
|
|
2585
|
+
if isinstance(value, int):
|
|
2586
|
+
# Python ints are unbounded; Databricks Spark INT is signed
|
|
2587
|
+
# 32-bit (-2^31..2^31-1). Many real-world ID columns (MSSQL
|
|
2588
|
+
# ``bigint``, epoch-ns timestamps, accumulator counters)
|
|
2589
|
+
# exceed that range, and an out-of-range INT parameter on
|
|
2590
|
+
# the Statement Execution API returns a hard cast error.
|
|
2591
|
+
# BIGINT (signed 64-bit) is a safe superset; Spark casts
|
|
2592
|
+
# BIGINT → INT implicitly where the target column is
|
|
2593
|
+
# narrower, so widening here costs nothing and prevents
|
|
2594
|
+
# overflow on the load-bearing path. Live-found 2026-05-23.
|
|
2595
|
+
#
|
|
2596
|
+
# CODEX-FINAL-V2 P2: refuse values outside the 64-bit range
|
|
2597
|
+
# rather than serialising a BIGINT that the Statement API
|
|
2598
|
+
# will reject server-side with an opaque cast error. The
|
|
2599
|
+
# caller can switch to Decimal/string binding if they
|
|
2600
|
+
# genuinely need wider integers (rare for migration data).
|
|
2601
|
+
if not -(2**63) <= value <= 2**63 - 1:
|
|
2602
|
+
raise OverflowError(
|
|
2603
|
+
f"int value {value!r} is outside Spark BIGINT range "
|
|
2604
|
+
f"(-2**63..2**63-1); rebind as a Decimal or string "
|
|
2605
|
+
f"parameter, or narrow the value before passing it to "
|
|
2606
|
+
f"the Databricks Statement Execution API."
|
|
2607
|
+
)
|
|
2608
|
+
return "BIGINT"
|
|
2609
|
+
if isinstance(value, float):
|
|
2610
|
+
return "DOUBLE"
|
|
2611
|
+
if isinstance(value, datetime):
|
|
2612
|
+
return "TIMESTAMP"
|
|
2613
|
+
if isinstance(value, date):
|
|
2614
|
+
return "DATE"
|
|
2615
|
+
# str + everything else → STRING.
|
|
2616
|
+
return "STRING"
|
|
2617
|
+
|
|
2618
|
+
def _execute(
|
|
2619
|
+
self, sql: str, parameters: list[Any] | None = None
|
|
2620
|
+
) -> list[tuple[Any, ...]] | None:
|
|
2621
|
+
"""Run a statement with typed params + transient-error retry.
|
|
2622
|
+
|
|
2623
|
+
Retries up to 3 times on 429 / 503 / connection errors with
|
|
2624
|
+
exponential backoff (0.5s, 1s, 2s). Hard errors (4xx that's
|
|
2625
|
+
not 429, semantic SQL failures) raise immediately — retrying
|
|
2626
|
+
a CREATE TABLE that's malformed won't help.
|
|
2627
|
+
|
|
2628
|
+
ADR 0049: prepends a structured trace-id comment so the
|
|
2629
|
+
Databricks audit log carries ``kiri_trace_id`` /
|
|
2630
|
+
``apply_id`` / ``release_sha`` / ``caller`` in
|
|
2631
|
+
``statement_text``. The Kirimana side issued the same id
|
|
2632
|
+
in its own audit; ``kiri audit join`` matches the two.
|
|
2633
|
+
"""
|
|
2634
|
+
from databricks.sdk.service.sql import (
|
|
2635
|
+
StatementParameterListItem,
|
|
2636
|
+
StatementState,
|
|
2637
|
+
)
|
|
2638
|
+
|
|
2639
|
+
# Prepend the trace comment first; the kirimana-prepended
|
|
2640
|
+
# comment must lead so the audit-log parser doesn't
|
|
2641
|
+
# collide with user-authored ``/* ... */`` content downstream
|
|
2642
|
+
# (e.g. dbt-rendered SQL that already starts with comments).
|
|
2643
|
+
sql = current_trace().sql_comment() + " " + sql
|
|
2644
|
+
|
|
2645
|
+
# The Databricks Statement Execution API supports only *named*
|
|
2646
|
+
# parameter markers (``:p0``); positional ``?`` is sent unbound
|
|
2647
|
+
# and rejected with UNBOUND_SQL_PARAMETER. Callers use ``?``, so
|
|
2648
|
+
# rewrite each positional marker to ``:pN`` in lockstep with the
|
|
2649
|
+
# named params here — ``?`` inside single-quoted literals is left
|
|
2650
|
+
# untouched.
|
|
2651
|
+
params_list = list(parameters or [])
|
|
2652
|
+
if params_list:
|
|
2653
|
+
out: list[str] = []
|
|
2654
|
+
in_str = False
|
|
2655
|
+
marker = 0
|
|
2656
|
+
for ch in sql:
|
|
2657
|
+
if ch == "'":
|
|
2658
|
+
in_str = not in_str
|
|
2659
|
+
out.append(ch)
|
|
2660
|
+
elif ch == "?" and not in_str:
|
|
2661
|
+
out.append(f":p{marker}")
|
|
2662
|
+
marker += 1
|
|
2663
|
+
else:
|
|
2664
|
+
out.append(ch)
|
|
2665
|
+
if marker != len(params_list):
|
|
2666
|
+
raise ValueError(
|
|
2667
|
+
f"parameter count mismatch: SQL has {marker} '?' "
|
|
2668
|
+
f"markers but {len(params_list)} parameters were given."
|
|
2669
|
+
)
|
|
2670
|
+
sql = "".join(out)
|
|
2671
|
+
|
|
2672
|
+
# The SDK serialises each item via ``.as_dict()``; it must be a
|
|
2673
|
+
# ``StatementParameterListItem``, not a raw dict.
|
|
2674
|
+
typed_params = [
|
|
2675
|
+
StatementParameterListItem(
|
|
2676
|
+
name=f"p{i}",
|
|
2677
|
+
value=str(p) if not isinstance(p, str) else p,
|
|
2678
|
+
type=self._db_type_for(p),
|
|
2679
|
+
)
|
|
2680
|
+
for i, p in enumerate(params_list)
|
|
2681
|
+
] or None
|
|
2682
|
+
|
|
2683
|
+
ws = self._workspace()
|
|
2684
|
+
self._gate_rate_limit()
|
|
2685
|
+
max_attempts = self._retry_attempts
|
|
2686
|
+
backoff_base = self._retry_backoff_base
|
|
2687
|
+
last_error: Exception | None = None
|
|
2688
|
+
attempts_used = 0
|
|
2689
|
+
started_at = time.monotonic()
|
|
2690
|
+
response = None
|
|
2691
|
+
for attempt in range(max_attempts):
|
|
2692
|
+
attempts_used = attempt + 1
|
|
2693
|
+
try:
|
|
2694
|
+
response = ws.statement_execution.execute_statement(
|
|
2695
|
+
statement=sql,
|
|
2696
|
+
warehouse_id=self._warehouse_id(),
|
|
2697
|
+
catalog=self._catalog(),
|
|
2698
|
+
parameters=typed_params,
|
|
2699
|
+
wait_timeout=self._wait_timeout(),
|
|
2700
|
+
)
|
|
2701
|
+
break
|
|
2702
|
+
except Exception as e:
|
|
2703
|
+
if not self._is_transient(e):
|
|
2704
|
+
self._emit_audit(
|
|
2705
|
+
sql=sql,
|
|
2706
|
+
duration_ms=int((time.monotonic() - started_at) * 1000),
|
|
2707
|
+
attempts=attempts_used,
|
|
2708
|
+
status="failed",
|
|
2709
|
+
error_class=type(e).__name__,
|
|
2710
|
+
)
|
|
2711
|
+
raise
|
|
2712
|
+
last_error = e
|
|
2713
|
+
if attempt < max_attempts - 1:
|
|
2714
|
+
time.sleep(backoff_base * (2**attempt))
|
|
2715
|
+
else:
|
|
2716
|
+
# All retries exhausted on transient errors.
|
|
2717
|
+
self._emit_audit(
|
|
2718
|
+
sql=sql,
|
|
2719
|
+
duration_ms=int((time.monotonic() - started_at) * 1000),
|
|
2720
|
+
attempts=attempts_used,
|
|
2721
|
+
status="failed",
|
|
2722
|
+
error_class=type(last_error).__name__ if last_error else "Unknown",
|
|
2723
|
+
)
|
|
2724
|
+
raise RuntimeError(
|
|
2725
|
+
f"Statement failed after {max_attempts} attempts (transient errors): {last_error}"
|
|
2726
|
+
) from last_error
|
|
2727
|
+
|
|
2728
|
+
assert response is not None
|
|
2729
|
+
state = response.status.state if response.status else None
|
|
2730
|
+
if state != StatementState.SUCCEEDED:
|
|
2731
|
+
self._emit_audit(
|
|
2732
|
+
sql=sql,
|
|
2733
|
+
duration_ms=int((time.monotonic() - started_at) * 1000),
|
|
2734
|
+
attempts=attempts_used,
|
|
2735
|
+
status="failed",
|
|
2736
|
+
error_class="UnsuccessfulState",
|
|
2737
|
+
)
|
|
2738
|
+
raise RuntimeError(
|
|
2739
|
+
f"Statement did not succeed: state={state}, "
|
|
2740
|
+
f"error={getattr(response.status, 'error', None)}"
|
|
2741
|
+
)
|
|
2742
|
+
|
|
2743
|
+
self._emit_audit(
|
|
2744
|
+
sql=sql,
|
|
2745
|
+
duration_ms=int((time.monotonic() - started_at) * 1000),
|
|
2746
|
+
attempts=attempts_used,
|
|
2747
|
+
status="success",
|
|
2748
|
+
error_class=None,
|
|
2749
|
+
)
|
|
2750
|
+
|
|
2751
|
+
if response.result and response.result.data_array:
|
|
2752
|
+
return [tuple(row) for row in response.result.data_array]
|
|
2753
|
+
return None
|
|
2754
|
+
|
|
2755
|
+
def _emit_audit(
|
|
2756
|
+
self,
|
|
2757
|
+
*,
|
|
2758
|
+
sql: str,
|
|
2759
|
+
duration_ms: int,
|
|
2760
|
+
attempts: int,
|
|
2761
|
+
status: str,
|
|
2762
|
+
error_class: str | None,
|
|
2763
|
+
) -> None:
|
|
2764
|
+
"""Append one ADR 0092 audit record rendered as log + JSONL."""
|
|
2765
|
+
|
|
2766
|
+
enabled, _disabled_reason = self._audit_enabled()
|
|
2767
|
+
if not enabled:
|
|
2768
|
+
if not self._audit_disabled_emitted:
|
|
2769
|
+
self._audit_disabled_emitted = True
|
|
2770
|
+
_emit_databricks_audit_record(
|
|
2771
|
+
profile=self.profile,
|
|
2772
|
+
paths=self._audit_paths,
|
|
2773
|
+
event="AUDIT_DISABLED",
|
|
2774
|
+
target="?",
|
|
2775
|
+
status="failed",
|
|
2776
|
+
duration_ms=0,
|
|
2777
|
+
attempts=0,
|
|
2778
|
+
warehouse_id=self.profile.get("warehouse_id"),
|
|
2779
|
+
via="adapter",
|
|
2780
|
+
user=self._current_user(),
|
|
2781
|
+
error_class=None,
|
|
2782
|
+
statement_preview=_redact_preview(_strip_leading_trace_comment(sql)),
|
|
2783
|
+
)
|
|
2784
|
+
return
|
|
2785
|
+
_emit_databricks_audit_record(
|
|
2786
|
+
profile=self.profile,
|
|
2787
|
+
paths=self._audit_paths,
|
|
2788
|
+
event=databricks_audit_event(sql),
|
|
2789
|
+
target=databricks_audit_target(sql),
|
|
2790
|
+
status=status,
|
|
2791
|
+
duration_ms=duration_ms,
|
|
2792
|
+
attempts=attempts,
|
|
2793
|
+
warehouse_id=self.profile.get("warehouse_id"),
|
|
2794
|
+
via="adapter",
|
|
2795
|
+
user=self._current_user(),
|
|
2796
|
+
error_class=error_class,
|
|
2797
|
+
statement_preview=_redact_preview(_strip_leading_trace_comment(sql)),
|
|
2798
|
+
)
|
|
2799
|
+
|
|
2800
|
+
def _write_audit_record(self, record: _AuditRecord) -> None:
|
|
2801
|
+
_write_audit_record(self._audit_paths, record)
|
|
2802
|
+
|
|
2803
|
+
def _write_audit_line(self, record: _AuditRecord) -> None:
|
|
2804
|
+
_write_audit_line(self._audit_paths, record)
|
|
2805
|
+
|
|
2806
|
+
def _write_audit_jsonl(self, record: _AuditRecord) -> None:
|
|
2807
|
+
_write_audit_jsonl(self._audit_paths, record)
|
|
2808
|
+
|
|
2809
|
+
def _audit_enabled(self) -> tuple[bool, str | None]:
|
|
2810
|
+
return databricks_audit_enabled(self.profile)
|
|
2811
|
+
|
|
2812
|
+
@staticmethod
|
|
2813
|
+
def _resolve_audit_paths(profile: dict[str, Any]) -> _AuditPaths:
|
|
2814
|
+
return _resolve_databricks_audit_paths(profile)
|
|
2815
|
+
|
|
2816
|
+
def _current_user(self) -> str:
|
|
2817
|
+
if self._audit_user is not None:
|
|
2818
|
+
return self._audit_user
|
|
2819
|
+
try:
|
|
2820
|
+
ws = self._workspace()
|
|
2821
|
+
response = ws.statement_execution.execute_statement(
|
|
2822
|
+
statement="SELECT current_user()",
|
|
2823
|
+
warehouse_id=self._warehouse_id(),
|
|
2824
|
+
wait_timeout=self._wait_timeout(),
|
|
2825
|
+
)
|
|
2826
|
+
user = _extract_current_user(response)
|
|
2827
|
+
except Exception:
|
|
2828
|
+
user = "unknown"
|
|
2829
|
+
self._audit_user = user or "unknown"
|
|
2830
|
+
return self._audit_user
|
|
2831
|
+
|
|
2832
|
+
def _gate_rate_limit(self) -> None:
|
|
2833
|
+
"""Throttle Statement Execution API calls to ``statement_rps_limit``.
|
|
2834
|
+
|
|
2835
|
+
S8: belt-and-braces against self-DoS. The Databricks SDK
|
|
2836
|
+
already retries 429s, but a tight ``kiri apply`` loop on a
|
|
2837
|
+
small warehouse can produce sustained 429-storms that waste
|
|
2838
|
+
the retry budget on every statement. This token-bucket
|
|
2839
|
+
sleeps the *next* call so the gap between calls honours the
|
|
2840
|
+
configured rate. Off by default (no profile knob set), so
|
|
2841
|
+
existing setups see no behaviour change.
|
|
2842
|
+
"""
|
|
2843
|
+
if self._rps_limit is None:
|
|
2844
|
+
return
|
|
2845
|
+
min_interval = 1.0 / self._rps_limit
|
|
2846
|
+
now = time.monotonic()
|
|
2847
|
+
wait = min_interval - (now - self._last_call_at)
|
|
2848
|
+
if wait > 0:
|
|
2849
|
+
time.sleep(wait)
|
|
2850
|
+
now = time.monotonic()
|
|
2851
|
+
self._last_call_at = now
|
|
2852
|
+
|
|
2853
|
+
def _auth_mode(self) -> str:
|
|
2854
|
+
"""Return the auth mode picked for the workspace client.
|
|
2855
|
+
|
|
2856
|
+
S9: makes the silent auth-priority decision observable.
|
|
2857
|
+
Operators can read ``adapter._auth_mode()`` (or set
|
|
2858
|
+
``KIRIMANA_DATABRICKS_AUTH_MODE`` to assert) without reading
|
|
2859
|
+
the SDK's auth chain implementation.
|
|
2860
|
+
"""
|
|
2861
|
+
if self.profile.get("client_id") and self.profile.get("client_secret"):
|
|
2862
|
+
return "oauth_m2m"
|
|
2863
|
+
if os.environ.get("DATABRICKS_CLIENT_ID") and os.environ.get("DATABRICKS_CLIENT_SECRET"):
|
|
2864
|
+
return "oauth_m2m"
|
|
2865
|
+
if self.profile.get("token") or os.environ.get("DATABRICKS_TOKEN"):
|
|
2866
|
+
return "pat"
|
|
2867
|
+
return "default_chain"
|
|
2868
|
+
|
|
2869
|
+
@staticmethod
|
|
2870
|
+
def _is_transient(exc: Exception) -> bool:
|
|
2871
|
+
"""Classify transient failures by SDK exception type, with
|
|
2872
|
+
message-based fallback for transport-layer errors.
|
|
2873
|
+
|
|
2874
|
+
S5: the SDK exposes typed exceptions in
|
|
2875
|
+
``databricks.sdk.errors`` that map to specific HTTP /
|
|
2876
|
+
platform conditions. Catching the type is robust to SDK
|
|
2877
|
+
message changes; the legacy string match was brittle as the
|
|
2878
|
+
SDK kept evolving its error wrappers.
|
|
2879
|
+
|
|
2880
|
+
Transport-level errors (``requests.exceptions.*``,
|
|
2881
|
+
``urllib3``) don't go through the SDK error mapper; for those
|
|
2882
|
+
we keep a narrow message-based fallback.
|
|
2883
|
+
"""
|
|
2884
|
+
try:
|
|
2885
|
+
from databricks.sdk.errors import (
|
|
2886
|
+
DeadlineExceeded,
|
|
2887
|
+
InternalError,
|
|
2888
|
+
OperationTimeout,
|
|
2889
|
+
RequestLimitExceeded,
|
|
2890
|
+
TemporarilyUnavailable,
|
|
2891
|
+
TooManyRequests,
|
|
2892
|
+
)
|
|
2893
|
+
except ImportError:
|
|
2894
|
+
# SDK not installed (unlikely on the Databricks adapter
|
|
2895
|
+
# path; keeps the classifier callable in tests that stub
|
|
2896
|
+
# the SDK away).
|
|
2897
|
+
transient_sdk_types: tuple[type[Exception], ...] = ()
|
|
2898
|
+
else:
|
|
2899
|
+
transient_sdk_types = (
|
|
2900
|
+
TooManyRequests,
|
|
2901
|
+
RequestLimitExceeded,
|
|
2902
|
+
TemporarilyUnavailable,
|
|
2903
|
+
DeadlineExceeded,
|
|
2904
|
+
OperationTimeout,
|
|
2905
|
+
InternalError,
|
|
2906
|
+
)
|
|
2907
|
+
|
|
2908
|
+
if transient_sdk_types and isinstance(exc, transient_sdk_types):
|
|
2909
|
+
return True
|
|
2910
|
+
|
|
2911
|
+
# Transport-layer fallback. The SDK doesn't wrap connection
|
|
2912
|
+
# resets / DNS timeouts / TLS hiccups; those raise from
|
|
2913
|
+
# ``urllib3`` / ``requests`` directly. Narrow on purpose — the
|
|
2914
|
+
# safety net for "request never reached the SDK error mapper,"
|
|
2915
|
+
# not a general string-matcher.
|
|
2916
|
+
msg = str(exc).lower()
|
|
2917
|
+
if "connection" in msg and ("reset" in msg or "aborted" in msg):
|
|
2918
|
+
return True
|
|
2919
|
+
return "timeout" in msg and ("connect" in msg or "read" in msg)
|
|
2920
|
+
|
|
2921
|
+
def _scalar(self, sql: str) -> Any:
|
|
2922
|
+
rows = self._execute(sql)
|
|
2923
|
+
if not rows:
|
|
2924
|
+
return None
|
|
2925
|
+
return rows[0][0]
|
|
2926
|
+
|
|
2927
|
+
def _detect_stale_meta_columns(self, target_fq: str) -> list[str]:
|
|
2928
|
+
"""Return ``_dca_*`` meta column names on ``target_fq`` if any survive.
|
|
2929
|
+
|
|
2930
|
+
Used by ``_ingest_landing_zone`` as a one-shot pre-MERGE guard
|
|
2931
|
+
(issue #366). Returns ``[]`` if the table doesn't exist yet
|
|
2932
|
+
(``CREATE TABLE IF NOT EXISTS`` runs immediately after this in
|
|
2933
|
+
``_merge_one_file``) or if every meta column is already
|
|
2934
|
+
renamed.
|
|
2935
|
+
"""
|
|
2936
|
+
try:
|
|
2937
|
+
rows = self._execute(f"DESCRIBE TABLE {target_fq}")
|
|
2938
|
+
except Exception:
|
|
2939
|
+
# Most commonly: TABLE_OR_VIEW_NOT_FOUND. CREATE TABLE IF
|
|
2940
|
+
# NOT EXISTS will run next; no stale schema to guard.
|
|
2941
|
+
return []
|
|
2942
|
+
return _stale_meta_columns_from_describe(rows)
|
|
2943
|
+
|
|
2944
|
+
def _resolve_fixture(self, source: Source, plan: IngestionPlan) -> Path | None:
|
|
2945
|
+
fixtures_dir = self.profile.get("fixtures_dir")
|
|
2946
|
+
if not fixtures_dir:
|
|
2947
|
+
return None
|
|
2948
|
+
return Path(fixtures_dir) / source.name / f"{plan.table.physical_name}.csv"
|
|
2949
|
+
|
|
2950
|
+
def _upload_to_volume(
|
|
2951
|
+
self, local_path: Path, volume: str, source_name: str, filename: str
|
|
2952
|
+
) -> str:
|
|
2953
|
+
"""Upload one local file to a UC Volume (legacy fixture path).
|
|
2954
|
+
|
|
2955
|
+
ADR 0049: the SDK doesn't expose a per-call User-Agent
|
|
2956
|
+
override on ``files.upload()``; instead we set
|
|
2957
|
+
``Config.user_agent_extra`` once on the WorkspaceClient so
|
|
2958
|
+
every Files API call carries the active trace context
|
|
2959
|
+
in its outgoing User-Agent header. The Files API audit log
|
|
2960
|
+
captures ``user_agent`` verbatim — auditors recover the
|
|
2961
|
+
trace-id from there.
|
|
2962
|
+
"""
|
|
2963
|
+
parts = volume.split(".")
|
|
2964
|
+
if len(parts) != 3:
|
|
2965
|
+
raise ValueError(f"ingest_volume must be catalog.schema.volume (got {volume!r})")
|
|
2966
|
+
remote = f"/Volumes/{parts[0]}/{parts[1]}/{parts[2]}/{source_name}/{filename}"
|
|
2967
|
+
ws = self._workspace()
|
|
2968
|
+
# Per-upload trace stamp on the SDK's user-agent. SDK reads
|
|
2969
|
+
# this attribute on every outgoing request, so updating it
|
|
2970
|
+
# here propagates to the very next ``upload`` call without
|
|
2971
|
+
# reconstructing the client. Restored to the prior value on
|
|
2972
|
+
# function exit so concurrent callers don't see leaked state.
|
|
2973
|
+
prior_ua_extra = getattr(ws.config, "user_agent_extra", "")
|
|
2974
|
+
ws.config.user_agent_extra = current_trace().user_agent()
|
|
2975
|
+
try:
|
|
2976
|
+
with local_path.open("rb") as f:
|
|
2977
|
+
ws.files.upload(remote, f, overwrite=True)
|
|
2978
|
+
finally:
|
|
2979
|
+
ws.config.user_agent_extra = prior_ua_extra
|
|
2980
|
+
return remote
|