Skip to content

Commit 8def542

Browse files
committed
fix(gtf): harden reaper/self-fence design + doc gaps (review follow-ups)
Addresses a full design + docs re-review of the GTF resilience mechanisms. Design: - R1 (reaper): claim the FAILURE transition via CAS *before* firing destructive cleanup. Revoke and out-of-band query cancellation now run only after the reaper wins the CAS, so a briefly-stalled-but-healthy worker that revives and commits its own terminal status keeps its warehouse query (no false-positive kill, which would also cascade FAILURE to dependents). - R2 (context): elect the abort/timeout/self-fence winner under a lock so abort handlers run exactly once across the three daemon threads (listener, timeout timer, heartbeat) that can race to abort. - R3 (DAO): anchor the heartbeat write and the orphan scan on the *database* clock (dialect-aware naive-UTC now, app-side fallback) so worker/reaper host clock skew can't reap a live task early. - R5 (heartbeat): only self-fence once the fence callback is armed (task is executing); a stalled heartbeat during the pre-execution DAG wait keeps retrying so recovered connectivity resumes the task instead of forfeiting it. Docs: - Document get_dependency_payloads() (+ the immediate=True pairing), add a reap_orphaned_tasks beat-schedule snippet, name the TASK_PROGRESS_UPDATE_THROTTLE_INTERVAL / TASK_ABORT_POLLING_DEFAULT_INTERVAL tunables, and note orphan-reap/self-fence as FAILURE causes. - Fix stale 'prune cron reaps' comments (reap.py, models/tasks.py, migration). Tests: reaper no longer cancels/revokes on a lost CAS; concurrent abort election runs handlers once; heartbeat does not fence before armed.
1 parent 864ef44 commit 8def542

10 files changed

Lines changed: 206 additions & 42 deletions

File tree

docs/developer_docs/extensions/tasks.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ PENDING ──→ IN_PROGRESS ────→ SUCCESS
107107
| `IN_PROGRESS` | Executing |
108108
| `ABORTING` | Abort/timeout triggered, abort handlers running |
109109
| `SUCCESS` | Completed successfully |
110-
| `FAILURE` | Failed with error or abort/cleanup handler exception |
110+
| `FAILURE` | Failed with error, abort/cleanup handler exception, orphan reaping, or worker self-fence |
111111
| `ABORTED` | Cancelled by user/admin |
112112
| `TIMED_OUT` | Exceeded configured timeout |
113113

@@ -158,7 +158,7 @@ In the Task List UI, when a payload is defined, an info icon appears in the **De
158158

159159
#### Forcing an Immediate Write
160160

161-
By default `update_task()` throttles database writes (batching frequent updates to limit metastore load). Pass `immediate=True` to bypass throttling and write synchronously:
161+
By default `update_task()` throttles database writes (batching frequent updates to limit metastore load, at most one write per `TASK_PROGRESS_UPDATE_THROTTLE_INTERVAL` seconds, default 2). Pass `immediate=True` to bypass throttling and write synchronously:
162162

163163
```python
164164
ctx.update_task(payload={"result_cache_key": key}, immediate=True)
@@ -370,6 +370,21 @@ Because dependents hold a worker slot while awaiting their prerequisites, a deep
370370

371371
Cycles (including self-dependencies) are rejected at schedule time. Dependency edges are removed automatically when either endpoint task is pruned.
372372

373+
**Reading a prerequisite's output.** A dependent reads the payloads its prerequisites published via `ctx.get_dependency_payloads()`, which returns the prerequisites' payloads in dependency-edge order. Pair it with the prerequisite writing its result with `ctx.update_task(payload=..., immediate=True)` so the value is flushed (not held in the write-throttle buffer) by the time the dependency gate releases the dependent:
374+
375+
```python
376+
@task
377+
def totals_task() -> None:
378+
ctx = get_context()
379+
# immediate=True so the dependent observes this the moment the gate releases.
380+
ctx.update_task(payload={"result_cache_key": key}, immediate=True)
381+
382+
@task
383+
def dependent_task() -> None:
384+
ctx = get_context()
385+
upstream = ctx.get_dependency_payloads() # [{"result_cache_key": ...}, ...]
386+
```
387+
373388
## Task Scopes
374389

375390
```python
@@ -422,12 +437,22 @@ A task whose worker dies mid-execution (OOM kill, crash, lost broker message) wo
422437

