Skip to content
Open
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
15 changes: 15 additions & 0 deletions .changeset/map-key-index-median-parts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@hyperdx/common-utils': patch
'@hyperdx/api': patch
'@hyperdx/app': patch
---

perf: read only 20 median-sized parts for text index key and value lookups

Map key discovery and "all values" lookups on text indexes read the index
dictionary of every part in the time range, which is slow on tables with many
parts. They now read the 20 parts closest to the median size. Log and trace
sources can change the count with the new "Map key index parts" setting
(`mapKeyIndexMaxParts`), and which parts are read with "Map key index part size"
(`mapKeyIndexPartSelection`: median, smallest, or largest). "Load more" on a
single filter key still reads every part in the time range.
36 changes: 36 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5203,6 +5203,24 @@
"nullable": true,
"example": "auto"
},
"mapKeyIndexMaxParts": {
"type": "integer",
"minimum": 1,
"description": "How many data parts are read from text indexes to discover attribute keys and filter values, chosen by mapKeyIndexPartSelection. Raise it if keys or values are missing from filters; lower it if they load slowly. Defaults to 20.",
"nullable": true,
"example": 20
},
"mapKeyIndexPartSelection": {
"type": "string",
"enum": [
"median",
"smallest",
"largest"
],
"description": "Which data parts, by row count, are read from text indexes to discover attribute keys and filter values. \"median\" (the default) balances coverage and cost, \"smallest\" reads the newest unmerged parts, \"largest\" reads the most merged data.",
"nullable": true,
"example": "median"
},
"highlightedTraceAttributeExpressions": {
"type": "array",
"description": "Expressions defining trace-level attributes which are displayed in the trace view for the selected trace.",
Expand Down Expand Up @@ -5457,6 +5475,24 @@
"nullable": true,
"example": "auto"
},
"mapKeyIndexMaxParts": {
"type": "integer",
"minimum": 1,
"description": "How many data parts are read from text indexes to discover attribute keys and filter values, chosen by mapKeyIndexPartSelection. Raise it if keys or values are missing from filters; lower it if they load slowly. Defaults to 20.",
"nullable": true,
"example": 20
},
"mapKeyIndexPartSelection": {
"type": "string",
"enum": [
"median",
"smallest",
"largest"
],
"description": "Which data parts, by row count, are read from text indexes to discover attribute keys and filter values. \"median\" (the default) balances coverage and cost, \"smallest\" reads the newest unmerged parts, \"largest\" reads the most merged data.",
"nullable": true,
"example": "median"
},
"highlightedTraceAttributeExpressions": {
"type": "array",
"description": "Expressions defining trace-level attributes which are displayed in the trace view for the selected trace.",
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/mcp/tools/sources/describeSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ async function describeSourceSchema(
'metadataMaterializedViews' in source
? source.metadataMaterializedViews
: undefined;
const mapKeyIndexMaxParts =
'mapKeyIndexMaxParts' in source ? source.mapKeyIndexMaxParts : undefined;
const mapKeyIndexPartSelection =
'mapKeyIndexPartSelection' in source
? source.mapKeyIndexPartSelection
: undefined;

const now = new Date();
const dateRange: [Date, Date] = [
Expand Down Expand Up @@ -306,6 +312,8 @@ async function describeSourceSchema(
maxKeys: 50,
connectionId,
metadataMVs,
mapKeyIndexMaxParts,
mapKeyIndexPartSelection,
dateRange,
timestampValueExpression,
signal,
Expand Down Expand Up @@ -352,6 +360,8 @@ async function describeSourceSchema(
maxValuesPerKey: MAX_LC_VALUES,
connectionId,
metadataMVs,
mapKeyIndexMaxParts,
mapKeyIndexPartSelection,
dateRange,
timestampValueExpression,
signal,
Expand Down Expand Up @@ -399,6 +409,8 @@ async function describeSourceSchema(
maxValuesPerKey: MAX_MAP_KEY_VALUES,
connectionId,
metadataMVs,
mapKeyIndexMaxParts,
mapKeyIndexPartSelection,
dateRange,
timestampValueExpression,
signal,
Expand Down
20 changes: 20 additions & 0 deletions packages/api/src/mcp/tools/sources/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,26 @@ const mcpSaveSourceSchema = z.object({
'Log and trace sources. Rollup tables used for fast field discovery and ' +
'value autocomplete in filters and search suggestions.',
),
mapKeyIndexMaxParts: z

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — MCP save_source restates the enum and int constraint instead of reusing the common-utils schemas

z.enum(['median','smallest','largest']) and .int().min(1) duplicate MapKeyIndexPartSelectionSchema and MapKeyIndexMaxPartsSchema in packages/common-utils/src/types.ts. Use z.nativeEnum(MapKeyIndexPartSelection) (or MapKeyIndexPartSelectionSchema), and export and reuse the max-parts constraint, so adding a selection mode can't leave MCP behind.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

.number()
.int()
.min(1)
.optional()
.describe(
'Log and trace sources. How many data parts are read from text indexes ' +
'to discover attribute keys and filter values, chosen by ' +
'mapKeyIndexPartSelection. Raise it if keys or values are missing from ' +
'filters; lower it if they load slowly. Omit for the default of 20.',
),
mapKeyIndexPartSelection: z
.enum(['median', 'smallest', 'largest'])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — MCP schema hand-copies the MapKeyIndexPartSelection enum values

The conventions require one source of truth (DRY, REQUIRED). This file already imports from @hyperdx/common-utils/dist/types, so use z.nativeEnum(MapKeyIndexPartSelection) or MapKeyIndexPartSelectionSchema in place of the literal ['median', 'smallest', 'largest']. As written, a fourth option added to the enum in types.ts would be rejected by save_source with nothing to flag it.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

.optional()
.describe(
'Log and trace sources. Which data parts, by row count, are read from ' +
'text indexes for attribute keys and filter values: "median" (used when unset) ' +
'balances coverage and cost, "smallest" reads the newest unmerged ' +
'parts, "largest" reads the most merged data.',
),
});

export type McpSaveSourceInput = z.infer<typeof mcpSaveSourceSchema>;
Expand Down
11 changes: 11 additions & 0 deletions packages/api/src/models/source.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
BaseSourceSchema,
LogSourceSchema,
MapKeyIndexPartSelection,
MetricsDataType,
MetricSourceSchema,
PromqlSourceSchema,
Expand Down Expand Up @@ -188,6 +189,11 @@ export const LogSource = Source.discriminator<ILogSource>(
type: MetadataMaterializedViewsMongoSchema,
default: undefined,
},
mapKeyIndexMaxParts: Number,
mapKeyIndexPartSelection: {
type: String,
enum: Object.values(MapKeyIndexPartSelection),
},
orderByExpression: String,
}),
);
Expand Down Expand Up @@ -239,6 +245,11 @@ export const TraceSource = Source.discriminator<ITraceSource>(
type: MetadataMaterializedViewsMongoSchema,
default: undefined,
},
mapKeyIndexMaxParts: Number,
mapKeyIndexPartSelection: {
type: String,
enum: Object.values(MapKeyIndexPartSelection),
},
orderByExpression: String,
}),
);
Expand Down
75 changes: 75 additions & 0 deletions packages/api/src/routers/api/__tests__/sources.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1592,4 +1592,79 @@ describe('sources router', () => {
expect(sources).toHaveLength(0);
});
});

