This is an automated email from the ASF dual-hosted git repository.
aminghadersohi 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 726c1ef67c9 fix(impala): cancel pending SQL Lab operations through the
live cursor (#44737)
726c1ef67c9 is described below
commit 726c1ef67c9fc9fa926baf514b3fbf2e126a4087
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Mon Oct 12 03:35:38 2026 +1000
fix(impala): cancel pending SQL Lab operations through the live cursor
(#44737)
---
superset/db_engine_specs/impala.py | 118 +++++---
.../db_engine_specs/test_impala_pending.py | 303 +++++++++++++++++++++
2 files changed, 382 insertions(+), 39 deletions(-)
diff --git a/superset/db_engine_specs/impala.py
b/superset/db_engine_specs/impala.py
index d95167a35fc..06e1f8e1c82 100644
--- a/superset/db_engine_specs/impala.py
+++ b/superset/db_engine_specs/impala.py
@@ -24,14 +24,16 @@ from datetime import datetime
from typing import Any, Optional, TYPE_CHECKING
import requests
+from celery.exceptions import SoftTimeLimitExceeded
from flask import current_app as app
from sqlalchemy import text, types
from sqlalchemy.engine.reflection import Inspector
from superset import db
-from superset.constants import QUERY_EARLY_CANCEL_KEY, TimeGrain
+from superset.constants import QUERY_CANCEL_KEY, QUERY_EARLY_CANCEL_KEY,
TimeGrain
from superset.db_engine_specs.base import BaseEngineSpec, DatabaseCategory
from superset.models.sql_lab import Query
+from superset.utils.core import QueryStatus
from superset.utils.network import is_safe_host
if TYPE_CHECKING:
@@ -105,14 +107,7 @@ class ImpalaEngineSpec(BaseEngineSpec):
@classmethod
def has_implicit_cancel(cls) -> bool:
- """
- Return True if the live cursor handles the implicit cancelation of the
query,
- False otherwise.
-
- :return: Whether the live cursor implicitly cancels the query
- :see: handle_cursor
- """
-
+ """Keep HTTP cancellation independent of the live cursor polling
loop."""
return False
@classmethod
@@ -149,61 +144,106 @@ class ImpalaEngineSpec(BaseEngineSpec):
raise cls.get_dbapi_mapped_exception(ex) from ex
return super().fetch_data(cursor, limit)
+ @classmethod
+ def _cancel_operation(cls, cursor: Any, query_id: int) -> None:
+ """Cancel the live operation and release its handles."""
+ try:
+ cursor.cancel_operation()
+ except SoftTimeLimitExceeded:
+ raise
+ except Exception: # pylint: disable=broad-except
+ logger.warning("Query %s: cancel_operation() failed", query_id)
+ # The handles are released even when the cancel RPC failed, so a
stopped
+ # query does not leave an operation open on the coordinator for the
rest
+ # of the connection's life.
+ try:
+ cursor.close_operation()
+ except SoftTimeLimitExceeded:
+ raise
+ except Exception: # pylint: disable=broad-except
+ logger.warning("Query %s: close_operation() failed", query_id)
+ try:
+ cursor.close()
+ except SoftTimeLimitExceeded:
+ raise
+ except Exception: # pylint: disable=broad-except
+ logger.warning("Query %s: close() failed", query_id)
+
@classmethod
def handle_cursor(cls, cursor: Any, query: Query) -> None:
"""Stop query and updates progress information"""
query_id = query.id
unfinished_states = (
+ "PENDING_STATE",
"INITIALIZED_STATE",
"RUNNING_STATE",
)
+ # An operation waiting for admission often starts within moments, so it
+ # is polled with a short interval that backs off to the configured one,
+ # rather than holding a short query back for a full poll interval.
+ pending_sleep_interval = 0.1
try:
status = cursor.status()
while status in unfinished_states:
db.session.refresh(query)
- query = db.session.query(Query).filter_by(id=query_id).one()
- # if query cancelation was requested prior to the
handle_cursor call, but # noqa: E501
- # the query was still executed
- # modified in stop_query in views / core.py is reflected here.
- # stop query
- if query.extra.get(QUERY_EARLY_CANCEL_KEY):
- cursor.cancel_operation()
- cursor.close_operation()
- cursor.close()
+ # Stop was requested: either before a cancel handle was
published
+ # (early-cancel flag) or through SQL Lab's stop, which persists
+ # STOPPED once cancel_query() succeeds.
+ if (
+ query.extra.get(QUERY_EARLY_CANCEL_KEY)
+ or query.status == QueryStatus.STOPPED
+ ):
+ cls._cancel_operation(cursor, query_id)
break
- # updates progress info by log
- try:
- log = cursor.get_log() or ""
- except Exception: # pylint: disable=broad-except
- logger.warning("Call to GetLog() failed")
- log = ""
-
- if log:
- match = QUERY_PROGRESS_REGEX.match(log)
- if match:
- progress = int(match.groupdict()["query_progress"])
- logger.debug(
- "Query %s: Progress total: %s", str(query_id),
str(progress)
- )
- needs_commit = False
- if progress > query.progress:
- query.progress = progress
- needs_commit = True
-
- if needs_commit:
- db.session.commit() # pylint:
disable=consider-using-transaction
sleep_interval = app.config["DB_POLL_INTERVAL_SECONDS"].get(
cls.engine, 5
)
+ # Pending/initialized operations have no execution progress
yet.
+ if status == "RUNNING_STATE":
+ try:
+ log = cursor.get_log() or ""
+ except Exception: # pylint: disable=broad-except
+ logger.warning("Call to GetLog() failed")
+ log = ""
+
+ if match := QUERY_PROGRESS_REGEX.match(log):
+ progress = int(match.groupdict()["query_progress"])
+ logger.debug("Query %s: Progress total: %s", query_id,
progress)
+ if progress > query.progress:
+ query.progress = progress
+ db.session.commit() # pylint:
disable=consider-using-transaction
+ else:
+ sleep_interval = min(pending_sleep_interval,
sleep_interval)
+ pending_sleep_interval *= 2
time.sleep(sleep_interval)
status = cursor.status()
+ except SoftTimeLimitExceeded:
+ # SQL Lab's own handler marks the query TIMED_OUT once this
propagates;
+ # the operation is cancelled first so it does not keep running on
the
+ # coordinator while fetch_data() waits on it.
+ cls._cancel_operation(cursor, query_id)
+ raise
except Exception: # pylint: disable=broad-except
logger.debug("Call to status() failed ")
return
+ @classmethod
+ def prepare_cancel_query(cls, query: Query) -> None:
+ """
+ Route a stop that arrives before the operation's cancel handle is
+ published to the live cursor.
+
+ The handle is only known once ``execute_async`` has returned. Until
then,
+ the early-cancel flag lets the stop succeed, and ``handle_cursor``
cancels
+ the operation as soon as it polls.
+ """
+ if QUERY_CANCEL_KEY not in query.extra:
+ query.set_extra_json_key(QUERY_EARLY_CANCEL_KEY, True)
+ db.session.commit() # pylint: disable=consider-using-transaction
+
@classmethod
def get_cancel_query_id(cls, cursor: Any, query: Query) -> Optional[str]:
"""
diff --git a/tests/unit_tests/db_engine_specs/test_impala_pending.py
b/tests/unit_tests/db_engine_specs/test_impala_pending.py
new file mode 100644
index 00000000000..e656113d1dd
--- /dev/null
+++ b/tests/unit_tests/db_engine_specs/test_impala_pending.py
@@ -0,0 +1,303 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+from celery.exceptions import SoftTimeLimitExceeded
+from flask import Flask
+
+from superset.constants import (
+ QUERY_CANCEL_KEY,
+ QUERY_DISPATCHED_KEY,
+ QUERY_EARLY_CANCEL_KEY,
+)
+from superset.db_engine_specs.impala import ImpalaEngineSpec
+from superset.sql.execution.executor import SQLExecutor
+from superset.sql_lab import cancel_query
+
+
[email protected](
+ "state", ["PENDING_STATE", "INITIALIZED_STATE", "RUNNING_STATE"]
+)
+def test_cancel_unfinished_operation(state: str) -> None:
+ """An early stop must reach the live operation in every unfinished
state."""
+ query: Mock = Mock(id=1, extra={QUERY_EARLY_CANCEL_KEY: True}, progress=0)
+ cursor: Mock = Mock()
+ cursor.status.side_effect = [state, "FINISHED_STATE"]
+ with patch("superset.db_engine_specs.impala.db"):
+ ImpalaEngineSpec.handle_cursor(cursor, query)
+ cursor.cancel_operation.assert_called_once_with()
+ cursor.close_operation.assert_called_once_with()
+ cursor.close.assert_called_once_with()
+ cursor.get_log.assert_not_called()
+
+
+def test_pending_operation_is_polled_without_progress() -> None:
+ """Pending work must not escape the cancel/progress polling loop."""
+ app: Flask = Flask(__name__)
+ app.config["DB_POLL_INTERVAL_SECONDS"] = {"impala": 0}
+ query: Mock = Mock(id=1, extra={}, progress=0)
+ cursor: Mock = Mock()
+ cursor.status.side_effect = [
+ "PENDING_STATE",
+ "INITIALIZED_STATE",
+ "RUNNING_STATE",
+ "FINISHED_STATE",
+ ]
+ cursor.get_log.return_value = "Query abc: 25% Complete"
+ with app.app_context(), patch("superset.db_engine_specs.impala.db"):
+ ImpalaEngineSpec.handle_cursor(cursor, query)
+ assert cursor.status.call_count == 4
+ cursor.get_log.assert_called_once_with()
+ assert query.progress == 25
+ cursor.cancel_operation.assert_not_called()
+
+
[email protected]("use_executor", [False, True])
+def test_stop_with_cancel_id_uses_http(use_executor: bool) -> None:
+ """Stop must reach Impala even when no worker is polling the live
cursor."""
+ app: Flask = Flask(__name__)
+ app.config["IMPALA_CANCEL_QUERY_ALLOW_INTERNAL_HOSTS"] = True
+ cancel_id: str = "0123456789abcdef:fedcba9876543210"
+ query: MagicMock = MagicMock(extra={QUERY_CANCEL_KEY: cancel_id})
+ query.database.db_engine_spec = ImpalaEngineSpec
+ query.database.url_object.host = "impala.example.com"
+
+ post: MagicMock
+ with (
+ app.app_context(),
+ patch("superset.db_engine_specs.impala.requests.post") as post,
+ ):
+ post.return_value.status_code = 200
+ if use_executor:
+ assert SQLExecutor._cancel_query(query.database, query)
+ else:
+ assert cancel_query(query)
+
+ post.assert_called_once_with(
+ f"http://impala.example.com:25000/cancel_query?query_id={cancel_id}",
+ timeout=3,
+ allow_redirects=False,
+ )
+
+
+def test_stopped_status_cancels_pending_operation() -> None:
+ """Publishing a handle must not lose Stop when no early flag is present."""
+ query: Mock = Mock(id=1, extra={}, status="stopped", progress=0)
+ cursor: Mock = Mock()
+ cursor.status.return_value = "PENDING_STATE"
+ with patch("superset.db_engine_specs.impala.db"):
+ ImpalaEngineSpec.handle_cursor(cursor, query)
+ cursor.cancel_operation.assert_called_once_with()
+ cursor.close_operation.assert_called_once_with()
+ cursor.close.assert_called_once_with()
+ cursor.get_log.assert_not_called()
+
+
[email protected]("log", ["", "Admission queued", "Query abc: 0%
Complete"])
+def test_non_progress_logs_keep_polling(log: str) -> None:
+ """An admission log is not a progress record and must not end polling."""
+ app: Flask = Flask(__name__)
+ app.config["DB_POLL_INTERVAL_SECONDS"] = {"impala": 0}
+ query: Mock = Mock(id=1, extra={}, progress=0)
+ cursor: Mock = Mock()
+ cursor.status.side_effect = ["RUNNING_STATE", "FINISHED_STATE"]
+ cursor.get_log.return_value = log
+ with app.app_context(), patch("superset.db_engine_specs.impala.db") as db:
+ ImpalaEngineSpec.handle_cursor(cursor, query)
+ assert cursor.status.call_count == 2
+ assert query.progress == 0
+ db.session.commit.assert_not_called()
+
+
+def test_failed_cancel_still_releases_the_operation() -> None:
+ """A cancel RPC that errors must not leave the operation and cursor
open."""
+ query: Mock = Mock(id=1, extra={QUERY_EARLY_CANCEL_KEY: True}, progress=0)
+ cursor: Mock = Mock()
+ cursor.status.return_value = "PENDING_STATE"
+ cursor.cancel_operation.side_effect = RuntimeError("rpc failed")
+ with patch("superset.db_engine_specs.impala.db"):
+ ImpalaEngineSpec.handle_cursor(cursor, query)
+ cursor.close_operation.assert_called_once_with()
+ cursor.close.assert_called_once_with()
+
+
[email protected]("use_executor", [False, True])
+def test_stop_before_cancel_id_is_published_uses_live_cursor(
+ use_executor: bool,
+) -> None:
+ """
+ A stop that lands after dispatch but before ``execute_async`` publishes the
+ cancel handle succeeds, and the polling loop then cancels the operation.
+ """
+ app: Flask = Flask(__name__)
+ query: MagicMock = MagicMock(id=1, extra={QUERY_DISPATCHED_KEY: True},
progress=0)
+ query.database.db_engine_spec = ImpalaEngineSpec
+
+ def set_extra_json_key(key: str, value: bool) -> None:
+ """Record the early-cancel flag in query.extra."""
+ query.extra[key] = value
+
+ query.set_extra_json_key.side_effect = set_extra_json_key
+ post: MagicMock
+ with (
+ app.app_context(),
+ patch("superset.db_engine_specs.impala.db") as db,
+ patch("superset.db_engine_specs.impala.requests.post") as post,
+ ):
+ if use_executor:
+ assert SQLExecutor._cancel_query(query.database, query)
+ else:
+ assert cancel_query(query)
+ db.session.commit.assert_called_once_with()
+
+ cursor: Mock = Mock()
+ cursor.status.return_value = "PENDING_STATE"
+ ImpalaEngineSpec.handle_cursor(cursor, query)
+
+ assert query.extra[QUERY_EARLY_CANCEL_KEY] is True
+ post.assert_not_called()
+ cursor.cancel_operation.assert_called_once_with()
+
+
+def test_prepare_cancel_query_keeps_a_published_cancel_id() -> None:
+ """Once the handle is published, Stop goes through the HTTP cancel."""
+ query: MagicMock = MagicMock(extra={QUERY_CANCEL_KEY: "abc"})
+ with patch("superset.db_engine_specs.impala.db") as db:
+ ImpalaEngineSpec.prepare_cancel_query(query)
+ query.set_extra_json_key.assert_not_called()
+ db.session.commit.assert_not_called()
+
+
+def test_pending_operation_polls_with_backoff() -> None:
+ """A pending operation is polled quickly, backing off to the poll
interval."""
+ app: Flask = Flask(__name__)
+ app.config["DB_POLL_INTERVAL_SECONDS"] = {"impala": 0.3}
+ query: Mock = Mock(id=1, extra={}, progress=0)
+ cursor: Mock = Mock()
+ cursor.status.side_effect = [
+ "PENDING_STATE",
+ "PENDING_STATE",
+ "INITIALIZED_STATE",
+ "RUNNING_STATE",
+ "FINISHED_STATE",
+ ]
+ cursor.get_log.return_value = ""
+ sleep: MagicMock
+ with (
+ app.app_context(),
+ patch("superset.db_engine_specs.impala.db") as db,
+ patch("superset.db_engine_specs.impala.time.sleep") as sleep,
+ ):
+ ImpalaEngineSpec.handle_cursor(cursor, query)
+ assert [call.args[0] for call in sleep.call_args_list] == [0.1, 0.2, 0.3,
0.3]
+ assert db.session.refresh.call_count == 4
+ db.session.query.assert_not_called()
+
+
[email protected]("state", ["PENDING_STATE", "RUNNING_STATE"])
+def test_soft_time_limit_cancels_the_operation(state: str) -> None:
+ """
+ The soft time limit firing while the loop polls cancels the operation and
+ propagates, so SQL Lab marks the query timed out instead of waiting on it.
+ """
+ app: Flask = Flask(__name__)
+ app.config["DB_POLL_INTERVAL_SECONDS"] = {"impala": 5}
+ query: Mock = Mock(id=1, extra={}, status="running", progress=0)
+ cursor: Mock = Mock()
+ cursor.status.return_value = state
+ cursor.get_log.return_value = ""
+ with (
+ app.app_context(),
+ patch("superset.db_engine_specs.impala.db"),
+ patch(
+ "superset.db_engine_specs.impala.time.sleep",
+ side_effect=SoftTimeLimitExceeded(),
+ ),
+ ):
+ with pytest.raises(SoftTimeLimitExceeded):
+ ImpalaEngineSpec.handle_cursor(cursor, query)
+ cursor.cancel_operation.assert_called_once_with()
+ cursor.close_operation.assert_called_once_with()
+ cursor.close.assert_called_once_with()
+
+
[email protected]("method", ["cancel_operation", "close_operation",
"close"])
+def test_cancel_operation_propagates_soft_time_limit(method: str) -> None:
+ """Timeouts in each cleanup RPC must escape the cancellation helper."""
+ cursor: Mock = Mock()
+ getattr(cursor, method).side_effect = SoftTimeLimitExceeded()
+
+ with pytest.raises(SoftTimeLimitExceeded):
+ ImpalaEngineSpec._cancel_operation(cursor, 1)
+
+
[email protected]("method", ["cancel_operation", "close_operation",
"close"])
[email protected]("repeat_timeout", [False, True])
+def test_stop_cleanup_propagates_soft_time_limit(
+ method: str, repeat_timeout: bool
+) -> None:
+ """A timeout during Stop must escape whether or not cleanup also times
out."""
+ query: Mock = Mock(id=1, extra={QUERY_EARLY_CANCEL_KEY: True}, progress=0)
+ cursor: Mock = Mock()
+ cursor.status.return_value = "PENDING_STATE"
+ timeout: SoftTimeLimitExceeded = SoftTimeLimitExceeded()
+ getattr(cursor, method).side_effect = timeout if repeat_timeout else
[timeout, None]
+
+ with patch("superset.db_engine_specs.impala.db"):
+ with pytest.raises(SoftTimeLimitExceeded):
+ ImpalaEngineSpec.handle_cursor(cursor, query)
+ if not repeat_timeout:
+ cursor.close.assert_called()
+
+
[email protected]("method", ["cancel_operation", "close_operation",
"close"])
+def test_cancel_operation_tolerates_rpc_failures(method: str) -> None:
+ """Ordinary RPC failures must not prevent the remaining cleanup calls."""
+ cursor: Mock = Mock()
+ getattr(cursor, method).side_effect = RuntimeError("rpc failed")
+
+ ImpalaEngineSpec._cancel_operation(cursor, 1)
+
+ cursor.cancel_operation.assert_called_once_with()
+ cursor.close_operation.assert_called_once_with()
+ cursor.close.assert_called_once_with()
+
+
+def test_pending_refresh_observes_stop() -> None:
+ """Refreshing the existing query must observe a Stop without a second
SELECT."""
+ app: Flask = Flask(__name__)
+ app.config["DB_POLL_INTERVAL_SECONDS"] = {"impala": 0}
+ query: Mock = Mock(id=1, extra={}, status="running", progress=0)
+ cursor: Mock = Mock()
+ cursor.status.return_value = "PENDING_STATE"
+
+ def refresh_query(refreshed_query: Mock) -> None:
+ """Simulate a stop arriving on the second metadata refresh."""
+ if db.session.refresh.call_count == 2:
+ refreshed_query.status = "stopped"
+
+ with app.app_context(), patch("superset.db_engine_specs.impala.db") as db:
+ db.session.refresh.side_effect = refresh_query
+ ImpalaEngineSpec.handle_cursor(cursor, query)
+
+ assert db.session.refresh.call_count == 2
+ db.session.query.assert_not_called()
+ cursor.cancel_operation.assert_called_once_with()
+ cursor.close_operation.assert_called_once_with()
+ cursor.close.assert_called_once_with()