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

sadpandajoe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 026ca216cb2 fix(sqllab): implement query cancellation for ClickHouse 
(Stop button) (#45070)
026ca216cb2 is described below

commit 026ca216cb24fa6c80890ba2bb510aedc1048d99
Author: Joe Li <[email protected]>
AuthorDate: Fri Oct 23 10:19:00 2026 -0700

    fix(sqllab): implement query cancellation for ClickHouse (Stop button) 
(#45070)
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
    Co-authored-by: Evan Rusackas <[email protected]>
    Co-authored-by: Evan Rusackas <[email protected]>
---
 superset/db_engine_specs/clickhouse.py             | 223 +++++++++++++
 .../db_engine_specs/test_clickhouse.py             | 118 +++++++
 tests/unit_tests/dao/test_query_stop_race.py       |  95 ++++++
 .../unit_tests/db_engine_specs/test_clickhouse.py  | 344 ++++++++++++++++++++-
 tests/unit_tests/sql/execution/test_celery_task.py |  47 +++
 tests/unit_tests/sql_lab_test.py                   |  38 +++
 6 files changed, 864 insertions(+), 1 deletion(-)

diff --git a/superset/db_engine_specs/clickhouse.py 
b/superset/db_engine_specs/clickhouse.py
index d748cd0973b..433ea8b2e61 100644
--- a/superset/db_engine_specs/clickhouse.py
+++ b/superset/db_engine_specs/clickhouse.py
@@ -18,6 +18,7 @@ from __future__ import annotations
 
 import logging
 import re
+import uuid
 from datetime import datetime, timezone
 from typing import Any, cast, TYPE_CHECKING
 from urllib import parse
@@ -31,6 +32,9 @@ from sqlalchemy.engine.url import URL
 from sqlalchemy.sql.expression import ColumnElement
 from urllib3.exceptions import NewConnectionError
 
+from superset import db
+from superset.common.db_query_status import QueryStatus
+from superset.constants import QUERY_CANCEL_KEY
 from superset.databases.utils import make_url_safe
 from superset.db_engine_specs.base import (
     BaseEngineSpec,
@@ -47,10 +51,27 @@ from superset.utils.network import is_hostname_valid, 
is_port_open
 
 if TYPE_CHECKING:
     from superset.models.core import Database
+    from superset.models.sql_lab import Query
 
 logger = logging.getLogger(__name__)
 
 
+class ClickHouseQueryStoppedBeforeDispatchError(Exception):
+    """
+    Raised internally, right before a statement would be sent to
+    ClickHouse, when ``query.status`` is already STOPPED. Any exception
+    type works for this purpose: ``execute_query()``
+    (``superset/sql_lab.py``) wraps ``execute_with_cursor`` in its own
+    generic ``except Exception`` handler that refreshes ``query`` and
+    converts whatever lands here into the standard
+    ``SqlLabQueryStoppedException`` flow if the status is STOPPED. A
+    dedicated type exists only so the intent reads clearly at the raise
+    site and in stack traces -- importing ``SqlLabQueryStoppedException``
+    itself would be circular, since ``sql_lab.py`` already imports from
+    ``superset.db_engine_specs``.
+    """
+
+
 class ClickHouseBaseEngineSpec(BaseEngineSpec):
     """Shared engine spec for ClickHouse."""
 
@@ -729,3 +750,205 @@ class ClickHouseConnectEngineSpec(BasicParametersMixin, 
ClickHouseEngineSpec):
         # bare outer SELECT to satisfy it without altering or dropping any of
         # the mutator's comments.
         return f"SELECT * FROM (\n{sql}\n) AS __superset_type_probe LIMIT 0"  
# noqa: S608
+
+    # ClickHouse never hands back a server-assigned identifier Superset could
+    # read and reuse later -- but its HTTP interface accepts a client-chosen
+    # `query_id`, and `KILL QUERY WHERE query_id = ...` accepts that same id
+    # from a second, independent connection. Minting it here needs no round
+    # trip: `has_query_id_before_execute` (the base default) runs this before
+    # the statement is sent, so the id is threaded into `execute_with_cursor`
+    # below in time to be passed to the driver.
+    #
+    # Only `execute_with_cursor` does that threading. The GTF chart-data path
+    # (`Database.get_df`) dispatches through the base `execute()` instead,
+    # which never forwards a `settings` kwarg, so an id minted for that path
+    # would never reach the driver -- `cancel_query`'s later `KILL QUERY
+    # WHERE query_id = ...` would have nothing real to kill, and its "nothing
+    # to confirm" fallback would then report a cancel that never happened.
+    # `superset.tasks.query_cancel._CancellationQuery` stubs `query.id` as
+    # `None` for exactly this case so engines can decline gracefully; do
+    # that here rather than minting an id this engine can't actually use.
+    @classmethod
+    def get_cancel_query_id(  # pylint: disable=unused-argument
+        cls,
+        cursor: Any,
+        query: Query,
+    ) -> str | None:
+        if query.id is None:
+            return None
+        return str(uuid.uuid4())
+
+    @classmethod
+    def execute_with_cursor(
+        cls,
+        cursor: Any,
+        sql: str,
+        query: Query,
+    ) -> None:
+        """
+        Forward the id `get_cancel_query_id` recorded into `query.extra` on
+        to `execute` as a `settings` kwarg -- the base `execute_with_cursor`/
+        `execute` pair never threads per-query kwargs through to
+        `cursor.execute()`, so cancellation needs this override to reach the
+        driver at all.
+        """
+        logger.debug("Query %d: Running query: %s", query.id, sql)
+
+        # Narrows (see the exception class's own docstring for how this is
+        # converted, and RCA.md for why this is closable for ClickHouse but
+        # not a given for every engine) the window between the per-block
+        # loop's own STOPPED check (sql_lab.py, which already ran once
+        # before this call) and actually sending the statement below.
+        # Postgres/MySQL are accidentally safe in this exact window because
+        # their cancel_query() kills the whole underlying connection, so a
+        # cursor.execute() landing after that fails outright and gets
+        # caught by execute_query()'s generic exception handler. ClickHouse
+        # KILL QUERY has no such side effect when nothing has matched yet
+        # -- the connection stays healthy and an unguarded cursor.execute()
+        # here would run the statement to completion even though Superset
+        # already told the user it stopped.
+        try:
+            # flush() first: refresh() does NOT autoflush -- without this,
+            # a pending, uncommitted attribute set just before this call
+            # (e.g. the async/Celery path's own execute_fn sets
+            # query.executed_sql = sql immediately before calling
+            # execute_with_cursor, with no commit in between) would be
+            # silently discarded and reloaded back to its previous
+            # committed value instead of surviving to this query's later
+            # commits. Same idiom and same reasoning as the existing
+            # flush()-before-refresh() pairs already in sql_lab.py.
+            db.session.flush()
+            db.session.refresh(query)
+            already_stopped = query.status == QueryStatus.STOPPED
+        except Exception:  # pylint: disable=broad-except
+            # Best-effort, same as cancel_query()'s own refresh below: if
+            # this fails for any reason, fall back to dispatching the
+            # statement rather than letting a diagnostic check become a
+            # new failure mode of query execution itself.
+            already_stopped = False
+
+        if already_stopped:
+            raise ClickHouseQueryStoppedBeforeDispatchError()
+
+        cancel_query_id = query.extra.get(QUERY_CANCEL_KEY)
+        settings = {"query_id": cancel_query_id} if cancel_query_id else None
+        cls.execute(cursor, sql, query.database, settings=settings)
+        logger.debug("Query %d: Handling cursor", query.id)
+        cls.handle_cursor(cursor, query)
+
+    @classmethod
+    def execute(  # pylint: disable=unused-argument
+        cls,
+        cursor: Any,
+        query: str,
+        database: Database,
+        **kwargs: Any,
+    ) -> None:
+        if cls.arraysize:
+            cursor.arraysize = cls.arraysize
+        try:
+            cursor.execute(query, settings=kwargs.get("settings"))
+        except Exception as ex:
+            raise cls.get_dbapi_mapped_exception(ex) from ex
+
+    # ClickHouse's own enum for the `kill_status` column `KILL QUERY SYNC`
+    # returns per matched process (https://clickhouse.com/docs/sql-reference/
+    # statements/kill): "finished" is the only value confirming the query
+    # was actually terminated. The other four are documented failure/
+    # indeterminate states -- a real process matched, but termination wasn't
+    # confirmed -- and must not be reported as a successful cancel.
+    _KILL_STATUS_CONFIRMED = "finished"
+    _KILL_STATUS_KNOWN_NOT_CONFIRMED = frozenset(
+        {"waiting", "cant_cancel", "pending", "unknown_status"}
+    )
+
+    @classmethod
+    def cancel_query(cls, cursor: Any, query: Query, cancel_query_id: str) -> 
bool:
+        """
+        :param cancel_query_id: client-chosen `query_id` minted by
+            `get_cancel_query_id` and passed to the driver at execute time
+        :return: True if query cancelled successfully, False otherwise
+        """
+        # UUID4-shaped: defense-in-depth against SQL injection, mirroring
+        # every other engine's own id-shaped pattern for this check (e.g.
+        # Postgres/MySQL's `^\\d+$` for their integer ids).
+        if not cls.validate_cancel_query_id(
+            cancel_query_id,
+            r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
+            r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
+        ):
+            return False
+
+        try:
+            # SYNC blocks until ClickHouse confirms the outcome. The default
+            # ASYNC mode returns immediately with kill_status="waiting" --
+            # before termination is confirmed -- which would make this
+            # method's return value meaningless.
+            cursor.execute(  # noqa: S608
+                f"KILL QUERY WHERE query_id = '{cancel_query_id}' SYNC"
+            )
+            rows = cursor.fetchall()
+        except Exception:  # pylint: disable=broad-except
+            return False
+
+        kill_status = rows[0][0] if rows and rows[0] else None
+        if kill_status == cls._KILL_STATUS_CONFIRMED:
+            return True
+        if kill_status in cls._KILL_STATUS_KNOWN_NOT_CONFIRMED:
+            # A real process matched our query_id, but ClickHouse itself
+            # couldn't confirm termination -- there's positive evidence the
+            # query may still be running, so don't report success.
+            return False
+
+        # No recognizable kill_status landed in the first column. Verified
+        # empirically against a real server, this covers two cases that
+        # can't be told apart from this response alone:
+        #   1. No process matched `query_id` at all -- most likely the query
+        #      had already finished on its own between the stop request and
+        #      this KILL QUERY reaching the server: ClickHouse returns a
+        #      genuinely empty HTTP body for a non-matching `KILL QUERY`
+        #      (0 result_rows per its own X-ClickHouse-Summary header). The
+        #      desired end state -- query not running -- already holds.
+        #   2. clickhouse-connect==1.9.0's DB-API cursor synthesizes a
+        #      1-row "stats" result from that same X-ClickHouse-Summary
+        #      header whenever the real HTTP body is empty, so `rows` is
+        #      never actually `[]` here even when nothing matched; its
+        #      first column is an int (a row count), never one of the
+        #      known kill_status strings above.
+        # Both report "nothing to confirm", not "confirmed failure" --
+        # treating this as success avoids stranding the SQL Lab UI on a
+        # query that has already finished on its own. See RCA.md for the
+        # full reasoning and empirical verification.
+        #
+        # Narrow, don't ignore, the one way "nothing to confirm" can still
+        # be wrong: the query may have finished -- with a real SUCCESS/
+        # FAILED/TIMED_OUT result already committed -- *during* the
+        # KILL QUERY SYNC round-trip above, after QueryDAO.stop_query()'s
+        # own already-complete guard ran (it only checks once, before
+        # calling into this method, and never re-checks after). Returning
+        # True here would make that caller unconditionally overwrite the
+        # just-committed status with STOPPED, destroying a legitimate
+        # result. `query` is the same session-bound ORM object
+        # `QueryDAO.stop_query()` already loaded, so re-reading it is a
+        # plain metadata-DB read, not a change to sql_lab.py/daos/query.py.
+        # This narrows the race to the (much smaller) gap between this
+        # refresh and that caller's own status assignment -- it cannot be
+        # closed from here without a lock that code doesn't have either.
+        # See RCA.md for the full reasoning.
+        try:
+            db.session.refresh(query)
+            already_terminal = query.status in (
+                QueryStatus.SUCCESS,
+                QueryStatus.FAILED,
+                QueryStatus.TIMED_OUT,
+            )
+        except Exception:  # pylint: disable=broad-except
+            # Best-effort: if the refresh itself fails for any reason,
+            # fall back to the pre-existing behavior rather than letting a
+            # diagnostic check become a new failure mode of its own.
+            already_terminal = False
+
+        if already_terminal:
+            return False
+
+        return True
diff --git a/tests/testcontainers/db_engine_specs/test_clickhouse.py 
b/tests/testcontainers/db_engine_specs/test_clickhouse.py
index d66d800114a..1c0a4e9a528 100644
--- a/tests/testcontainers/db_engine_specs/test_clickhouse.py
+++ b/tests/testcontainers/db_engine_specs/test_clickhouse.py
@@ -41,6 +41,8 @@ the real app instance `tests/conftest.py` already builds for 
the rest of the
 test run rather than constructing a second one.
 """
 
+import threading
+import time
 from collections.abc import Iterator
 
 import pytest
@@ -171,3 +173,119 @@ def test_group_by_all_groups_correctly(engine: Engine) -> 
None:
         ).fetchall()
 
     assert [tuple(row) for row in rows] == [("a", 15), ("b", 7)]
+
+
+def _processes_count_for(engine: Engine, query_id: str) -> int:
+    conn = engine.raw_connection()
+    cursor = conn.cursor()
+    cursor.execute(
+        "SELECT count() FROM system.processes WHERE query_id = %(query_id)s",
+        {"query_id": query_id},
+    )
+    return cursor.fetchone()[0]
+
+
+def test_cancel_query_actually_stops_the_query_server_side(engine: Engine) -> 
None:
+    """
+    SQL Lab's Stop button must make the query actually stop running on the
+    ClickHouse server -- not just make Superset give up waiting on it. Drives
+    the real `get_cancel_query_id` -> `execute_with_cursor` -> `cancel_query`
+    sequence `superset/sql_lab.py` uses, against a real server, with a second,
+    independent connection/cursor for the cancellation itself (mirroring
+    `superset/sql_lab.py::cancel_query()`'s own `engine.raw_connection()`).
+    A regression that merely raises client-side without reaching a real
+    `KILL QUERY` on the server would leave the query in `system.processes`
+    and this test would catch that even though the client-side call returned.
+
+    Two things this test deliberately gets right that an earlier version of
+    it got wrong (caught by review, see RCA.md):
+
+    - `sleep(N)` runs once PER BLOCK, not once per row. `numbers(20)` alone
+      fits in a single block under ClickHouse's default `max_block_size`, so
+      `SELECT sleep(1) FROM numbers(20)` finishes in ~1s regardless of
+      whether cancellation works at all -- a no-op `KILL QUERY` would look
+      identical to a real one. `SETTINGS max_block_size=1` forces 20
+      separate blocks (and therefore 20 separate ~1s `sleep()` calls),
+      guaranteeing the query is still running seconds in, verified
+      empirically against a real server.
+    - The test confirms the query is ACTUALLY still running (queries
+      `system.processes` directly) before cancelling, rather than just
+      sleeping an arbitrary amount of time and hoping that's enough.
+    """
+    from tests.integration_tests.test_app import app
+
+    with app.app_context():
+        from superset.db_engine_specs.clickhouse import 
ClickHouseConnectEngineSpec
+
+    from superset.models.sql_lab import Query
+
+    query = Query()
+    query.id = 1
+
+    worker_conn = engine.raw_connection()
+    worker_cursor = worker_conn.cursor()
+
+    cancel_query_id = ClickHouseConnectEngineSpec.get_cancel_query_id(
+        worker_cursor, query
+    )
+    assert cancel_query_id is not None
+    query.set_extra_json_key("cancel_query", cancel_query_id)
+
+    # Forces 20 separate ~1s `sleep()` calls (one per block), guaranteeing
+    # genuine multi-second runtime regardless of ClickHouse's default block
+    # sizing -- see the docstring above.
+    sql = "SELECT sleep(1) FROM numbers(20) SETTINGS max_block_size=1"
+    elapsed_seconds: list[float] = []
+    errors: list[BaseException] = []
+    start = time.monotonic()
+
+    def run_query() -> None:
+        try:
+            ClickHouseConnectEngineSpec.execute_with_cursor(worker_cursor, 
sql, query)
+        except Exception as ex:  # pylint: disable=broad-except
+            # A genuine mid-flight kill makes clickhouse-connect raise
+            # QUERY_WAS_CANCELLED on the worker's own cursor.execute() call
+            # -- verified empirically against a real server. This is
+            # expected and not itself a failure; the real proof of
+            # cancellation is `system.processes` below, not whether this
+            # particular call happened to raise.
+            errors.append(ex)
+        finally:
+            elapsed_seconds.append(time.monotonic() - start)
+
+    worker_thread = threading.Thread(target=run_query)
+    worker_thread.start()
+
+    # Confirm the query is ACTUALLY still running server-side before
+    # cancelling it -- not just "we slept a bit and assumed so". Polls
+    # briefly since the query needs a moment to register in
+    # system.processes after the thread starts.
+    deadline = time.monotonic() + 10
+    while time.monotonic() < deadline:
+        if _processes_count_for(engine, cancel_query_id) == 1:
+            break
+        time.sleep(0.2)
+    else:
+        raise AssertionError(
+            "query never appeared in system.processes -- can't prove "
+            "cancellation works against a query that was never confirmed "
+            "running in the first place"
+        )
+
+    cancel_conn = engine.raw_connection()
+    cancel_cursor = cancel_conn.cursor()
+    assert (
+        ClickHouseConnectEngineSpec.cancel_query(cancel_cursor, query, 
cancel_query_id)
+        is True
+    )
+
+    worker_thread.join(timeout=25)
+    assert not worker_thread.is_alive(), (
+        "worker query did not stop after cancel_query() returned True"
+    )
+    # The real assertion: fewer than the full ~20s elapsed, proving the
+    # query was actually killed server-side rather than running to
+    # completion while Superset's cancel_query() call merely returned True.
+    assert elapsed_seconds[0] < 15
+
+    assert _processes_count_for(engine, cancel_query_id) == 0
diff --git a/tests/unit_tests/dao/test_query_stop_race.py 
b/tests/unit_tests/dao/test_query_stop_race.py
index 324fc1d7def..62f5a21c0b8 100644
--- a/tests/unit_tests/dao/test_query_stop_race.py
+++ b/tests/unit_tests/dao/test_query_stop_race.py
@@ -1224,3 +1224,98 @@ def 
test_handle_query_error_status_refresh_does_not_autoflush_other_state(
         f"expected no write before the targeted status SELECT, but found "
         f"one: {preceding}"
     )
+
+
+def test_execute_with_cursor_refresh_does_not_discard_pending_executed_sql(
+    app: Any, session: Session
+) -> None:
+    """
+    Round 9 review: `ClickHouseConnectEngineSpec.execute_with_cursor()`'s own
+    pre-dispatch STOPPED check does `db.session.refresh(query)` -- a *full*
+    refresh (every column, not the targeted single-attribute one
+    `handle_query_error()` uses), since nothing it does locally beforehand
+    needs protecting from an autoflush the way `handle_query_error()`'s
+    exception-handler callers do. But `refresh()` does NOT autoflush on its
+    own either: the async/Celery path's own `_make_execute_fn().
+    execute_with_stats()` (superset/sql/execution/celery_task.py) sets
+    `query.executed_sql = sql` immediately before calling
+    `execute_with_cursor()`, with no commit or flush in between. Without a
+    `flush()` first, that pending, uncommitted `executed_sql` would be
+    silently discarded and reloaded back to its previous committed value --
+    leaving the persisted Query row describing the wrong executed SQL,
+    verified here by actually reproducing the sequence and reading the
+    column back from a fresh query, not just asserting the in-memory
+    attribute.
+
+    Getting this reproduction right required care: a commit expires every
+    attribute by default (`expire_on_commit=True`), and accessing an
+    *expired* attribute (e.g. this method's own `query.id` in its first
+    debug-log line) triggers an incidental autoflush+reload as a side
+    effect of the lazy-load SELECT -- which would flush the pending
+    `executed_sql` "for free" and mask the bug entirely. The real call
+    sequence doesn't hit that accidental protection: `execute_sql_with_cursor`
+    (superset/sql/execution/executor.py) calls `check_stopped_fn()` --
+    which itself does a plain `db.session.refresh(query)` -- immediately
+    before `execute_fn()` in the *same* loop iteration, so every attribute
+    is already loaded (not expired) by the time `execute_with_cursor` sets
+    `executed_sql` and runs. This test replicates that ordering explicitly
+    (an explicit refresh to simulate `check_stopped_fn()`, then the dirty
+    write, then the call) rather than relying on a bare post-commit object,
+    which would accidentally dodge the bug it's meant to catch.
+    """
+    from unittest.mock import MagicMock
+
+    from superset import db
+    from superset.common.db_query_status import QueryStatus
+    from superset.models.core import Database
+    from superset.models.sql_lab import Query
+
+    engine = db.session.get_bind()
+    Query.metadata.create_all(engine)  # pylint: disable=no-member
+
+    database = Database(database_name="my_database", 
sqlalchemy_uri="sqlite://")
+    query_obj = Query(
+        client_id="pending-executed-sql",
+        database=database,
+        tab_name="test_tab",
+        sql_editor_id="test_editor_id",
+        sql="select * from bar",
+        select_sql="select * from bar",
+        executed_sql="select * from bar",
+        limit=100,
+        select_as_cta=False,
+        status=QueryStatus.RUNNING,
+    )
+    db.session.add(database)
+    db.session.add(query_obj)
+    db.session.commit()
+
+    with app.app_context():
+        from superset.db_engine_specs.clickhouse import 
ClickHouseConnectEngineSpec
+
+    # Simulates execute_sql_with_cursor()'s own check_stopped_fn() call,
+    # which always runs immediately before execute_fn() in the same loop
+    # iteration -- this is what leaves every attribute already loaded (not
+    # expired) by the time the dirty write below happens, matching the real
+    # sequence instead of a freshly-expired, accidentally-protected object.
+    db.session.refresh(query_obj)
+
+    new_sql = "select * from bar where id = 42"
+    # Mirrors celery_task.py's execute_with_stats(): set the attribute, then
+    # call execute_with_cursor() with no flush/commit in between.
+    query_obj.executed_sql = new_sql
+
+    cursor_mock = MagicMock()
+    ClickHouseConnectEngineSpec.execute_with_cursor(cursor_mock, new_sql, 
query_obj)
+
+    cursor_mock.execute.assert_called_once_with(new_sql, settings=None)
+
+    db.session.expire_all()
+    refreshed = (
+        
db.session.query(Query).filter_by(client_id="pending-executed-sql").one()
+    )
+    assert refreshed.executed_sql == new_sql, (
+        f"expected the pending executed_sql set just before "
+        f"execute_with_cursor() to survive its internal refresh(), got "
+        f"{refreshed.executed_sql!r}"
+    )
diff --git a/tests/unit_tests/db_engine_specs/test_clickhouse.py 
b/tests/unit_tests/db_engine_specs/test_clickhouse.py
index ac22b949cef..3b78fcb24f7 100644
--- a/tests/unit_tests/db_engine_specs/test_clickhouse.py
+++ b/tests/unit_tests/db_engine_specs/test_clickhouse.py
@@ -17,7 +17,7 @@
 
 from datetime import datetime, timedelta, timezone
 from typing import Any, Optional
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
 
 import pytest
 from sqlalchemy.engine.url import make_url
@@ -765,3 +765,345 @@ def test_multivalue_contains_any_numeric_coercion_sql() 
-> None:
     # element type) and confirm the emitted array literal is numeric.
     expr = spec.array_contains_any(column("scores"), [5, 6])
     assert _compile(expr) == "hasAny(scores, array(5, 6))"
+
+
+def test_get_cancel_query_id_mints_a_uuid_without_a_round_trip() -> None:
+    """
+    No SQL should be issued to obtain the id: ClickHouse never hands one
+    back, the id is entirely client-chosen (verified against a real server
+    in tests/testcontainers/db_engine_specs/test_clickhouse.py).
+    """
+    import uuid
+
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    query = Query()
+    query.id = 1
+    cursor_mock = Mock()
+    cancel_query_id = spec.get_cancel_query_id(cursor_mock, query)
+
+    assert cancel_query_id is not None
+    assert uuid.UUID(cancel_query_id).version == 4
+    cursor_mock.execute.assert_not_called()
+
+
+def test_get_cancel_query_id_declines_for_the_gtf_chart_data_stub() -> None:
+    """
+    `superset.tasks.query_cancel._CancellationQuery` stubs `query.id` as
+    `None` for the GTF chart-data path, which dispatches through the base
+    `execute()` rather than `execute_with_cursor` and so never forwards a
+    `settings` kwarg. An id minted for that path would never reach the
+    driver, so `cancel_query`'s `KILL QUERY WHERE query_id = ...` would have
+    nothing real to kill and would still report success via its "nothing to
+    confirm" fallback -- worse than the pre-existing, honest non-cancellable
+    behavior. Decline by returning `None` instead.
+    """
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    cursor_mock = Mock()
+    query = Query()
+    assert query.id is None
+
+    assert spec.get_cancel_query_id(cursor_mock, query) is None
+    cursor_mock.execute.assert_not_called()
+
+
+def test_execute_with_cursor_forwards_recorded_cancel_id_as_query_id_setting() 
-> None:
+    """
+    `execute_with_cursor` is the only place that can thread the id recorded
+    by `get_cancel_query_id` into the driver -- the base `execute`/
+    `execute_with_cursor` pair (superset/db_engine_specs/base.py) never
+    forwards per-query kwargs to `cursor.execute()`. Without this, the id
+    Superset records in `query.extra` would never reach the server, and
+    `KILL QUERY WHERE query_id = ...` would have nothing real to kill.
+    """
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    query = Query()
+    query.id = 1
+    query.set_extra_json_key("cancel_query", 
"d14b9e5b-47ec-478e-bc7c-f8b56be98576")
+    cursor_mock = Mock()
+
+    spec.execute_with_cursor(cursor_mock, "SELECT 1", query)
+
+    cursor_mock.execute.assert_called_once_with(
+        "SELECT 1",
+        settings={"query_id": "d14b9e5b-47ec-478e-bc7c-f8b56be98576"},
+    )
+
+
+def test_execute_with_cursor_omits_settings_when_no_cancel_id_recorded() -> 
None:
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    query = Query()
+    query.id = 1
+    cursor_mock = Mock()
+
+    spec.execute_with_cursor(cursor_mock, "SELECT 1", query)
+
+    cursor_mock.execute.assert_called_once_with("SELECT 1", settings=None)
+
+
+def test_execute_with_cursor_refuses_to_dispatch_an_already_stopped_query() -> 
None:
+    """
+    The window this closes: the per-block loop's own STOPPED check
+    (sql_lab.py, which already ran once before this call) can pass, and
+    then Stop can be clicked and committed before the statement below is
+    actually sent. Unlike Postgres/MySQL (whose cancel_query() kills the
+    whole connection, so a cursor.execute() landing in this window fails
+    outright and gets caught upstream), ClickHouse's KILL QUERY has no such
+    side effect when nothing has matched yet -- without this check, the
+    statement would be sent and run to completion even though Superset
+    already told the user it stopped.
+    """
+    from superset.common.db_query_status import QueryStatus
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+        ClickHouseQueryStoppedBeforeDispatchError,
+    )
+    from superset.models.sql_lab import Query
+
+    query = Query()
+    query.id = 1
+    query.status = QueryStatus.STOPPED
+    cursor_mock = Mock()
+
+    with patch("superset.db_engine_specs.clickhouse.db.session.refresh"):
+        with pytest.raises(ClickHouseQueryStoppedBeforeDispatchError):
+            spec.execute_with_cursor(cursor_mock, "SELECT 1", query)
+
+    # The real assertion: the statement must never reach the driver.
+    cursor_mock.execute.assert_not_called()
+
+
+def test_execute_with_cursor_dispatches_normally_when_not_stopped() -> None:
+    from superset.common.db_query_status import QueryStatus
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    query = Query()
+    query.id = 1
+    query.status = QueryStatus.RUNNING
+    cursor_mock = Mock()
+
+    with patch("superset.db_engine_specs.clickhouse.db.session.refresh"):
+        spec.execute_with_cursor(cursor_mock, "SELECT 1", query)
+
+    cursor_mock.execute.assert_called_once_with("SELECT 1", settings=None)
+
+
+def test_cancel_query_issues_kill_query_sync_for_the_recorded_id() -> None:
+    """
+    SYNC is required: the default ASYNC mode returns immediately with
+    kill_status="waiting", before ClickHouse has confirmed the query is
+    actually gone -- verified empirically against a real server (see
+    RCA.md). Without SYNC, cancel_query's return value would be meaningless.
+    """
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    cursor_mock = Mock()
+    cancel_query_id = "d14b9e5b-47ec-478e-bc7c-f8b56be98576"
+    cursor_mock.fetchall.return_value = [
+        ["finished", cancel_query_id, "default", "SELECT 1"]
+    ]
+
+    assert spec.cancel_query(cursor_mock, Query(), cancel_query_id) is True
+    cursor_mock.execute.assert_called_once_with(
+        f"KILL QUERY WHERE query_id = '{cancel_query_id}' SYNC"
+    )
+
+
+def test_cancel_query_returns_false_when_kill_status_not_confirmed() -> None:
+    """
+    A real process matched our query_id, but ClickHouse itself reports it
+    couldn't confirm termination (e.g. `cant_cancel`) -- there's positive
+    evidence the query may still be running, so this must not report
+    success just because `cursor.execute()` didn't raise.
+    """
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    cancel_query_id = "d14b9e5b-47ec-478e-bc7c-f8b56be98576"
+
+    for kill_status in ("waiting", "cant_cancel", "pending", "unknown_status"):
+        cursor_mock = Mock()
+        cursor_mock.fetchall.return_value = [
+            [kill_status, cancel_query_id, "default", "SELECT 1"]
+        ]
+        assert spec.cancel_query(cursor_mock, Query(), cancel_query_id) is 
False, (
+            f"kill_status={kill_status!r} must not report success"
+        )
+
+
+def test_cancel_query_returns_true_when_nothing_matched() -> None:
+    """
+    No row with a recognizable kill_status -- either a genuinely empty
+    result (no process matched, e.g. the query already finished on its
+    own) or clickhouse-connect==1.9.0's own synthesized "stats" row (its
+    DB-API cursor returns a 1-row summary derived from the
+    X-ClickHouse-Summary response header instead of an empty list when the
+    real HTTP body is empty -- verified empirically against a real server,
+    see RCA.md). Both mean "nothing to confirm", which is treated as
+    success so SQL Lab doesn't get stuck believing an already-finished
+    query is still running.
+
+    `Query()` here is transient (never added to a session), so the
+    best-effort `db.session.refresh(query)` follow-up check (see the next
+    two tests) raises and is caught, falling back to this same True --
+    exercising that fallback path, not bypassing it.
+    """
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    cancel_query_id = "d14b9e5b-47ec-478e-bc7c-f8b56be98576"
+
+    # Case 1: a genuinely empty result.
+    cursor_mock = Mock()
+    cursor_mock.fetchall.return_value = []
+    assert spec.cancel_query(cursor_mock, Query(), cancel_query_id) is True
+
+    # Case 2: clickhouse-connect's synthesized X-ClickHouse-Summary stats
+    # row -- same 9-column shape observed against a real server, first
+    # column is an int (a row count), never a kill_status string.
+    cursor_mock = Mock()
+    cursor_mock.fetchall.return_value = [[1, 834, 0, 0, 0, 0, 0, 1686330, 
"some-id"]]
+    assert spec.cancel_query(cursor_mock, Query(), cancel_query_id) is True
+
+
+def 
test_cancel_query_returns_false_when_query_finished_during_kill_round_trip() -> 
(
+    None
+):
+    """
+    The narrow race this guards: KILL QUERY SYNC finds no match because the
+    query finished -- with a real result already committed -- *during* the
+    round-trip itself, after QueryDAO.stop_query()'s own already-complete
+    guard ran (it only checks once, before calling cancel_query(), and never
+    re-checks after). Returning True here would make that caller
+    unconditionally overwrite the just-committed status with STOPPED.
+    """
+    from superset.common.db_query_status import QueryStatus
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    cancel_query_id = "d14b9e5b-47ec-478e-bc7c-f8b56be98576"
+
+    for terminal_status in (
+        QueryStatus.SUCCESS,
+        QueryStatus.FAILED,
+        QueryStatus.TIMED_OUT,
+    ):
+        query = Query()
+        query.status = terminal_status
+        cursor_mock = Mock()
+        cursor_mock.fetchall.return_value = []
+
+        with patch("superset.db_engine_specs.clickhouse.db.session.refresh"):
+            assert spec.cancel_query(cursor_mock, query, cancel_query_id) is 
False, (
+                f"status={terminal_status!r} must not be overwritten"
+            )
+
+
+def test_cancel_query_returns_true_when_refresh_shows_still_running() -> None:
+    """
+    The common case: the refresh succeeds and shows the query is still
+    genuinely RUNNING (or STOPPED already) -- nothing matched because it
+    hadn't registered yet, or some other benign reason, not because a
+    result was raced. This must stay True, or routine stops would start
+    spuriously failing.
+    """
+    from superset.common.db_query_status import QueryStatus
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    cancel_query_id = "d14b9e5b-47ec-478e-bc7c-f8b56be98576"
+
+    query = Query()
+    query.status = QueryStatus.RUNNING
+    cursor_mock = Mock()
+    cursor_mock.fetchall.return_value = []
+
+    with patch("superset.db_engine_specs.clickhouse.db.session.refresh"):
+        assert spec.cancel_query(cursor_mock, query, cancel_query_id) is True
+
+
+def test_cancel_query_rejects_non_uuid_ids() -> None:
+    """
+    Defense-in-depth against injection via a tampered/forged cancel id --
+    mirrors every other engine's own id-shaped validation (e.g. Postgres/
+    MySQL's `^\\d+$` for their integer ids).
+    """
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    cursor_mock = Mock()
+
+    assert (
+        spec.cancel_query(cursor_mock, Query(), "1; DROP TABLE 
system.processes")
+        is False
+    )
+    cursor_mock.execute.assert_not_called()
+
+
+def test_cancel_query_returns_false_on_driver_error() -> None:
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseConnectEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    cursor_mock = Mock()
+    cursor_mock.execute.side_effect = Exception("boom")
+
+    assert (
+        spec.cancel_query(cursor_mock, Query(), 
"d14b9e5b-47ec-478e-bc7c-f8b56be98576")
+        is False
+    )
+
+
+def test_legacy_clickhouse_engine_spec_has_no_cancel_support() -> None:
+    """
+    The legacy `clickhouse-sqlalchemy` driver's cursor (`ClickHouseEngineSpec`,
+    the non-recommended "clickhouse" engine) has a different `execute()`
+    signature with no `settings` kwarg, and only generates its own query id
+    internally inside `execute()` -- it isn't available beforehand the way
+    `has_query_id_before_execute` (the base default both engines use)
+    requires. Passing a `settings` kwarg to that driver's cursor would raise
+    a TypeError, so the cancel overrides below live on
+    `ClickHouseConnectEngineSpec` only; this locks in that the legacy engine
+    still falls through to the inert base defaults rather than inheriting
+    something that would break it.
+    """
+    from superset.db_engine_specs.clickhouse import (  # noqa: N813
+        ClickHouseEngineSpec as spec,
+    )
+    from superset.models.sql_lab import Query
+
+    cursor_mock = Mock()
+    assert spec.get_cancel_query_id(cursor_mock, Query()) is None
+    assert spec.cancel_query(cursor_mock, Query(), "123") is False
diff --git a/tests/unit_tests/sql/execution/test_celery_task.py 
b/tests/unit_tests/sql/execution/test_celery_task.py
index f48b6d4c789..30d7b696c41 100644
--- a/tests/unit_tests/sql/execution/test_celery_task.py
+++ b/tests/unit_tests/sql/execution/test_celery_task.py
@@ -1496,6 +1496,53 @@ def test_execute_sql_task_unhandled_exception(
     assert result["status"] == QueryStatusEnum.FAILED
 
 
+def test_execute_sql_task_preserves_stopped_status_despite_dispatch_exception(
+    mocker: MockerFixture,
+    app_context: None,
+    mock_query: MagicMock,
+) -> None:
+    """
+    Regression guard, not a characterization of a gap: an exception that
+    reaches `execute_sql_task`'s outer handler while `query.status` is
+    already STOPPED must not get overwritten to FAILED -- regardless of
+    engine, and regardless of why the exception was raised (a real
+    cancel-induced driver error for any engine whose cancellation mechanism
+    can make a subsequent/concurrent `cursor.execute()` fail, not something
+    specific to ClickHouse).
+
+    This used to be a real, disclosed gap in `_handle_query_error`, which
+    only special-cased TIMED_OUT and had no equivalent STOPPED check. It is
+    now fixed upstream on master (independently of this PR, picked up via
+    merge): `_handle_query_error` checks
+    `_make_check_stopped_fn(query, preserve_pending=True)()` first and
+    returns the recorded STOPPED outcome via `_stopped_query_payload()`
+    instead of overwriting it. Kept as a regression guard for this
+    property, since it's exactly the invariant this PR's own
+    `ClickHouseQueryStoppedBeforeDispatchError` (see
+    `db_engine_specs/clickhouse.py::execute_with_cursor`) relies on holding
+    on the Celery path too.
+    """
+    from superset.sql.execution.celery_task import execute_sql_task
+
+    mock_query.status = QueryStatusEnum.STOPPED
+
+    mocker.patch(
+        "superset.sql.execution.celery_task._get_query", 
return_value=mock_query
+    )
+    mocker.patch(
+        "superset.sql.execution.celery_task._execute_sql_statements",
+        side_effect=Exception("driver raised during dispatch"),
+    )
+    mocker.patch("superset.sql.execution.celery_task.db.session")
+    mocker.patch("superset.sql.execution.celery_task.security_manager")
+    mocker.patch.dict(current_app.config, {"STATS_LOGGER": MagicMock()})
+
+    result = execute_sql_task(123, "SELECT * FROM users")
+
+    assert result["status"] == QueryStatusEnum.STOPPED
+    assert mock_query.status == QueryStatusEnum.STOPPED
+
+
 def test_execute_sql_task_success_final_commit(
     mocker: MockerFixture,
     app_context: None,
diff --git a/tests/unit_tests/sql_lab_test.py b/tests/unit_tests/sql_lab_test.py
index aa9b04400a9..fe91caa1cc7 100644
--- a/tests/unit_tests/sql_lab_test.py
+++ b/tests/unit_tests/sql_lab_test.py
@@ -126,6 +126,44 @@ def test_execute_query(mocker: MockerFixture, app: None) 
-> None:
     SupersetResultSet.assert_called_with([(42,)], cursor.description, 
db_engine_spec)
 
 
+def test_execute_query_converts_stopped_exception_to_sqllab_query_stopped(
+    mocker: MockerFixture, app: None
+) -> None:
+    """
+    This is the generic mechanism `ClickHouseConnectEngineSpec.
+    execute_with_cursor` relies on to refuse dispatching an already-STOPPED
+    query without needing to import or construct `SqlLabQueryStoppedException`
+    itself (which would be circular -- `sql_lab.py` already imports from
+    `superset.db_engine_specs`): any exception a `db_engine_spec`'s
+    `execute_with_cursor` raises is caught here, `query` is refreshed, and if
+    its status is STOPPED it's converted into `SqlLabQueryStoppedException`
+    -- regardless of the exception's type or message. Verified directly
+    against `execute_query` (not assumed) so a future change to this
+    mechanism would be caught here first.
+    """
+    from superset.sql_lab import SqlLabQueryStoppedException
+
+    query = mocker.MagicMock()
+    query.executed_sql = "SELECT 42 AS answer"
+    query.status = QueryStatus.STOPPED
+    query.limit = 1
+    database = query.database
+    database.allow_dml = False
+    db_engine_spec = database.db_engine_spec
+    db_engine_spec.execute_with_cursor.side_effect = Exception(
+        "refused to dispatch an already-stopped query"
+    )
+
+    cursor = mocker.MagicMock()
+    # Refreshing a MagicMock is a no-op; `query.status` is already set above
+    # to simulate QueryDAO.stop_query() having committed STOPPED during the
+    # window between the per-block loop's own earlier check and this call.
+    mocker.patch("superset.sql_lab.db.session.refresh", return_value=None)
+
+    with pytest.raises(SqlLabQueryStoppedException):
+        execute_query(query, cursor=cursor, log_params={})
+
+
 def test_get_query_rolls_back_session_before_retrying(
     mocker: MockerFixture, app: SupersetApp
 ) -> None:

Reply via email to