diff --git a/.changeset/row-side-panel-single-fetch.md b/.changeset/row-side-panel-single-fetch.md new file mode 100644 index 0000000000..581ad0c286 --- /dev/null +++ b/.changeset/row-side-panel-single-fetch.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/app': patch +--- + +fix: run the row details query once when opening a row + +The side panel ran the same `SELECT *` row query twice: once for the panel, and +again when its Overview or Column Values tab mounted. A row that was found is +now reused from the cache, so the tab and later tab switches do not query it +again. A different row or source still runs the query, and a row that was not +found is looked up again. diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 35053b2b2f..5a3803b327 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -9,6 +9,7 @@ import { TSource, } from '@hyperdx/common-utils/dist/types'; import { Box } from '@mantine/core'; +import type { Query } from '@tanstack/react-query'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { @@ -23,6 +24,7 @@ import { getDurationMsExpression, getEventBody, } from '@/source'; +import type { ChartQueryResult } from '@/types'; import { getSelectExpressionsForHighlightedAttributes } from '@/utils/highlightedAttributes'; import { getTimestampValueSelects } from '@/utils/rowTimestamps'; @@ -48,6 +50,14 @@ export enum ROW_DATA_ALIASES { EVENT_NAME = '__hdx_event_name', } +// The side panel mounts its tabs once its own lookup settles, and each tab +// looks the same row up again. Those lookups reuse a found row instead of +// running `SELECT *` again. A miss stays stale so the next lookup retries it: +// the row may not have been ingested yet. +function rowLookupStaleTime(query: Query) { + return query.state.data?.data.length ? Infinity : 0; +} + export function useRowData({ source, rowId, @@ -289,6 +299,7 @@ export function useRowData({ queryKey: [...baseQueryKey, dateRange], enabled: rowId != null && hasWindow && !isEventNameColumnPending, additionalQuerySettings, + staleTime: rowLookupStaleTime, }, ); @@ -310,6 +321,7 @@ export function useRowData({ queryKey: [...baseQueryKey, undefined], enabled: rowId != null && isFallbackActive && !isEventNameColumnPending, additionalQuerySettings, + staleTime: rowLookupStaleTime, }); const queryResult = isFallbackActive ? fallbackResult : boundedResult; diff --git a/packages/app/src/components/__tests__/DBRowDataPanel.rowQuery.test.tsx b/packages/app/src/components/__tests__/DBRowDataPanel.rowQuery.test.tsx new file mode 100644 index 0000000000..cff83b9b58 --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowDataPanel.rowQuery.test.tsx @@ -0,0 +1,166 @@ +import type { ResponseJSON } from '@hyperdx/common-utils/dist/clickhouse'; +import { SourceKind, TLogSource } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; + +import { RowDataPanel, useRowData } from '@/components/DBRowDataPanel'; + +const mockQueryChartConfig = jest.fn(); +jest.mock('@/clickhouse', () => ({ + useClickhouseClient: () => ({ queryChartConfig: mockQueryChartConfig }), +})); +jest.mock('@/hooks/useMetadata', () => ({ + useColumns: () => ({ data: undefined, isLoading: false }), + useMetadataWithSettings: () => ({}), +})); +jest.mock('@/hooks/useMVOptimizationExplanation', () => ({ + useMVOptimizationExplanation: () => ({ data: undefined, isLoading: false }), +})); +jest.mock('@/source', () => ({ + ...jest.requireActual('@/source'), + useSource: () => ({ data: undefined, isLoading: false }), +})); +// Records the row the tab renders. +const mockJsonViewerData = jest.fn(); +jest.mock('@/components/DBRowJsonViewer', () => ({ + DBRowJsonViewer: ({ data }: { data: unknown }) => { + mockJsonViewerData(data); + return null; + }, +})); + +const source: TLogSource = { + id: 'source-id', + kind: SourceKind.Log, + name: 'logs', + connection: 'conn-id', + from: { databaseName: 'default', tableName: 'logs' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, Body', + bodyExpression: 'Body', +}; + +const DATE_RANGE: [Date, Date] = [ + new Date('2024-01-01T00:00:00Z'), + new Date('2024-01-01T02:00:00Z'), +]; + +const found: ResponseJSON> = { + data: [{ Body: 'hello' }], + meta: [{ name: 'Body', type: 'String' }], + rows: 1, +}; + +type PanelProps = { + source: TLogSource; + rowId: string; + dateRange?: [Date, Date]; +}; + +// Mirrors DBRowSidePanel: the panel shows "Loading..." until its own lookup of +// the row settles, then mounts the tab, which looks up the same row again. +function SidePanel(props: PanelProps) { + const { isLoading } = useRowData(props); + if (isLoading) { + return
Loading...
; + } + return ; +} + +describe('row lookup', () => { + // The app's QueryClient uses React Query's defaults (pages/_app.tsx). + let queryClient: QueryClient; + + function renderPanel(props: PanelProps) { + const tree = (panelProps: PanelProps) => ( + + + + + + ); + const result = render(tree(props)); + return { + rerender: (next: PanelProps) => result.rerender(tree(next)), + unmount: result.unmount, + }; + } + + async function settled() { + await screen.findByTestId('tab'); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + } + + beforeEach(() => { + queryClient = new QueryClient(); + mockQueryChartConfig.mockReset().mockResolvedValue(found); + mockJsonViewerData.mockReset(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it.each<{ lookup: string; dateRange?: [Date, Date] }>([ + { lookup: 'unbounded' }, + { lookup: 'bounded', dateRange: DATE_RANGE }, + ])( + 'runs the $lookup lookup once for the panel and its tab', + async ({ dateRange }) => { + renderPanel({ source, rowId: "id='a'", dateRange }); + await settled(); + + expect(mockQueryChartConfig).toHaveBeenCalledTimes(1); + expect(mockJsonViewerData).toHaveBeenLastCalledWith({ Body: 'hello' }); + }, + ); + + it('reuses a found row when the panel opens it again', async () => { + const first = renderPanel({ source, rowId: "id='a'" }); + await settled(); + first.unmount(); + + renderPanel({ source, rowId: "id='a'" }); + await settled(); + + expect(mockQueryChartConfig).toHaveBeenCalledTimes(1); + expect(mockJsonViewerData).toHaveBeenLastCalledWith({ Body: 'hello' }); + }); + + it('runs again for another row', async () => { + const { rerender } = renderPanel({ source, rowId: "id='a'" }); + await settled(); + rerender({ source, rowId: "id='b'" }); + await settled(); + + expect( + mockQueryChartConfig.mock.calls.map(([{ config }]) => config.where), + ).toEqual(["id='a'", "id='b'"]); + }); + + it('runs again for another source', async () => { + const { rerender } = renderPanel({ source, rowId: "id='a'" }); + await settled(); + rerender({ + source: { ...source, from: { ...source.from, tableName: 'logs_v2' } }, + rowId: "id='a'", + }); + await settled(); + + expect( + mockQueryChartConfig.mock.calls.map( + ([{ config }]) => config.from.tableName, + ), + ).toEqual(['logs', 'logs_v2']); + }); + + it('runs again for a row it did not find', async () => { + mockQueryChartConfig.mockResolvedValue({ data: [], meta: [], rows: 0 }); + + renderPanel({ source, rowId: "id='a'" }); + await settled(); + + expect(mockQueryChartConfig).toHaveBeenCalledTimes(2); + }); +});