Skip to content

Commit 22faad7

Browse files
authored
Web console: Improve explore max time cancelation (#18830)
* add a timeout * hook up cancel for table * remove console.log
1 parent 7272d27 commit 22faad7

File tree

6 files changed

+164
-14
lines changed

6 files changed

+164
-14
lines changed

web-console/src/utils/general.spec.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
OVERLAY_OPEN_SELECTOR,
3535
parseCsvLine,
3636
swapElements,
37+
wait,
3738
} from './general';
3839

3940
describe('general', () => {
@@ -231,4 +232,118 @@ describe('general', () => {
231232
expect(OVERLAY_OPEN_SELECTOR).toEqual('.bp5-portal .bp5-overlay-open');
232233
});
233234
});
235+
236+
describe('wait', () => {
237+
beforeEach(() => {
238+
jest.useFakeTimers();
239+
});
240+
241+
afterEach(() => {
242+
jest.useRealTimers();
243+
});
244+
245+
it('resolves after the specified time', async () => {
246+
const promise = wait(100);
247+
expect(promise).toBeInstanceOf(Promise);
248+
249+
jest.advanceTimersByTime(99);
250+
await Promise.resolve(); // Let microtasks run
251+
expect(promise).not.toBe(await Promise.race([promise, Promise.resolve('pending')]));
252+
253+
jest.advanceTimersByTime(1);
254+
await expect(promise).resolves.toBeUndefined();
255+
});
256+
257+
it('works without a signal (backward compatibility)', async () => {
258+
const promise = wait(50);
259+
jest.advanceTimersByTime(50);
260+
await expect(promise).resolves.toBeUndefined();
261+
});
262+
263+
it('resolves normally when signal does not abort', async () => {
264+
const controller = new AbortController();
265+
const promise = wait(100, controller.signal);
266+
267+
jest.advanceTimersByTime(100);
268+
await expect(promise).resolves.toBeUndefined();
269+
});
270+
271+
it('rejects when signal aborts before timeout', async () => {
272+
const controller = new AbortController();
273+
const promise = wait(100, controller.signal);
274+
275+
jest.advanceTimersByTime(50);
276+
controller.abort();
277+
278+
await expect(promise).rejects.toThrow('Aborted');
279+
});
280+
281+
it('rejects immediately if signal is already aborted', async () => {
282+
const controller = new AbortController();
283+
controller.abort();
284+
285+
const promise = wait(100, controller.signal);
286+
await expect(promise).rejects.toThrow('Aborted');
287+
288+
// Timer should not have been created
289+
expect(jest.getTimerCount()).toBe(0);
290+
});
291+
292+
it('cleans up timeout when aborted', async () => {
293+
const controller = new AbortController();
294+
const promise = wait(100, controller.signal);
295+
296+
expect(jest.getTimerCount()).toBe(1);
297+
298+
controller.abort();
299+
300+
try {
301+
await promise;
302+
} catch {
303+
// Expected
304+
}
305+
306+
// Timer should be cleaned up
307+
expect(jest.getTimerCount()).toBe(0);
308+
});
309+
310+
it('cleans up event listener when timeout completes', async () => {
311+
const controller = new AbortController();
312+
const removeEventListenerSpy = jest.spyOn(controller.signal, 'removeEventListener');
313+
314+
const promise = wait(100, controller.signal);
315+
jest.advanceTimersByTime(100);
316+
await promise;
317+
318+
expect(removeEventListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function));
319+
});
320+
321+
it('cleans up event listener when aborted', async () => {
322+
const controller = new AbortController();
323+
const removeEventListenerSpy = jest.spyOn(controller.signal, 'removeEventListener');
324+
325+
const promise = wait(100, controller.signal);
326+
controller.abort();
327+
328+
try {
329+
await promise;
330+
} catch {
331+
// Expected
332+
}
333+
334+
expect(removeEventListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function));
335+
});
336+
337+
it('handles multiple waits with same signal', async () => {
338+
const controller = new AbortController();
339+
const promise1 = wait(100, controller.signal);
340+
const promise2 = wait(200, controller.signal);
341+
342+
jest.advanceTimersByTime(50);
343+
controller.abort();
344+
345+
await expect(promise1).rejects.toThrow('Aborted');
346+
await expect(promise2).rejects.toThrow('Aborted');
347+
});
348+
});
234349
});

web-console/src/utils/general.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,29 @@ export function arraysEqualByElement<T>(xs: T[], ys: T[]): boolean {
6363
return xs.length === ys.length && xs.every((x, i) => x === ys[i]);
6464
}
6565

