Add typed Arrow execution backend boundary
This commit is contained in:
@@ -18,6 +18,7 @@ from govoplan_core.core.datasources import (
|
||||
DatasourcePublicationResult,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_dataflow.backend.backends.duckdb import DuckDbExecutionBackend
|
||||
from govoplan_dataflow.backend.db.models import (
|
||||
DataflowPipeline,
|
||||
DataflowPipelineRevision,
|
||||
@@ -320,6 +321,37 @@ class DataflowServiceTests(unittest.TestCase):
|
||||
self.session.scalar(select(func.count()).select_from(DataflowRun)),
|
||||
)
|
||||
|
||||
@unittest.skipUnless(
|
||||
DuckDbExecutionBackend().available(),
|
||||
"analytics extra is not installed",
|
||||
)
|
||||
def test_saved_preview_can_use_isolated_duckdb_backend(self) -> None:
|
||||
pipeline = self._create()
|
||||
|
||||
response = preview_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="user-1",
|
||||
payload=PipelinePreviewRequest(
|
||||
pipeline_id=pipeline.id,
|
||||
execution_backend="duckdb",
|
||||
),
|
||||
principal=principal(),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
run = self.session.get(DataflowRun, response.run_id)
|
||||
self.assertEqual("succeeded", response.status)
|
||||
self.assertEqual(
|
||||
[{"id": 2, "amount": 15}, {"id": 3, "amount": 25}],
|
||||
response.rows,
|
||||
)
|
||||
self.assertEqual(2, response.total_rows)
|
||||
self.assertFalse(response.truncated)
|
||||
self.assertEqual(3, response.input_row_count)
|
||||
self.assertTrue(response.executor_version.startswith("duckdb-isolated-v1"))
|
||||
self.assertEqual(response.executor_version, run.executor_version)
|
||||
|
||||
def test_failed_preview_keeps_upstream_lineage_and_failed_node_diagnostic(self) -> None:
|
||||
graph = sample_graph()
|
||||
graph.nodes[0].config["rows"] = [{"id": 1, "amount": "not-a-number"}]
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_dataflow.backend.backends import (
|
||||
EXECUTION_BACKENDS,
|
||||
ExecutionBudget,
|
||||
execute_typed_graph,
|
||||
)
|
||||
from govoplan_dataflow.backend.backends.duckdb import DuckDbExecutionBackend
|
||||
from govoplan_dataflow.backend.batches import TypedBatch
|
||||
from govoplan_dataflow.backend.ir import (
|
||||
DATAFLOW_IR_VERSION,
|
||||
graph_to_ir,
|
||||
ir_to_graph,
|
||||
)
|
||||
from govoplan_dataflow.backend.planner import plan_execution
|
||||
from govoplan_dataflow.backend.schemas import (
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphPosition,
|
||||
PipelineGraph,
|
||||
)
|
||||
from govoplan_dataflow.backend.sql_compiler import compile_sql
|
||||
|
||||
|
||||
def inline_source(
|
||||
node_id: str = "source",
|
||||
source_name: str = "records",
|
||||
rows: list[dict] | None = None,
|
||||
) -> GraphNode:
|
||||
return GraphNode(
|
||||
id=node_id,
|
||||
type="source.inline",
|
||||
label=source_name,
|
||||
position=GraphPosition(x=40, y=120),
|
||||
config={
|
||||
"source_name": source_name,
|
||||
"rows": rows
|
||||
or [
|
||||
{"department": "a", "amount": 10},
|
||||
{"department": "a", "amount": 5},
|
||||
{"department": "b", "amount": 20},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def simple_graph() -> PipelineGraph:
|
||||
source = inline_source()
|
||||
derive = GraphNode(
|
||||
id="derive",
|
||||
type="expression",
|
||||
label="Double amount",
|
||||
position=GraphPosition(x=280, y=120),
|
||||
config={
|
||||
"target_column": "double_amount",
|
||||
"expression": "amount * 2",
|
||||
"result_type": "integer",
|
||||
},
|
||||
)
|
||||
output = GraphNode(
|
||||
id="output",
|
||||
type="output",
|
||||
label="Output",
|
||||
position=GraphPosition(x=520, y=120),
|
||||
config={},
|
||||
)
|
||||
return PipelineGraph(
|
||||
nodes=[source, derive, output],
|
||||
edges=[
|
||||
GraphEdge(id="edge-1", source=source.id, target=derive.id),
|
||||
GraphEdge(id="edge-2", source=derive.id, target=output.id),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TypedIrTests(unittest.TestCase):
|
||||
def test_ir_roundtrip_preserves_identity_layout_and_expressions(self) -> None:
|
||||
graph = simple_graph()
|
||||
|
||||
ir = graph_to_ir(graph)
|
||||
roundtrip = ir_to_graph(ir)
|
||||
|
||||
self.assertEqual(DATAFLOW_IR_VERSION, ir.ir_version)
|
||||
self.assertEqual(64, len(ir.semantic_hash))
|
||||
self.assertEqual(
|
||||
graph.model_dump(mode="json"),
|
||||
roundtrip.model_dump(mode="json"),
|
||||
)
|
||||
expression = ir.nodes[1].expressions[0]
|
||||
self.assertEqual(("amount",), expression.columns)
|
||||
self.assertEqual("integer", expression.result_type)
|
||||
self.assertEqual(
|
||||
{"amount", "department", "double_amount"},
|
||||
{field.name for field in ir.nodes[-1].output_schema.fields},
|
||||
)
|
||||
|
||||
def test_semantic_hash_ignores_layout_but_changes_with_behavior(self) -> None:
|
||||
graph = simple_graph()
|
||||
moved = graph.model_copy(deep=True)
|
||||
moved.nodes[1].position = GraphPosition(x=900, y=700)
|
||||
changed = graph.model_copy(deep=True)
|
||||
changed.nodes[1].config["expression"] = "amount * 3"
|
||||
|
||||
self.assertEqual(
|
||||
graph_to_ir(graph).semantic_hash,
|
||||
graph_to_ir(moved).semantic_hash,
|
||||
)
|
||||
self.assertNotEqual(
|
||||
graph_to_ir(graph).semantic_hash,
|
||||
graph_to_ir(changed).semantic_hash,
|
||||
)
|
||||
|
||||
def test_typed_batch_is_columnar_and_roundtrips_rows(self) -> None:
|
||||
rows = [
|
||||
{"name": "Ada", "active": True},
|
||||
{"name": "Grace", "active": None},
|
||||
]
|
||||
|
||||
batch = TypedBatch.from_rows(rows)
|
||||
|
||||
self.assertEqual(2, batch.row_count)
|
||||
self.assertEqual(("Ada", "Grace"), batch.columns["name"])
|
||||
self.assertEqual(rows, batch.to_rows())
|
||||
self.assertGreater(batch.byte_count, 0)
|
||||
|
||||
def test_physical_plan_pins_order_sql_and_semantic_hash(self) -> None:
|
||||
plan = plan_execution(graph_to_ir(simple_graph()))
|
||||
|
||||
self.assertEqual(
|
||||
("source", "derive", "output"),
|
||||
plan.ordered_node_ids,
|
||||
)
|
||||
self.assertIn("amount * 2", plan.generated_sql)
|
||||
self.assertEqual(64, len(plan.semantic_hash))
|
||||
self.assertEqual((), plan.sql_diagnostics)
|
||||
|
||||
|
||||
class ExecutionBackendTests(unittest.TestCase):
|
||||
def test_reference_backend_executes_typed_ir_with_budgets(self) -> None:
|
||||
result = execute_typed_graph(
|
||||
simple_graph(),
|
||||
backend="reference",
|
||||
budget=ExecutionBudget(max_output_rows=2),
|
||||
)
|
||||
|
||||
self.assertEqual("reference", result.contract.backend)
|
||||
self.assertEqual(3, result.contract.row_count)
|
||||
self.assertTrue(result.contract.truncated)
|
||||
self.assertEqual(
|
||||
[
|
||||
{"department": "a", "amount": 10, "double_amount": 20},
|
||||
{"department": "a", "amount": 5, "double_amount": 10},
|
||||
],
|
||||
result.rows,
|
||||
)
|
||||
|
||||
def test_backend_registry_keeps_reference_available(self) -> None:
|
||||
self.assertIn("reference", EXECUTION_BACKENDS.names())
|
||||
self.assertIn("reference", EXECUTION_BACKENDS.available())
|
||||
|
||||
@unittest.skipUnless(
|
||||
DuckDbExecutionBackend().available(),
|
||||
"analytics extra is not installed",
|
||||
)
|
||||
def test_duckdb_matches_reference_for_aggregate_join_and_union(self) -> None:
|
||||
graphs = [
|
||||
compile_sql(
|
||||
"""
|
||||
SELECT department, COUNT(*) AS records, SUM(amount) AS total
|
||||
FROM records
|
||||
GROUP BY department
|
||||
ORDER BY department
|
||||
""",
|
||||
source_nodes=[inline_source()],
|
||||
)[0],
|
||||
compile_sql(
|
||||
"""
|
||||
SELECT records.department, labels.label
|
||||
FROM records
|
||||
LEFT JOIN labels ON records.department = labels.department
|
||||
ORDER BY records.department
|
||||
""",
|
||||
source_nodes=[
|
||||
inline_source(),
|
||||
inline_source(
|
||||
"labels-source",
|
||||
"labels",
|
||||
[
|
||||
{"department": "a", "label": "Alpha"},
|
||||
{"department": "b", "label": "Beta"},
|
||||
],
|
||||
),
|
||||
],
|
||||
)[0],
|
||||
compile_sql(
|
||||
"""
|
||||
SELECT * FROM first
|
||||
UNION ALL BY NAME
|
||||
SELECT * FROM second
|
||||
ORDER BY amount
|
||||
""",
|
||||
source_nodes=[
|
||||
inline_source(
|
||||
"first-source",
|
||||
"first",
|
||||
[{"amount": 2}, {"amount": None}],
|
||||
),
|
||||
inline_source(
|
||||
"second-source",
|
||||
"second",
|
||||
[{"amount": 1}],
|
||||
),
|
||||
],
|
||||
)[0],
|
||||
]
|
||||
for graph in graphs:
|
||||
with self.subTest(nodes=[node.type for node in graph.nodes]):
|
||||
reference = execute_typed_graph(
|
||||
graph,
|
||||
backend="reference",
|
||||
budget=ExecutionBudget(max_output_rows=100),
|
||||
)
|
||||
analytical = execute_typed_graph(
|
||||
graph,
|
||||
backend="duckdb",
|
||||
budget=ExecutionBudget(
|
||||
max_output_rows=100,
|
||||
max_wall_seconds=10,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(reference.rows, analytical.rows)
|
||||
self.assertEqual(
|
||||
reference.contract.row_count,
|
||||
analytical.contract.row_count,
|
||||
)
|
||||
self.assertEqual(
|
||||
[field.name for field in reference.batch.schema.fields],
|
||||
[field.name for field in analytical.batch.schema.fields],
|
||||
)
|
||||
self.assertEqual(
|
||||
reference.batch.schema.semantic_hash,
|
||||
analytical.batch.schema.semantic_hash,
|
||||
)
|
||||
self.assertEqual(
|
||||
reference.contract.diagnostics,
|
||||
analytical.contract.diagnostics,
|
||||
)
|
||||
self.assertEqual(
|
||||
reference.contract.lineage.source_fingerprints,
|
||||
analytical.contract.lineage.source_fingerprints,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user