423438
Enable the `reap_orphaned_tasks` beat schedule on a short interval (e.g. every minute) so orphaned tasks — and their warehouse queries — do not linger; it is separate from `prune_tasks` (a heavier retention delete that runs infrequently). Keep `GTF_ORPHAN_TASK_TIMEOUT` comfortably larger than the heartbeat interval (≥ ~3×) so a brief pause or CPU-bound stretch is not mistaken for a dead worker.
424439

440+
```python
441+
# In your superset_config.py, add to your Celery beat schedule:
442+
CELERY_CONFIG.beat_schedule["reap_orphaned_tasks"] = {
443+
"task": "reap_orphaned_tasks",
444+
"schedule": crontab(minute="*", hour="*"), # Run every minute
445+
}
446+
```
447+
448+
Unlike `prune_tasks`, the reaper takes no kwargs — it reads `GTF_ORPHAN_TASK_TIMEOUT` from config.
449+
425450
:::note Cancelling the underlying query
426451
For long-running work backed by an external query, register an `on_abort` handler that cancels it (this is how async chart-data query tasks cancel the warehouse query on engines that support cancellation). Without such a handler an abort/timeout frees the task but cannot stop the external work.
427452
:::
428453

429454
:::tip Distributed Coordination for Faster Notifications
430-
By default, abort detection and sync join-and-wait poll the task row in the metadata database. Configure `DISTRIBUTED_COORDINATION_CONFIG` (Redis/Valkey) and these become event-driven: completion and abort are signalled over Redis **Streams**, so a waiter wakes when the signal lands instead of polling the database. Because stream entries are persisted, a waiter that reads slightly late, reconnects, or fails over still receives the signal. Each signal stream keeps only its latest entry and is given a TTL, so streams for tasks that are never awaited do not accumulate; set the retention window with `DISTRIBUTED_COORDINATION_SIGNAL_TTL` (default 24h). See [Distributed Coordination Backend](/admin-docs/configuration/cache#signal-cache-backend) for configuration details.
455+
By default, abort detection and sync join-and-wait poll the task row in the metadata database (every `TASK_ABORT_POLLING_DEFAULT_INTERVAL` seconds, default 10). Configure `DISTRIBUTED_COORDINATION_CONFIG` (Redis/Valkey) and these become event-driven: completion and abort are signalled over Redis **Streams**, so a waiter wakes when the signal lands instead of polling the database. Because stream entries are persisted, a waiter that reads slightly late, reconnects, or fails over still receives the signal. Each signal stream keeps only its latest entry and is given a TTL, so streams for tasks that are never awaited do not accumulate; set the retention window with `DISTRIBUTED_COORDINATION_SIGNAL_TTL` (default 24h). See [Distributed Coordination Backend](/admin-docs/configuration/cache#signal-cache-backend) for configuration details.
431456
:::
432457

433458
## API Reference
@@ -451,6 +476,7 @@ By default, abort detection and sync join-and-wait poll the task row in the meta
451476
| Method | Description |
452477
| -------------------------------- | --------------------------------------------- |
453478
| `update_task(progress, payload, immediate=False)` | Update progress and/or custom payload (`immediate=True` bypasses write throttling) |
479+
| `get_dependency_payloads()` | Return prerequisite tasks' payloads, in dependency-edge order |
454480
| `on_cleanup(handler)` | Register cleanup handler |
455481
| `on_abort(handler)` | Register abort handler (makes task abortable) |
456482

superset/commands/tasks/reap.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
class ReapOrphanedTasksCommand(BaseCommand):
4141
"""Recover tasks abandoned by a worker that stopped refreshing its heartbeat.
4242
43-
Run from the prune cron before row deletion. For each orphan (an active task
43+
Run from the ``reap_orphaned_tasks`` beat job. For each orphan (an active task
4444
whose liveness heartbeat has gone stale — see ``TaskDAO.find_orphaned``) the
4545
command transitions the row to ``FAILURE`` and publishes completion so waiters
4646
(sync joiners, DAG dependents, chart-data pollers) unblock, then revokes the
@@ -90,12 +90,15 @@ def _reap(self, task_uuid: UUID, stats_logger: BaseStatsLogger) -> bool:
9090
)
9191
),
9292
)
93-
self._revoke(properties.get("celery_task_id"), stats_logger)
94-
self._cancel_orphaned_query(properties)
95-
9693
properties["error_message"] = ORPHAN_ERROR_MESSAGE
9794
properties["exception_type"] = "OrphanedTaskError"
9895

96+
# Claim the terminal transition FIRST, before any destructive side effect.
97+
# The CAS is atomic (row-locked) against a worker that merely stalled and
98+
# then revived to commit its own terminal status: if that worker wins, this
99+
# returns False and we must NOT cancel its (healthy) warehouse query or
100+
# revoke its job. Only once we own the FAILURE transition is the task
101+
# genuinely orphaned and its query safe to cancel out-of-band.
99102
if not TaskDAO.conditional_status_update(
100103
task_uuid,
101104
TaskStatus.FAILURE,
@@ -108,6 +111,11 @@ def _reap(self, task_uuid: UUID, stats_logger: BaseStatsLogger) -> bool:
108111

109112
db.session.commit() # pylint: disable=consider-using-transaction
110113

114+
# We own the terminal transition — the worker is gone (or lost the race),
115+
# so its Celery job and warehouse query are safe to clean up.
116+
self._revoke(properties.get("celery_task_id"), stats_logger)
117+
self._cancel_orphaned_query(properties)
118+
111119
from superset.tasks.manager import TaskManager
112120

113121
TaskManager.publish_completion(task_uuid, TaskStatus.FAILURE.value)

superset/daos/tasks.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,23 +100,59 @@ def get_id(cls, task_uuid: UUID) -> int | None:
100100
"""
101101
return db.session.query(Task.id).filter(Task.uuid == task_uuid).scalar()
102102

103+
# Dialect SQL for the database's current time as a naive UTC timestamp. Used
104+
# so the heartbeat write and the orphan scan share one clock (the DB's) rather
105+
# than the worker's and the reaper's host clocks, which can skew and cause a
106+
# live task to be reaped early. None -> fall back to an app-side timestamp on
107+
# unrecognized dialects (correct if hosts are NTP-synced).
108+
_DB_UTCNOW_SQL = {
109+
"postgresql": "timezone('UTC', now())",
110+
"mysql": "UTC_TIMESTAMP()",
111+
"sqlite": "CURRENT_TIMESTAMP",
112+
}
113+
114+
@classmethod
115+
def _db_utcnow_sql(cls) -> str | None:
116+
"""Return the dialect's naive-UTC-now SQL, or None to use an app clock."""
117+
return cls._DB_UTCNOW_SQL.get(db.session.get_bind().dialect.name)
118+
119+
@classmethod
120+
def _db_utcnow(cls) -> datetime:
121+
"""Read the database's current time as a naive UTC datetime."""
122+
if (expr := cls._db_utcnow_sql()) is None:
123+
return naive_utcnow()
124+
# type_coerce applies DateTime result processing so SQLite's text value is
125+
# parsed into a datetime like the other drivers already return.
126+
return db.session.scalar(
127+
sa.select(sa.type_coerce(sa.text(expr), sa.DateTime()))
128+
)
129+
103130
@classmethod
104131
def touch_heartbeat(cls, task_id: int) -> None:
105132
"""Bump a task's ``last_heartbeat`` to now without touching ``changed_on``.
106133
107134
Called on an interval by the executing worker's heartbeat thread so the
108-
prune cron can tell a live task from an orphaned one. Uses a raw textual
135+
reaper can tell a live task from an orphaned one. Uses a raw textual
109136
UPDATE on the single column: ``changed_on`` carries a client-side
110137
``onupdate`` default that ORM/Core updates would fire, and any bump to
111138
``changed_on`` would resurface the task in ``get_statuses_changed_since``
112139
every heartbeat (resetting client polling backoff). A textual statement
113140
bypasses that default processing entirely. Bound by integer ``id`` to
114141
sidestep ``UUIDType`` dialect handling.
142+
143+
Stamps the *database* clock (via ``_db_utcnow_sql``) rather than the
144+
worker's host clock so the value is comparable to the reaper's scan
145+
without cross-host skew (see ``find_orphaned``).
115146
"""
116-
db.session.execute(
117-
sa.text("UPDATE tasks SET last_heartbeat = :ts WHERE id = :id"),
118-
{"ts": naive_utcnow(), "id": task_id},
119-
)
147+
if (expr := cls._db_utcnow_sql()) is not None:
148+
# expr is a fixed per-dialect literal, not user input.
149+
sql = f"UPDATE tasks SET last_heartbeat = {expr} WHERE id = :id" # noqa: S608
150+
db.session.execute(sa.text(sql), {"id": task_id})
151+
else:
152+
db.session.execute(
153+
sa.text("UPDATE tasks SET last_heartbeat = :ts WHERE id = :id"),
154+
{"ts": naive_utcnow(), "id": task_id},
155+
)
120156
# Deliberate standalone commit: this is a single out-of-band column write,
121157
# not a unit of work, and must not be wrapped in the ORM transaction flow.
122158
db.session.commit() # pylint: disable=consider-using-transaction
@@ -132,10 +168,14 @@ def find_orphaned(cls, orphan_timeout_seconds: int) -> list[UUID]:
132168
still being worked on (fresh heartbeat) is never returned, so this never
133169
interferes with a live worker's cooperative abort/cleanup.
134170
171+
The staleness cutoff is anchored on the *database* clock (the same clock
172+
the heartbeat is stamped with) so it does not depend on this host's clock
173+
agreeing with the workers'.
174+
135175
Skips the base filter: internal maintenance over all tasks, not a
136176
user-facing listing.
137177
"""
138-
stale_before = naive_utcnow() - timedelta(seconds=orphan_timeout_seconds)
178+
stale_before = cls._db_utcnow() - timedelta(seconds=orphan_timeout_seconds)
139179
rows = (
140180
db.session.query(Task.uuid)
141181
.filter(

superset/migrations/versions/2026-08-21_12-00_7e2c9a4f1b83_create_task_dependencies_table.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,9 @@ def upgrade():
149149
)
150150

151151
# Liveness marker for orphan detection: the executing worker bumps
152-
# ``tasks.last_heartbeat`` on a background thread, and the prune cron reaps
153-
# ACTIVE tasks whose heartbeat has gone stale (a dead/orphaned worker). The
154-
# index backs the reaper's ``last_heartbeat < now - timeout`` scan.
152+
# ``tasks.last_heartbeat`` on a background thread, and the reap_orphaned_tasks
153+
# beat job reaps ACTIVE tasks whose heartbeat has gone stale (a dead/orphaned
154+
# worker). The index backs the reaper's ``last_heartbeat < now - timeout`` scan.
155155
add_columns(
156156
TASKS_TABLE,
157157
Column("last_heartbeat", DateTime, nullable=True),

superset/models/tasks.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ class Task(CoreTask, AuditMixinNullable, Model):
8888
ended_at = Column(DateTime, nullable=True)
8989

9090
# Liveness marker bumped by the executing worker's heartbeat thread while it
91-
# holds the task. The prune cron reaps ACTIVE tasks whose heartbeat has gone
92-
# stale (a dead/orphaned worker). Written out-of-band via a raw UPDATE (see
93-
# TaskDAO.touch_heartbeat) so a heartbeat never advances changed_on and thus
94-
# never resurfaces the task in the status-change poll.
91+
# holds the task. The reap_orphaned_tasks beat job reaps ACTIVE tasks whose
92+
# heartbeat has gone stale (a dead/orphaned worker). Written out-of-band via a
93+
# raw UPDATE (see TaskDAO.touch_heartbeat) so a heartbeat never advances
94+
# changed_on and thus never resurfaces the task in the status-change poll.
9595
last_heartbeat = Column(DateTime, nullable=True, index=True)
9696

9797
# User context for execution

superset/tasks/context.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ def __init__(self, task: "Task") -> None:
7171
self._abort_detected = False
7272
self._abort_handlers_completed = False # Track if all abort handlers finished
7373
self._execution_completed = False # Set by executor after task work completes
74+
# Elects one abort trigger across the listener/timeout/fence threads so
75+
# abort handlers run exactly once.
76+
self._abort_lock = threading.Lock()
7477

7578
# Collected handler failures for unified reporting
7679
self._handler_failures: list[TaskContext.HandlerFailure] = []
@@ -416,21 +419,25 @@ def _on_abort_detected(self) -> None:
416419
"""
417420
Callback invoked by TaskManager when abort is detected.
418421
419-
Triggers all registered abort handlers.
422+
Triggers all registered abort handlers exactly once, even when the abort
423+
listener, timeout timer, and heartbeat self-fence race to call this
424+
concurrently: the winner is elected atomically under ``_abort_lock``.
420425
"""
421-
if self._abort_detected:
422-
return # Already handled
426+
with self._abort_lock:
427+
if self._abort_detected:
428+
return # Another thread already won the election
423429

424-
# Check if task execution has already completed (late abort race).
425-
# Executor sets _execution_completed after task work finishes.
426-
if self._execution_completed:
427-
logger.info(
428-
"Abort detected for task %s but execution already completed",
429-
self._task_uuid,
430-
)
431-
return
430+
# Check if task execution has already completed (late abort race).
431+
# Executor sets _execution_completed after task work finishes.
432+
if self._execution_completed:
433+
logger.info(
434+
"Abort detected for task %s but execution already completed",
435+
self._task_uuid,
436+
)
437+
return
438+
439+
self._abort_detected = True
432440

433-
self._abort_detected = True
434441
logger.info("Abort detected for task %s", self._task_uuid)
435442
self._trigger_abort_handlers()
436443

superset/tasks/heartbeat.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,10 @@ class HeartbeatController:
4343
4444
The heartbeat thread starts before the ``TaskContext`` exists (it spans the
4545
whole time the worker holds the task, including the DAG wait), so the
46-
executor registers the fence callback once the context is built. Until then
47-
a fence can only stop the heartbeat and log — there is no running query to
48-
cancel yet.
46+
executor registers the fence callback once the context is built. The worker
47+
only self-fences once it is ``armed`` (a callback is registered); before that
48+
there is no running query to cancel, so a stalled heartbeat is simply left
49+
for the reaper and the loop keeps trying in case connectivity recovers.
4950
"""
5051

5152
def __init__(self) -> None:
@@ -55,6 +56,11 @@ def on_fence(self, callback: Callable[[], None]) -> None:
5556
"""Register the callback invoked when the worker self-fences."""
5657
self._fence_callback = callback
5758

59+
@property
60+
def armed(self) -> bool:
61+
"""True once a fence callback is registered (task is executing)."""
62+
return self._fence_callback is not None
63+
5864
def invoke_fence(self) -> None:
5965
"""Invoke the registered fence callback, if one has been registered."""
6066
if self._fence_callback is not None:
@@ -80,7 +86,10 @@ def task_heartbeat( # noqa: C901
8086
reaper has already (or will shortly) mark FAILURE, the worker fails itself
8187
via the registered fence callback. A single failed write is tolerated; only
8288
a sustained outage spanning the orphan window fences, so a transient blip
83-
never kills a healthy task.
89+
never kills a healthy task. Fencing only kicks in once the controller is
90+
armed (the task is executing and has a fence callback); a stalled heartbeat
91+
during the pre-execution DAG wait keeps retrying so recovered connectivity
92+
resumes the task rather than forfeiting it.
8493
8594
The thread is a daemon so it never blocks worker shutdown (matching the
8695
existing timeout/abort threads) and relies on DB drivers releasing the GIL
@@ -142,7 +151,9 @@ def _beat() -> None:
142151
with app.app_context():
143152
if _write():
144153
deadline = _new_deadline()
145-
elif naive_utcnow() >= deadline:
154+
elif controller.armed and naive_utcnow() >= deadline:
155+
# Only fence an executing task; a pre-execution stall is left
156+
# to the reaper and keeps retrying in case the outage clears.
146157
_fence()
147158
return
148159

tests/unit_tests/tasks/test_heartbeat.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,24 @@ def test_heartbeat_does_not_fence_while_writes_succeed() -> None:
116116
fence_callback.assert_not_called()
117117

118118

119+
def test_heartbeat_does_not_fence_before_armed() -> None:
120+
"""During the pre-execution DAG wait (no callback yet) a stalled heartbeat
121+
must not fence — it keeps retrying so recovered connectivity resumes the task."""
122+
stats = MagicMock()
123+
app = _mock_app(interval=0.02, stats=stats, orphan_timeout=0)
124+
125+
with patch(
126+
"superset.daos.tasks.TaskDAO.touch_heartbeat",
127+
side_effect=RuntimeError("db down"),
128+
):
129+
with task_heartbeat(11, app): # never call on_fence -> controller not armed
130+
time.sleep(0.1) # deadline passes and several beats fail
131+
132+
# Writes kept failing and being counted, but no fence fired (not armed).
133+
stats.incr.assert_any_call("gtf.task.heartbeat_failure")
134+
assert ("gtf.task.self_fenced",) not in [c.args for c in stats.incr.call_args_list]
135+
136+
119137
def test_heartbeat_tolerates_intermittent_failures() -> None:
120138
"""A transient blip (fail then success) resets the deadline and does not fence."""
121139
stats = MagicMock()

0 commit comments

Comments
 (0)