Skip to content

Commit 61e183a

Browse files
committed
fix: run selected Python nodes against upstreams outside the run
Read-side tasks and assets now wait only for SQL resources scheduled in the current build; unselected upstreams and unmanaged sources are read as they exist, like unselected upstreams of a selected SQL model. A skipped upstream model now skips its dependent Python nodes, as it already blocks dependent models.
1 parent c79ff36 commit 61e183a

15 files changed

Lines changed: 340 additions & 37 deletions

File tree

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ E2E_DUCKDB_BUILD_CORE_PATHS := \
7676
tests/e2e/src/sqlbuild/cli/commands/main/build/test_no_tests_no_audits_flags.py \
7777
tests/e2e/src/sqlbuild/cli/commands/main/build/test_plan_command_surface.py \
7878
tests/e2e/src/sqlbuild/cli/commands/main/build/test_python_hooks.py \
79+
tests/e2e/src/sqlbuild/cli/commands/main/build/test_python_node_selection.py \
7980
tests/e2e/src/sqlbuild/cli/commands/main/build/test_python_source_deferral.py \
8081
tests/e2e/src/sqlbuild/cli/commands/main/build/test_sql_hook_reads.py \
8182
tests/e2e/src/sqlbuild/cli/commands/main/build/test_python_node_identity.py \

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/python-nodes/overview.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@ Python nodes run in two phases relative to SQL:
170170
- **Ingress (pre-SQL):** loaders, and tasks/assets that feed sources, run before SQL models are built.
171171
- **Read-side (post-SQL):** tasks/assets that read SQL run after their SQL dependencies are built.
172172

173+
Read-side tasks and assets follow the same selection rule as SQL models. A SQL dependency that is
174+
part of the run gates the node: it waits for that model, seed, or source load, and is skipped if the
175+
dependency fails or is skipped. A dependency that is not part of the run (not selected, or an
176+
unmanaged source) is read as it currently exists, so `sqb build --select task:export_orders` runs
177+
the task against the relations already in the warehouse.
178+
173179
The commands differ in what they include by default:
174180

175181
| Command | SQL | Loaders / tasks / assets | Checks | Audits |