66-
export function wait(ms: number): Promise<void> {
67-
return new Promise(resolve => {
68-
setTimeout(resolve, ms);
66+
export function wait(ms: number, signal?: AbortSignal): Promise<void> {
67+
return new Promise((resolve, reject) => {
68+
if (signal?.aborted) {
69+
reject(new Error('Aborted'));
70+
return;
71+
}
72+
73+
const timeoutId = setTimeout(() => {
74+
cleanup();
75+
resolve();
76+
}, ms);
77+
78+
const onAbort = () => {
79+
cleanup();
80+
reject(new Error('Aborted'));
81+
};
82+
83+
const cleanup = () => {
84+
clearTimeout(timeoutId);
85+
signal?.removeEventListener('abort', onAbort);
86+
};
87+
88+
signal?.addEventListener('abort', onAbort);
6989
});
7090
}
7191

web-console/src/views/explore-view/explore-view.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export const ExploreView = React.memo(function ExploreView({ capabilities }: Exp
229229

230230
const { query: rewrittenQuery, maxTime } = await rewriteMaxDataTime(
231231
rewriteAggregate(parsedQuery, querySource.measures),
232+
signal,
232233
);
233234
const results = await runSqlQuery(rewrittenQuery, queryTimezone, signal);
234235

web-console/src/views/explore-view/modules/grouping-table-module/grouping-table-module.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import { Button } from '@blueprintjs/core';
2020
import { IconNames } from '@blueprintjs/icons';
2121
import type { Timezone } from 'chronoshift';
22-
import type { SqlExpression, SqlOrderByDirection, SqlQuery } from 'druid-query-toolkit';
22+
import type { SqlExpression, SqlOrderByDirection } from 'druid-query-toolkit';
2323
import { C, F } from 'druid-query-toolkit';
2424
import { useMemo } from 'react';
2525

@@ -233,10 +233,10 @@ ModuleRepository.registerModule<GroupingTableParameterValues>({
233233
.changeLimitValue(maxPivotValues);
234234
}, [querySource, where, moduleWhere, parameterValues]);
235235

236-
const [pivotValueState, queryManager] = useQueryManager({
236+
const [pivotValueState, pivotValueQueryManager] = useQueryManager({
237237
query: pivotValueQuery,
238-
processQuery: async (pivotValueQuery: SqlQuery) => {
239-
return (await runSqlQuery(pivotValueQuery)).getColumnByName('v') as string[];
238+
processQuery: async (pivotValueQuery, signal) => {
239+
return (await runSqlQuery(pivotValueQuery, signal)).getColumnByName('v') as string[];
240240
},
241241
});
242242

@@ -272,11 +272,12 @@ ModuleRepository.registerModule<GroupingTableParameterValues>({
272272
};
273273
}, [querySource.query, timezone, where, parameterValues, pivotValueState.data]);
274274

275-
const [resultState] = useQueryManager({
275+
const [resultState, resultQueryManager] = useQueryManager({
276276
query: queryAndMore,
277277
processQuery: async (queryAndMore, signal) => {
278278
const { timezone, globalWhere, queryAndHints } = queryAndMore;
279279
const { query, columnHints } = queryAndHints;
280+
280281
let result = await runSqlQuery({ query, timezone }, signal);
281282
if (result.sqlQuery) {
282283
result = result.attachQuery(
@@ -328,7 +329,13 @@ ModuleRepository.registerModule<GroupingTableParameterValues>({
328329
/>
329330
) : undefined}
330331
{resultState.loading && (
331-
<Loader cancelText="Cancel query" onCancel={() => queryManager.cancelCurrent()} />
332+
<Loader
333+
cancelText="Cancel query"
334+
onCancel={() => {
335+
pivotValueQueryManager.cancelCurrent();
336+
resultQueryManager.cancelCurrent();
337+
}}
338+
/>
332339
)}
333340
</div>
334341
);

web-console/src/views/explore-view/query-macros/max-data-time.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const tablesForWhichWeCouldNotDetermineMaxTime = new Set<string>();
2828

2929
export async function rewriteMaxDataTime(
3030
query: SqlQuery,
31+
signal?: AbortSignal,
3132
): Promise<{ query: SqlQuery; maxTime?: Date }> {
3233
if (!query.containsFunction('MAX_DATA_TIME')) return { query };
3334

@@ -36,7 +37,7 @@ export async function rewriteMaxDataTime(
3637

3738
let maxTime: Date;
3839
try {
39-
maxTime = await getMaxTimeForTable(tableName);
40+
maxTime = await getMaxTimeForTable(tableName, signal);
4041
} catch (error) {
4142
if (!tablesForWhichWeCouldNotDetermineMaxTime.has(tableName)) {
4243
tablesForWhichWeCouldNotDetermineMaxTime.add(tableName);

web-console/src/views/explore-view/utils/max-time-for-table.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ let lastMaxTimeTable: string | undefined;
2727
let lastMaxTimeValue: Date | undefined;
2828
let lastMaxTimeTimestamp = 0;
2929

30-
export async function getMaxTimeForTable(tableName: string): Promise<Date> {
30+
export async function getMaxTimeForTable(tableName: string, signal?: AbortSignal): Promise<Date> {
3131
// micro-cache get
3232
if (
3333
lastMaxTimeTable === tableName &&
@@ -37,9 +37,15 @@ export async function getMaxTimeForTable(tableName: string): Promise<Date> {
3737
return lastMaxTimeValue;
3838
}
3939

40-
const d = await queryDruidSql({
41-
query: sql`SELECT MAX(__time) AS "maxTime" FROM ${T(tableName)}`,
42-
});
40+
const d = await queryDruidSql(
41+
{
42+
query: sql`SELECT MAX(__time) AS "maxTime" FROM ${T(tableName)}`,
43+
context: {
44+
timeout: 2000, // We expect this query to be superfast
45+
},
46+
},
47+
signal,
48+
);
4349

4450
const maxTimeRaw = deepGet(d, '0.maxTime');
4551
const maxTime = new Date(maxTimeRaw);

0 commit comments

Comments
 (0)