Skip to content

Commit ff86d40

Browse files
authored
perf: Implement query chunking for charts (#1233)
# Summary Closes HDX-2310 Closes HDX-2616 This PR implements chunking of chart queries to improve performance of charts on large data sets and long time ranges. Recent data is loaded first, then older data is loaded one-chunk-at-a-time until the full chart date range has been queried. https://github.com/user-attachments/assets/83333041-9e41-438a-9763-d6f6c32a0576 ## Performance Impacts ### Expectations This change is intended to improve performance in a few ways: 1. Queries over long time ranges are now much less likely to time out, since the range is chunked into several smaller queries 2. Average memory usage should decrease, since the total result size and number of rows being read are smaller 3. _Perceived_ latency of queries over long date ranges is likely to decrease, because users will start seeing charts render (more recent) data as soon as the first chunk is queried, instead of after the entire date range has been queried. **However**, _total_ latency to display results for the entire date range is likely to increase, due to additional round-trip network latency being added for each additional chunk. ### Measured Results Overall, the results match the expectations outlined above. - Total latency changed between ~-4% and ~25% - Average memory usage decreased by between 18% and 80% <details> <summary>Scenarios and data</summary> In each of the following tests: 1. Queries were run 5 times before starting to measure, to ensure data is filesystem cached. 2. Queries were then run 3 times. The results shown are the median result from the 3 runs. #### Scenario: Log Search Histogram in Staging V2, 2 Day Range, No Filter | | Total Latency | Memory Usage (Avg) | Memory Usage (Max) | Chunk Count | |---|---|---|---|---| | Original | 5.36 | 409.23 MiB | 409.23 MiB | 1 | | Chunked | 5.14 | 83.06 MiB | 232.69 MiB | 4 | #### Scenario: Log Search Histogram in Staging V2, 14 Day Range, No Filter | | Total Latency | Memory Usage (Avg) | Memory Usage (Max) | Chunk Count | |---|---|---|---|---| | Original | 26.56 | 383.63 MiB | 383.63 MiB | 1 | | Chunked | 33.08 | 130.00 MiB | 241.21 MiB | 16 | #### Scenario: Chart Explorer Line Chart with p90 and p99 trace durations, Staging V2 Traces, Filtering for "GET" spans, 7 Day range | | Total Latency | Memory Usage (Avg) | Memory Usage (Max) | Chunk Count | |---|---|---|---|---| | Original | 2.79 | 346.12 MiB | 346.12 MiB | 1 | | Chunked | 3.26 | 283.00 MiB | 401.38 MiB | 9 | </details> ## Implementation Notes <details> <summary>When is chunking used?</summary> Chunking is used when all of the following are true: 1. `granularity` and `timestampValueExpression` are defined in the config. This ensures that the query is already being bucketed. Without bucketing, chunking would break aggregation queries, since groups can span multiple chunks. 4. `dateRange` is defined in the config. Without a date range, we'd need an unbounded set of chunks or the start and end chunks would have to be unbounded at their start and end, respectively. 5. The config is not a metrics query. Metrics queries have complex logic which we want to avoid breaking with the initial delivery of this feature. 6. The consumer of `useQueriedChartConfig` does not pass the `disableQueryChunking: true` option. This option is provided to disable chunking when necessary. </details> <details> <summary>How are time windows chosen?</summary> 1. First, generate the windows as they are generated for the existing search chunking feature (eg. 6 hours back, 6 hours back, 12 hours back, 24 hours back...) 4. Then, the start and end of each window is aligned to the start of a time bucket that depends on the "granularity" of the chart. 7. The first and last windows are shortened or extended so that the combined date range of all of the windows matches the start and end of the original config. </details> <details> <summary>Which order are the chunks queried in?</summary> Chunks are queried sequentially, most-recent first, due to the expectation that more recent data is typically more important to the user. Unlike with `useOffsetPaginatedSearch`, we are not paginating the data beyond the chunks, and all data is typically displayed together, so there is no need to support "ascending" order. </details> <details> <summary>Does this improve client-side caching behavior?</summary> One theoretical way in which query chunking could improve performance to enable client-side caching of individual chunks, which could then be re-used if the same query is run over a longer time range. Unfortunately, using streamedQuery, react-query stores the entire time range as one item in the cache, so it does not re-use individual chunks or "pages" from another query. We could accomplish this improvement by using useQueries instead of streamedQuery or useInfiniteQuery. In that case, we'd treat each chunk as its own query. This would require a number of changes: 1. Our query key would have to include the chunk's window duration 2. We'd need some hacky way of making the useQueries requests fire in sequence. This can be done using `enabled` but requires some additional state to figure out whether the previous query is done. 5. We'd need to emulate the return value of a useQuery using the useQueries result, or update consumers. </details>
1 parent 21614b9 commit ff86d40

File tree

11 files changed

+1548
-122
lines changed

11 files changed

+1548
-122
lines changed

.changeset/soft-donkeys-fetch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
feat: Implement query chunking for charts

packages/app/src/components/DBTimeChart.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ function DBTimeChartComponent({
6565
const { data, isLoading, isError, error, isPlaceholderData, isSuccess } =
6666
useQueriedChartConfig(queriedConfig, {
6767
placeholderData: (prev: any) => prev,
68-
queryKey: [queryKeyPrefix, queriedConfig],
68+
queryKey: [queryKeyPrefix, queriedConfig, 'chunked'],
6969
enabled,
70+
enableQueryChunking: true,
7071
});
7172

7273
useEffect(() => {
@@ -75,7 +76,8 @@ function DBTimeChartComponent({
7576
}
7677
}, [isError, isErrorExpanded, errorExpansion]);
7778

78-
const isLoadingOrPlaceholder = isLoading || isPlaceholderData;
79+
const isLoadingOrPlaceholder =
80+
isLoading || !data?.isComplete || isPlaceholderData;
7981
const { data: source } = useSource({ id: sourceId });
8082

8183
const { graphResults, timestampColumn, groupKeys, lineNames, lineColors } =

packages/app/src/components/PatternTable.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ export default function PatternTable({
2929

3030
const [selectedPattern, setSelectedPattern] = useState<Pattern | null>(null);
3131

32-
const { totalCount, isLoading: isTotalCountLoading } = useSearchTotalCount(
33-
totalCountConfig,
34-
totalCountQueryKeyPrefix,
35-
);
32+
const {
33+
totalCount,
34+
isLoading: isTotalCountLoading,
35+
isTotalCountComplete,
36+
} = useSearchTotalCount(totalCountConfig, totalCountQueryKeyPrefix);
3637

3738
const {
3839
data: groupedResults,
@@ -46,7 +47,8 @@ export default function PatternTable({
4647
totalCount,
4748
});
4849

49-
const isLoading = isTotalCountLoading || isGroupedPatternsLoading;
50+
const isLoading =
51+
isTotalCountLoading || !isTotalCountComplete || isGroupedPatternsLoading;
5052

5153
const sortedGroupedResults = useMemo(() => {
5254
return Object.values(groupedResults).sort(

packages/app/src/components/SearchTotalCountChart.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function useSearchTotalCount(
1010
config: ChartConfigWithDateRange,
1111
queryKeyPrefix: string,
1212
) {
13-
// copied from DBTimeChart
13+
// queriedConfig, queryKey, and enableQueryChunking match DBTimeChart so that react query can de-dupe these queries.
1414
const { granularity } = useTimeChartSettings(config);
1515
const queriedConfig = {
1616
...config,
@@ -22,12 +22,15 @@ export function useSearchTotalCount(
2222
isLoading,
2323
isError,
2424
} = useQueriedChartConfig(queriedConfig, {
25-
queryKey: [queryKeyPrefix, queriedConfig],
25+
queryKey: [queryKeyPrefix, queriedConfig, 'chunked'],
2626
staleTime: 1000 * 60 * 5,
2727
refetchOnWindowFocus: false,
2828
placeholderData: keepPreviousData, // no need to flash loading state when in live tail
29+
enableQueryChunking: true,
2930
});
3031

32+
const isTotalCountComplete = !!totalCountData?.isComplete;
33+
3134
const totalCount = useMemo(() => {
3235
return totalCountData?.data?.reduce(
3336
(p: number, v: any) => p + Number.parseInt(v['count()']),
@@ -39,6 +42,7 @@ export function useSearchTotalCount(
3942
totalCount,
4043
isLoading,
4144
isError,
45+
isTotalCountComplete,
4246
};
4347
}
4448

0 commit comments

Comments
 (0)