Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/row-side-panel-single-fetch.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/app/src/components/DBRowDataPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,6 +24,7 @@ import {
getDurationMsExpression,
getEventBody,
} from '@/source';
import type { ChartQueryResult } from '@/types';
import { getSelectExpressionsForHighlightedAttributes } from '@/utils/highlightedAttributes';
import { getTimestampValueSelects } from '@/utils/rowTimestamps';

Expand All @@ -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<ChartQueryResult>) {
return query.state.data?.data.length ? Infinity : 0;
}

export function useRowData({
source,
rowId,
Expand Down Expand Up @@ -289,6 +299,7 @@ export function useRowData({
queryKey: [...baseQueryKey, dateRange],
enabled: rowId != null && hasWindow && !isEventNameColumnPending,
additionalQuerySettings,
staleTime: rowLookupStaleTime,
},
);

Expand All @@ -310,6 +321,7 @@ export function useRowData({
queryKey: [...baseQueryKey, undefined],
enabled: rowId != null && isFallbackActive && !isEventNameColumnPending,
additionalQuerySettings,
staleTime: rowLookupStaleTime,
});

const queryResult = isFallbackActive ? fallbackResult : boundedResult;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> = {
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 <div>Loading...</div>;
}
return <RowDataPanel {...props} data-testid="tab" />;
}

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) => (
<MantineProvider>
<QueryClientProvider client={queryClient}>
<SidePanel {...panelProps} />
</QueryClientProvider>
</MantineProvider>
);
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);
});
});
Loading