describe('mapKeyIndexMaxParts field', () => {
it('POST / - persists mapKeyIndexMaxParts and PUT /:id updates it', async () => {
const { agent } = await getLoggedInAgent(server);

const created = await agent
.post('/sources')
.send({ ...MOCK_SOURCE, mapKeyIndexMaxParts: 50 })
.expect(200);
expect(created.body.mapKeyIndexMaxParts).toBe(50);

await agent
.put(`/sources/${created.body.id}`)
.send({ ...MOCK_SOURCE, id: created.body.id, mapKeyIndexMaxParts: 5 })
.expect(200);

const updated = await Source.findById(created.body.id).lean();
if (updated?.kind !== SourceKind.Log) {
throw new Error(`expected Log source, got ${updated?.kind}`);
}
expect(updated.mapKeyIndexMaxParts).toBe(5);
});

it.each([0, 2.5, -1])(
'POST / - rejects mapKeyIndexMaxParts %s',
async mapKeyIndexMaxParts => {
const { agent } = await getLoggedInAgent(server);

await agent
.post('/sources')
.send({ ...MOCK_SOURCE, mapKeyIndexMaxParts })
.expect(400);

expect(await Source.find({}).lean()).toHaveLength(0);
},
);
});

describe('mapKeyIndexPartSelection field', () => {
it('POST / - persists mapKeyIndexPartSelection and PUT /:id updates it', async () => {
const { agent } = await getLoggedInAgent(server);

const created = await agent
.post('/sources')
.send({ ...MOCK_SOURCE, mapKeyIndexPartSelection: 'largest' })
.expect(200);
expect(created.body.mapKeyIndexPartSelection).toBe('largest');

await agent
.put(`/sources/${created.body.id}`)
.send({
...MOCK_SOURCE,
id: created.body.id,
mapKeyIndexPartSelection: 'smallest',
})
.expect(200);

const updated = await Source.findById(created.body.id).lean();
if (updated?.kind !== SourceKind.Log) {
throw new Error(`expected Log source, got ${updated?.kind}`);
}
expect(updated.mapKeyIndexPartSelection).toBe('smallest');
});

it('POST / - rejects an unknown mapKeyIndexPartSelection', async () => {
const { agent } = await getLoggedInAgent(server);

await agent
.post('/sources')
.send({ ...MOCK_SOURCE, mapKeyIndexPartSelection: 'random' })
.expect(400);

expect(await Source.find({}).lean()).toHaveLength(0);
});
});
});
24 changes: 24 additions & 0 deletions packages/api/src/routers/external-api/v2/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,18 @@ function formatExternalSource(source: SourceDocument) {
* description: Controls whether lucene rendering uses ClickHouse text indices via hasAllTokens() against the implicit column. "auto" detects a covering index at query time, "enabled" forces text index usage, "disabled" forces a LIKE/hasToken fallback.
* nullable: true
* example: auto
* mapKeyIndexMaxParts:
* type: integer
* minimum: 1
* description: How many data parts are read from text indexes to discover attribute keys and filter values, chosen by mapKeyIndexPartSelection. Raise it if keys or values are missing from filters; lower it if they load slowly. Defaults to 20.
* nullable: true
* example: 20
* mapKeyIndexPartSelection:
* type: string
* enum: [median, smallest, largest]
* description: Which data parts, by row count, are read from text indexes to discover attribute keys and filter values. "median" (the default) balances coverage and cost, "smallest" reads the newest unmerged parts, "largest" reads the most merged data.
* nullable: true
* example: median
* highlightedTraceAttributeExpressions:
* type: array
* description: Expressions defining trace-level attributes which are displayed in the trace view for the selected trace.
Expand Down Expand Up @@ -613,6 +625,18 @@ function formatExternalSource(source: SourceDocument) {
* description: Controls whether lucene rendering uses ClickHouse text indices via hasAllTokens() against the implicit column. "auto" detects a covering index at query time, "enabled" forces text index usage, "disabled" forces a LIKE/hasToken fallback.
* nullable: true
* example: auto
* mapKeyIndexMaxParts:
* type: integer
* minimum: 1
* description: How many data parts are read from text indexes to discover attribute keys and filter values, chosen by mapKeyIndexPartSelection. Raise it if keys or values are missing from filters; lower it if they load slowly. Defaults to 20.
* nullable: true
* example: 20
* mapKeyIndexPartSelection:
* type: string
* enum: [median, smallest, largest]
* description: Which data parts, by row count, are read from text indexes to discover attribute keys and filter values. "median" (the default) balances coverage and cost, "smallest" reads the newest unmerged parts, "largest" reads the most merged data.
* nullable: true
* example: median
* highlightedTraceAttributeExpressions:
* type: array
* description: Expressions defining trace-level attributes which are displayed in the trace view for the selected trace.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,9 @@ describe('useFetchFacets', () => {
});

expect(getAllKeyValues).toHaveBeenCalledTimes(1);
expect(getAllKeyValues).toHaveBeenCalledWith(
expect.objectContaining({ mapKeyIndexMaxParts: null }),
);
expect(getKeyValuesWithMVs).not.toHaveBeenCalled();
expect(result.current.data.keyValues).toEqual([
{ key: 'ServiceName', value: ['api', 'web'] },
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/components/DBSearchPageFilters/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ function useFacets({
tableName: tableConnection.tableName,
connectionId: tableConnection.connectionId,
metadataMVs: tableConnection.metadataMVs,
// "Load more" wants every value in the time range, not a sample of parts.
mapKeyIndexMaxParts: null,
keyExpressions: [sqlKey],
maxValuesPerKey: SINGLE_KEY_VALUES_LIMIT,
dateRange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import {
import { ExpressionFormRow } from './ExpressionFormRow';
import { FormRow } from './FormRow';
import { HighlightedAttributeExpressionsFormRow } from './HighlightedAttributes';
import {
MapKeyIndexMaxPartsFormRow,
MapKeyIndexPartSelectionFormRow,
} from './MapKeyIndexMaxPartsFormRow';
import {
MaterializedViewsFormSection,
MetadataMaterializedViewsFormSection,
Expand Down Expand Up @@ -285,6 +289,8 @@ export function LogTableModelForm(props: TableModelProps) {
/>
</FormRow>
<UseTextIndexFormRow control={control} />
<MapKeyIndexMaxPartsFormRow control={control} />
<MapKeyIndexPartSelectionFormRow control={control} />
<Divider />
<HighlightedAttributeExpressionsFormRow
{...props}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Control, Controller } from 'react-hook-form';
import { DEFAULT_MAP_KEY_INDEX_MAX_PARTS } from '@hyperdx/common-utils/dist/core/metadata';
import { TSource } from '@hyperdx/common-utils/dist/types';
import { NumberInput } from '@mantine/core';

import SelectControlled from '@/components/SelectControlled';

import { MAP_KEY_INDEX_PART_SELECTION_OPTIONS } from './constants';
import { FormRow } from './FormRow';

export function MapKeyIndexMaxPartsFormRow({
control,
}: {
control: Control<TSource>;
}) {
return (
<FormRow
label="Map key index parts"
helpText={`How many data parts are read from text indexes to discover attribute keys and filter values; "Map key index part size" picks which ones. Raise it if keys or values are missing from filters; lower it if they load slowly. Defaults to ${DEFAULT_MAP_KEY_INDEX_MAX_PARTS}.`}
>
<Controller
control={control}
name="mapKeyIndexMaxParts"
render={({ field, fieldState }) => (
<NumberInput
{...field}
value={field.value ?? ''}
onChange={v => field.onChange(v === '' ? undefined : v)}
min={1}
allowDecimal={false}
allowNegative={false}
placeholder={String(DEFAULT_MAP_KEY_INDEX_MAX_PARTS)}
error={fieldState.error?.message}
/>
)}
/>
</FormRow>
);
}

export function MapKeyIndexPartSelectionFormRow({
control,
}: {
control: Control<TSource>;
}) {
return (
<FormRow
label="Map key index part size"
helpText='Which data parts, by row count, are read from text indexes for attribute keys and filter values. "Median" balances coverage and cost; "Smallest" reads the newest unmerged parts, which is cheap and shows new keys first; "Largest" reads the most merged data, which covers the most keys but reads the biggest indexes.'
>
<SelectControlled
control={control}
name="mapKeyIndexPartSelection"
data={MAP_KEY_INDEX_PART_SELECTION_OPTIONS}
placeholder={MAP_KEY_INDEX_PART_SELECTION_OPTIONS[0].label}
allowDeselect={false}
/>
</FormRow>
);
}
Loading
Loading