‎src/sqlbuild/cli/commands/_helpers/build_python_nodes/python_lifecycle.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter
1010
from sqlbuild.cli.commands._helpers.build_python_nodes.python_lifecycle_selection import (
1111
python_node_result_names,
12+
scheduled_sql_resource_names,
1213
task_asset_python_node_names,
1314
)
1415
from sqlbuild.cli.commands._helpers.build_python_nodes.python_node_output import (
@@ -177,6 +178,8 @@ def prepare_direct_python_lifecycle(
177178
read_side_tracker = create_read_side_python_execution_tracker(
178179
python_graph=python_graph,
179180
selected_python_names=read_side_names,
181+
scheduled_sql_names=scheduled_sql_resource_names(plan_output)
182+
| frozenset(result.source_name for result in ingress_load_results),
180183
runtime=PythonNodeRuntime(
181184
adapter=adapter,
182185
connection_config=connection_config,

‎src/sqlbuild/cli/commands/_helpers/build_python_nodes/python_lifecycle_selection.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ def task_asset_python_node_names(
2323
return frozenset(name for name in selected_names if name in task_asset_names)
2424

2525

26+
def scheduled_sql_resource_names(plan: PlanOutput) -> frozenset[str]:
27+
"""Return the models, seeds, and source loads this run executes and reports results for."""
28+
29+
scheduled_keys: frozenset[CompiledObjectKey] = frozenset(plan.execution_order)
30+
return frozenset(
31+
entry.name
32+
for entry in (*plan.model_entries, *plan.seed_entries, *plan.source_load_entries)
33+
if entry.key in scheduled_keys
34+
)
35+
36+
2637
def sql_loader_functions_for_lifecycle_handoff(
2738
*,
2839
discovered_inputs: DiscoveredProjectInputs,

‎src/sqlbuild/cli/commands/_helpers/check/core.py‎

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
PythonSqlRunLifecyclePlan,
3737
)
3838
from sqlbuild.compiler.python_nodes.types import PythonNodeKind, PythonNodeStatus
39-
from sqlbuild.executor.build.models import SeedExecutionResult
4039
from sqlbuild.executor.build.types import ExecutionStatus
4140
from sqlbuild.executor.load.models import LoadExecutionResult
4241
from sqlbuild.executor.python_nodes.main.read_side import create_read_side_python_execution_tracker
@@ -46,12 +45,10 @@
4645
PythonNodeRunState,
4746
PythonNodeRuntime,
4847
)
49-
from sqlbuild.executor.run.models import ModelExecutionResult
5048
from sqlbuild.presentation.classes.cli_style import CliStyle
5149
from sqlbuild.provider.main.runtime import ProviderContainer
5250
from sqlbuild.python_nodes.models import SqlResourceRef
5351
from sqlbuild.python_nodes.types import SqlResourceRefKind
54-
from sqlbuild.runtime.contracts.types import ExecutionResourceKind
5552
from sqlbuild.spec.contracts.models import SourceEntry
5653

5754
_IGNORED_EXCLUDE_SELECTOR_ERROR_CODES: frozenset[str] = frozenset({"S007", "S008", "S009"})
@@ -234,6 +231,7 @@ def run_check_read_side_dependencies(
234231
tracker: Any = create_read_side_python_execution_tracker(
235232
python_graph=python_graph,
236233
selected_python_names=read_side_names,
234+
scheduled_sql_names=frozenset(),
237235
runtime=PythonNodeRuntime(
238236
adapter=adapter,
239237
connection_config=connection_config,
@@ -249,25 +247,6 @@ def run_check_read_side_dependencies(
249247
providers=providers,
250248
),
251249
)
252-
for sql_ref in sorted(read_side_refs, key=_sql_ref_sort_key):
253-
if sql_ref.kind == SqlResourceRefKind.MODEL:
254-
tracker.record_sql_result(
255-
ModelExecutionResult(model_name=sql_ref.name, status=ExecutionStatus.SUCCESS)
256-
)
257-
elif sql_ref.kind == SqlResourceRefKind.SEED:
258-
tracker.record_sql_result(
259-
SeedExecutionResult(seed_name=sql_ref.name, status=ExecutionStatus.SUCCESS)
260-
)
261-
elif sql_ref.kind == SqlResourceRefKind.SOURCE:
262-
tracker.record_sql_result(
263-
LoadExecutionResult(
264-
source_name=sql_ref.name,
265-
loader_name=sql_ref.name,
266-
status=ExecutionStatus.SUCCESS,
267-
target=relation_targets[sql_ref],
268-
resource_kind=ExecutionResourceKind.SOURCE,
269-
)
270-
)
271250
tracker.dispatch_ready_python_nodes()
272251
tracker.finalize_unrun_python_nodes()
273252
return tracker.results

‎src/sqlbuild/executor/python_nodes/_helpers/read_side_execution.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ def _sql_result_name(result: object) -> str | None:
1919

2020

2121
def _sql_result_failed(result: object) -> bool:
22-
if isinstance(result, ModelExecutionResult | LoadExecutionResult | SeedExecutionResult):
22+
if isinstance(result, ModelExecutionResult):
23+
return result.status in {ExecutionStatus.FAILED, ExecutionStatus.SKIPPED}
24+
if isinstance(result, LoadExecutionResult | SeedExecutionResult):
2325
return result.status == ExecutionStatus.FAILED
2426
return False

‎src/sqlbuild/executor/python_nodes/classes/read_side_python_execution_tracker.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,20 @@
3131

3232

3333
class ReadSidePythonExecutionTracker:
34-
"""Dispatch selected read-only Python nodes as SQL dependencies complete."""
34+
"""Dispatch selected read-only Python nodes as SQL dependencies scheduled in this run finish."""
3535

3636
def __init__(
3737
self,
3838
*,
3939
python_graph: PythonNodeGraph,
4040
selected_python_names: frozenset[str],
41+
scheduled_sql_names: frozenset[str],
4142
runtime: PythonNodeRuntime,
4243
identity_recorder: PythonIdentityRecorder | None = None,
4344
) -> None:
4445
self._python_graph: PythonNodeGraph = python_graph
4546
self._selected_python_names: frozenset[str] = selected_python_names
47+
self._scheduled_sql_names: frozenset[str] = scheduled_sql_names
4648
self._runtime: PythonNodeRuntime = runtime
4749
self._identity_recorder: PythonIdentityRecorder | None = identity_recorder
4850
self._result_store: Any | None = (
@@ -133,7 +135,10 @@ def _sql_dependencies_satisfied(self, node: DiscoveredPythonNode) -> bool:
133135
for sql_dep_name in (dependency.name for dependency in node.sql_deps):
134136
if sql_dep_name in self._failed_sql_names:
135137
return False
136-
if sql_dep_name not in self._completed_sql_names:
138+
if (
139+
sql_dep_name in self._scheduled_sql_names
140+
and sql_dep_name not in self._completed_sql_names
141+
):
137142
return False
138143
return True
139144

@@ -191,7 +196,10 @@ def _unrun_reason(self, node: DiscoveredPythonNode) -> str:
191196
for sql_dep_name in (dependency.name for dependency in node.sql_deps):
192197
if sql_dep_name in self._failed_sql_names:
193198
return f"Upstream SQL resource did not succeed: {sql_dep_name}"
194-
if sql_dep_name not in self._completed_sql_names:
199+
if (
200+
sql_dep_name in self._scheduled_sql_names
201+
and sql_dep_name not in self._completed_sql_names
202+
):
195203
return f"Upstream SQL resource did not complete: {sql_dep_name}"
196204
upstream_name: str
197205
for upstream_name in self._python_graph.upstream_deps.get(node.name, ()):

‎src/sqlbuild/executor/python_nodes/main/read_side.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def create_read_side_python_execution_tracker(
1414
*,
1515
python_graph: PythonNodeGraph,
1616
selected_python_names: frozenset[str],
17+
scheduled_sql_names: frozenset[str],
1718
runtime: PythonNodeRuntime,
1819
identity_recorder: PythonIdentityRecorder | None = None,
1920
) -> ReadSidePythonExecutionTracker:
@@ -22,6 +23,7 @@ def create_read_side_python_execution_tracker(
2223
return ReadSidePythonExecutionTracker(
2324
python_graph=python_graph,
2425
selected_python_names=selected_python_names,
26+
scheduled_sql_names=scheduled_sql_names,
2527
runtime=runtime,
2628
identity_recorder=identity_recorder,
2729
)

‎tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1104,7 +1104,7 @@ class PythonSourceTaskOnlyE2ETestCase:
11041104

11051105
description: str
11061106
target_config: str
1107-
expected_task_pattern: str
1107+
expected_task_count: int
11081108

11091109

11101110
@dataclass(frozen=True)
@@ -1142,3 +1142,18 @@ class AwareCursorDateWatermarkE2ETestCase:
11421142
plan_args: tuple[str, ...]
11431143
expected_plan_fragment: str
11441144
expected_event_ids: tuple[int, ...]
1145+
1146+
1147+
@dataclass(frozen=True)
1148+
class PythonNodeSelectionE2ETestCase:
1149+
"""Python nodes selected with or without their SQL upstreams."""
1150+
1151+
description: str
1152+
select: tuple[str, ...]
1153+
expected_exit_code: int
1154+
expected_output_patterns: tuple[str, ...]
1155+
expected_row_counts: dict[str, int]
1156+
expected_node_counts: dict[str, int]
1157+
expected_missing_tables: tuple[str, ...] = ()
1158+
orders_sql: str = 'MODEL (materialized table);\nSELECT order_id FROM __source("raw_orders")\n'
1159+
existing_sql: str = ""

‎tests/e2e/src/sqlbuild/cli/commands/main/build/helpers.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,3 +751,68 @@ def explicit_reference_literal_loader(*, table: str) -> str:
751751
f' ctx.query("SELECT count(*) FROM {table}")\n'
752752
" return [{'id': 1}]\n"
753753
)
754+
755+
756+
PYTHON_NODE_SELECTION_DATABASE: str = "warehouse.duckdb"
757+
758+
759+
def _counting_python_node(*, decorator: str, name: str, ref: str, returned: str) -> str:
760+
return (
761+
f"@{decorator}(depends_on={ref})\n"
762+
f"def {name}(ctx):\n"
763+
f" relation = ctx.relation({ref})\n"
764+
f' ctx.execute_sql(f"CREATE OR REPLACE TABLE main.{name}_result AS '
765+
f'SELECT count(*) AS n FROM {{relation}}")\n'
766+
f" return {returned}\n\n\n"
767+
)
768+
769+
770+
def python_node_selection_project_files(*, orders_sql: str) -> dict[str, str]:
771+
"""Return a DuckDB project whose tasks and assets count managed, unmanaged, and model rows."""
772+
773+
task_nodes: str = "".join(
774+
_counting_python_node(decorator="task", name=name, ref=ref, returned="None")
775+
for name, ref in (
776+
("count_customers", 'source("raw_customers")'),
777+
("count_raw_orders", 'source("raw_orders")'),
778+
("count_orders", 'model("orders")'),
779+
("count_order_summary", 'model("order_summary")'),
780+
)
781+
)
782+
asset_nodes: str = "".join(
783+
_counting_python_node(
784+
decorator="asset", name=name, ref=ref, returned="ctx.result(materialized=True)"
785+
)
786+
for name, ref in (
787+
("customers_extract", 'source("raw_customers")'),
788+
("orders_extract", 'model("orders")'),
789+
)
790+
)
791+
return {
792+
"sqlbuild_project.toml": (
793+
'name = "order_nodes"\nadapter = "duckdb"\n\n'
794+
f'[connection]\ndatabase = "{PYTHON_NODE_SELECTION_DATABASE}"\n'
795+
),
796+
"sources/raw.yml": (
797+
"sources:\n"
798+
" - name: raw_orders\n schema: main\n table: raw_orders\n"
799+
" - name: raw_customers\n managed: true\n write_strategy: table\n"
800+
" columns:\n - name: customer_id\n type: INTEGER\n"
801+
),
802+
"python/loaders/customers.py": (
803+
"from sqlbuild.loaders import loader\n\n\n"
804+
"@loader\ndef raw_customers(ctx):\n return [{'customer_id': 99}]\n"
805+
),
806+
"python/tasks/counts.py": (
807+
"from sqlbuild.refs import model, source\nfrom sqlbuild.tasks import task\n\n\n"
808+
+ task_nodes
809+
),
810+
"python/assets/extracts.py": (
811+
"from sqlbuild.assets import asset\nfrom sqlbuild.refs import model, source\n\n\n"
812+
+ asset_nodes
813+
),
814+
"models/orders.sql": orders_sql,
815+
"models/order_summary.sql": (
816+
'MODEL (materialized table);\nSELECT count(*) AS order_count FROM __ref("orders")\n'
817+
),
818+
}

0 commit comments

Comments
 (0)