You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by el...@apache.org on 2026/10/19 18:50:22 UTC
(superset) 01/01: fix(tasks): let deployments propagate context into the abort-listener thread
This is an automated email from the ASF dual-hosted git repository.
eschutho pushed a commit to branch fix-task-abort-listener-context
in repository https://gitbox.apache.org/repos/asf/superset.git
commit cf2fd0f0481a52abf83818aa62cad1a128beacc8
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Mon Oct 19 18:50:09 2026 +0000
fix(tasks): let deployments propagate context into the abort-listener thread
The task abort listener polls from a background thread. Any deployment that
keeps per-request state outside the app context (e.g. a multi-tenant
workspace binding on flask.g) has none of it there, so every poll of
_check_abort_status fails and abort signals are never delivered.
Add an opt-in BACKGROUND_THREAD_CONTEXT_FACTORY config hook. It is resolved
on the calling thread and the context manager it yields wraps the listener
thread's lifetime. Default is a no-op, so behaviour is unchanged.
Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
superset/config.py | 23 +++++++++++++-
superset/coordination/base.py | 27 ++++++++++++++--
tests/unit_tests/coordination/test_service.py | 46 +++++++++++++++++++++++++++
3 files changed, 92 insertions(+), 4 deletions(-)
diff --git a/superset/config.py b/superset/config.py
index ceb47817f78..4ea0d090167 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -37,7 +37,16 @@ from contextlib import contextmanager
from datetime import timedelta
from email.mime.multipart import MIMEMultipart
from importlib.resources import files
-from typing import Any, Callable, Iterator, Literal, Optional, TYPE_CHECKING, TypedDict
+from typing import (
+ Any,
+ Callable,
+ ContextManager,
+ Iterator,
+ Literal,
+ Optional,
+ TYPE_CHECKING,
+ TypedDict,
+)
import click
from celery.schedules import crontab
@@ -3653,6 +3662,18 @@ DISTRIBUTED_COORDINATION_CONFIG: CacheConfig | None = None
# indefinitely. Defaults to 24 hours.
DISTRIBUTED_COORDINATION_SIGNAL_TTL = int(timedelta(hours=24).total_seconds())
+# Optional hook for propagating caller-thread context (e.g. a multi-tenant
+# deployment's per-workspace state) into background threads Superset spawns, such as
+# the task abort listener. Superset calls the factory on the *calling* thread, where
+# that context is live; the factory returns a zero-argument callable that the
+# background thread invokes to get a context manager, which wraps the thread's whole
+# lifetime. The context manager is responsible for pushing its own app context
+# (never share the caller's: SQLAlchemy sessions are scoped to it). Defaults to
+# None, which leaves background threads to push a bare app context as before.
+BACKGROUND_THREAD_CONTEXT_FACTORY: (
+ Callable[[], Callable[[], ContextManager[Any]]] | None
+) = None
+
# Maximum time to wait for a semantic-cache descriptor lease.
SEMANTIC_CACHE_COORDINATION_WAIT_SECONDS: float = 1.0
diff --git a/superset/coordination/base.py b/superset/coordination/base.py
index 85bbba6b724..1bb66c24ac5 100644
--- a/superset/coordination/base.py
+++ b/superset/coordination/base.py
@@ -21,10 +21,11 @@ See :mod:`superset.coordination` for the package overview.
from __future__ import annotations
+import contextlib
import logging
import threading
import time
-from typing import Any, Callable, TYPE_CHECKING, TypeVar, Union
+from typing import Any, Callable, ContextManager, TYPE_CHECKING, TypeVar, Union
from flask import current_app
from redis.exceptions import RedisError, TimeoutError as RedisTimeoutError
@@ -497,9 +498,16 @@ class CoordinationService:
:param name: optional thread name suffix for logging.
"""
stop_event = threading.Event()
+ enter_context = cls._capture_background_context()
+
+ def run() -> None:
+ with enter_context():
+ cls._run_listen_loop(
+ channel, check, on_signal, stop_event, poll_interval
+ )
+
thread = threading.Thread(
- target=cls._run_listen_loop,
- args=(channel, check, on_signal, stop_event, poll_interval),
+ target=run,
daemon=True,
name=f"coord-listen-{name or channel}",
)
@@ -525,6 +533,19 @@ class CoordinationService:
return SignalListener(thread, stop_event, wake=wake)
+ @staticmethod
+ def _capture_background_context() -> Callable[[], ContextManager[Any]]:
+ """Snapshot caller-thread context for a background thread to re-enter.
+
+ Resolves ``BACKGROUND_THREAD_CONTEXT_FACTORY`` on the calling thread (where
+ the request/task context is live) and returns the callable the background
+ thread uses to enter an equivalent context. Without a configured factory
+ the returned context manager is a no-op, leaving the thread to push a bare
+ app context per call as before.
+ """
+ factory = current_app.config.get("BACKGROUND_THREAD_CONTEXT_FACTORY")
+ return factory() if factory else contextlib.nullcontext
+
@classmethod
def _run_listen_loop(
cls,
diff --git a/tests/unit_tests/coordination/test_service.py b/tests/unit_tests/coordination/test_service.py
index 2f8f1e43022..4421b65601d 100644
--- a/tests/unit_tests/coordination/test_service.py
+++ b/tests/unit_tests/coordination/test_service.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import threading
+from typing import Callable
import pytest
from pytest_mock import MockerFixture
@@ -456,3 +457,48 @@ def test_listen_stop_wakes_blocked_backend_read(
call.args[:2] == ("ch", {"m": "__wake__"})
for call in backend.xadd.call_args_list
)
+
+
+def test_listen_runs_inside_configured_background_context(
+ app_context: None, mocker: MockerFixture
+) -> None:
+ """The factory runs on the caller thread; its context wraps the listener thread."""
+ from flask import current_app
+
+ mocker.patch.object(CoordinationService, "get_backend", return_value=None)
+ caller = threading.current_thread()
+ events: list[str] = []
+ factory_threads: list[threading.Thread] = []
+
+ class _Ctx:
+ def __enter__(self) -> None:
+ events.append("enter")
+
+ def __exit__(self, *exc: object) -> None:
+ events.append("exit")
+
+ def factory() -> Callable[[], _Ctx]:
+ factory_threads.append(threading.current_thread())
+ return _Ctx
+
+ current_app.config["BACKGROUND_THREAD_CONTEXT_FACTORY"] = factory
+ try:
+ checked_inside: list[bool] = []
+
+ def check() -> bool:
+ checked_inside.append(events == ["enter"])
+ return True
+
+ fired = threading.Event()
+ listener = CoordinationService.listen_for_signal(
+ "ch", check=check, on_signal=fired.set, poll_interval=0.01
+ )
+ assert fired.wait(timeout=2.0) is True
+ listener.stop()
+ listener._thread.join(timeout=2.0)
+ finally:
+ current_app.config.pop("BACKGROUND_THREAD_CONTEXT_FACTORY", None)
+
+ assert factory_threads == [caller]
+ assert checked_inside == [True]
+ assert events == ["enter", "exit"]