Skip to content

Commit 3e73289

Browse files
committed
feat: Add disableQueryChunking option
1 parent dd2d0fe commit 3e73289

File tree

4 files changed

+165
-17
lines changed

4 files changed

+165
-17
lines changed

packages/app/src/hooks/__tests__/useChartConfig.test.tsx

Lines changed: 121 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,26 @@ describe('useChartConfig', () => {
7474
expect(
7575
getGranularityAlignedTimeWindows({
7676
granularity: '1 hour',
77-
}),
77+
timestampValueExpression: 'TimestampTime',
78+
} as ChartConfigWithOptDateRange),
7879
).toEqual([undefined]);
7980
});
8081

8182
it('returns [undefined] if no granularity is provided', () => {
8283
expect(
8384
getGranularityAlignedTimeWindows({
8485
dateRange: [new Date('2023-01-01'), new Date('2023-01-02')],
85-
}),
86+
timestampValueExpression: 'TimestampTime',
87+
} as ChartConfigWithOptDateRange),
88+
).toEqual([undefined]);
89+
});
90+
91+
it('returns [undefined] if no timestampValueExpression is provided', () => {
92+
expect(
93+
getGranularityAlignedTimeWindows({
94+
dateRange: [new Date('2023-01-01'), new Date('2023-01-02')],
95+
granularity: '1 hour',
96+
} as ChartConfigWithOptDateRange),
8697
).toEqual([undefined]);
8798
});
8899

@@ -95,7 +106,8 @@ describe('useChartConfig', () => {
95106
new Date('2023-01-10 00:10:00'),
96107
],
97108
granularity: '1 minute',
98-
},
109+
timestampValueExpression: 'TimestampTime',
110+
} as ChartConfigWithOptDateRange,
99111
[
100112
30, // 30s
101113
60, // 1m
@@ -143,7 +155,8 @@ describe('useChartConfig', () => {
143155
new Date('2023-01-10 00:10:00'),
144156
],
145157
granularity: '1 minute',
146-
},
158+
timestampValueExpression: 'TimestampTime',
159+
} as ChartConfigWithOptDateRange,
147160
[
148161
15, // 15s
149162
],
@@ -175,8 +188,9 @@ describe('useChartConfig', () => {
175188
new Date('2023-01-10 00:00:30'),
176189
],
177190
granularity: '1 minute',
191+
timestampValueExpression: 'TimestampTime',
178192
dateRangeEndInclusive: true,
179-
},
193+
} as ChartConfigWithOptDateRange,
180194
[
181195
15 * 60, // 15m
182196
30 * 60, // 30m
@@ -230,7 +244,8 @@ describe('useChartConfig', () => {
230244
new Date('2023-01-10 00:02:00'),
231245
],
232246
granularity: '1 minute',
233-
},
247+
timestampValueExpression: 'TimestampTime',
248+
} as ChartConfigWithOptDateRange,
234249
[
235250
60, // 1m
236251
],
@@ -373,6 +388,104 @@ describe('useChartConfig', () => {
373388
expect(result.current.isPending).toBe(false);
374389
});
375390

