Skip to content

Commit b6cb6ba

Browse files
committed
perf: read only median-sized parts in text index value lookups
1 parent 2556d74 commit b6cb6ba

11 files changed

Lines changed: 154 additions & 45 deletions

File tree

‎.changeset/text-index-values-no-row-cap.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
---
22
'@hyperdx/common-utils': patch
3+
'@hyperdx/api': patch
4+
'@hyperdx/app': patch
35
---
46

57
fix: show values for every key in text index value lookups
@@ -9,3 +11,9 @@ indexes on plain columns) stopped at `max_rows_to_read` with
911
`read_overflow_mode = 'break'`. The index is read in sorted token order, so once
1012
a high-cardinality key used up the budget, every key that sorts after it came
1113
back with no values and no error. These lookups no longer apply a row cap.
14+
15+
To keep them bounded, they now read a limited set of parts like Map key
16+
discovery: the 20 parts closest to the median size by default, or as set by the
17+
source's "Map key index parts" (`mapKeyIndexMaxParts`) and "Map key index part
18+
size" (`mapKeyIndexPartSelection`) settings, which now cover values as well as
19+
keys.

‎packages/api/openapi.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5206,7 +5206,7 @@
52065206
"mapKeyIndexMaxParts": {
52075207
"type": "integer",
52085208
"minimum": 1,
5209-
"description": "How many data parts Map attribute key discovery reads from a text index, chosen by mapKeyIndexPartSelection. Raise it if attribute keys are missing from filters; lower it if field loading is slow. Defaults to 20.",
5209+
"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.",
52105210
"nullable": true,
52115211
"example": 20
52125212
},
@@ -5217,7 +5217,7 @@
52175217
"smallest",
52185218
"largest"
52195219
],
5220-
"description": "Which data parts, by row count, Map attribute key discovery reads from a text index. \"median\" (the default) balances coverage and cost, \"smallest\" reads the newest unmerged parts, \"largest\" reads the most merged data.",
5220+
"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.",
52215221
"nullable": true,
52225222
"example": "median"
52235223
},
@@ -5478,7 +5478,7 @@
54785478
"mapKeyIndexMaxParts": {
54795479
"type": "integer",
54805480
"minimum": 1,
5481-
"description": "How many data parts Map attribute key discovery reads from a text index, chosen by mapKeyIndexPartSelection. Raise it if attribute keys are missing from filters; lower it if field loading is slow. Defaults to 20.",
5481+
"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.",
54825482
"nullable": true,
54835483
"example": 20
54845484
},
@@ -5489,7 +5489,7 @@
54895489
"smallest",
54905490
"largest"
54915491
],
5492-
"description": "Which data parts, by row count, Map attribute key discovery reads from a text index. \"median\" (the default) balances coverage and cost, \"smallest\" reads the newest unmerged parts, \"largest\" reads the most merged data.",
5492+
"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.",
54935493
"nullable": true,
54945494
"example": "median"
54955495
},

