diff --git a/Makefile b/Makefile index f37d3a12ba..16496970d5 100644 --- a/Makefile +++ b/Makefile @@ -121,7 +121,11 @@ E2E_DUCKDB_CLI_DATA_PATHS := \ tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_authored_check_sql.py \ tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_scenario.py \ tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_capture.py \ - tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_namespace.py + tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_namespace.py \ + tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_promotion.py \ + tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_concurrency.py \ + tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_interrupts.py \ + tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_empty_fixture.py E2E_DUCKDB_CLI_PATHS := \ tests/e2e/scripts/cli_preview \ diff --git a/fensu.toml b/fensu.toml index fc7cc68f9e..89e4d7f529 100644 --- a/fensu.toml +++ b/fensu.toml @@ -99,6 +99,12 @@ path = "src/sqlbuild/compiler/planner/_helpers/warehouse/source_columns.py" symbols = ["gather_source_inspection"] reason = "Each type-enforced expression source is its own query whose output columns can only be read by running it; table sources are listed in one batch per database." +[[targets.python.rule_exceptions]] +rule = "XSB051" +path = "src/sqlbuild/executor/scenario/_helpers/lifecycle/cleanup.py" +symbols = ["existing_scenario_cleanup_targets"] +reason = "Scenario cleanup lists every scenario-owned relation in one batch per database instead of dropping each planned relation blindly." + [[targets.python.rule_exceptions]] rule = "XSB051" path = "src/sqlbuild/executor/migrations/_helpers/columns.py" diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/scenario.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/scenario.md index 27155865a1..05c25fa021 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/scenario.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/scenario.md @@ -32,6 +32,7 @@ sqb scenario test [flags] | `--scenario-namespace NAMESPACE` | Isolate warehouse artifacts for this run; overrides environment and config | | `--local` | Run locally against DuckDB using captured JSONL snapshots | | `--strict` | Treat missing/stale local snapshots as errors instead of skips | +| `--concurrency N` | Run up to `N` scenarios at once (default: the project `concurrency` setting) | | `--sync-snapshots` | Capture missing/stale snapshots before local run (requires `--local`) | | `--refresh` | Recapture all selected snapshots before local run (requires `--local`) | | `--force` | Bypass snapshot capture safety limits | @@ -93,17 +94,20 @@ sqb scenario test --local --strict Remote scenarios report per-scenario PASS/FAIL with nested check rows: ``` -daily_revenue_minimal PASS +daily_revenue_minimal PASS 0.84s expect expected daily_revenue PASS expect assertion no_negative_revenue PASS -PASS=1 FAIL=0 TOTAL=1 +PASS=1 FAIL=0 TOTAL=1 (0.91s) ``` +Each scenario row shows its wall time and the summary shows the total. `--json` reports the same +values as `scenarios[].duration_ms` and `summary.duration_ms`. + Local scenarios add ERROR and SKIP statuses: ``` -PASS=2 FAIL=0 ERROR=0 SKIP=1 TOTAL=3 +PASS=2 FAIL=0 ERROR=0 SKIP=1 TOTAL=3 (1.20s) ``` Failed scenarios suggest rerunning with `--retain` for inspection. Local runs always keep the DuckDB file at `target/run/scenarios//local.duckdb`. diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/materializations.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/materializations.md index 05ff176ab4..a9caf5a4f7 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/materializations.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/materializations.md @@ -33,7 +33,7 @@ FROM __ref("stg_orders") GROUP BY customer_id ``` -Projects may opt into `settings.table_promotion_mode = "immediate"`. Immediate promotion replaces the destination before audits, so a failed audit does not restore the old table. Immediate promotion rejects models that require declared-type enforcement or `contract enforced`; use staged promotion for those guarantees. +Projects may opt into `settings.table_promotion_mode = "immediate"`. Immediate promotion replaces the destination before audits, so a failed audit does not restore the old table. Immediate promotion rejects models that require declared-type enforcement or `contract enforced`; use staged promotion for those guarantees. A table or incremental model with `contract enforced` under immediate promotion is a `K011` compile error, so `sqb compile`, `sqb plan`, `sqb build`, and `sqb scenario test` report it before any warehouse work. ## Incremental diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/scenarios.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/scenarios.md index df22b9868d..fdde2184a0 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/scenarios.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/scenarios.md @@ -106,6 +106,10 @@ SELECT 1 For sources with two-part identity, use double underscores: `__source__raw__orders`. +To mock a contracted source, ref, or seed with no rows, use `SELECT * FROM __empty_fixture()` as +the complete fixture body. SQLBuild expands it to the relation's declared, typed columns with a +false filter, as in [unit tests](testing.md). + Every scenario must have at least one fixture CTE and at least one `__expected__` or `__assert__` CTE. Scenario SQL uses macros, enums, and constants available from the scenario file's directory under @@ -145,13 +149,32 @@ Scenario artifacts are physically isolated from production: ### Execution flow -1. Clean any existing scenario artifacts from a previous run in the effective run namespace +1. Drop any scenario artifacts a previous run left in the effective run namespace (one catalog read finds them) 2. Materialize source, ref, and seed fixtures as physical tables 3. Load required project seeds 4. Build required models in dependency order (incremental models run as full-refresh in scenarios) 5. Run expected-output comparisons (order-insensitive) 6. Run zero-row assertions -7. Clean up this namespace's scenario-owned artifacts (unless `--retain`) +7. Clean up this namespace's scenario-owned artifacts (unless `--retain`), including after a failure or Ctrl-C + +Table models use the same promotion as `sqb build`: the project's `table_promotion_mode`, or the +adapter default (staged) when it is unset. An enforced contract needs staged promotion, so its +runtime contract check runs before the scenario reaches its expectations; with +`table_promotion_mode = "immediate"` such a model is a `K011` compile error, reported before any +scenario runs, exactly as in `sqb compile`, `sqb plan`, and `sqb build`. + +Independent scenarios run concurrently, up to `--concurrency` or the project `concurrency` +setting. Each scenario writes only its own prefixed relations, so concurrent scenarios never +share an artifact. Results are reported in selection order with each scenario's wall time, and the +summary includes the total time. On DuckDB, concurrent scenarios use separate connections to the +same database file; an in-memory database (`:memory:`) gives each connection its own database, so +fixtures still isolate every scenario. + +On Ctrl-C, SQLBuild prints one notice, cancels scenarios that have not started, cancels the +statements running scenarios have in flight, drops their relations (unless `--retain`), and only +then exits. A second Ctrl-C skips that cleanup and exits immediately; the next run of the same +scenarios drops whatever they left behind. `--local` replay and `sqb scenario capture` run one +scenario at a time. ### Parallel CI runs diff --git a/src/sqlbuild/adapter/contract/classes/base_adapter.py b/src/sqlbuild/adapter/contract/classes/base_adapter.py index 8250838d72..622fff8ef7 100644 --- a/src/sqlbuild/adapter/contract/classes/base_adapter.py +++ b/src/sqlbuild/adapter/contract/classes/base_adapter.py @@ -149,6 +149,18 @@ def supports_unqualified_function_fingerprints(self) -> bool: def supports_table_functions(self) -> bool: return False + def interrupt_connection(self, connection: Any) -> bool: + """Cancel a connection's running statement from another thread; False if unsupported.""" + + del connection + return False + + def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool: + """Return whether ``list_relations`` reports every relation in this scope.""" + + del database, schema + return False + def supports_concurrent_microbatch_dml(self) -> bool: """Return whether disjoint same-target delete/insert batches may run concurrently.""" diff --git a/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py b/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py index 51f224517a..713617e3da 100644 --- a/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py +++ b/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py @@ -112,6 +112,18 @@ def get_columns_for_relations( ) relation_grants_supported: ClassVar[bool] = False + def interrupt_connection(self, connection: Any) -> bool: + """Cancel a connection's running statement from another thread; False if unsupported.""" + + connection.interrupt() + return True + + def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool: + """Return whether ``list_relations`` reports every relation in this scope.""" + + del database, schema + return True + def supports_zero_copy_clone(self) -> bool: return False diff --git a/src/sqlbuild/adapter/contract/classes/strict_adapter.py b/src/sqlbuild/adapter/contract/classes/strict_adapter.py index 04d5a6519b..4ce382bf8b 100644 --- a/src/sqlbuild/adapter/contract/classes/strict_adapter.py +++ b/src/sqlbuild/adapter/contract/classes/strict_adapter.py @@ -101,6 +101,16 @@ def maximum_identifier_length(self) -> int: """Return the maximum unqualified identifier length supported by the adapter.""" ... + @abstractmethod + def interrupt_connection(self, connection: Any) -> bool: + """Cancel a connection's running statement from another thread; False if unsupported.""" + ... + + @abstractmethod + def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool: + """Return whether ``list_relations`` reports every relation in this scope.""" + ... + @abstractmethod def supports_concurrent_microbatch_dml(self) -> bool: """Return whether disjoint delete/insert microbatches may execute concurrently.""" diff --git a/src/sqlbuild/adapter/relations/_helpers/identifier_fitting.py b/src/sqlbuild/adapter/relations/_helpers/identifier_fitting.py index f920eac27c..1b17b5bb0b 100644 --- a/src/sqlbuild/adapter/relations/_helpers/identifier_fitting.py +++ b/src/sqlbuild/adapter/relations/_helpers/identifier_fitting.py @@ -1,4 +1,4 @@ -"""Identifier-limit fitting implementations for generated relation names.""" +"""Fit generated relation names within identifier limits measured in UTF-8 bytes.""" from __future__ import annotations @@ -13,13 +13,13 @@ def fit_artifact_logical_name_impl( ) -> str: """Fit a readable logical component with a deterministic hash suffix.""" - max_logical_length: int = identifier_limit - len(fixed_prefix) + max_logical_length: int = identifier_limit - _byte_length(fixed_prefix) if max_logical_length < 1: raise SharedInputError( f"{artifact_label} prefix '{fixed_prefix}' does not fit within identifier " f"limit {identifier_limit}" ) - if len(logical_name) <= max_logical_length: + if _byte_length(logical_name) <= max_logical_length: return logical_name suffix_length: int = SHORTENED_LOGICAL_NAME_HASH_LENGTH + 1 @@ -32,4 +32,32 @@ def fit_artifact_logical_name_impl( :SHORTENED_LOGICAL_NAME_HASH_LENGTH ] prefix_length: int = max_logical_length - suffix_length - return f"{logical_name[:prefix_length]}_{logical_hash}" + return f"{_truncate_bytes(value=logical_name, max_bytes=prefix_length)}_{logical_hash}" + + +def fit_auxiliary_relation_name_impl(*, base_name: str, suffix: str, identifier_limit: int) -> str: + """Append a suffix, shortening the base with a stable hash when the whole name is too long.""" + + if _byte_length(base_name) + _byte_length(suffix) <= identifier_limit: + return f"{base_name}{suffix}" + hash_part: str = ( + "_" + + hashlib.sha256(base_name.encode("utf-8")).hexdigest()[:SHORTENED_LOGICAL_NAME_HASH_LENGTH] + ) + kept_length: int = identifier_limit - _byte_length(suffix) - len(hash_part) + if kept_length < 1: + raise SharedInputError( + f"auxiliary relation suffix '{suffix}' for '{base_name}' cannot fit within " + f"identifier limit {identifier_limit}" + ) + return f"{_truncate_bytes(value=base_name, max_bytes=kept_length)}{hash_part}{suffix}" + + +def _byte_length(value: str) -> int: + return len(value.encode("utf-8")) + + +def _truncate_bytes(*, value: str, max_bytes: int) -> str: + """Keep the longest prefix whose UTF-8 encoding fits, never splitting a character.""" + + return value.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore") diff --git a/src/sqlbuild/adapter/relations/main/fit_auxiliary_relation_name.py b/src/sqlbuild/adapter/relations/main/fit_auxiliary_relation_name.py new file mode 100644 index 0000000000..ec4f1a6dba --- /dev/null +++ b/src/sqlbuild/adapter/relations/main/fit_auxiliary_relation_name.py @@ -0,0 +1,15 @@ +"""Auxiliary relation name fitting entrypoint.""" + +from __future__ import annotations + +from sqlbuild.adapter.relations._helpers.identifier_fitting import ( + fit_auxiliary_relation_name_impl, +) + + +def fit_auxiliary_relation_name(*, base_name: str, suffix: str, identifier_limit: int) -> str: + """Return ``base_name + suffix`` fitted within the limit, keeping the suffix and a base hash.""" + + return fit_auxiliary_relation_name_impl( + base_name=base_name, suffix=suffix, identifier_limit=identifier_limit + ) diff --git a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py index 960a6a6dc2..7ad00dd704 100644 --- a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py +++ b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py @@ -1512,6 +1512,18 @@ def close(self, connection: _BigQueryConnection) -> None: def supports_transactions(self) -> bool: return False + def interrupt_connection(self, connection: Any) -> bool: + """Cancel a connection's running statement from another thread; False if unsupported.""" + + del connection + return False + + def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool: + """Return whether ``list_relations`` reports every relation in this scope.""" + + del database, schema + return True + def supports_zero_copy_clone(self) -> bool: return True diff --git a/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py b/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py index 8bf639e22b..853ec79caf 100644 --- a/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py +++ b/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py @@ -1170,6 +1170,18 @@ def render_apply_historical_check_snapshot_changes( invalidate_hard_deletes=invalidate_hard_deletes, ) + def interrupt_connection(self, connection: Any) -> bool: + """Cancel a connection's running statement from another thread; False if unsupported.""" + + del connection + return False + + def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool: + """Return whether ``list_relations`` reports every relation in this scope.""" + + del schema + return database is not None + def supports_zero_copy_clone(self) -> bool: return True diff --git a/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py b/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py index 302b28d445..c627c14b41 100644 --- a/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py +++ b/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py @@ -140,6 +140,18 @@ def get_columns_for_relations( historical_insert=HistoricalSnapshotInsertStyle.WITH_INSERT, ) + def interrupt_connection(self, connection: Any) -> bool: + """Cancel a connection's running statement from another thread; False if unsupported.""" + + connection.raw_connection.cancel_safe() + return True + + def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool: + """Return whether ``list_relations`` reports every relation in this scope.""" + + del database, schema + return True + def supports_zero_copy_clone(self) -> bool: return False diff --git a/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py b/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py index 8a4bd5bdb3..4113e81f18 100644 --- a/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py +++ b/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py @@ -2843,6 +2843,18 @@ def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) -> return f"'{escaped_value}'" return f"'{value}'" + def interrupt_connection(self, connection: Any) -> bool: + """Cancel a connection's running statement from another thread; False if unsupported.""" + + del connection + return False + + def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool: + """Return whether ``list_relations`` reports every relation in this scope.""" + + del database, schema + return True + def supports_zero_copy_clone(self) -> bool: return True diff --git a/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py b/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py index 2b43f182aa..73db8a4683 100644 --- a/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py +++ b/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py @@ -2807,6 +2807,18 @@ def supports_table_functions(self) -> bool: def supports_unqualified_function_fingerprints(self) -> bool: return False + def interrupt_connection(self, connection: Any) -> bool: + """Cancel a connection's running statement from another thread; False if unsupported.""" + + del connection + return False + + def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool: + """Return whether ``list_relations`` reports every relation in this scope.""" + + del database, schema + return True + def supports_zero_copy_clone(self) -> bool: return False diff --git a/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py b/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py index 4cff7ca407..917ff920f0 100644 --- a/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py +++ b/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py @@ -679,6 +679,7 @@ def _dispatch_scenario_command( retain=args.scenario_retain, local=args.scenario_local, strict=args.scenario_strict, + concurrency=args.concurrency, sync_snapshots=args.scenario_sync_snapshots, refresh=args.scenario_refresh, limit_inputs=ScenarioSnapshotLimitInputs( diff --git a/src/sqlbuild/cli/commands/_helpers/entry/parser.py b/src/sqlbuild/cli/commands/_helpers/entry/parser.py index 3360e7ccd7..ba08ac6710 100644 --- a/src/sqlbuild/cli/commands/_helpers/entry/parser.py +++ b/src/sqlbuild/cli/commands/_helpers/entry/parser.py @@ -792,6 +792,7 @@ def _add_workspace_parsers( scenario_test_parser.add_argument("--retain", dest="scenario_retain", action="store_true") scenario_test_parser.add_argument("--local", dest="scenario_local", action="store_true") scenario_test_parser.add_argument("--strict", dest="scenario_strict", action="store_true") + scenario_test_parser.add_argument("--concurrency", type=int, default=None) scenario_test_parser.add_argument("--json", action="store_true", default=False) _ = add_execution_json_output_arg(scenario_test_parser) _ = add_select_args(scenario_test_parser) diff --git a/src/sqlbuild/cli/commands/_helpers/scenario_execution/local_run.py b/src/sqlbuild/cli/commands/_helpers/scenario_execution/local_run.py index 60489e5f29..bc581ca271 100644 --- a/src/sqlbuild/cli/commands/_helpers/scenario_execution/local_run.py +++ b/src/sqlbuild/cli/commands/_helpers/scenario_execution/local_run.py @@ -2,28 +2,21 @@ from __future__ import annotations +from collections import Counter from pathlib import Path -from typing import TextIO from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter -from sqlbuild.cli.commands._helpers.scenario_output.namespace import ( - scenario_activity_message, - write_namespace_completion, -) from sqlbuild.cli.commands._helpers.scenario_output.result_output import complete_scenario_run -from sqlbuild.cli.commands.models import ScenarioRunOutputContext -from sqlbuild.cli.output.main._scenario_execution_json import ( - format_scenario_execution_json, +from sqlbuild.cli.commands._helpers.scenario_output.run_presentation import ( + begin_scenario_run, + finish_scenario_run, ) -from sqlbuild.cli.output.main._write_execution_json_output import write_execution_json_output +from sqlbuild.cli.commands.models import ScenarioRunOutputContext, ScenarioRunPresentation from sqlbuild.compiler.compile.models import CompiledSqlScenario from sqlbuild.compiler.pipeline.models import CompilePipelineResult from sqlbuild.executor.pipeline.main.run import run_scenario_local_test_pipeline from sqlbuild.executor.scenario.models import ScenarioLocalReplaySource, ScenarioRunResult from sqlbuild.executor.scenario.types import ScenarioLocalRunStatus -from sqlbuild.presentation.classes.cli_style import CliStyle -from sqlbuild.presentation.classes.transient_status_reporter import TransientStatusReporter -from sqlbuild.presentation.main.summary_footer import format_summary_footer def run_local_scenarios( @@ -40,24 +33,9 @@ def run_local_scenarios( ) -> int: """Replay selected scenarios on run-scoped DuckDB and render results.""" - progress_stream: TextIO = output_context.progress_stream - use_color: bool = output_context.use_color - json_output: bool = output_context.json_output - json_output_path: Path | None = output_context.json_output_path - style: CliStyle = CliStyle(use_color=use_color) - progress_stream.write(f"\n{style.success_strong(f'Scenario ({len(scenarios)} selected)')}\n\n") - progress_stream.flush() - scenario_status: TransientStatusReporter = TransientStatusReporter( - stream=progress_stream, - use_color=use_color, - ) - status_is_tty: bool = hasattr(progress_stream, "isatty") and progress_stream.isatty() - activity: str = scenario_activity_message( - activity="Running scenarios...", context=output_context + presentation: ScenarioRunPresentation = begin_scenario_run( + context=output_context, scenario_count=len(scenarios) ) - if not status_is_tty: - progress_stream.write(f"{activity}\n\n") - progress_stream.flush() results: tuple[ScenarioRunResult, ...] = run_scenario_local_test_pipeline( project_dir=project_dir, pipeline_result=pipeline_result, @@ -67,65 +45,39 @@ def run_local_scenarios( strict=strict, replay_source=replay_source, on_scenario_start=lambda _scenario: ( - scenario_status.start(activity) if status_is_tty else None + presentation.scenario_status.start(presentation.activity) + if presentation.status_is_tty + else None ), on_scenario_complete=lambda _scenario, scenario_plan, result: complete_scenario_run( - scenario_status=scenario_status, - status_is_tty=status_is_tty, + scenario_status=presentation.scenario_status, + status_is_tty=presentation.status_is_tty, target_dir=target_dir, adapter=adapter, scenario_plan=scenario_plan, result=result, - progress_stream=progress_stream, - use_color=use_color, + progress_stream=output_context.progress_stream, + use_color=output_context.use_color, ), ) - scenario_status.close() - exit_code: int = _write_local_summary( - results=results, stream=progress_stream, use_color=use_color + counts: Counter[ScenarioLocalRunStatus | None] = Counter( + result.local_status for result in results ) - write_namespace_completion(context=output_context, succeeded=exit_code == 0) - write_execution_json_output( - payload=format_scenario_execution_json( - results=results, - local=True, - run_namespace=output_context.namespace.value, - namespace_source=output_context.namespace.source, - ), - json_output=json_output, - json_output_path=json_output_path, + succeeded: bool = ( + counts[ScenarioLocalRunStatus.FAIL] == 0 and counts[ScenarioLocalRunStatus.ERROR] == 0 ) - return exit_code - - -def _write_local_summary( - *, results: tuple[ScenarioRunResult, ...], stream: TextIO, use_color: bool -) -> int: - pass_count: int = sum( - 1 for result in results if result.local_status == ScenarioLocalRunStatus.PASS - ) - fail_count: int = sum( - 1 for result in results if result.local_status == ScenarioLocalRunStatus.FAIL - ) - error_count: int = sum( - 1 for result in results if result.local_status == ScenarioLocalRunStatus.ERROR - ) - skip_count: int = sum( - 1 for result in results if result.local_status == ScenarioLocalRunStatus.SKIP - ) - stream.write( - "\n" - + format_summary_footer( - counts=( - ("PASS", pass_count), - ("FAIL", fail_count), - ("ERROR", error_count), - ("SKIP", skip_count), - ("TOTAL", len(results)), - ), - use_color=use_color, - ) - + "\n" + finish_scenario_run( + context=output_context, + presentation=presentation, + results=results, + counts=( + ("PASS", counts[ScenarioLocalRunStatus.PASS]), + ("FAIL", counts[ScenarioLocalRunStatus.FAIL]), + ("ERROR", counts[ScenarioLocalRunStatus.ERROR]), + ("SKIP", counts[ScenarioLocalRunStatus.SKIP]), + ("TOTAL", len(results)), + ), + succeeded=succeeded, + local=True, ) - stream.flush() - return 0 if fail_count == 0 and error_count == 0 else 1 + return 0 if succeeded else 1 diff --git a/src/sqlbuild/cli/commands/_helpers/scenario_execution/runner.py b/src/sqlbuild/cli/commands/_helpers/scenario_execution/runner.py index 6143b6b847..10b0a072db 100644 --- a/src/sqlbuild/cli/commands/_helpers/scenario_execution/runner.py +++ b/src/sqlbuild/cli/commands/_helpers/scenario_execution/runner.py @@ -52,6 +52,7 @@ from sqlbuild.cli.progress.classes.connection_progress_reporter import ConnectionProgressReporter from sqlbuild.cli.progress.classes.planning_progress_reporter import PlanningProgressReporter from sqlbuild.cli.progress.main._write_execution_header import write_execution_header +from sqlbuild.compiler.compile.main.effective_settings import build_effective_settings_config from sqlbuild.compiler.compile.models import CompiledSqlScenario from sqlbuild.compiler.discovery.main.discover import discover_project_inputs from sqlbuild.compiler.discovery.main.sql_analysis_off_guidance import sql_analysis_off_guidance @@ -158,6 +159,12 @@ def run_scenario(request: ScenarioTestCommandRequest) -> int: ) ) use_color: bool = not no_color and supports_color() + concurrency: int = max( + 1, + request.concurrency + if request.concurrency is not None + else build_effective_settings_config(discovered_inputs=discovered_inputs).concurrency, + ) progress_stream: TextIO = sys.stderr if json_output else sys.stdout progress_stream.write( f"Scenario namespace: {namespace.value or '(unset)'} (source: {namespace.source})\n" @@ -172,7 +179,7 @@ def run_scenario(request: ScenarioTestCommandRequest) -> int: stream=progress_stream, command="sqb scenario test --local" if local else "sqb scenario test", target=target_label, - concurrency=1, + concurrency=1 if local else concurrency, use_color=use_color, ) @@ -252,6 +259,7 @@ def run_scenario(request: ScenarioTestCommandRequest) -> int: project_name=discovered_inputs.project_config.name, target_dir=effective_project_dir / "target", retain=retain, + concurrency=concurrency, output_context=ScenarioRunOutputContext( namespace=namespace, progress_stream=progress_stream, diff --git a/src/sqlbuild/cli/commands/_helpers/scenario_execution/warehouse_run.py b/src/sqlbuild/cli/commands/_helpers/scenario_execution/warehouse_run.py index 4d1cc962fc..5e9232ad5b 100644 --- a/src/sqlbuild/cli/commands/_helpers/scenario_execution/warehouse_run.py +++ b/src/sqlbuild/cli/commands/_helpers/scenario_execution/warehouse_run.py @@ -3,28 +3,20 @@ from __future__ import annotations from pathlib import Path -from typing import TextIO from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter -from sqlbuild.cli.commands._helpers.scenario_output.namespace import ( - scenario_activity_message, - write_namespace_completion, -) from sqlbuild.cli.commands._helpers.scenario_output.result_output import complete_scenario_run -from sqlbuild.cli.commands.constants import SUCCESS_STATUS -from sqlbuild.cli.commands.models import ScenarioRunOutputContext -from sqlbuild.cli.output.main._scenario_execution_json import ( - format_scenario_execution_json, +from sqlbuild.cli.commands._helpers.scenario_output.run_presentation import ( + begin_scenario_run, + finish_scenario_run, ) -from sqlbuild.cli.output.main._write_execution_json_output import write_execution_json_output +from sqlbuild.cli.commands.constants import SUCCESS_STATUS +from sqlbuild.cli.commands.models import ScenarioRunOutputContext, ScenarioRunPresentation from sqlbuild.cli.progress.classes.connection_progress_reporter import ConnectionProgressReporter from sqlbuild.compiler.compile.models import CompiledSqlScenario from sqlbuild.compiler.pipeline.models import CompilePipelineResult from sqlbuild.executor.pipeline.main.run import run_scenario_test_pipeline from sqlbuild.executor.scenario.models import ScenarioRunResult -from sqlbuild.presentation.classes.cli_style import CliStyle -from sqlbuild.presentation.classes.transient_status_reporter import TransientStatusReporter -from sqlbuild.presentation.main.summary_footer import format_summary_footer from sqlbuild.runtime.contracts.models import ConnectionHooks @@ -38,33 +30,19 @@ def run_warehouse_scenarios( project_name: str, target_dir: Path, retain: bool, + concurrency: int, output_context: ScenarioRunOutputContext, ) -> int: """Run selected scenarios warehouse-direct and render results.""" - progress_stream: TextIO = output_context.progress_stream - use_color: bool = output_context.use_color - json_output: bool = output_context.json_output - json_output_path: Path | None = output_context.json_output_path - style: CliStyle = CliStyle(use_color=use_color) - progress_stream.write(f"\n{style.success_strong(f'Scenario ({len(scenarios)} selected)')}\n\n") - progress_stream.flush() - scenario_status: TransientStatusReporter = TransientStatusReporter( - stream=progress_stream, - use_color=use_color, + presentation: ScenarioRunPresentation = begin_scenario_run( + context=output_context, scenario_count=len(scenarios) ) - status_is_tty: bool = hasattr(progress_stream, "isatty") and progress_stream.isatty() - activity: str = scenario_activity_message( - activity="Running scenarios...", context=output_context - ) - if not status_is_tty: - progress_stream.write(f"{activity}\n\n") - progress_stream.flush() execution_connection_progress: ConnectionProgressReporter = ConnectionProgressReporter( adapter_name=adapter_name, blank_line_after_complete=True, - stream=progress_stream, - use_color=use_color, + stream=output_context.progress_stream, + use_color=output_context.use_color, ) results: tuple[ScenarioRunResult, ...] = run_scenario_test_pipeline( pipeline_result=pipeline_result, @@ -73,6 +51,7 @@ def run_warehouse_scenarios( adapter=adapter, project_name=project_name, retain=retain, + concurrency=concurrency, connection_hooks=ConnectionHooks( on_connection_start=execution_connection_progress.on_connection_start, on_connection_complete=lambda connection_count, elapsed_seconds: ( @@ -87,53 +66,29 @@ def run_warehouse_scenarios( ), ), on_scenario_start=lambda _scenario: ( - scenario_status.start(activity) if status_is_tty else None + presentation.scenario_status.start(presentation.activity) + if presentation.status_is_tty + else None ), on_scenario_complete=lambda _scenario, scenario_plan, result: complete_scenario_run( - scenario_status=scenario_status, - status_is_tty=status_is_tty, + scenario_status=presentation.scenario_status, + status_is_tty=presentation.status_is_tty, target_dir=target_dir, adapter=adapter, scenario_plan=scenario_plan, result=result, - progress_stream=progress_stream, - use_color=use_color, + progress_stream=output_context.progress_stream, + use_color=output_context.use_color, ), ) - scenario_status.close() - exit_code: int = _write_remote_summary( - results=results, stream=progress_stream, use_color=use_color - ) - write_namespace_completion(context=output_context, succeeded=exit_code == 0) - write_execution_json_output( - payload=format_scenario_execution_json( - results=results, - local=False, - run_namespace=output_context.namespace.value, - namespace_source=output_context.namespace.source, - ), - json_output=json_output, - json_output_path=json_output_path, - ) - return exit_code - - -def _write_remote_summary( - *, results: tuple[ScenarioRunResult, ...], stream: TextIO, use_color: bool -) -> int: pass_count: int = sum(1 for result in results if result.status == SUCCESS_STATUS) fail_count: int = len(results) - pass_count - stream.write( - "\n" - + format_summary_footer( - counts=( - ("PASS", pass_count), - ("FAIL", fail_count), - ("TOTAL", len(results)), - ), - use_color=use_color, - ) - + "\n" + finish_scenario_run( + context=output_context, + presentation=presentation, + results=results, + counts=(("PASS", pass_count), ("FAIL", fail_count), ("TOTAL", len(results))), + succeeded=fail_count == 0, + local=False, ) - stream.flush() return 0 if fail_count == 0 else 1 diff --git a/src/sqlbuild/cli/commands/_helpers/scenario_output/result_output.py b/src/sqlbuild/cli/commands/_helpers/scenario_output/result_output.py index 90f9040ef1..6f7279885a 100644 --- a/src/sqlbuild/cli/commands/_helpers/scenario_output/result_output.py +++ b/src/sqlbuild/cli/commands/_helpers/scenario_output/result_output.py @@ -74,7 +74,8 @@ def write_scenario_result(*, result: ScenarioRunResult, stream: TextIO, use_colo ) style: CliStyle = CliStyle(use_color=use_color) status: str = style.status(status=status_text) - stream.write(f"{result.scenario_name:<{_SCENARIO_NAME_WIDTH}} {status}\n") + duration: str = "" if result.duration_ms is None else f" {result.duration_ms / 1000:.2f}s" + stream.write(f"{result.scenario_name:<{_SCENARIO_NAME_WIDTH}} {status}{duration}\n") if result.error_message: rendered_error_message: str = render_result_error( error_code=result.error_code, diff --git a/src/sqlbuild/cli/commands/_helpers/scenario_output/run_presentation.py b/src/sqlbuild/cli/commands/_helpers/scenario_output/run_presentation.py new file mode 100644 index 0000000000..7a847c09f9 --- /dev/null +++ b/src/sqlbuild/cli/commands/_helpers/scenario_output/run_presentation.py @@ -0,0 +1,78 @@ +"""Progress framing shared by warehouse and local scenario test runs.""" + +from __future__ import annotations + +import time + +from sqlbuild.cli.commands._helpers.scenario_output.namespace import ( + scenario_activity_message, + write_namespace_completion, +) +from sqlbuild.cli.commands.models import ScenarioRunOutputContext, ScenarioRunPresentation +from sqlbuild.cli.output.main._scenario_execution_json import format_scenario_execution_json +from sqlbuild.cli.output.main._write_execution_json_output import write_execution_json_output +from sqlbuild.executor.scenario.models import ScenarioRunResult +from sqlbuild.presentation.classes.cli_style import CliStyle +from sqlbuild.presentation.classes.transient_status_reporter import TransientStatusReporter +from sqlbuild.presentation.main.summary_footer import format_summary_footer + + +def begin_scenario_run( + *, context: ScenarioRunOutputContext, scenario_count: int +) -> ScenarioRunPresentation: + """Write the scenario section header and start the run clock.""" + + style: CliStyle = CliStyle(use_color=context.use_color) + context.progress_stream.write( + f"\n{style.success_strong(f'Scenario ({scenario_count} selected)')}\n\n" + ) + status_is_tty: bool = ( + hasattr(context.progress_stream, "isatty") and context.progress_stream.isatty() + ) + activity: str = scenario_activity_message(activity="Running scenarios...", context=context) + if not status_is_tty: + context.progress_stream.write(f"{activity}\n\n") + context.progress_stream.flush() + return ScenarioRunPresentation( + scenario_status=TransientStatusReporter( + stream=context.progress_stream, use_color=context.use_color + ), + status_is_tty=status_is_tty, + activity=activity, + started=time.monotonic(), + ) + + +def finish_scenario_run( + *, + context: ScenarioRunOutputContext, + presentation: ScenarioRunPresentation, + results: tuple[ScenarioRunResult, ...], + counts: tuple[tuple[str, int], ...], + succeeded: bool, + local: bool, +) -> None: + """Write the timed summary, the namespace completion line, and optional JSON.""" + + presentation.scenario_status.close() + elapsed_seconds: float = time.monotonic() - presentation.started + context.progress_stream.write( + "\n" + + format_summary_footer( + counts=counts, use_color=context.use_color, elapsed=f"{elapsed_seconds:.2f}s" + ) + + "\n" + ) + context.progress_stream.flush() + write_namespace_completion(context=context, succeeded=succeeded) + write_execution_json_output( + payload=format_scenario_execution_json( + results=results, + local=local, + run_namespace=context.namespace.value, + namespace_source=context.namespace.source, + duration_ms=elapsed_seconds * 1000, + ), + json_output=context.json_output, + json_output_path=context.json_output_path, + ) diff --git a/src/sqlbuild/cli/commands/models.py b/src/sqlbuild/cli/commands/models.py index bb0f8c2b84..9f359355e3 100644 --- a/src/sqlbuild/cli/commands/models.py +++ b/src/sqlbuild/cli/commands/models.py @@ -949,6 +949,16 @@ class ScenarioRunOutputContext: json_output_path: Path | None = None +@dataclass(frozen=True) +class ScenarioRunPresentation: + """Live status and timing shared by one scenario test run's progress output.""" + + scenario_status: TransientStatusReporter + status_is_tty: bool + activity: str + started: float + + @dataclass(frozen=True) class ScenarioSnapshotLimitInputs: """CLI snapshot capture-limit overrides for one scenario run.""" @@ -973,6 +983,7 @@ class ScenarioTestCommandRequest: retain: bool = False local: bool = False strict: bool = False + concurrency: int | None = None sync_snapshots: bool = False refresh: bool = False limit_inputs: ScenarioSnapshotLimitInputs = ScenarioSnapshotLimitInputs() diff --git a/src/sqlbuild/cli/output/_helpers/execution_result_document.py b/src/sqlbuild/cli/output/_helpers/execution_result_document.py index fe6cfd50eb..3252087a36 100644 --- a/src/sqlbuild/cli/output/_helpers/execution_result_document.py +++ b/src/sqlbuild/cli/output/_helpers/execution_result_document.py @@ -471,6 +471,7 @@ def format_scenario_execution_json( local: bool = False, run_namespace: str | None = None, namespace_source: str = "unset", + duration_ms: float | None = None, ) -> str: """Format scenario test command execution results as JSON.""" @@ -494,11 +495,14 @@ def format_scenario_execution_json( assets=tuple(assets), checks=tuple(checks), scenarios=tuple(_format_scenario_result(result) for result in results), - summary={ - "pass_count": len(results) - fail_count, - "fail_count": fail_count, - "total_count": len(results), - }, + summary=_drop_none( + { + "pass_count": len(results) - fail_count, + "fail_count": fail_count, + "total_count": len(results), + "duration_ms": None if duration_ms is None else round(duration_ms), + } + ), ) @@ -1044,6 +1048,7 @@ def _format_scenario_result(result: ScenarioRunResult) -> dict[str, object]: { "name": result.scenario_name, "status": result.status.value, + "duration_ms": None if result.duration_ms is None else round(result.duration_ms), "local_status": result.local_status.value if result.local_status else None, "retained": result.retained, "local_duckdb_path": ( diff --git a/src/sqlbuild/cli/output/main/_scenario_execution_json.py b/src/sqlbuild/cli/output/main/_scenario_execution_json.py index dc2278c6f9..4ac85c949f 100644 --- a/src/sqlbuild/cli/output/main/_scenario_execution_json.py +++ b/src/sqlbuild/cli/output/main/_scenario_execution_json.py @@ -14,9 +14,14 @@ def format_scenario_execution_json( local: bool = False, run_namespace: str | None = None, namespace_source: str = "unset", + duration_ms: float | None = None, ) -> str: """Format scenario test command execution results as JSON.""" return _format_scenario_execution_json( - results=results, local=local, run_namespace=run_namespace, namespace_source=namespace_source + results=results, + local=local, + run_namespace=run_namespace, + namespace_source=namespace_source, + duration_ms=duration_ms, ) diff --git a/src/sqlbuild/cli/progress/classes/native_progress_projector.py b/src/sqlbuild/cli/progress/classes/native_progress_projector.py index 90469d686b..78d8f57f9e 100644 --- a/src/sqlbuild/cli/progress/classes/native_progress_projector.py +++ b/src/sqlbuild/cli/progress/classes/native_progress_projector.py @@ -79,6 +79,7 @@ _FRESHNESS_METADATA_OPERATION: str = "source_freshness_metadata_observation" _FRESHNESS_QUERY_OPERATION: str = "source_freshness_query_observation" _RESOURCE_START: str = "resource_attempt_started" +_SCENARIO_RESOURCE_PREFIX: str = "scenario:" _OPERATION_START: str = "operation_started" _OPERATION_TERMINALS: frozenset[str] = frozenset({"operation_completed", "operation_failed"}) _STATEMENT_START: str = "statement_started" @@ -258,7 +259,10 @@ def _consume_resource_start(self, event: LifecycleEvent) -> None: if ordinal is not None and self._resource_total > 0: counter = f"{ordinal}/{self._resource_total} " status: str = self._style.status(status="START") - self._write(f" {counter}{resource_kind:<10}{resource_name} {status}") + display_name: str = _display_resource_name( + resource_id=resource_id, resource_name=resource_name + ) + self._write(f" {counter}{resource_kind:<10}{display_name} {status}") def _consume_resource_terminal(self, event: LifecycleEvent) -> None: attempt_id: str | None = event.resource_attempt_id @@ -292,7 +296,10 @@ def _render_resource_terminal(self, *, event: LifecycleEvent, resource_name: str elapsed: str = ( f" {float(duration) / 1000.0:.2f}s" if isinstance(duration, int | float) else "" ) - self._write(f" {resource_kind:<10}{resource_name} {status}{elapsed}") + display_name: str = _display_resource_name( + resource_id=event.resource_id, resource_name=resource_name + ) + self._write(f" {resource_kind:<10}{display_name} {status}{elapsed}") def _render_prior_attempts(self, *, resource_id: str, resource_name: str) -> None: pending: deque[str] = self._pending_terminal_ids_by_resource[resource_id] @@ -494,6 +501,13 @@ def _write(self, line: str) -> None: ) +def _display_resource_name(*, resource_id: str | None, resource_name: str) -> str: + if resource_id is None or not resource_id.startswith(_SCENARIO_RESOURCE_PREFIX): + return resource_name + scenario_name: str = resource_id.removeprefix(_SCENARIO_RESOURCE_PREFIX).split(":", 1)[0] + return f"{scenario_name}/{resource_name}" + + def current_native_progress_projector() -> NativeProgressProjector | None: """Return the projector installed for the current CLI invocation.""" diff --git a/src/sqlbuild/compiler/contracts/_helpers/promotion.py b/src/sqlbuild/compiler/contracts/_helpers/promotion.py new file mode 100644 index 0000000000..62d948a78f --- /dev/null +++ b/src/sqlbuild/compiler/contracts/_helpers/promotion.py @@ -0,0 +1,112 @@ +"""Compile-time conflicts between enforced contracts and immediate table promotion.""" + +from __future__ import annotations + +from sqlbuild.adapter.contract.types import TablePromotionMode +from sqlbuild.compiler.compile.models import CompiledModel, CompiledProject, CompilerDiagnostic +from sqlbuild.compiler.compile.types import ( + CompiledResourceType, + DiagnosticPhase, + DiagnosticSeverity, +) +from sqlbuild.compiler.discovery.constants import ( + SETTINGS_SECTION, + STAGED_PROMOTION_MODE, + TABLE_PROMOTION_MODE_SETTING_KEY, +) +from sqlbuild.compiler.planner.types import ContractPolicy, IncrementalMode, MaterializationType +from sqlbuild.errors.setting_help.main.join_helps import join_helps +from sqlbuild.errors.setting_help.main.setting_help import setting_help +from sqlbuild.errors.setting_help.main.setting_note import setting_note +from sqlbuild.errors.setting_help.main.snippet_help import snippet_help + +_PROMOTION_CONFLICT_CODE: str = "K011" +_STAGED_LIFECYCLE_MATERIALIZATIONS: frozenset[MaterializationType] = frozenset( + {MaterializationType.TABLE, MaterializationType.INCREMENTAL} +) + + +def promotion_conflict_diagnostics_impl( + *, + project: CompiledProject, + adapter_default: TablePromotionMode, + settings_file: str, +) -> tuple[CompilerDiagnostic, ...]: + """Report each enforced-contract table model when promotion is immediate.""" + + explicit_mode: str | None = project.settings.table_promotion_mode + effective_mode: str = explicit_mode if explicit_mode is not None else adapter_default + if effective_mode != TablePromotionMode.IMMEDIATE: + return () + help_text: str = _conflict_help( + explicit=explicit_mode is not None, + adapter_default=adapter_default, + settings_file=settings_file, + ) + return tuple( + CompilerDiagnostic( + phase=DiagnosticPhase.CONTRACT, + severity=DiagnosticSeverity.ERROR, + code=_PROMOTION_CONFLICT_CODE, + message=( + f"model '{model.name}': contract enforced requires staged table promotion; " + "immediate table promotion cannot validate runtime output before target mutation" + ), + resource_type=CompiledResourceType.MODEL, + resource_name=model.name, + path=model.relative_path, + help=help_text, + ) + for model in project.models + if _uses_staged_table_lifecycle(model) + ) + + +def _uses_staged_table_lifecycle(model: CompiledModel) -> bool: + values: dict[str, object] = model.config.values + if values.get("contract") != ContractPolicy.ENFORCED: + return False + materialized: object = values.get("materialized", MaterializationType.TABLE) + return ( + materialized in _STAGED_LIFECYCLE_MATERIALIZATIONS + and values.get("incremental_mode") != IncrementalMode.MICROBATCH + ) + + +def _conflict_help( + *, explicit: bool, adapter_default: TablePromotionMode, settings_file: str +) -> str: + current: str = ( + setting_note( + file_name=settings_file, + section=SETTINGS_SECTION, + key=TABLE_PROMOTION_MODE_SETTING_KEY, + value=TablePromotionMode.IMMEDIATE.value, + explicit=explicit, + ) + + "; enforced contracts are validated in a staging table before promotion" + ) + staged_fix: str = ( + snippet_help( + purpose="remove that line to use the default staged promotion", + target=f"or set this in {settings_file}", + lines=( + f"[{SETTINGS_SECTION}]", + f'{TABLE_PROMOTION_MODE_SETTING_KEY} = "{STAGED_PROMOTION_MODE}"', + ), + ) + if explicit and adapter_default == TablePromotionMode.STAGED + else setting_help( + purpose="to validate enforced contracts before promotion", + file_name=settings_file, + section=SETTINGS_SECTION, + key=TABLE_PROMOTION_MODE_SETTING_KEY, + value=STAGED_PROMOTION_MODE, + ) + ) + return join_helps( + current, + staged_fix, + "or remove the model's enforced contract: `contract enforced` in its MODEL header, or " + '`contract = "enforced"` in the [defaults] or [path_defaults] entry that applies to it', + ) diff --git a/src/sqlbuild/compiler/contracts/main/promotion_conflicts.py b/src/sqlbuild/compiler/contracts/main/promotion_conflicts.py new file mode 100644 index 0000000000..fa6f932517 --- /dev/null +++ b/src/sqlbuild/compiler/contracts/main/promotion_conflicts.py @@ -0,0 +1,20 @@ +"""Report enforced contracts that the effective table promotion mode cannot validate.""" + +from __future__ import annotations + +from sqlbuild.adapter.contract.types import TablePromotionMode +from sqlbuild.compiler.compile.models import CompiledProject, CompilerDiagnostic +from sqlbuild.compiler.contracts._helpers.promotion import promotion_conflict_diagnostics_impl + + +def promotion_conflict_diagnostics( + *, + project: CompiledProject, + adapter_default: TablePromotionMode, + settings_file: str, +) -> tuple[CompilerDiagnostic, ...]: + """Return one K011 error per enforced-contract table model under immediate promotion.""" + + return promotion_conflict_diagnostics_impl( + project=project, adapter_default=adapter_default, settings_file=settings_file + ) diff --git a/src/sqlbuild/compiler/pipeline/main/_compile_phase.py b/src/sqlbuild/compiler/pipeline/main/_compile_phase.py index ebd233917e..227657d846 100644 --- a/src/sqlbuild/compiler/pipeline/main/_compile_phase.py +++ b/src/sqlbuild/compiler/pipeline/main/_compile_phase.py @@ -79,6 +79,7 @@ def compile_project_phase( for diagnostic in errors ), code=errors[0].code, + help=_shared_help(errors), ) if on_progress is not None: on_progress(f"Compiled project. ({compile_seconds:.2f}s)") @@ -87,3 +88,10 @@ def compile_project_phase( connection_config=effective_config, compile_seconds=compile_seconds, ) + + +def _shared_help(errors: tuple[CompilerDiagnostic, ...]) -> str | None: + """Return the help every error shares, so one remedy is shown once.""" + + helps: frozenset[str | None] = frozenset(diagnostic.help for diagnostic in errors) + return next(iter(helps)) if len(helps) == 1 else None diff --git a/src/sqlbuild/compiler/pipeline/main/compiled_project.py b/src/sqlbuild/compiler/pipeline/main/compiled_project.py index 499eda8df0..62d0b230ac 100644 --- a/src/sqlbuild/compiler/pipeline/main/compiled_project.py +++ b/src/sqlbuild/compiler/pipeline/main/compiled_project.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import replace + from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.adapter.contract.models import ExpressionInferenceProfile from sqlbuild.compiler.compile.main._assemble_project import assemble_project @@ -12,6 +14,12 @@ CompiledProject, CompileProjectInputs, ) +from sqlbuild.compiler.contracts.main.promotion_conflicts import promotion_conflict_diagnostics +from sqlbuild.compiler.discovery.constants import ( + LOCAL_CONFIG_FILENAME, + PROJECT_CONFIG_FILENAME, + TABLE_PROMOTION_MODE_SETTING_KEY, +) from sqlbuild.compiler.discovery.models import DiscoveredProjectInputs from sqlbuild.compiler.lineage.types import ColumnLineageMode from sqlbuild.compiler.pipeline._helpers.target_defaults import apply_target_defaults @@ -120,7 +128,22 @@ def build_compiled_project( ), project=project, ) - return project + return replace( + project, + diagnostics=( + *project.diagnostics, + *promotion_conflict_diagnostics( + project=project, + adapter_default=adapter.default_table_promotion_mode(), + settings_file=( + LOCAL_CONFIG_FILENAME + if TABLE_PROMOTION_MODE_SETTING_KEY + in discovered_inputs.local_config.setting_overrides + else PROJECT_CONFIG_FILENAME + ), + ), + ), + ) def _resolve_analysis_model_names( diff --git a/src/sqlbuild/executor/load/_helpers/staging.py b/src/sqlbuild/executor/load/_helpers/staging.py index 68ec53106b..4e2308ee91 100644 --- a/src/sqlbuild/executor/load/_helpers/staging.py +++ b/src/sqlbuild/executor/load/_helpers/staging.py @@ -7,6 +7,10 @@ from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.adapter.contract.classes.statement_recorder import StatementRecorder from sqlbuild.adapter.contract.types import LoaderLogicalType +from sqlbuild.adapter.relations.main.fit_auxiliary_relation_name import fit_auxiliary_relation_name +from sqlbuild.adapter.relations.main.resolve_qualified_name_parts import ( + resolve_qualified_name_parts, +) from sqlbuild.compiler.planner.types import ContractPolicy from sqlbuild.executor.load._helpers.rows import ( build_rows_sql, @@ -14,6 +18,7 @@ update_loader_rows_schema, ) from sqlbuild.executor.load.models import LoaderRowsSchema +from sqlbuild.executor.run.constants import STAGING_RELATION_SUFFIX from sqlbuild.spec.contracts.models import SourceEntry @@ -99,3 +104,28 @@ def write_loader_rows_to_staging( statement_recorder=statement_recorder, ) return rows_loaded + + +def resolve_loader_relations( + *, adapter: BaseAdapter, source_entry: SourceEntry, destination_name: str +) -> tuple[str, str]: + """Return the qualified loader destination and its fitted staging relation.""" + + return ( + resolve_qualified_name_parts( + adapter=adapter, + database=source_entry.database, + schema=source_entry.schema, + name=destination_name, + ), + resolve_qualified_name_parts( + adapter=adapter, + database=source_entry.database, + schema=source_entry.schema, + name=fit_auxiliary_relation_name( + base_name=destination_name, + suffix=STAGING_RELATION_SUFFIX, + identifier_limit=adapter.maximum_identifier_length(), + ), + ), + ) diff --git a/src/sqlbuild/executor/load/main/_execute.py b/src/sqlbuild/executor/load/main/_execute.py index 9d6537fcc1..06804ab1db 100644 --- a/src/sqlbuild/executor/load/main/_execute.py +++ b/src/sqlbuild/executor/load/main/_execute.py @@ -11,9 +11,6 @@ from sqlbuild.adapter.contract.classes.statement_recorder import StatementRecorder from sqlbuild.adapter.contract.models import ColumnInfo from sqlbuild.adapter.contract.types import CursorKind -from sqlbuild.adapter.relations.main.resolve_qualified_name_parts import ( - resolve_qualified_name_parts, -) from sqlbuild.compiler.discovery.models import DiscoveredLoaderFunction from sqlbuild.compiler.discovery.types import LoaderConnectionMode from sqlbuild.cost.classes.cost_context import CostContext @@ -34,7 +31,10 @@ validate_source_write_strategy, ) from sqlbuild.executor.load._helpers.schema import validate_and_evolve_existing_target -from sqlbuild.executor.load._helpers.staging import write_loader_rows_to_staging +from sqlbuild.executor.load._helpers.staging import ( + resolve_loader_relations, + write_loader_rows_to_staging, +) from sqlbuild.executor.load.models import ( LoaderContext, LoaderDestination, @@ -109,17 +109,10 @@ def _execute_source_load( destination_name: str = ( source_entry.table if source_entry.table is not None else source_entry.name ) - destination_relation: str = resolve_qualified_name_parts( - adapter=adapter, - database=source_entry.database, - schema=source_entry.schema, - name=destination_name, - ) - staging: str = resolve_qualified_name_parts( - adapter=adapter, - database=source_entry.database, - schema=source_entry.schema, - name=f"{destination_name}__staging", + destination_relation: str + staging: str + destination_relation, staging = resolve_loader_relations( + adapter=adapter, source_entry=source_entry, destination_name=destination_name ) destination: LoaderDestination = LoaderDestination( relation=destination_relation, name=destination_name diff --git a/src/sqlbuild/executor/pipeline/_helpers/scenario.py b/src/sqlbuild/executor/pipeline/_helpers/scenario.py index 4338f30534..edea222d55 100644 --- a/src/sqlbuild/executor/pipeline/_helpers/scenario.py +++ b/src/sqlbuild/executor/pipeline/_helpers/scenario.py @@ -3,13 +3,20 @@ from __future__ import annotations import logging +import queue +import threading import time from collections.abc import Callable, Iterator +from concurrent.futures import Future, wait from contextlib import contextmanager +from contextvars import Context, copy_context +from dataclasses import dataclass, field, replace +from functools import partial from pathlib import Path -from typing import Any, Protocol +from typing import Any, ClassVar, Protocol from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.adapter.contract.types import TablePromotionMode from sqlbuild.cli.progress.classes.native_progress_projector import ( NativeProgressProjector, current_native_progress_projector, @@ -23,6 +30,14 @@ from sqlbuild.errors.contracts.main.error_code import error_code from sqlbuild.errors.contracts.main.error_help import error_help from sqlbuild.errors.contracts.main.error_message import error_message +from sqlbuild.executor.pipeline._helpers.connections import ( + close_connections, + open_worker_connections, +) +from sqlbuild.executor.pipeline._helpers.settings import resolve_promotion_mode +from sqlbuild.executor.scenario.classes.prepared_scenario_schemas import PreparedScenarioSchemas +from sqlbuild.executor.scenario.classes.running_scenarios import RunningScenarios +from sqlbuild.executor.scenario.classes.scenario_interrupts import ScenarioInterrupts from sqlbuild.executor.scenario.constants import ( SCENARIO_EXEC_INTERNAL, SCENARIO_LOCAL_INTERNAL, @@ -30,12 +45,14 @@ from sqlbuild.executor.scenario.main._capture_steps import ( execute_scenario_snapshot_capture_run, ) +from sqlbuild.executor.scenario.main._cleanup_interrupted import cleanup_interrupted_scenarios from sqlbuild.executor.scenario.main._local import execute_local_scenario_load_only_run from sqlbuild.executor.scenario.main._run import execute_scenario_run from sqlbuild.executor.scenario.main._snapshots import classify_scenario_snapshot_state from sqlbuild.executor.scenario.models import ( ScenarioCaptureSettings, ScenarioLocalReplaySource, + ScenarioRunOptions, ScenarioRunResult, ScenarioSnapshotCaptureRunResult, ScenarioSnapshotStateResult, @@ -52,11 +69,27 @@ ) _DEBUG_LOGGER: logging.Logger = logging.getLogger("sqlbuild.execution") +_STOP_POLL_SECONDS: float = 0.05 _SCENARIO_INTERNAL_ERROR_HELP: str = ( "This is likely a SQLBuild bug. Please file an issue with the scenario name." ) +@dataclass(frozen=True) +class _ScenarioScheduling: + worker_count: int = 1 + interrupts: ScenarioInterrupts = field(default_factory=ScenarioInterrupts) + running: RunningScenarios | None = None + + +@dataclass(frozen=True) +class _ScenarioOutcome[ResultT]: + scenario: CompiledSqlScenario + scenario_plan: ScenarioExecutionPlan | None + result: ResultT + resource_attempt_id: str + + def _scenario_failure_help(exc: Exception) -> str | None: explicit_help: str | None = error_help(exc) if explicit_help is not None: @@ -67,6 +100,8 @@ def _scenario_failure_help(exc: Exception) -> str | None: class _ScenarioPipelineResult(Protocol): + __dataclass_fields__: ClassVar[dict[str, Any]] + @property def status(self) -> ExecutionStatus: ... @@ -86,6 +121,7 @@ def run_scenario_test_pipeline( adapter: BaseAdapter, project_name: str, retain: bool, + concurrency: int = 1, connection_hooks: ConnectionHooks | None = None, on_scenario_start: Callable[[CompiledSqlScenario], None] | None = None, on_scenario_complete: Callable[ @@ -95,20 +131,45 @@ def run_scenario_test_pipeline( ) -> tuple[ScenarioRunResult, ...]: """Execute selected scenarios from a compiled project.""" - with _scenario_target_connection( + worker_count: int = max(1, min(concurrency, len(scenarios))) + running: RunningScenarios = RunningScenarios(adapter=adapter) + scheduling: _ScenarioScheduling = _ScenarioScheduling( + worker_count=worker_count, + interrupts=ScenarioInterrupts( + cancel_statements=partial(running.interrupt, close_uncancellable=False) + ), + running=running, + ) + with _scenario_target_connections( adapter=adapter, connection_config=connection_config, connection_hooks=connection_hooks, - ) as connection: + connection_count=worker_count, + abandoned=scheduling.interrupts.abandoned, + ) as connection_pool: + options: ScenarioRunOptions = ScenarioRunOptions( + promotion_mode=resolve_promotion_mode( + settings=pipeline_result.project.settings, adapter=adapter + ), + prepared_schemas=PreparedScenarioSchemas(), + interrupts=scheduling.interrupts, + ) def execute(scenario_plan: ScenarioExecutionPlan) -> ScenarioRunResult: - return execute_scenario_run( - scenario_plan=scenario_plan, - adapter=adapter, - connection=connection, - run_id=pipeline_result.project.run_id, - retain=retain, - ) + connection: Any = connection_pool.get() + running.start(scenario_plan=scenario_plan, connection=connection) + try: + return execute_scenario_run( + scenario_plan=scenario_plan, + adapter=adapter, + connection=connection, + run_id=pipeline_result.project.run_id, + retain=retain, + options=options, + ) + finally: + running.finish(connection=connection) + connection_pool.put(connection) def failed(*, scenario_name: str, exc: Exception) -> ScenarioRunResult: return ScenarioRunResult( @@ -120,17 +181,61 @@ def failed(*, scenario_name: str, exc: Exception) -> ScenarioRunResult: error_message=error_message(exc), ) - return _run_scenarios( - pipeline_result=pipeline_result, - scenarios=scenarios, + try: + return _run_scenarios( + pipeline_result=pipeline_result, + scenarios=scenarios, + adapter=adapter, + project_name=project_name, + source_lexical_syntax=adapter.sql_lexical_syntax, + execute=execute, + failed=failed, + on_scenario_start=on_scenario_start, + on_scenario_complete=on_scenario_complete, + scheduling=scheduling, + ) + except BaseException: + if worker_count > 1 and not retain: + _sweep_interrupted_scenarios( + running=running, + interrupts=scheduling.interrupts, + adapter=adapter, + connection_config=connection_config, + connection_pool=connection_pool, + ) + raise + + +def _sweep_interrupted_scenarios( + *, + running: RunningScenarios, + interrupts: ScenarioInterrupts, + adapter: BaseAdapter, + connection_config: dict[str, object], + connection_pool: queue.SimpleQueue[Any], +) -> None: + """Drop started scenarios' relations once workers stop, on a connection still valid.""" + + if interrupts.abandoned.is_set(): + return + if not running.closed_connection: + cleanup_interrupted_scenarios( + scenario_plans=running.started_plans, adapter=adapter, - project_name=project_name, - source_lexical_syntax=adapter.sql_lexical_syntax, - execute=execute, - failed=failed, - on_scenario_start=on_scenario_start, - on_scenario_complete=on_scenario_complete, + connection=connection_pool.get(), + interrupts=interrupts, ) + return + connection: Any = adapter.connect(connection_config) + try: + cleanup_interrupted_scenarios( + scenario_plans=running.started_plans, + adapter=adapter, + connection=connection, + interrupts=interrupts, + ) + finally: + adapter.close(connection) def run_scenario_local_test_pipeline( @@ -150,12 +255,17 @@ def run_scenario_local_test_pipeline( ) -> tuple[ScenarioRunResult, ...]: """Load selected local scenario snapshots into run-scoped DuckDB databases.""" + promotion_mode: TablePromotionMode = resolve_promotion_mode( + settings=pipeline_result.project.settings, adapter=adapter + ) + def execute(scenario_plan: ScenarioExecutionPlan) -> ScenarioRunResult: return execute_local_scenario_load_only_run( project_dir=project_dir, scenario_plan=scenario_plan, adapter=adapter, strict=strict, + promotion_mode=promotion_mode, capture_adapter=replay_source.capture_adapter, capture_dialect=replay_source.capture_dialect, ) @@ -202,25 +312,32 @@ def run_scenario_capture_pipeline( ) -> tuple[ScenarioSnapshotCaptureRunResult, ...]: """Capture selected scenario inputs into durable local snapshot files.""" - with _scenario_target_connection( + with _scenario_target_connections( adapter=adapter, connection_config=connection_config, connection_hooks=connection_hooks, - ) as connection: + connection_count=1, + ) as connection_pool: + connection: Any = connection_pool.get() + running: RunningScenarios = RunningScenarios(adapter=adapter) def execute(scenario_plan: ScenarioExecutionPlan) -> ScenarioSnapshotCaptureRunResult: - return execute_scenario_snapshot_capture_run( - project_dir=project_dir, - scenario_plan=scenario_plan, - adapter=adapter, - connection=connection, - run_id=pipeline_result.project.run_id, - settings=settings, - local_type_overrides=scenario_local_type_overrides_for_dialect( - scenario_config=pipeline_result.project.scenario, - sql_analysis_dialect=adapter.sql_analysis_dialect(), - ), - ) + running.start(scenario_plan=scenario_plan, connection=connection) + try: + return execute_scenario_snapshot_capture_run( + project_dir=project_dir, + scenario_plan=scenario_plan, + adapter=adapter, + connection=connection, + run_id=pipeline_result.project.run_id, + settings=settings, + local_type_overrides=scenario_local_type_overrides_for_dialect( + scenario_config=pipeline_result.project.scenario, + sql_analysis_dialect=adapter.sql_analysis_dialect(), + ), + ) + finally: + running.finish(connection=connection) def failed(*, scenario_name: str, exc: Exception) -> ScenarioSnapshotCaptureRunResult: return ScenarioSnapshotCaptureRunResult( @@ -242,35 +359,55 @@ def failed(*, scenario_name: str, exc: Exception) -> ScenarioSnapshotCaptureRunR failed=failed, on_scenario_start=on_scenario_start, on_scenario_complete=on_scenario_complete, + scheduling=_ScenarioScheduling( + interrupts=ScenarioInterrupts( + cancel_statements=partial(running.interrupt, close_uncancellable=False) + ) + ), ) @contextmanager -def _scenario_target_connection( +def _scenario_target_connections( *, adapter: BaseAdapter, connection_config: dict[str, object], connection_hooks: ConnectionHooks | None, -) -> Iterator[Any]: + connection_count: int, + abandoned: threading.Event | None = None, +) -> Iterator[queue.SimpleQueue[Any]]: hooks: ConnectionHooks = connection_hooks if connection_hooks is not None else ConnectionHooks() if hooks.on_connection_start is not None: - hooks.on_connection_start(1) + hooks.on_connection_start(connection_count) start: float = time.monotonic() try: with OperationLifecycle( operation_kind="scenario", operation_name="scenario_target_connection" ): - connection: Any = adapter.connect(connection_config) + connections: tuple[Any, ...] = open_worker_connections( + adapter=adapter, + connection_config=connection_config, + connection_count=connection_count, + ) except Exception: if hooks.on_connection_error is not None: - hooks.on_connection_error(1, elapsed_seconds=time.monotonic() - start) + hooks.on_connection_error(connection_count, elapsed_seconds=time.monotonic() - start) raise if hooks.on_connection_complete is not None: - hooks.on_connection_complete(1, elapsed_seconds=time.monotonic() - start) + hooks.on_connection_complete(connection_count, elapsed_seconds=time.monotonic() - start) + connection_pool: queue.SimpleQueue[Any] = queue.SimpleQueue() + connection: Any + for connection in connections: + connection_pool.put(connection) + active_error: BaseException | None = None try: - yield connection + yield connection_pool + except BaseException as error: + active_error = error + raise finally: - adapter.close(connection) + if abandoned is None or not abandoned.is_set(): + close_connections(adapter=adapter, connections=connections, active_error=active_error) def _run_scenarios[ResultT: _ScenarioPipelineResult]( @@ -287,56 +424,234 @@ def _run_scenarios[ResultT: _ScenarioPipelineResult]( [CompiledSqlScenario, ScenarioExecutionPlan | None, ResultT], None ] | None, + scheduling: _ScenarioScheduling | None = None, ) -> tuple[ResultT, ...]: - results: list[ResultT] = [] + resolved_scheduling: _ScenarioScheduling = ( + scheduling if scheduling is not None else _ScenarioScheduling() + ) + + def run(*, scenario: CompiledSqlScenario, notify_start: bool) -> _ScenarioOutcome[ResultT]: + return _run_scenario( + scenario=scenario, + pipeline_result=pipeline_result, + adapter=adapter, + project_name=project_name, + source_lexical_syntax=source_lexical_syntax, + execute=execute, + failed=failed, + on_scenario_start=on_scenario_start if notify_start else None, + ) + + def complete(outcome: _ScenarioOutcome[ResultT]) -> ResultT: + projector: NativeProgressProjector | None = current_native_progress_projector() + if projector is not None and on_scenario_complete is not None: + _ = projector.consume_resource_terminal( + resource_name=outcome.scenario.name, + resource_id=_scenario_resource_id(outcome.scenario.name), + resource_attempt_id=outcome.resource_attempt_id, + ) + if on_scenario_complete is not None: + on_scenario_complete(outcome.scenario, outcome.scenario_plan, outcome.result) + return outcome.result + scenario: CompiledSqlScenario for scenario in scenarios: - scenario_plan: ScenarioExecutionPlan | None = None - resource_id: str = f"sql_scenario:{scenario.name}" - projector: NativeProgressProjector | None = _prepare_scenario_presentation( + _prepare_scenario_presentation( scenario_name=scenario.name, has_completion_callback=on_scenario_complete is not None, ) - with ResourceAttemptLifecycle( - resource_id=resource_id, - resource_kind="scenario", - resource_name=scenario.name, - run_id=pipeline_result.project.run_id, - ) as lifecycle: + interrupts: ScenarioInterrupts = resolved_scheduling.interrupts + try: + with interrupts.handle_sigint(): + if resolved_scheduling.worker_count <= 1: + return _run_sequentially( + scenarios=scenarios, run=run, complete=complete, interrupts=interrupts + ) + return _run_concurrently( + scenarios=scenarios, + run=run, + complete=complete, + on_scenario_start=on_scenario_start, + scheduling=resolved_scheduling, + ) + except BaseException as error: + if interrupts.abandoned.is_set(): + interrupts.abandon() + elif isinstance(error, KeyboardInterrupt): + interrupts.announce_stop() + raise + + +def _run_sequentially[OutcomeT, ResultT]( + *, + scenarios: tuple[CompiledSqlScenario, ...], + run: Callable[..., OutcomeT], + complete: Callable[[OutcomeT], ResultT], + interrupts: ScenarioInterrupts, +) -> tuple[ResultT, ...]: + """Run on the calling thread so Ctrl-C reaches the in-flight statement directly.""" + + results: list[ResultT] = [] + scenario: CompiledSqlScenario + for scenario in scenarios: + results.append(complete(run(scenario=scenario, notify_start=True))) + if interrupts.stop_requested.is_set(): + interrupts.announce_stop() + raise KeyboardInterrupt + return tuple(results) + + +def _run_concurrently[OutcomeT, ResultT]( + *, + scenarios: tuple[CompiledSqlScenario, ...], + run: Callable[..., OutcomeT], + complete: Callable[[OutcomeT], ResultT], + on_scenario_start: Callable[[CompiledSqlScenario], None] | None, + scheduling: _ScenarioScheduling, +) -> tuple[ResultT, ...]: + futures: tuple[Future[OutcomeT], ...] = tuple(Future() for _ in scenarios) + results: list[ResultT] = [] + try: + _start_scenario_workers( + scenarios=scenarios, + futures=futures, + run=run, + worker_count=scheduling.worker_count, + ) + index: int + future: Future[OutcomeT] + for index, future in enumerate(futures): if on_scenario_start is not None: - on_scenario_start(scenario) + on_scenario_start(scenarios[index]) + results.append(complete(future.result())) + except BaseException as error: + _stop_scenarios(error=error, futures=futures, scheduling=scheduling) + raise + return tuple(results) + + +def _start_scenario_workers[OutcomeT]( + *, + scenarios: tuple[CompiledSqlScenario, ...], + futures: tuple[Future[OutcomeT], ...], + run: Callable[..., OutcomeT], + worker_count: int, +) -> None: + """Start daemon workers so an abandoned run never blocks interpreter exit.""" + + queued: queue.SimpleQueue[int] = queue.SimpleQueue() + index: int + for index in range(len(scenarios)): + queued.put(index) + + def work() -> None: + while True: try: - scenario_plan = build_scenario_plan( - scenario=scenario, - pipeline_result=pipeline_result, - adapter=adapter, - project_name=project_name, - source_lexical_syntax=source_lexical_syntax, + position: int = queued.get_nowait() + except queue.Empty: + return + future: Future[OutcomeT] = futures[position] + if not future.set_running_or_notify_cancel(): + continue + try: + future.set_result( + copy_context().run(run, scenario=scenarios[position], notify_start=False) ) - result: ResultT = execute(scenario_plan) - except Exception as exc: - result = failed(scenario_name=scenario.name, exc=exc) - if result.status == ExecutionStatus.FAILED: - lifecycle.failed(error_code=result.error_code) - if projector is not None and on_scenario_complete is not None: - _ = projector.consume_resource_terminal( - resource_name=scenario.name, - resource_id=resource_id, - resource_attempt_id=lifecycle.resource_attempt_id, + except BaseException as exc: # noqa: BLE001 - delivered through the future + future.set_exception(exc) + + context: Context = copy_context() + worker: int + for worker in range(worker_count): + threading.Thread( + target=context.copy().run, + args=(work,), + name=f"sqlbuild-scenario-{worker}", + daemon=True, + ).start() + + +def _stop_scenarios[OutcomeT]( + *, + error: BaseException, + futures: tuple[Future[OutcomeT], ...], + scheduling: _ScenarioScheduling, +) -> None: + """Cancel queued scenarios and in-flight statements; a repeat interrupt abandons cleanup.""" + + interrupts: ScenarioInterrupts = scheduling.interrupts + if isinstance(error, KeyboardInterrupt): + interrupts.announce_stop() + interrupts.request_stop() + future: Future[OutcomeT] + for future in futures: + _ = future.cancel() + pending: tuple[Future[OutcomeT], ...] = futures + try: + if scheduling.running is not None: + scheduling.running.interrupt() + while pending and not interrupts.abandoned.is_set(): + _ = wait(pending, timeout=_STOP_POLL_SECONDS) + pending = tuple(item for item in pending if not item.done()) + except KeyboardInterrupt: + interrupts.abandon() + raise + if interrupts.abandoned.is_set(): + interrupts.abandon() + raise KeyboardInterrupt + + +def _run_scenario[ResultT: _ScenarioPipelineResult]( + *, + scenario: CompiledSqlScenario, + pipeline_result: CompilePipelineResult, + adapter: BaseAdapter, + project_name: str, + source_lexical_syntax: SqlLexicalSyntax, + execute: Callable[[ScenarioExecutionPlan], ResultT], + failed: _ScenarioFailureResult[ResultT], + on_scenario_start: Callable[[CompiledSqlScenario], None] | None, +) -> _ScenarioOutcome[ResultT]: + scenario_plan: ScenarioExecutionPlan | None = None + started: float = time.monotonic() + with ResourceAttemptLifecycle( + resource_id=_scenario_resource_id(scenario.name), + resource_kind="scenario", + resource_name=scenario.name, + run_id=pipeline_result.project.run_id, + ) as lifecycle: + if on_scenario_start is not None: + on_scenario_start(scenario) + try: + scenario_plan = build_scenario_plan( + scenario=scenario, + pipeline_result=pipeline_result, + adapter=adapter, + project_name=project_name, + source_lexical_syntax=source_lexical_syntax, ) - results.append(result) - if on_scenario_complete is not None: - on_scenario_complete(scenario, scenario_plan, result) - return tuple(results) + result: ResultT = execute(scenario_plan) + except Exception as exc: + result = failed(scenario_name=scenario.name, exc=exc) + result = replace(result, duration_ms=(time.monotonic() - started) * 1000) + if result.status == ExecutionStatus.FAILED: + lifecycle.failed(error_code=result.error_code) + return _ScenarioOutcome( + scenario=scenario, + scenario_plan=scenario_plan, + result=result, + resource_attempt_id=lifecycle.resource_attempt_id, + ) + + +def _scenario_resource_id(scenario_name: str) -> str: + return f"sql_scenario:{scenario_name}" -def _prepare_scenario_presentation( - *, scenario_name: str, has_completion_callback: bool -) -> NativeProgressProjector | None: +def _prepare_scenario_presentation(*, scenario_name: str, has_completion_callback: bool) -> None: projector: NativeProgressProjector | None = current_native_progress_projector() if projector is not None and has_completion_callback: projector.expect_resource_enrichment(resource_name=scenario_name) - return projector def select_scenario_snapshot_capture_candidates( diff --git a/src/sqlbuild/executor/run/_helpers/execution/table_targets.py b/src/sqlbuild/executor/run/_helpers/execution/table_targets.py index 8f277a4f61..cb2a03f5f9 100644 --- a/src/sqlbuild/executor/run/_helpers/execution/table_targets.py +++ b/src/sqlbuild/executor/run/_helpers/execution/table_targets.py @@ -3,6 +3,7 @@ from __future__ import annotations from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.adapter.relations.main.fit_auxiliary_relation_name import fit_auxiliary_relation_name from sqlbuild.adapter.relations.main.resolve_qualified_name_parts import ( resolve_qualified_name_parts, ) @@ -10,6 +11,7 @@ resolve_relation_location_qualified_name, ) from sqlbuild.compiler.planner.models import ModelPlanEntry +from sqlbuild.executor.run.constants import STAGING_RELATION_SUFFIX from sqlbuild.executor.run.models import TableTargets @@ -19,7 +21,11 @@ def resolve_table_targets(*, adapter: BaseAdapter, entry: ModelPlanEntry) -> Tab target_database: str | None = entry.destination.database target_schema: str | None = entry.destination.schema target_table: str = entry.destination.name - staging_table: str = f"{target_table}__staging" + staging_table: str = fit_auxiliary_relation_name( + base_name=target_table, + suffix=STAGING_RELATION_SUFFIX, + identifier_limit=adapter.maximum_identifier_length(), + ) return TableTargets( target_qualified=resolve_relation_location_qualified_name( adapter=adapter, location=entry.destination diff --git a/src/sqlbuild/executor/run/_helpers/materializations/incremental.py b/src/sqlbuild/executor/run/_helpers/materializations/incremental.py index 169ba3adf7..1c6fc03da1 100644 --- a/src/sqlbuild/executor/run/_helpers/materializations/incremental.py +++ b/src/sqlbuild/executor/run/_helpers/materializations/incremental.py @@ -8,6 +8,7 @@ from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.adapter.contract.classes.statement_recorder import StatementRecorder from sqlbuild.adapter.contract.models import ColumnInfo +from sqlbuild.adapter.relations.main.fit_auxiliary_relation_name import fit_auxiliary_relation_name from sqlbuild.adapter.relations.main.resolve_qualified_name_parts import ( resolve_qualified_name_parts, ) @@ -47,6 +48,7 @@ substitute_cursor_sentinels, ) from sqlbuild.executor.run._helpers.validation.type_enforcement import enforce_types_staged +from sqlbuild.executor.run.constants import DELTA_RELATION_SUFFIX from sqlbuild.executor.run.exceptions import EmptyCursorInputsError from sqlbuild.executor.run.models import ( BoundViewGuard, @@ -118,7 +120,11 @@ def execute_incremental_entry( target_qualified: str = resolve_relation_location_qualified_name( adapter=adapter, location=entry.destination ) - delta_table: str = f"{target_table}__delta" + delta_table: str = fit_auxiliary_relation_name( + base_name=target_table, + suffix=DELTA_RELATION_SUFFIX, + identifier_limit=adapter.maximum_identifier_length(), + ) delta_qualified: str = resolve_qualified_name_parts( adapter=adapter, database=target_database, diff --git a/src/sqlbuild/executor/run/_helpers/materializations/microbatch.py b/src/sqlbuild/executor/run/_helpers/materializations/microbatch.py index 23c5db6c41..0e310670b8 100644 --- a/src/sqlbuild/executor/run/_helpers/materializations/microbatch.py +++ b/src/sqlbuild/executor/run/_helpers/materializations/microbatch.py @@ -15,6 +15,7 @@ from sqlbuild.adapter.contract.classes.statement_recorder import StatementRecorder from sqlbuild.adapter.contract.models import ColumnInfo from sqlbuild.adapter.relations.main.cached_relation_exists import cached_relation_exists +from sqlbuild.adapter.relations.main.fit_auxiliary_relation_name import fit_auxiliary_relation_name from sqlbuild.adapter.relations.main.resolve_qualified_name_parts import ( resolve_qualified_name_parts, ) @@ -100,6 +101,7 @@ substitute_cursor_sentinels, ) from sqlbuild.executor.run._helpers.validation.type_enforcement import enforce_types_staged +from sqlbuild.executor.run.constants import DELTA_RELATION_SUFFIX from sqlbuild.executor.run.exceptions import EmptyCursorInputsError from sqlbuild.executor.run.models import ( BatchWindow, @@ -1000,7 +1002,11 @@ def _publish_pending_reconciliation_events( def _resolve_microbatch_targets(*, context: ModelMaterializationContext) -> MicrobatchTargets: entry: ModelPlanEntry = context.entry target_table: str = entry.destination.name - delta_table: str = f"{target_table}__delta" + delta_table: str = fit_auxiliary_relation_name( + base_name=target_table, + suffix=DELTA_RELATION_SUFFIX, + identifier_limit=context.adapter.maximum_identifier_length(), + ) return MicrobatchTargets( target_database=entry.destination.database, target_schema=entry.destination.schema, @@ -1512,10 +1518,11 @@ def _targets_for_batch( f"{context.run_id}:{batch.index}:{render(value=batch.start)}:" f"{render(value=batch.end)}".encode() ).hexdigest()[:12] - suffix: str = f"__delta_{identity}" - max_length: int = context.adapter.maximum_identifier_length() - target_prefix: str = targets.target_table[: max(1, max_length - len(suffix))] - delta_table: str = f"{target_prefix}{suffix}" + delta_table: str = fit_auxiliary_relation_name( + base_name=targets.target_table, + suffix=f"{DELTA_RELATION_SUFFIX}_{identity}", + identifier_limit=context.adapter.maximum_identifier_length(), + ) return replace( targets, delta_table=delta_table, diff --git a/src/sqlbuild/executor/run/_helpers/materializations/snapshot.py b/src/sqlbuild/executor/run/_helpers/materializations/snapshot.py index 0e6f458515..911926fe1f 100644 --- a/src/sqlbuild/executor/run/_helpers/materializations/snapshot.py +++ b/src/sqlbuild/executor/run/_helpers/materializations/snapshot.py @@ -13,6 +13,7 @@ ) from sqlbuild.adapter.contract.types import TypeFamily from sqlbuild.adapter.relations.main.cached_relation_exists import cached_relation_exists +from sqlbuild.adapter.relations.main.fit_auxiliary_relation_name import fit_auxiliary_relation_name from sqlbuild.adapter.relations.main.resolve_qualified_name_parts import ( resolve_qualified_name_parts, ) @@ -56,7 +57,10 @@ ) from sqlbuild.executor.run._helpers.reuse.fingerprinting import try_write_fingerprint from sqlbuild.executor.run._helpers.validation.contracts import validate_runtime_contract -from sqlbuild.executor.run.constants import SNAPSHOT_ALL_CHECK_COLUMNS +from sqlbuild.executor.run.constants import ( + SNAPSHOT_ALL_CHECK_COLUMNS, + SNAPSHOT_DELTA_RELATION_SUFFIX, +) from sqlbuild.executor.run.models import ( FinalAuditRun, FullRefreshRelations, @@ -100,7 +104,11 @@ def execute_snapshot_entry( # noqa: PLR0915 target_qualified: str = resolve_relation_location_qualified_name( adapter=adapter, location=entry.destination ) - delta_table: str = f"{target_table}__snapshot_delta" + delta_table: str = fit_auxiliary_relation_name( + base_name=target_table, + suffix=SNAPSHOT_DELTA_RELATION_SUFFIX, + identifier_limit=adapter.maximum_identifier_length(), + ) delta_qualified: str = resolve_qualified_name_parts( adapter=adapter, database=target_database, diff --git a/src/sqlbuild/executor/run/_helpers/validation/type_enforcement.py b/src/sqlbuild/executor/run/_helpers/validation/type_enforcement.py index 0d4d1b9c51..bf0cf58fa7 100644 --- a/src/sqlbuild/executor/run/_helpers/validation/type_enforcement.py +++ b/src/sqlbuild/executor/run/_helpers/validation/type_enforcement.py @@ -7,8 +7,13 @@ from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.adapter.contract.classes.statement_recorder import StatementRecorder from sqlbuild.adapter.contract.models import ColumnInfo +from sqlbuild.adapter.relations.main.fit_auxiliary_relation_name import fit_auxiliary_relation_name +from sqlbuild.adapter.relations.main.resolve_qualified_name_parts import ( + resolve_qualified_name_parts, +) from sqlbuild.adapter.type_system.main.types_equal import types_equal from sqlbuild.executor.run._helpers.execution.schema import inspect_runtime_relation_schema +from sqlbuild.executor.run.constants import TYPE_ENFORCEMENT_RELATION_SUFFIX def enforce_types_staged( @@ -59,7 +64,12 @@ def enforce_types_staged( produced_columns=produced_columns, declared_map=declared_map, ) - enforced_qualified: str = f"{staging_qualified}__enforced" + enforced_qualified: str = resolve_qualified_name_parts( + adapter=adapter, + database=staging_database, + schema=staging_schema, + name=type_enforcement_relation_name(adapter=adapter, staging_table=staging_table), + ) adapter.create_table_as( connection=connection, destination=enforced_qualified, @@ -105,3 +115,13 @@ def _build_type_enforcement_projection( ) ) return ", ".join(projection_parts) + + +def type_enforcement_relation_name(*, adapter: BaseAdapter, staging_table: str) -> str: + """Return the fitted name of the relation type enforcement rebuilds staging into.""" + + return fit_auxiliary_relation_name( + base_name=staging_table, + suffix=TYPE_ENFORCEMENT_RELATION_SUFFIX, + identifier_limit=adapter.maximum_identifier_length(), + ) diff --git a/src/sqlbuild/executor/run/constants.py b/src/sqlbuild/executor/run/constants.py index 307aded100..d5486ad907 100644 --- a/src/sqlbuild/executor/run/constants.py +++ b/src/sqlbuild/executor/run/constants.py @@ -19,3 +19,8 @@ RUNTIME_CONTRACT_TYPE_MISMATCH_CODE: str = "K010" SNAPSHOT_ALL_CHECK_COLUMNS: tuple[str, ...] = ("*",) + +STAGING_RELATION_SUFFIX: str = "__staging" +TYPE_ENFORCEMENT_RELATION_SUFFIX: str = "__enforced" +DELTA_RELATION_SUFFIX: str = "__delta" +SNAPSHOT_DELTA_RELATION_SUFFIX: str = "__snapshot_delta" diff --git a/src/sqlbuild/executor/run/main/_table_targets.py b/src/sqlbuild/executor/run/main/_table_targets.py new file mode 100644 index 0000000000..69539ca521 --- /dev/null +++ b/src/sqlbuild/executor/run/main/_table_targets.py @@ -0,0 +1,16 @@ +"""Public destination and staging identifier resolution for table models.""" + +from __future__ import annotations + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.compiler.planner.models import ModelPlanEntry +from sqlbuild.executor.run._helpers.execution.table_targets import ( + resolve_table_targets as _resolve_table_targets, +) +from sqlbuild.executor.run.models import TableTargets + + +def resolve_table_targets(*, adapter: BaseAdapter, entry: ModelPlanEntry) -> TableTargets: + """Resolve destination and staging identifiers for one table model.""" + + return _resolve_table_targets(adapter=adapter, entry=entry) diff --git a/src/sqlbuild/executor/run/main/_type_enforcement_relation_name.py b/src/sqlbuild/executor/run/main/_type_enforcement_relation_name.py new file mode 100644 index 0000000000..73ea92d219 --- /dev/null +++ b/src/sqlbuild/executor/run/main/_type_enforcement_relation_name.py @@ -0,0 +1,14 @@ +"""Public name of the relation type enforcement rebuilds a staging table into.""" + +from __future__ import annotations + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.executor.run._helpers.validation.type_enforcement import ( + type_enforcement_relation_name, +) + + +def resolve_type_enforcement_relation_name(*, adapter: BaseAdapter, staging_table: str) -> str: + """Return the fitted relation name type enforcement rebuilds a staging table into.""" + + return type_enforcement_relation_name(adapter=adapter, staging_table=staging_table) diff --git a/src/sqlbuild/executor/scenario/_helpers/execution/interrupts.py b/src/sqlbuild/executor/scenario/_helpers/execution/interrupts.py new file mode 100644 index 0000000000..a51ea11a8f --- /dev/null +++ b/src/sqlbuild/executor/scenario/_helpers/execution/interrupts.py @@ -0,0 +1,32 @@ +"""Cleanup of scenarios stopped by an interrupt.""" + +from __future__ import annotations + +from typing import Any + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.compiler.planner.models import ScenarioExecutionPlan +from sqlbuild.executor.scenario.classes.scenario_interrupts import ScenarioInterrupts +from sqlbuild.executor.scenario.main._cleanup import execute_scenario_cleanup + + +def cleanup_interrupted_scenarios( + *, + scenario_plans: tuple[ScenarioExecutionPlan, ...], + adapter: BaseAdapter, + connection: Any, + interrupts: ScenarioInterrupts, +) -> None: + """Drop the relations of interrupted scenarios; a repeat interrupt abandons the rest.""" + + if interrupts.abandoned.is_set(): + return + try: + scenario_plan: ScenarioExecutionPlan + for scenario_plan in scenario_plans: + _ = execute_scenario_cleanup( + scenario_plan=scenario_plan, adapter=adapter, connection=connection + ) + except KeyboardInterrupt: + interrupts.abandon() + raise diff --git a/src/sqlbuild/executor/scenario/_helpers/execution/model_execution.py b/src/sqlbuild/executor/scenario/_helpers/execution/model_execution.py index bfe20e089d..b4c113d910 100644 --- a/src/sqlbuild/executor/scenario/_helpers/execution/model_execution.py +++ b/src/sqlbuild/executor/scenario/_helpers/execution/model_execution.py @@ -2,11 +2,14 @@ from __future__ import annotations +import threading from typing import Any from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.adapter.contract.types import TablePromotionMode from sqlbuild.compiler.planner.models import ModelPlanEntry, ScenarioExecutionPlan from sqlbuild.executor.run.models import ModelExecutionResult +from sqlbuild.executor.scenario.exceptions import ScenarioStopRequested from sqlbuild.executor.scenario.main._execute import execute_scenario_model from sqlbuild.executor.scheduling.types import ExecutionStatus from sqlbuild.runtime.observability.classes.resource_attempt_lifecycle import ( @@ -20,12 +23,17 @@ def execute_scenario_models( adapter: BaseAdapter, connection: Any, run_id: str, + promotion_mode: TablePromotionMode, + schema_prepared: bool = False, + stop_requested: threading.Event | None = None, ) -> tuple[ModelExecutionResult, ...]: """Execute scenario model entries in planned dependency order.""" results: list[ModelExecutionResult] = [] entry: ModelPlanEntry for entry in scenario_plan.model_entries: + if stop_requested is not None and stop_requested.is_set(): + raise ScenarioStopRequested with ResourceAttemptLifecycle( resource_id=f"scenario:{scenario_plan.name}:model:{entry.name}", resource_kind=str(entry.materialization_type), @@ -38,6 +46,8 @@ def execute_scenario_models( adapter=adapter, connection=connection, run_id=run_id, + promotion_mode=promotion_mode, + schema_prepared=schema_prepared, ) if result.status == ExecutionStatus.FAILED: lifecycle.failed(error_code=result.error_code) diff --git a/src/sqlbuild/executor/scenario/_helpers/execution/run.py b/src/sqlbuild/executor/scenario/_helpers/execution/run.py index 666b58b377..f72438428e 100644 --- a/src/sqlbuild/executor/scenario/_helpers/execution/run.py +++ b/src/sqlbuild/executor/scenario/_helpers/execution/run.py @@ -8,6 +8,9 @@ from sqlbuild.compiler.planner.models import ScenarioExecutionPlan, ScenarioRelationMap from sqlbuild.executor.build.models import SeedExecutionResult from sqlbuild.executor.run.models import ModelExecutionResult +from sqlbuild.executor.scenario._helpers.execution.interrupts import ( + cleanup_interrupted_scenarios, +) from sqlbuild.executor.scenario._helpers.execution.model_execution import execute_scenario_models from sqlbuild.executor.scenario._helpers.lifecycle.expectations import ( execute_scenario_assertion_expectations, @@ -18,7 +21,9 @@ execute_scenario_fixtures, execute_scenario_seed_entries, ) +from sqlbuild.executor.scenario.classes.scenario_interrupts import ScenarioInterrupts from sqlbuild.executor.scenario.constants import SCENARIO_EXEC_CLEANUP_FAILED +from sqlbuild.executor.scenario.exceptions import ScenarioStopRequested from sqlbuild.executor.scenario.main._cleanup import execute_scenario_cleanup from sqlbuild.executor.scenario.models import ( ScenarioAssertionExpectationExecutionResult, @@ -26,6 +31,7 @@ ScenarioExpectedExpectationExecutionResult, ScenarioFailureDetails, ScenarioFixtureExecutionResult, + ScenarioRunOptions, ScenarioRunResult, ScenarioStepResults, ) @@ -42,17 +48,31 @@ def execute_scenario_run_steps( connection: Any, run_id: str, retain: bool, + options: ScenarioRunOptions, ) -> ScenarioRunResult: """Execute a planned scenario and apply cleanup policy.""" with OperationLifecycle(operation_kind="scenario", operation_name="scenario_execution"): - return _execute_scenario_run_steps( - scenario_plan=scenario_plan, - adapter=adapter, - connection=connection, - run_id=run_id, - retain=retain, - ) + try: + return _execute_scenario_run_steps( + scenario_plan=scenario_plan, + adapter=adapter, + connection=connection, + run_id=run_id, + retain=retain, + options=options, + ) + except BaseException as error: + if isinstance(error, (KeyboardInterrupt, ScenarioStopRequested)): + options.interrupts.announce_stop() + if not retain: + cleanup_interrupted_scenarios( + scenario_plans=(scenario_plan,), + adapter=adapter, + connection=connection, + interrupts=options.interrupts, + ) + raise def _execute_scenario_run_steps( @@ -62,8 +82,10 @@ def _execute_scenario_run_steps( connection: Any, run_id: str, retain: bool, + options: ScenarioRunOptions, ) -> ScenarioRunResult: + _raise_if_stop_requested(options) prepare_result: ScenarioCleanupExecutionResult = execute_scenario_cleanup( scenario_plan=scenario_plan, adapter=adapter, @@ -80,15 +102,23 @@ def _execute_scenario_run_steps( error_message=prepare_result.error_message, ) + _raise_if_stop_requested(options) + schema_prepared: bool = options.prepared_schemas is not None and ( + options.prepared_schemas.ensure( + scenario_plan=scenario_plan, adapter=adapter, connection=connection + ) + ) fixture_results: tuple[ScenarioFixtureExecutionResult, ...] = execute_scenario_fixtures( scenario_name=scenario_plan.name, fixture_plans=scenario_plan.fixture_plans, adapter=adapter, connection=connection, + schema_prepared=schema_prepared, ) if _has_failed(fixture_results): return _finish_scenario( scenario_plan=scenario_plan, + interrupts=options.interrupts, adapter=adapter, connection=connection, retain=retain, @@ -99,6 +129,7 @@ def _execute_scenario_run_steps( ), ) + _raise_if_stop_requested(options) seed_results: tuple[SeedExecutionResult, ...] = execute_scenario_seed_entries( scenario_name=scenario_plan.name, seed_entries=scenario_plan.seed_entries, @@ -109,6 +140,7 @@ def _execute_scenario_run_steps( if _has_failed(seed_results): return _finish_scenario( scenario_plan=scenario_plan, + interrupts=options.interrupts, adapter=adapter, connection=connection, retain=retain, @@ -122,15 +154,20 @@ def _execute_scenario_run_steps( ), ) + _raise_if_stop_requested(options) model_results: tuple[ModelExecutionResult, ...] = execute_scenario_models( scenario_plan=scenario_plan, adapter=adapter, connection=connection, run_id=run_id, + promotion_mode=options.promotion_mode, + schema_prepared=schema_prepared, + stop_requested=options.interrupts.stop_requested, ) if _has_failed(model_results): return _finish_scenario( scenario_plan=scenario_plan, + interrupts=options.interrupts, adapter=adapter, connection=connection, retain=retain, @@ -145,12 +182,14 @@ def _execute_scenario_run_steps( ), ) + _raise_if_stop_requested(options) expected_results: tuple[ScenarioExpectedExpectationExecutionResult, ...] expected_results = execute_scenario_expected_expectations( scenario_plan=scenario_plan, adapter=adapter, connection=connection, ) + _raise_if_stop_requested(options) assertion_results: tuple[ScenarioAssertionExpectationExecutionResult, ...] assertion_results = execute_scenario_assertion_expectations( scenario_plan=scenario_plan, @@ -159,6 +198,7 @@ def _execute_scenario_run_steps( ) return _finish_scenario( scenario_plan=scenario_plan, + interrupts=options.interrupts, adapter=adapter, connection=connection, retain=retain, @@ -180,6 +220,7 @@ def _execute_scenario_run_steps( def _finish_scenario( *, scenario_plan: ScenarioExecutionPlan, + interrupts: ScenarioInterrupts, adapter: BaseAdapter, connection: Any, retain: bool, @@ -200,7 +241,9 @@ def _finish_scenario( ExecutionStatus.FAILED if error_message is not None else ExecutionStatus.SUCCESS ) cleanup_result: ScenarioCleanupExecutionResult | None = None - if not retain: + if interrupts.stop_requested.is_set(): + interrupts.announce_stop() + if not retain and not interrupts.abandoned.is_set(): cleanup_result = execute_scenario_cleanup( scenario_plan=scenario_plan, adapter=adapter, @@ -256,5 +299,10 @@ def _scenario_failure( ) +def _raise_if_stop_requested(options: ScenarioRunOptions) -> None: + if options.interrupts.stop_requested.is_set(): + raise ScenarioStopRequested + + def _has_failed(results: tuple[object, ...]) -> bool: return any(getattr(result, "status", None) == ExecutionStatus.FAILED for result in results) diff --git a/src/sqlbuild/executor/scenario/_helpers/lifecycle/cleanup.py b/src/sqlbuild/executor/scenario/_helpers/lifecycle/cleanup.py index 4722bb82e9..6575d7cb45 100644 --- a/src/sqlbuild/executor/scenario/_helpers/lifecycle/cleanup.py +++ b/src/sqlbuild/executor/scenario/_helpers/lifecycle/cleanup.py @@ -2,10 +2,21 @@ from __future__ import annotations +import logging +from collections import defaultdict +from dataclasses import replace +from typing import Any + from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.adapter.contract.models import RelationInfo, RelationLookup +from sqlbuild.adapter.contract.types import RelationType +from sqlbuild.adapter.relations.main.resolve_qualified_name_parts import ( + resolve_qualified_name_parts, +) from sqlbuild.adapter.relations.main.resolve_relation_location_qualified_name import ( resolve_relation_location_qualified_name, ) +from sqlbuild.adapter.type_system.main.normalize_relation_type import normalize_relation_type from sqlbuild.compiler.compile.models import CompiledRelationLocation from sqlbuild.compiler.planner.models import ( ModelPlanEntry, @@ -14,8 +25,16 @@ SeedPlanEntry, ) from sqlbuild.compiler.planner.types import MaterializationType, ScenarioArtifactKind +from sqlbuild.diagnostics.main.log_debug_event import log_debug_event +from sqlbuild.executor.run.main._table_targets import resolve_table_targets +from sqlbuild.executor.run.main._type_enforcement_relation_name import ( + resolve_type_enforcement_relation_name, +) +from sqlbuild.executor.run.models import TableTargets from sqlbuild.executor.scenario.models import ScenarioCleanupTarget +_DEBUG_LOGGER: logging.Logger = logging.getLogger("sqlbuild.execution") + def collect_scenario_cleanup_targets( *, @@ -69,6 +88,38 @@ def collect_scenario_cleanup_targets( materialization_type=materialization_type, ) ) + if model_entry is not None and MaterializationType.is_table_backed( + materialized=materialization_type + ): + table_targets: TableTargets = resolve_table_targets(adapter=adapter, entry=model_entry) + candidates.append( + ScenarioCleanupTarget( + kind=ScenarioArtifactKind.MODEL, + logical_name=model_name, + target_relation=table_targets.staging_qualified, + database=table_targets.target_database, + schema=table_targets.target_schema, + name=table_targets.staging_table, + ) + ) + enforced_table: str = resolve_type_enforcement_relation_name( + adapter=adapter, staging_table=table_targets.staging_table + ) + candidates.append( + ScenarioCleanupTarget( + kind=ScenarioArtifactKind.MODEL, + logical_name=model_name, + target_relation=resolve_qualified_name_parts( + adapter=adapter, + database=table_targets.target_database, + schema=table_targets.target_schema, + name=enforced_table, + ), + database=table_targets.target_database, + schema=table_targets.target_schema, + name=enforced_table, + ) + ) targets: list[ScenarioCleanupTarget] = [] seen: set[str] = set() @@ -97,4 +148,89 @@ def _cleanup_target( logical_name=logical_name, target_relation=target_relation, materialization_type=materialization_type, + database=target.database, + schema=target.schema, + name=target.name, + ) + + +def existing_scenario_cleanup_targets( + *, + targets: tuple[ScenarioCleanupTarget, ...], + adapter: BaseAdapter, + connection: Any, +) -> tuple[ScenarioCleanupTarget, ...]: + """Return targets to drop: catalogued ones typed by the catalog, plus every unlistable one.""" + + listed_targets: tuple[ScenarioCleanupTarget, ...] = tuple( + target for target in targets if _authoritatively_listable(target=target, adapter=adapter) ) + names_by_database: defaultdict[str | None, set[str]] = defaultdict(set) + schemas_by_database: defaultdict[str | None, set[str]] = defaultdict(set) + target: ScenarioCleanupTarget + for target in listed_targets: + names_by_database[target.database].update(_case_variants(str(target.name))) + schemas_by_database[target.database].update(_case_variants(str(target.schema))) + exact_types: dict[tuple[str | None, str | None, str], str] = {} + folded_types: dict[tuple[str | None, str | None, str], str] = {} + try: + database: str | None + names: set[str] + for database, names in names_by_database.items(): + relation: RelationInfo + for relation in adapter.list_relations( + connection=connection, + database=database, + schemas=tuple(sorted(schemas_by_database[database])), + names=tuple(sorted(names)), + ): + exact_types[(database, relation.schema, relation.name)] = relation.relation_type + folded_types[ + RelationLookup.key( + database=database, schema=relation.schema, name=relation.name + ) + ] = relation.relation_type + except Exception as exc: + log_debug_event( + logger=_DEBUG_LOGGER, + message="scenario cleanup catalog read failed; dropping every target", + sqlbuild_error=str(exc), + ) + return targets + existing: list[ScenarioCleanupTarget] = [] + for target in targets: + if target not in listed_targets: + existing.append(target) + continue + relation_type: str | None = exact_types.get( + (target.database, target.schema, str(target.name)) + ) or folded_types.get( + RelationLookup.key( + database=target.database, schema=target.schema, name=str(target.name) + ) + ) + if relation_type is None: + continue + existing.append( + replace( + target, + materialization_type=( + MaterializationType.VIEW + if normalize_relation_type(relation_type) == RelationType.VIEW + else MaterializationType.TABLE + ), + ) + ) + return tuple(existing) + + +def _authoritatively_listable(*, target: ScenarioCleanupTarget, adapter: BaseAdapter) -> bool: + return ( + target.name is not None + and target.schema is not None + and adapter.lists_relations_authoritatively(database=target.database, schema=target.schema) + ) + + +def _case_variants(value: str) -> tuple[str, ...]: + return (value, value.lower(), value.upper()) diff --git a/src/sqlbuild/executor/scenario/_helpers/lifecycle/fixtures.py b/src/sqlbuild/executor/scenario/_helpers/lifecycle/fixtures.py index b1261b8cec..7d90a5b52e 100644 --- a/src/sqlbuild/executor/scenario/_helpers/lifecycle/fixtures.py +++ b/src/sqlbuild/executor/scenario/_helpers/lifecycle/fixtures.py @@ -25,6 +25,7 @@ def execute_scenario_fixtures( fixture_plans: tuple[ScenarioFixturePlan, ...], adapter: BaseAdapter, connection: Any, + schema_prepared: bool = False, ) -> tuple[ScenarioFixtureExecutionResult, ...]: """Materialize scenario fixtures in planned order.""" @@ -36,6 +37,7 @@ def execute_scenario_fixtures( fixture_plan=fixture_plan, adapter=adapter, connection=connection, + schema_prepared=schema_prepared, ) results.append(result) if result.status == ExecutionStatus.FAILED: diff --git a/src/sqlbuild/executor/scenario/_helpers/local/execution.py b/src/sqlbuild/executor/scenario/_helpers/local/execution.py index 52b73bfd98..d3173a0a38 100644 --- a/src/sqlbuild/executor/scenario/_helpers/local/execution.py +++ b/src/sqlbuild/executor/scenario/_helpers/local/execution.py @@ -8,6 +8,7 @@ from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.adapter.contract.classes.statement_recorder import StatementRecorder +from sqlbuild.adapter.contract.types import TablePromotionMode from sqlbuild.compiler.compile.models import CompiledRelationLocation from sqlbuild.compiler.compile.types import FunctionLanguage from sqlbuild.compiler.planner.models import ( @@ -81,6 +82,7 @@ def execute_local_scenario_load_only_run( scenario_plan: ScenarioExecutionPlan, adapter: BaseAdapter, strict: bool, + promotion_mode: TablePromotionMode, capture_adapter: str | None = None, capture_dialect: str | None = None, ) -> ScenarioRunResult: @@ -155,6 +157,7 @@ def execute_local_scenario_load_only_run( adapter=adapter, connection=connection, run_id=f"local-{scenario_plan.name}", + promotion_mode=promotion_mode, duckdb_path=duckdb_path, loaded_relations=load_result.relations, ) @@ -288,6 +291,7 @@ def _execute_local_plan( adapter: BaseAdapter, connection: Any, run_id: str, + promotion_mode: TablePromotionMode, duckdb_path: Path, loaded_relations: tuple[ScenarioLocalSnapshotLoadedRelation, ...], ) -> ScenarioRunResult: @@ -317,6 +321,7 @@ def _execute_local_plan( adapter=adapter, connection=connection, run_id=run_id, + promotion_mode=promotion_mode, ) if _has_failed(model_results): local_model_results: tuple[ModelExecutionResult, ...] = _with_local_model_error_codes( diff --git a/src/sqlbuild/executor/scenario/classes/__init__.py b/src/sqlbuild/executor/scenario/classes/__init__.py new file mode 100644 index 0000000000..971ff5966d --- /dev/null +++ b/src/sqlbuild/executor/scenario/classes/__init__.py @@ -0,0 +1 @@ +"""Scenario executor runtime classes.""" diff --git a/src/sqlbuild/executor/scenario/classes/prepared_scenario_schemas.py b/src/sqlbuild/executor/scenario/classes/prepared_scenario_schemas.py new file mode 100644 index 0000000000..d8e316f66b --- /dev/null +++ b/src/sqlbuild/executor/scenario/classes/prepared_scenario_schemas.py @@ -0,0 +1,61 @@ +"""Scenario schemas ensured once per invocation.""" + +from __future__ import annotations + +import logging +import threading +from typing import Any + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.adapter.contract.classes.statement_recorder import StatementRecorder +from sqlbuild.compiler.compile.models import CompiledRelationLocation +from sqlbuild.compiler.planner.models import ScenarioExecutionPlan +from sqlbuild.diagnostics.main.log_debug_event import log_debug_event + +_DEBUG_LOGGER: logging.Logger = logging.getLogger("sqlbuild.execution") + + +class PreparedScenarioSchemas: + """Thread-safe record of the scenario destination schemas already ensured.""" + + def __init__(self) -> None: + self._lock: threading.Lock = threading.Lock() + self._prepared: set[tuple[str | None, str]] = set() + + def ensure( + self, *, scenario_plan: ScenarioExecutionPlan, adapter: BaseAdapter, connection: Any + ) -> bool: + """Ensure every schema the scenario writes to; return whether all are ready.""" + + locations: tuple[CompiledRelationLocation, ...] = ( + *(fixture_plan.destination for fixture_plan in scenario_plan.fixture_plans), + *(entry.destination for entry in scenario_plan.model_entries), + ) + schemas: set[tuple[str | None, str]] = set() + location: CompiledRelationLocation + for location in locations: + if location.schema is not None: + schemas.add((location.database, location.schema)) + with self._lock: + missing: list[tuple[str | None, str]] = sorted( + schemas - self._prepared, key=lambda item: (item[0] or "", item[1]) + ) + database: str | None + schema: str + for database, schema in missing: + try: + adapter.ensure_schema( + connection=connection, + database=database, + schema=schema, + statement_recorder=StatementRecorder(), + ) + except Exception as exc: + log_debug_event( + logger=_DEBUG_LOGGER, + message="scenario schema preparation failed; preparing per relation", + sqlbuild_error=str(exc), + ) + return False + self._prepared.add((database, schema)) + return True diff --git a/src/sqlbuild/executor/scenario/classes/running_scenarios.py b/src/sqlbuild/executor/scenario/classes/running_scenarios.py new file mode 100644 index 0000000000..174fbe19ff --- /dev/null +++ b/src/sqlbuild/executor/scenario/classes/running_scenarios.py @@ -0,0 +1,68 @@ +"""Connections and plans of scenarios running on concurrent workers.""" + +from __future__ import annotations + +import logging +import threading +from typing import Any + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.compiler.planner.models import ScenarioExecutionPlan +from sqlbuild.diagnostics.main.log_debug_event import log_debug_event + +_DEBUG_LOGGER: logging.Logger = logging.getLogger("sqlbuild.execution") + + +class RunningScenarios: + """Track in-flight worker connections so an interrupt can cancel their statements.""" + + def __init__(self, *, adapter: BaseAdapter) -> None: + self._adapter: BaseAdapter = adapter + self._lock: threading.RLock = threading.RLock() + self._connections: dict[int, Any] = {} + self._started: list[ScenarioExecutionPlan] = [] + self._closed_connection: bool = False + + def start(self, *, scenario_plan: ScenarioExecutionPlan, connection: Any) -> None: + """Record that a worker began a scenario on a connection.""" + + with self._lock: + self._connections[id(connection)] = connection + self._started.append(scenario_plan) + + def finish(self, *, connection: Any) -> None: + """Record that a worker stopped using a connection.""" + + with self._lock: + _ = self._connections.pop(id(connection), None) + + def interrupt(self, *, close_uncancellable: bool = True) -> None: + """Cancel every in-flight statement, optionally closing connections that cannot cancel.""" + + with self._lock: + connections: tuple[Any, ...] = tuple(self._connections.values()) + connection: Any + for connection in connections: + try: + if not self._adapter.interrupt_connection(connection) and close_uncancellable: + self._closed_connection = True + self._adapter.close(connection) + except Exception as exc: + log_debug_event( + logger=_DEBUG_LOGGER, + message="scenario statement interrupt failed", + sqlbuild_error=str(exc), + ) + + @property + def started_plans(self) -> tuple[ScenarioExecutionPlan, ...]: + """Return the plans of every scenario a worker started.""" + + with self._lock: + return tuple(self._started) + + @property + def closed_connection(self) -> bool: + """Return whether an interrupt closed a worker connection.""" + + return self._closed_connection diff --git a/src/sqlbuild/executor/scenario/classes/scenario_interrupts.py b/src/sqlbuild/executor/scenario/classes/scenario_interrupts.py new file mode 100644 index 0000000000..c6a1829b3c --- /dev/null +++ b/src/sqlbuild/executor/scenario/classes/scenario_interrupts.py @@ -0,0 +1,78 @@ +"""Ctrl-C state shared by the scenario scheduler and running scenarios.""" + +from __future__ import annotations + +import signal +import sys +import threading +from collections.abc import Callable, Iterator +from contextlib import contextmanager + +from sqlbuild.presentation.main.transient_line_coordinator import ( + shared_transient_line_coordinator, +) + +_STOP_NOTICE: str = "Interrupted; cleaning up running scenarios...\n" +_ABANDON_NOTICE: str = ( + "Interrupted again; skipped scenario cleanup. Leftover scenario relations are dropped " + "the next time those scenarios run.\n" +) + + +class ScenarioInterrupts: + """First Ctrl-C stops scenarios and cleans up; a repeat abandons cleanup and exits.""" + + def __init__(self, *, cancel_statements: Callable[[], None] | None = None) -> None: + self._cancel_statements: Callable[[], None] | None = cancel_statements + self.stop_requested: threading.Event = threading.Event() + self.abandoned: threading.Event = threading.Event() + self._lock: threading.Lock = threading.Lock() + self._announced: set[str] = set() + + @contextmanager + def handle_sigint(self) -> Iterator[None]: + """Record Ctrl-C and cancel in-flight statements, even when a driver hides the signal.""" + + if threading.current_thread() is not threading.main_thread(): + yield + return + previous: object = signal.getsignal(signal.SIGINT) + if previous is not signal.default_int_handler: + yield + return + _ = signal.signal(signal.SIGINT, self._on_sigint) + try: + yield + finally: + _ = signal.signal(signal.SIGINT, previous) + + def request_stop(self) -> None: + """Ask running scenarios to stop at their next step.""" + + self.stop_requested.set() + + def abandon(self) -> None: + """Skip all remaining cleanup and tell the user once.""" + + self.abandoned.set() + self._announce(_ABANDON_NOTICE) + + def announce_stop(self) -> None: + """Tell the user, once, that scenarios are stopping and cleaning up.""" + + self._announce(_STOP_NOTICE) + + def _announce(self, notice: str) -> None: + with self._lock: + if notice in self._announced: + return + self._announced.add(notice) + shared_transient_line_coordinator().write_persistent(stream=sys.stderr, text=notice) + + def _on_sigint(self, *_signal_args: object) -> None: + if self.stop_requested.is_set(): + self.abandoned.set() + self.stop_requested.set() + if self._cancel_statements is not None: + self._cancel_statements() + raise KeyboardInterrupt diff --git a/src/sqlbuild/executor/scenario/exceptions.py b/src/sqlbuild/executor/scenario/exceptions.py index 7705042a41..3243210e6a 100644 --- a/src/sqlbuild/executor/scenario/exceptions.py +++ b/src/sqlbuild/executor/scenario/exceptions.py @@ -13,3 +13,7 @@ def __init__(self, message: str, *, code: str | None = None, help: str | None = self.message = message self.code = code if code is not None else self.code self.help = help + + +class ScenarioStopRequested(BaseException): # noqa: N818 - a control signal, not an error + """Raised at a step boundary when an interrupt asked running scenarios to stop.""" diff --git a/src/sqlbuild/executor/scenario/main/_cleanup.py b/src/sqlbuild/executor/scenario/main/_cleanup.py index e65cd70476..831fc6c66f 100644 --- a/src/sqlbuild/executor/scenario/main/_cleanup.py +++ b/src/sqlbuild/executor/scenario/main/_cleanup.py @@ -9,7 +9,10 @@ from sqlbuild.compiler.planner.models import ScenarioExecutionPlan from sqlbuild.compiler.planner.types import MaterializationType from sqlbuild.diagnostics.main.diagnostics_context import diagnostics_context -from sqlbuild.executor.scenario._helpers.lifecycle.cleanup import collect_scenario_cleanup_targets +from sqlbuild.executor.scenario._helpers.lifecycle.cleanup import ( + collect_scenario_cleanup_targets, + existing_scenario_cleanup_targets, +) from sqlbuild.executor.scenario.constants import SCENARIO_EXEC_CLEANUP_FAILED from sqlbuild.executor.scenario.models import ( ScenarioCleanupExecutionResult, @@ -25,7 +28,7 @@ def execute_scenario_cleanup( adapter: BaseAdapter, connection: Any, ) -> ScenarioCleanupExecutionResult: - """Drop only scenario-owned relations listed in the current scenario plan.""" + """Drop the scenario-owned relations of the current plan that exist.""" with OperationLifecycle( operation_kind="scenario", operation_name="scenario_cleanup" @@ -52,26 +55,28 @@ def _execute_scenario_cleanup( ) try: - cleanup_target: ScenarioCleanupTarget - for cleanup_target in cleanup_targets: + drop_target: ScenarioCleanupTarget + for drop_target in existing_scenario_cleanup_targets( + targets=cleanup_targets, adapter=adapter, connection=connection + ): with diagnostics_context( sqlbuild_phase="scenario_cleanup", sqlbuild_action_name="drop_relation", sqlbuild_scenario=scenario_plan.name, - sqlbuild_artifact_kind=cleanup_target.kind.value, - sqlbuild_artifact_name=cleanup_target.logical_name, + sqlbuild_artifact_kind=drop_target.kind.value, + sqlbuild_artifact_name=drop_target.logical_name, ): - if cleanup_target.materialization_type == MaterializationType.VIEW: + if drop_target.materialization_type == MaterializationType.VIEW: adapter.drop_view( connection=connection, - destination=cleanup_target.target_relation, + destination=drop_target.target_relation, if_exists=True, statement_recorder=statement_recorder, ) else: adapter.drop( connection=connection, - destination=cleanup_target.target_relation, + destination=drop_target.target_relation, if_exists=True, statement_recorder=statement_recorder, ) diff --git a/src/sqlbuild/executor/scenario/main/_cleanup_interrupted.py b/src/sqlbuild/executor/scenario/main/_cleanup_interrupted.py new file mode 100644 index 0000000000..75ad2e6a55 --- /dev/null +++ b/src/sqlbuild/executor/scenario/main/_cleanup_interrupted.py @@ -0,0 +1,29 @@ +"""Drop the relations of scenarios stopped by an interrupt.""" + +from __future__ import annotations + +from typing import Any + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.compiler.planner.models import ScenarioExecutionPlan +from sqlbuild.executor.scenario._helpers.execution.interrupts import ( + cleanup_interrupted_scenarios as _cleanup_interrupted_scenarios, +) +from sqlbuild.executor.scenario.classes.scenario_interrupts import ScenarioInterrupts + + +def cleanup_interrupted_scenarios( + *, + scenario_plans: tuple[ScenarioExecutionPlan, ...], + adapter: BaseAdapter, + connection: Any, + interrupts: ScenarioInterrupts, +) -> None: + """Drop interrupted scenarios' relations; a repeat interrupt skips the rest.""" + + return _cleanup_interrupted_scenarios( + scenario_plans=scenario_plans, + adapter=adapter, + connection=connection, + interrupts=interrupts, + ) diff --git a/src/sqlbuild/executor/scenario/main/_execute.py b/src/sqlbuild/executor/scenario/main/_execute.py index 80162dead6..f629db08d1 100644 --- a/src/sqlbuild/executor/scenario/main/_execute.py +++ b/src/sqlbuild/executor/scenario/main/_execute.py @@ -22,6 +22,8 @@ def execute_scenario_model( adapter: BaseAdapter, connection: Any, run_id: str, + promotion_mode: TablePromotionMode, + schema_prepared: bool = False, ) -> ModelExecutionResult: """Execute one scenario model entry against scenario-scoped relations.""" @@ -58,6 +60,7 @@ def execute_scenario_model( run_id=run_id, query_change_tracking=False, hook_functions=scenario_plan.hook_functions, + schema_prepared=schema_prepared, ) ) ) @@ -80,9 +83,10 @@ def execute_scenario_model( run_id=run_id, query_change_tracking=False, hook_functions=scenario_plan.hook_functions, + schema_prepared=schema_prepared, ), declared_columns=entry.declared_columns, - promotion_mode=TablePromotionMode.IMMEDIATE, + promotion_mode=promotion_mode, ) ) diff --git a/src/sqlbuild/executor/scenario/main/_fixtures.py b/src/sqlbuild/executor/scenario/main/_fixtures.py index 4564e04cbe..5fb4ec4969 100644 --- a/src/sqlbuild/executor/scenario/main/_fixtures.py +++ b/src/sqlbuild/executor/scenario/main/_fixtures.py @@ -22,6 +22,7 @@ def execute_scenario_fixture( fixture_plan: ScenarioFixturePlan, adapter: BaseAdapter, connection: Any, + schema_prepared: bool = False, ) -> ScenarioFixtureExecutionResult: """Materialize one source/ref/seed fixture as a scenario-owned table.""" @@ -32,12 +33,13 @@ def execute_scenario_fixture( ) try: - adapter.ensure_schema( - connection=connection, - database=fixture_plan.destination.database, - schema=fixture_plan.destination.schema, - statement_recorder=statement_recorder, - ) + if not schema_prepared: + adapter.ensure_schema( + connection=connection, + database=fixture_plan.destination.database, + schema=fixture_plan.destination.schema, + statement_recorder=statement_recorder, + ) with diagnostics_context( sqlbuild_phase="scenario_fixture", sqlbuild_action_name="create_table", diff --git a/src/sqlbuild/executor/scenario/main/_local.py b/src/sqlbuild/executor/scenario/main/_local.py index 3c6c2fffad..ffd7242821 100644 --- a/src/sqlbuild/executor/scenario/main/_local.py +++ b/src/sqlbuild/executor/scenario/main/_local.py @@ -5,6 +5,7 @@ from pathlib import Path from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.adapter.contract.types import TablePromotionMode from sqlbuild.compiler.planner.models import ScenarioExecutionPlan from sqlbuild.executor.scenario._helpers.local.execution import ( execute_local_scenario_load_only_run as _execute_local_scenario_load_only_run, @@ -18,6 +19,7 @@ def execute_local_scenario_load_only_run( scenario_plan: ScenarioExecutionPlan, adapter: BaseAdapter, strict: bool, + promotion_mode: TablePromotionMode, capture_adapter: str | None = None, capture_dialect: str | None = None, ) -> ScenarioRunResult: @@ -28,6 +30,7 @@ def execute_local_scenario_load_only_run( scenario_plan=scenario_plan, adapter=adapter, strict=strict, + promotion_mode=promotion_mode, capture_adapter=capture_adapter, capture_dialect=capture_dialect, ) diff --git a/src/sqlbuild/executor/scenario/main/_run.py b/src/sqlbuild/executor/scenario/main/_run.py index b1352d266a..16e6f533ee 100644 --- a/src/sqlbuild/executor/scenario/main/_run.py +++ b/src/sqlbuild/executor/scenario/main/_run.py @@ -7,7 +7,7 @@ from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.compiler.planner.models import ScenarioExecutionPlan from sqlbuild.executor.scenario._helpers.execution.run import execute_scenario_run_steps -from sqlbuild.executor.scenario.models import ScenarioRunResult +from sqlbuild.executor.scenario.models import ScenarioRunOptions, ScenarioRunResult def execute_scenario_run( @@ -17,6 +17,7 @@ def execute_scenario_run( connection: Any, run_id: str, retain: bool, + options: ScenarioRunOptions, ) -> ScenarioRunResult: """Execute a planned scenario for an external entrypoint.""" @@ -26,4 +27,5 @@ def execute_scenario_run( connection=connection, run_id=run_id, retain=retain, + options=options, ) diff --git a/src/sqlbuild/executor/scenario/models.py b/src/sqlbuild/executor/scenario/models.py index 436836a53c..a0fdb38e90 100644 --- a/src/sqlbuild/executor/scenario/models.py +++ b/src/sqlbuild/executor/scenario/models.py @@ -6,6 +6,7 @@ from pathlib import Path from sqlbuild.adapter.contract.models import LifeCycleEvent +from sqlbuild.adapter.contract.types import TablePromotionMode from sqlbuild.compiler.planner.models import ( CompiledRelationLocation, ScenarioExecutionPlan, @@ -15,6 +16,8 @@ from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax from sqlbuild.executor.build.models import FunctionExecutionResult, SeedExecutionResult from sqlbuild.executor.run.models import ModelExecutionResult +from sqlbuild.executor.scenario.classes.prepared_scenario_schemas import PreparedScenarioSchemas +from sqlbuild.executor.scenario.classes.scenario_interrupts import ScenarioInterrupts from sqlbuild.executor.scenario.types import ScenarioLocalRunStatus, ScenarioSnapshotState from sqlbuild.executor.scheduling.types import ExecutionStatus @@ -34,6 +37,15 @@ class ScenarioFixtureExecutionResult: error_message: str | None = None +@dataclass(frozen=True) +class ScenarioRunOptions: + """Invocation-wide settings shared by every warehouse scenario run.""" + + promotion_mode: TablePromotionMode + prepared_schemas: PreparedScenarioSchemas | None = None + interrupts: ScenarioInterrupts = field(default_factory=ScenarioInterrupts) + + @dataclass(frozen=True) class ScenarioCleanupTarget: """One planned scenario-owned relation selected for cleanup.""" @@ -42,6 +54,9 @@ class ScenarioCleanupTarget: logical_name: str target_relation: str materialization_type: MaterializationType = MaterializationType.TABLE + database: str | None = None + schema: str | None = None + name: str | None = None @dataclass(frozen=True) @@ -266,6 +281,7 @@ class ScenarioSnapshotCaptureRunResult: error_code: str | None = None error_help: str | None = None error_message: str | None = None + duration_ms: float | None = None @dataclass(frozen=True) @@ -339,3 +355,4 @@ class ScenarioRunResult: error_code: str | None = None error_help: str | None = None error_message: str | None = None + duration_ms: float | None = None diff --git a/src/sqlbuild/lint/_helpers/expansion.py b/src/sqlbuild/lint/_helpers/expansion.py index 29ae933e18..d0235ec799 100644 --- a/src/sqlbuild/lint/_helpers/expansion.py +++ b/src/sqlbuild/lint/_helpers/expansion.py @@ -364,7 +364,7 @@ def expand_file_bodies( allows_ceremonial_select: bool = any( header.kind in {HEADER_KIND_TEST, HEADER_KIND_SCENARIO} for header in headers ) - allows_empty_fixture_star: bool = any(header.kind == HEADER_KIND_TEST for header in headers) + allows_empty_fixture_star: bool = allows_ceremonial_select body_start: int body_end: int try: diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/postgres/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/postgres/_test_types.py index eca7d7aec1..4388eba3f8 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/postgres/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/postgres/_test_types.py @@ -234,3 +234,12 @@ class PostgresDefaultPrivilegesE2ETestCase: role: str revoke_before_rebuild: bool expected_error: str + + +@dataclass(frozen=True) +class PostgresLongModelNameE2ETestCase: + description: str + model_name: str + model_columns: str + expected_exit_code: int + expected_scenario_fragments: tuple[str, ...] diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/postgres/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/postgres/helpers.py index b37f33056d..708ff9e68b 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/postgres/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/postgres/helpers.py @@ -878,3 +878,64 @@ def revoke_view_select(*, view: str, role: str, revoke: bool, config: dict[str, statement: str for statement in statements[revoke]: execute_postgres_sql(sql=statement, config=config) + + +def build_long_model_name_project_files( + *, project_toml: str, schema_name: str, model_name: str, model_columns: str +) -> dict[str, str]: + """Build a contracted table model whose name nearly fills the identifier limit.""" + + return { + "sqlbuild_project.toml": project_toml + 'contract = "enforced"\n', + "sources/raw.yml": ( + "sources:\n" + " - name: raw_orders\n" + " description: Raw orders.\n" + f" schema: {schema_name}\n" + " table: raw_orders\n" + " columns:\n" + " - name: id\n" + " type: INTEGER\n" + " - name: amount\n" + " type: INTEGER\n" + ), + f"models/{model_name}.sql": ( + "MODEL (\n" + " description 'Order totals for a long-named model.',\n" + " materialized table,\n" + " sql_analysis false,\n" + f" columns (\n{model_columns} ),\n" + ");\n\n" + "SELECT CAST(SUM(amount) AS BIGINT) AS total_amount\n" + 'FROM __source("raw_orders")\n' + ), + "tests/scenarios/long_name_totals.sql": ( + "SCENARIO (description 'Totals for a long-named model.');\n\n" + "WITH\n" + "__source__raw_orders AS (\n" + " SELECT 1 AS id, 10 AS amount\n" + "),\n" + f"__expected__{model_name} AS (\n" + " SELECT 10 AS total_amount\n" + ")\n" + "SELECT 1\n" + ), + } + + +def postgres_relation_names( + *, schema_name: str, name_prefix: str, config: dict[str, object] +) -> tuple[str, ...]: + """Return the relation names in one Postgres schema that start with a literal prefix.""" + + return tuple( + str(row[0]) + for row in fetch_postgres_rows( + sql=( + "SELECT table_name FROM information_schema.tables " + f"WHERE table_schema = '{schema_name}' " + f"AND starts_with(table_name, '{name_prefix}') ORDER BY table_name" + ), + config=config, + ) + ) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/postgres/test_long_names.py b/tests/e2e/src/sqlbuild/cli/commands/main/postgres/test_long_names.py new file mode 100644 index 0000000000..7afa4c6c0a --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/postgres/test_long_names.py @@ -0,0 +1,113 @@ +"""PostgreSQL e2e coverage for auxiliary relation names near the identifier limit.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.postgres._test_types import ( + PostgresLongModelNameE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.main.postgres.helpers import ( + build_long_model_name_project_files, + build_postgres_project_toml, + build_unique_schema_name, + cleanup_postgres_schema, + ensure_postgres_schema_ready, + execute_postgres_sql, + postgres_relation_names, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import prepare_inline_project, run_sqb + +_TARGET_LENGTH_NAME: str = "regional_fulfillment_order_totals_by_customer" +_STAGING_OVERFLOW_NAME: str = "regional_order_totals_by_cust" +_TOTAL_COLUMN: str = " total_amount (type BIGINT),\n" + + +@pytest.mark.parametrize( + "test_case", + [ + PostgresLongModelNameE2ETestCase( + description="long model name builds and passes its scenario", + model_name=_TARGET_LENGTH_NAME, + model_columns=_TOTAL_COLUMN, + expected_exit_code=0, + expected_scenario_fragments=("PASS=1 FAIL=0 TOTAL=1",), + ), + PostgresLongModelNameE2ETestCase( + description="long model name contract failure leaves no staging relation", + model_name=_TARGET_LENGTH_NAME, + model_columns=_TOTAL_COLUMN + " order_count (type BIGINT),\n", + expected_exit_code=1, + expected_scenario_fragments=( + "runtime contract missing columns: order_count", + "PASS=0 FAIL=1 TOTAL=1", + ), + ), + PostgresLongModelNameE2ETestCase( + description="staging-overflow model name contract failure leaves no staging relation", + model_name=_STAGING_OVERFLOW_NAME, + model_columns=_TOTAL_COLUMN + " order_count (type BIGINT),\n", + expected_exit_code=1, + expected_scenario_fragments=( + "runtime contract missing columns: order_count", + "PASS=0 FAIL=1 TOTAL=1", + ), + ), + ], + ids=lambda case: case.description, +) +def test_given_long_model_name_when_building_and_running_scenario_then_staging_never_collides( + tmp_path: Path, + test_case: PostgresLongModelNameE2ETestCase, + postgres_e2e_config: dict[str, object], +) -> None: + schema_name: str = build_unique_schema_name(prefix="sqb_long_names") + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="postgres_long_names", + repo_files=build_long_model_name_project_files( + project_toml=build_postgres_project_toml( + project_name="postgres_long_names", + schema_name=schema_name, + config=postgres_e2e_config, + ), + schema_name=schema_name, + model_name=test_case.model_name, + model_columns=test_case.model_columns, + ), + ) + ensure_postgres_schema_ready(schema_name=schema_name, config=postgres_e2e_config) + execute_postgres_sql( + sql=f"CREATE TABLE {schema_name}.raw_orders AS SELECT 1 AS id, 10 AS amount", + config=postgres_e2e_config, + ) + + try: + build: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "build"), project_dir=project_dir + ) + scenario: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "scenario", "test"), project_dir=project_dir + ) + + assert build.returncode == test_case.expected_exit_code, build.stdout + build.stderr + assert scenario.returncode == test_case.expected_exit_code, ( + scenario.stdout + scenario.stderr + ) + for fragment in test_case.expected_scenario_fragments: + assert fragment in scenario.stdout, scenario.stdout + assert ( + postgres_relation_names( + schema_name=schema_name, name_prefix="__sqb_", config=postgres_e2e_config + ) + == () + ) + finally: + cleanup_postgres_schema(schema_name=schema_name, config=postgres_e2e_config) + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/scenario/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/_test_types.py index a867076339..9811d4014c 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/scenario/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/_test_types.py @@ -180,3 +180,72 @@ class ScenarioLocalReplayProjectDialectE2ETestCase: source_fixture_sql: str assertion_sql: str expected_stdout_fragment: str + + +@dataclass(frozen=True) +class ScenarioPromotionE2ETestCase: + """Test case for scenario table promotion matching build configuration.""" + + description: str + defaults_config: str + settings_config: str + model_columns: str + expected_exit_code: int + expected_stdout_fragments: tuple[str, ...] + expect_staged_promotion: bool + unexpected_stdout_fragments: tuple[str, ...] = ("K011", "R004") + + +@dataclass(frozen=True) +class ScenarioConcurrencyE2ETestCase: + """Test case for concurrent scenario execution through the CLI.""" + + description: str + command_args: tuple[str, ...] + settings_config: str + expected_header: str + + +@dataclass(frozen=True) +class ScenarioEmptyFixtureE2ETestCase: + """Test case for `__empty_fixture()` mocks inside SQL scenarios.""" + + description: str + customer_columns_yaml: str + expected_exit_code: int + expected_fragments: tuple[str, ...] + + +@dataclass(frozen=True) +class ScenarioInterruptE2ETestCase: + """Test case for interrupting a running scenario command through the real CLI.""" + + description: str + args: tuple[str, ...] + trigger: str + trigger_stream: str = "stderr" + long_model: bool = False + long_fixture: bool = False + slow_hook_seconds: float = 0.0 + interrupt_count: int = 1 + expected_notice_counts: tuple[int, int] = (1, 0) + expected_max_stop_seconds: float = 15.0 + + +@dataclass(frozen=True) +class PromotionConflictE2ETestCase: + """Test case for the compile-time enforced-contract promotion conflict.""" + + description: str + settings_config: str + expected_fragments: tuple[str, ...] + + +@dataclass(frozen=True) +class PromotionConflictReuseE2ETestCase: + """Test case for the promotion conflict across warm reused compiles.""" + + description: str + settings_steps: tuple[str, ...] + expected_exit_codes: tuple[int, ...] + expected_reused: tuple[bool, ...] diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/scenario/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/helpers.py index 77f6d2d840..523bbcde73 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/scenario/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/helpers.py @@ -3,16 +3,26 @@ from __future__ import annotations import json +import os +import re +import signal import subprocess +import sys +import time from collections import defaultdict +from itertools import takewhile from pathlib import Path -from typing import cast +from typing import IO, NamedTuple, cast +from sqlbuild.cli.compile_reuse.constants import REUSE_DISABLE_ENV_VAR from sqlbuild.compiler.planner.types import ScenarioArtifactKind from sqlbuild.executor.scenario._helpers.snapshots.core import ( build_scenario_snapshot_input_fingerprint, ) from sqlbuild.executor.scenario.models import ScenarioSnapshotInputSpec +from tests.e2e.src.sqlbuild.cli.commands.main.scenario._test_types import ( + ScenarioPromotionE2ETestCase, +) from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import ( prepare_inline_project, query_duckdb, @@ -571,3 +581,277 @@ def restamp_scenario_snapshot_capture_adapter( capture_dialect=capture_adapter, ) manifest_path.write_text(json.dumps(manifest_data, indent=2) + "\n", encoding="utf-8") + + +def build_promotion_project_files(test_case: ScenarioPromotionE2ETestCase) -> dict[str, str]: + """Build a one-model project whose scenario exercises table promotion settings.""" + + return { + "sqlbuild_project.toml": ( + 'name = "scenario_promotion"\n' + 'adapter = "duckdb"\n\n' + "[connection]\n" + 'database = "scenario_promotion.duckdb"\n\n' + "[defaults]\n" + 'materialized = "table"\n' + f"{test_case.defaults_config}\n" + f"{test_case.settings_config}" + ), + "sources/raw.yml": ( + "sources:\n" + " - name: raw_orders\n" + " description: Raw orders.\n" + " schema: main\n" + " table: raw_orders\n" + " columns:\n" + " - name: id\n" + " type: INTEGER\n" + " - name: amount\n" + " type: INTEGER\n" + ), + "models/order_totals.sql": ( + "MODEL (\n" + " description 'Order totals.',\n" + " materialized table,\n" + " sql_analysis false,\n" + f"{test_case.model_columns}" + ");\n\n" + "SELECT\n" + " CAST(SUM(amount) AS BIGINT) AS total_amount,\n" + " CAST(COUNT(*) AS BIGINT) AS order_count\n" + 'FROM __source("raw_orders")\n' + ), + "tests/scenarios/order_totals_pass.sql": ( + "SCENARIO (description 'Order totals scenario.');\n\n" + "WITH\n" + "__source__raw_orders AS (\n" + " SELECT 1 AS id, 10 AS amount\n" + " UNION ALL\n" + " SELECT 2 AS id, 5 AS amount\n" + "),\n" + "__expected__order_totals AS (\n" + " SELECT 15 AS total_amount, 2 AS order_count\n" + ")\n" + "SELECT 1\n" + ), + } + + +def scenario_result_lines(stdout: str) -> tuple[str, ...]: + """Return scenario result rows and expectation rows without timings, in output order.""" + + rows: list[str] = re.findall( + r"^(?:order_|orders_| expect| expected | assertion ).*$", stdout, re.M + ) + return tuple(re.sub(r"\s+\d+\.\d\ds$", "", row).rstrip() for row in rows) + + +def build_empty_fixture_scenario_project_files(*, customer_columns_yaml: str) -> dict[str, str]: + """Build a scenario that mocks one source as empty through `__empty_fixture()`.""" + + return { + "sqlbuild_project.toml": ( + 'name = "scenario_empty_fixture"\n' + 'adapter = "duckdb"\n\n' + "[connection]\n" + 'database = "scenario_empty_fixture.duckdb"\n\n' + "[defaults]\n" + 'materialized = "table"\n\n' + "[rules]\n" + 'select = ["SQBRSQL021"]\n' + ), + "sources/raw.yml": ( + "sources:\n" + " - name: raw_orders\n" + " description: Raw orders.\n" + " schema: main\n" + " table: raw_orders\n" + " columns:\n" + " - name: id\n" + " type: INTEGER\n" + " - name: customer_id\n" + " type: INTEGER\n" + " - name: raw_customers\n" + " description: Raw customers.\n" + " schema: main\n" + " table: raw_customers\n" + f"{customer_columns_yaml}" + ), + "models/customer_orders.sql": ( + "MODEL (description 'Orders joined to known customers.', materialized table);\n\n" + "SELECT o.id AS order_id, c.name AS customer_name\n" + 'FROM __source("raw_orders") AS o\n' + 'JOIN __source("raw_customers") AS c ON c.id = o.customer_id\n' + ), + "tests/scenarios/orders_without_customers.sql": ( + "SCENARIO (description 'Orders without known customers produce no rows.');\n\n" + "WITH\n" + "__source__raw_orders AS (\n" + " SELECT 1 AS id, 7 AS customer_id\n" + "),\n" + "__source__raw_customers AS (\n" + " SELECT * FROM __empty_fixture()\n" + "),\n" + "__assert__no_unmatched_orders AS (\n" + ' SELECT order_id FROM __ref("customer_orders")\n' + ")\n" + "SELECT 1\n" + ), + } + + +INTERRUPT_HOOK_SECONDS_ENV_VAR: str = "SCENARIO_INTERRUPT_HOOK_SECONDS" + + +class InterruptedScenarioRun(NamedTuple): + """Outcome of a scenario command interrupted through SIGINT.""" + + returncode: int + stdout: str + output: str + stop_seconds: float + + +_ENDLESS_SUM: str = "(SELECT SUM(big.n) FROM range(1000000000000) AS big(n)) * 0" +_FIXTURE_AMOUNTS: dict[bool, str] = {True: f"CAST(10 + {_ENDLESS_SUM} AS INTEGER)", False: "10"} +_MODEL_TOTALS: dict[bool, str] = {True: f"SUM(o.amount) + {_ENDLESS_SUM}", False: "SUM(o.amount)"} +_POST_HOOKS: dict[bool, str] = {True: ', post_hooks [python("pause_after_build")]', False: ""} +_HOOK_FILES: dict[bool, dict[str, str]] = { + True: { + "hooks/python/slow_hooks.py": ( + "import os\n" + "import time\n\n" + "from sqlbuild.hooks import hook\n\n\n" + "@hook\n" + "def pause_after_build(ctx):\n" + ' """Pause so an interrupt lands while scenarios run."""\n' + f" time.sleep(float(os.environ.get('{INTERRUPT_HOOK_SECONDS_ENV_VAR}', '0')))\n" + ) + }, + False: {}, +} + + +def build_interrupt_project_files( + *, scenario_count: int, long_model: bool, long_fixture: bool, slow_hook: bool +) -> dict[str, str]: + """Build scenarios whose model, fixture, or post-hook runs until interrupted.""" + + scenario_sql: str = ( + "WITH\n" + "__source__raw_orders AS (\n" + f" SELECT 1 AS id, {_FIXTURE_AMOUNTS[long_fixture]} AS amount\n" + "),\n" + "__expected__order_totals AS (\n" + " SELECT 10 AS total_amount\n" + ")\n" + "SELECT 1\n" + ) + return { + **_HOOK_FILES[slow_hook], + "sqlbuild_project.toml": ( + 'name = "scenario_interrupt"\n' + 'adapter = "duckdb"\n\n' + "[connection]\n" + 'database = "scenario_interrupt.duckdb"\n\n' + "[defaults]\n" + 'materialized = "table"\n' + ), + "sources/raw.yml": ( + "sources:\n" + " - name: raw_orders\n" + " description: Raw orders.\n" + " schema: main\n" + " table: raw_orders\n" + ), + "models/order_totals.sql": ( + "MODEL (description 'Order totals.', materialized table, sql_analysis false" + f"{_POST_HOOKS[slow_hook]});\n\n" + f"SELECT {_MODEL_TOTALS[long_model]} AS total_amount " + 'FROM __source("raw_orders") AS o\n' + ), + **{ + f"tests/scenarios/slow_{index}.sql": ( + f"SCENARIO (description 'Slow scenario {index}.');\n\n{scenario_sql}" + ) + for index in range(scenario_count) + }, + } + + +def interrupt_scenario_run( + *, + project_dir: Path, + args: tuple[str, ...], + trigger: str, + trigger_stream: str, + interrupt_count: int, + hook_seconds: float = 0.0, +) -> InterruptedScenarioRun: + """Interrupt a scenario command once ``trigger`` is printed on ``trigger_stream``.""" + + process: subprocess.Popen[str] = subprocess.Popen( + [ + str(Path(sys.executable).with_name("sqb")), + "--no-color", + "--project-dir", + str(project_dir), + "scenario", + *args, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={**os.environ, INTERRUPT_HOOK_SECONDS_ENV_VAR: str(hook_seconds)}, + ) + streams: dict[str, IO[str] | None] = {"stdout": process.stdout, "stderr": process.stderr} + watched: IO[str] | None = streams[trigger_stream] + assert watched is not None + started: str = "".join(takewhile(lambda line: trigger not in line, watched)) + time.sleep(1.0) + interrupted_at: float = time.monotonic() + for _ in range(interrupt_count): + process.send_signal(signal.SIGINT) + time.sleep(0.5) + stdout, stderr = process.communicate(timeout=120) + return InterruptedScenarioRun( + returncode=process.returncode, + stdout=stdout, + output=started + stdout + stderr, + stop_seconds=time.monotonic() - interrupted_at, + ) + + +def scenario_relation_names_if_present(*, db_path: Path) -> tuple[str, ...]: + """Return scenario-owned DuckDB relation names, or none when the database was never made.""" + + return {True: list_scenario_relation_names, False: lambda **_: ()}[db_path.exists()]( + db_path=db_path + ) + + +COMPILE_REUSE_HIT_LINE: str = "Inputs unchanged; reused the previous compile" + + +def write_project_settings(*, project_dir: Path, base_toml: str, settings: str) -> None: + """Rewrite the project file with a settings block appended to its base content.""" + + _ = (project_dir / "sqlbuild_project.toml").write_text(base_toml + settings, encoding="utf-8") + + +def run_reusable_compile(*, project_dir: Path) -> subprocess.CompletedProcess[str]: + """Compile in a fresh process with whole-project compile reuse enabled.""" + + return subprocess.run( + [ + str(Path(sys.executable).with_name("sqb")), + "--project-dir", + str(project_dir), + "--no-color", + "compile", + ], + capture_output=True, + text=True, + env={**os.environ, REUSE_DISABLE_ENV_VAR: "0"}, + check=False, + ) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_concurrency.py b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_concurrency.py new file mode 100644 index 0000000000..fae577bdcb --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_concurrency.py @@ -0,0 +1,159 @@ +"""E2E tests for concurrent scenario execution, timings, and cleanup.""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path +from typing import cast + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.scenario._test_types import ( + ScenarioCliE2ETestCase, + ScenarioConcurrencyE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.main.scenario.helpers import ( + build_scenario_project_files, + list_scenario_relation_names, + scenario_result_lines, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import prepare_inline_project, run_sqb + +_SCENARIO_ROW: re.Pattern[str] = re.compile(r"^order_totals_pass\s+PASS \d+\.\d\ds$", re.M) +_SUMMARY: re.Pattern[str] = re.compile(r"PASS=2 FAIL=1 TOTAL=3 \(\d+\.\d\ds\)") + + +@pytest.mark.parametrize( + "test_case", + ( + ScenarioConcurrencyE2ETestCase( + description="cli concurrency runs scenarios concurrently", + command_args=("--concurrency", "4"), + settings_config="", + expected_header="(concurrency: 4)", + ), + ScenarioConcurrencyE2ETestCase( + description="project concurrency setting is honoured", + command_args=(), + settings_config="\n[settings]\nconcurrency = 3\n", + expected_header="(concurrency: 3)", + ), + ), + ids=lambda case: case.description, +) +def test_given_concurrency_when_running_scenarios_then_results_match_sequential_run( + test_case: ScenarioConcurrencyE2ETestCase, + tmp_path: Path, +) -> None: + repo_files: dict[str, str] = build_scenario_project_files() + sequential_dir: Path = prepare_inline_project( + tmp_path=tmp_path, project_name="sequential", repo_files=repo_files + ) + repo_files["sqlbuild_project.toml"] += test_case.settings_config + concurrent_dir: Path = prepare_inline_project( + tmp_path=tmp_path, project_name="concurrent", repo_files=repo_files + ) + + sequential: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "scenario", "test", "--concurrency", "1"), + project_dir=sequential_dir, + ) + concurrent: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "scenario", "test", *test_case.command_args), + project_dir=concurrent_dir, + ) + + assert sequential.returncode == 1, sequential.stdout + sequential.stderr + assert concurrent.returncode == 1, concurrent.stdout + concurrent.stderr + assert "(concurrency: 1)" in sequential.stdout + assert test_case.expected_header in concurrent.stdout + assert scenario_result_lines(concurrent.stdout) == scenario_result_lines(sequential.stdout) + assert "expected order_totals: actual=1 expected=1 mismatched=1" in concurrent.stdout + assert _SCENARIO_ROW.search(concurrent.stdout) is not None, concurrent.stdout + assert _SUMMARY.search(concurrent.stdout) is not None, concurrent.stdout + assert list_scenario_relation_names(db_path=concurrent_dir / "scenario_demo.duckdb") == () + + +@pytest.mark.parametrize( + "test_case", + ( + ScenarioCliE2ETestCase( + description="json output reports per-scenario and total durations", + command=("--no-color", "scenario", "test", "--concurrency", "4", "--json"), + expected_exit_code=1, + expected_stderr_fragments=("(concurrency: 4)",), + ), + ), + ids=lambda case: case.description, +) +def test_given_json_output_when_running_scenarios_concurrently_then_reports_durations( + test_case: ScenarioCliE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, project_name="scenario_json", repo_files=build_scenario_project_files() + ) + + result: subprocess.CompletedProcess[str] = run_sqb( + command=test_case.command, project_dir=project_dir + ) + + assert result.returncode == test_case.expected_exit_code, result.stdout + result.stderr + payload: dict[str, object] = json.loads(result.stdout) + scenarios: list[dict[str, object]] = cast(list[dict[str, object]], payload["scenarios"]) + assert sorted(str(scenario["name"]) for scenario in scenarios) == [ + "order_totals_fail", + "order_totals_pass", + "orders_assert_pass", + ] + assert all(isinstance(scenario["duration_ms"], int) for scenario in scenarios) + summary: dict[str, object] = cast(dict[str, object], payload["summary"]) + assert (summary["pass_count"], summary["fail_count"], summary["total_count"]) == (2, 1, 3) + assert isinstance(summary["duration_ms"], int) + for fragment in test_case.expected_stderr_fragments: + assert fragment in result.stderr + + +@pytest.mark.parametrize( + "test_case", + ( + ScenarioCliE2ETestCase( + description="retained table replaced by a view model is dropped as a table", + command=("--no-color", "scenario", "test", "order_totals_pass"), + expected_exit_code=0, + expected_stdout_fragments=("PASS=1 FAIL=0 TOTAL=1",), + ), + ), + ids=lambda case: case.description, +) +def test_given_retained_artifact_of_another_type_when_rerunning_then_replaces_and_cleans_up( + test_case: ScenarioCliE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, project_name="scenario_retype", repo_files=build_scenario_project_files() + ) + retained: subprocess.CompletedProcess[str] = run_sqb( + command=(*test_case.command, "--retain"), project_dir=project_dir + ) + assert retained.returncode == 0, retained.stdout + retained.stderr + orders_model: Path = project_dir / "models/orders.sql" + orders_model.write_text( + orders_model.read_text(encoding="utf-8").replace("materialized table", "materialized view"), + encoding="utf-8", + ) + + rerun: subprocess.CompletedProcess[str] = run_sqb( + command=test_case.command, project_dir=project_dir + ) + + assert rerun.returncode == test_case.expected_exit_code, rerun.stdout + rerun.stderr + for fragment in test_case.expected_stdout_fragments: + assert fragment in rerun.stdout + assert list_scenario_relation_names(db_path=project_dir / "scenario_demo.duckdb") == () + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_empty_fixture.py b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_empty_fixture.py new file mode 100644 index 0000000000..fc9df8604f --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_empty_fixture.py @@ -0,0 +1,122 @@ +"""E2E tests for `__empty_fixture()` mocks in SQL scenarios.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.scenario._test_types import ( + ScenarioEmptyFixtureE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.main.scenario.helpers import ( + build_empty_fixture_scenario_project_files, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import prepare_inline_project, run_sqb + +_OPEN_CUSTOMER_COLUMNS: str = ( + " columns:\n" + " - name: id\n" + " type: INTEGER\n" + " - name: name\n" + " type: VARCHAR\n" +) +_TYPED_CUSTOMER_COLUMNS: str = " contract: enforced\n" + _OPEN_CUSTOMER_COLUMNS + + +@pytest.mark.parametrize( + "test_case", + ( + ScenarioEmptyFixtureE2ETestCase( + description="typed source declaration shapes the empty fixture", + customer_columns_yaml=_TYPED_CUSTOMER_COLUMNS, + expected_exit_code=0, + expected_fragments=( + "expect assertion no_unmatched_orders", + "PASS=1 FAIL=0 TOTAL=1", + ), + ), + ScenarioEmptyFixtureE2ETestCase( + description="source without an enforced contract is rejected clearly", + customer_columns_yaml=_OPEN_CUSTOMER_COLUMNS, + expected_exit_code=1, + expected_fragments=( + "error[S510]", + "mock source 'raw_customers' uses __empty_fixture() but its column schema is " + "not authoritative", + ), + ), + ), + ids=lambda case: case.description, +) +def test_given_empty_fixture_scenario_when_running_rules_and_scenario_then_types_come_from_source( + test_case: ScenarioEmptyFixtureE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="scenario_empty_fixture", + repo_files=build_empty_fixture_scenario_project_files( + customer_columns_yaml=test_case.customer_columns_yaml + ), + ) + + rules: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "rules", "run", "SQBRSQL021"), project_dir=project_dir + ) + scenario: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "scenario", "test"), project_dir=project_dir + ) + + assert rules.returncode == 0, rules.stdout + rules.stderr + assert "SQBRSQL021" not in rules.stdout.replace("Rule selection SQBRSQL021 passed", "") + assert scenario.returncode == test_case.expected_exit_code, scenario.stdout + scenario.stderr + for fragment in test_case.expected_fragments: + assert fragment in scenario.stdout, scenario.stdout + + +@pytest.mark.parametrize( + "test_case", + ( + ScenarioEmptyFixtureE2ETestCase( + description="empty fixture casts every declared source column", + customer_columns_yaml=_TYPED_CUSTOMER_COLUMNS, + expected_exit_code=0, + expected_fragments=( + 'CAST(NULL AS INTEGER) AS "id"', + 'CAST(NULL AS VARCHAR) AS "name"', + "WHERE FALSE", + ), + ), + ), + ids=lambda case: case.description, +) +def test_given_typed_empty_fixture_when_running_scenario_then_fixture_casts_declared_types( + test_case: ScenarioEmptyFixtureE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="scenario_empty_fixture", + repo_files=build_empty_fixture_scenario_project_files( + customer_columns_yaml=test_case.customer_columns_yaml + ), + ) + + result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "scenario", "test"), project_dir=project_dir + ) + + assert result.returncode == test_case.expected_exit_code, result.stdout + result.stderr + fixture_sql: str = ( + project_dir + / "target/run/scenarios/orders_without_customers/fixtures/source__raw_customers.sql" + ).read_text(encoding="utf-8") + for fragment in test_case.expected_fragments: + assert fragment in fixture_sql + assert "__empty_fixture" not in fixture_sql + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_interrupts.py b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_interrupts.py new file mode 100644 index 0000000000..6416667777 --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_interrupts.py @@ -0,0 +1,188 @@ +"""E2E tests for Ctrl-C during scenario test, local replay, and capture.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.scenario._test_types import ( + ScenarioInterruptE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.main.scenario.helpers import ( + InterruptedScenarioRun, + build_interrupt_project_files, + interrupt_scenario_run, + list_scenario_relation_names, + scenario_relation_names_if_present, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import prepare_inline_project, run_sqb + +_STOP_NOTICE: str = "Interrupted; cleaning up running scenarios..." +_ABANDON_NOTICE: str = "Interrupted again; skipped scenario cleanup." +_NOTICES: tuple[str, ...] = (_STOP_NOTICE, _ABANDON_NOTICE) + + +@pytest.mark.parametrize( + "test_case", + [ + ScenarioInterruptE2ETestCase( + description="sequential run cancels the in-flight statement", + args=("test", "--concurrency", "1", "--json"), + trigger="order_totals START", + long_model=True, + ), + ScenarioInterruptE2ETestCase( + description="concurrent run cancels every in-flight statement", + args=("test", "--concurrency", "4", "--json"), + trigger="order_totals START", + long_model=True, + ), + ScenarioInterruptE2ETestCase( + description="capture cancels the in-flight fixture statement", + args=("capture", "slow_0"), + trigger="slow_0 START", + trigger_stream="stdout", + long_fixture=True, + ), + ], + ids=lambda case: case.description, +) +def test_given_long_statement_when_interrupted_then_stops_promptly_and_cleans_up( + test_case: ScenarioInterruptE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="scenario_interrupt", + repo_files=build_interrupt_project_files( + scenario_count=4, + long_model=test_case.long_model, + long_fixture=test_case.long_fixture, + slow_hook=False, + ), + ) + + result: InterruptedScenarioRun = interrupt_scenario_run( + project_dir=project_dir, + args=test_case.args, + trigger=test_case.trigger, + trigger_stream=test_case.trigger_stream, + interrupt_count=test_case.interrupt_count, + ) + + assert result.returncode != 0, result.output + assert result.stop_seconds < test_case.expected_max_stop_seconds, result.output + assert tuple(result.output.count(notice) for notice in _NOTICES) == ( + test_case.expected_notice_counts + ), result.output + assert "expect expected order_totals" not in result.output + assert ( + scenario_relation_names_if_present(db_path=project_dir / "scenario_interrupt.duckdb") == () + ) + + +@pytest.mark.parametrize( + "test_case", + [ + ScenarioInterruptE2ETestCase( + description="local replay cancels the in-flight statement with clean json stdout", + args=("test", "--local", "--json"), + trigger="order_totals START", + long_model=True, + ), + ], + ids=lambda case: case.description, +) +def test_given_local_replay_when_interrupted_then_stops_promptly_with_clean_json_stdout( + test_case: ScenarioInterruptE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="scenario_interrupt", + repo_files=build_interrupt_project_files( + scenario_count=4, + long_model=test_case.long_model, + long_fixture=test_case.long_fixture, + slow_hook=False, + ), + ) + capture: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "scenario", "capture"), project_dir=project_dir + ) + + result: InterruptedScenarioRun = interrupt_scenario_run( + project_dir=project_dir, + args=test_case.args, + trigger=test_case.trigger, + trigger_stream=test_case.trigger_stream, + interrupt_count=test_case.interrupt_count, + ) + + assert capture.returncode == 0, capture.stdout + capture.stderr + assert result.returncode != 0, result.output + assert result.stdout == "" + assert result.stop_seconds < test_case.expected_max_stop_seconds, result.output + assert tuple(result.output.count(notice) for notice in _NOTICES) == ( + test_case.expected_notice_counts + ), result.output + + +@pytest.mark.parametrize( + "test_case", + [ + ScenarioInterruptE2ETestCase( + description="second interrupt abandons cleanup of busy workers", + args=("test", "--concurrency", "4", "--json"), + trigger="order_totals START", + slow_hook_seconds=60.0, + interrupt_count=2, + expected_notice_counts=(1, 1), + ), + ], + ids=lambda case: case.description, +) +def test_given_repeat_interrupt_when_workers_are_busy_then_exits_and_next_run_cleans_up( + test_case: ScenarioInterruptE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="scenario_interrupt", + repo_files=build_interrupt_project_files( + scenario_count=4, + long_model=test_case.long_model, + long_fixture=test_case.long_fixture, + slow_hook=True, + ), + ) + db_path: Path = project_dir / "scenario_interrupt.duckdb" + + result: InterruptedScenarioRun = interrupt_scenario_run( + project_dir=project_dir, + args=test_case.args, + trigger=test_case.trigger, + trigger_stream=test_case.trigger_stream, + interrupt_count=test_case.interrupt_count, + hook_seconds=test_case.slow_hook_seconds, + ) + leftovers: tuple[str, ...] = list_scenario_relation_names(db_path=db_path) + rerun: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "scenario", "test"), project_dir=project_dir + ) + + assert result.returncode != 0, result.output + assert result.stdout == "" + assert result.stop_seconds < test_case.expected_max_stop_seconds, result.output + assert tuple(result.output.count(notice) for notice in _NOTICES) == ( + test_case.expected_notice_counts + ), result.output + assert leftovers != () + assert rerun.returncode == 0, rerun.stdout + rerun.stderr + assert list_scenario_relation_names(db_path=db_path) == () + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_promotion.py b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_promotion.py new file mode 100644 index 0000000000..1da1d0a7da --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_promotion.py @@ -0,0 +1,234 @@ +"""E2E tests for scenario table promotion matching build configuration.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.scenario._test_types import ( + PromotionConflictE2ETestCase, + PromotionConflictReuseE2ETestCase, + ScenarioPromotionE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.main.scenario.helpers import ( + COMPILE_REUSE_HIT_LINE, + build_promotion_project_files, + list_scenario_relation_names, + run_reusable_compile, + write_project_settings, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import ( + prepare_inline_project, + run_sqb, + table_exists, +) + +_MATCHING_COLUMNS: str = ( + " columns (\n total_amount (type BIGINT),\n order_count (type BIGINT),\n ),\n" +) +_ENFORCED_DEFAULTS: str = 'contract = "enforced"\n' +_CONFLICT_FRAGMENTS: tuple[str, ...] = ( + "error[K011]", + "models/order_totals.sql", + "model 'order_totals': contract enforced requires staged table promotion", + 'sqlbuild_project.toml sets [settings] table_promotion_mode = "immediate"', + "remove that line to use the default staged promotion, or set this in sqlbuild_project.toml:", + 'table_promotion_mode = "staged"', + "`contract enforced` in its MODEL header", +) + + +@pytest.mark.parametrize( + "test_case", + ( + ScenarioPromotionE2ETestCase( + description="project enforced contract runs the contract check and expectations", + defaults_config=_ENFORCED_DEFAULTS, + settings_config="", + model_columns=_MATCHING_COLUMNS, + expected_exit_code=0, + expected_stdout_fragments=( + "expect expected order_totals", + "PASS=1 FAIL=0 TOTAL=1", + ), + expect_staged_promotion=True, + ), + ScenarioPromotionE2ETestCase( + description="explicit immediate setting is honoured without a contract", + defaults_config="", + settings_config='[settings]\ntable_promotion_mode = "immediate"\n', + model_columns="", + expected_exit_code=0, + expected_stdout_fragments=("PASS=1 FAIL=0 TOTAL=1",), + expect_staged_promotion=False, + ), + ScenarioPromotionE2ETestCase( + description="explicit staged setting enables declared type enforcement", + defaults_config="", + settings_config='[settings]\ntable_promotion_mode = "staged"\n', + model_columns=_MATCHING_COLUMNS, + expected_exit_code=0, + expected_stdout_fragments=("PASS=1 FAIL=0 TOTAL=1",), + expect_staged_promotion=True, + ), + ScenarioPromotionE2ETestCase( + description="runtime contract failure is reported and staging is cleaned up", + defaults_config=_ENFORCED_DEFAULTS, + settings_config="", + model_columns=_MATCHING_COLUMNS.replace( + " ),\n", " discount_amount (type BIGINT),\n ),\n" + ), + expected_exit_code=1, + expected_stdout_fragments=( + "error[K008]", + "runtime contract missing columns: discount_amount", + "PASS=0 FAIL=1 TOTAL=1", + ), + expect_staged_promotion=True, + ), + ), + ids=lambda case: case.description, +) +def test_given_promotion_configuration_when_running_scenario_then_matches_build_promotion( + test_case: ScenarioPromotionE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="scenario_promotion", + repo_files=build_promotion_project_files(test_case), + ) + + result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "scenario", "test"), + project_dir=project_dir, + ) + + output: str = result.stdout + result.stderr + assert result.returncode == test_case.expected_exit_code, output + for fragment in test_case.expected_stdout_fragments: + assert fragment in result.stdout, output + for fragment in test_case.unexpected_stdout_fragments: + assert fragment not in output + model_sql: str = ( + project_dir / "target/run/scenarios/order_totals_pass/models/order_totals.sql" + ).read_text(encoding="utf-8") + assert ("__staging" in model_sql) is test_case.expect_staged_promotion, model_sql + assert list_scenario_relation_names(db_path=project_dir / "scenario_promotion.duckdb") == () + + +@pytest.mark.parametrize( + "test_case", + ( + PromotionConflictE2ETestCase( + description="enforced defaults with immediate promotion fail before warehouse work", + settings_config='[settings]\ntable_promotion_mode = "immediate"\n', + expected_fragments=_CONFLICT_FRAGMENTS, + ), + ), + ids=lambda case: case.description, +) +def test_given_enforced_contract_and_immediate_promotion_when_running_then_fails_at_compile( + test_case: PromotionConflictE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="scenario_promotion", + repo_files=build_promotion_project_files( + ScenarioPromotionE2ETestCase( + description=test_case.description, + defaults_config=_ENFORCED_DEFAULTS, + settings_config=test_case.settings_config, + model_columns=_MATCHING_COLUMNS, + expected_exit_code=1, + expected_stdout_fragments=(), + expect_staged_promotion=False, + ) + ), + ) + db_path: Path = project_dir / "scenario_promotion.duckdb" + + results: dict[str, subprocess.CompletedProcess[str]] = { + name: run_sqb(command=("--no-color", *command), project_dir=project_dir) + for name, command in ( + ("compile", ("compile",)), + ("plan", ("plan",)), + ("build", ("build",)), + ("scenario", ("scenario", "test")), + ("scenario_local", ("scenario", "test", "--local")), + ) + } + + for name, result in results.items(): + output: str = result.stdout + result.stderr + assert result.returncode == 1, (name, output) + for fragment in test_case.expected_fragments[2:]: + assert fragment in output, (name, fragment, output) + assert all( + fragment in results["compile"].stdout for fragment in test_case.expected_fragments + ), results["compile"].stdout + assert "order_totals START" not in results["build"].stdout + results["build"].stderr + assert not db_path.exists() or not table_exists(db_path=db_path, table_name="order_totals") + assert not db_path.exists() or list_scenario_relation_names(db_path=db_path) == () + + +@pytest.mark.parametrize( + "test_case", + ( + PromotionConflictReuseE2ETestCase( + description="promotion setting edits invalidate warm compile reuse", + settings_steps=( + "", + "", + '[settings]\ntable_promotion_mode = "immediate"\n', + '[settings]\ntable_promotion_mode = "immediate"\n', + '[settings]\ntable_promotion_mode = "staged"\n', + ), + expected_exit_codes=(0, 0, 1, 1, 0), + expected_reused=(False, True, False, True, False), + ), + ), + ids=lambda case: case.description, +) +def test_given_warm_compile_when_promotion_setting_changes_then_k011_follows_the_setting( + test_case: PromotionConflictReuseE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="scenario_promotion", + repo_files=build_promotion_project_files( + ScenarioPromotionE2ETestCase( + description=test_case.description, + defaults_config=_ENFORCED_DEFAULTS, + settings_config="", + model_columns=_MATCHING_COLUMNS, + expected_exit_code=0, + expected_stdout_fragments=(), + expect_staged_promotion=True, + ) + ), + ) + base_toml: str = (project_dir / "sqlbuild_project.toml").read_text(encoding="utf-8") + + outcomes: list[tuple[int, bool, bool]] = [] + for settings in test_case.settings_steps: + write_project_settings(project_dir=project_dir, base_toml=base_toml, settings=settings) + result: subprocess.CompletedProcess[str] = run_reusable_compile(project_dir=project_dir) + output: str = result.stdout + result.stderr + outcomes.append( + (result.returncode, COMPILE_REUSE_HIT_LINE in output, "error[K011]" in output) + ) + + assert tuple(code for code, _, _ in outcomes) == test_case.expected_exit_codes + assert tuple(reused for _, reused, _ in outcomes) == test_case.expected_reused + assert tuple(k011 for _, _, k011 in outcomes) == tuple( + code != 0 for code in test_case.expected_exit_codes + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/integration/src/sqlbuild/executor/build/test_build_execution.py b/tests/integration/src/sqlbuild/executor/build/test_build_execution.py index f7a346009a..7a21d6e6e7 100644 --- a/tests/integration/src/sqlbuild/executor/build/test_build_execution.py +++ b/tests/integration/src/sqlbuild/executor/build/test_build_execution.py @@ -701,26 +701,6 @@ def test_given_view_with_run_audits_false_when_executing_then_succeeds( expected_model_error_codes=(("orders", "K009"),), expected_missing_relations=("main.orders",), ), - BuildExecutionTestCase( - description="immediate table promotion rejects enforced contract before mutation", - project_files={ - "sqlbuild_project.toml": _PROJECT_YML_IMMEDIATE, - "models/orders.sql": ( - "MODEL (description 'Test model orders.',\n" - " materialized table,\n" - " contract enforced,\n" - " columns (id (type INTEGER)),\n" - ");\n\n" - "SELECT 1 AS id" - ), - }, - expected_status=BuildStatus.FAILED, - expected_failure_count=1, - expected_model_statuses=(("orders", ExecutionStatus.FAILED),), - expected_model_error_fragments=(("orders", "requires staged table promotion"),), - expected_model_error_codes=(("orders", "K011"),), - expected_missing_relations=("main.orders",), - ), BuildExecutionTestCase( description="snapshot runtime contract failure blocks target creation", project_files={ diff --git a/tests/integration/src/sqlbuild/executor/scenario/test_fixture_lifecycle.py b/tests/integration/src/sqlbuild/executor/scenario/test_fixture_lifecycle.py index 4023275deb..333fb27434 100644 --- a/tests/integration/src/sqlbuild/executor/scenario/test_fixture_lifecycle.py +++ b/tests/integration/src/sqlbuild/executor/scenario/test_fixture_lifecycle.py @@ -6,6 +6,7 @@ import pytest from sqlbuild.adapter.contract.models import ColumnInfo +from sqlbuild.adapter.contract.types import TablePromotionMode from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter from sqlbuild.compiler.compile.models import ( CompiledObjectKey, @@ -273,6 +274,7 @@ def test_given_scenario_plan_when_executing_models_then_builds_model_relations( adapter=adapter, connection=connection, run_id="run-1", + promotion_mode=TablePromotionMode.STAGED, ) assert tuple(result.status for result in results) == test_case.expected_statuses diff --git a/tests/unit/src/sqlbuild/adapter/relations/main/fit_artifact_logical_name/test_fit_artifact_logical_name.py b/tests/unit/src/sqlbuild/adapter/relations/main/fit_artifact_logical_name/test_fit_artifact_logical_name.py index ffc2743ab3..40c3632495 100644 --- a/tests/unit/src/sqlbuild/adapter/relations/main/fit_artifact_logical_name/test_fit_artifact_logical_name.py +++ b/tests/unit/src/sqlbuild/adapter/relations/main/fit_artifact_logical_name/test_fit_artifact_logical_name.py @@ -12,6 +12,8 @@ ) LONG_NAME: str = "customer_orders_" + "x" * 40 +MULTIBYTE_NAME: str = "kunde_" + "ä" * 13 +MULTIBYTE_NAME_HASH: str = hashlib.sha256(MULTIBYTE_NAME.encode("utf-8")).hexdigest()[:8] LONG_NAME_HASH: str = hashlib.sha256(LONG_NAME.encode("utf-8")).hexdigest()[:8] @@ -32,6 +34,13 @@ identifier_limit=40, expected_name=f"{LONG_NAME[:21]}_{LONG_NAME_HASH}", ), + FitArtifactLogicalNameTestCase( + description="multibyte name is fitted by UTF-8 bytes on a character boundary", + logical_name=MULTIBYTE_NAME, + fixed_prefix="__prefix__", + identifier_limit=40, + expected_name=f"kunde_{'ä' * 7}_{MULTIBYTE_NAME_HASH}", + ), ], ids=lambda case: case.description, ) diff --git a/tests/unit/src/sqlbuild/adapter/relations/main/fit_auxiliary_relation_name/_test_types.py b/tests/unit/src/sqlbuild/adapter/relations/main/fit_auxiliary_relation_name/_test_types.py new file mode 100644 index 0000000000..d1a770c75a --- /dev/null +++ b/tests/unit/src/sqlbuild/adapter/relations/main/fit_auxiliary_relation_name/_test_types.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FitAuxiliaryRelationNameTestCase: + description: str + base_name: str + suffix: str + identifier_limit: int + expected_name: str + + +@dataclass(frozen=True) +class FitAuxiliaryRelationNameErrorTestCase: + description: str + base_name: str + suffix: str + identifier_limit: int + expected_error_fragment: str diff --git a/tests/unit/src/sqlbuild/adapter/relations/main/fit_auxiliary_relation_name/test_fit_auxiliary_relation_name.py b/tests/unit/src/sqlbuild/adapter/relations/main/fit_auxiliary_relation_name/test_fit_auxiliary_relation_name.py new file mode 100644 index 0000000000..c70d7e4456 --- /dev/null +++ b/tests/unit/src/sqlbuild/adapter/relations/main/fit_auxiliary_relation_name/test_fit_auxiliary_relation_name.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import hashlib + +import pytest + +from sqlbuild.adapter.relations.main.fit_auxiliary_relation_name import ( + fit_auxiliary_relation_name, +) +from sqlbuild.errors.contracts.exceptions import SharedInputError +from tests.unit.src.sqlbuild.adapter.relations.main.fit_auxiliary_relation_name._test_types import ( + FitAuxiliaryRelationNameErrorTestCase, + FitAuxiliaryRelationNameTestCase, +) + +_LONG_BASE: str = "orders_by_region_" * 80 +_HASHES: dict[int, str] = { + length: hashlib.sha256(_LONG_BASE[:length].encode("utf-8")).hexdigest()[:8] + for length in (55, 60, 63, 128, 255, 1024) +} +_MULTIBYTE_BASE: str = "a" * 44 + "ü" * 10 +_MULTIBYTE_HASH: str = hashlib.sha256(_MULTIBYTE_BASE.encode("utf-8")).hexdigest()[:8] +_SIBLING_HASH: str = hashlib.sha256(f"{_LONG_BASE[:62]}x".encode()).hexdigest()[:8] + + +@pytest.mark.parametrize( + "test_case", + [ + FitAuxiliaryRelationNameTestCase( + description="name that fits keeps the plain suffix", + base_name=_LONG_BASE[:54], + suffix="__staging", + identifier_limit=63, + expected_name=f"{_LONG_BASE[:54]}__staging", + ), + FitAuxiliaryRelationNameTestCase( + description="one character over the 63 limit is hashed", + base_name=_LONG_BASE[:55], + suffix="__staging", + identifier_limit=63, + expected_name=f"{_LONG_BASE[:45]}_{_HASHES[55]}__staging", + ), + FitAuxiliaryRelationNameTestCase( + description="target at the 63 limit (DuckDB, MotherDuck, Postgres)", + base_name=_LONG_BASE[:63], + suffix="__staging", + identifier_limit=63, + expected_name=f"{_LONG_BASE[:45]}_{_HASHES[63]}__staging", + ), + FitAuxiliaryRelationNameTestCase( + description="sibling target sharing the prefix gets a different name", + base_name=f"{_LONG_BASE[:62]}x", + suffix="__staging", + identifier_limit=63, + expected_name=f"{_LONG_BASE[:45]}_{_SIBLING_HASH}__staging", + ), + FitAuxiliaryRelationNameTestCase( + description="type enforcement suffix on a long staging name", + base_name=_LONG_BASE[:60], + suffix="__enforced", + identifier_limit=63, + expected_name=f"{_LONG_BASE[:44]}_{_HASHES[60]}__enforced", + ), + FitAuxiliaryRelationNameTestCase( + description="target at the 128 limit (SQL Server)", + base_name=_LONG_BASE[:128], + suffix="__delta", + identifier_limit=128, + expected_name=f"{_LONG_BASE[:112]}_{_HASHES[128]}__delta", + ), + FitAuxiliaryRelationNameTestCase( + description="target at the 255 limit (Snowflake, Databricks)", + base_name=_LONG_BASE[:255], + suffix="__snapshot_delta", + identifier_limit=255, + expected_name=f"{_LONG_BASE[:230]}_{_HASHES[255]}__snapshot_delta", + ), + FitAuxiliaryRelationNameTestCase( + description="target at the 1024 limit (BigQuery)", + base_name=_LONG_BASE[:1024], + suffix="__staging", + identifier_limit=1024, + expected_name=f"{_LONG_BASE[:1006]}_{_HASHES[1024]}__staging", + ), + FitAuxiliaryRelationNameTestCase( + description="name fitting by characters but not bytes is cut on a character boundary", + base_name=_MULTIBYTE_BASE, + suffix="__staging", + identifier_limit=63, + expected_name=f"{'a' * 44}_{_MULTIBYTE_HASH}__staging", + ), + ], + ids=lambda case: case.description, +) +def test_given_identifier_limit_when_fitting_auxiliary_name_then_returns_expected_name( + test_case: FitAuxiliaryRelationNameTestCase, +) -> None: + fitted: str = fit_auxiliary_relation_name( + base_name=test_case.base_name, + suffix=test_case.suffix, + identifier_limit=test_case.identifier_limit, + ) + + assert fitted == test_case.expected_name + assert len(fitted.encode("utf-8")) <= test_case.identifier_limit + assert fitted != test_case.base_name + + +@pytest.mark.parametrize( + "test_case", + [ + FitAuxiliaryRelationNameErrorTestCase( + description="suffix longer than the limit is rejected", + base_name="orders", + suffix="_" * 70, + identifier_limit=63, + expected_error_fragment="cannot fit within identifier limit 63", + ) + ], + ids=lambda case: case.description, +) +def test_given_oversized_suffix_when_fitting_auxiliary_name_then_raises( + test_case: FitAuxiliaryRelationNameErrorTestCase, +) -> None: + with pytest.raises(SharedInputError, match=test_case.expected_error_fragment): + _ = fit_auxiliary_relation_name( + base_name=test_case.base_name, + suffix=test_case.suffix, + identifier_limit=test_case.identifier_limit, + ) diff --git a/tests/unit/src/sqlbuild/compiler/contracts/_test_types.py b/tests/unit/src/sqlbuild/compiler/contracts/_test_types.py index dedb4ff39b..ee757c5903 100644 --- a/tests/unit/src/sqlbuild/compiler/contracts/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/contracts/_test_types.py @@ -52,3 +52,14 @@ class DynamicContractValidationTestCase: failure_reason: str | None expected_code: str expected_message: str + + +@dataclass(frozen=True) +class PromotionConflictTestCase: + description: str + model_configs: tuple[tuple[str, dict[str, object]], ...] + table_promotion_mode: str | None + adapter_default: str + settings_file: str + expected_resource_names: tuple[str, ...] + expected_help_fragments: tuple[str, ...] = () diff --git a/tests/unit/src/sqlbuild/compiler/contracts/helpers.py b/tests/unit/src/sqlbuild/compiler/contracts/helpers.py index 9da4604558..1fb7cb04b1 100644 --- a/tests/unit/src/sqlbuild/compiler/contracts/helpers.py +++ b/tests/unit/src/sqlbuild/compiler/contracts/helpers.py @@ -116,3 +116,36 @@ def _column_audits( *, name: str, not_null_audit_columns: tuple[str, ...] ) -> tuple[SchemaAuditInstance, ...]: return ((), (SchemaAuditInstance(definition_name="not_null"),))[name in not_null_audit_columns] + + +def make_promotion_project( + *, + model_configs: tuple[tuple[str, dict[str, object]], ...], + table_promotion_mode: str | None, +) -> CompiledProject: + """Build a compiled project of models with the given configs and promotion setting.""" + + return CompiledProject( + run_id="run-1", + effective_target_name="dev", + effective_connection={}, + effective_vars={}, + settings=SettingsConfig(table_promotion_mode=table_promotion_mode), + models=tuple( + CompiledModel( + key=CompiledObjectKey(resource_type=CompiledResourceType.MODEL, name=name), + deps=(), + name=name, + relative_path=Path(f"models/{name}.sql"), + query_sql="SELECT 1 AS id", + config=CompileModelConfig(values=values), + destination=CompiledRelationLocation( + database=None, + schema="analytics", + name=name, + qualified_name=f"analytics.{name}", + ), + ) + for name, values in model_configs + ), + ) diff --git a/tests/unit/src/sqlbuild/compiler/contracts/test_promotion_conflicts.py b/tests/unit/src/sqlbuild/compiler/contracts/test_promotion_conflicts.py new file mode 100644 index 0000000000..d3ceff0652 --- /dev/null +++ b/tests/unit/src/sqlbuild/compiler/contracts/test_promotion_conflicts.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import pytest + +from sqlbuild.adapter.contract.types import TablePromotionMode +from sqlbuild.compiler.compile.models import CompilerDiagnostic +from sqlbuild.compiler.contracts.main.promotion_conflicts import promotion_conflict_diagnostics +from tests.unit.src.sqlbuild.compiler.contracts._test_types import PromotionConflictTestCase +from tests.unit.src.sqlbuild.compiler.contracts.helpers import make_promotion_project + +_ENFORCED_TABLE: dict[str, object] = {"materialized": "table", "contract": "enforced"} + + +@pytest.mark.parametrize( + "test_case", + [ + PromotionConflictTestCase( + description="explicit immediate reports every enforced table-lifecycle model", + model_configs=( + ("order_totals", _ENFORCED_TABLE), + ("customer_orders", {"contract": "enforced"}), + ("order_events", {"materialized": "incremental", "contract": "enforced"}), + ( + "order_batches", + { + "materialized": "incremental", + "incremental_mode": "microbatch", + "contract": "enforced", + }, + ), + ("order_view", {"materialized": "view", "contract": "enforced"}), + ("order_history", {"materialized": "snapshot", "contract": "enforced"}), + ("raw_totals", {"materialized": "table"}), + ), + table_promotion_mode="immediate", + adapter_default="staged", + settings_file="sqlbuild_project.toml", + expected_resource_names=("order_totals", "customer_orders", "order_events"), + expected_help_fragments=( + 'sqlbuild_project.toml sets [settings] table_promotion_mode = "immediate"', + "remove that line to use the default staged promotion, or set this in " + "sqlbuild_project.toml:", + 'table_promotion_mode = "staged"', + "`contract enforced` in its MODEL header", + ), + ), + PromotionConflictTestCase( + description="local override names the local file", + model_configs=(("order_totals", _ENFORCED_TABLE),), + table_promotion_mode="immediate", + adapter_default="staged", + settings_file="sqlbuild_local.toml", + expected_resource_names=("order_totals",), + expected_help_fragments=( + 'sqlbuild_local.toml sets [settings] table_promotion_mode = "immediate"', + ), + ), + PromotionConflictTestCase( + description="immediate adapter default asks for an explicit staged setting", + model_configs=(("order_totals", _ENFORCED_TABLE),), + table_promotion_mode=None, + adapter_default="immediate", + settings_file="sqlbuild_project.toml", + expected_resource_names=("order_totals",), + expected_help_fragments=( + "sqlbuild_project.toml does not set [settings] table_promotion_mode, so it " + 'defaults to "immediate"', + "to validate enforced contracts before promotion, set this in " + "sqlbuild_project.toml:", + ), + ), + PromotionConflictTestCase( + description="staged setting reports nothing", + model_configs=(("order_totals", _ENFORCED_TABLE),), + table_promotion_mode="staged", + adapter_default="staged", + settings_file="sqlbuild_project.toml", + expected_resource_names=(), + ), + PromotionConflictTestCase( + description="unset setting with staged adapter default reports nothing", + model_configs=(("order_totals", _ENFORCED_TABLE),), + table_promotion_mode=None, + adapter_default="staged", + settings_file="sqlbuild_project.toml", + expected_resource_names=(), + ), + ], + ids=lambda case: case.description, +) +def test_given_promotion_setting_when_checking_contracts_then_reports_conflicting_models( + test_case: PromotionConflictTestCase, +) -> None: + diagnostics: tuple[CompilerDiagnostic, ...] = promotion_conflict_diagnostics( + project=make_promotion_project( + model_configs=test_case.model_configs, + table_promotion_mode=test_case.table_promotion_mode, + ), + adapter_default=TablePromotionMode(test_case.adapter_default), + settings_file=test_case.settings_file, + ) + + assert tuple(item.resource_name for item in diagnostics) == test_case.expected_resource_names + assert all(item.code == "K011" and item.is_error for item in diagnostics) + for diagnostic in diagnostics: + for fragment in test_case.expected_help_fragments: + assert fragment in str(diagnostic.help) diff --git a/tests/unit/src/sqlbuild/executor/pipeline/_helpers/test_scenario.py b/tests/unit/src/sqlbuild/executor/pipeline/_helpers/test_scenario.py index 08132e7cc4..02d472bc5b 100644 --- a/tests/unit/src/sqlbuild/executor/pipeline/_helpers/test_scenario.py +++ b/tests/unit/src/sqlbuild/executor/pipeline/_helpers/test_scenario.py @@ -21,6 +21,7 @@ from sqlbuild.executor.scenario.models import ( ScenarioCaptureSettings, ScenarioLocalReplaySource, + ScenarioRunOptions, ScenarioRunResult, ScenarioSnapshotStateResult, ) @@ -82,8 +83,9 @@ def execute_run( connection: Any, run_id: str, retain: bool, + options: ScenarioRunOptions, ) -> ScenarioRunResult: - del adapter, connection, run_id, retain + del adapter, connection, run_id, retain, options return ScenarioRunResult( scenario_name=scenario_plan.name, status=ExecutionStatus.SUCCESS, diff --git a/tests/unit/src/sqlbuild/executor/scenario/main/_test_types.py b/tests/unit/src/sqlbuild/executor/scenario/main/_test_types.py index e958d955c6..cadbde17aa 100644 --- a/tests/unit/src/sqlbuild/executor/scenario/main/_test_types.py +++ b/tests/unit/src/sqlbuild/executor/scenario/main/_test_types.py @@ -68,3 +68,31 @@ class ScenarioExpectedComparisonSqlTestCase: description: str set_difference_operator: str expected_fragments: tuple[str, ...] + + +@dataclass(frozen=True) +class ScenarioCatalogCleanupTestCase: + description: str + relation_types: dict[str, str] + expected_drop_sql: tuple[str, ...] + fail_listing: bool = False + authoritative: bool = True + catalog_schema: str = "scenario_schema" + expected_listing_count: int = 1 + + +@dataclass(frozen=True) +class ScenarioAuxiliaryCleanupTargetTestCase: + description: str + model_target_name: str + expected_max_length: int + expected_suffixes: tuple[str, ...] = ("", "__staging", "__enforced") + + +@dataclass(frozen=True) +class RunningScenariosInterruptTestCase: + description: str + cancellable: bool + close_uncancellable: bool + expected_events: tuple[str, ...] + expected_closed_connection: bool diff --git a/tests/unit/src/sqlbuild/executor/scenario/main/helpers.py b/tests/unit/src/sqlbuild/executor/scenario/main/helpers.py index 6b013dc326..3b6d8cf6bf 100644 --- a/tests/unit/src/sqlbuild/executor/scenario/main/helpers.py +++ b/tests/unit/src/sqlbuild/executor/scenario/main/helpers.py @@ -6,7 +6,7 @@ from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.adapter.contract.classes.statement_recorder import StatementRecorder -from sqlbuild.adapter.contract.models import ColumnInfo, QueryResult +from sqlbuild.adapter.contract.models import ColumnInfo, QueryResult, RelationInfo from sqlbuild.compiler.compile.models import ( CompiledObjectKey, CompiledRelationLocation, @@ -56,6 +56,62 @@ def close(self, connection: object) -> None: del connection +class ScenarioCatalogTestAdapter(ScenarioFixtureTestAdapter): + """Adapter whose catalog lists only the named scenario relations.""" + + adapter_name: ClassVar[str] = "scenario-catalog-test" + + def __init__( + self, + *, + relation_types: dict[str, str], + fail_listing: bool = False, + interrupt_on_create: bool = False, + authoritative: bool = True, + catalog_schema: str = "scenario_schema", + ) -> None: + super().__init__() + self.relation_types: dict[str, str] = relation_types + self.fail_listing: bool = fail_listing + self.authoritative: bool = authoritative + self.catalog_schema: str = catalog_schema + self.interrupt_on_create: bool = interrupt_on_create + self.listing_count: int = 0 + + def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool: + del database, schema + return self.authoritative + + def list_relations( + self, + *, + connection: Any, + database: str | None, + schemas: tuple[str, ...] | None, + names: tuple[str, ...] | None = None, + ) -> tuple[RelationInfo, ...]: + del connection + self.listing_count += 1 + _CATALOG_LISTING_STRATEGIES[self.fail_listing]() + in_scope: bool = self.authoritative and self.catalog_schema in (schemas or ()) + listed_names: set[str] = set(self.relation_types).intersection( + {True: names or (), False: ()}[in_scope] + ) + return tuple( + RelationInfo( + database=database, + schema=self.catalog_schema, + name=name, + relation_type=self.relation_types[name], + ) + for name in sorted(listed_names) + ) + + def _execute(self, connection: object, sql: str) -> object: + _CATALOG_CREATE_STRATEGIES[self.interrupt_on_create and sql.startswith("CREATE ")]() + return super()._execute(connection, sql) + + class ScenarioSnapshotCaptureStepsTestAdapter(BaseAdapter): """Adapter that records capture orchestration operations.""" @@ -189,7 +245,9 @@ def executed_drop_sql(adapter: ScenarioFixtureTestAdapter) -> tuple[str, ...]: def build_scenario_cleanup_test_plan( - *, model_materialization_type: MaterializationType = MaterializationType.TABLE + *, + model_materialization_type: MaterializationType = MaterializationType.TABLE, + model_target_name: str = "__sqb_51b385aebe20__model__daily_revenue", ) -> ScenarioExecutionPlan: source_fixture: ScenarioFixturePlan = build_scenario_fixture_plan() ref_fixture: ScenarioFixturePlan = build_scenario_fixture_plan( @@ -207,8 +265,8 @@ def build_scenario_cleanup_test_plan( model_target: CompiledRelationLocation = CompiledRelationLocation( database=None, schema="scenario_schema", - name="__sqb_51b385aebe20__model__daily_revenue", - qualified_name="scenario_schema.__sqb_51b385aebe20__model__daily_revenue", + name=model_target_name, + qualified_name=f"scenario_schema.{model_target_name}", ) return ScenarioExecutionPlan( key=CompiledObjectKey( @@ -452,3 +510,39 @@ def _unexpected_describe(relation: str) -> tuple[ColumnInfo, ...]: (False, True, False): _constant_columns((ColumnInfo(name="customer_id", type="INTEGER"),)), (False, False, True): _constant_columns((ColumnInfo(name="country_code", type="VARCHAR"),)), } + + +def _raise_catalog_unavailable() -> None: + raise RuntimeError("catalog unavailable") + + +def _raise_interrupt() -> None: + raise KeyboardInterrupt + + +_CATALOG_LISTING_STRATEGIES: dict[bool, Callable[[], None]] = { + True: _raise_catalog_unavailable, + False: lambda: None, +} +_CATALOG_CREATE_STRATEGIES: dict[bool, Callable[[], None]] = { + True: _raise_interrupt, + False: lambda: None, +} + + +class ScenarioInterruptTestAdapter(ScenarioFixtureTestAdapter): + """Adapter that records statement cancellation and connection closes.""" + + adapter_name: ClassVar[str] = "scenario-interrupt-test" + + def __init__(self, *, cancellable: bool) -> None: + super().__init__() + self.cancellable: bool = cancellable + self.events: list[str] = [] + + def interrupt_connection(self, connection: Any) -> bool: + self.events.append(f"interrupt:{connection}") + return self.cancellable + + def close(self, connection: object) -> None: + self.events.append(f"close:{connection}") diff --git a/tests/unit/src/sqlbuild/executor/scenario/main/test_cleanup.py b/tests/unit/src/sqlbuild/executor/scenario/main/test_cleanup.py index 4d2831a423..3503c7dcf8 100644 --- a/tests/unit/src/sqlbuild/executor/scenario/main/test_cleanup.py +++ b/tests/unit/src/sqlbuild/executor/scenario/main/test_cleanup.py @@ -2,20 +2,35 @@ import pytest +from sqlbuild.adapter.contract.types import TablePromotionMode from sqlbuild.compiler.planner.types import MaterializationType from sqlbuild.executor.scenario.main._cleanup import execute_scenario_cleanup -from sqlbuild.executor.scenario.models import ScenarioCleanupExecutionResult +from sqlbuild.executor.scenario.main._run import execute_scenario_run +from sqlbuild.executor.scenario.models import ScenarioCleanupExecutionResult, ScenarioRunOptions from sqlbuild.executor.scheduling.types import ExecutionStatus from tests.unit.src.sqlbuild.executor.scenario.main._test_types import ( ExecuteScenarioCleanupTestCase, + ScenarioAuxiliaryCleanupTargetTestCase, + ScenarioCatalogCleanupTestCase, ) from tests.unit.src.sqlbuild.executor.scenario.main.helpers import ( + ScenarioCatalogTestAdapter, ScenarioFixtureTestAdapter, build_scenario_cleanup_test_plan, build_scenario_cleanup_test_plan_with_project_seed, executed_drop_sql, ) +_CATALOG_PREFIX: str = "scenario_schema.__sqb_51b385aebe20" +_ALL_PLANNED_DROPS: tuple[str, ...] = ( + f"DROP TABLE IF EXISTS {_CATALOG_PREFIX}__source__raw__orders", + f"DROP TABLE IF EXISTS {_CATALOG_PREFIX}__ref__stg_customers", + f"DROP TABLE IF EXISTS {_CATALOG_PREFIX}__seed__country_codes", + f"DROP TABLE IF EXISTS {_CATALOG_PREFIX}__model__daily_revenue", + f"DROP TABLE IF EXISTS {_CATALOG_PREFIX}__model__daily_revenue__staging", + f"DROP TABLE IF EXISTS {_CATALOG_PREFIX}__model__daily_revenue__staging__enforced", +) + @pytest.mark.parametrize( "test_case", @@ -28,6 +43,8 @@ "scenario_schema.__sqb_51b385aebe20__ref__stg_customers", "scenario_schema.__sqb_51b385aebe20__seed__country_codes", "scenario_schema.__sqb_51b385aebe20__model__daily_revenue", + "scenario_schema.__sqb_51b385aebe20__model__daily_revenue__staging", + "scenario_schema.__sqb_51b385aebe20__model__daily_revenue__staging__enforced", ), unexpected_drop_targets=( "scenario_schema.__sqb_51b385aebe20__model__stale_not_in_plan", @@ -88,6 +105,7 @@ def test_given_view_model_in_scenario_plan_when_cleaning_up_then_drops_view( expected_target: str = test_case.expected_drop_targets[0] assert f"DROP VIEW IF EXISTS {expected_target}" in drop_sql assert f"DROP TABLE IF EXISTS {expected_target}" not in drop_sql + assert all("__staging" not in statement for statement in drop_sql) @pytest.mark.parametrize( @@ -101,6 +119,8 @@ def test_given_view_model_in_scenario_plan_when_cleaning_up_then_drops_view( "scenario_schema.__sqb_51b385aebe20__ref__stg_customers", "scenario_schema.__sqb_51b385aebe20__seed__country_codes", "scenario_schema.__sqb_51b385aebe20__model__daily_revenue", + "scenario_schema.__sqb_51b385aebe20__model__daily_revenue__staging", + "scenario_schema.__sqb_51b385aebe20__model__daily_revenue__staging__enforced", ), expected_error_fragment="failed target __sqb_51b385aebe20__seed__country_codes", ) @@ -162,3 +182,138 @@ def test_given_unmocked_project_seed_when_cleaning_up_then_drops_seed_target( tuple(target.target_relation for target in result.targets) == test_case.expected_drop_targets ) + + +@pytest.mark.parametrize( + "test_case", + [ + ScenarioCatalogCleanupTestCase( + description="drops only catalogued relations using the catalogued type", + relation_types={ + "__sqb_51b385aebe20__source__raw__orders": "BASE TABLE", + "__sqb_51b385aebe20__model__daily_revenue": "VIEW", + }, + expected_drop_sql=( + f"DROP TABLE IF EXISTS {_CATALOG_PREFIX}__source__raw__orders", + f"DROP VIEW IF EXISTS {_CATALOG_PREFIX}__model__daily_revenue", + ), + ), + ScenarioCatalogCleanupTestCase( + description="issues no drops when nothing scenario-owned exists", + relation_types={}, + expected_drop_sql=(), + ), + ScenarioCatalogCleanupTestCase( + description="matches a catalog that folds schema and name case", + relation_types={"__SQB_51B385AEBE20__SOURCE__RAW__ORDERS": "BASE TABLE"}, + catalog_schema="SCENARIO_SCHEMA", + expected_drop_sql=(f"DROP TABLE IF EXISTS {_CATALOG_PREFIX}__source__raw__orders",), + ), + ScenarioCatalogCleanupTestCase( + description="drops every target when the listing is not authoritative", + relation_types={}, + authoritative=False, + expected_listing_count=0, + expected_drop_sql=_ALL_PLANNED_DROPS, + ), + ScenarioCatalogCleanupTestCase( + description="drops every planned target when the catalog cannot be read", + relation_types={}, + fail_listing=True, + expected_drop_sql=_ALL_PLANNED_DROPS, + ), + ], + ids=lambda case: case.description, +) +def test_given_catalog_state_when_cleaning_up_then_drops_only_existing_relations( + test_case: ScenarioCatalogCleanupTestCase, +) -> None: + adapter: ScenarioCatalogTestAdapter = ScenarioCatalogTestAdapter( + relation_types=test_case.relation_types, + fail_listing=test_case.fail_listing, + authoritative=test_case.authoritative, + catalog_schema=test_case.catalog_schema, + ) + + result: ScenarioCleanupExecutionResult = execute_scenario_cleanup( + scenario_plan=build_scenario_cleanup_test_plan(), + adapter=adapter, + connection=object(), + ) + + assert result.status == ExecutionStatus.SUCCESS + assert adapter.listing_count == test_case.expected_listing_count + assert executed_drop_sql(adapter) == test_case.expected_drop_sql + + +@pytest.mark.parametrize( + "test_case", + [ + ScenarioCatalogCleanupTestCase( + description="interrupt drops relations created before it and reraises", + relation_types={"__sqb_51b385aebe20__source__raw__orders": "BASE TABLE"}, + expected_drop_sql=( + f"DROP TABLE IF EXISTS {_CATALOG_PREFIX}__source__raw__orders", + f"DROP TABLE IF EXISTS {_CATALOG_PREFIX}__source__raw__orders", + ), + ), + ], + ids=lambda case: case.description, +) +def test_given_interrupt_during_scenario_when_running_then_cleans_up_and_reraises( + test_case: ScenarioCatalogCleanupTestCase, +) -> None: + adapter: ScenarioCatalogTestAdapter = ScenarioCatalogTestAdapter( + relation_types=test_case.relation_types, interrupt_on_create=True + ) + + with pytest.raises(KeyboardInterrupt): + _ = execute_scenario_run( + scenario_plan=build_scenario_cleanup_test_plan(), + adapter=adapter, + connection=object(), + run_id="run-1", + retain=False, + options=ScenarioRunOptions(promotion_mode=TablePromotionMode.STAGED), + ) + + assert adapter.listing_count == 2 + assert executed_drop_sql(adapter) == test_case.expected_drop_sql + + +@pytest.mark.parametrize( + "test_case", + [ + ScenarioAuxiliaryCleanupTargetTestCase( + description="target at the identifier limit", + model_target_name="__sqb_51b385aebe20__model__" + "regional_order_totals_" * 2, + expected_max_length=63, + ), + ScenarioAuxiliaryCleanupTargetTestCase( + description="staging suffix overflows the identifier limit", + model_target_name="__sqb_51b385aebe20__model__regional_order_totals_by_cust", + expected_max_length=63, + ), + ], + ids=lambda case: case.description, +) +def test_given_long_model_target_when_collecting_cleanup_then_auxiliary_names_fit_and_differ( + test_case: ScenarioAuxiliaryCleanupTargetTestCase, +) -> None: + adapter: ScenarioFixtureTestAdapter = ScenarioFixtureTestAdapter() + target_name: str = test_case.model_target_name[: test_case.expected_max_length] + + result: ScenarioCleanupExecutionResult = execute_scenario_cleanup( + scenario_plan=build_scenario_cleanup_test_plan(model_target_name=target_name), + adapter=adapter, + connection=object(), + ) + + model_names: tuple[str, ...] = tuple(str(target.name) for target in result.targets[-3:]) + assert model_names[0] == target_name + assert len(set(model_names)) == len(test_case.expected_suffixes) + assert max(len(name) for name in model_names) <= test_case.expected_max_length + assert all( + name.endswith(suffix) + for name, suffix in zip(model_names, test_case.expected_suffixes, strict=True) + ) diff --git a/tests/unit/src/sqlbuild/executor/scenario/main/test_models.py b/tests/unit/src/sqlbuild/executor/scenario/main/test_models.py index d91114d516..1f46373255 100644 --- a/tests/unit/src/sqlbuild/executor/scenario/main/test_models.py +++ b/tests/unit/src/sqlbuild/executor/scenario/main/test_models.py @@ -2,6 +2,7 @@ import pytest +from sqlbuild.adapter.contract.types import TablePromotionMode from sqlbuild.compiler.planner.models import ModelPlanEntry, ScenarioExecutionPlan from sqlbuild.compiler.planner.types import MaterializationType, PlanAction from sqlbuild.executor.run.models import ModelExecutionResult @@ -46,6 +47,7 @@ def test_given_table_model_entry_when_executing_scenario_model_then_creates_tabl adapter=adapter, connection=object(), run_id="run-1", + promotion_mode=TablePromotionMode.IMMEDIATE, ) assert result.status == test_case.expected_statuses[0] @@ -84,6 +86,7 @@ def test_given_view_model_entry_when_executing_scenario_model_then_creates_view( adapter=adapter, connection=object(), run_id="run-1", + promotion_mode=TablePromotionMode.IMMEDIATE, ) assert result.status == test_case.expected_statuses[0] @@ -122,6 +125,7 @@ def test_given_incremental_model_entry_when_executing_scenario_model_then_create adapter=adapter, connection=object(), run_id="run-1", + promotion_mode=TablePromotionMode.IMMEDIATE, ) assert result.status == test_case.expected_statuses[0] @@ -163,6 +167,7 @@ def test_given_model_failure_when_executing_scenario_models_then_stops_before_ne adapter=adapter, connection=object(), run_id="run-1", + promotion_mode=TablePromotionMode.IMMEDIATE, ) assert tuple(result.status for result in results) == test_case.expected_statuses diff --git a/tests/unit/src/sqlbuild/executor/scenario/main/test_running_scenarios.py b/tests/unit/src/sqlbuild/executor/scenario/main/test_running_scenarios.py new file mode 100644 index 0000000000..639f68554a --- /dev/null +++ b/tests/unit/src/sqlbuild/executor/scenario/main/test_running_scenarios.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import pytest + +from sqlbuild.executor.scenario.classes.running_scenarios import RunningScenarios +from tests.unit.src.sqlbuild.executor.scenario.main._test_types import ( + RunningScenariosInterruptTestCase, +) +from tests.unit.src.sqlbuild.executor.scenario.main.helpers import ( + ScenarioInterruptTestAdapter, + build_scenario_cleanup_test_plan, +) + + +@pytest.mark.parametrize( + "test_case", + [ + RunningScenariosInterruptTestCase( + description="cancellable adapter interrupts only in-flight connections", + cancellable=True, + close_uncancellable=True, + expected_events=("interrupt:busy",), + expected_closed_connection=False, + ), + RunningScenariosInterruptTestCase( + description="uncancellable adapter closes in-flight connections when stopping", + cancellable=False, + close_uncancellable=True, + expected_events=("interrupt:busy", "close:busy"), + expected_closed_connection=True, + ), + RunningScenariosInterruptTestCase( + description="uncancellable adapter keeps connections on a signal-time cancel", + cancellable=False, + close_uncancellable=False, + expected_events=("interrupt:busy",), + expected_closed_connection=False, + ), + ], + ids=lambda case: case.description, +) +def test_given_running_scenarios_when_interrupting_then_cancels_or_closes_in_flight_connections( + test_case: RunningScenariosInterruptTestCase, +) -> None: + adapter: ScenarioInterruptTestAdapter = ScenarioInterruptTestAdapter( + cancellable=test_case.cancellable + ) + running: RunningScenarios = RunningScenarios(adapter=adapter) + running.start(scenario_plan=build_scenario_cleanup_test_plan(), connection="idle") + running.finish(connection="idle") + running.start(scenario_plan=build_scenario_cleanup_test_plan(), connection="busy") + + running.interrupt(close_uncancellable=test_case.close_uncancellable) + + assert tuple(adapter.events) == test_case.expected_events + assert running.closed_connection is test_case.expected_closed_connection + assert len(running.started_plans) == 2 diff --git a/website/src/content/docs/docs/cli/scenario.mdx b/website/src/content/docs/docs/cli/scenario.mdx index c29d81dcae..209b687c1f 100644 --- a/website/src/content/docs/docs/cli/scenario.mdx +++ b/website/src/content/docs/docs/cli/scenario.mdx @@ -25,6 +25,7 @@ sqb scenario test [flags] | `--scenario-namespace NAMESPACE` | Isolate warehouse artifacts for this run; overrides environment and config | | `--local` | Run locally against DuckDB using captured JSONL snapshots | | `--strict` | Treat missing/stale local snapshots as errors instead of skips | +| `--concurrency N` | Run up to `N` scenarios at once (default: the project `concurrency` setting) | | `--sync-snapshots` | Capture missing/stale snapshots before local run (requires `--local`) | | `--refresh` | Recapture all selected snapshots before local run (requires `--local`) | | `--force` | Bypass snapshot capture safety limits | @@ -86,17 +87,20 @@ sqb scenario test --local --strict Remote scenarios report per-scenario PASS/FAIL with nested check rows: ``` -daily_revenue_minimal PASS +daily_revenue_minimal PASS 0.84s expect expected daily_revenue PASS expect assertion no_negative_revenue PASS -PASS=1 FAIL=0 TOTAL=1 +PASS=1 FAIL=0 TOTAL=1 (0.91s) ``` +Each scenario row shows its wall time and the summary shows the total. `--json` reports the same +values as `scenarios[].duration_ms` and `summary.duration_ms`. + Local scenarios add ERROR and SKIP statuses: ``` -PASS=2 FAIL=0 ERROR=0 SKIP=1 TOTAL=3 +PASS=2 FAIL=0 ERROR=0 SKIP=1 TOTAL=3 (1.20s) ``` Failed scenarios suggest rerunning with `--retain` for inspection. Local runs always keep the DuckDB file at `target/run/scenarios//local.duckdb`. diff --git a/website/src/content/docs/docs/concepts/models/materializations.mdx b/website/src/content/docs/docs/concepts/models/materializations.mdx index 22d0aae228..57a9d10aa9 100644 --- a/website/src/content/docs/docs/concepts/models/materializations.mdx +++ b/website/src/content/docs/docs/concepts/models/materializations.mdx @@ -30,7 +30,7 @@ FROM __ref("stg_orders") GROUP BY customer_id ``` -Projects may opt into `settings.table_promotion_mode = "immediate"`. Immediate promotion replaces the destination before audits, so a failed audit does not restore the old table. Immediate promotion rejects models that require declared-type enforcement or `contract enforced`; use staged promotion for those guarantees. +Projects may opt into `settings.table_promotion_mode = "immediate"`. Immediate promotion replaces the destination before audits, so a failed audit does not restore the old table. Immediate promotion rejects models that require declared-type enforcement or `contract enforced`; use staged promotion for those guarantees. A table or incremental model with `contract enforced` under immediate promotion is a `K011` compile error, so `sqb compile`, `sqb plan`, `sqb build`, and `sqb scenario test` report it before any warehouse work. ## Incremental diff --git a/website/src/content/docs/docs/concepts/scenarios.mdx b/website/src/content/docs/docs/concepts/scenarios.mdx index cd6934f6eb..49c86dd5fc 100644 --- a/website/src/content/docs/docs/concepts/scenarios.mdx +++ b/website/src/content/docs/docs/concepts/scenarios.mdx @@ -94,6 +94,10 @@ SELECT 1 For sources with two-part identity, use double underscores: `__source__raw__orders`. +To mock a contracted source, ref, or seed with no rows, use `SELECT * FROM __empty_fixture()` as +the complete fixture body. SQLBuild expands it to the relation's declared, typed columns with a +false filter, as in [unit tests](/docs/concepts/testing). + Every scenario must have at least one fixture CTE and at least one `__expected__` or `__assert__` CTE. Scenario SQL uses macros, enums, and constants available from the scenario file's directory under @@ -133,13 +137,32 @@ Scenario artifacts are physically isolated from production: ### Execution flow -1. Clean any existing scenario artifacts from a previous run in the effective run namespace +1. Drop any scenario artifacts a previous run left in the effective run namespace (one catalog read finds them) 2. Materialize source, ref, and seed fixtures as physical tables 3. Load required project seeds 4. Build required models in dependency order (incremental models run as full-refresh in scenarios) 5. Run expected-output comparisons (order-insensitive) 6. Run zero-row assertions -7. Clean up this namespace's scenario-owned artifacts (unless `--retain`) +7. Clean up this namespace's scenario-owned artifacts (unless `--retain`), including after a failure or Ctrl-C + +Table models use the same promotion as `sqb build`: the project's `table_promotion_mode`, or the +adapter default (staged) when it is unset. An enforced contract needs staged promotion, so its +runtime contract check runs before the scenario reaches its expectations; with +`table_promotion_mode = "immediate"` such a model is a `K011` compile error, reported before any +scenario runs, exactly as in `sqb compile`, `sqb plan`, and `sqb build`. + +Independent scenarios run concurrently, up to `--concurrency` or the project `concurrency` +setting. Each scenario writes only its own prefixed relations, so concurrent scenarios never +share an artifact. Results are reported in selection order with each scenario's wall time, and the +summary includes the total time. On DuckDB, concurrent scenarios use separate connections to the +same database file; an in-memory database (`:memory:`) gives each connection its own database, so +fixtures still isolate every scenario. + +On Ctrl-C, SQLBuild prints one notice, cancels scenarios that have not started, cancels the +statements running scenarios have in flight, drops their relations (unless `--retain`), and only +then exits. A second Ctrl-C skips that cleanup and exits immediately; the next run of the same +scenarios drops whatever they left behind. `--local` replay and `sqb scenario capture` run one +scenario at a time. ### Parallel CI runs