Skip to content

Commit 059cf18

Browse files
committed
feat: add target execution limits
1 parent 959b7e2 commit 059cf18

42 files changed

Lines changed: 1163 additions & 18 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ E2E_DUCKDB_BUILD_CORE_PATHS := \
6464
tests/e2e/src/sqlbuild/cli/commands/main/build/test_dynamic_column_contract.py \
6565
tests/e2e/src/sqlbuild/cli/commands/main/build/test_dag_json_behavior.py \
6666
tests/e2e/src/sqlbuild/cli/commands/main/build/test_enum_contract.py \
67+
tests/e2e/src/sqlbuild/cli/commands/main/build/test_execution_limits.py \
6768
tests/e2e/src/sqlbuild/cli/commands/main/build/test_expression_sources.py \
6869
tests/e2e/src/sqlbuild/cli/commands/main/build/test_lifecycle_commands.py \
6970
tests/e2e/src/sqlbuild/cli/commands/main/build/test_manifest_artifact_gating.py \

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ All state is persisted as append-only tables in the warehouse alongside your dat
2424
- **Cursor-based incremental processing.** Automatic gap detection and resume, with microbatch mode for large ranges. No external checkpoint to maintain.
2525
- **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.
2626

27-
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).
27+
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).
2828

2929
## Quick start
3030

‎docs/execution-limits.md‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Target execution limits
2+
3+
Named targets can set optional limits for `sqb build`. Limits are useful for development targets
4+
where an unexpectedly broad selector or long-running command should stop instead of consuming an
5+
unbounded amount of warehouse compute.
6+
7+
```toml
8+
[targets.dev]
9+
connection = "warehouse"
10+
database = "analytics"
11+
schema = "dev_alice"
12+
13+
[targets.dev.execution_limits]
14+
max_models = 125
15+
max_duration = "45m"
16+
remediation = """
17+
Review the expanded plan and confirm that the selected scope is intentional.
18+
Automated tools must request approval before changing this policy.
19+
"""
20+
```
21+
22+
All fields are optional. A target without an `execution_limits` section retains unlimited existing
23+
behavior, so production or orchestrated targets do not need placeholder values.
24+
25+
## Model limit
26+
27+
`max_models` is a positive integer. SQLBuild expands selectors and counts model entries that would
28+
execute. When the count exceeds the target limit, the build exits before clone, load, or model
29+
execution begins. The error reports the target, selected count, configured maximum, configuration
30+
key, and confirms that no warehouse changes were made.
31+
32+
`sqb plan` remains unrestricted and can be used to inspect a broad selection safely.
33+
34+
## Duration limit
35+
36+
`max_duration` is a positive fixed duration such as `30m`, `2h`, or `90s`. It covers planning and
37+
warehouse-mutating build phases after target resolution. SQLBuild stops dispatching work at the
38+
deadline and passes the remaining duration to each warehouse statement so in-flight work is
39+
cancelled by the adapter.
40+
41+
Safe in-flight cancellation is currently supported by the Snowflake adapter, whose maximum accepted
42+
duration is seven days. SQLBuild rejects an unsupported duration or an adapter that cannot guarantee
43+
active statement cancellation rather than presenting a limit that may leave warehouse work running.
44+
45+
A duration failure may occur after earlier models have completed. Its error therefore distinguishes
46+
the timeout from a pre-execution model-limit failure and states that warehouse changes may already
47+
have occurred.
48+
49+
## Custom remediation
50+
51+
`remediation` is optional project-authored text. SQLBuild appends it to model-count, duration, and
52+
unsupported-adapter errors after the factual error details. This lets each project define its own
53+
approval or recovery process without SQLBuild recommending that users weaken local policy.
54+
55+
## Local overrides
56+
57+
The same section is accepted in `sqlbuild_local.toml` and follows normal field-by-field local target
58+
precedence:
59+
60+
```toml
61+
[targets.dev.execution_limits]
62+
max_models = 200
63+
max_duration = "90m"
64+
```
65+
66+
This permits an intentional developer override while keeping the shared target policy visible in
67+
`sqlbuild_project.toml`. Other target fields and any execution-limit fields omitted locally continue
68+
to inherit their project values.

