Skip to content

feat(gtf): per-tab websocket task-status fanout - #43689

Merged
villebro merged 5 commits into
apache:gaq-to-gtffrom
villebro:villebro/gtf-per-tab-fanout
Aug 30, 2026
Merged

feat(gtf): per-tab websocket task-status fanout#43689
villebro merged 5 commits into
apache:gaq-to-gtffrom
villebro:villebro/gtf-per-tab-fanout

Conversation

@villebro

Copy link
Copy Markdown
Member

SUMMARY

Delivers per-tab realtime task-status fanout for async chart-data, against the
umbrella branch (#43407). Builds on the per-tab subscription refcount from #43685
(private.task.consumers).

Problem. Realtime task-status is delivered at principal grain:
publish_task_status names the task's subscriber principals and the
superset-websocket server fans each out to realtime:user:<id>, so every tab
a user has open receives status for every task any of their tabs is watching
,
and the browser discards the irrelevant ones by task_id. Now that each tab's
subscription is tracked per-tab, we can route task-status to only the tab that
is watching a given task.

This realizes the three channel tiers:

  1. tab-specificrealtime:user:<id>:<tabId> (new: per-tab task-status)
  2. user-specificrealtime:user:<id> (all of a user's tabs — kept for
    non-per-tab tasks and future per-user notices)
  3. all users+tabs → the existing entity-changes:* broadcast tier

DESIGN

  • Auth unchanged: one user-specific cookie, one ws connection per tab. The
    cookie authorizes the principal (channel: user:<id>, identity-bound — the
    token integrity check is kept); each tab opens its own WebSocket and
    advertises its getTabId() on the connect URL (?tab_id=…), not in the
    token. The server derives the per-tab channel by prefixing the authorized
    principal channel, so a client-supplied tab_id can never address another
    principal's sockets. No per-tab cookies, no bearer token.
  • Socket dual-registration. On connect the socket is registered under
    user:<id> (principal — all-tabs/broadcast delivery + the per-principal
    connection cap) and, when a tab_id is present, also user:<id>:<tabId>. One
    socket id, two channel keys.
  • Fanout routes by channel-key strings. The task-status wire payload
    changes from subscribers: [{principal_type, sub}] to channels: [str] — the
    producer computes the exact routing keys and the ws server just delivers to
    realtime:<key> (this also simplifies the server: no re-derivation/validation
    of principal keys in the fanout path).
  • Routing keys come from the task type's subscription policy. New
    TaskSubscriptionPolicy.routing_channels(task) -> list[str] | None (concrete
    default None). Chart-data returns its consumers list (already the per-tab
    keys); any task type without a policy — or a chart task with no recorded tab —
    falls back to principal-grain keys from get_subscriber_principals (unchanged
    behavior for non-chart tasks
    ).
  • Broadcast dedupe fix. broadcastToAll now iterates the unique socket
    registry (via an extracted sendToSocket helper) instead of per-channel, so a
    dual-registered socket receives each entity-change nudge exactly once.

BEFORE/AFTER

  • Before: two tabs of one user watching different charts each receive every
    chart's task-status; the client filters by task_id.
  • After: each tab receives realtime task-status only for its own tasks, on
    realtime:user:<id>:<tabId>. Entity-change list nudges still reach every tab.

TESTING INSTRUCTIONS

  • pytest tests/unit_tests/tasks/test_manager.py tests/unit_tests/tasks/test_async_queries.py
  • npm --prefix superset-websocket run test && npm --prefix superset-websocket run lint
  • npm run test -- realtime
  • Manual (worker + Redis coordination + ws server): open the same chart in two
    tabs; confirm each tab's task-status arrives only on its own
    realtime:user:<id>:<tabId> channel (inspect ws frames) while entity-change
    list nudges reach both tabs exactly once; stop the ws server and confirm
    polling still resolves charts.

ADDITIONAL INFORMATION

Wire-contract change (subscriberschannels) is a coordinated Python↔Node
change with no version field, so deploy the Flask app and the superset-websocket
server together. During a mismatched rolling upgrade each side drops the other's
task-status payload and degrades to client polling (no crash, no leak).

  • Has associated issue:
  • Required feature flags: GLOBAL_ASYNC_QUERIES / GLOBAL_TASK_FRAMEWORK; optional WEBSOCKET_ENABLE
  • Changes UI
  • Includes DB Migration
  • Introduces new feature or API
  • Removes existing feature or API

A browser tab now receives realtime task-status only for the tasks it is
watching, instead of every task any of the user's tabs watches.

- TaskSubscriptionPolicy gains routing_channels(task) (default None); chart-data
  returns its per-tab consumer keys (else principal-grain fallback).
- publish_task_status computes routing keys and publishes {task_id,status,channels}
  (was subscribers:[{principal_type,sub}]); the ws server delivers each to
  realtime:<key>.
- ws server: parse tab_id at upgrade and dual-register the socket under its
  principal channel and user:<id>:<tabId>; broadcastToAll now dedupes by socket.
- frontend advertises getTabId() on the ws connect URL.

Auth is unchanged: one user-specific cookie authorizes the principal; the tab id
rides the connect URL, and the per-tab channel is derived from the authorized
principal channel so it can never cross principals.
@dosubot dosubot Bot added change:backend Requires changing the backend change:frontend Requires changing the frontend global:async-query Related to Async Queries feature labels Aug 29, 2026
@bito-code-review

bito-code-review Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped - Branch Excluded

Bito didn't auto-review because the source or target branch is excluded from automatic reviews.
No action is needed if you didn't intend for the agent to review it. Otherwise, to manually trigger a review, type /review in a comment and save.
You can change the branch exclusion settings here, or contact your Bito workspace admin at evan@preset.io.

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.35%. Comparing base (e2dbca8) to head (17ea87b).
⚠️ Report is 1 commits behind head on gaq-to-gtf.

Additional details and impacted files
@@              Coverage Diff               @@
##           gaq-to-gtf   #43689      +/-   ##
==============================================
+ Coverage       75.02%   79.35%   +4.32%     
==============================================
  Files            2892     2897       +5     
  Lines          166543   167175     +632     
  Branches        38473    38474       +1     
==============================================
+ Hits           124953   132663    +7710     
+ Misses          39044    32017    -7027     
+ Partials         2546     2495      -51     
Flag Coverage Δ
hive 37.92% <7.14%> (-0.01%) ⬇️
mysql 57.62% <35.71%> (?)
postgres 57.65% <35.71%> (?)
presto 39.83% <7.14%> (-0.01%) ⬇️
python 83.88% <100.00%> (+8.56%) ⬆️
sqlite 57.34% <35.71%> (?)
superset-extensions-cli 90.57% <ø> (?)
unit 74.29% <100.00%> (+<0.01%) ⬆️

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.

@netlify

netlify Bot commented Aug 29, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 9067e6b
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a933446d217b80008d707ea
😎 Deploy Preview https://deploy-preview-43689--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

# task-status message reaches only the tabs watching this task. Empty ->
# None so a chart task with no recorded tab (all detached, or a no-tab
# caller) falls back to principal-grain fanout instead of dropping it.
return self._consumers(task) 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: The routing list is treated as authoritative without checking current subscriber rows. A no-tab unsubscribe leaves the existing per-tab entries intact while removing the principal subscriber, so a later status transition can still be routed to sockets for a principal that is no longer subscribed. Reconcile the recorded consumer entries with active subscribers, or clear all entries for that principal when the principal-grain unsubscribe path is used. [security]

Severity Level: Major ⚠️
- ⚠️ Legacy cancellation leaves stale tab routing state.
- ⚠️ Unsubscribed tabs can receive task-status events.
- ⚠️ Task private properties retain obsolete consumer entries.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

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

**Path:** superset/tasks/async_queries.py
**Line:** 134:134
**Comment:**
	*Security: The routing list is treated as authoritative without checking current subscriber rows. A no-tab unsubscribe leaves the existing per-tab entries intact while removing the principal subscriber, so a later status transition can still be routed to sockets for a principal that is no longer subscribed. Reconcile the recorded consumer entries with active subscribers, or clear all entries for that principal when the principal-grain unsubscribe path is used.

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
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in 78ec946. on_unsubscribe now clears all of the principal's tab entries on the principal-grain (no-tab_id) unsubscribe path, so a later status transition can no longer route to that principal's tab channels after it has left; the per-tab (client_ref) path is unchanged. Added a regression test (test_consumer_policy_no_client_ref_clears_that_principals_tab_entries).

Note on scope: even before the fix this was stale delivery to the same principal's own JWT-bound socket, not a cross-principal leak (a user:5:<tab> key only ever routes to user 5's socket) — but delivering to a principal that explicitly unsubscribed is still wrong, so the fix stands.