391+
it('fetches data without chunking when no timestampValueExpression is provided', async () => {
392+
const config = createMockChartConfig({
393+
dateRange: [
394+
new Date('2025-10-01 00:00:00Z'),
395+
new Date('2025-10-02 00:00:00Z'),
396+
],
397+
granularity: '1 hour',
398+
timestampValueExpression: undefined,
399+
});
400+
401+
const mockResponse = createMockQueryResponse([
402+
{
403+
'count()': '71',
404+
SeverityText: 'info',
405+
__hdx_time_bucket: '2025-10-01T00:00:00Z',
406+
},
407+
{
408+
'count()': '73',
409+
SeverityText: 'info',
410+
__hdx_time_bucket: '2025-10-02T00:00:00Z',
411+
},
412+
]);
413+
414+
mockClickhouseClient.queryChartConfig.mockResolvedValue(mockResponse);
415+
416+
const { result } = renderHook(() => useQueriedChartConfig(config), {
417+
wrapper,
418+
});
419+
420+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
421+
await waitFor(() => expect(result.current.isFetching).toBe(false));
422+
423+
// Should only be called once since chunking is disabled without timestampValueExpression
424+
expect(mockClickhouseClient.queryChartConfig).toHaveBeenCalledTimes(1);
425+
expect(mockClickhouseClient.queryChartConfig).toHaveBeenCalledWith({
426+
config,
427+
metadata: expect.any(Object),
428+
opts: {
429+
abort_signal: expect.any(AbortSignal),
430+
},
431+
});
432+
expect(result.current.data).toEqual({
433+
data: mockResponse.data,
434+
meta: mockResponse.meta,
435+
rows: mockResponse.rows,
436+
});
437+
});
438+
439+
it('fetches data without chunking when disableQueryChunking is true', async () => {
440+
const config = createMockChartConfig({
441+
dateRange: [
442+
new Date('2025-10-01 00:00:00Z'),
443+
new Date('2025-10-02 00:00:00Z'),
444+
],
445+
granularity: '1 hour',
446+
});
447+
448+
const mockResponse = createMockQueryResponse([
449+
{
450+
'count()': '71',
451+
SeverityText: 'info',
452+
__hdx_time_bucket: '2025-10-01T00:00:00Z',
453+
},
454+
{
455+
'count()': '73',
456+
SeverityText: 'info',
457+
__hdx_time_bucket: '2025-10-02T00:00:00Z',
458+
},
459+
]);
460+
461+
mockClickhouseClient.queryChartConfig.mockResolvedValue(mockResponse);
462+
463+
const { result } = renderHook(
464+
() => useQueriedChartConfig(config, { disableQueryChunking: true }),
465+
{
466+
wrapper,
467+
},
468+
);
469+
470+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
471+
await waitFor(() => expect(result.current.isFetching).toBe(false));
472+
473+
// Should only be called once since chunking is explicitly disabled
474+
expect(mockClickhouseClient.queryChartConfig).toHaveBeenCalledTimes(1);
475+
expect(mockClickhouseClient.queryChartConfig).toHaveBeenCalledWith({
476+
config,
477+
metadata: expect.any(Object),
478+
opts: {
479+
abort_signal: expect.any(AbortSignal),
480+
},
481+
});
482+
expect(result.current.data).toEqual({
483+
data: mockResponse.data,
484+
meta: mockResponse.meta,
485+
rows: mockResponse.rows,
486+
});
487+
});
488+
376489
it('fetches data with chunking when granularity and date range are provided', async () => {
377490
const config = createMockChartConfig({
378491
dateRange: [
@@ -521,6 +634,8 @@ describe('useChartConfig', () => {
521634
rows: 4,
522635
});
523636
expect(result.current.isFetching).toBe(true);
637+
expect(result.current.isLoading).toBe(false); // isLoading is false because we have partial data
638+
expect(result.current.isSuccess).toBe(true); // isSuccess is true because we have partial data
524639

525640
// Resolve the final promise to simulate data arriving
526641
const mockResponse3Data = createMockQueryResponse([

packages/app/src/hooks/useChartConfig.tsx

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/browser';
88
import {
99
DEFAULT_AUTO_GRANULARITY_MAX_BUCKETS,
10+
isUsingGranularity,
1011
renderChartConfig,
1112
} from '@hyperdx/common-utils/dist/renderChartConfig';
1213
import { format } from '@hyperdx/common-utils/dist/sqlFormatter';
@@ -29,6 +30,13 @@ import { generateTimeWindowsDescending } from '@/utils/searchWindows';
2930

3031
interface AdditionalUseQueriedChartConfigOptions {
3132
onError?: (error: Error | ClickHouseQueryError) => void;
33+
/**
34+
* By default, queries with large date ranges are split into multiple smaller queries to
35+
* avoid overloading the ClickHouse server and running into timeouts. In some cases, such
36+
* as when data is being sampled across the entire range, this chunking is not desirable
37+
* and can be disabled.
38+
*/
39+
disableQueryChunking?: boolean;
3240
}
3341

3442
type TimeWindow = {
@@ -39,15 +47,14 @@ type TimeWindow = {
3947
type TQueryFnData = Pick<ResponseJSON<any>, 'data' | 'meta' | 'rows'> & {};
4048

4149
export const getGranularityAlignedTimeWindows = (
42-
config: Pick<
43-
ChartConfigWithOptDateRange,
44-
'dateRange' | 'granularity' | 'dateRangeEndInclusive'
45-
>,
50+
config: ChartConfigWithOptDateRange,
4651
windowDurationsSeconds?: number[],
4752
): TimeWindow[] | [undefined] => {
48-
// Granularity is required for pagination, otherwise we could break other group-bys.
49-
// Date range is required for pagination, otherwise we'd have infinite pages, or some unbounded page(s).
50-
if (!config.dateRange || !config.granularity) return [undefined];
53+
// Granularity is required for chunking, otherwise we could break other group-bys.
54+
if (!isUsingGranularity(config)) return [undefined];
55+
56+
// Date range is required for chunking, otherwise we'd have infinite chunks, or some unbounded chunk(s).
57+
if (!config.dateRange) return [undefined];
5158

5259
const [startDate, endDate] = config.dateRange;
5360
const windowsUnaligned = generateTimeWindowsDescending(
@@ -95,8 +102,11 @@ async function* fetchDataInChunks(
95102
config: ChartConfigWithOptDateRange,
96103
clickhouseClient: ClickhouseClient,
97104
signal: AbortSignal,
105+
disableQueryChunking: boolean = false,
98106
) {
99-
const windows = getGranularityAlignedTimeWindows(config);
107+
const windows = disableQueryChunking
108+
? ([undefined] as const)
109+
: getGranularityAlignedTimeWindows(config);
100110

101111
let query = null;
102112
if (IS_MTVIEWS_ENABLED) {
@@ -128,6 +138,20 @@ async function* fetchDataInChunks(
128138
}
129139
}
130140

141+
/**
142+
* A hook providing data queried based on the provided chart config.
143+
*
144+
* If all of the following are true, the query will be chunked into multiple smaller queries:
145+
* - The config includes a date range
146+
* - The config includes a granularity
147+
* - `options.disableQueryChunking` is falsy
148+
*
149+
* For chunked queries, note the following:
150+
* - The config's limit, if provided, is applied to each chunk, so the total number
151+
* of rows returned may be up to `limit * number_of_chunks`.
152+
* - The returned data will be ordered within each chunk, and chunks will
153+
* be ordered oldest-first, by the timestampValueExpression.
154+
*/
131155
export function useQueriedChartConfig(
132156
config: ChartConfigWithOptDateRange,
133157
options?: Partial<UseQueryOptions<ResponseJSON<any>>> &
@@ -139,7 +163,12 @@ export function useQueriedChartConfig(
139163
queryKey: [config],
140164
queryFn: streamedQuery({
141165
streamFn: context =>
142-
fetchDataInChunks(config, clickhouseClient, context.signal),
166+
fetchDataInChunks(
167+
config,
168+
clickhouseClient,
169+
context.signal,
170+
options?.disableQueryChunking,
171+
),
143172
/**
144173
* This mode ensures that data remains in the cache until the next full streamed result is available.
145174
* By default, the cache would be cleared before new data starts arriving, which results in the query briefly

packages/app/src/hooks/usePatterns.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,11 @@ function usePatterns({
143143

144144
const { data: sampleRows } = useQueriedChartConfig(
145145
configWithPrimaryAndPartitionKey ?? config, // `config` satisfying type, never used due to `enabled` check
146-
{ enabled: configWithPrimaryAndPartitionKey != null && enabled },
146+
{
147+
enabled: configWithPrimaryAndPartitionKey != null && enabled,
148+
// Disable chunking so that we get a uniform sample across the entire date range
149+
disableQueryChunking: true,
150+
},
147151
);
148152

149153
const { data: pyodide, isLoading: isLoadingPyodide } = usePyodide({

packages/common-utils/src/renderChartConfig.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function isUsingGroupBy(
5858
return chartConfig.groupBy != null && chartConfig.groupBy.length > 0;
5959
}
6060

61-
function isUsingGranularity(
61+
export function isUsingGranularity(
6262
chartConfig: ChartConfigWithOptDateRange,
6363
): chartConfig is Omit<
6464
Omit<Omit<ChartConfigWithDateRange, 'granularity'>, 'dateRange'>,

0 commit comments

Comments
 (0)