‎src/sqlbuild/adapter/contract/classes/base_adapter.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class BaseAdapter(RetentionAdapterMixin, StrictAdapter):
8585
max_identifier_length: ClassVar[int] = 63
8686
state_tables_transient: ClassVar[bool] = False
8787
allows_implicit_managed_write_schema: ClassVar[bool] = False
88+
execution_duration_limit_seconds: ClassVar[int | None] = None
8889

8990
def supports_zero_copy_clone(self) -> bool:
9091
return False

‎src/sqlbuild/adapter/contract/classes/connection.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99

1010
from sqlbuild.adapter.contract.main._attribute_failed_sql import attribute_failed_sql
1111
from sqlbuild.adapter.contract.models import QueryResult
12+
from sqlbuild.runtime.execution_limits.exceptions import ExecutionDurationLimitError
13+
from sqlbuild.runtime.execution_limits.main.enforce_execution_deadline import (
14+
enforce_execution_deadline,
15+
)
1216
from sqlbuild.runtime.observability.classes.statement_lifecycle import StatementLifecycle
1317

1418

@@ -27,8 +31,13 @@ def execute(self, *, connection: Any, sql: str) -> Any:
2731

2832
with StatementLifecycle(adapter=self.adapter_name, sql=sql, intent="execute") as lifecycle:
2933
try:
34+
enforce_execution_deadline()
3035
result: Any = self._execute(connection=connection, sql=sql)
3136
except Exception as error:
37+
try:
38+
enforce_execution_deadline()
39+
except ExecutionDurationLimitError as deadline_error:
40+
raise deadline_error from error
3241
attributed_error: Exception = attribute_failed_sql(error=error, sql=sql)
3342
if attributed_error is error:
3443
raise