@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged security issue concerns stale routing state in the task-status fanout mechanism. When a principal unsubscribes from a task, the system must ensure that any per-tab routing entries associated with that principal are also cleared to prevent unauthorized or stale status updates from being delivered to those tabs.

To resolve this, you should ensure that the on_unsubscribe logic in superset-core/src/superset_core/tasks/subscription.py (or the corresponding task manager) explicitly reconciles or clears all routing entries for the principal being unsubscribed. If you are using a custom routing_channels implementation, ensure it returns None or an empty list when the principal is no longer subscribed, triggering a fallback to principal-grain fanout or no fanout at all, rather than returning stale per-tab keys.

Would you like me to fetch all comments on this PR to validate the rest of the findings and implement a comprehensive fix?

superset-core/src/superset_core/tasks/subscription.py

def routing_channels(self, task: "Task") -> list[str] | None:
        """Realtime websocket routing keys for this task's status fanout.

        Return ``None`` (the default) to keep principal-grain fanout — the
        framework then derives one key per subscriber principal. A concrete policy
        that manages per-client keys should also return ``None`` (not an empty
        list) when it currently has no keys, so fanout falls back to
        principal-grain rather than silently delivering to no one.
        """
        return None

Comment on lines +395 to 399
Array.isArray(candidate.channels) &&
candidate.channels.every(
channel => typeof channel === 'string' && channel.length > 0,
)
);

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: Requiring channels makes the websocket server reject the previous {task_id, status} task-status message format. During a rolling deployment, or when another publisher still emits the legacy shape, every status event is logged as invalid and dropped instead of being delivered at principal grain as before. Accept the legacy shape with principal-grain routing, or coordinate an atomic producer/consumer protocol migration. [api mismatch]