‎packages/api/src/mcp/tools/sources/describeSource.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,12 @@ async function describeSourceSchema(
228228
'metadataMaterializedViews' in source
229229
? source.metadataMaterializedViews
230230
: undefined;
231+
const mapKeyIndexMaxParts =
232+
'mapKeyIndexMaxParts' in source ? source.mapKeyIndexMaxParts : undefined;
233+
const mapKeyIndexPartSelection =
234+
'mapKeyIndexPartSelection' in source
235+
? source.mapKeyIndexPartSelection
236+
: undefined;
231237

232238
const now = new Date();
233239
const dateRange: [Date, Date] = [
@@ -306,14 +312,8 @@ async function describeSourceSchema(
306312
maxKeys: 50,
307313
connectionId,
308314
metadataMVs,
309-
mapKeyIndexMaxParts:
310-
'mapKeyIndexMaxParts' in source
311-
? source.mapKeyIndexMaxParts
312-
: undefined,
313-
mapKeyIndexPartSelection:
314-
'mapKeyIndexPartSelection' in source
315-
? source.mapKeyIndexPartSelection
316-
: undefined,
315+
mapKeyIndexMaxParts,
316+
mapKeyIndexPartSelection,
317317
dateRange,
318318
timestampValueExpression,
319319
signal,
@@ -360,6 +360,8 @@ async function describeSourceSchema(
360360
maxValuesPerKey: MAX_LC_VALUES,
361361
connectionId,
362362
metadataMVs,
363+
mapKeyIndexMaxParts,
364+
mapKeyIndexPartSelection,
363365
dateRange,
364366
timestampValueExpression,
365367
signal,
@@ -407,6 +409,8 @@ async function describeSourceSchema(
407409
maxValuesPerKey: MAX_MAP_KEY_VALUES,
408410
connectionId,
409411
metadataMVs,
412+
mapKeyIndexMaxParts,
413+
mapKeyIndexPartSelection,
410414
dateRange,
411415
timestampValueExpression,
412416
signal,

‎packages/api/src/mcp/tools/sources/schemas.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -454,17 +454,17 @@ const mcpSaveSourceSchema = z.object({
454454
.min(1)
455455
.optional()
456456
.describe(
457-
'Log and trace sources. How many data parts Map attribute key discovery ' +
458-
'reads from a text index, chosen by mapKeyIndexPartSelection. Raise it ' +
459-
'if attribute keys are missing from filters; lower it if field loading ' +
460-
'is slow. Omit for the default of 20.',
457+
'Log and trace sources. How many data parts are read from text indexes ' +
458+
'to discover attribute keys and filter values, chosen by ' +
459+
'mapKeyIndexPartSelection. Raise it if keys or values are missing from ' +
460+
'filters; lower it if they load slowly. Omit for the default of 20.',
461461
),
462462
mapKeyIndexPartSelection: z
463463
.enum(['median', 'smallest', 'largest'])
464464
.optional()
465465
.describe(
466-
'Log and trace sources. Which data parts, by row count, Map attribute ' +
467-
'key discovery reads from a text index: "median" (used when unset) ' +
466+
'Log and trace sources. Which data parts, by row count, are read from ' +
467+
'text indexes for attribute keys and filter values: "median" (used when unset) ' +
468468
'balances coverage and cost, "smallest" reads the newest unmerged ' +
469469
'parts, "largest" reads the most merged data.',
470470
),

‎packages/api/src/routers/external-api/v2/sources.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -415,13 +415,13 @@ function formatExternalSource(source: SourceDocument) {
415415
* mapKeyIndexMaxParts:
416416
* type: integer
417417
* minimum: 1
418-
* description: How many data parts Map attribute key discovery reads from a text index, chosen by mapKeyIndexPartSelection. Raise it if attribute keys are missing from filters; lower it if field loading is slow. Defaults to 20.
418+
* 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.
419419
* nullable: true
420420
* example: 20
421421
* mapKeyIndexPartSelection:
422422
* type: string
423423
* enum: [median, smallest, largest]
424-
* description: Which data parts, by row count, Map attribute key discovery reads from a text index. "median" (the default) balances coverage and cost, "smallest" reads the newest unmerged parts, "largest" reads the most merged data.
424+
* 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.
425425
* nullable: true
426426
* example: median
427427
* highlightedTraceAttributeExpressions:
@@ -628,13 +628,13 @@ function formatExternalSource(source: SourceDocument) {
628628
* mapKeyIndexMaxParts:
629629
* type: integer
630630
* minimum: 1
631-
* description: How many data parts Map attribute key discovery reads from a text index, chosen by mapKeyIndexPartSelection. Raise it if attribute keys are missing from filters; lower it if field loading is slow. Defaults to 20.
631+
* 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.
632632
* nullable: true
633633
* example: 20
634634
* mapKeyIndexPartSelection:
635635
* type: string
636636
* enum: [median, smallest, largest]
637-
* description: Which data parts, by row count, Map attribute key discovery reads from a text index. "median" (the default) balances coverage and cost, "smallest" reads the newest unmerged parts, "largest" reads the most merged data.
637+
* 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.
638638
* nullable: true
639639
* example: median
640640
* highlightedTraceAttributeExpressions:

‎packages/app/src/components/DBSearchPageFilters/hooks.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ function useFacets({
255255
tableName: tableConnection.tableName,
256256
connectionId: tableConnection.connectionId,
257257
metadataMVs: tableConnection.metadataMVs,
258+
mapKeyIndexMaxParts: tableConnection.mapKeyIndexMaxParts,
259+
mapKeyIndexPartSelection: tableConnection.mapKeyIndexPartSelection,
258260
keyExpressions: [sqlKey],
259261
maxValuesPerKey: SINGLE_KEY_VALUES_LIMIT,
260262
dateRange,

‎packages/app/src/components/Sources/SourceForm/MapKeyIndexMaxPartsFormRow.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function MapKeyIndexMaxPartsFormRow({
1616
return (
1717
<FormRow
1818
label="Map key index parts"
19-
helpText={`How many data parts are read from a Map key text index to discover attribute keys; "Map key index part size" picks which ones. Raise it if keys are missing from filters; lower it if fields load slowly. Defaults to ${DEFAULT_MAP_KEY_INDEX_MAX_PARTS}.`}
19+
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}.`}
2020
>
2121
<Controller
2222
control={control}
@@ -46,7 +46,7 @@ export function MapKeyIndexPartSelectionFormRow({
4646
return (
4747
<FormRow
4848
label="Map key index part size"
49-
helpText='Which data parts, by row count, are read from a Map key text index. "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.'
49+
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.'
5050
>
5151
<SelectControlled
5252
control={control}

‎packages/app/src/hooks/useMetadata.tsx‎

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -577,12 +577,22 @@ export function useMultipleGetKeyValues(
577577
const source = chartConfig.source
578578
? sources?.find(s => s.id === chartConfig.source)
579579
: undefined;
580+
const isLogOrTrace =
581+
!!source && (isLogSource(source) || isTraceSource(source));
580582
const metadataMVs =
581583
usableMVsOverride ??
582-
(source && (isLogSource(source) || isTraceSource(source))
583-
? source.metadataMaterializedViews
584-
: undefined);
585-
return { chartConfig, source, metadataMVs };
584+
(isLogOrTrace ? source.metadataMaterializedViews : undefined);
585+
return {
586+
chartConfig,
587+
source,
588+
metadataMVs,
589+
mapKeyIndexMaxParts: isLogOrTrace
590+
? source.mapKeyIndexMaxParts
591+
: undefined,
592+
mapKeyIndexPartSelection: isLogOrTrace
593+
? source.mapKeyIndexPartSelection
594+
: undefined,
595+
};
586596
});
587597

588598
if (mode === 'exact') {
@@ -606,18 +616,26 @@ export function useMultipleGetKeyValues(
606616
}
607617

608618
// mode === 'all'
609-
const streams = lookups.map(({ chartConfig, metadataMVs }) =>
610-
metadata.streamAllKeyValues({
611-
databaseName: chartConfig.from.databaseName,
612-
tableName: chartConfig.from.tableName,
613-
keyExpressions: limitedKeys,
614-
maxValuesPerKey: limit,
615-
connectionId: chartConfig.connection,
619+
const streams = lookups.map(
620+
({
621+
chartConfig,
616622
metadataMVs,
617-
dateRange: chartConfig.dateRange,
618-
timestampValueExpression: chartConfig.timestampValueExpression,
619-
signal,
620-
}),
623+
mapKeyIndexMaxParts,
624+
mapKeyIndexPartSelection,
625+
}) =>
626+
metadata.streamAllKeyValues({
627+
databaseName: chartConfig.from.databaseName,
628+
tableName: chartConfig.from.tableName,
629+
keyExpressions: limitedKeys,
630+
maxValuesPerKey: limit,
631+
connectionId: chartConfig.connection,
632+
metadataMVs,
633+
mapKeyIndexMaxParts,
634+
mapKeyIndexPartSelection,
635+
dateRange: chartConfig.dateRange,
636+
timestampValueExpression: chartConfig.timestampValueExpression,
637+
signal,
638+
}),
621639
);
622640
for await (const keyValues of mergeStreams(streams)) {
623641
yield keyValues.map(({ key, value }) => ({

‎packages/common-utils/src/__tests__/metadata.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,6 +1429,62 @@ describe('Metadata', () => {
14291429
},
14301430
);
14311431

1432+
it.each([
1433+
['map KV', "LogAttributes['requestId']"],
1434+
['native', 'TraceId'],
1435+
])(
1436+
'reads only the parts nearest the median size from the %s text index',
1437+
async (_, keyExpression) => {
1438+
setupDefaultLogsSchema();
1439+
1440+
await metadata.getAllKeyValues({
1441+
...baseArgs,
1442+
keyExpressions: [keyExpression],
1443+
});
1444+
await metadata.getAllKeyValues({
1445+
...baseArgs,
1446+
keyExpressions: [keyExpression],
1447+
mapKeyIndexMaxParts: 5,
1448+
});
1449+
1450+
const partsLimits = (mockClickhouseClient.query as jest.Mock).mock.calls
1451+
.filter(([opts]) => opts.query.includes('mergeTreeTextIndex('))
1452+
.map(([opts]) => {
1453+
const match = opts.query.match(
1454+
/ORDER BY abs\(rn - \(cnt \+ 1\) \/ 2\), name\s+LIMIT \{(\w+):Int32\}/,
1455+
);
1456+
expect(match).not.toBeNull();
1457+
return opts.query_params[match[1]];
1458+
});
1459+
expect(partsLimits).toEqual([20, 5]);
1460+
},
1461+
);
1462+
1463+
it.each([
1464+
['map KV', "LogAttributes['requestId']"],
1465+
['native', 'TraceId'],
1466+
])(
1467+
'orders the %s text index parts by the configured part selection',
1468+
async (_, keyExpression) => {
1469+
setupDefaultLogsSchema();
1470+
1471+
await metadata.getAllKeyValues({
1472+
...baseArgs,
1473+
keyExpressions: [keyExpression],
1474+
mapKeyIndexPartSelection: MapKeyIndexPartSelection.Largest,
1475+
});
1476+
1477+
const textIndexCall = (
1478+
mockClickhouseClient.query as jest.Mock
1479+
).mock.calls.find(([opts]) =>
1480+
opts.query.includes('mergeTreeTextIndex('),
1481+
);
1482+
expect(textIndexCall[0].query).toMatch(
1483+
/\)\s+ORDER BY rn DESC\s+LIMIT \{\w+:Int32\}/,
1484+
);
1485+
},
1486+
);
1487+
14321488
it('routes ServiceName and SeverityText through getMetadataMVKeyValues (KV rollup MV path)', async () => {
14331489
setupDefaultLogsSchema();
14341490

0 commit comments

Comments
 (0)