‎src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
from sqlbuild.adapters.snowflake.constants import (
7171
BASE_TABLE_METADATA_TYPE,
7272
EXTERNAL_BROWSER_AUTHENTICATOR,
73+
MAX_STATEMENT_TIMEOUT_SECONDS,
7374
MFA_AUTHENTICATOR,
7475
NUMBER_TYPE_NAME,
7576
OAUTH_AUTHORIZATION_CODE_AUTHENTICATOR,
@@ -102,6 +103,8 @@ class SnowflakeAdapter(MicrobatchMixin, UnkeyedDiffMixin, BaseAdapter):
102103

103104
adapter_name: ClassVar[str] = BuiltinAdapter.SNOWFLAKE.value
104105
sql_analysis_dialect_name: ClassVar[str | None] = "snowflake"
106+
execution_duration_limit_seconds: ClassVar[int | None] = MAX_STATEMENT_TIMEOUT_SECONDS
107+
105108
max_identifier_length: ClassVar[int] = 255
106109
state_tables_transient: ClassVar[bool] = True
107110
connection_routing_keys: ClassVar[frozenset[str]] = frozenset(

‎src/sqlbuild/adapters/snowflake/classes/snowflake_cursor.py‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import logging
7+
import math
78
import time
89
from collections.abc import Callable, Iterator
910
from datetime import UTC, datetime
@@ -15,6 +16,10 @@
1516
from sqlbuild.cost.classes.statement_ledger import StatementLedger
1617
from sqlbuild.cost.constants import SQLBUILD_QUERY_TAG_APP
1718
from sqlbuild.cost.models import CostResourceContext, StatementExecutionTelemetry
19+
from sqlbuild.runtime.execution_limits.main.current_execution_deadline import (
20+
current_execution_deadline,
21+
)
22+
from sqlbuild.runtime.execution_limits.models import ExecutionDeadline
1823
from sqlbuild.runtime.observability.classes.statement_lifecycle import StatementLifecycle
1924
from sqlbuild.runtime.observability.main.current_execution_identity import (
2025
current_execution_identity,
@@ -23,6 +28,7 @@
2328

2429
_LOGGER: logging.Logger = logging.getLogger("sqlbuild.cost")
2530
_QUERY_TAG_PARAMETER: str = "QUERY_TAG"
31+
_STATEMENT_TIMEOUT_PARAMETER: str = "STATEMENT_TIMEOUT_IN_SECONDS"
2632
_STATEMENT_PARAMETER_ERROR_FRAGMENT: str = "STATEMENT PARAMETER"
2733
_RAW_CURSOR_ATTRIBUTE: str = "raw_cursor"
2834
_EXECUTEMANY_INTENT: str = "executemany"
@@ -50,8 +56,9 @@ def __setattr__(self, name: str, value: Any) -> None:
5056
def execute(self, sql: str, *args: Any, **kwargs: Any) -> Any:
5157
context: CostResourceContext | None = CostContext.current()
5258
statement_id: str = _statement_id()
59+
deadline_kwargs: dict[str, Any] = _with_execution_deadline(kwargs=kwargs)
5360
tagged_kwargs, query_tag_injected = _with_query_tag(
54-
kwargs=kwargs, context=context, statement_id=statement_id
61+
kwargs=deadline_kwargs, context=context, statement_id=statement_id
5562
)
5663
return self._execute_with_telemetry(
5764
operation=self.raw_cursor.execute,
@@ -67,8 +74,9 @@ def execute(self, sql: str, *args: Any, **kwargs: Any) -> Any:
6774
def executemany(self, sql: str, *args: Any, **kwargs: Any) -> Any:
6875
context: CostResourceContext | None = CostContext.current()
6976
statement_id: str = _statement_id()
77+
deadline_kwargs: dict[str, Any] = _with_execution_deadline(kwargs=kwargs)
7078
tagged_kwargs, query_tag_injected = _with_query_tag(
71-
kwargs=kwargs, context=context, statement_id=statement_id
79+
kwargs=deadline_kwargs, context=context, statement_id=statement_id
7280
)
7381
return self._execute_with_telemetry(
7482
operation=self.raw_cursor.executemany,
@@ -120,7 +128,9 @@ def _execute_with_telemetry(
120128
"Snowflake query tagging was rejected before submission; "
121129
"continuing without a query tag"
122130
)
123-
retry_kwargs: dict[str, Any] = _without_query_tag(kwargs=kwargs)
131+
retry_kwargs: dict[str, Any] = _with_execution_deadline(
132+
kwargs=_without_query_tag(kwargs=kwargs)
133+
)
124134
try:
125135
result = operation(sql, *args, **retry_kwargs)
126136
except Exception as retry_error:
@@ -284,6 +294,20 @@ def _with_query_tag(
284294
return tagged_kwargs, True
285295

286296

297+
def _with_execution_deadline(*, kwargs: dict[str, Any]) -> dict[str, Any]:
298+
deadline: ExecutionDeadline | None = current_execution_deadline()
299+
if deadline is None:
300+
return dict(kwargs)
301+
deadline.enforce()
302+
statement_params: dict[str, str] = dict(kwargs.get("_statement_params") or {})
303+
statement_params[_STATEMENT_TIMEOUT_PARAMETER] = str(
304+
max(1, math.ceil(deadline.remaining_seconds))
305+
)
306+
deadline_kwargs: dict[str, Any] = dict(kwargs)
307+
deadline_kwargs["_statement_params"] = statement_params
308+
return deadline_kwargs
309+
310+
287311
def _without_query_tag(*, kwargs: dict[str, Any]) -> dict[str, Any]:
288312
retry_kwargs: dict[str, Any] = dict(kwargs)
289313
statement_params: dict[str, str] = dict(retry_kwargs.get("_statement_params") or {})

‎src/sqlbuild/adapters/snowflake/constants.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Snowflake adapter constants."""
22

3+
MAX_STATEMENT_TIMEOUT_SECONDS: int = 604_800
4+
35
BASE_TABLE_METADATA_TYPE: str = "BASE TABLE"
46
VIEW_RELATION_TYPE_TOKEN: str = "view"
57
EXTERNAL_BROWSER_AUTHENTICATOR: str = "externalbrowser"

‎src/sqlbuild/cli/commands/_helpers/build_planning/defer_clone.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from typing import Any
88

99
from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter
10+
from sqlbuild.cli.commands._helpers.build_planning.execution_limits import (
11+
enforce_model_execution_limit,
12+
)
1013
from sqlbuild.cli.commands._helpers.planning.external_refs import (
1114
resolve_external_sql_reference_resolver,
1215
)
@@ -69,7 +72,7 @@ def build_defer_clone_boundary_selectors(
6972
cli_vars: dict[str, object] | None,
7073
project_dir: Path,
7174
auto_load_sources: bool,
72-
) -> tuple[CompiledProject, tuple[str, ...], tuple[str, ...]]:
75+
) -> tuple[CompiledProject, tuple[str, ...], tuple[str, ...], int]:
7376
"""Resolve out-of-selection clone boundaries and the view chain to rebuild over them."""
7477

7578
project: CompiledProject = build_compiled_project(
@@ -94,6 +97,7 @@ def build_defer_clone_boundary_selectors(
9497
project,
9598
defer_clone_boundary_selectors(scope=scope),
9699
defer_clone_view_chain_selectors(scope=scope),
100+
selected_executable_model_count(scope=scope),
97101
)
98102

99103

@@ -142,6 +146,20 @@ def defer_clone_view_chain_selectors(*, scope: PlannerScope) -> tuple[str, ...]:
142146
return tuple(sorted(key.name for key in view_keys))
143147

144148

149+
def selected_executable_model_count(*, scope: PlannerScope) -> int:
150+
"""Count selected models except those explicitly disabled."""
151+
152+
count: int = 0
153+
for key in scope.selected_keys:
154+
if key.resource_type != CompiledResourceType.MODEL:
155+
continue
156+
model: CompiledModel | None = scope.models_by_name.get(key.name)
157+
if model is not None and model.config.values.get("enabled") is False:
158+
continue
159+
count += 1
160+
return count
161+
162+
145163
def run_defer_clone_boundary_prephase(
146164
*,
147165
request: BuildCommandRequest,
@@ -153,7 +171,12 @@ def run_defer_clone_boundary_prephase(
153171
cloned_project: CompiledProject
154172
boundary_selectors: tuple[str, ...]
155173
view_chain_selectors: tuple[str, ...]
156-
cloned_project, boundary_selectors, view_chain_selectors = build_defer_clone_boundary_selectors(
174+
(
175+
cloned_project,
176+
boundary_selectors,
177+
view_chain_selectors,
178+
selected_model_count,
179+
) = build_defer_clone_boundary_selectors(
157180
discovered_inputs=invocation.discovered_inputs,
158181
adapter=invocation.adapter,
159182
selected_target=request.selected_target,
@@ -165,6 +188,11 @@ def run_defer_clone_boundary_prephase(
165188
project_dir=invocation.effective_project_dir,
166189
auto_load_sources=invocation.should_load_sources,
167190
)
191+
enforce_model_execution_limit(
192+
model_count=selected_model_count,
193+
target_name=invocation.effective_target_name,
194+
limits=invocation.execution_limits,
195+
)
168196
run_defer_clone_prephase(
169197
inputs=DeferClonePrephaseInputs(
170198
discovered_inputs=invocation.discovered_inputs,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Target-scoped build execution-limit enforcement."""
2+
3+
from __future__ import annotations
4+
5+
from sqlbuild.cli.commands.exceptions import CliUserError
6+
from sqlbuild.compiler.planner.models import PlanOutput
7+
from sqlbuild.compiler.planner.types import PlanAction
8+
from sqlbuild.spec.contracts.models import ExecutionLimitsConfig
9+
10+
11+
def executable_model_count(*, plan: PlanOutput) -> int:
12+
"""Return the number of model entries that would execute."""
13+
14+
return sum(entry.action != PlanAction.SKIP for entry in plan.model_entries)
15+
16+
17+
def enforce_model_execution_limit(
18+
*,
19+
model_count: int,
20+
target_name: str | None,
21+
limits: ExecutionLimitsConfig,
22+
) -> None:
23+
"""Reject a build whose expanded model count exceeds its target policy."""
24+
25+
maximum: int | None = limits.max_models
26+
if maximum is None or model_count <= maximum:
27+
return
28+
target_label: str = target_name or "default"
29+
raise CliUserError(
30+
"Build plan exceeds the configured model limit\n\n"
31+
f"Target: {target_label}\n"
32+
f"Selected models: {model_count}\n"
33+
f"Maximum models: {maximum}\n"
34+
"Configuration: "
35+
f"targets.{target_label}.execution_limits.max_models\n\n"
36+
"No warehouse changes were made.",
37+
code="C413",
38+
help=limits.remediation,
39+
)

0 commit comments

Comments
 (0)