Severity Level: Major ⚠️
- ❌ Async chart task-status events drop during rolling upgrades.
- ⚠️ Browsers rely on polling backstop for completion updates.
- ⚠️ Mixed worker/websocket versions lose realtime delivery.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

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

**Path:** superset-websocket/src/index.ts
**Line:** 395:399
**Comment:**
	*Api Mismatch: Requiring `channels` makes the websocket server reject the previous `{task_id, status}` task-status message format. During a rolling deployment, or when another publisher still emits the legacy shape, every status event is logged as invalid and dropped instead of being delivered at principal grain as before. Accept the legacy shape with principal-grain routing, or coordinate an atomic producer/consumer protocol migration.

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
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is intentional and I don't think transitional dual-shape acceptance is warranted here. The subscriberschannels payload is an internal Redis wire contract between superset/tasks/manager.py (the sole producer) and this server (the sole consumer), and both shapes only ever exist within this GAQ→GTF epic — the task-status fanout feature is entirely new in the epic and the epic merges to master atomically (umbrella #43407). So there is no released/prior producer emitting the legacy {subscribers} shape in the wild: a real cluster upgrading from pre-epic has old app pods that don't emit task-status at all (feature absent) and new pods that emit {channels}, which this server reads.

The remaining window — an operator running a mid-epic intermediate build where the app pod and ws pod roll at slightly different times — is documented in the PR description: deploy the Flask app and ws server together; a mismatch degrades gracefully to the client's polling backstop (no crash, no leak). Adding legacy {subscribers} acceptance would be dead defensive code (plus the principalChannel-mapping + isTaskStatusSubscriber guard I just removed) guarding against a shape that never ships publicly, so I'd rather keep the wire contract single-shape. Happy to add it if you'd prefer belt-and-suspenders.

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.

