You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by vi...@apache.org on 2026/08/24 14:23:45 UTC

(superset) 01/01: fix(gtf): close async-chart-data waiter race + widen guest_key column

This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch villebro/gtf-review-fixes
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 0528372174856cacc64e54c7ac2ffbde67e53e02
Author: Ville Brofeldt <vi...@gmail.com>
AuthorDate: Mon Aug 24 07:23:02 2026 -0700

    fix(gtf): close async-chart-data waiter race + widen guest_key column
    
    Two correctness fixes from the PR #43407 review:
    
    - P1 (async chart-data can hang): waitForAsyncData awaited baselineReady before
      registering the task waiter. The 202 is returned when tasks are *scheduled*,
      not finished, so at that moment the shared poll cursor is <= now < any task's
      future terminal — but awaiting between the 202 and registration opened a gap
      in which a fast task could finish and a concurrent chart's poll advance the
      cursor past its terminal update (and the socket event is dropped with no
      waiter yet), hanging the chart/native filter forever. Register the waiter
      synchronously in the same tick as the 202 (no await first); the poll then
      always observes the completion. Removed the now-unused baselineReady handle.
    
    - P1 (guest subscriber key too long): superset.tasks.guest returns "guest-" +
      a 64-char SHA256 hex digest = 70 chars, but task_subscribers.guest_key was
      String(64), so a guest-created async task's subscription would error on
      Postgres / truncate on MySQL. Widen the column to String(128) in the model
      and the (feature-branch) migration.
    
    Tests: async-registration is exercised by delivering the completion in the same
    tick with no wait; a full-length (70-char) guest key round-trips through the DAO.
---
 .../src/middleware/asyncEvent.test.ts              | 12 ++++--------
 superset-frontend/src/middleware/asyncEvent.ts     | 12 +++++++++---
 ..._7e2c9a4f1b83_create_task_dependencies_table.py |  2 +-
 superset/models/task_subscribers.py                |  4 +++-
 tests/unit_tests/daos/test_tasks.py                | 22 ++++++++++++++++++++++
 5 files changed, 39 insertions(+), 13 deletions(-)

diff --git a/superset-frontend/src/middleware/asyncEvent.test.ts b/superset-frontend/src/middleware/asyncEvent.test.ts
index 75507e85080..070cb2c0279 100644
--- a/superset-frontend/src/middleware/asyncEvent.test.ts
+++ b/superset-frontend/src/middleware/asyncEvent.test.ts
@@ -164,11 +164,6 @@ describe('realtime WebSocket acceleration', () => {
     payload: { task_id: taskId, status },
   });
 
-  // waitForAsyncData registers its waiter only after the baseline cursor fetch
-  // resolves; wait a tick so a one-shot socket message isn't delivered before
-  // the task is being awaited.
-  const afterRegistered = () => new Promise(resolve => setTimeout(resolve, 50));
-
   test('a tier-2 message settles a waiting chart without a poll', async () => {
     // No status batches queued (only the baseline), so completion can ONLY come
     // from the socket message — proving the WS path settles on its own.
@@ -181,7 +176,10 @@ describe('realtime WebSocket acceleration', () => {
       refetch,
     );
 
-    await afterRegistered();
+    // Deliver the completion in the SAME tick, with no wait: waitForAsyncData
+    // registers its waiter synchronously (before any await), so a fast task
+    // whose event arrives immediately after the 202 is never missed. (Regression
+    // guard: registration must not sit behind an awaited baseline fetch.)
     asyncEvent.handleRealtimeMessage(realtime('task-1', 'success'));
 
     expect(await promise).toEqual([{ rows: 1 }]);
@@ -198,7 +196,6 @@ describe('realtime WebSocket acceleration', () => {
       refetch,
     );
 
-    await afterRegistered();
     asyncEvent.handleRealtimeMessage(realtime('task-1', 'failure'));
 
     await expect(promise).rejects.toThrow();
@@ -215,7 +212,6 @@ describe('realtime WebSocket acceleration', () => {
       refetch,
     );
 
-    await afterRegistered();
     // A public entity-change nudge carries no status and must not settle a chart.
     asyncEvent.handleRealtimeMessage({
       channel: 'entity-changes:task',
diff --git a/superset-frontend/src/middleware/asyncEvent.ts b/superset-frontend/src/middleware/asyncEvent.ts
index 3c62b1ed00a..4c9d0dd2e08 100644
--- a/superset-frontend/src/middleware/asyncEvent.ts
+++ b/superset-frontend/src/middleware/asyncEvent.ts
@@ -84,7 +84,6 @@ let waitersByTaskId: Map<string, Set<Waiter>> = new Map();
 // is triggered) so no task created afterwards is missed, then advanced by each
 // poll. Always the server's own clock, never the browser's.
 let cursor: string | null;
-let baselineReady: Promise<void> | null;
 // Incremented on every init() so an in-flight poll can detect it is stale and
 // stop instead of scheduling a second loop or mutating fresh state.
 let pollingGeneration = 0;
@@ -217,8 +216,15 @@ export const waitForAsyncData = async <T = unknown[]>(
   signal?: AbortSignal,
 ): Promise<T> => {
   const taskIds = asyncJob.task_ids ?? [];
-  if (baselineReady) await baselineReady;
 
+  // Register the waiter synchronously, in the same tick the 202 was received —
+  // NOT after an await. The 202 is returned when the tasks are *scheduled*, not
+  // finished, so at this point the shared poll cursor is <= now < any task's
+  // future terminal transition; the poll is therefore guaranteed to observe the
+  // completion. Awaiting anything here (e.g. the init baseline) would open a gap
+  // in which a fast task could finish and a concurrent chart's poll advance the
+  // cursor past its terminal update, and the socket event (no waiter yet) would
+  // be dropped — hanging the request.
   await new Promise<void>((resolve, reject) => {
     if (signal?.aborted) {
       taskIds.forEach(cancelTask);
@@ -281,7 +287,7 @@ export const init = (appConfig?: AppConfig) => {
   // Establish a baseline cursor before any chart query is triggered, so tasks
   // created afterwards are all caught by the changed-since poll, then start the
   // shared poll loop from that watermark.
-  baselineReady = fetchStatusChanges({ task_type: CHART_QUERY_TASK_TYPE })
+  fetchStatusChanges({ task_type: CHART_QUERY_TASK_TYPE })
     .then(({ cursor: baseline }) => {
       if (generation === pollingGeneration) cursor = baseline;
     })
diff --git a/superset/migrations/versions/2026-08-21_12-00_7e2c9a4f1b83_create_task_dependencies_table.py b/superset/migrations/versions/2026-08-21_12-00_7e2c9a4f1b83_create_task_dependencies_table.py
index 9c70c3aac2c..6a0fe43b045 100644
--- a/superset/migrations/versions/2026-08-21_12-00_7e2c9a4f1b83_create_task_dependencies_table.py
+++ b/superset/migrations/versions/2026-08-21_12-00_7e2c9a4f1b83_create_task_dependencies_table.py
@@ -139,7 +139,7 @@ def upgrade():
     # not collide.)
     add_columns(
         TASK_SUBSCRIBERS_TABLE,
-        Column("guest_key", sa.String(length=64), nullable=True),
+        Column("guest_key", sa.String(length=128), nullable=True),
     )
     with op.batch_alter_table(TASK_SUBSCRIBERS_TABLE) as batch_op:
         batch_op.alter_column("user_id", existing_type=sa.Integer(), nullable=True)
diff --git a/superset/models/task_subscribers.py b/superset/models/task_subscribers.py
index f56f4621447..475f655fc05 100644
--- a/superset/models/task_subscribers.py
+++ b/superset/models/task_subscribers.py
@@ -63,7 +63,9 @@ class TaskSubscriber(CoreTaskSubscriber, AuditMixinNullable, Model):
     user_id = Column(
         Integer, ForeignKey("ab_user.id", ondelete="CASCADE"), nullable=True
     )
-    guest_key = Column(String(64), nullable=True, index=True)
+    # A guest key is ``guest-`` + a 64-char SHA256 hex digest (70 chars); the
+    # column is sized with headroom (see superset.tasks.guest).
+    guest_key = Column(String(128), nullable=True, index=True)
     subscribed_at = Column(DateTime, nullable=False, default=datetime.now(timezone.utc))
 
     # Relationships
diff --git a/tests/unit_tests/daos/test_tasks.py b/tests/unit_tests/daos/test_tasks.py
index ca10970e643..dde92a17874 100644
--- a/tests/unit_tests/daos/test_tasks.py
+++ b/tests/unit_tests/daos/test_tasks.py
@@ -378,6 +378,28 @@ def test_get_subscriber_channels(session_with_task: Session) -> None:
     assert set(channels) == {"user:7", "guest-abc"}
 
 
+def test_add_guest_subscriber_full_length_key(session_with_task: Session) -> None:
+    """A realistic guest key (``guest-`` + 64-char SHA256 hex = 70 chars) persists.
+
+    Regression guard for the guest_key column width: the real key from
+    superset.tasks.guest is 70 chars, so a String(64) column would error/truncate.
+    """
+    from superset.daos.tasks import TaskDAO
+
+    task = create_task(
+        session_with_task,
+        task_key="shared-guest-len",
+        scope=TaskScope.SHARED,
+        user_id=None,
+    )
+    guest_key = "guest-" + "a" * 64  # mirrors superset.tasks.guest (70 chars)
+
+    assert TaskDAO.add_guest_subscriber(task.id, guest_key=guest_key) is True
+
+    session_with_task.refresh(task)
+    assert task.subscribers[0].guest_key == guest_key
+
+
 def test_get_subscriber_channels_empty(session_with_task: Session) -> None:
     """get_subscriber_channels returns [] for a task with no subscribers."""
     from superset.daos.tasks import TaskDAO