blob: c558762feb3986730d9329472fe06c8cab8700d4 [file]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import { isFeatureEnabled } from '@superset-ui/core';
import * as asyncEvent from 'src/middleware/asyncEvent';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
// Mutable tab-id mock (overrides the global shim) so a test can change the tab id
// between submit and cancel. `var` + lazy init sidesteps jest.mock hoisting/TDZ.
/* eslint-disable no-var, vars-on-top */
var mockTabId: string;
/* eslint-enable no-var, vars-on-top */
jest.mock('src/hooks/useTabId', () => {
mockTabId = 'test-tab-id';
return {
getTabId: () => mockTabId,
subscribeTabIdChange: () => () => {},
};
});
// Mock the shared realtime client so no real socket is opened and we can drive
// the "socket (re)connected" event that triggers the WS-mode catch-up.
/* eslint-disable no-var, vars-on-top */
var mockRealtimeOpenListener: (() => void) | undefined;
var mockRealtimeStateListener: ((state: string) => void) | undefined;
/* eslint-enable no-var, vars-on-top */
jest.mock('src/middleware/realtime', () => ({
connectRealtime: jest.fn(),
subscribeRealtime: jest.fn(() => () => {}),
subscribeRealtimeOpen: (listener: () => void) => {
mockRealtimeOpenListener = listener;
return () => {
mockRealtimeOpenListener = undefined;
};
},
subscribeRealtimeState: (listener: (state: string) => void) => {
mockRealtimeStateListener = listener;
return () => {
mockRealtimeStateListener = undefined;
};
},
}));
// Bootstrap can carry no `conf` (e.g. a component test with no data-bootstrap);
// init() runs at module load, so it must not throw when conf is undefined.
/* eslint-disable no-var, vars-on-top */
var mockBootstrapConf: object | undefined;
/* eslint-enable no-var, vars-on-top */
jest.mock('src/utils/getBootstrapData', () => ({
__esModule: true,
default: () => ({ common: { conf: mockBootstrapConf } }),
}));
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
const STATUS_CHANGES_ENDPOINT = 'glob:*/api/v1/task/status_changes*';
const CANCEL_ENDPOINT = 'glob:*/api/v1/task/*/cancel';
const config = { GLOBAL_ASYNC_QUERIES_POLLING_DELAY: 20 };
// Queue of status_changes responses the polling loop drains in order. The first
// is an empty no-progress poll, then each poll consumes the next.
let statusResponses: { statuses: Record<string, unknown>; cursor: string }[];
const queueStatuses = (
...batches: Record<string, { status: string }>[]
): void => {
statusResponses = [
{ statuses: {}, cursor: '2020-01-01T00:00:00' },
...batches.map((statuses, i) => ({
statuses,
cursor: `2020-01-01T00:00:0${i + 1}`,
})),
];
};
beforeEach(() => {
mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === 'GLOBAL_ASYNC_QUERIES',
);
fetchMock.get(STATUS_CHANGES_ENDPOINT, () => {
const next = statusResponses.shift();
return { status: 200, body: next ?? { statuses: {}, cursor: null } };
});
fetchMock.post(CANCEL_ENDPOINT, { status: 200, body: { action: 'aborted' } });
});
afterEach(() => {
jest.useRealTimers();
fetchMock.clearHistory().removeRoutes();
mockedIsFeatureEnabled.mockRestore();
mockTabId = 'test-tab-id';
});
test('re-issues the request once every query task succeeds', async () => {
queueStatuses({
'task-1': { status: 'success' },
'task-2': { status: 'success' },
});
asyncEvent.init(config);
const refetch = jest.fn().mockResolvedValue([{ rows: 1 }]);
const result = await asyncEvent.waitForAsyncData(
{ task_ids: ['task-1', 'task-2'] },
refetch,
);
expect(refetch).toHaveBeenCalledTimes(1);
expect(result).toEqual([{ rows: 1 }]);
});
test('does not poll status changes until a chart is awaiting async data', () => {
queueStatuses();
asyncEvent.init(config);
expect(fetchMock.callHistory.calls(STATUS_CHANGES_ENDPOINT)).toHaveLength(0);
});
test('init does not throw when bootstrap carries no conf', () => {
// Regression: init() runs at module load; reading WEBSOCKET_ENABLE off an
// undefined conf (async off, no data-bootstrap) must not crash the module —
// which would break every test file that transitively imports it.
mockedIsFeatureEnabled.mockReturnValue(false);
mockBootstrapConf = undefined;
expect(() => asyncEvent.init()).not.toThrow();
});
test('waits for every task before re-issuing', async () => {
// task-2 only succeeds in the second poll batch.
queueStatuses(
{ 'task-1': { status: 'success' } },
{ 'task-2': { status: 'success' } },
);
asyncEvent.init(config);
const refetch = jest.fn().mockResolvedValue([{ rows: 2 }]);
await asyncEvent.waitForAsyncData(
{ task_ids: ['task-1', 'task-2'] },
refetch,
);
expect(refetch).toHaveBeenCalledTimes(1);
});
test('rejects and does not re-issue when a task fails', async () => {
queueStatuses({
'task-1': { status: 'success' },
'task-2': { status: 'failure' },
});
asyncEvent.init(config);
const refetch = jest.fn();
await expect(
asyncEvent.waitForAsyncData({ task_ids: ['task-1', 'task-2'] }, refetch),
).rejects.toThrow();
expect(refetch).not.toHaveBeenCalled();
});
test('resolves immediately for an empty task list', async () => {
queueStatuses();
asyncEvent.init(config);
const refetch = jest.fn().mockResolvedValue([]);
await asyncEvent.waitForAsyncData({ task_ids: [] }, refetch);
expect(refetch).toHaveBeenCalledTimes(1);
});
test('aborting cancels the tasks and rejects with AbortError', async () => {
queueStatuses(); // tasks never resolve on their own
asyncEvent.init(config);
const controller = new AbortController();
const refetch = jest.fn();
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
controller.signal,
);
controller.abort();
await expect(promise).rejects.toThrow('Aborted');
expect(refetch).not.toHaveBeenCalled();
const cancelCalls = fetchMock.callHistory.calls(CANCEL_ENDPOINT);
expect(cancelCalls).toHaveLength(1);
// The cancel carries this tab's id so the backend detaches only this tab.
const cancelBody = JSON.parse(String(cancelCalls[0].options.body));
expect(typeof cancelBody.tab_id).toBe('string');
expect(cancelBody.tab_id.length).toBeGreaterThan(0);
});
test('aborting one chart does not cancel a shared task another chart still awaits', async () => {
// Two charts for the same principal join one deduplicated SHARED task (one
// backend subscriber). Aborting one must NOT cancel the task while the other
// still awaits it — otherwise the server sees the last subscriber leave and
// aborts work the surviving chart needs. Cancellation is deferred until the
// last local waiter goes away.
queueStatuses(); // task never resolves on its own
asyncEvent.init(config);
const controllerA = new AbortController();
const controllerB = new AbortController();
const a = asyncEvent.waitForAsyncData(
{ task_ids: ['shared'] },
jest.fn(),
controllerA.signal,
);
const b = asyncEvent.waitForAsyncData(
{ task_ids: ['shared'] },
jest.fn(),
controllerB.signal,
);
// First abort: the other chart still awaits 'shared', so nothing is cancelled.
controllerA.abort();
await expect(a).rejects.toThrow('Aborted');
expect(fetchMock.callHistory.calls(CANCEL_ENDPOINT)).toHaveLength(0);
// Second abort: now the last waiter is gone, so the task is cancelled once.
controllerB.abort();
await expect(b).rejects.toThrow('Aborted');
expect(fetchMock.callHistory.calls(CANCEL_ENDPOINT)).toHaveLength(1);
});
test('cancel uses the tab id the backend recorded (echoed in the 202)', async () => {
// The 202 echoes the tab id the backend recorded as this tab's consumer.
// Cancel must use that value, not the tab id read fresh at cancel time — a
// duplicate-tab collision could otherwise reassign getTabId() and detach the
// wrong subscription.
queueStatuses(); // task never resolves on its own
asyncEvent.init(config);
const controller = new AbortController();
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'], tab_id: 'tab-A' },
jest.fn(),
controller.signal,
);
mockTabId = 'tab-B'; // reassigned after submit — must NOT be used for cancel
controller.abort();
await expect(promise).rejects.toThrow('Aborted');
const cancelCalls = fetchMock.callHistory.calls(CANCEL_ENDPOINT);
expect(cancelCalls).toHaveLength(1);
const body = JSON.parse(String(cancelCalls[0].options.body));
expect(body.tab_id).toBe('tab-A');
});
test('cancel falls back to the current tab id when the 202 carried none', async () => {
queueStatuses();
asyncEvent.init(config);
mockTabId = 'tab-current';
const controller = new AbortController();
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] }, // no tab_id echoed
jest.fn(),
controller.signal,
);
controller.abort();
await expect(promise).rejects.toThrow('Aborted');
const cancelCalls = fetchMock.callHistory.calls(CANCEL_ENDPOINT);
const body = JSON.parse(String(cancelCalls[0].options.body));
expect(body.tab_id).toBe('tab-current');
});
test('settles every request awaiting a deduplicated shared task', async () => {
// Two concurrent chart requests share the same (deduplicated) task uuid; both
// must resolve when it completes — the later waiter must not overwrite the first.
queueStatuses({ shared: { status: 'success' } });
asyncEvent.init(config);
const refetchA = jest.fn().mockResolvedValue([{ chart: 'a' }]);
const refetchB = jest.fn().mockResolvedValue([{ chart: 'b' }]);
const [a, b] = await Promise.all([
asyncEvent.waitForAsyncData({ task_ids: ['shared'] }, refetchA),
asyncEvent.waitForAsyncData({ task_ids: ['shared'] }, refetchB),
]);
expect(refetchA).toHaveBeenCalledTimes(1);
expect(refetchB).toHaveBeenCalledTimes(1);
expect(a).toEqual([{ chart: 'a' }]);
expect(b).toEqual([{ chart: 'b' }]);
});
test('polls from the pre-task cursor returned in the 202', async () => {
// The 202 carries a cursor captured before the tasks existed; the poll must
// use it so an early terminal status can't be skipped.
queueStatuses({ 'task-1': { status: 'success' } });
asyncEvent.init(config);
const refetch = jest.fn().mockResolvedValue([{ rows: 1 }]);
await asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'], cursor: '2019-06-06T00:00:00' },
refetch,
);
const polled = fetchMock.callHistory
.calls(STATUS_CHANGES_ENDPOINT)
.some(call => decodeURIComponent(call.url).includes('2019-06-06T00:00:00'));
expect(polled).toBe(true);
});
test('stops polling once all awaited tasks settle', async () => {
queueStatuses({ 'task-1': { status: 'success' } });
asyncEvent.init(config);
await asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
jest.fn().mockResolvedValue([]),
);
// The loop must go fully idle — no heartbeat polling after everything settled.
const pollsAtSettle = fetchMock.callHistory.calls(
STATUS_CHANGES_ENDPOINT,
).length;
await new Promise(resolve => {
setTimeout(resolve, config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY * 12);
});
expect(fetchMock.callHistory.calls(STATUS_CHANGES_ENDPOINT).length).toBe(
pollsAtSettle,
);
});
test('restarts polling for a new job after going idle', async () => {
queueStatuses({ 'task-1': { status: 'success' } });
asyncEvent.init(config);
await asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
jest.fn().mockResolvedValue([]),
);
// Loop is idle now. A second job must wake it and resolve.
statusResponses.push({
statuses: { 'task-2': { status: 'success' } },
cursor: '2020-01-01T00:00:09',
});
const refetch = jest.fn().mockResolvedValue([{ rows: 9 }]);
const result = await asyncEvent.waitForAsyncData(
{ task_ids: ['task-2'] },
refetch,
);
expect(result).toEqual([{ rows: 9 }]);
});
test('gives up (rejects) after the stale timeout with no progress', async () => {
queueStatuses(); // no terminal status for the awaited task
asyncEvent.init({
...config,
GLOBAL_ASYNC_QUERIES_POLLING_STALE_TIMEOUT: 40, // ms
});
const refetch = jest.fn();
await expect(
asyncEvent.waitForAsyncData({ task_ids: ['stuck-task'] }, refetch),
).rejects.toThrow('Timed out waiting for chart-data query results');
expect(refetch).not.toHaveBeenCalled();
});
test('poll mode gives up (rejects) on a persistent status_changes error', async () => {
// A sustained fetch error must age toward the give-up too — not spin forever.
// Use a 400 (client error, not retried by SupersetClient) so it rejects fast
// and deterministically rather than retrying over real timers.
fetchMock.removeRoutes().clearHistory();
fetchMock.get(STATUS_CHANGES_ENDPOINT, {
status: 400,
body: { message: 'bad' },
});
fetchMock.post(CANCEL_ENDPOINT, { status: 200, body: { action: 'aborted' } });
asyncEvent.init({
...config,
GLOBAL_ASYNC_QUERIES_POLLING_STALE_TIMEOUT: 40, // ms
});
const refetch = jest.fn();
await expect(
asyncEvent.waitForAsyncData({ task_ids: ['stuck-task'] }, refetch),
).rejects.toThrow('Timed out waiting for chart-data query results');
expect(refetch).not.toHaveBeenCalled();
});
test('backs off while awaited tasks stay quiet, then polls eagerly again on progress', async () => {
jest.useFakeTimers();
queueStatuses(); // every poll comes back empty: no progress
asyncEvent.init({
...config, // 20ms eager interval
GLOBAL_ASYNC_QUERIES_POLLING_MAX_DELAY: 80,
GLOBAL_ASYNC_QUERIES_POLLING_STALE_TIMEOUT: 60_000,
});
const polls = () =>
fetchMock.callHistory.calls(STATUS_CHANGES_ENDPOINT).length;
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1', 'task-2'] },
jest.fn().mockResolvedValue([]),
);
// Registering a waiter polls immediately; that first poll is quiet, so the
// next one is scheduled at twice the eager interval (40ms, not 20ms).
await jest.advanceTimersByTimeAsync(0);
expect(polls()).toBe(1);
await jest.advanceTimersByTimeAsync(20);
expect(polls()).toBe(1);
await jest.advanceTimersByTimeAsync(20);
expect(polls()).toBe(2);
// Still quiet: the delay doubles again to 80ms.
await jest.advanceTimersByTimeAsync(40);
expect(polls()).toBe(2);
await jest.advanceTimersByTimeAsync(40);
expect(polls()).toBe(3);
// Capped at GLOBAL_ASYNC_QUERIES_POLLING_MAX_DELAY: 80ms, never 160ms.
await jest.advanceTimersByTimeAsync(80);
expect(polls()).toBe(4);
// A change for an awaited task snaps the delay back to the eager interval
// (the other task is still pending, so the loop keeps running).
statusResponses.push({
statuses: { 'task-1': { status: 'success' } },
cursor: '2020-01-01T00:01:00',
});
await jest.advanceTimersByTimeAsync(80);
expect(polls()).toBe(5);
await jest.advanceTimersByTimeAsync(20);
expect(polls()).toBe(6);
// Complete the second task so the waiter settles and the loop goes idle.
statusResponses.push({
statuses: { 'task-2': { status: 'success' } },
cursor: '2020-01-01T00:01:01',
});
await jest.advanceTimersByTimeAsync(80);
await expect(promise).resolves.toEqual([]);
});
// The shared realtime client passes a `task.status` message's payload straight to
// the handler (the topic already matched), so tests call the handler with just
// the payload.
const taskStatusPayload = (taskId: string, status: string) => ({
task_id: taskId,
status,
});
test('a realtime message settles a waiting chart without a poll', async () => {
// No terminal status batches are queued, so completion can ONLY come from
// the socket message — proving the WS path settles on its own.
queueStatuses();
asyncEvent.init(config);
const refetch = jest.fn().mockResolvedValue([{ rows: 1 }]);
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
);
// 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.handleTaskStatus(taskStatusPayload('task-1', 'success'));
expect(await promise).toEqual([{ rows: 1 }]);
expect(refetch).toHaveBeenCalledTimes(1);
});
test('a realtime failure message rejects the waiting chart', async () => {
queueStatuses();
asyncEvent.init(config);
const refetch = jest.fn();
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
);
asyncEvent.handleTaskStatus(taskStatusPayload('task-1', 'failure'));
await expect(promise).rejects.toThrow();
expect(refetch).not.toHaveBeenCalled();
});
test('ignores a payload without task_id/status', async () => {
queueStatuses();
asyncEvent.init(config);
const refetch = jest.fn().mockResolvedValue([{ rows: 1 }]);
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
);
// A non-task-status payload (e.g. an entity-change nudge shape) must not
// settle a chart.
asyncEvent.handleTaskStatus({ entity_type: 'task', id: 'task-1' });
expect(refetch).not.toHaveBeenCalled();
// The task is still pending; complete it so the test doesn't leak a waiter.
asyncEvent.handleTaskStatus(taskStatusPayload('task-1', 'success'));
await promise;
});
test('a realtime message is a no-op when async queries are disabled', () => {
// The shared socket connects whenever WEBSOCKET_ENABLE, independent of the
// GLOBAL_ASYNC_QUERIES flag, so a realtime message can arrive with no active
// async flow — it must not throw (waiter registry stays an empty map).
mockedIsFeatureEnabled.mockReturnValue(false);
asyncEvent.init(config);
expect(() =>
asyncEvent.handleTaskStatus(taskStatusPayload('task-1', 'success')),
).not.toThrow();
});
// --- WebSocket mode: no interval polling; catch-up on registration/reconnect ---
const wsConfig = {
WEBSOCKET_ENABLE: true,
WEBSOCKET_URL: 'ws://localhost:8080/',
GLOBAL_ASYNC_QUERIES_POLLING_DELAY: 20,
GLOBAL_ASYNC_QUERIES_POLLING_STALE_TIMEOUT: 600_000,
};
test('WEBSOCKET_ENABLE without a URL keeps polling (never disables the poll)', async () => {
// A socket can never open without a URL, so the transport must not be treated
// as enabled — otherwise the poll is disabled and completion never arrives.
queueStatuses({ 'task-1': { status: 'success' } });
asyncEvent.init({
WEBSOCKET_ENABLE: true,
GLOBAL_ASYNC_QUERIES_POLLING_DELAY: 20,
});
const refetch = jest.fn().mockResolvedValue([{ rows: 1 }]);
// Resolves via the interval poll (no socket message is delivered).
expect(
await asyncEvent.waitForAsyncData({ task_ids: ['task-1'] }, refetch),
).toEqual([{ rows: 1 }]);
});
test('WS mode: settles via the socket without any status_changes polling', async () => {
queueStatuses(); // registration catch-up sees no change
asyncEvent.init(wsConfig);
const refetch = jest.fn().mockResolvedValue([{ rows: 1 }]);
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
);
// Completion arrives over the socket, not a poll.
asyncEvent.handleTaskStatus(taskStatusPayload('task-1', 'success'));
expect(await promise).toEqual([{ rows: 1 }]);
// At most the single one-shot registration catch-up ran — never a loop. (It
// no-ops if the socket settled the waiter before its microtask ran.)
expect(
fetchMock.callHistory.calls(STATUS_CHANGES_ENDPOINT).length,
).toBeLessThanOrEqual(1);
});
test('WS mode: does not start an interval poll loop', async () => {
jest.useFakeTimers();
queueStatuses();
asyncEvent.init(wsConfig);
const controller = new AbortController();
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
jest.fn(),
controller.signal,
);
// Advance well past several poll intervals; a poll loop would fire repeatedly.
await jest.advanceTimersByTimeAsync(5000);
// Only the single registration catch-up ran — no recurring polling.
expect(fetchMock.callHistory.calls(STATUS_CHANGES_ENDPOINT)).toHaveLength(1);
controller.abort(); // clean up the pending waiter + give-up timer
await expect(promise).rejects.toThrow('Aborted');
jest.useRealTimers();
});
test('WS mode: registration catch-up settles a task that completed pre-registration', async () => {
// The very first (registration) catch-up fetch returns the task as already
// succeeded — no empty baseline in front of it.
statusResponses = [
{
statuses: { 'task-1': { status: 'success' } },
cursor: '2020-01-01T00:00:01',
},
];
asyncEvent.init(wsConfig);
const refetch = jest.fn().mockResolvedValue([{ rows: 2 }]);
// No socket message is delivered; the registration catch-up alone settles it.
const result = await asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
);
expect(result).toEqual([{ rows: 2 }]);
expect(refetch).toHaveBeenCalledTimes(1);
});
test('WS mode: a reconnect runs one catch-up that reconciles the gap', async () => {
// queueStatuses prepends an empty baseline (consumed by the registration
// catch-up); the success batch is served to the reconnect catch-up.
queueStatuses({ 'task-1': { status: 'success' } });
asyncEvent.init(wsConfig);
const refetch = jest.fn().mockResolvedValue([{ rows: 3 }]);
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
);
// Let the registration catch-up run and finish (consuming the empty baseline)
// so the reconnect below is a distinct, non-coalesced catch-up.
await new Promise(resolve => {
setTimeout(resolve, 0);
});
// Simulate the socket reconnecting: the shared client fires its open-listener.
mockRealtimeOpenListener?.();
expect(await promise).toEqual([{ rows: 3 }]);
expect(refetch).toHaveBeenCalledTimes(1);
});
test('WS mode: a reconnecting transition runs a catch-up that reconciles the gap', async () => {
queueStatuses({ 'task-1': { status: 'success' } });
asyncEvent.init(wsConfig);
const refetch = jest.fn().mockResolvedValue([{ rows: 4 }]);
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
);
// Let the registration catch-up consume the empty baseline first.
await new Promise(resolve => {
setTimeout(resolve, 0);
});
// The socket dropped: reconcile via the socket-independent status_changes fetch.
mockRealtimeStateListener?.('reconnecting');
expect(await promise).toEqual([{ rows: 4 }]);
expect(refetch).toHaveBeenCalledTimes(1);
});
test('WS mode: an unhealthy socket settles pending waiters with a bounded error', async () => {
queueStatuses(); // no completion is ever delivered
asyncEvent.init(wsConfig);
const refetch = jest.fn();
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
);
// Let the registration catch-up run (finding nothing) so the waiter is pending.
await new Promise(resolve => {
setTimeout(resolve, 0);
});
// The socket has failed to reconnect enough times to be considered down: rather
// than hang until the long give-up, the waiter is settled with a clear error.
mockRealtimeStateListener?.('unhealthy');
await expect(promise).rejects.toThrow('Realtime connection unavailable');
expect(refetch).not.toHaveBeenCalled();
});
test('WS mode: coalesces many same-tick registrations into one catch-up', async () => {
jest.useFakeTimers();
queueStatuses(); // all catch-ups see no change
asyncEvent.init(wsConfig);
// Three charts register in the same tick (a dashboard loading async charts).
const controllers = [
new AbortController(),
new AbortController(),
new AbortController(),
];
const promises = controllers.map((c, i) =>
asyncEvent.waitForAsyncData(
{ task_ids: [`task-${i}`] },
jest.fn(),
c.signal,
),
);
await jest.advanceTimersByTimeAsync(0); // flush the coalescing microtask + fetch
// A single status_changes request reconciles all three (waitersByTaskId is
// global) — not one request per chart.
expect(fetchMock.callHistory.calls(STATUS_CHANGES_ENDPOINT)).toHaveLength(1);
controllers.forEach(c => c.abort());
await Promise.allSettled(promises);
jest.useRealTimers();
});
test('WS mode: an in-flight catch-up does not clobber a cursor rewound mid-flight', async () => {
asyncEvent.init(wsConfig);
// Control the first catch-up's resolution so a second waiter can register
// (rewinding the cursor to an earlier watermark) while it is in flight.
let resolveFirst: () => void = () => {};
const firstInFlight = new Promise<void>(res => {
resolveFirst = res;
});
let calls = 0;
fetchMock.removeRoutes().clearHistory();
fetchMock.get(STATUS_CHANGES_ENDPOINT, () => {
calls += 1;
// First catch-up (from T2) stays in flight, then returns a later cursor T3.
if (calls === 1) {
return firstInFlight.then(() => ({
status: 200,
body: { statuses: {}, cursor: '2020-01-01T00:00:03' },
}));
}
return {
status: 200,
body: { statuses: {}, cursor: '2020-01-01T00:00:09' },
};
});
fetchMock.post(CANCEL_ENDPOINT, { status: 200, body: { action: 'aborted' } });
// Waiter 1 (202 cursor T2) → global cursor T2 → first catch-up fetches from T2.
const c1 = new AbortController();
const p1 = asyncEvent.waitForAsyncData(
{ task_ids: ['t1'], cursor: '2020-01-01T00:00:02' },
jest.fn(),
c1.signal,
);
// Let the first catch-up actually start its (deferred) fetch.
await new Promise(resolve => {
setTimeout(resolve, 0);
});
expect(calls).toBe(1);
// Waiter 2 (earlier 202 cursor T1) registers while the first is in flight →
// rewinds the global cursor to T1 and queues a follow-up catch-up.
const c2 = new AbortController();
const p2 = asyncEvent.waitForAsyncData(
{ task_ids: ['t2'], cursor: '2020-01-01T00:00:01' },
jest.fn(),
c2.signal,
);
// The first catch-up resolves (returns T3); it must NOT overwrite the rewound T1.
resolveFirst();
await new Promise(resolve => {
setTimeout(resolve, 0);
});
// The queued follow-up must fetch from T1 (the rewound watermark), not T3.
const secondCall = fetchMock.callHistory.calls(STATUS_CHANGES_ENDPOINT)[1];
expect(decodeURIComponent(secondCall.url)).toContain('2020-01-01T00:00:01');
expect(decodeURIComponent(secondCall.url)).not.toContain(
'2020-01-01T00:00:03',
);
c1.abort();
c2.abort();
await Promise.allSettled([p1, p2]);
});
test('WS mode: a genuinely lost completion gives up after the timeout', async () => {
jest.useFakeTimers();
queueStatuses(); // nothing ever reports success (neither catch-up finds it)
asyncEvent.init({
...wsConfig,
GLOBAL_ASYNC_QUERIES_POLLING_STALE_TIMEOUT: 1000,
});
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
jest.fn(),
);
// Attach the rejection handler before the give-up timer fires.
const expectation = expect(promise).rejects.toThrow('Timed out');
// Past the jittered deadline (+ up to GIVE_UP_JITTER_MS); the awaited last-chance
// catch-up finds nothing, so the waiter is rejected.
await jest.advanceTimersByTimeAsync(1000 + 5000 + 200);
await expectation;
jest.useRealTimers();
});
test('WS mode: the last-chance catch-up before give-up recovers a missed completion', async () => {
jest.useFakeTimers();
// Registration catch-up consumes the empty baseline; by give-up time the task
// has completed, so the give-up's one-shot catch-up picks it up and the waiter
// resolves instead of rejecting (the "message missed but socket stayed open" case).
queueStatuses({ 'task-1': { status: 'success' } });
asyncEvent.init({
...wsConfig,
GLOBAL_ASYNC_QUERIES_POLLING_STALE_TIMEOUT: 1000,
});
const refetch = jest.fn().mockResolvedValue([{ rows: 1 }]);
const promise = asyncEvent.waitForAsyncData(
{ task_ids: ['task-1'] },
refetch,
);
// Reach the give-up deadline (+ jitter); its catch-up finds the completion.
await jest.advanceTimersByTimeAsync(1000 + 5000 + 200);
expect(await promise).toEqual([{ rows: 1 }]);
expect(refetch).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});