Customized review instruction saved!

Instruction:

Do not require transitional dual-shape acceptance for internal Redis wire contracts when the producer and consumer are introduced and deployed together as part of the same atomic feature rollout; treat the new payload shape as the sole supported contract.

Applied to:

  • superset-websocket/**

💡 To manage or update this instruction, visit: CodeAnt AI Settings

…ain unsubscribe

A no-tab (principal-grain) unsubscribe removed the principal's subscriber row
but left its per-tab consumer entries in private.task.consumers, so a later
status transition could still route task-status to that principal's tab channels
after it had unsubscribed. Clear all of the principal's tab entries on the
principal-grain unsubscribe path; per-tab (client_ref) unsubscribe is unchanged.
Addresses review feedback on apache#43689.
@villebro

Copy link
Copy Markdown
Member Author

CI note: the failing checks here (sharded-jest-tests (8), test-mysql, test-postgres(-required), test-sqlite) are pre-existing on the base branch — the gaq-to-gtf head (e2dbca8bb7, untouched by this PR) fails the identical set. This PR's failures are a subset of the base's and it introduces no new ones (the changed Python unit tests, superset-websocket suite + lint/type, and realtime/asyncEvent frontend tests all pass locally). They should clear once the base is green and this branch is rebased.

// `url` stays stable for connectRealtime's idempotency check.
let connectUrl = url;
try {
const parsed = new URL(url);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A root-relative websocket endpoint (for example /superset-ws behind a same-origin proxy) is valid for WebSocket, but new URL(url) rejects it and this catch connects without tab_id. Chart requests still subscribe under the tab-specific key, so their status events have no matching socket and only polling notices completion. Could this resolve against the page URL before adding the parameter?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in a371d21. openSocket now builds the connect URL with new URL(url, window.location.href), so a root-relative endpoint (e.g. /superset-ws behind a same-origin proxy) resolves against the page and still carries tab_id; an http(s) result is normalized to ws(s). Absolute ws(s):// URLs are unaffected (an absolute URL ignores the base). Added a test (resolves a root-relative ws url against the page and adds tab_id).

let connectUrl = url;
try {
const parsed = new URL(url);
parsed.searchParams.set('tab_id', getTabId());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When a duplicated tab receives TAB_ID_DENIED, useTabId replaces its session-storage ID, but this socket remains registered under the old ID until it happens to reconnect. New chart subscriptions use the replacement ID, so their tab-targeted status events cannot reach this active socket. Should the tab-ID change trigger a realtime reconnect or otherwise keep the socket registration in sync?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in a371d21 — the tab-id change now triggers a realtime reconnect so the socket re-registers under the new per-tab channel. useTabId exposes subscribeTabIdChange and calls it on the TAB_ID_DENIED reassignment; realtime.ts subscribes and, when a socket is active, tears down and reopens (which re-reads getTabId()), so new tab-targeted status events reach the current socket instead of only being noticed by polling. Test: reconnects with the new tab id when the tab id changes.

Review (sadpandajoe) on apache#43689:
- realtime.ts: resolve the ws URL against the page URL so a root-relative
  endpoint (e.g. /superset-ws behind a same-origin proxy) still connects with
  tab_id (new URL(url) alone rejected it and dropped the tab id -> tab-targeted
  status never reached the socket). http(s) is normalized to ws(s).
- realtime.ts: reconnect when this tab's id changes (a duplicated tab reassigned
  on a TAB_ID_DENIED collision) so the socket re-registers under the new per-tab
  channel; useTabId now exposes subscribeTabIdChange and notifies on reassignment.

Failing test:
- test_reap fixture wrote the engine cancel handle at top-level properties, but
  the apache#43678 task-state hierarchy moved cancel_query_id/cancel_database_id into
  private.task (where the reaper reads them); fixed the helper to use
  update_task_private so the orphan-query-cancel assertion passes.

Tests: realtime root-relative-url + tab-id-change reconnect; shim mock gains
subscribeTabIdChange. All frontend + touched python unit tests green.
SQL_LAB_GTF_MIGRATION_ANALYSIS.md is a local design-scratch doc (like GAQ_TO_GTF_EPIC.md); it does not belong in this PR. Untracked and added to .git/info/exclude; the file stays on disk locally.
@villebro

Copy link
Copy Markdown
Member Author

On the two failing tests:

  • test_reap_cancels_orphaned_query_when_handle_present — real fixture bug, fixed in a371d21. The helper wrote the engine cancel handle at top-level properties, but the fix(gtf): async chart-data hardening (abort teardown, private properties, review follow-ups) #43678 task-state hierarchy moved cancel_query_id/cancel_database_id into private["task"] (where the executor writes and the reaper reads them). Fixed the helper to use update_task_private.

  • test_related_subscribers_scoped_to_visible_tasks — I could not reproduce this in isolation and believe it is a full-run test-isolation flake, not a regression from this PR. The code path is correct: TaskDAO.create_task auto-subscribes the creator via the passed user_id for all scopes (incl. PRIVATE), so the fixture makes admin a subscriber of its 5 tasks; admin (sees all via TaskFilter) should therefore appear in /related/subscribers. Nothing in this PR touches related_subscribers, visible_task_ids_query, get_subscriber_principals, or create_task. The failure ({1,2} <= {2}: admin missing, gamma present) is consistent with shared-session/cross-test pollution in the full integration run rather than a deterministic logic error. I can dig into the ordering/pollution source if it reproduces in a re-run, but I did not want to blind-edit a passing-in-isolation test and risk masking a real issue — happy to if you would prefer I harden the fixture.

test_related_subscribers is the only task API test that switches users within a
single method (gamma then admin). The shared test client keeps one session and
login() just POSTs /login/, which redirects without re-authenticating when a
user is already logged in — so admin's request ran with gamma's scoping and the
subscriber dropdown returned only gamma (assert {1,2} <= {2} failed) even though
the fixture correctly auto-subscribes admin to its tasks. Log out before each
login so the admin switch takes effect.
@villebro

Copy link
Copy Markdown
Member Author

Root-caused and fixed test_related_subscribers_scoped_to_visible_tasks in 17ea87b — it was a real, deterministic test bug, not pollution.

The captured log confirmed the fixture auto-subscribes admin to its tasks (Added subscriber 1 to task 1..5), yet admin got {2} (gamma only). The cause: this is the only task-API test that switches users within a single test method (login(GAMMA) then login(ADMIN)). The shared test client keeps one session, and login() just POST /login/, which — when a user is already authenticated — redirects without re-authenticating (the Invalid redirect detected warning), so admin’s request ran with gamma’s scoping and /related/subscribers returned only gamma. Every other test logs in once, so none hit this.

Fix: self.logout() before each login so the admin switch takes effect. No product-code change; the endpoint/scoping logic was correct.

@villebro
villebro merged commit 933da58 into apache:gaq-to-gtf Aug 30, 2026
68 of 69 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend change:frontend Requires changing the frontend global:async-query Related to Async Queries feature size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants