Skip to content

feat(async): cancel running chart queries under GLOBAL_ASYNC_QUERIES#42305

Open
msyavuz wants to merge 1 commit into
masterfrom
msyavuz/fix/gaq-stop-query
Open

feat(async): cancel running chart queries under GLOBAL_ASYNC_QUERIES#42305
msyavuz wants to merge 1 commit into
masterfrom
msyavuz/fix/gaq-stop-query

Conversation

@msyavuz

@msyavuz msyavuz commented Jul 22, 2026

Copy link
Copy Markdown
Member

SUMMARY

Under GLOBAL_ASYNC_QUERIES (GAQ), the Stop button in Explore did nothing: once a query returns 202 and the client is polling for the async result, aborting the request has no effect — the fetch already settled, so no AbortError fires, chartUpdateStopped never dispatches, and the button stays stuck while the query keeps running server-side. This is the case the reporting customer (GAQ enabled) actually hits.

This makes Stop work under GAQ, on both ends:

Frontend — thread the request's AbortSignal into waitForAsyncData. On abort it stops listening for the job, POSTs the cancel endpoint, and rejects with an AbortError so the existing abort handling dispatches chartUpdateStopped. The stale-response guards now check controller.signal.aborted (authoritative — the reducer nulls queryController on stop) instead of only comparing controllers.

Backend — a new POST /api/v1/async_event/<job_id>/cancel revokes the Celery task. The job's job_id is now used as the Celery task_id, so the id the client already holds is directly revocable. revoke(..., signal="SIGUSR1") surfaces as SoftTimeLimitExceeded in the worker, which reads a cancel flag and emits a single terminal cancelled event.

Authorization — the endpoint never trusts the client-supplied job_id. At submit time a Redis record binds job_id → {channel_id, user_id}; cancel resolves the caller's channel and user id server-side and must match both. Channel-and-user (not channel alone) matters for embedded guests, whose channel can be shared across viewers.

The resulting stopped state is rendered by #42270 (merged).

BEFORE/AFTER

Before (GAQ): Stop → button stuck on "Stop", query keeps running, eventually a blank chart.
After (GAQ): Stop → button returns to "Update chart", the query is cancelled server-side, and the stopped message shows (with #42270).

TESTING INSTRUCTIONS

  1. Enable GLOBAL_ASYNC_QUERIES with Redis + a Celery worker.
  2. Build a chart on a slow dataset (e.g. a virtual dataset SELECT pg_sleep(20)).
  3. Trigger a refresh (click the Cached badge) and click Stop while it loads.
  4. The button returns to "Update chart"; the worker logs the cancellation and no result is cached.

Unit tests: pytest tests/unit_tests/async_events/ tests/unit_tests/tasks/test_async_queries.py; npm run test -- asyncEvent.test.ts chartActions.test.ts.

ADDITIONAL INFORMATION

  • Has associated issue: No — internal tracker
  • Required feature flags: GLOBAL_ASYNC_QUERIES
  • Changes UI
  • Includes DB Migration
  • Introduces new feature or API — POST /api/v1/async_event/<job_id>/cancel
  • Removes existing feature or API

@dosubot dosubot Bot added api Related to the REST API change:backend Requires changing the backend change:frontend Requires changing the frontend global:async-query Related to Async Queries feature labels Jul 22, 2026

@bito-code-review bito-code-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Agent Run #0d77a5

Actionable Suggestions - 1
  • superset-frontend/src/middleware/asyncEvent.test.ts - 1
    • Missing microtask flush for fire-and-forget POST · Line 166-166
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset-frontend/src/middleware/asyncEvent.test.ts - 2
  • superset/async_events/cache_backend.py - 1
Review Details
  • Files reviewed - 9 · Commit Range: 464aa02..464aa02
    • superset-frontend/src/components/Chart/chartAction.ts
    • superset-frontend/src/middleware/asyncEvent.test.ts
    • superset-frontend/src/middleware/asyncEvent.ts
    • superset/async_events/api.py
    • superset/async_events/async_query_manager.py
    • superset/async_events/cache_backend.py
    • superset/tasks/async_queries.py
    • tests/unit_tests/async_events/async_query_manager_tests.py
    • tests/unit_tests/tasks/test_async_queries.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • Eslint (Linter) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

await expect(
asyncEvent.waitForAsyncData(asyncPendingEvent, controller.signal),
).rejects.toMatchObject({ name: 'AbortError' });
expect(fetchMock.callHistory.calls(CANCEL_ENDPOINT)).toHaveLength(1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing microtask flush for fire-and-forget POST

The cancel POST is fire-and-forget per implementation (cancelAsyncJob at asyncEvent.ts:105-112), but this test doesn't flush microtasks before asserting the endpoint was called, unlike the equivalent test at line 152. This inconsistency can cause flaky test failures in CI environments where async operations complete after assertions run.

Code Review Run #0d77a5


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

Comment on lines +453 to +467
raw = self._cache.get(key)
if raw is None:
raise AsyncQueryJobException("Job not found or already completed")

record = json.loads(raw)
if record.get("channel_id") != channel_id or record.get("user_id") != user_id:
raise AsyncQueryTokenException("Not authorized to cancel this job")

# Flag before revoking so the worker's timeout handler, which may fire
# almost immediately, reliably sees the cancellation.
self._cache.set(
key,
json.dumps({**record, "cancelled": True}),
ex=self._jwt_expiration_seconds or None,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: There is a race condition between reading the job record and writing the cancellation flag: if the worker finishes and deletes the registry key in between, this code recreates the key and still returns success, so callers can “cancel” already-finished jobs and revoke is sent for stale state. Make this update atomic (for example, conditional write only if the key still exists and fail otherwise, ideally in a Redis transaction/Lua script). [race condition]

Severity Level: Major ⚠️
- ❌ Cancel endpoint reports success cancelling already-completed async jobs.
- ⚠️ Workers may see stale cancellation flag for finished jobs.
Steps of Reproduction ✅
1. In `superset/async_events/async_query_manager.py:338-363`, a client starts an async
chart query via `AsyncQueryManager.submit_chart_data_job(...)`, which calls
`init_job(...)` at `281-286`; `init_job` registers the cancellable job in Redis through
`_register_cancellable_job(...)` at `292-307`.

2. The Celery task created by `_load_chart_data_into_cache_job.apply_async(...)` at
`352-362` runs and, on successful completion, calls `AsyncQueryManager.update_job(...)` at
`392-421` with `status=self.STATUS_DONE`, which emits the terminal event and deletes the
job registry key via `self._cache.delete(self._job_registry_key(job_id))` at `419-421`.

3. Around the same time the client presses Stop in Explore, hitting the new cancel
endpoint described in the PR, which calls `AsyncQueryManager.cancel_job(...)` at
`435-475`; inside `cancel_job`, `key = self._job_registry_key(job_id)` (line 452) and `raw
= self._cache.get(key)` (line 453) read the current record before the `update_job` call in
step 2 performs its delete.

4. After `update_job` deletes the key, `cancel_job` still proceeds to
`self._cache.set(...)` at `463-467`, recreating the key with `{"cancelled": True}` and
returning success while the job is already completed; `celery_app.control.revoke(job_id,
...)` at `475` is then issued for a finished task, so from the caller’s perspective a
“cancel” appears to succeed based on stale state instead of failing with “Job not found or
already completed”.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/async_events/async_query_manager.py
**Line:** 453:467
**Comment:**
	*Race Condition: There is a race condition between reading the job record and writing the cancellation flag: if the worker finishes and deletes the registry key in between, this code recreates the key and still returns success, so callers can “cancel” already-finished jobs and revoke is sent for stale state. Make this update atomic (for example, conditional write only if the key still exists and fail otherwise, ideally in a Redis transaction/Lua script).

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@msyavuz
msyavuz force-pushed the msyavuz/fix/gaq-stop-query branch from 464aa02 to 27d02a4 Compare July 22, 2026 10:34

@bito-code-review bito-code-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Agent Run #670063

Actionable Suggestions - 1
  • superset/async_events/api.py - 1
Additional Suggestions - 2
  • superset/async_events/api.py - 1
    • CWE-285: Reused permission on cancel · Line 107-113
      The `cancel` endpoint reuses `permission_name="list"`, which is the same permission used by the `events` read-only endpoint. Since these are distinct operations (read vs. cancel), the cancel action should have its own permission name to allow fine-grained access control.
  • superset-frontend/src/middleware/asyncEvent.test.ts - 1
    • Duplicated mock setup across tests · Line 133-154
      Lines 134-135 in the first test and lines 157-158 in the second test duplicate identical CANCEL_ENDPOINT constant and fetchMock.post setup. Consider moving this to a beforeEach within the describe block for consistency with the EVENTS_ENDPOINT and CACHED_DATA_ENDPOINT patterns at lines 88-89.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset-frontend/src/middleware/asyncEvent.ts - 1
  • tests/unit_tests/tasks/test_async_queries.py - 1
    • Missing cancellation test for explore_json path · Line 73-91
Review Details
  • Files reviewed - 9 · Commit Range: 27d02a4..27d02a4
    • superset-frontend/src/components/Chart/chartAction.ts
    • superset-frontend/src/middleware/asyncEvent.test.ts
    • superset-frontend/src/middleware/asyncEvent.ts
    • superset/async_events/api.py
    • superset/async_events/async_query_manager.py
    • superset/async_events/cache_backend.py
    • superset/tasks/async_queries.py
    • tests/unit_tests/async_events/async_query_manager_tests.py
    • tests/unit_tests/tasks/test_async_queries.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • Eslint (Linter) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Comment on lines +107 to +171
@expose("/<job_id>/cancel", methods=("POST",))
@event_logger.log_this
@protect()
@safe
@statsd_metrics
@permission_name("list")
def cancel(self, job_id: str) -> Response:
"""Cancel a running async query job.
---
post:
summary: Cancel a running async query job
description: >-
Revokes the Celery task backing an in-flight async query. The
caller is authorized against the job's original owner (channel and
user), both resolved server-side from the request, so a client
cannot cancel a job it did not submit.
parameters:
- in: path
name: job_id
required: true
description: The job ID returned when the async query was submitted
schema:
type: string
responses:
200:
description: Job cancelled
content:
application/json:
schema:
type: object
properties:
result:
type: object
properties:
job_id:
type: string
status:
type: string
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
try:
async_channel_id = async_query_manager.parse_channel_id_from_request(
request
)
except AsyncQueryTokenException:
return self.response_401()

try:
async_query_manager.cancel_job(job_id, async_channel_id, get_user_id())
except AsyncQueryTokenException:
return self.response_403()
except AsyncQueryJobException:
return self.response_404()

return self.response(
200,
result={"job_id": job_id, "status": async_query_manager.STATUS_CANCELLED},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for cancel API

The new cancel endpoint has no test coverage in api_tests.py. The existing async_query_manager_tests.py only tests the manager layer, not the API's request parsing, exception handling, and response mapping. Without API-level tests, regression in the cancel flow (error handling, response codes, authorization) will go undetected.

Code Review Run #670063


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 40.47619% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.61%. Comparing base (6e1954f) to head (27d02a4).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
superset/async_events/async_query_manager.py 17.14% 29 Missing ⚠️
superset/async_events/api.py 45.00% 11 Missing ⚠️
superset/tasks/async_queries.py 0.00% 4 Missing ⚠️
...erset-frontend/src/components/Chart/chartAction.ts 57.14% 3 Missing ⚠️
superset/async_events/cache_backend.py 50.00% 2 Missing ⚠️
superset-frontend/src/middleware/asyncEvent.ts 92.85% 1 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (6e1954f) and HEAD (27d02a4). Click for more details.

HEAD has 70 uploads less than BASE
Flag BASE (6e1954f) HEAD (27d02a4)
python 46 3
presto 8 1
hive 7 1
javascript 8 1
unit 8 1
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #42305      +/-   ##
==========================================
- Coverage   65.15%   56.61%   -8.54%     
==========================================
  Files        2789     2789              
  Lines      157572   157650      +78     
  Branches    35872    35887      +15     
==========================================
- Hits       102662    89251   -13411     
- Misses      52939    67566   +14627     
+ Partials     1971      833    -1138     
Flag Coverage Δ
hive 38.72% <26.98%> (-0.01%) ⬇️
javascript 71.07% <80.95%> (-0.01%) ⬇️
mysql ?
postgres ?
presto 40.63% <26.98%> (-0.02%) ⬇️
python 41.87% <26.98%> (-17.22%) ⬇️
sqlite ?
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Related to the REST API change:backend Requires changing the backend change:frontend Requires changing the frontend global:async-query Related to Async Queries feature preset-io size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant