This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 7722b1b2e03 Stop `DataFusionToolset` from materializing full results 
for a capped query (#73384)
7722b1b2e03 is described below

commit 7722b1b2e033c09a3994ed252e5e6beabd383876
Author: Jyun-An Chen <[email protected]>
AuthorDate: Thu Sep 24 19:22:14 2026 +0800

    Stop `DataFusionToolset` from materializing full results for a capped query 
(#73384)
    
    * Stop DataFusionToolset from materializing full results for a capped query
    
    * Document the DataFusionToolset row limit and its EXPLAIN side effect
    
    The bounded-results documentation still described the old behaviour, where
    the engine materialized the whole result before the toolset bounded it, so
    it now contradicted the code and the missing total_rows. The row limit also
    makes EXPLAIN fail, which is only reachable with allow_writes=True and is
    easier to meet in the docstring than in an error from the agent.
---
 providers/common/ai/docs/toolsets/datafusion.rst   |  8 +++++---
 providers/common/ai/docs/toolsets/sql.rst          | 11 +++++++----
 .../providers/common/ai/toolsets/datafusion.py     | 16 ++++++++++------
 .../unit/common/ai/toolsets/test_datafusion.py     | 22 ++++++++++++----------
 4 files changed, 34 insertions(+), 23 deletions(-)

diff --git a/providers/common/ai/docs/toolsets/datafusion.rst 
b/providers/common/ai/docs/toolsets/datafusion.rst
index ae44d889ebc..fc83a90f8b9 100644
--- a/providers/common/ai/docs/toolsets/datafusion.rst
+++ b/providers/common/ai/docs/toolsets/datafusion.rst
@@ -103,9 +103,11 @@ Iceberg is looked up by ``db_name`` instead, and 
``DataSourceConfig`` raises
   this toolset "does not prevent the agent from reading any registered data
   source". The registration list is therefore the whole boundary: register
   exactly what the agent may read.
-- It bounds the payload, not the scan. DataFusion has already materialized the
-  full result before ``max_rows`` and ``max_result_bytes`` apply, so those 
limits
-  protect the model's context, not the cost of the query.
+- It bounds what the engine materializes, not what it scans. The ``query`` tool
+  runs the statement with a ``LIMIT`` of ``max_rows + 1``, so DataFusion never
+  builds a larger result than that, but a plan that has to read every row 
before
+  it can return one -- an aggregation, a sort, a late-matching filter -- still
+  pays for the whole scan.
 - It cannot tell failure kinds apart precisely. The DataFusion Python bindings
   expose no native exception types, so the retry decision is made by matching 
the
   error message against regular expressions, which a wording change upstream 
can
diff --git a/providers/common/ai/docs/toolsets/sql.rst 
b/providers/common/ai/docs/toolsets/sql.rst
index fa82aed4559..b34433ef8bd 100644
--- a/providers/common/ai/docs/toolsets/sql.rst
+++ b/providers/common/ai/docs/toolsets/sql.rst
@@ -231,9 +231,10 @@ costs. How much is saved depends on the driver: with a 
server-side cursor the
 remaining rows are never sent, while a client-buffering driver (psycopg2's 
default
 cursor, MySQLdb) has already received them and only the per-row conversion is 
skipped.
 Hooks whose cursor is not DBAPI 2.0 (``ExasolHook`` passes a pyexasol 
statement) fall
-back to a full fetch, and ``DataFusionToolset`` materializes the full result 
in the
-engine before the toolset sees it; in both the payload is bounded but the 
transfer is
-not.
+back to a full fetch, where the payload is bounded but the transfer is not.
+``DataFusionToolset`` pushes the bound into the query instead: it runs the 
statement
+with a DataFusion ``LIMIT`` of ``max_rows + 1``, so the engine never 
materializes more
+than that and the extra row only signals truncation.
 
 **A byte budget bounds the payload.** ``max_rows`` caps rows, which says 
nothing about
 size -- one row of a 3000-column table is larger than a thousand rows of a 
narrow one.
@@ -251,7 +252,9 @@ it. The result says which limit it hit:
 or the column names alone exceed the budget, the result carries a ``hint`` 
telling the
 agent to narrow its projection -- the only move that helps. ``total_rows`` is 
present
 when the driver reports a row count for the query; several (SQLite, some 
warehouse
-drivers) do not, and it is then omitted rather than guessed.
+drivers) do not, and it is then omitted rather than guessed. 
``DataFusionToolset``
+never reports it, because it reads only ``max_rows + 1`` rows and so has no 
total to
+report; an agent that needs one runs ``COUNT(*)``.
 
 The default budget is deliberately generous: the columnar shape alone shrinks 
a wide
 result several-fold, so results that fit before still fit. Lower 
``max_result_bytes``
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/datafusion.py 
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/datafusion.py
index ebc99a297b1..cc771bda05a 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/datafusion.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/datafusion.py
@@ -99,9 +99,13 @@ class DataFusionToolset(AbstractToolset[Any]):
     :param datasource_configs: One or more DataFusion data-source 
configurations.
     :param allow_writes: Allow data-modifying SQL (CREATE TABLE, CREATE VIEW,
         INSERT INTO, etc.). Default ``False`` — only SELECT-family statements
-        are permitted.
+        are permitted. ``EXPLAIN`` reaches the engine only with 
``allow_writes=True``,
+        and fails there: the ``max_rows`` limit wraps the plan, and DataFusion 
requires
+        ``EXPLAIN`` to be the root of the plan. The agent gets an error 
result, not the
+        plan.
     :param max_rows: Maximum number of rows returned from the ``query`` tool.
-        Default ``50``.
+        Default ``50``. The query is limited to ``max_rows + 1`` rows, so a 
large
+        result is never fully materialized; the extra row only signals 
truncation.
     :param max_result_bytes: Budget for the serialized ``query`` result, in 
bytes.
         Default 64 KiB. ``max_rows`` bounds rows, which says nothing about 
size: one
         row of a 3000-column table is larger than a thousand rows of a narrow 
one, and
@@ -211,12 +215,13 @@ class DataFusionToolset(AbstractToolset[Any]):
                 _validate_sql(sql)
 
             engine = self._get_engine()
-            pydict = engine.execute_query(sql)
+            try:
+                pydict = engine.session_context.sql(sql).limit(self._max_rows 
+ 1).to_pydict()
+            except Exception as e:
+                raise QueryExecutionException(f"Error while executing query: 
{e}") from e
             col_names = list(pydict.keys())
             num_rows = len(next(iter(pydict.values()), []))
 
-            # DataFusion has already materialised the full result, so unlike 
SQLToolset
-            # there is nothing left to avoid fetching -- only the payload is 
bounded.
             rows = [[pydict[col][i] for col in col_names] for i in 
range(min(num_rows, self._max_rows))]
             return build_query_result(
                 col_names,
@@ -224,7 +229,6 @@ class DataFusionToolset(AbstractToolset[Any]):
                 max_rows=self._max_rows,
                 max_result_bytes=self._max_result_bytes,
                 more_rows_available=num_rows > self._max_rows,
-                total_rows=num_rows,
             )
         except SQLSafetyError as ex:
             log.warning("query failed SQL safety validation: %s", ex)
diff --git 
a/providers/common/ai/tests/unit/common/ai/toolsets/test_datafusion.py 
b/providers/common/ai/tests/unit/common/ai/toolsets/test_datafusion.py
index f5d2572b9b1..98a8d21f853 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_datafusion.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_datafusion.py
@@ -65,7 +65,7 @@ def _make_mock_engine(
     for tname in tables:
         mock.session_context.table(tname).schema.return_value = arrow_fields
 
-    mock.execute_query.return_value = (
+    
mock.session_context.sql.return_value.limit.return_value.to_pydict.return_value 
= (
         query_result
         if query_result is not None
         else {
@@ -187,7 +187,8 @@ class TestDataFusionToolsetQuery:
     def test_truncates_at_max_rows(self):
         cfg = _make_mock_datasource_config()
         ts = DataFusionToolset([cfg], max_rows=1)
-        ts._engine = _make_mock_engine(query_result={"id": [1, 2, 3], "name": 
["a", "b", "c"]})
+        engine = _make_mock_engine(query_result={"id": [1, 2], "name": ["a", 
"b"]})
+        ts._engine = engine
 
         result = asyncio.run(
             ts.call_tool(
@@ -198,10 +199,11 @@ class TestDataFusionToolsetQuery:
             )
         )
         data = json.loads(result)
+        engine.session_context.sql.assert_called_once_with("SELECT id, name 
FROM sales_data")
+        
engine.session_context.sql.return_value.limit.assert_called_once_with(2)
         assert data["rows"] == [[1, "a"]]
         assert data["truncated"] is True
         assert data["truncated_by"] == "max_rows"
-        assert data["total_rows"] == 3
 
     def test_handles_empty_result(self):
         cfg = _make_mock_datasource_config()
@@ -324,12 +326,10 @@ class TestDataFusionToolsetQueryErrors:
         assert matches is expected
 
     def test_query_execution_exception_returns_error_json(self):
-        from airflow.providers.common.sql.datafusion.exceptions import 
QueryExecutionException
-
         cfg = _make_mock_datasource_config()
         ts = DataFusionToolset([cfg])
         engine = _make_mock_engine()
-        engine.execute_query.side_effect = QueryExecutionException("execution 
failed")
+        engine.session_context.sql.side_effect = RuntimeError("execution 
failed")
         ts._engine = engine
 
         result = asyncio.run(
@@ -382,12 +382,14 @@ class TestDataFusionToolsetQueryErrors:
         assert "get_schema" in exc_info.value.message
         assert "list_tables" in exc_info.value.message
 
-    def test_unexpected_exception_propagates(self):
+    @patch(
+        "airflow.providers.common.ai.toolsets.datafusion.build_query_result",
+        side_effect=TypeError("unexpected error"),
+    )
+    def test_unexpected_exception_propagates(self, _):
         cfg = _make_mock_datasource_config()
         ts = DataFusionToolset([cfg])
-        engine = _make_mock_engine()
-        engine.execute_query.side_effect = TypeError("unexpected error")
-        ts._engine = engine
+        ts._engine = _make_mock_engine()
 
         with pytest.raises(TypeError, match="unexpected error"):
             asyncio.run(

Reply via email to