From 059cf18e98d84dc6a2db6cec0028ec5e93141d0b Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:27:28 +0100 Subject: [PATCH 1/2] feat: add target execution limits --- Makefile | 1 + README.md | 2 +- docs/execution-limits.md | 68 ++++++++ .../adapter/contract/classes/base_adapter.py | 1 + .../adapter/contract/classes/connection.py | 9 + .../snowflake/classes/snowflake_adapter.py | 3 + .../snowflake/classes/snowflake_cursor.py | 30 +++- src/sqlbuild/adapters/snowflake/constants.py | 2 + .../_helpers/build_planning/defer_clone.py | 32 +++- .../build_planning/execution_limits.py | 39 +++++ .../_helpers/build_planning/invocation.py | 42 ++++- .../build_virtual/virtual_execution.py | 16 +- .../_helpers/entry/dispatch_errors.py | 2 + .../classes/virtual_build_plan_hook.py | 11 ++ .../cli/commands/main/execution/_build.py | 34 +++- src/sqlbuild/cli/commands/models.py | 6 +- .../discovery/_helpers/yml/project.py | 48 ++++++ .../executor/build/classes/build_scheduler.py | 7 + .../executor/pipeline/_helpers/connections.py | 4 +- .../runtime/execution_limits/__init__.py | 1 + .../execution_limits/_helpers/__init__.py | 1 + .../execution_limits/_helpers/context.py | 10 ++ .../runtime/execution_limits/exceptions.py | 27 +++ .../runtime/execution_limits/main/__init__.py | 1 + .../main/current_execution_deadline.py | 10 ++ .../main/enforce_execution_deadline.py | 12 ++ .../main/execution_deadline_scope.py | 47 ++++++ .../runtime/execution_limits/models.py | 31 ++++ .../spec/contracts/_helpers/targets.py | 32 ++++ src/sqlbuild/spec/contracts/models.py | 12 ++ .../cli/commands/main/build/_test_types.py | 7 + .../main/build/test_execution_limits.py | 130 +++++++++++++++ .../snowflake_connection/_test_types.py | 11 ++ .../test_snowflake_connection.py | 71 ++++++++ .../_helpers/build_planning/_test_types.py | 18 ++ .../build_planning/test_execution_limits.py | 154 ++++++++++++++++++ .../cli/commands/main/build/_test_types.py | 7 + .../commands/main/build/test_defer_clone.py | 60 ++++++- .../discovery/_helpers/_test_types.py | 10 ++ .../discovery/_helpers/test_yml_project.py | 102 +++++++++++- .../spec/contracts/main/_test_types.py | 16 +- .../spec/contracts/main/test_targets.py | 54 ++++++ 42 files changed, 1163 insertions(+), 18 deletions(-) create mode 100644 docs/execution-limits.md create mode 100644 src/sqlbuild/cli/commands/_helpers/build_planning/execution_limits.py create mode 100644 src/sqlbuild/runtime/execution_limits/__init__.py create mode 100644 src/sqlbuild/runtime/execution_limits/_helpers/__init__.py create mode 100644 src/sqlbuild/runtime/execution_limits/_helpers/context.py create mode 100644 src/sqlbuild/runtime/execution_limits/exceptions.py create mode 100644 src/sqlbuild/runtime/execution_limits/main/__init__.py create mode 100644 src/sqlbuild/runtime/execution_limits/main/current_execution_deadline.py create mode 100644 src/sqlbuild/runtime/execution_limits/main/enforce_execution_deadline.py create mode 100644 src/sqlbuild/runtime/execution_limits/main/execution_deadline_scope.py create mode 100644 src/sqlbuild/runtime/execution_limits/models.py create mode 100644 tests/e2e/src/sqlbuild/cli/commands/main/build/test_execution_limits.py create mode 100644 tests/unit/src/sqlbuild/cli/commands/_helpers/build_planning/test_execution_limits.py diff --git a/Makefile b/Makefile index c5ae1bb499..07e7eda9f8 100644 --- a/Makefile +++ b/Makefile @@ -64,6 +64,7 @@ E2E_DUCKDB_BUILD_CORE_PATHS := \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_dynamic_column_contract.py \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_dag_json_behavior.py \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_enum_contract.py \ + tests/e2e/src/sqlbuild/cli/commands/main/build/test_execution_limits.py \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_expression_sources.py \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_lifecycle_commands.py \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_manifest_artifact_gating.py \ diff --git a/README.md b/README.md index 01c1be89f4..2e3f9c71a6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ All state is persisted as append-only tables in the warehouse alongside your dat - **Cursor-based incremental processing.** Automatic gap detection and resume, with microbatch mode for large ranges. No external checkpoint to maintain. - **Ingestion and Python nodes.** Load external data with Python `@loader` functions, and run `@task`, `@asset`, and `@check` nodes as first-class members of the same DAG as your SQL models. -See the [documentation](https://docs.sqlbuild.com) for the full feature set, including providers, lifecycle hooks, Python macros, UDFs, custom materializations, data diffs, zero-copy cloning, and virtual environments. This repository also includes a focused guide to [model and raw-query diffs](docs/query-diffs.md). To coordinate dbt and SQLBuild projects, see the [dbt compatibility guide](https://docs.sqlbuild.com/concepts/dbt-compatibility/overview). +See the [documentation](https://docs.sqlbuild.com) for the full feature set, including providers, lifecycle hooks, Python macros, UDFs, custom materializations, data diffs, zero-copy cloning, and virtual environments. This repository also includes focused guides to [target execution limits](docs/execution-limits.md) and [model and raw-query diffs](docs/query-diffs.md). To coordinate dbt and SQLBuild projects, see the [dbt compatibility guide](https://docs.sqlbuild.com/concepts/dbt-compatibility/overview). ## Quick start diff --git a/docs/execution-limits.md b/docs/execution-limits.md new file mode 100644 index 0000000000..60c07e6ec5 --- /dev/null +++ b/docs/execution-limits.md @@ -0,0 +1,68 @@ +# Target execution limits + +Named targets can set optional limits for `sqb build`. Limits are useful for development targets +where an unexpectedly broad selector or long-running command should stop instead of consuming an +unbounded amount of warehouse compute. + +```toml +[targets.dev] +connection = "warehouse" +database = "analytics" +schema = "dev_alice" + +[targets.dev.execution_limits] +max_models = 125 +max_duration = "45m" +remediation = """ +Review the expanded plan and confirm that the selected scope is intentional. +Automated tools must request approval before changing this policy. +""" +``` + +All fields are optional. A target without an `execution_limits` section retains unlimited existing +behavior, so production or orchestrated targets do not need placeholder values. + +## Model limit + +`max_models` is a positive integer. SQLBuild expands selectors and counts model entries that would +execute. When the count exceeds the target limit, the build exits before clone, load, or model +execution begins. The error reports the target, selected count, configured maximum, configuration +key, and confirms that no warehouse changes were made. + +`sqb plan` remains unrestricted and can be used to inspect a broad selection safely. + +## Duration limit + +`max_duration` is a positive fixed duration such as `30m`, `2h`, or `90s`. It covers planning and +warehouse-mutating build phases after target resolution. SQLBuild stops dispatching work at the +deadline and passes the remaining duration to each warehouse statement so in-flight work is +cancelled by the adapter. + +Safe in-flight cancellation is currently supported by the Snowflake adapter, whose maximum accepted +duration is seven days. SQLBuild rejects an unsupported duration or an adapter that cannot guarantee +active statement cancellation rather than presenting a limit that may leave warehouse work running. + +A duration failure may occur after earlier models have completed. Its error therefore distinguishes +the timeout from a pre-execution model-limit failure and states that warehouse changes may already +have occurred. + +## Custom remediation + +`remediation` is optional project-authored text. SQLBuild appends it to model-count, duration, and +unsupported-adapter errors after the factual error details. This lets each project define its own +approval or recovery process without SQLBuild recommending that users weaken local policy. + +## Local overrides + +The same section is accepted in `sqlbuild_local.toml` and follows normal field-by-field local target +precedence: + +```toml +[targets.dev.execution_limits] +max_models = 200 +max_duration = "90m" +``` + +This permits an intentional developer override while keeping the shared target policy visible in +`sqlbuild_project.toml`. Other target fields and any execution-limit fields omitted locally continue +to inherit their project values. diff --git a/src/sqlbuild/adapter/contract/classes/base_adapter.py b/src/sqlbuild/adapter/contract/classes/base_adapter.py index 979ba8a255..2d98e9ae44 100644 --- a/src/sqlbuild/adapter/contract/classes/base_adapter.py +++ b/src/sqlbuild/adapter/contract/classes/base_adapter.py @@ -85,6 +85,7 @@ class BaseAdapter(RetentionAdapterMixin, StrictAdapter): max_identifier_length: ClassVar[int] = 63 state_tables_transient: ClassVar[bool] = False allows_implicit_managed_write_schema: ClassVar[bool] = False + execution_duration_limit_seconds: ClassVar[int | None] = None def supports_zero_copy_clone(self) -> bool: return False diff --git a/src/sqlbuild/adapter/contract/classes/connection.py b/src/sqlbuild/adapter/contract/classes/connection.py index 55c3ad2ae9..5e4a3f539b 100644 --- a/src/sqlbuild/adapter/contract/classes/connection.py +++ b/src/sqlbuild/adapter/contract/classes/connection.py @@ -9,6 +9,10 @@ from sqlbuild.adapter.contract.main._attribute_failed_sql import attribute_failed_sql from sqlbuild.adapter.contract.models import QueryResult +from sqlbuild.runtime.execution_limits.exceptions import ExecutionDurationLimitError +from sqlbuild.runtime.execution_limits.main.enforce_execution_deadline import ( + enforce_execution_deadline, +) from sqlbuild.runtime.observability.classes.statement_lifecycle import StatementLifecycle @@ -27,8 +31,13 @@ def execute(self, *, connection: Any, sql: str) -> Any: with StatementLifecycle(adapter=self.adapter_name, sql=sql, intent="execute") as lifecycle: try: + enforce_execution_deadline() result: Any = self._execute(connection=connection, sql=sql) except Exception as error: + try: + enforce_execution_deadline() + except ExecutionDurationLimitError as deadline_error: + raise deadline_error from error attributed_error: Exception = attribute_failed_sql(error=error, sql=sql) if attributed_error is error: raise diff --git a/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py b/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py index 3e8a477b37..61cd5de2a0 100644 --- a/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py +++ b/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py @@ -70,6 +70,7 @@ from sqlbuild.adapters.snowflake.constants import ( BASE_TABLE_METADATA_TYPE, EXTERNAL_BROWSER_AUTHENTICATOR, + MAX_STATEMENT_TIMEOUT_SECONDS, MFA_AUTHENTICATOR, NUMBER_TYPE_NAME, OAUTH_AUTHORIZATION_CODE_AUTHENTICATOR, @@ -102,6 +103,8 @@ class SnowflakeAdapter(MicrobatchMixin, UnkeyedDiffMixin, BaseAdapter): adapter_name: ClassVar[str] = BuiltinAdapter.SNOWFLAKE.value sql_analysis_dialect_name: ClassVar[str | None] = "snowflake" + execution_duration_limit_seconds: ClassVar[int | None] = MAX_STATEMENT_TIMEOUT_SECONDS + max_identifier_length: ClassVar[int] = 255 state_tables_transient: ClassVar[bool] = True connection_routing_keys: ClassVar[frozenset[str]] = frozenset( diff --git a/src/sqlbuild/adapters/snowflake/classes/snowflake_cursor.py b/src/sqlbuild/adapters/snowflake/classes/snowflake_cursor.py index 967198b160..0d9dc57594 100644 --- a/src/sqlbuild/adapters/snowflake/classes/snowflake_cursor.py +++ b/src/sqlbuild/adapters/snowflake/classes/snowflake_cursor.py @@ -4,6 +4,7 @@ import json import logging +import math import time from collections.abc import Callable, Iterator from datetime import UTC, datetime @@ -15,6 +16,10 @@ from sqlbuild.cost.classes.statement_ledger import StatementLedger from sqlbuild.cost.constants import SQLBUILD_QUERY_TAG_APP from sqlbuild.cost.models import CostResourceContext, StatementExecutionTelemetry +from sqlbuild.runtime.execution_limits.main.current_execution_deadline import ( + current_execution_deadline, +) +from sqlbuild.runtime.execution_limits.models import ExecutionDeadline from sqlbuild.runtime.observability.classes.statement_lifecycle import StatementLifecycle from sqlbuild.runtime.observability.main.current_execution_identity import ( current_execution_identity, @@ -23,6 +28,7 @@ _LOGGER: logging.Logger = logging.getLogger("sqlbuild.cost") _QUERY_TAG_PARAMETER: str = "QUERY_TAG" +_STATEMENT_TIMEOUT_PARAMETER: str = "STATEMENT_TIMEOUT_IN_SECONDS" _STATEMENT_PARAMETER_ERROR_FRAGMENT: str = "STATEMENT PARAMETER" _RAW_CURSOR_ATTRIBUTE: str = "raw_cursor" _EXECUTEMANY_INTENT: str = "executemany" @@ -50,8 +56,9 @@ def __setattr__(self, name: str, value: Any) -> None: def execute(self, sql: str, *args: Any, **kwargs: Any) -> Any: context: CostResourceContext | None = CostContext.current() statement_id: str = _statement_id() + deadline_kwargs: dict[str, Any] = _with_execution_deadline(kwargs=kwargs) tagged_kwargs, query_tag_injected = _with_query_tag( - kwargs=kwargs, context=context, statement_id=statement_id + kwargs=deadline_kwargs, context=context, statement_id=statement_id ) return self._execute_with_telemetry( operation=self.raw_cursor.execute, @@ -67,8 +74,9 @@ def execute(self, sql: str, *args: Any, **kwargs: Any) -> Any: def executemany(self, sql: str, *args: Any, **kwargs: Any) -> Any: context: CostResourceContext | None = CostContext.current() statement_id: str = _statement_id() + deadline_kwargs: dict[str, Any] = _with_execution_deadline(kwargs=kwargs) tagged_kwargs, query_tag_injected = _with_query_tag( - kwargs=kwargs, context=context, statement_id=statement_id + kwargs=deadline_kwargs, context=context, statement_id=statement_id ) return self._execute_with_telemetry( operation=self.raw_cursor.executemany, @@ -120,7 +128,9 @@ def _execute_with_telemetry( "Snowflake query tagging was rejected before submission; " "continuing without a query tag" ) - retry_kwargs: dict[str, Any] = _without_query_tag(kwargs=kwargs) + retry_kwargs: dict[str, Any] = _with_execution_deadline( + kwargs=_without_query_tag(kwargs=kwargs) + ) try: result = operation(sql, *args, **retry_kwargs) except Exception as retry_error: @@ -284,6 +294,20 @@ def _with_query_tag( return tagged_kwargs, True +def _with_execution_deadline(*, kwargs: dict[str, Any]) -> dict[str, Any]: + deadline: ExecutionDeadline | None = current_execution_deadline() + if deadline is None: + return dict(kwargs) + deadline.enforce() + statement_params: dict[str, str] = dict(kwargs.get("_statement_params") or {}) + statement_params[_STATEMENT_TIMEOUT_PARAMETER] = str( + max(1, math.ceil(deadline.remaining_seconds)) + ) + deadline_kwargs: dict[str, Any] = dict(kwargs) + deadline_kwargs["_statement_params"] = statement_params + return deadline_kwargs + + def _without_query_tag(*, kwargs: dict[str, Any]) -> dict[str, Any]: retry_kwargs: dict[str, Any] = dict(kwargs) statement_params: dict[str, str] = dict(retry_kwargs.get("_statement_params") or {}) diff --git a/src/sqlbuild/adapters/snowflake/constants.py b/src/sqlbuild/adapters/snowflake/constants.py index cdd67c67c9..01c94d449e 100644 --- a/src/sqlbuild/adapters/snowflake/constants.py +++ b/src/sqlbuild/adapters/snowflake/constants.py @@ -1,5 +1,7 @@ """Snowflake adapter constants.""" +MAX_STATEMENT_TIMEOUT_SECONDS: int = 604_800 + BASE_TABLE_METADATA_TYPE: str = "BASE TABLE" VIEW_RELATION_TYPE_TOKEN: str = "view" EXTERNAL_BROWSER_AUTHENTICATOR: str = "externalbrowser" diff --git a/src/sqlbuild/cli/commands/_helpers/build_planning/defer_clone.py b/src/sqlbuild/cli/commands/_helpers/build_planning/defer_clone.py index 0cd92293e1..9ecf6a3c2c 100644 --- a/src/sqlbuild/cli/commands/_helpers/build_planning/defer_clone.py +++ b/src/sqlbuild/cli/commands/_helpers/build_planning/defer_clone.py @@ -7,6 +7,9 @@ from typing import Any from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.cli.commands._helpers.build_planning.execution_limits import ( + enforce_model_execution_limit, +) from sqlbuild.cli.commands._helpers.planning.external_refs import ( resolve_external_sql_reference_resolver, ) @@ -69,7 +72,7 @@ def build_defer_clone_boundary_selectors( cli_vars: dict[str, object] | None, project_dir: Path, auto_load_sources: bool, -) -> tuple[CompiledProject, tuple[str, ...], tuple[str, ...]]: +) -> tuple[CompiledProject, tuple[str, ...], tuple[str, ...], int]: """Resolve out-of-selection clone boundaries and the view chain to rebuild over them.""" project: CompiledProject = build_compiled_project( @@ -94,6 +97,7 @@ def build_defer_clone_boundary_selectors( project, defer_clone_boundary_selectors(scope=scope), defer_clone_view_chain_selectors(scope=scope), + selected_executable_model_count(scope=scope), ) @@ -142,6 +146,20 @@ def defer_clone_view_chain_selectors(*, scope: PlannerScope) -> tuple[str, ...]: return tuple(sorted(key.name for key in view_keys)) +def selected_executable_model_count(*, scope: PlannerScope) -> int: + """Count selected models except those explicitly disabled.""" + + count: int = 0 + for key in scope.selected_keys: + if key.resource_type != CompiledResourceType.MODEL: + continue + model: CompiledModel | None = scope.models_by_name.get(key.name) + if model is not None and model.config.values.get("enabled") is False: + continue + count += 1 + return count + + def run_defer_clone_boundary_prephase( *, request: BuildCommandRequest, @@ -153,7 +171,12 @@ def run_defer_clone_boundary_prephase( cloned_project: CompiledProject boundary_selectors: tuple[str, ...] view_chain_selectors: tuple[str, ...] - cloned_project, boundary_selectors, view_chain_selectors = build_defer_clone_boundary_selectors( + ( + cloned_project, + boundary_selectors, + view_chain_selectors, + selected_model_count, + ) = build_defer_clone_boundary_selectors( discovered_inputs=invocation.discovered_inputs, adapter=invocation.adapter, selected_target=request.selected_target, @@ -165,6 +188,11 @@ def run_defer_clone_boundary_prephase( project_dir=invocation.effective_project_dir, auto_load_sources=invocation.should_load_sources, ) + enforce_model_execution_limit( + model_count=selected_model_count, + target_name=invocation.effective_target_name, + limits=invocation.execution_limits, + ) run_defer_clone_prephase( inputs=DeferClonePrephaseInputs( discovered_inputs=invocation.discovered_inputs, diff --git a/src/sqlbuild/cli/commands/_helpers/build_planning/execution_limits.py b/src/sqlbuild/cli/commands/_helpers/build_planning/execution_limits.py new file mode 100644 index 0000000000..fdacb3efbd --- /dev/null +++ b/src/sqlbuild/cli/commands/_helpers/build_planning/execution_limits.py @@ -0,0 +1,39 @@ +"""Target-scoped build execution-limit enforcement.""" + +from __future__ import annotations + +from sqlbuild.cli.commands.exceptions import CliUserError +from sqlbuild.compiler.planner.models import PlanOutput +from sqlbuild.compiler.planner.types import PlanAction +from sqlbuild.spec.contracts.models import ExecutionLimitsConfig + + +def executable_model_count(*, plan: PlanOutput) -> int: + """Return the number of model entries that would execute.""" + + return sum(entry.action != PlanAction.SKIP for entry in plan.model_entries) + + +def enforce_model_execution_limit( + *, + model_count: int, + target_name: str | None, + limits: ExecutionLimitsConfig, +) -> None: + """Reject a build whose expanded model count exceeds its target policy.""" + + maximum: int | None = limits.max_models + if maximum is None or model_count <= maximum: + return + target_label: str = target_name or "default" + raise CliUserError( + "Build plan exceeds the configured model limit\n\n" + f"Target: {target_label}\n" + f"Selected models: {model_count}\n" + f"Maximum models: {maximum}\n" + "Configuration: " + f"targets.{target_label}.execution_limits.max_models\n\n" + "No warehouse changes were made.", + code="C413", + help=limits.remediation, + ) diff --git a/src/sqlbuild/cli/commands/_helpers/build_planning/invocation.py b/src/sqlbuild/cli/commands/_helpers/build_planning/invocation.py index 4edbf0f2ea..47d3d73ef8 100644 --- a/src/sqlbuild/cli/commands/_helpers/build_planning/invocation.py +++ b/src/sqlbuild/cli/commands/_helpers/build_planning/invocation.py @@ -32,7 +32,7 @@ ) from sqlbuild.spec.contracts.main.resolve_target_config import resolve_target_config from sqlbuild.spec.contracts.main.resolve_target_name import resolve_target_name -from sqlbuild.spec.contracts.models import TargetConfig +from sqlbuild.spec.contracts.models import ExecutionLimitsConfig, TargetConfig def resolve_build_invocation(*, request: BuildCommandRequest) -> BuildInvocation: @@ -101,6 +101,44 @@ def resolve_build_invocation(*, request: BuildCommandRequest) -> BuildInvocation if request.load_sources is not None else build_effective_settings_config(discovered_inputs=discovered_inputs).auto_load_sources ) + effective_target_name: str | None = resolve_target_name( + project_config=discovered_inputs.project_config, + local_config=discovered_inputs.local_config, + selected_target=request.selected_target, + ) + execution_limits: ExecutionLimitsConfig = ( + resolve_target_config( + project_config=discovered_inputs.project_config, + local_config=discovered_inputs.local_config, + target_name=effective_target_name, + ).execution_limits + if effective_target_name is not None + else ExecutionLimitsConfig() + ) + if ( + execution_limits.max_duration is not None + and adapter_context.adapter.execution_duration_limit_seconds is None + ): + target_label: str = effective_target_name or "default" + raise CliUserError( + f"Target '{target_label}' configures max_duration, but adapter " + f"'{adapter_context.adapter_name}' cannot cancel active statements safely", + code="C415", + help=execution_limits.remediation, + ) + maximum_duration_seconds: int | None = adapter_context.adapter.execution_duration_limit_seconds + if ( + execution_limits.max_duration_seconds is not None + and maximum_duration_seconds is not None + and execution_limits.max_duration_seconds > maximum_duration_seconds + ): + target_label = effective_target_name or "default" + raise CliUserError( + f"Target '{target_label}' max_duration exceeds the largest duration supported by " + f"adapter '{adapter_context.adapter_name}' ({maximum_duration_seconds} seconds)", + code="C416", + help=execution_limits.remediation, + ) return BuildInvocation( effective_project_dir=effective_project_dir, discovered_inputs=discovered_inputs, @@ -115,6 +153,8 @@ def resolve_build_invocation(*, request: BuildCommandRequest) -> BuildInvocation planning_progress=reporters.planning, should_load_sources=should_load_sources, virtual_mode=bool(discovered_inputs.project_config.settings.virtual_environments), + effective_target_name=effective_target_name, + execution_limits=execution_limits, ) diff --git a/src/sqlbuild/cli/commands/_helpers/build_virtual/virtual_execution.py b/src/sqlbuild/cli/commands/_helpers/build_virtual/virtual_execution.py index 8507824fa5..fa9d95378f 100644 --- a/src/sqlbuild/cli/commands/_helpers/build_virtual/virtual_execution.py +++ b/src/sqlbuild/cli/commands/_helpers/build_virtual/virtual_execution.py @@ -30,6 +30,7 @@ from sqlbuild.compiler.planner.models import CursorOverrides, PlanOutput from sqlbuild.spec.contracts.main.resolve_target_config import resolve_target_config from sqlbuild.spec.contracts.main.resolve_target_name import resolve_target_name +from sqlbuild.spec.contracts.models import ExecutionLimitsConfig, TargetConfig from sqlbuild.virtual.executor.main.build import run_virtual_build as run_virtual_build_pipeline from sqlbuild.virtual.executor.models import ( VirtualBuildExecutionHooks, @@ -72,15 +73,20 @@ def execute_virtual_build( local_config=discovered_inputs.local_config, selected_target=request.selected_target, ) - unsuffixed_virtual_environment_name: str | None = ( + physical_target_config: TargetConfig | None = ( resolve_target_config( project_config=discovered_inputs.project_config, local_config=discovered_inputs.local_config, target_name=physical_target_name, - ).state.unsuffixed_virtual_env + ) if physical_target_name is not None else None ) + unsuffixed_virtual_environment_name: str | None = ( + physical_target_config.state.unsuffixed_virtual_env + if physical_target_config is not None + else None + ) plan_hook: VirtualBuildPlanHook = VirtualBuildPlanHook( stream=stream, project_dir=project_dir, @@ -102,6 +108,12 @@ def execute_virtual_build( request.virtual_environment_name or physical_target_name or "default" ), unsuffixed_virtual_environment_name=unsuffixed_virtual_environment_name, + effective_target_name=physical_target_name, + execution_limits=( + physical_target_config.execution_limits + if physical_target_config is not None + else ExecutionLimitsConfig() + ), ), ) cursor_overrides: CursorOverrides = request.cursor_overrides or CursorOverrides() diff --git a/src/sqlbuild/cli/commands/_helpers/entry/dispatch_errors.py b/src/sqlbuild/cli/commands/_helpers/entry/dispatch_errors.py index 3e6b485ea2..605ae7a30e 100644 --- a/src/sqlbuild/cli/commands/_helpers/entry/dispatch_errors.py +++ b/src/sqlbuild/cli/commands/_helpers/entry/dispatch_errors.py @@ -21,6 +21,7 @@ from sqlbuild.executor.pipeline.exceptions import AuditExecutionError from sqlbuild.lint.exceptions import LintError from sqlbuild.rule_engine.exceptions import RulesError +from sqlbuild.runtime.execution_limits.exceptions import ExecutionDurationLimitError from sqlbuild.spec.contracts.exceptions import SpecConfigError from sqlbuild.virtual.state.exceptions import StateBackendError @@ -68,6 +69,7 @@ def dispatch_and_handle_errors( except ( AuditExecutionError, DiscoveryError, + ExecutionDurationLimitError, SpecConfigError, StateBackendError, ValueError, diff --git a/src/sqlbuild/cli/commands/classes/virtual_build_plan_hook.py b/src/sqlbuild/cli/commands/classes/virtual_build_plan_hook.py index 052f757237..557f857af6 100644 --- a/src/sqlbuild/cli/commands/classes/virtual_build_plan_hook.py +++ b/src/sqlbuild/cli/commands/classes/virtual_build_plan_hook.py @@ -10,6 +10,10 @@ from sqlbuild.cli.commands._helpers.build_execution.run_context import ( write_build_run_context, ) +from sqlbuild.cli.commands._helpers.build_planning.execution_limits import ( + enforce_model_execution_limit, + executable_model_count, +) from sqlbuild.cli.commands._helpers.build_planning.full_refresh import ( enforce_snapshot_full_refresh_policy, ) @@ -58,6 +62,8 @@ def __init__( self._selector_files = config.selector_files self._virtual_environment_name = config.virtual_environment_name or "default" self._unsuffixed_virtual_environment_name = config.unsuffixed_virtual_environment_name + self._effective_target_name = config.effective_target_name + self._execution_limits = config.execution_limits self.callbacks: BuildProgressCallbacks | None = None self._closed: bool = False @@ -85,6 +91,11 @@ def on_plan_ready( ) self._stream.write("\n" + plan_text + "\n\n") self._stream.flush() + enforce_model_execution_limit( + model_count=executable_model_count(plan=plan_output), + target_name=self._effective_target_name, + limits=self._execution_limits, + ) enforce_snapshot_full_refresh_policy( plan=plan_output, snapshots_config=self._discovered_inputs.project_config.snapshots, diff --git a/src/sqlbuild/cli/commands/main/execution/_build.py b/src/sqlbuild/cli/commands/main/execution/_build.py index c7cd8e1e95..9b552e796e 100644 --- a/src/sqlbuild/cli/commands/main/execution/_build.py +++ b/src/sqlbuild/cli/commands/main/execution/_build.py @@ -25,6 +25,10 @@ from sqlbuild.cli.commands._helpers.build_planning.defer_clone import ( run_defer_clone_boundary_prephase, ) +from sqlbuild.cli.commands._helpers.build_planning.execution_limits import ( + enforce_model_execution_limit, + executable_model_count, +) from sqlbuild.cli.commands._helpers.build_planning.full_refresh import ( enforce_snapshot_full_refresh_policy, ) @@ -52,6 +56,10 @@ from sqlbuild.diagnostics.main.process_resource_reporting import process_resource_reporting from sqlbuild.executor.python_nodes.models import PythonCheckExecutionResult from sqlbuild.provider.main.session import build_provider_session +from sqlbuild.runtime.execution_limits.main.execution_deadline_scope import ( + execution_deadline_scope, +) +from sqlbuild.spec.contracts.models import ExecutionLimitsConfig def run_build(request: BuildCommandRequest) -> int: @@ -60,7 +68,20 @@ def run_build(request: BuildCommandRequest) -> int: timing_tracker: BuildPhaseTimingTracker = BuildPhaseTimingTracker() with process_resource_reporting(enabled=request.debug), timing_tracker.scope(): try: - return _run_build(request=request) + command_started_at: float = time.monotonic() + invocation: BuildInvocation = resolve_build_invocation(request=request) + limits: ExecutionLimitsConfig = invocation.execution_limits + with execution_deadline_scope( + max_duration_seconds=limits.max_duration_seconds, + max_duration=limits.max_duration, + target_name=invocation.effective_target_name, + remediation=limits.remediation, + ): + return _run_build( + request=request, + invocation=invocation, + command_started_at=command_started_at, + ) except BaseException: if request.verbose or request.debug: try: @@ -74,9 +95,9 @@ def run_build(request: BuildCommandRequest) -> int: raise -def _run_build(*, request: BuildCommandRequest) -> int: - command_started_at: float = time.monotonic() - invocation: BuildInvocation = resolve_build_invocation(request=request) +def _run_build( + *, request: BuildCommandRequest, invocation: BuildInvocation, command_started_at: float +) -> int: provider_session: Any = build_provider_session( discovered_providers=invocation.discovered_inputs.providers ) @@ -138,6 +159,11 @@ def _run_build(*, request: BuildCommandRequest) -> int: invocation=invocation, pipeline_result=pipeline_result, ) + enforce_model_execution_limit( + model_count=executable_model_count(plan=pipeline_result.plan_output), + target_name=invocation.effective_target_name, + limits=invocation.execution_limits, + ) enforce_snapshot_full_refresh_policy( plan=pipeline_result.plan_output, snapshots_config=invocation.discovered_inputs.project_config.snapshots, diff --git a/src/sqlbuild/cli/commands/models.py b/src/sqlbuild/cli/commands/models.py index 5187fe087b..9b660a6ea7 100644 --- a/src/sqlbuild/cli/commands/models.py +++ b/src/sqlbuild/cli/commands/models.py @@ -73,7 +73,7 @@ from sqlbuild.provider.main.runtime import ProviderContainer from sqlbuild.python_nodes.models import SqlResourceRef from sqlbuild.runtime.contracts.types import NodeStartCallback -from sqlbuild.spec.contracts.models import CostConfig, SourceEntry +from sqlbuild.spec.contracts.models import CostConfig, ExecutionLimitsConfig, SourceEntry from sqlbuild.virtual.executor.models import VirtualBuildPipelineResult from sqlbuild.virtual.state.models import ( CheckpointRetentionInspection, @@ -255,6 +255,8 @@ class BuildInvocation: planning_progress: PlanningProgressReporter should_load_sources: bool virtual_mode: bool + effective_target_name: str | None = None + execution_limits: ExecutionLimitsConfig = field(default_factory=ExecutionLimitsConfig) @dataclass(frozen=True) @@ -344,6 +346,8 @@ class VirtualBuildPlanHookConfig: selector_files: tuple[SelectorFileSummary, ...] = () virtual_environment_name: str | None = None unsuffixed_virtual_environment_name: str | None = None + effective_target_name: str | None = None + execution_limits: ExecutionLimitsConfig = field(default_factory=ExecutionLimitsConfig) @dataclass(frozen=True) diff --git a/src/sqlbuild/compiler/discovery/_helpers/yml/project.py b/src/sqlbuild/compiler/discovery/_helpers/yml/project.py index 45f144c496..8f724df335 100644 --- a/src/sqlbuild/compiler/discovery/_helpers/yml/project.py +++ b/src/sqlbuild/compiler/discovery/_helpers/yml/project.py @@ -49,6 +49,7 @@ DbtConfig, DefaultsConfig, DiffConfig, + ExecutionLimitsConfig, FutureCursorsConfig, JanitorConfig, LifecycleEventSinkFilterConfig, @@ -1018,6 +1019,11 @@ def _load_targets(*, payload: object, file_path: Path) -> dict[str, TargetConfig _validate_state_keys( state_mapping=state_mapping, target_name=target_name, file_path=file_path ) + execution_limits: ExecutionLimitsConfig = _load_execution_limits( + payload=target_mapping.get("execution_limits"), + label=f"targets.{target_name}.execution_limits", + file_path=file_path, + ) connection, connection_name = _load_target_connection( target_mapping=target_mapping, target_name=target_name, file_path=file_path ) @@ -1058,6 +1064,7 @@ def _load_targets(*, payload: object, file_path: Path) -> dict[str, TargetConfig ) or TableTypeDowngradePolicy.REQUIRE_CONFIRMATION ), + execution_limits=execution_limits, clone=ClonePolicy( allow_as_clone_origin=_optional_bool( mapping=clone_mapping, @@ -1120,6 +1127,11 @@ def _load_local_targets(*, payload: object, file_path: Path) -> dict[str, LocalT _validate_state_keys( state_mapping=state_mapping, target_name=target_name, file_path=file_path ) + execution_limits: ExecutionLimitsConfig = _load_execution_limits( + payload=target_mapping.get("execution_limits"), + label=f"targets.{target_name}.execution_limits", + file_path=file_path, + ) connection, connection_name = _load_target_connection( target_mapping=target_mapping, target_name=target_name, file_path=file_path ) @@ -1156,6 +1168,7 @@ def _load_local_targets(*, payload: object, file_path: Path) -> dict[str, LocalT label=f"targets.{target_name}.table_type_downgrade", file_path=file_path, ), + execution_limits=execution_limits, clone=LocalClonePolicy( allow_as_clone_origin=_optional_nullable_bool( mapping=clone_mapping, @@ -1234,6 +1247,7 @@ def _validate_target_keys( "table_type_downgrade", "clone", "state", + "execution_limits", } ), label=f"targets.{target_name}", @@ -1241,6 +1255,40 @@ def _validate_target_keys( ) +def _load_execution_limits( + *, payload: object, label: str, file_path: Path +) -> ExecutionLimitsConfig: + mapping: dict[str, object] = _coerce_mapping( + payload=payload, + label=label, + file_path=file_path, + ) + _validate_allowed_keys( + mapping=mapping, + allowed_keys=frozenset({"max_models", "max_duration", "remediation"}), + label=label, + file_path=file_path, + ) + max_models: int | None = _optional_strict_int(mapping=mapping, key="max_models") + if max_models is not None and max_models < 1: + raise ProjectConfigError(f"{file_path} {label}.max_models must be >= 1") + max_duration: str | None = _optional_str(payload=mapping, key="max_duration") + max_duration_seconds: int | None = None + if max_duration is not None: + duration: Duration | None = Duration.parse(max_duration) + if duration is None or duration.has_calendar_component or duration.fixed_seconds <= 0: + raise ProjectConfigError( + f"{file_path} {label}.max_duration must be a positive fixed duration such as '30m'" + ) + max_duration_seconds = duration.fixed_seconds + return ExecutionLimitsConfig( + max_models=max_models, + max_duration=max_duration, + max_duration_seconds=max_duration_seconds, + remediation=_optional_str(payload=mapping, key="remediation"), + ) + + def _validate_clone_keys( *, clone_mapping: dict[str, object], target_name: str, file_path: Path ) -> None: diff --git a/src/sqlbuild/executor/build/classes/build_scheduler.py b/src/sqlbuild/executor/build/classes/build_scheduler.py index 730fa7b50b..eb74f7d97c 100644 --- a/src/sqlbuild/executor/build/classes/build_scheduler.py +++ b/src/sqlbuild/executor/build/classes/build_scheduler.py @@ -106,6 +106,9 @@ from sqlbuild.microbatches.types import MicrobatchEventStore from sqlbuild.provider.main.runtime import ProviderContainer from sqlbuild.runtime.contracts.types import ExecutionResourceKind, NodeStartCallback +from sqlbuild.runtime.execution_limits.main.enforce_execution_deadline import ( + enforce_execution_deadline, +) from sqlbuild.runtime.observability.classes.resource_attempt_lifecycle import ( ResourceAttemptLifecycle, ) @@ -262,6 +265,7 @@ def run( else: self._run_concurrent() + enforce_execution_deadline() self._skip_remaining() end_audit_results: tuple[AuditExecutionResult, ...] = () @@ -357,6 +361,7 @@ def _run_serial(self) -> None: connection: Any = self._connection_pool.get() try: while self._ready: + enforce_execution_deadline() if self._stop: break key: CompiledObjectKey = self._ready.popleft() @@ -392,6 +397,7 @@ def _run_concurrent(self) -> None: with ThreadPoolExecutor(max_workers=self._max_concurrency) as pool: self._report_scheduler_state() while self._ready or self._in_flight: + enforce_execution_deadline() if self._stop and not self._in_flight: break @@ -670,6 +676,7 @@ def _cancel_microbatch_subworker(self, *, future: Future[MicrobatchPhaseOutcome] def _pre_dispatch(self, key: CompiledObjectKey) -> bool: """Run pre-dispatch checks. Returns False if the node should be skipped.""" + enforce_execution_deadline() if key.resource_type == CompiledResourceType.SQL_TEST: if not self._run_tests: return False diff --git a/src/sqlbuild/executor/pipeline/_helpers/connections.py b/src/sqlbuild/executor/pipeline/_helpers/connections.py index 9aa94da18f..90cc7ae2b2 100644 --- a/src/sqlbuild/executor/pipeline/_helpers/connections.py +++ b/src/sqlbuild/executor/pipeline/_helpers/connections.py @@ -5,6 +5,7 @@ import logging import time from concurrent.futures import Future, ThreadPoolExecutor +from contextvars import copy_context from typing import Any from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter @@ -27,7 +28,8 @@ def open_worker_connections( first_error: BaseException | None = None with ThreadPoolExecutor(max_workers=connection_count) as executor: futures: tuple[Future[Any], ...] = tuple( - executor.submit(adapter.connect, connection_config) for _ in range(connection_count) + executor.submit(copy_context().run, adapter.connect, connection_config) + for _ in range(connection_count) ) future: Future[Any] for future in futures: diff --git a/src/sqlbuild/runtime/execution_limits/__init__.py b/src/sqlbuild/runtime/execution_limits/__init__.py new file mode 100644 index 0000000000..e55e68b39d --- /dev/null +++ b/src/sqlbuild/runtime/execution_limits/__init__.py @@ -0,0 +1 @@ +"""Runtime execution-limit enforcement.""" diff --git a/src/sqlbuild/runtime/execution_limits/_helpers/__init__.py b/src/sqlbuild/runtime/execution_limits/_helpers/__init__.py new file mode 100644 index 0000000000..74bfa31cda --- /dev/null +++ b/src/sqlbuild/runtime/execution_limits/_helpers/__init__.py @@ -0,0 +1 @@ +"""Execution-limit implementation helpers.""" diff --git a/src/sqlbuild/runtime/execution_limits/_helpers/context.py b/src/sqlbuild/runtime/execution_limits/_helpers/context.py new file mode 100644 index 0000000000..31f32b4e13 --- /dev/null +++ b/src/sqlbuild/runtime/execution_limits/_helpers/context.py @@ -0,0 +1,10 @@ +"""Private execution deadline context storage.""" + +from contextvars import ContextVar + +from sqlbuild.runtime.execution_limits.models import ExecutionDeadline + +current_deadline_context: ContextVar[ExecutionDeadline | None] = ContextVar( + "sqlbuild_execution_deadline", + default=None, +) diff --git a/src/sqlbuild/runtime/execution_limits/exceptions.py b/src/sqlbuild/runtime/execution_limits/exceptions.py new file mode 100644 index 0000000000..adc141e991 --- /dev/null +++ b/src/sqlbuild/runtime/execution_limits/exceptions.py @@ -0,0 +1,27 @@ +"""Runtime execution-limit exceptions.""" + + +class ExecutionDurationLimitError(Exception): + """Raised when a build exceeds its configured maximum duration.""" + + code: str = "C414" + + def __init__( + self, + *, + target_name: str | None, + max_duration: str, + remediation: str | None, + ) -> None: + target_label: str = target_name or "default" + self.message = ( + "Build exceeded the configured execution duration\n\n" + f"Target: {target_label}\n" + f"Maximum duration: {max_duration}\n" + "Configuration: " + f"targets.{target_label}.execution_limits.max_duration\n\n" + "Warehouse changes may have occurred before the deadline.\n" + "No additional work will be started, and active warehouse statements were cancelled." + ) + self.help = remediation + super().__init__(self.message) diff --git a/src/sqlbuild/runtime/execution_limits/main/__init__.py b/src/sqlbuild/runtime/execution_limits/main/__init__.py new file mode 100644 index 0000000000..dbbcfdec39 --- /dev/null +++ b/src/sqlbuild/runtime/execution_limits/main/__init__.py @@ -0,0 +1 @@ +"""Execution-limit runtime operations.""" diff --git a/src/sqlbuild/runtime/execution_limits/main/current_execution_deadline.py b/src/sqlbuild/runtime/execution_limits/main/current_execution_deadline.py new file mode 100644 index 0000000000..f7d86929aa --- /dev/null +++ b/src/sqlbuild/runtime/execution_limits/main/current_execution_deadline.py @@ -0,0 +1,10 @@ +"""Current execution deadline lookup.""" + +from sqlbuild.runtime.execution_limits._helpers.context import current_deadline_context +from sqlbuild.runtime.execution_limits.models import ExecutionDeadline + + +def current_execution_deadline() -> ExecutionDeadline | None: + """Return the active build deadline, when configured.""" + + return current_deadline_context.get() diff --git a/src/sqlbuild/runtime/execution_limits/main/enforce_execution_deadline.py b/src/sqlbuild/runtime/execution_limits/main/enforce_execution_deadline.py new file mode 100644 index 0000000000..091aa06edc --- /dev/null +++ b/src/sqlbuild/runtime/execution_limits/main/enforce_execution_deadline.py @@ -0,0 +1,12 @@ +"""Current execution deadline enforcement.""" + +from sqlbuild.runtime.execution_limits._helpers.context import current_deadline_context +from sqlbuild.runtime.execution_limits.models import ExecutionDeadline + + +def enforce_execution_deadline() -> None: + """Raise when the active build deadline has expired.""" + + deadline: ExecutionDeadline | None = current_deadline_context.get() + if deadline is not None: + deadline.enforce() diff --git a/src/sqlbuild/runtime/execution_limits/main/execution_deadline_scope.py b/src/sqlbuild/runtime/execution_limits/main/execution_deadline_scope.py new file mode 100644 index 0000000000..2e83f96b03 --- /dev/null +++ b/src/sqlbuild/runtime/execution_limits/main/execution_deadline_scope.py @@ -0,0 +1,47 @@ +"""Run-scoped execution deadline context.""" + +from __future__ import annotations + +import time +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import Token + +from sqlbuild.runtime.execution_limits._helpers.context import current_deadline_context +from sqlbuild.runtime.execution_limits.exceptions import ExecutionDurationLimitError +from sqlbuild.runtime.execution_limits.models import ExecutionDeadline + + +@contextmanager +def execution_deadline_scope( + *, + max_duration_seconds: int | None, + max_duration: str | None, + target_name: str | None, + remediation: str | None, +) -> Generator[None]: + """Apply one optional deadline to every statement and scheduler phase in a build.""" + + if max_duration_seconds is None or max_duration is None: + yield + return + deadline: ExecutionDeadline = ExecutionDeadline( + deadline=time.monotonic() + max_duration_seconds, + target_name=target_name, + max_duration=max_duration, + remediation=remediation, + ) + token: Token[ExecutionDeadline | None] = current_deadline_context.set(deadline) + try: + try: + yield + except Exception as error: + try: + deadline.enforce() + except ExecutionDurationLimitError as deadline_error: + raise deadline_error from error + raise + else: + deadline.enforce() + finally: + current_deadline_context.reset(token) diff --git a/src/sqlbuild/runtime/execution_limits/models.py b/src/sqlbuild/runtime/execution_limits/models.py new file mode 100644 index 0000000000..cf85018d84 --- /dev/null +++ b/src/sqlbuild/runtime/execution_limits/models.py @@ -0,0 +1,31 @@ +"""Runtime execution-limit models.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass + +from sqlbuild.runtime.execution_limits.exceptions import ExecutionDurationLimitError + + +@dataclass(frozen=True) +class ExecutionDeadline: + """One monotonic build deadline and its user-facing policy context.""" + + deadline: float + target_name: str | None + max_duration: str + remediation: str | None = None + + @property + def remaining_seconds(self) -> float: + return max(0.0, self.deadline - time.monotonic()) + + def enforce(self) -> None: + if self.remaining_seconds > 0: + return + raise ExecutionDurationLimitError( + target_name=self.target_name, + max_duration=self.max_duration, + remediation=self.remediation, + ) diff --git a/src/sqlbuild/spec/contracts/_helpers/targets.py b/src/sqlbuild/spec/contracts/_helpers/targets.py index 8e959c2c7d..28d8f58468 100644 --- a/src/sqlbuild/spec/contracts/_helpers/targets.py +++ b/src/sqlbuild/spec/contracts/_helpers/targets.py @@ -6,6 +6,7 @@ from sqlbuild.spec.contracts.exceptions import SpecConfigError from sqlbuild.spec.contracts.models import ( ClonePolicy, + ExecutionLimitsConfig, LocalClonePolicy, LocalConfig, LocalStateConfig, @@ -105,6 +106,10 @@ def resolve_target_config( if local_target.table_type_downgrade is not None else project_target.table_type_downgrade ), + execution_limits=_merge_execution_limits( + project_limits=project_target.execution_limits, + local_limits=local_target.execution_limits, + ), clone=_merge_clone_policy( project_clone=project_target.clone, local_clone=local_target.clone, @@ -117,6 +122,33 @@ def resolve_target_config( return target_config +def _merge_execution_limits( + *, project_limits: ExecutionLimitsConfig, local_limits: ExecutionLimitsConfig +) -> ExecutionLimitsConfig: + return ExecutionLimitsConfig( + max_models=( + local_limits.max_models + if local_limits.max_models is not None + else project_limits.max_models + ), + max_duration=( + local_limits.max_duration + if local_limits.max_duration is not None + else project_limits.max_duration + ), + max_duration_seconds=( + local_limits.max_duration_seconds + if local_limits.max_duration is not None + else project_limits.max_duration_seconds + ), + remediation=( + local_limits.remediation + if local_limits.remediation is not None + else project_limits.remediation + ), + ) + + def resolve_effective_changes_only( *, project_config: ProjectConfig, diff --git a/src/sqlbuild/spec/contracts/models.py b/src/sqlbuild/spec/contracts/models.py index af8ec6e662..855b357027 100644 --- a/src/sqlbuild/spec/contracts/models.py +++ b/src/sqlbuild/spec/contracts/models.py @@ -101,6 +101,16 @@ class StateConfig: unsuffixed_virtual_env: str | None = None +@dataclass(frozen=True) +class ExecutionLimitsConfig: + """Optional target-scoped build execution limits.""" + + max_models: int | None = None + max_duration: str | None = None + max_duration_seconds: int | None = None + remediation: str | None = None + + @dataclass(frozen=True) class LocalStateConfig: """Local virtual environment mode state store overrides.""" @@ -132,6 +142,7 @@ class TargetConfig: owns_time_travel_retention_namespace: bool = False default_table_type: TableType | None = None table_type_downgrade: TableTypeDowngradePolicy = TableTypeDowngradePolicy.REQUIRE_CONFIRMATION + execution_limits: ExecutionLimitsConfig = field(default_factory=ExecutionLimitsConfig) @dataclass(frozen=True) @@ -154,6 +165,7 @@ class LocalTargetConfig: owns_time_travel_retention_namespace: bool | None = None default_table_type: TableType | None = None table_type_downgrade: TableTypeDowngradePolicy | None = None + execution_limits: ExecutionLimitsConfig = field(default_factory=ExecutionLimitsConfig) @dataclass(frozen=True) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py index 2c0ff1aba7..4ea7cd4ccd 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py @@ -12,6 +12,13 @@ class AuditFactoryFailureBuildE2ETestCase: expected_rows: tuple[tuple[int, int], ...] +@dataclass(frozen=True) +class ExecutionLimitBuildE2ETestCase: + description: str + expected_exit_code: int + expected_tables: bool + + @dataclass(frozen=True) class MeasurementAuditCliE2ETestCase: description: str diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/build/test_execution_limits.py b/tests/e2e/src/sqlbuild/cli/commands/main/build/test_execution_limits.py new file mode 100644 index 0000000000..1889731764 --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/build/test_execution_limits.py @@ -0,0 +1,130 @@ +"""E2E coverage for target-scoped build execution limits.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.build._test_types import ( + ExecutionLimitBuildE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import ( + prepare_inline_project, + run_sqb, + table_exists, +) + + +@pytest.mark.parametrize( + "test_case", + [ + ExecutionLimitBuildE2ETestCase( + description="project limit blocks expanded build before mutation", + expected_exit_code=1, + expected_tables=False, + ) + ], + ids=lambda case: case.description, +) +def test_given_target_model_limit_when_building_then_effective_policy_controls_execution( + tmp_path: Path, + test_case: ExecutionLimitBuildE2ETestCase, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="execution_limit_build", + repo_files={ + "sqlbuild_project.toml": """ +name = "execution_limit_build" +adapter = "duckdb" +default_target = "dev" + +[connection] +database = "warehouse.duckdb" + +[targets.dev] +schema = "main" + +[targets.dev.execution_limits] +max_models = 1 +remediation = "Automated tools must request approval before changing this policy." +""".strip(), + "models/orders.sql": "MODEL (materialized table);\n\nSELECT 1 AS id\n", + "models/order_items.sql": "MODEL (materialized table);\n\nSELECT 1 AS id\n", + }, + ) + + result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "build"), + project_dir=project_dir, + ) + output: str = result.stdout + result.stderr + + assert result.returncode == test_case.expected_exit_code, output + assert "C413" in output + assert "Selected models: 2" in output + assert "Maximum models: 1" in output + assert "No warehouse changes were made." in output + assert "Automated tools must request approval before changing this policy." in output + database_path: Path = project_dir / "warehouse.duckdb" + assert table_exists(db_path=database_path, table_name="orders") is test_case.expected_tables + assert ( + table_exists(db_path=database_path, table_name="order_items") is test_case.expected_tables + ) + + +@pytest.mark.parametrize( + "test_case", + [ + ExecutionLimitBuildE2ETestCase( + description="local limit override permits intentional expanded build", + expected_exit_code=0, + expected_tables=True, + ) + ], + ids=lambda case: case.description, +) +def test_given_local_model_limit_override_when_building_then_local_policy_is_applied( + tmp_path: Path, + test_case: ExecutionLimitBuildE2ETestCase, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="local_execution_limit_build", + repo_files={ + "sqlbuild_project.toml": """ +name = "local_execution_limit_build" +adapter = "duckdb" +default_target = "dev" + +[connection] +database = "warehouse.duckdb" + +[targets.dev] +schema = "main" + +[targets.dev.execution_limits] +max_models = 1 +""".strip(), + "sqlbuild_local.toml": """ +[targets.dev.execution_limits] +max_models = 2 +""".strip(), + "models/orders.sql": "MODEL (materialized table);\n\nSELECT 1 AS id\n", + "models/order_items.sql": "MODEL (materialized table);\n\nSELECT 1 AS id\n", + }, + ) + + result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "build"), + project_dir=project_dir, + ) + + assert result.returncode == test_case.expected_exit_code, result.stdout + result.stderr + database_path: Path = project_dir / "warehouse.duckdb" + assert table_exists(db_path=database_path, table_name="orders") is test_case.expected_tables + assert ( + table_exists(db_path=database_path, table_name="order_items") is test_case.expected_tables + ) diff --git a/tests/unit/src/sqlbuild/adapters/snowflake/classes/snowflake_connection/_test_types.py b/tests/unit/src/sqlbuild/adapters/snowflake/classes/snowflake_connection/_test_types.py index 8ef485cc1e..c41ed60bfc 100644 --- a/tests/unit/src/sqlbuild/adapters/snowflake/classes/snowflake_connection/_test_types.py +++ b/tests/unit/src/sqlbuild/adapters/snowflake/classes/snowflake_connection/_test_types.py @@ -53,3 +53,14 @@ class StatementDiagnosticsTestCase: expected_resource_type: str expected_resource_name: str expected_phase: str + + +@dataclass(frozen=True) +class ExecutionDeadlineTestCase: + description: str + max_duration_seconds: int + max_duration: str + remediation: str | None = None + expected_maximum_timeout_seconds: int | None = None + expected_cursor_calls: int = 1 + expected_error_code: str | None = None diff --git a/tests/unit/src/sqlbuild/adapters/snowflake/classes/snowflake_connection/test_snowflake_connection.py b/tests/unit/src/sqlbuild/adapters/snowflake/classes/snowflake_connection/test_snowflake_connection.py index 4d1f23360e..39ce775b47 100644 --- a/tests/unit/src/sqlbuild/adapters/snowflake/classes/snowflake_connection/test_snowflake_connection.py +++ b/tests/unit/src/sqlbuild/adapters/snowflake/classes/snowflake_connection/test_snowflake_connection.py @@ -19,11 +19,16 @@ dispatcher_scope, invocation_scope, ) +from sqlbuild.runtime.execution_limits.exceptions import ExecutionDurationLimitError +from sqlbuild.runtime.execution_limits.main.execution_deadline_scope import ( + execution_deadline_scope, +) from tests.unit.src.sqlbuild.adapters.snowflake.classes.snowflake_connection._test_types import ( CursorAttributeTestCase, CursorContextManagerTestCase, CursorIterationTestCase, CursorReturnTestCase, + ExecutionDeadlineTestCase, QueryTagPolicyTestCase, SnowflakeConnectionTestCase, StatementDiagnosticsTestCase, @@ -130,6 +135,72 @@ def execute(self, sql: str, *, _statement_params: dict[str, str] | None = None) raise RuntimeError("submitted QUERY_TAG statement failed") +@pytest.mark.parametrize( + "test_case", + [ + ExecutionDeadlineTestCase( + description="active deadline adds Snowflake statement timeout", + max_duration_seconds=30, + max_duration="30s", + expected_maximum_timeout_seconds=30, + ) + ], + ids=lambda case: case.description, +) +def test_given_execution_deadline_when_running_snowflake_statement_then_timeout_is_submitted( + test_case: ExecutionDeadlineTestCase, +) -> None: + cursor: _Cursor = _Cursor() + connection: _SnowflakeConnection = _SnowflakeConnection(_RawConnection(cursor)) + + with execution_deadline_scope( + max_duration_seconds=test_case.max_duration_seconds, + max_duration=test_case.max_duration, + target_name="dev", + remediation=None, + ): + connection.execute("SELECT 1") + + assert cursor.statement_params is not None + timeout_seconds: int = int(cursor.statement_params["STATEMENT_TIMEOUT_IN_SECONDS"]) + assert test_case.expected_maximum_timeout_seconds is not None + assert 1 <= timeout_seconds <= test_case.expected_maximum_timeout_seconds + + +@pytest.mark.parametrize( + "test_case", + [ + ExecutionDeadlineTestCase( + description="expired deadline blocks Snowflake statement submission", + max_duration_seconds=0, + max_duration="1s", + remediation="Request approval before extending the deadline.", + expected_cursor_calls=0, + expected_error_code="C414", + ) + ], + ids=lambda case: case.description, +) +def test_given_expired_execution_deadline_when_running_statement_then_query_is_not_submitted( + test_case: ExecutionDeadlineTestCase, +) -> None: + cursor: _Cursor = _Cursor() + connection: _SnowflakeConnection = _SnowflakeConnection(_RawConnection(cursor)) + + with pytest.raises(ExecutionDurationLimitError) as raised: + with execution_deadline_scope( + max_duration_seconds=test_case.max_duration_seconds, + max_duration=test_case.max_duration, + target_name="dev", + remediation=test_case.remediation, + ): + connection.execute("SELECT 1") + + assert cursor.calls == test_case.expected_cursor_calls + assert raised.value.code == test_case.expected_error_code + assert raised.value.help == test_case.remediation + + @pytest.mark.parametrize( "test_case", ( diff --git a/tests/unit/src/sqlbuild/cli/commands/_helpers/build_planning/_test_types.py b/tests/unit/src/sqlbuild/cli/commands/_helpers/build_planning/_test_types.py index 34a43c2bdb..59e15dc1d8 100644 --- a/tests/unit/src/sqlbuild/cli/commands/_helpers/build_planning/_test_types.py +++ b/tests/unit/src/sqlbuild/cli/commands/_helpers/build_planning/_test_types.py @@ -27,3 +27,21 @@ class TableTypeDowngradePolicyTestCase: expected_output: str = "" input_text: str = "" input_is_tty: bool = False + + +@dataclass(frozen=True) +class ModelExecutionLimitTestCase: + description: str + model_count: int + maximum_models: int + remediation: str | None = None + expected_code: str | None = None + + +@dataclass(frozen=True) +class UnsupportedDurationLimitTestCase: + description: str + max_duration: str + remediation: str + expected_code: str + expected_error_fragment: str diff --git a/tests/unit/src/sqlbuild/cli/commands/_helpers/build_planning/test_execution_limits.py b/tests/unit/src/sqlbuild/cli/commands/_helpers/build_planning/test_execution_limits.py new file mode 100644 index 0000000000..d6080c2b89 --- /dev/null +++ b/tests/unit/src/sqlbuild/cli/commands/_helpers/build_planning/test_execution_limits.py @@ -0,0 +1,154 @@ +from pathlib import Path + +import pytest + +from sqlbuild.cli.commands._helpers.build_planning.execution_limits import ( + enforce_model_execution_limit, +) +from sqlbuild.cli.commands._helpers.build_planning.invocation import resolve_build_invocation +from sqlbuild.cli.commands.exceptions import CliUserError +from sqlbuild.cli.commands.models import BuildCommandRequest +from sqlbuild.spec.contracts.models import ExecutionLimitsConfig +from tests.unit.src.sqlbuild.cli.commands._helpers.build_planning._test_types import ( + ModelExecutionLimitTestCase, + UnsupportedDurationLimitTestCase, +) + + +@pytest.mark.parametrize( + "test_case", + [ + ModelExecutionLimitTestCase( + description="model count equals configured limit", + model_count=25, + maximum_models=25, + expected_code=None, + ) + ], + ids=lambda case: case.description, +) +def test_given_model_count_within_limit_when_enforcing_then_build_is_allowed( + test_case: ModelExecutionLimitTestCase, +) -> None: + result: None = enforce_model_execution_limit( + model_count=test_case.model_count, + target_name="dev", + limits=ExecutionLimitsConfig(max_models=test_case.maximum_models), + ) + assert result is test_case.expected_code + + +@pytest.mark.parametrize( + "test_case", + [ + ModelExecutionLimitTestCase( + description="model count exceeds configured limit", + model_count=26, + maximum_models=25, + remediation=( + "Review the expanded selection and request approval before changing the policy." + ), + expected_code="C413", + ) + ], + ids=lambda case: case.description, +) +def test_given_model_count_above_limit_when_enforcing_then_error_has_custom_remediation( + test_case: ModelExecutionLimitTestCase, +) -> None: + + with pytest.raises(CliUserError) as raised: + enforce_model_execution_limit( + model_count=test_case.model_count, + target_name="dev", + limits=ExecutionLimitsConfig( + max_models=test_case.maximum_models, + remediation=test_case.remediation, + ), + ) + + assert raised.value.code == test_case.expected_code + assert raised.value.help == test_case.remediation + assert "Selected models: 26" in raised.value.message + assert "Maximum models: 25" in raised.value.message + assert "No warehouse changes were made." in raised.value.message + + +@pytest.mark.parametrize( + "test_case", + [ + UnsupportedDurationLimitTestCase( + description="DuckDB target cannot guarantee statement cancellation", + max_duration="30m", + remediation="Use an adapter with safe statement cancellation.", + expected_code="C415", + expected_error_fragment="cannot cancel active statements safely", + ) + ], + ids=lambda case: case.description, +) +def test_given_duration_limit_for_unsupported_adapter_when_resolving_then_configuration_is_rejected( + tmp_path: Path, + test_case: UnsupportedDurationLimitTestCase, +) -> None: + (tmp_path / "sqlbuild_project.toml").write_text( + """ +name = "shop" +adapter = "duckdb" +default_target = "dev" + +[targets.dev.execution_limits] +max_duration = "{test_case.max_duration}" +remediation = "{test_case.remediation}" +""".strip().format(test_case=test_case), + encoding="utf-8", + ) + + with pytest.raises(CliUserError) as raised: + resolve_build_invocation(request=BuildCommandRequest(project_dir=tmp_path)) + + assert raised.value.code == test_case.expected_code + assert test_case.expected_error_fragment in raised.value.message + assert raised.value.help == test_case.remediation + + +@pytest.mark.parametrize( + "test_case", + [ + UnsupportedDurationLimitTestCase( + description="Snowflake target rejects duration beyond statement timeout range", + max_duration="8d", + remediation="Choose a duration supported by the configured warehouse.", + expected_code="C416", + expected_error_fragment="max_duration exceeds the largest duration supported", + ) + ], + ids=lambda case: case.description, +) +def test_given_duration_above_adapter_maximum_when_resolving_then_configuration_is_rejected( + tmp_path: Path, + test_case: UnsupportedDurationLimitTestCase, +) -> None: + (tmp_path / "sqlbuild_project.toml").write_text( + """ +name = "shop" +adapter = "snowflake" +default_target = "dev" + +[targets.dev] +database = "analytics" +schema = "dev" + +[targets.dev.execution_limits] +max_duration = "{test_case.max_duration}" +remediation = "{test_case.remediation}" +""".strip().format(test_case=test_case), + encoding="utf-8", + ) + + with pytest.raises(CliUserError) as raised: + resolve_build_invocation(request=BuildCommandRequest(project_dir=tmp_path)) + + assert raised.value.code == test_case.expected_code + assert test_case.expected_error_fragment in raised.value.message + assert raised.value.help == test_case.remediation diff --git a/tests/unit/src/sqlbuild/cli/commands/main/build/_test_types.py b/tests/unit/src/sqlbuild/cli/commands/main/build/_test_types.py index 4901015ed8..5f7f39c3e8 100644 --- a/tests/unit/src/sqlbuild/cli/commands/main/build/_test_types.py +++ b/tests/unit/src/sqlbuild/cli/commands/main/build/_test_types.py @@ -18,3 +18,10 @@ class FunctionDeferCloneBoundaryTestCase: description: str expected_boundary_selectors: tuple[str, ...] expected_view_chain_selectors: tuple[str, ...] + + +@dataclass(frozen=True) +class DeferCloneModelCountTestCase: + description: str + enabled_by_name: dict[str, bool] + expected_model_count: int diff --git a/tests/unit/src/sqlbuild/cli/commands/main/build/test_defer_clone.py b/tests/unit/src/sqlbuild/cli/commands/main/build/test_defer_clone.py index 986f2b7db3..d14691367d 100644 --- a/tests/unit/src/sqlbuild/cli/commands/main/build/test_defer_clone.py +++ b/tests/unit/src/sqlbuild/cli/commands/main/build/test_defer_clone.py @@ -1,16 +1,25 @@ from __future__ import annotations +from pathlib import Path + import pytest from sqlbuild.cli.commands._helpers.build_planning.defer_clone import ( defer_clone_boundary_selectors, defer_clone_view_chain_selectors, + selected_executable_model_count, +) +from sqlbuild.compiler.compile.models import ( + CompiledModel, + CompiledObjectKey, + CompiledRelationLocation, + CompileModelConfig, ) -from sqlbuild.compiler.compile.models import CompiledObjectKey from sqlbuild.compiler.compile.types import CompiledResourceType from sqlbuild.compiler.planner.models import PlannerScope from tests.unit.src.sqlbuild.cli.commands.main.build._test_types import ( DeferCloneBoundaryTestCase, + DeferCloneModelCountTestCase, FunctionDeferCloneBoundaryTestCase, ) from tests.unit.src.sqlbuild.cli.commands.main.build.helpers import ( @@ -103,3 +112,52 @@ def test_given_function_between_model_and_seed_when_resolving_then_recreates_fun assert defer_clone_boundary_selectors(scope=scope) == test_case.expected_boundary_selectors assert defer_clone_view_chain_selectors(scope=scope) == test_case.expected_view_chain_selectors + + +@pytest.mark.parametrize( + "test_case", + [ + DeferCloneModelCountTestCase( + description="disabled selected model is excluded from preflight count", + enabled_by_name={"a": True, "b": False}, + expected_model_count=1, + ) + ], + ids=lambda case: case.description, +) +def test_given_disabled_model_in_defer_clone_scope_when_counting_then_only_enabled_models_count( + test_case: DeferCloneModelCountTestCase, +) -> None: + keys_by_name: dict[str, CompiledObjectKey] = { + "a": MODEL_A, + "b": MODEL_B, + } + models_by_name: dict[str, CompiledModel] = { + name: CompiledModel( + key=keys_by_name[name], + deps=(), + name=name, + relative_path=Path(f"models/{name}.sql"), + query_sql="SELECT 1 AS id", + config=CompileModelConfig(values={"enabled": enabled}), + destination=CompiledRelationLocation( + database=None, + schema="main", + name=name, + qualified_name=f"main.{name}", + ), + ) + for name, enabled in test_case.enabled_by_name.items() + } + scope: PlannerScope = PlannerScope( + selected_keys=frozenset(keys_by_name.values()), + upstream_deps={}, + downstream_deps={}, + all_keys=keys_by_name, + models_by_name=models_by_name, + execution_order=tuple(keys_by_name.values()), + ) + + result: int = selected_executable_model_count(scope=scope) + + assert result == test_case.expected_model_count diff --git a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py index 5534915eac..8387f92562 100644 --- a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py @@ -59,6 +59,16 @@ class DiffConfigTestCase: expected_error_fragment: str | None = None +@dataclass(frozen=True) +class TargetExecutionLimitsConfigTestCase: + description: str + contents: str + expected_max_models: int + expected_max_duration: str + expected_max_duration_seconds: int + expected_remediation: str + + @dataclass(frozen=True) class CommandOutputSinkDiscoveryTestCase: description: str diff --git a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_yml_project.py b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_yml_project.py index e61a2e0496..abb2f76a3e 100644 --- a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_yml_project.py +++ b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_yml_project.py @@ -11,7 +11,7 @@ ) from sqlbuild.compiler.discovery.exceptions import ProjectConfigError from sqlbuild.runtime.event_exporting.types import LifecycleEventKind -from sqlbuild.spec.contracts.models import LocalConfig, ProjectConfig +from sqlbuild.spec.contracts.models import ExecutionLimitsConfig, LocalConfig, ProjectConfig from sqlbuild.sql_values.types import CollectionRendering from tests.unit.src.sqlbuild.compiler.discovery._helpers._test_types import ( ColumnContractModeConfigErrorTestCase, @@ -34,6 +34,7 @@ MicrobatchLimitConfigErrorTestCase, MicrobatchLimitConfigTestCase, StartCursorConfigTestCase, + TargetExecutionLimitsConfigTestCase, ) from tests.unit.src.sqlbuild.compiler.discovery._helpers.helpers import ( write_project_config_test_files, @@ -1072,6 +1073,105 @@ def test_given_local_config_state_when_loading_local_config_then_it_returns_expe @pytest.mark.parametrize( "test_case", [ + TargetExecutionLimitsConfigTestCase( + description="project target execution limits are typed", + contents=""" +name = "shop" +adapter = "duckdb" + +[targets.dev.execution_limits] +max_models = 125 +max_duration = "45m" +remediation = "Review the expanded selection before continuing." +""".strip(), + expected_max_models=125, + expected_max_duration="45m", + expected_max_duration_seconds=2_700, + expected_remediation="Review the expanded selection before continuing.", + ), + ], + ids=lambda case: case.description, +) +def test_given_project_target_execution_limits_when_loading_then_limits_are_typed( + test_case: TargetExecutionLimitsConfigTestCase, + tmp_path: Path, +) -> None: + config_path: Path = tmp_path / "sqlbuild_project.toml" + config_path.write_text(test_case.contents, encoding="utf-8") + + config: ProjectConfig = load_project_config(project_dir=tmp_path) + + limits: ExecutionLimitsConfig = config.targets["dev"].execution_limits + assert limits.max_models == test_case.expected_max_models + assert limits.max_duration == test_case.expected_max_duration + assert limits.max_duration_seconds == test_case.expected_max_duration_seconds + assert limits.remediation == test_case.expected_remediation + + +@pytest.mark.parametrize( + "test_case", + [ + TargetExecutionLimitsConfigTestCase( + description="local target execution limits are typed", + contents=""" +[targets.dev.execution_limits] +max_models = 200 +max_duration = "2h" +remediation = "Automated tools must request approval before changing this policy." +""".strip(), + expected_max_models=200, + expected_max_duration="2h", + expected_max_duration_seconds=7_200, + expected_remediation=( + "Automated tools must request approval before changing this policy." + ), + ) + ], + ids=lambda case: case.description, +) +def test_given_local_target_execution_limits_when_loading_then_limits_are_typed( + test_case: TargetExecutionLimitsConfigTestCase, + tmp_path: Path, +) -> None: + config_path: Path = tmp_path / "sqlbuild_local.toml" + config_path.write_text(test_case.contents, encoding="utf-8") + + config: LocalConfig = load_local_config(project_dir=tmp_path) + + limits: ExecutionLimitsConfig = config.targets["dev"].execution_limits + assert limits.max_models == test_case.expected_max_models + assert limits.max_duration == test_case.expected_max_duration + assert limits.max_duration_seconds == test_case.expected_max_duration_seconds + assert limits.remediation == test_case.expected_remediation + + +@pytest.mark.parametrize( + "test_case", + [ + LoadProjectConfigErrorTestCase( + description="raises when target maximum model count is not positive", + project_file_contents=""" +name = "demo" +adapter = "duckdb" + +[targets.dev.execution_limits] +max_models = 0 +""".strip(), + expected_error_fragment=("targets.dev.execution_limits.max_models must be >= 1"), + ), + LoadProjectConfigErrorTestCase( + description="raises when target maximum duration has a calendar component", + project_file_contents=""" +name = "demo" +adapter = "duckdb" + +[targets.dev.execution_limits] +max_duration = "1mo" +""".strip(), + expected_error_fragment=( + "targets.dev.execution_limits.max_duration must be a positive fixed duration" + ), + ), LoadProjectConfigErrorTestCase( description="raises when scope placement enforcement is not a boolean", project_file_contents=""" diff --git a/tests/unit/src/sqlbuild/spec/contracts/main/_test_types.py b/tests/unit/src/sqlbuild/spec/contracts/main/_test_types.py index f77143e634..3234635f21 100644 --- a/tests/unit/src/sqlbuild/spec/contracts/main/_test_types.py +++ b/tests/unit/src/sqlbuild/spec/contracts/main/_test_types.py @@ -2,7 +2,12 @@ from dataclasses import dataclass -from sqlbuild.spec.contracts.models import LoaderDestinationParts, LocalConfig, ProjectConfig +from sqlbuild.spec.contracts.models import ( + ExecutionLimitsConfig, + LoaderDestinationParts, + LocalConfig, + ProjectConfig, +) from sqlbuild.sql_values.types import CollectionRendering @@ -48,6 +53,15 @@ class TargetRetentionResolutionTestCase: expected_desired_days: int +@dataclass(frozen=True) +class ExecutionLimitsResolutionTestCase: + description: str + project_config: ProjectConfig + local_config: LocalConfig + target_name: str + expected_limits: ExecutionLimitsConfig + + @dataclass(frozen=True) class LoaderDestinationPartsTestCase: description: str diff --git a/tests/unit/src/sqlbuild/spec/contracts/main/test_targets.py b/tests/unit/src/sqlbuild/spec/contracts/main/test_targets.py index 85eb6b73ac..e1ef3e8dce 100644 --- a/tests/unit/src/sqlbuild/spec/contracts/main/test_targets.py +++ b/tests/unit/src/sqlbuild/spec/contracts/main/test_targets.py @@ -8,6 +8,7 @@ from sqlbuild.spec.contracts.main.resolve_target_config import resolve_target_config from sqlbuild.spec.contracts.models import ( AuthoredTimeTravelRetention, + ExecutionLimitsConfig, LocalConfig, LocalStateConfig, LocalTargetConfig, @@ -18,6 +19,7 @@ ) from tests.unit.src.sqlbuild.spec.contracts.main._test_types import ( EffectiveChangesOnlyResolutionTestCase, + ExecutionLimitsResolutionTestCase, TargetConfigResolutionTestCase, TargetRetentionResolutionTestCase, ) @@ -236,3 +238,55 @@ def test_given_local_target_retention_when_resolving_then_it_overrides_project_t assert target_config.time_travel_retention is not None assert target_config.time_travel_retention.desired_days == test_case.expected_desired_days + + +@pytest.mark.parametrize( + "test_case", + [ + ExecutionLimitsResolutionTestCase( + description="local execution fields override project fields independently", + project_config=ProjectConfig( + name="shop", + adapter="duckdb", + targets={ + "dev": TargetConfig( + execution_limits=ExecutionLimitsConfig( + max_models=100, + max_duration="30m", + max_duration_seconds=1_800, + remediation="Review the project policy.", + ) + ) + }, + ), + local_config=LocalConfig( + targets={ + "dev": LocalTargetConfig( + execution_limits=ExecutionLimitsConfig( + max_models=150, + remediation="Request approval before continuing.", + ) + ) + } + ), + target_name="dev", + expected_limits=ExecutionLimitsConfig( + max_models=150, + max_duration="30m", + max_duration_seconds=1_800, + remediation="Request approval before continuing.", + ), + ) + ], + ids=lambda case: case.description, +) +def test_given_local_execution_limit_fields_when_resolving_then_each_field_overrides_project( + test_case: ExecutionLimitsResolutionTestCase, +) -> None: + target_config: TargetConfig = resolve_target_config( + project_config=test_case.project_config, + local_config=test_case.local_config, + target_name=test_case.target_name, + ) + + assert target_config.execution_limits == test_case.expected_limits From ecec463da65a166ee07909f7b640043e0403cd17 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:44:00 +0100 Subject: [PATCH 2/2] test: isolate build wrapper from project discovery --- .../cli/commands/main/execution/helpers.py | 20 +++++++++++++++++++ .../execution/test_build_process_resources.py | 8 ++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/unit/src/sqlbuild/cli/commands/main/execution/helpers.py diff --git a/tests/unit/src/sqlbuild/cli/commands/main/execution/helpers.py b/tests/unit/src/sqlbuild/cli/commands/main/execution/helpers.py new file mode 100644 index 0000000000..77c9a121eb --- /dev/null +++ b/tests/unit/src/sqlbuild/cli/commands/main/execution/helpers.py @@ -0,0 +1,20 @@ +"""Test helpers for build execution command boundaries.""" + +from types import ModuleType +from unittest.mock import Mock + +import pytest + +from sqlbuild.cli.commands.models import BuildInvocation +from sqlbuild.spec.contracts.models import ExecutionLimitsConfig + + +def stub_resolved_build_invocation( + *, monkeypatch: pytest.MonkeyPatch, build_module: ModuleType +) -> None: + """Keep command-wrapper tests isolated from project discovery.""" + + invocation: Mock = Mock(spec=BuildInvocation) + invocation.execution_limits = ExecutionLimitsConfig() + invocation.effective_target_name = None + monkeypatch.setattr(build_module, "resolve_build_invocation", lambda **_kwargs: invocation) diff --git a/tests/unit/src/sqlbuild/cli/commands/main/execution/test_build_process_resources.py b/tests/unit/src/sqlbuild/cli/commands/main/execution/test_build_process_resources.py index aabe15169f..19ada1111b 100644 --- a/tests/unit/src/sqlbuild/cli/commands/main/execution/test_build_process_resources.py +++ b/tests/unit/src/sqlbuild/cli/commands/main/execution/test_build_process_resources.py @@ -15,6 +15,9 @@ BuildPartialTimingOutputTestCase, BuildProcessReportTestCase, ) +from tests.unit.src.sqlbuild.cli.commands.main.execution.helpers import ( + stub_resolved_build_invocation, +) @pytest.mark.parametrize( @@ -31,6 +34,7 @@ def test_given_successful_debug_build_when_finishing_then_reports_process_resour test_case: BuildProcessReportTestCase, monkeypatch: pytest.MonkeyPatch, ) -> None: + stub_resolved_build_invocation(monkeypatch=monkeypatch, build_module=build_module) reporter: Mock = Mock() monkeypatch.setattr(build_module, "process_resource_reporting", reporter) reporter.return_value = nullcontext() @@ -56,6 +60,7 @@ def test_given_failed_debug_build_when_unwinding_then_reports_process_resources( test_case: BuildProcessReportTestCase, monkeypatch: pytest.MonkeyPatch, ) -> None: + stub_resolved_build_invocation(monkeypatch=monkeypatch, build_module=build_module) reporter: Mock = Mock() def fail_build(**_kwargs: object) -> int: @@ -85,6 +90,7 @@ def test_given_interrupted_debug_build_when_unwinding_then_reports_process_resou test_case: BuildProcessReportTestCase, monkeypatch: pytest.MonkeyPatch, ) -> None: + stub_resolved_build_invocation(monkeypatch=monkeypatch, build_module=build_module) reporter: Mock = Mock() def interrupt_build(**_kwargs: object) -> int: @@ -157,6 +163,7 @@ def test_given_phase_failure_when_building_then_available_partial_timings_are_wr monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: + stub_resolved_build_invocation(monkeypatch=monkeypatch, build_module=build_module) timing_tracker: Mock = Mock() timing_tracker.snapshot.return_value = PartialBuildPhaseTimings( compile_seconds=test_case.compile_seconds, @@ -207,6 +214,7 @@ def test_given_json_cost_failure_when_building_then_partial_timing_only_uses_std monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: + stub_resolved_build_invocation(monkeypatch=monkeypatch, build_module=build_module) timing_tracker: Mock = Mock() timing_tracker.snapshot.return_value = PartialBuildPhaseTimings( compile_seconds=test_case.compile_seconds,