From 326c14969f1676415fead1b6de9a5e00517dcda9 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:56:13 -0500 Subject: [PATCH 01/18] fix: drop the row cap from text index value lookups --- .changeset/text-index-values-no-row-cap.md | 11 ++++++++ .../src/__tests__/metadata.test.ts | 26 +++++++++++++++++++ packages/common-utils/src/core/metadata.ts | 16 +++++------- 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 .changeset/text-index-values-no-row-cap.md diff --git a/.changeset/text-index-values-no-row-cap.md b/.changeset/text-index-values-no-row-cap.md new file mode 100644 index 000000000..103ed81b0 --- /dev/null +++ b/.changeset/text-index-values-no-row-cap.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/common-utils': patch +--- + +fix: show values for every key in text index value lookups + +"All values" lookups that read a text index (Map key=value indexes and text +indexes on plain columns) stopped at `max_rows_to_read` with +`read_overflow_mode = 'break'`. The index is read in sorted token order, so once +a high-cardinality key used up the budget, every key that sorts after it came +back with no values and no error. These lookups no longer apply a row cap. diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 646957efc..4ed0cdd08 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1403,6 +1403,32 @@ describe('Metadata', () => { expect(sql).toContain('GROUP BY column, key'); }); + it.each([ + ['map KV', "LogAttributes['requestId']"], + ['native', 'TraceId'], + ])( + 'reads the %s text index without a row cap, even when one is configured', + async (_, keyExpression) => { + setupDefaultLogsSchema(); + jest + .spyOn(metadata, 'getClickHouseSettings') + .mockReturnValue({ max_rows_to_read: '1000' }); + + await metadata.getAllKeyValues({ + ...baseArgs, + keyExpressions: [keyExpression], + }); + + const calls = (mockClickhouseClient.query as jest.Mock).mock.calls; + const textIndexCall = calls.find(([opts]) => + opts.query.includes('mergeTreeTextIndex('), + ); + expect(textIndexCall[0].clickhouse_settings).toMatchObject({ + max_rows_to_read: '0', + }); + }, + ); + it.each([ ['map KV', "LogAttributes['requestId']"], ['native', 'TraceId'], diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 6409a4b7d..659584fe6 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -1813,12 +1813,11 @@ export class Metadata { query_params: sql.params, connectionId, clickhouse_settings: { - max_rows_to_read: String( - this.getClickHouseSettings().max_rows_to_read ?? - DEFAULT_METADATA_MAX_ROWS_TO_READ, - ), - read_overflow_mode: 'break', ...this.getClickHouseSettings(), + // The dictionary is read in sorted token order, so a row cap + // with read_overflow_mode=break silently leaves every key that + // sorts after the cutoff with no values. + max_rows_to_read: '0', }, abort_signal: signal, }) @@ -1896,12 +1895,9 @@ export class Metadata { query_params: sql.params, connectionId, clickhouse_settings: { - max_rows_to_read: String( - this.getClickHouseSettings().max_rows_to_read ?? - DEFAULT_METADATA_MAX_ROWS_TO_READ, - ), - read_overflow_mode: 'break', ...this.getClickHouseSettings(), + // See getMapTextIndexKeyValues. + max_rows_to_read: '0', }, abort_signal: signal, }) From cb5ed9afa1aca57b7acd271422f9bc739dbc7a24 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:02:35 -0500 Subject: [PATCH 02/18] feat: show the most frequent text index values with topKWeighted --- .changeset/topk-text-index-values.md | 11 ++++++++ .../src/__tests__/metadata.int.test.ts | 27 +++++++++++++++++++ .../src/__tests__/metadata.test.ts | 23 +++++++--------- packages/common-utils/src/core/metadata.ts | 6 +++-- 4 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 .changeset/topk-text-index-values.md diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md new file mode 100644 index 000000000..332e05a55 --- /dev/null +++ b/.changeset/topk-text-index-values.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/common-utils': patch +--- + +feat: show the most frequent filter values read from text indexes + +"All values" lookups that read a text index (Map key=value indexes and text +indexes on plain columns) used `groupUniqArray(limit)`, which keeps the +alphabetically first tokens it reads. They now use `topKWeighted(limit)` +weighted by each token's row count, so a capped list holds the most common +values, most common first. This costs more CPU per token than before. diff --git a/packages/common-utils/src/__tests__/metadata.int.test.ts b/packages/common-utils/src/__tests__/metadata.int.test.ts index 55853a742..10e834039 100644 --- a/packages/common-utils/src/__tests__/metadata.int.test.ts +++ b/packages/common-utils/src/__tests__/metadata.int.test.ts @@ -907,6 +907,33 @@ describe('Metadata Integration Tests', () => { ); }); + it('keeps the most frequent text index values when maxValuesPerKey truncates the list', async () => { + if (!textIndexSupported) { + console.warn( + 'Skipping: ClickHouse < 26.3 does not support mergeTreeTextIndex()', + ); + return; + } + // The index dictionary is sorted, so 'a-rare' is read first. + const freqKey = `freq.${tag}`; + const timestamp = testTime.toISOString().replace('T', ' ').slice(0, 23); + await client.command({ + query: `INSERT INTO default.otel_logs (Timestamp, ResourceAttributes) + SELECT '${timestamp}', map('${freqKey}', if(number = 0, 'a-rare', 'z-common')) + FROM numbers(4)`, + }); + + const result = await metadata.getAllKeyValues({ + ...commonArgs, + keyExpressions: [`ResourceAttributes['${freqKey}']`], + maxValuesPerKey: 1, + }); + + expect(result).toEqual([ + { key: `ResourceAttributes['${freqKey}']`, value: ['z-common'] }, + ]); + }); + it('routes TraceId through getTextIndexKeyValues', async () => { if (!textIndexSupported) { console.warn( diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 4ed0cdd08..76e640b95 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1380,7 +1380,7 @@ describe('Metadata', () => { const sql = calls[calls.length - 1][0].query as string; expect(sql).toContain('mergeTreeTextIndex('); - expect(sql).toContain('groupUniqArray('); + expect(sql).toContain('topKWeighted('); expect(sql).toContain('GROUP BY key'); expect(sql).not.toContain('startsWith(token,'); }); @@ -1831,9 +1831,8 @@ describe('Metadata', () => { // via text index or raw scan. describe('maxValuesPerKey threading', () => { // Pick a value that (a) isn't ambient in the test setup (like 20, - // dates, or granularity numbers) and (b) is easy to spot in a param - // dump when a test fails. Int32 chSql params render as numbers, so - // assertions compare against the numeric literal. + // dates, or granularity numbers) and (b) is easy to spot in the SQL or a + // param dump when a test fails. const MAX_VALUES = 7777; it('threads maxValuesPerKey into the map text-index query', async () => { @@ -1853,11 +1852,9 @@ describe('Metadata', () => { c[0].query.includes('startsWith(token,'), ); expect(mapTextIndexCall).toBeDefined(); - const params = mapTextIndexCall![0].query_params as Record< - string, - unknown - >; - expect(Object.values(params)).toContain(MAX_VALUES); + expect(mapTextIndexCall![0].query).toContain( + `topKWeighted(${MAX_VALUES})(`, + ); }); it('threads maxValuesPerKey into the native text-index query', async () => { @@ -1878,11 +1875,9 @@ describe('Metadata', () => { !c[0].query.includes('startsWith(token,'), ); expect(nativeTextIndexCall).toBeDefined(); - const params = nativeTextIndexCall![0].query_params as Record< - string, - unknown - >; - expect(Object.values(params)).toContain(MAX_VALUES); + expect(nativeTextIndexCall![0].query).toContain( + `topKWeighted(${MAX_VALUES})(token, cardinality)`, + ); }); it('threads maxValuesPerKey into the raw-table getKeyValues fallback', async () => { diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 659584fe6..fc9ec660d 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -1792,11 +1792,13 @@ export class Metadata { partSelection, }); const valueSql = chSql`substring(token, position(token, ${{ String: info.separator }}) + ${{ Int32: info.separator.length }})`; + // `cardinality` is the token's row count in the part, so this keeps + // each key's most frequent values rather than the first ones read. const sql = chSql` SELECT * FROM ( SELECT ${{ String: columnName }} as column, substring(token, 1, position(token, ${{ String: info.separator }}) - 1) AS key, - groupUniqArray(${{ Int32: info.limit }})(${valueSql}) AS value + topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(${valueSql}, cardinality) AS value FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) WHERE ${partsFilter} AND (${orChain}) @@ -1879,7 +1881,7 @@ export class Metadata { const sql = chSql` SELECT * FROM ( SELECT ${{ String: columnName }} AS key, - groupUniqArray(${{ Int32: info.limit }})(token) AS value + topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(token, cardinality) AS value FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) WHERE ${partsFilter} AND token != '' From bddaf8791e00e62ea16b644a4794668d6d5a0fb7 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:31:49 -0500 Subject: [PATCH 03/18] perf: read text index values per key with a token cap --- .changeset/topk-text-index-values.md | 16 +++-- .../src/__tests__/metadata.int.test.ts | 16 +++-- .../src/__tests__/metadata.test.ts | 30 ++++++++- packages/common-utils/src/core/metadata.ts | 66 ++++++++++--------- 4 files changed, 85 insertions(+), 43 deletions(-) diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index 332e05a55..a25ccb0f7 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -2,10 +2,16 @@ '@hyperdx/common-utils': patch --- -feat: show the most frequent filter values read from text indexes +perf: read each key's text index values separately, and show the most +frequent ones "All values" lookups that read a text index (Map key=value indexes and text -indexes on plain columns) used `groupUniqArray(limit)`, which keeps the -alphabetically first tokens it reads. They now use `topKWeighted(limit)` -weighted by each token's row count, so a capped list holds the most common -values, most common first. This costs more CPU per token than before. +indexes on plain columns) read every token of every requested key, capped at +`max_rows_to_read` for the whole query. One key with a value per row (request +IDs, UUIDs) slowed the whole query down, and because the index is read in +sorted order, the cap left keys that sort after it with no values. Each key now +has its own query branch that reads at most 100,000 tokens, and there is no +whole-query row cap. Values come from `topKWeighted(limit)` weighted by each +token's row count, so a capped list holds the most common values, most common +first. For keys with more distinct values than the token cap, they are the most +common among the alphabetically first ones. diff --git a/packages/common-utils/src/__tests__/metadata.int.test.ts b/packages/common-utils/src/__tests__/metadata.int.test.ts index 10e834039..5b5acf2f9 100644 --- a/packages/common-utils/src/__tests__/metadata.int.test.ts +++ b/packages/common-utils/src/__tests__/metadata.int.test.ts @@ -925,13 +925,21 @@ describe('Metadata Integration Tests', () => { const result = await metadata.getAllKeyValues({ ...commonArgs, - keyExpressions: [`ResourceAttributes['${freqKey}']`], + keyExpressions: [ + `ResourceAttributes['${freqKey}']`, + `ResourceAttributes['${mapKey}']`, + ], maxValuesPerKey: 1, }); - expect(result).toEqual([ - { key: `ResourceAttributes['${freqKey}']`, value: ['z-common'] }, - ]); + // UNION ALL branches can arrive in any order. + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + { key: `ResourceAttributes['${freqKey}']`, value: ['z-common'] }, + { key: `ResourceAttributes['${mapKey}']`, value: [podNameA] }, + ]), + ); }); it('routes TraceId through getTextIndexKeyValues', async () => { diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 76e640b95..a6d99f4e4 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1381,7 +1381,7 @@ describe('Metadata', () => { expect(sql).toContain('mergeTreeTextIndex('); expect(sql).toContain('topKWeighted('); - expect(sql).toContain('GROUP BY key'); + expect(sql).toContain('LIMIT 100000'); expect(sql).not.toContain('startsWith(token,'); }); @@ -1400,7 +1400,33 @@ describe('Metadata', () => { expect(sql).toContain('mergeTreeTextIndex('); expect(sql).toContain('startsWith(token,'); expect(sql).toContain('substring(token, position(token,'); - expect(sql).toContain('GROUP BY column, key'); + expect(sql).toContain("startsWith(token, 'requestId=')"); + }); + + it('reads a capped number of tokens per Map key, without a row cap that could starve later keys', async () => { + setupDefaultLogsSchema(); + + await metadata.getAllKeyValues({ + ...baseArgs, + keyExpressions: [ + "LogAttributes['requestId']", + "LogAttributes['region']", + ], + }); + + const call = (mockClickhouseClient.query as jest.Mock).mock.calls.find( + ([opts]) => (opts.query as string).includes('startsWith(token,'), + )![0]; + const branches = (call.query as string).split('UNION ALL'); + expect(branches).toHaveLength(2); + expect(branches[0]).toContain("startsWith(token, 'requestId=')"); + expect(branches[0]).toContain("'requestId' AS key"); + expect(branches[1]).toContain("startsWith(token, 'region=')"); + for (const branch of branches) { + expect(branch).toContain('LIMIT 100000'); + } + expect(call.clickhouse_settings.max_rows_to_read).toBe('0'); + expect(call.clickhouse_settings.read_overflow_mode).toBeUndefined(); }); it.each([ diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index fc9ec660d..9b22c042c 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -78,6 +78,11 @@ export const DEFAULT_MAP_KEY_INDEX_MAX_PARTS = 20; // Cap keys per dispatched query: each key is another operation for the db to fetch, and simply fetching all keys at once can be too much for the db to handle. export const GET_ALL_KEY_VALUES_CHUNK_SIZE = 100; +// Tokens a text index values lookup reads per key. The dictionary is sorted, +// so a key with more distinct values than this gets the most frequent among +// its alphabetically first ones. Keep it above "Load more"'s 10,000 values. +const TEXT_INDEX_TOKENS_PER_KEY = 100_000; + /** * The index read could not be scoped to the requested date range, so it was * refused in favour of a path that can. Not a failure — an expected property @@ -1770,19 +1775,6 @@ export class Metadata { try { const sqlBranches: Array = []; for (const [columnName, info] of queryOptions.entries()) { - const orChain = concatChSql( - ' OR ', - // Inline keys as SQL-escaped literals, not bind params: ~100 - // per-key params exceed the web client's URL param budget and - // silently switch the request to a multipart body that proxies - // may reject. Keys are ingest-controlled, hence SqlString.escape. - info.keys.map( - k => - chSql`startsWith(token, ${{ - UNSAFE_RAW_SQL: SqlString.escape(`${k}${info.separator}`), - }})`, - ), - ); const partsFilter = await this.partsOverlapFilter({ databaseName, tableName, @@ -1792,20 +1784,29 @@ export class Metadata { partSelection, }); const valueSql = chSql`substring(token, position(token, ${{ String: info.separator }}) + ${{ Int32: info.separator.length }})`; - // `cardinality` is the token's row count in the part, so this keeps - // each key's most frequent values rather than the first ones read. - const sql = chSql` - SELECT * FROM ( - SELECT ${{ String: columnName }} as column, - substring(token, 1, position(token, ${{ String: info.separator }}) - 1) AS key, - topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(${valueSql}, cardinality) AS value + // One branch per key, each with its own LIMIT: the read for a key + // stops after TEXT_INDEX_TOKENS_PER_KEY tokens, so a key with a + // value per row can't slow down or starve the others. `cardinality` + // is the token's row count in the part, so topKWeighted keeps the + // most frequent values among the tokens read. + for (const k of info.keys) { + // Inline keys as SQL-escaped literals, not bind params: ~100 + // per-key params exceed the web client's URL param budget and + // silently switch the request to a multipart body that proxies + // may reject. Keys are ingest-controlled, hence SqlString.escape. + sqlBranches.push(chSql` + SELECT ${{ String: columnName }} AS column, + ${{ UNSAFE_RAW_SQL: SqlString.escape(k) }} AS key, + topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(v, cardinality) AS value + FROM ( + SELECT ${valueSql} AS v, cardinality FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) WHERE ${partsFilter} - AND (${orChain}) - AND ${valueSql} != '' - GROUP BY column, key - )`; - sqlBranches.push(sql); + AND startsWith(token, ${{ UNSAFE_RAW_SQL: SqlString.escape(`${k}${info.separator}`) }}) + AND v != '' + LIMIT ${{ UNSAFE_RAW_SQL: String(TEXT_INDEX_TOKENS_PER_KEY) }} + )`); + } } const sql = concatChSql(' UNION ALL ', sqlBranches); @@ -1816,9 +1817,8 @@ export class Metadata { connectionId, clickhouse_settings: { ...this.getClickHouseSettings(), - // The dictionary is read in sorted token order, so a row cap - // with read_overflow_mode=break silently leaves every key that - // sorts after the cutoff with no values. + // The per-key LIMIT bounds the read. A row cap would stop the + // sorted dictionary read partway and leave later keys empty. max_rows_to_read: '0', }, abort_signal: signal, @@ -1878,14 +1878,16 @@ export class Metadata { maxParts: maxParts ?? undefined, partSelection, }); + // See getMapTextIndexKeyValues for the per-column LIMIT. const sql = chSql` - SELECT * FROM ( - SELECT ${{ String: columnName }} AS key, - topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(token, cardinality) AS value + SELECT ${{ String: columnName }} AS key, + topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(token, cardinality) AS value + FROM ( + SELECT token, cardinality FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) WHERE ${partsFilter} AND token != '' - GROUP BY key + LIMIT ${{ UNSAFE_RAW_SQL: String(TEXT_INDEX_TOKENS_PER_KEY) }} )`; sqlBranches.push(sql); } From 54f2bf6e0abf5e3ae6baa3ef0d2db8ee1d7c2aa3 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:40:01 -0500 Subject: [PATCH 04/18] perf: run text index values in batches of 10 keys --- .../src/__tests__/metadata.test.ts | 18 ++++++++ packages/common-utils/src/core/metadata.ts | 41 +++++++++++-------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index a6d99f4e4..09d373f82 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1537,6 +1537,24 @@ describe('Metadata', () => { }, ); + it('splits Map text index values into queries of at most 10 keys', async () => { + setupDefaultLogsSchema(); + + await metadata.getAllKeyValues({ + ...baseArgs, + keyExpressions: Array.from( + { length: 25 }, + (_, i) => `LogAttributes['key${i}']`, + ), + }); + + const branchCounts = (mockClickhouseClient.query as jest.Mock).mock.calls + .map(([opts]) => opts.query as string) + .filter(q => q.includes('startsWith(token,')) + .map(q => q.split('UNION ALL').length); + expect(branchCounts).toEqual([10, 10, 5]); + }); + it('routes ServiceName and SeverityText through getMetadataMVKeyValues (KV rollup MV path)', async () => { setupDefaultLogsSchema(); diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 9b22c042c..0b82bd1a5 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -82,6 +82,8 @@ export const GET_ALL_KEY_VALUES_CHUNK_SIZE = 100; // so a key with more distinct values than this gets the most frequent among // its alphabetically first ones. Keep it above "Load more"'s 10,000 values. const TEXT_INDEX_TOKENS_PER_KEY = 100_000; +// Keys per text index values query; see dispatchKeyValueQueries. +const TEXT_INDEX_KEYS_PER_QUERY = 10; /** * The index read could not be scoped to the requested date range, so it was @@ -3054,28 +3056,35 @@ export class Metadata { // fire all the kv fetch queries const promises: Array> = []; - if (mapTextIndexQueryOptions.size > 0) { - promises.push( - this.getMapTextIndexKeyValues({ - databaseName, - tableName, - connectionId, - queryOptions: mapTextIndexQueryOptions, - maxParts: mapKeyIndexMaxParts, - partSelection: mapKeyIndexPartSelection, - dateRange, - timestampValueExpression, - signal, - }), - ); + // Text index lookups run in small parallel batches, so a slow, + // high-cardinality key holds up only its own batch's values. + for (const [column, options] of mapTextIndexQueryOptions) { + for (const keys of chunk(options.keys, TEXT_INDEX_KEYS_PER_QUERY)) { + promises.push( + this.getMapTextIndexKeyValues({ + databaseName, + tableName, + connectionId, + queryOptions: new Map([[column, { ...options, keys }]]), + maxParts: mapKeyIndexMaxParts, + partSelection: mapKeyIndexPartSelection, + dateRange, + timestampValueExpression, + signal, + }), + ); + } } - if (nativeTextIndexQueryOptions.size > 0) { + for (const entries of chunk( + [...nativeTextIndexQueryOptions], + TEXT_INDEX_KEYS_PER_QUERY, + )) { promises.push( this.getTextIndexKeyValues({ databaseName, tableName, connectionId, - queryOptions: nativeTextIndexQueryOptions, + queryOptions: new Map(entries), maxParts: mapKeyIndexMaxParts, partSelection: mapKeyIndexPartSelection, dateRange, From a8806afafa26ba7a6e1b97c644cebc745d6e66bf Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:48:14 -0500 Subject: [PATCH 05/18] perf: read text index values in 1024-row blocks so per-key LIMITs stop early --- packages/common-utils/src/__tests__/metadata.test.ts | 5 +++++ packages/common-utils/src/core/metadata.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 09d373f82..255b0287e 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1383,6 +1383,10 @@ describe('Metadata', () => { expect(sql).toContain('topKWeighted('); expect(sql).toContain('LIMIT 100000'); expect(sql).not.toContain('startsWith(token,'); + expect(calls[calls.length - 1][0].clickhouse_settings).toMatchObject({ + max_rows_to_read: '0', + max_block_size: '1024', + }); }); it("routes LogAttributes['requestId'] through getMapTextIndexKeyValues (map KV text index path)", async () => { @@ -1427,6 +1431,7 @@ describe('Metadata', () => { } expect(call.clickhouse_settings.max_rows_to_read).toBe('0'); expect(call.clickhouse_settings.read_overflow_mode).toBeUndefined(); + expect(call.clickhouse_settings.max_block_size).toBe('1024'); }); it.each([ diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 0b82bd1a5..aa3209c84 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -1822,6 +1822,9 @@ export class Metadata { // The per-key LIMIT bounds the read. A row cap would stop the // sorted dictionary read partway and leave later keys empty. max_rows_to_read: '0', + // LIMIT stops a part's read only after a whole block, so the + // default 65k-row block makes every key read far past its LIMIT. + max_block_size: '1024', }, abort_signal: signal, }) @@ -1904,6 +1907,7 @@ export class Metadata { ...this.getClickHouseSettings(), // See getMapTextIndexKeyValues. max_rows_to_read: '0', + max_block_size: '1024', }, abort_signal: signal, }) From 7e32a817df1eb8e196dc9dfff32830b2b6a53e2c Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:13:53 -0500 Subject: [PATCH 06/18] Revert "perf: read text index values in 1024-row blocks so per-key LIMITs stop early" This reverts commit 969a5e04b69a11f23a9b5d925df2cd032a01201f. --- packages/common-utils/src/__tests__/metadata.test.ts | 5 ----- packages/common-utils/src/core/metadata.ts | 4 ---- 2 files changed, 9 deletions(-) diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 255b0287e..09d373f82 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1383,10 +1383,6 @@ describe('Metadata', () => { expect(sql).toContain('topKWeighted('); expect(sql).toContain('LIMIT 100000'); expect(sql).not.toContain('startsWith(token,'); - expect(calls[calls.length - 1][0].clickhouse_settings).toMatchObject({ - max_rows_to_read: '0', - max_block_size: '1024', - }); }); it("routes LogAttributes['requestId'] through getMapTextIndexKeyValues (map KV text index path)", async () => { @@ -1431,7 +1427,6 @@ describe('Metadata', () => { } expect(call.clickhouse_settings.max_rows_to_read).toBe('0'); expect(call.clickhouse_settings.read_overflow_mode).toBeUndefined(); - expect(call.clickhouse_settings.max_block_size).toBe('1024'); }); it.each([ diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index aa3209c84..0b82bd1a5 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -1822,9 +1822,6 @@ export class Metadata { // The per-key LIMIT bounds the read. A row cap would stop the // sorted dictionary read partway and leave later keys empty. max_rows_to_read: '0', - // LIMIT stops a part's read only after a whole block, so the - // default 65k-row block makes every key read far past its LIMIT. - max_block_size: '1024', }, abort_signal: signal, }) @@ -1907,7 +1904,6 @@ export class Metadata { ...this.getClickHouseSettings(), // See getMapTextIndexKeyValues. max_rows_to_read: '0', - max_block_size: '1024', }, abort_signal: signal, }) From 5fe11833836dff7f75c4e8a808b6191a00ae6dd1 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:15:23 -0500 Subject: [PATCH 07/18] Revert "perf: read text index values per key with a token cap" This reverts commit e66388dafe66fd7008bcd1863f0104fdc9219874. --- .changeset/topk-text-index-values.md | 18 ++--- .../src/__tests__/metadata.int.test.ts | 16 +--- .../src/__tests__/metadata.test.ts | 36 ++------- packages/common-utils/src/core/metadata.ts | 75 ++++++++++--------- 4 files changed, 55 insertions(+), 90 deletions(-) diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index a25ccb0f7..95f8e695d 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -2,16 +2,12 @@ '@hyperdx/common-utils': patch --- -perf: read each key's text index values separately, and show the most -frequent ones +perf: show the most frequent text index values, in batches of 10 keys "All values" lookups that read a text index (Map key=value indexes and text -indexes on plain columns) read every token of every requested key, capped at -`max_rows_to_read` for the whole query. One key with a value per row (request -IDs, UUIDs) slowed the whole query down, and because the index is read in -sorted order, the cap left keys that sort after it with no values. Each key now -has its own query branch that reads at most 100,000 tokens, and there is no -whole-query row cap. Values come from `topKWeighted(limit)` weighted by each -token's row count, so a capped list holds the most common values, most common -first. For keys with more distinct values than the token cap, they are the most -common among the alphabetically first ones. +indexes on plain columns) used `groupUniqArray(limit)`, which keeps the +alphabetically first tokens it reads. They now use `topKWeighted(limit)` +weighted by each token's row count, so a capped list holds the most common +values, most common first. These lookups also run in parallel batches of at +most 10 keys instead of 100, so a key with very many values delays only its own +batch. diff --git a/packages/common-utils/src/__tests__/metadata.int.test.ts b/packages/common-utils/src/__tests__/metadata.int.test.ts index 5b5acf2f9..10e834039 100644 --- a/packages/common-utils/src/__tests__/metadata.int.test.ts +++ b/packages/common-utils/src/__tests__/metadata.int.test.ts @@ -925,21 +925,13 @@ describe('Metadata Integration Tests', () => { const result = await metadata.getAllKeyValues({ ...commonArgs, - keyExpressions: [ - `ResourceAttributes['${freqKey}']`, - `ResourceAttributes['${mapKey}']`, - ], + keyExpressions: [`ResourceAttributes['${freqKey}']`], maxValuesPerKey: 1, }); - // UNION ALL branches can arrive in any order. - expect(result).toHaveLength(2); - expect(result).toEqual( - expect.arrayContaining([ - { key: `ResourceAttributes['${freqKey}']`, value: ['z-common'] }, - { key: `ResourceAttributes['${mapKey}']`, value: [podNameA] }, - ]), - ); + expect(result).toEqual([ + { key: `ResourceAttributes['${freqKey}']`, value: ['z-common'] }, + ]); }); it('routes TraceId through getTextIndexKeyValues', async () => { diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 09d373f82..b656eade9 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1381,7 +1381,7 @@ describe('Metadata', () => { expect(sql).toContain('mergeTreeTextIndex('); expect(sql).toContain('topKWeighted('); - expect(sql).toContain('LIMIT 100000'); + expect(sql).toContain('GROUP BY key'); expect(sql).not.toContain('startsWith(token,'); }); @@ -1400,33 +1400,7 @@ describe('Metadata', () => { expect(sql).toContain('mergeTreeTextIndex('); expect(sql).toContain('startsWith(token,'); expect(sql).toContain('substring(token, position(token,'); - expect(sql).toContain("startsWith(token, 'requestId=')"); - }); - - it('reads a capped number of tokens per Map key, without a row cap that could starve later keys', async () => { - setupDefaultLogsSchema(); - - await metadata.getAllKeyValues({ - ...baseArgs, - keyExpressions: [ - "LogAttributes['requestId']", - "LogAttributes['region']", - ], - }); - - const call = (mockClickhouseClient.query as jest.Mock).mock.calls.find( - ([opts]) => (opts.query as string).includes('startsWith(token,'), - )![0]; - const branches = (call.query as string).split('UNION ALL'); - expect(branches).toHaveLength(2); - expect(branches[0]).toContain("startsWith(token, 'requestId=')"); - expect(branches[0]).toContain("'requestId' AS key"); - expect(branches[1]).toContain("startsWith(token, 'region=')"); - for (const branch of branches) { - expect(branch).toContain('LIMIT 100000'); - } - expect(call.clickhouse_settings.max_rows_to_read).toBe('0'); - expect(call.clickhouse_settings.read_overflow_mode).toBeUndefined(); + expect(sql).toContain('GROUP BY column, key'); }); it.each([ @@ -1548,11 +1522,11 @@ describe('Metadata', () => { ), }); - const branchCounts = (mockClickhouseClient.query as jest.Mock).mock.calls + const keysPerQuery = (mockClickhouseClient.query as jest.Mock).mock.calls .map(([opts]) => opts.query as string) .filter(q => q.includes('startsWith(token,')) - .map(q => q.split('UNION ALL').length); - expect(branchCounts).toEqual([10, 10, 5]); + .map(q => q.split('startsWith(token,').length - 1); + expect(keysPerQuery).toEqual([10, 10, 5]); }); it('routes ServiceName and SeverityText through getMetadataMVKeyValues (KV rollup MV path)', async () => { diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 0b82bd1a5..b02a2164e 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -78,10 +78,6 @@ export const DEFAULT_MAP_KEY_INDEX_MAX_PARTS = 20; // Cap keys per dispatched query: each key is another operation for the db to fetch, and simply fetching all keys at once can be too much for the db to handle. export const GET_ALL_KEY_VALUES_CHUNK_SIZE = 100; -// Tokens a text index values lookup reads per key. The dictionary is sorted, -// so a key with more distinct values than this gets the most frequent among -// its alphabetically first ones. Keep it above "Load more"'s 10,000 values. -const TEXT_INDEX_TOKENS_PER_KEY = 100_000; // Keys per text index values query; see dispatchKeyValueQueries. const TEXT_INDEX_KEYS_PER_QUERY = 10; @@ -1777,6 +1773,19 @@ export class Metadata { try { const sqlBranches: Array = []; for (const [columnName, info] of queryOptions.entries()) { + const orChain = concatChSql( + ' OR ', + // Inline keys as SQL-escaped literals, not bind params: ~100 + // per-key params exceed the web client's URL param budget and + // silently switch the request to a multipart body that proxies + // may reject. Keys are ingest-controlled, hence SqlString.escape. + info.keys.map( + k => + chSql`startsWith(token, ${{ + UNSAFE_RAW_SQL: SqlString.escape(`${k}${info.separator}`), + }})`, + ), + ); const partsFilter = await this.partsOverlapFilter({ databaseName, tableName, @@ -1786,29 +1795,20 @@ export class Metadata { partSelection, }); const valueSql = chSql`substring(token, position(token, ${{ String: info.separator }}) + ${{ Int32: info.separator.length }})`; - // One branch per key, each with its own LIMIT: the read for a key - // stops after TEXT_INDEX_TOKENS_PER_KEY tokens, so a key with a - // value per row can't slow down or starve the others. `cardinality` - // is the token's row count in the part, so topKWeighted keeps the - // most frequent values among the tokens read. - for (const k of info.keys) { - // Inline keys as SQL-escaped literals, not bind params: ~100 - // per-key params exceed the web client's URL param budget and - // silently switch the request to a multipart body that proxies - // may reject. Keys are ingest-controlled, hence SqlString.escape. - sqlBranches.push(chSql` - SELECT ${{ String: columnName }} AS column, - ${{ UNSAFE_RAW_SQL: SqlString.escape(k) }} AS key, - topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(v, cardinality) AS value - FROM ( - SELECT ${valueSql} AS v, cardinality + // `cardinality` is the token's row count in the part, so this keeps + // each key's most frequent values rather than the first ones read. + const sql = chSql` + SELECT * FROM ( + SELECT ${{ String: columnName }} as column, + substring(token, 1, position(token, ${{ String: info.separator }}) - 1) AS key, + topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(${valueSql}, cardinality) AS value FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) WHERE ${partsFilter} - AND startsWith(token, ${{ UNSAFE_RAW_SQL: SqlString.escape(`${k}${info.separator}`) }}) - AND v != '' - LIMIT ${{ UNSAFE_RAW_SQL: String(TEXT_INDEX_TOKENS_PER_KEY) }} - )`); - } + AND (${orChain}) + AND ${valueSql} != '' + GROUP BY column, key + )`; + sqlBranches.push(sql); } const sql = concatChSql(' UNION ALL ', sqlBranches); @@ -1818,10 +1818,12 @@ export class Metadata { query_params: sql.params, connectionId, clickhouse_settings: { + max_rows_to_read: String( + this.getClickHouseSettings().max_rows_to_read ?? + DEFAULT_METADATA_MAX_ROWS_TO_READ, + ), + read_overflow_mode: 'break', ...this.getClickHouseSettings(), - // The per-key LIMIT bounds the read. A row cap would stop the - // sorted dictionary read partway and leave later keys empty. - max_rows_to_read: '0', }, abort_signal: signal, }) @@ -1880,16 +1882,14 @@ export class Metadata { maxParts: maxParts ?? undefined, partSelection, }); - // See getMapTextIndexKeyValues for the per-column LIMIT. const sql = chSql` - SELECT ${{ String: columnName }} AS key, - topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(token, cardinality) AS value - FROM ( - SELECT token, cardinality + SELECT * FROM ( + SELECT ${{ String: columnName }} AS key, + topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(token, cardinality) AS value FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) WHERE ${partsFilter} AND token != '' - LIMIT ${{ UNSAFE_RAW_SQL: String(TEXT_INDEX_TOKENS_PER_KEY) }} + GROUP BY key )`; sqlBranches.push(sql); } @@ -1901,9 +1901,12 @@ export class Metadata { query_params: sql.params, connectionId, clickhouse_settings: { + max_rows_to_read: String( + this.getClickHouseSettings().max_rows_to_read ?? + DEFAULT_METADATA_MAX_ROWS_TO_READ, + ), + read_overflow_mode: 'break', ...this.getClickHouseSettings(), - // See getMapTextIndexKeyValues. - max_rows_to_read: '0', }, abort_signal: signal, }) From dde2febd442cdb5cce8eec5d4f0b129899c85184 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:30:13 -0500 Subject: [PATCH 08/18] Reapply "perf: read text index values per key with a token cap" This reverts commit 6525149b281abf2773a59667dcc69c153dc3cc00. --- .changeset/topk-text-index-values.md | 18 +++-- .../src/__tests__/metadata.int.test.ts | 16 +++- .../src/__tests__/metadata.test.ts | 36 +++++++-- packages/common-utils/src/core/metadata.ts | 75 +++++++++---------- 4 files changed, 90 insertions(+), 55 deletions(-) diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index 95f8e695d..a25ccb0f7 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -2,12 +2,16 @@ '@hyperdx/common-utils': patch --- -perf: show the most frequent text index values, in batches of 10 keys +perf: read each key's text index values separately, and show the most +frequent ones "All values" lookups that read a text index (Map key=value indexes and text -indexes on plain columns) used `groupUniqArray(limit)`, which keeps the -alphabetically first tokens it reads. They now use `topKWeighted(limit)` -weighted by each token's row count, so a capped list holds the most common -values, most common first. These lookups also run in parallel batches of at -most 10 keys instead of 100, so a key with very many values delays only its own -batch. +indexes on plain columns) read every token of every requested key, capped at +`max_rows_to_read` for the whole query. One key with a value per row (request +IDs, UUIDs) slowed the whole query down, and because the index is read in +sorted order, the cap left keys that sort after it with no values. Each key now +has its own query branch that reads at most 100,000 tokens, and there is no +whole-query row cap. Values come from `topKWeighted(limit)` weighted by each +token's row count, so a capped list holds the most common values, most common +first. For keys with more distinct values than the token cap, they are the most +common among the alphabetically first ones. diff --git a/packages/common-utils/src/__tests__/metadata.int.test.ts b/packages/common-utils/src/__tests__/metadata.int.test.ts index 10e834039..5b5acf2f9 100644 --- a/packages/common-utils/src/__tests__/metadata.int.test.ts +++ b/packages/common-utils/src/__tests__/metadata.int.test.ts @@ -925,13 +925,21 @@ describe('Metadata Integration Tests', () => { const result = await metadata.getAllKeyValues({ ...commonArgs, - keyExpressions: [`ResourceAttributes['${freqKey}']`], + keyExpressions: [ + `ResourceAttributes['${freqKey}']`, + `ResourceAttributes['${mapKey}']`, + ], maxValuesPerKey: 1, }); - expect(result).toEqual([ - { key: `ResourceAttributes['${freqKey}']`, value: ['z-common'] }, - ]); + // UNION ALL branches can arrive in any order. + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + { key: `ResourceAttributes['${freqKey}']`, value: ['z-common'] }, + { key: `ResourceAttributes['${mapKey}']`, value: [podNameA] }, + ]), + ); }); it('routes TraceId through getTextIndexKeyValues', async () => { diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index b656eade9..09d373f82 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1381,7 +1381,7 @@ describe('Metadata', () => { expect(sql).toContain('mergeTreeTextIndex('); expect(sql).toContain('topKWeighted('); - expect(sql).toContain('GROUP BY key'); + expect(sql).toContain('LIMIT 100000'); expect(sql).not.toContain('startsWith(token,'); }); @@ -1400,7 +1400,33 @@ describe('Metadata', () => { expect(sql).toContain('mergeTreeTextIndex('); expect(sql).toContain('startsWith(token,'); expect(sql).toContain('substring(token, position(token,'); - expect(sql).toContain('GROUP BY column, key'); + expect(sql).toContain("startsWith(token, 'requestId=')"); + }); + + it('reads a capped number of tokens per Map key, without a row cap that could starve later keys', async () => { + setupDefaultLogsSchema(); + + await metadata.getAllKeyValues({ + ...baseArgs, + keyExpressions: [ + "LogAttributes['requestId']", + "LogAttributes['region']", + ], + }); + + const call = (mockClickhouseClient.query as jest.Mock).mock.calls.find( + ([opts]) => (opts.query as string).includes('startsWith(token,'), + )![0]; + const branches = (call.query as string).split('UNION ALL'); + expect(branches).toHaveLength(2); + expect(branches[0]).toContain("startsWith(token, 'requestId=')"); + expect(branches[0]).toContain("'requestId' AS key"); + expect(branches[1]).toContain("startsWith(token, 'region=')"); + for (const branch of branches) { + expect(branch).toContain('LIMIT 100000'); + } + expect(call.clickhouse_settings.max_rows_to_read).toBe('0'); + expect(call.clickhouse_settings.read_overflow_mode).toBeUndefined(); }); it.each([ @@ -1522,11 +1548,11 @@ describe('Metadata', () => { ), }); - const keysPerQuery = (mockClickhouseClient.query as jest.Mock).mock.calls + const branchCounts = (mockClickhouseClient.query as jest.Mock).mock.calls .map(([opts]) => opts.query as string) .filter(q => q.includes('startsWith(token,')) - .map(q => q.split('startsWith(token,').length - 1); - expect(keysPerQuery).toEqual([10, 10, 5]); + .map(q => q.split('UNION ALL').length); + expect(branchCounts).toEqual([10, 10, 5]); }); it('routes ServiceName and SeverityText through getMetadataMVKeyValues (KV rollup MV path)', async () => { diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index b02a2164e..0b82bd1a5 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -78,6 +78,10 @@ export const DEFAULT_MAP_KEY_INDEX_MAX_PARTS = 20; // Cap keys per dispatched query: each key is another operation for the db to fetch, and simply fetching all keys at once can be too much for the db to handle. export const GET_ALL_KEY_VALUES_CHUNK_SIZE = 100; +// Tokens a text index values lookup reads per key. The dictionary is sorted, +// so a key with more distinct values than this gets the most frequent among +// its alphabetically first ones. Keep it above "Load more"'s 10,000 values. +const TEXT_INDEX_TOKENS_PER_KEY = 100_000; // Keys per text index values query; see dispatchKeyValueQueries. const TEXT_INDEX_KEYS_PER_QUERY = 10; @@ -1773,19 +1777,6 @@ export class Metadata { try { const sqlBranches: Array = []; for (const [columnName, info] of queryOptions.entries()) { - const orChain = concatChSql( - ' OR ', - // Inline keys as SQL-escaped literals, not bind params: ~100 - // per-key params exceed the web client's URL param budget and - // silently switch the request to a multipart body that proxies - // may reject. Keys are ingest-controlled, hence SqlString.escape. - info.keys.map( - k => - chSql`startsWith(token, ${{ - UNSAFE_RAW_SQL: SqlString.escape(`${k}${info.separator}`), - }})`, - ), - ); const partsFilter = await this.partsOverlapFilter({ databaseName, tableName, @@ -1795,20 +1786,29 @@ export class Metadata { partSelection, }); const valueSql = chSql`substring(token, position(token, ${{ String: info.separator }}) + ${{ Int32: info.separator.length }})`; - // `cardinality` is the token's row count in the part, so this keeps - // each key's most frequent values rather than the first ones read. - const sql = chSql` - SELECT * FROM ( - SELECT ${{ String: columnName }} as column, - substring(token, 1, position(token, ${{ String: info.separator }}) - 1) AS key, - topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(${valueSql}, cardinality) AS value + // One branch per key, each with its own LIMIT: the read for a key + // stops after TEXT_INDEX_TOKENS_PER_KEY tokens, so a key with a + // value per row can't slow down or starve the others. `cardinality` + // is the token's row count in the part, so topKWeighted keeps the + // most frequent values among the tokens read. + for (const k of info.keys) { + // Inline keys as SQL-escaped literals, not bind params: ~100 + // per-key params exceed the web client's URL param budget and + // silently switch the request to a multipart body that proxies + // may reject. Keys are ingest-controlled, hence SqlString.escape. + sqlBranches.push(chSql` + SELECT ${{ String: columnName }} AS column, + ${{ UNSAFE_RAW_SQL: SqlString.escape(k) }} AS key, + topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(v, cardinality) AS value + FROM ( + SELECT ${valueSql} AS v, cardinality FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) WHERE ${partsFilter} - AND (${orChain}) - AND ${valueSql} != '' - GROUP BY column, key - )`; - sqlBranches.push(sql); + AND startsWith(token, ${{ UNSAFE_RAW_SQL: SqlString.escape(`${k}${info.separator}`) }}) + AND v != '' + LIMIT ${{ UNSAFE_RAW_SQL: String(TEXT_INDEX_TOKENS_PER_KEY) }} + )`); + } } const sql = concatChSql(' UNION ALL ', sqlBranches); @@ -1818,12 +1818,10 @@ export class Metadata { query_params: sql.params, connectionId, clickhouse_settings: { - max_rows_to_read: String( - this.getClickHouseSettings().max_rows_to_read ?? - DEFAULT_METADATA_MAX_ROWS_TO_READ, - ), - read_overflow_mode: 'break', ...this.getClickHouseSettings(), + // The per-key LIMIT bounds the read. A row cap would stop the + // sorted dictionary read partway and leave later keys empty. + max_rows_to_read: '0', }, abort_signal: signal, }) @@ -1882,14 +1880,16 @@ export class Metadata { maxParts: maxParts ?? undefined, partSelection, }); + // See getMapTextIndexKeyValues for the per-column LIMIT. const sql = chSql` - SELECT * FROM ( - SELECT ${{ String: columnName }} AS key, - topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(token, cardinality) AS value + SELECT ${{ String: columnName }} AS key, + topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(token, cardinality) AS value + FROM ( + SELECT token, cardinality FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) WHERE ${partsFilter} AND token != '' - GROUP BY key + LIMIT ${{ UNSAFE_RAW_SQL: String(TEXT_INDEX_TOKENS_PER_KEY) }} )`; sqlBranches.push(sql); } @@ -1901,12 +1901,9 @@ export class Metadata { query_params: sql.params, connectionId, clickhouse_settings: { - max_rows_to_read: String( - this.getClickHouseSettings().max_rows_to_read ?? - DEFAULT_METADATA_MAX_ROWS_TO_READ, - ), - read_overflow_mode: 'break', ...this.getClickHouseSettings(), + // See getMapTextIndexKeyValues. + max_rows_to_read: '0', }, abort_signal: signal, }) From 7ec3fe1670346e2ae6e7aac83c5a217815ec36de Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:31:13 -0500 Subject: [PATCH 09/18] docs: describe per-key text index value batches in the changeset --- .changeset/topk-text-index-values.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index a25ccb0f7..897e03a41 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -2,16 +2,19 @@ '@hyperdx/common-utils': patch --- -perf: read each key's text index values separately, and show the most -frequent ones +perf: read text index values per key, in batches of 10 keys, most frequent +first "All values" lookups that read a text index (Map key=value indexes and text -indexes on plain columns) read every token of every requested key, capped at -`max_rows_to_read` for the whole query. One key with a value per row (request -IDs, UUIDs) slowed the whole query down, and because the index is read in -sorted order, the cap left keys that sort after it with no values. Each key now -has its own query branch that reads at most 100,000 tokens, and there is no -whole-query row cap. Values come from `topKWeighted(limit)` weighted by each -token's row count, so a capped list holds the most common values, most common -first. For keys with more distinct values than the token cap, they are the most -common among the alphabetically first ones. +indexes on plain columns) read every token of up to 100 keys in one query, +capped at `max_rows_to_read` for the whole query. One key with a value per row +(request IDs, UUIDs) slowed every other key down, and because the index is read +in sorted order, the cap left keys that sort after it with no values. These +lookups now run in parallel queries of at most 10 keys, each key in its own +branch that reads at most 100,000 tokens, with no whole-query row cap. Values +come from `topKWeighted(limit)` weighted by each token's row count, so a capped +list holds the most common values, most common first. + +Each branch reads every part's text index once, so this is fastest when the +index uses large dictionary blocks (e.g. `text_index_dictionary_block_size = +4096`). From 4639a465cd8fba8464cfc226b343496197238460 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:36:15 -0500 Subject: [PATCH 10/18] perf: use 4096-token dictionary blocks for key=value text indexes in seed schemas --- .changeset/text-index-dictionary-block-size.md | 15 +++++++++++++++ .../schema/seed/00002_otel_logs.sql | 9 ++++++--- .../schema/seed/00005_otel_traces.sql | 7 +++++-- 3 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 .changeset/text-index-dictionary-block-size.md diff --git a/.changeset/text-index-dictionary-block-size.md b/.changeset/text-index-dictionary-block-size.md new file mode 100644 index 000000000..9801a1ac6 --- /dev/null +++ b/.changeset/text-index-dictionary-block-size.md @@ -0,0 +1,15 @@ +--- +'@hyperdx/otel-collector': patch +--- + +perf: use 4,096-token dictionary blocks for the key=value text indexes in the +logs and traces seed schemas + +`idx_res_attr_items`, `idx_scope_attr_items` and `idx_log_attr_items` on +`otel_logs`, and `idx_res_attr_items` and `idx_span_attr_items` on +`otel_traces`, now set `dictionary_block_size = 4096` (the default is 512). +Each part's sparse index is about 8x smaller, which cuts the per-part cost of +reading filter values from these indexes about 10x on large parts. Lookups in +these indexes decode up to 8x more tokens per block. Only new installs get the +new schema; existing tables keep their indexes until they are recreated, or +the indexes are cleared and materialized again. diff --git a/docker/otel-collector/schema/seed/00002_otel_logs.sql b/docker/otel-collector/schema/seed/00002_otel_logs.sql index 12ec3fbe3..2872a5d89 100644 --- a/docker/otel-collector/schema/seed/00002_otel_logs.sql +++ b/docker/otel-collector/schema/seed/00002_otel_logs.sql @@ -32,11 +32,14 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_logs INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array'), INDEX idx_session_id __hdx_materialized_session.id TYPE text(tokenizer = 'array'), INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array'), + -- Larger dictionary blocks make each part's sparse index smaller. Reading + -- a key's values with mergeTreeTextIndex() loads that index per part on + -- every read, so this cuts the per-part cost about 10x on large parts. + INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 4096), INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_scope_attr_items ScopeAttributeItems TYPE text(tokenizer = 'array'), + INDEX idx_scope_attr_items ScopeAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 4096), INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array'), + INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 4096), INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha') ) ENGINE = MergeTree diff --git a/docker/otel-collector/schema/seed/00005_otel_traces.sql b/docker/otel-collector/schema/seed/00005_otel_traces.sql index b3ad5b3a9..ba3fc5fad 100644 --- a/docker/otel-collector/schema/seed/00005_otel_traces.sql +++ b/docker/otel-collector/schema/seed/00005_otel_traces.sql @@ -30,9 +30,12 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_traces INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array'), INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE text(tokenizer = 'array'), INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array'), + -- Larger dictionary blocks make each part's sparse index smaller. Reading + -- a key's values with mergeTreeTextIndex() loads that index per part on + -- every read, so this cuts the per-part cost about 10x on large parts. + INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 4096), INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_span_attr_items SpanAttributeItems TYPE text(tokenizer = 'array'), + INDEX idx_span_attr_items SpanAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 4096), INDEX idx_duration Duration TYPE minmax GRANULARITY 1, INDEX idx_lower_span_name SpanName TYPE text(tokenizer = 'splitByNonAlpha', preprocessor=lower(SpanName)) ) From 363ee04037fb61abcf8c1fe656543020c9f4d4be Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:50:41 -0500 Subject: [PATCH 11/18] perf: read at most 20,000 text index tokens per key --- .changeset/topk-text-index-values.md | 2 +- packages/common-utils/src/__tests__/metadata.test.ts | 4 ++-- packages/common-utils/src/core/metadata.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index 897e03a41..273b3af25 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -11,7 +11,7 @@ capped at `max_rows_to_read` for the whole query. One key with a value per row (request IDs, UUIDs) slowed every other key down, and because the index is read in sorted order, the cap left keys that sort after it with no values. These lookups now run in parallel queries of at most 10 keys, each key in its own -branch that reads at most 100,000 tokens, with no whole-query row cap. Values +branch that reads at most 20,000 tokens, with no whole-query row cap. Values come from `topKWeighted(limit)` weighted by each token's row count, so a capped list holds the most common values, most common first. diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 09d373f82..e44e78a6f 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1381,7 +1381,7 @@ describe('Metadata', () => { expect(sql).toContain('mergeTreeTextIndex('); expect(sql).toContain('topKWeighted('); - expect(sql).toContain('LIMIT 100000'); + expect(sql).toContain('LIMIT 20000'); expect(sql).not.toContain('startsWith(token,'); }); @@ -1423,7 +1423,7 @@ describe('Metadata', () => { expect(branches[0]).toContain("'requestId' AS key"); expect(branches[1]).toContain("startsWith(token, 'region=')"); for (const branch of branches) { - expect(branch).toContain('LIMIT 100000'); + expect(branch).toContain('LIMIT 20000'); } expect(call.clickhouse_settings.max_rows_to_read).toBe('0'); expect(call.clickhouse_settings.read_overflow_mode).toBeUndefined(); diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 0b82bd1a5..3e339e8d6 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -81,7 +81,7 @@ export const GET_ALL_KEY_VALUES_CHUNK_SIZE = 100; // Tokens a text index values lookup reads per key. The dictionary is sorted, // so a key with more distinct values than this gets the most frequent among // its alphabetically first ones. Keep it above "Load more"'s 10,000 values. -const TEXT_INDEX_TOKENS_PER_KEY = 100_000; +const TEXT_INDEX_TOKENS_PER_KEY = 20_000; // Keys per text index values query; see dispatchKeyValueQueries. const TEXT_INDEX_KEYS_PER_QUERY = 10; From 892f472456ad74319161d83a7d8c428d212c61d0 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:10:44 -0500 Subject: [PATCH 12/18] perf: use 8192-token dictionary blocks for every text index --- .../text-index-dictionary-block-size.md | 21 +++++++++------- .changeset/topk-text-index-values.md | 4 ++-- docker/clickhouse/local/init-db-e2e.sh | 20 ++++++++-------- .../schema/seed/00002_otel_logs.sql | 24 +++++++++---------- .../schema/seed/00004_hyperdx_sessions.sql | 7 ++++-- .../schema/seed/00005_otel_traces.sql | 20 ++++++++-------- .../src/__tests__/kvItemsRewrite.int.test.ts | 2 +- .../src/__tests__/metadata.int.test.ts | 8 +++---- 8 files changed, 56 insertions(+), 50 deletions(-) diff --git a/.changeset/text-index-dictionary-block-size.md b/.changeset/text-index-dictionary-block-size.md index 9801a1ac6..da6a7e61b 100644 --- a/.changeset/text-index-dictionary-block-size.md +++ b/.changeset/text-index-dictionary-block-size.md @@ -2,14 +2,17 @@ '@hyperdx/otel-collector': patch --- -perf: use 4,096-token dictionary blocks for the key=value text indexes in the -logs and traces seed schemas +perf: use 8,192-token dictionary blocks for every text index in the seed +schemas -`idx_res_attr_items`, `idx_scope_attr_items` and `idx_log_attr_items` on -`otel_logs`, and `idx_res_attr_items` and `idx_span_attr_items` on -`otel_traces`, now set `dictionary_block_size = 4096` (the default is 512). -Each part's sparse index is about 8x smaller, which cuts the per-part cost of -reading filter values from these indexes about 10x on large parts. Lookups in -these indexes decode up to 8x more tokens per block. Only new installs get the -new schema; existing tables keep their indexes until they are recreated, or +Every text index on `otel_logs`, `otel_traces` and `hyperdx_sessions` now sets +`dictionary_block_size = 8192` (the default is 512). Each part's sparse index +is 16x smaller. Reading filter values with `mergeTreeTextIndex()` loads that +index for every part on every read, and on a 537M-row table 4,096-token blocks +already made those reads about 10x cheaper per part, made searches for a rare +attribute value 3-7x faster, and made searches for a common value 10-30% +faster. The setting is a per-index argument rather than the table setting +`text_index_dictionary_block_size`, which ClickHouse only accepts from 26.6. + +Only new installs get the new schema. Existing tables keep their indexes until the indexes are cleared and materialized again. diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index 273b3af25..1628bebb5 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -16,5 +16,5 @@ come from `topKWeighted(limit)` weighted by each token's row count, so a capped list holds the most common values, most common first. Each branch reads every part's text index once, so this is fastest when the -index uses large dictionary blocks (e.g. `text_index_dictionary_block_size = -4096`). +index uses large dictionary blocks (e.g. `dictionary_block_size = 8192`, as the +seed schemas now do). diff --git a/docker/clickhouse/local/init-db-e2e.sh b/docker/clickhouse/local/init-db-e2e.sh index c5242dc59..4ce993493 100755 --- a/docker/clickhouse/local/init-db-e2e.sh +++ b/docker/clickhouse/local/init-db-e2e.sh @@ -40,14 +40,14 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.e2e_otel_logs \`__hdx_materialized_k8s.pod.name\` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.pod.name'] CODEC(ZSTD(3)), \`__hdx_materialized_k8s.pod.uid\` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.pod.uid'] CODEC(ZSTD(3)), \`__hdx_materialized_deployment.environment.name\` LowCardinality(String) MATERIALIZED ResourceAttributes['deployment.environment.name'] CODEC(ZSTD(3)), - INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array'), - INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_scope_attr_value mapValues(ScopeAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_log_attr_value mapValues(LogAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha') + INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_scope_attr_value mapValues(ScopeAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_log_attr_value mapValues(LogAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha', dictionary_block_size = 8192) ) ENGINE = MergeTree PARTITION BY toDate(Timestamp) @@ -118,10 +118,10 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.e2e_otel_traces INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE bloom_filter(0.001) GRANULARITY 1, INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array'), + INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192), INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, INDEX idx_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_span_attr_items SpanAttributeItems TYPE text(tokenizer = 'array'), + INDEX idx_span_attr_items SpanAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192), INDEX idx_duration Duration TYPE minmax GRANULARITY 1, INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8 ) diff --git a/docker/otel-collector/schema/seed/00002_otel_logs.sql b/docker/otel-collector/schema/seed/00002_otel_logs.sql index 2872a5d89..fc046fdcb 100644 --- a/docker/otel-collector/schema/seed/00002_otel_logs.sql +++ b/docker/otel-collector/schema/seed/00002_otel_logs.sql @@ -29,18 +29,18 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_logs `ResourceAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ResourceAttributes::Array(Tuple(String, String))), `ScopeAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ScopeAttributes::Array(Tuple(String, String))), `LogAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), LogAttributes::Array(Tuple(String, String))), - INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array'), - INDEX idx_session_id __hdx_materialized_session.id TYPE text(tokenizer = 'array'), - INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array'), - -- Larger dictionary blocks make each part's sparse index smaller. Reading - -- a key's values with mergeTreeTextIndex() loads that index per part on - -- every read, so this cuts the per-part cost about 10x on large parts. - INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 4096), - INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_scope_attr_items ScopeAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 4096), - INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 4096), - INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha') + -- Text indexes use 8,192-token dictionary blocks (the default is 512). Each + -- part's sparse index is 16x smaller, which makes reading filter values + -- with mergeTreeTextIndex() and looking up tokens much cheaper on large parts. + INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_session_id __hdx_materialized_session.id TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_scope_attr_items ScopeAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha', dictionary_block_size = 8192) ) ENGINE = MergeTree PARTITION BY toDate(Timestamp) diff --git a/docker/otel-collector/schema/seed/00004_hyperdx_sessions.sql b/docker/otel-collector/schema/seed/00004_hyperdx_sessions.sql index e14258ce0..9f106e9fe 100644 --- a/docker/otel-collector/schema/seed/00004_hyperdx_sessions.sql +++ b/docker/otel-collector/schema/seed/00004_hyperdx_sessions.sql @@ -20,8 +20,11 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.hyperdx_sessions `__hdx_materialized_session.id` String MATERIALIZED LogAttributes['session.id'] CODEC(ZSTD(3)), `__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(3)), INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1, - INDEX idx_session_id __hdx_materialized_session.id TYPE text(tokenizer = 'array'), - INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE text(tokenizer = 'array'), + -- Text indexes use 8,192-token dictionary blocks (the default is 512). Each + -- part's sparse index is 16x smaller, which makes reading filter values + -- with mergeTreeTextIndex() and looking up tokens much cheaper on large parts. + INDEX idx_session_id __hdx_materialized_session.id TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE text(tokenizer = 'array', dictionary_block_size = 8192), INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, diff --git a/docker/otel-collector/schema/seed/00005_otel_traces.sql b/docker/otel-collector/schema/seed/00005_otel_traces.sql index ba3fc5fad..17d040434 100644 --- a/docker/otel-collector/schema/seed/00005_otel_traces.sql +++ b/docker/otel-collector/schema/seed/00005_otel_traces.sql @@ -27,17 +27,17 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_traces `SampleRate` UInt64 MATERIALIZED greatest(toUInt64OrZero(SpanAttributes['SampleRate']), 1) CODEC(T64, ZSTD(3)), `ResourceAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ResourceAttributes::Array(Tuple(String, String))), `SpanAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), SpanAttributes::Array(Tuple(String, String))), - INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array'), - INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE text(tokenizer = 'array'), - INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array'), - -- Larger dictionary blocks make each part's sparse index smaller. Reading - -- a key's values with mergeTreeTextIndex() loads that index per part on - -- every read, so this cuts the per-part cost about 10x on large parts. - INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 4096), - INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE text(tokenizer = 'array'), - INDEX idx_span_attr_items SpanAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 4096), + -- Text indexes use 8,192-token dictionary blocks (the default is 512). Each + -- part's sparse index is 16x smaller, which makes reading filter values + -- with mergeTreeTextIndex() and looking up tokens much cheaper on large parts. + INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192), + INDEX idx_span_attr_items SpanAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192), INDEX idx_duration Duration TYPE minmax GRANULARITY 1, - INDEX idx_lower_span_name SpanName TYPE text(tokenizer = 'splitByNonAlpha', preprocessor=lower(SpanName)) + INDEX idx_lower_span_name SpanName TYPE text(tokenizer = 'splitByNonAlpha', preprocessor=lower(SpanName), dictionary_block_size = 8192) ) ENGINE = MergeTree PARTITION BY toDate(Timestamp) diff --git a/packages/common-utils/src/__tests__/kvItemsRewrite.int.test.ts b/packages/common-utils/src/__tests__/kvItemsRewrite.int.test.ts index 3748c8bd2..7a59955b6 100644 --- a/packages/common-utils/src/__tests__/kvItemsRewrite.int.test.ts +++ b/packages/common-utils/src/__tests__/kvItemsRewrite.int.test.ts @@ -70,7 +70,7 @@ describe('KV items rewrite integration', () => { LogAttributeItems Array(String) MATERIALIZED arrayMap((x) -> concat(x.1, '=', x.2), LogAttributes::Array(Tuple(String, String))) CODEC(ZSTD(1)), - INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array') GRANULARITY 1 + INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192) GRANULARITY 1 ) ENGINE = MergeTree ORDER BY (Timestamp)`, diff --git a/packages/common-utils/src/__tests__/metadata.int.test.ts b/packages/common-utils/src/__tests__/metadata.int.test.ts index 5b5acf2f9..3735d787c 100644 --- a/packages/common-utils/src/__tests__/metadata.int.test.ts +++ b/packages/common-utils/src/__tests__/metadata.int.test.ts @@ -697,7 +697,7 @@ describe('Metadata Integration Tests', () => { ResourceAttributeItems Array(String) MATERIALIZED arrayMap(x -> concat(x.1, '=', x.2), CAST(ResourceAttributes, 'Array(Tuple(String, String))')) CODEC(ZSTD(1)), - INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array') GRANULARITY 1 + INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192) GRANULARITY 1 ) ENGINE = MergeTree() ORDER BY (Timestamp) @@ -712,7 +712,7 @@ describe('Metadata Integration Tests', () => { LogAttributeItems Array(String) MATERIALIZED arrayMap((x) -> concat(x.1, '=', x.2), LogAttributes::Array(Tuple(String, String))) CODEC(ZSTD(1)), - INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array') GRANULARITY 1 + INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192) GRANULARITY 1 ) ENGINE = MergeTree() ORDER BY (Timestamp) @@ -1610,14 +1610,14 @@ describe('Metadata Integration Tests', () => { { path: 'key', tableName: 'test_map_keys_key_text_index_order', - indexDefinition: `INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array') GRANULARITY 1`, + indexDefinition: `INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array', dictionary_block_size = 8192) GRANULARITY 1`, }, { path: 'kv', tableName: 'test_map_keys_kv_text_index_order', indexDefinition: `LogAttributeItems Array(String) MATERIALIZED arrayMap(x -> concat(x.1, '=', x.2), CAST(LogAttributes, 'Array(Tuple(String, String))')), - INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array') GRANULARITY 1`, + INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array', dictionary_block_size = 8192) GRANULARITY 1`, }, ])( 'getMapKeys - $path text index ordering', From 0634f1a96b778b0454204617b8e50520ba79da92 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:55:51 -0500 Subject: [PATCH 13/18] perf: read Map text index values per key only when dictionary blocks are large --- .changeset/topk-text-index-values.md | 28 +++--- packages/app/src/components/DBRowTable.tsx | 13 +-- .../src/__tests__/metadata.test.ts | 78 +++++++++++++---- .../common-utils/src/__tests__/utils.test.ts | 50 +++++++++++ packages/common-utils/src/core/metadata.ts | 86 ++++++++++++++++--- packages/common-utils/src/core/utils.ts | 35 ++++++++ 6 files changed, 240 insertions(+), 50 deletions(-) diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index 1628bebb5..f5dd4380c 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -2,19 +2,21 @@ '@hyperdx/common-utils': patch --- -perf: read text index values per key, in batches of 10 keys, most frequent -first +perf: read text index values in batches of 10 keys, most frequent first "All values" lookups that read a text index (Map key=value indexes and text -indexes on plain columns) read every token of up to 100 keys in one query, -capped at `max_rows_to_read` for the whole query. One key with a value per row -(request IDs, UUIDs) slowed every other key down, and because the index is read -in sorted order, the cap left keys that sort after it with no values. These -lookups now run in parallel queries of at most 10 keys, each key in its own -branch that reads at most 20,000 tokens, with no whole-query row cap. Values -come from `topKWeighted(limit)` weighted by each token's row count, so a capped -list holds the most common values, most common first. +indexes on plain columns) read every token of up to 100 keys in one query, so +one key with a value per row (request IDs, UUIDs) slowed every other key down. +These lookups now run in parallel queries of at most 10 keys, and keep at most +20,000 tokens per key. Values come from `topKWeighted(limit)` weighted by each +token's row count, so a capped list holds the most common values, most common +first. -Each branch reads every part's text index once, so this is fastest when the -index uses large dictionary blocks (e.g. `dictionary_block_size = 8192`, as the -seed schemas now do). +Map key=value indexes with large dictionary blocks (4096 or more, e.g. +`dictionary_block_size = 8192` as the seed schemas now use) read each key +separately, so a high-cardinality key stops after its 20,000 tokens. Indexes with +smaller blocks, including ClickHouse's default of 512, read all keys of a batch +in one pass: every read loads each part's sparse index, which is large with small +blocks, so one read per key would multiply that cost. The block size comes from +the index definition, then the table's `text_index_dictionary_block_size`, then +the server's. diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index 51ed633ac..55ea7676c 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -29,7 +29,10 @@ import { isJSDataTypeJSONStringifiable, JSDataType, } from '@hyperdx/common-utils/dist/clickhouse'; -import { splitAndTrimWithBracket } from '@hyperdx/common-utils/dist/core/utils'; +import { + getEngineSetting, + splitAndTrimWithBracket, +} from '@hyperdx/common-utils/dist/core/utils'; import { DENOISE_NOISE_THRESHOLD, DENOISE_SAMPLE_SIZE, @@ -1545,14 +1548,6 @@ const BLOCK_COLUMN_SETTINGS = [ { column: '_block_offset', setting: 'enable_block_offset_column' }, ]; -// engine_full ends with the SETTINGS clause. -function getEngineSetting(engineFull: string, name: string) { - const settingsClause = engineFull.match(/ SETTINGS (.*)$/)?.[1] ?? ''; - return splitAndTrimWithBracket(settingsClause) - .map(setting => setting.split(' = ')) - .find(([key]) => key === name)?.[1]; -} - function omitsBlockColumnSetting(engineFull: string) { return BLOCK_COLUMN_SETTINGS.some( ({ setting }) => getEngineSetting(engineFull, setting) == null, diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index e44e78a6f..43395d700 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1403,8 +1403,20 @@ describe('Metadata', () => { expect(sql).toContain("startsWith(token, 'requestId=')"); }); - it('reads a capped number of tokens per Map key, without a row cap that could starve later keys', async () => { + const withDictionaryBlockSize = (size: number) => + jest.spyOn(metadata, 'getSkipIndices').mockResolvedValue([ + { + name: 'idx_log_attr_items', + type: 'text', + typeFull: `text(tokenizer = 'array', dictionary_block_size = ${size})`, + expression: 'LogAttributes', + granularity: 100000000, + }, + ]); + + it('reads each Map key in its own capped branch when the index has large dictionary blocks', async () => { setupDefaultLogsSchema(); + withDictionaryBlockSize(8192); await metadata.getAllKeyValues({ ...baseArgs, @@ -1429,6 +1441,31 @@ describe('Metadata', () => { expect(call.clickhouse_settings.read_overflow_mode).toBeUndefined(); }); + it('reads all Map keys in one pass when the index has small dictionary blocks', async () => { + setupDefaultLogsSchema(); + withDictionaryBlockSize(512); + + await metadata.getAllKeyValues({ + ...baseArgs, + keyExpressions: [ + "LogAttributes['requestId']", + "LogAttributes['region']", + ], + }); + + const call = (mockClickhouseClient.query as jest.Mock).mock.calls.find( + ([opts]) => (opts.query as string).includes('startsWith(token,'), + )![0]; + const sql = call.query as string; + expect(sql).not.toContain('UNION ALL'); + expect(sql).toContain( + "startsWith(token, 'requestId=') OR startsWith(token, 'region=')", + ); + expect(sql).toContain('LIMIT 20000 BY key'); + expect(sql).toContain('GROUP BY key'); + expect(call.clickhouse_settings.max_rows_to_read).toBe('0'); + }); + it.each([ ['map KV', "LogAttributes['requestId']"], ['native', 'TraceId'], @@ -1537,23 +1574,32 @@ describe('Metadata', () => { }, ); - it('splits Map text index values into queries of at most 10 keys', async () => { - setupDefaultLogsSchema(); + it.each([ + [8192, [10, 10, 5]], + [512, [1, 1, 1]], + ])( + 'splits Map text index values into queries of at most 10 keys (dictionary blocks of %d)', + async (blockSize, expectedBranchCounts) => { + setupDefaultLogsSchema(); + withDictionaryBlockSize(blockSize); - await metadata.getAllKeyValues({ - ...baseArgs, - keyExpressions: Array.from( - { length: 25 }, - (_, i) => `LogAttributes['key${i}']`, - ), - }); + await metadata.getAllKeyValues({ + ...baseArgs, + keyExpressions: Array.from( + { length: 25 }, + (_, i) => `LogAttributes['key${i}']`, + ), + }); - const branchCounts = (mockClickhouseClient.query as jest.Mock).mock.calls - .map(([opts]) => opts.query as string) - .filter(q => q.includes('startsWith(token,')) - .map(q => q.split('UNION ALL').length); - expect(branchCounts).toEqual([10, 10, 5]); - }); + const branchCounts = ( + mockClickhouseClient.query as jest.Mock + ).mock.calls + .map(([opts]) => opts.query as string) + .filter(q => q.includes('startsWith(token,')) + .map(q => q.split('UNION ALL').length); + expect(branchCounts).toEqual(expectedBranchCounts); + }, + ); it('routes ServiceName and SeverityText through getMetadataMVKeyValues (KV rollup MV path)', async () => { setupDefaultLogsSchema(); diff --git a/packages/common-utils/src/__tests__/utils.test.ts b/packages/common-utils/src/__tests__/utils.test.ts index 327585152..452e3f589 100644 --- a/packages/common-utils/src/__tests__/utils.test.ts +++ b/packages/common-utils/src/__tests__/utils.test.ts @@ -15,6 +15,7 @@ import { getDistributedTableArgs, getFirstOrderingItem, getPercentileAggFnLevel, + getTextIndexDictionaryBlockSize, hasNonEmptyOrderBy, hasPositiveSeriesLimit, isFirstOrderByAscending, @@ -3620,4 +3621,53 @@ describe('utils', () => { expect(resolvePercentileAggFn(select)).toBe(select); }); }); + + describe('getTextIndexDictionaryBlockSize', () => { + const engineFull = + 'MergeTree ORDER BY Timestamp SETTINGS index_granularity = 8192, text_index_dictionary_block_size = 4096'; + + it.each([ + [ + 'the index argument first', + { + typeFull: "text(tokenizer = 'array', dictionary_block_size = 8192)", + engineFull, + serverMergeTreeSettings: new Map([ + ['text_index_dictionary_block_size', '1024'], + ]), + }, + 8192, + ], + [ + 'the table setting next', + { + typeFull: "text(tokenizer = 'array')", + engineFull, + serverMergeTreeSettings: new Map([ + ['text_index_dictionary_block_size', '1024'], + ]), + }, + 4096, + ], + [ + 'the server setting next', + { + typeFull: "text(tokenizer = 'array')", + engineFull: 'MergeTree ORDER BY Timestamp', + serverMergeTreeSettings: new Map([ + ['text_index_dictionary_block_size', '1024'], + ]), + }, + 1024, + ], + ['512 when nothing is known', {}, 512], + [ + '512 for a value that is not a positive integer', + { typeFull: "text(tokenizer = 'array', dictionary_block_size = 0)" }, + 512, + ], + ])('uses %s', (_, input, expected) => { + expect(getTextIndexDictionaryBlockSize(input)).toBe(expected); + }); + }); }); diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 3e339e8d6..e56ae9fcc 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -59,6 +59,7 @@ import { import { getAlignedDateRange, getDistributedTableArgs, + getTextIndexDictionaryBlockSize, MetadataMVQueryOptions, objectHash, splitAndTrimWithBracket, @@ -84,6 +85,9 @@ export const GET_ALL_KEY_VALUES_CHUNK_SIZE = 100; const TEXT_INDEX_TOKENS_PER_KEY = 20_000; // Keys per text index values query; see dispatchKeyValueQueries. const TEXT_INDEX_KEYS_PER_QUERY = 10; +// Smallest dictionary block size that gets one read per key; see +// getMapTextIndexKeyValues. +const PER_KEY_TEXT_INDEX_MIN_DICTIONARY_BLOCK_SIZE = 4096; /** * The index read could not be scoped to the requested date range, so it was @@ -1752,6 +1756,31 @@ export class Metadata { }); } + // shortcut: parts written before a table's text_index_dictionary_block_size + // changed keep their old block size; good enough to pick a query shape. + private async getTextIndexDictionaryBlockSize({ + databaseName, + tableName, + connectionId, + indexName, + }: TableConnection & { indexName: string }): Promise { + const [skipIndices, tableMetadata, serverMergeTreeSettings] = + await Promise.all([ + this.getSkipIndices({ databaseName, tableName, connectionId }).catch( + () => [] as SkipIndexMetadata[], + ), + this.getTableMetadata({ databaseName, tableName, connectionId }).catch( + () => undefined, + ), + this.getMergeTreeSettings({ connectionId }), + ]); + return getTextIndexDictionaryBlockSize({ + typeFull: skipIndices.find(idx => idx.name === indexName)?.typeFull, + engineFull: tableMetadata?.engine_full, + serverMergeTreeSettings, + }); + } + private async getMapTextIndexKeyValues({ databaseName, tableName, @@ -1786,16 +1815,49 @@ export class Metadata { partSelection, }); const valueSql = chSql`substring(token, position(token, ${{ String: info.separator }}) + ${{ Int32: info.separator.length }})`; - // One branch per key, each with its own LIMIT: the read for a key - // stops after TEXT_INDEX_TOKENS_PER_KEY tokens, so a key with a - // value per row can't slow down or starve the others. `cardinality` - // is the token's row count in the part, so topKWeighted keeps the - // most frequent values among the tokens read. + // Inline keys as SQL-escaped literals, not bind params: ~100 + // per-key params exceed the web client's URL param budget and + // silently switch the request to a multipart body that proxies + // may reject. Keys are ingest-controlled, hence SqlString.escape. + const keyPrefix = (k: string) => + chSql`${{ UNSAFE_RAW_SQL: SqlString.escape(`${k}${info.separator}`) }}`; + // `cardinality` is the token's row count in the part, so + // topKWeighted keeps the most frequent values among the tokens read. + // + // Every read of the index loads each part's sparse index, which has + // one entry per dictionary block. With large blocks that is cheap, so + // each key gets its own read whose LIMIT stops it early, and a key + // with a value per row can't slow down the others. With small blocks + // (ClickHouse's default 512) the sparse index is large, so all keys + // share one read; LIMIT BY bounds their values but not the read. + const blockSize = await this.getTextIndexDictionaryBlockSize({ + databaseName, + tableName, + connectionId, + indexName: info.indexName, + }); + if (blockSize < PER_KEY_TEXT_INDEX_MIN_DICTIONARY_BLOCK_SIZE) { + sqlBranches.push(chSql` + SELECT ${{ String: columnName }} AS column, + key, + topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(v, cardinality) AS value + FROM ( + SELECT substring(token, 1, position(token, ${{ String: info.separator }}) - 1) AS key, + ${valueSql} AS v, + cardinality + FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) + WHERE ${partsFilter} + AND (${concatChSql( + ' OR ', + info.keys.map(k => chSql`startsWith(token, ${keyPrefix(k)})`), + )}) + AND v != '' + LIMIT ${{ UNSAFE_RAW_SQL: String(TEXT_INDEX_TOKENS_PER_KEY) }} BY key + ) + GROUP BY key`); + continue; + } for (const k of info.keys) { - // Inline keys as SQL-escaped literals, not bind params: ~100 - // per-key params exceed the web client's URL param budget and - // silently switch the request to a multipart body that proxies - // may reject. Keys are ingest-controlled, hence SqlString.escape. sqlBranches.push(chSql` SELECT ${{ String: columnName }} AS column, ${{ UNSAFE_RAW_SQL: SqlString.escape(k) }} AS key, @@ -1804,7 +1866,7 @@ export class Metadata { SELECT ${valueSql} AS v, cardinality FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) WHERE ${partsFilter} - AND startsWith(token, ${{ UNSAFE_RAW_SQL: SqlString.escape(`${k}${info.separator}`) }}) + AND startsWith(token, ${keyPrefix(k)}) AND v != '' LIMIT ${{ UNSAFE_RAW_SQL: String(TEXT_INDEX_TOKENS_PER_KEY) }} )`); @@ -1819,8 +1881,8 @@ export class Metadata { connectionId, clickhouse_settings: { ...this.getClickHouseSettings(), - // The per-key LIMIT bounds the read. A row cap would stop the - // sorted dictionary read partway and leave later keys empty. + // A row cap would stop the sorted dictionary read partway and + // leave the keys that sort after it with no values. max_rows_to_read: '0', }, abort_signal: signal, diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index 7c9307845..a79601e5c 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -1443,6 +1443,41 @@ export type TextIndexTokenizer = } | { type: 'array' }; +/** Reads a setting from `system.tables.engine_full`, which ends with the SETTINGS clause. */ +export function getEngineSetting(engineFull: string, name: string) { + const settingsClause = engineFull.match(/ SETTINGS (.*)$/)?.[1] ?? ''; + return splitAndTrimWithBracket(settingsClause) + .map(setting => setting.split(' = ')) + .find(([key]) => key === name)?.[1]; +} + +const DEFAULT_TEXT_INDEX_DICTIONARY_BLOCK_SIZE = 512; + +/** + * A text index's dictionary block size: the index argument, else the table's + * `text_index_dictionary_block_size`, else the server's, else ClickHouse's 512. + */ +export function getTextIndexDictionaryBlockSize({ + typeFull, + engineFull, + serverMergeTreeSettings, +}: { + typeFull?: string; + engineFull?: string; + serverMergeTreeSettings?: Map; +}): number { + const value = + typeFull?.match(/\bdictionary_block_size\s*=\s*(\d+)/)?.[1] ?? + (engineFull + ? getEngineSetting(engineFull, 'text_index_dictionary_block_size') + : undefined) ?? + serverMergeTreeSettings?.get('text_index_dictionary_block_size'); + const size = Number(value); + return Number.isInteger(size) && size > 0 + ? size + : DEFAULT_TEXT_INDEX_DICTIONARY_BLOCK_SIZE; +} + /** * Parses the tokenizer and any associated tokenizer parameters from a text index type definition. * From 94ecf87dcea4b0d3a045da096172ab7f23f8eb75 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:04:13 -0500 Subject: [PATCH 14/18] perf: rank small-block text index values over every token read --- .changeset/topk-text-index-values.md | 19 +++++++++---------- .../src/__tests__/metadata.test.ts | 3 ++- packages/common-utils/src/core/metadata.ts | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index f5dd4380c..9730d039c 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -7,16 +7,15 @@ perf: read text index values in batches of 10 keys, most frequent first "All values" lookups that read a text index (Map key=value indexes and text indexes on plain columns) read every token of up to 100 keys in one query, so one key with a value per row (request IDs, UUIDs) slowed every other key down. -These lookups now run in parallel queries of at most 10 keys, and keep at most -20,000 tokens per key. Values come from `topKWeighted(limit)` weighted by each -token's row count, so a capped list holds the most common values, most common -first. +These lookups now run in parallel queries of at most 10 keys. Values come from +`topKWeighted(limit)` weighted by each token's row count, so a capped list holds +the most common values, most common first. Map key=value indexes with large dictionary blocks (4096 or more, e.g. `dictionary_block_size = 8192` as the seed schemas now use) read each key -separately, so a high-cardinality key stops after its 20,000 tokens. Indexes with -smaller blocks, including ClickHouse's default of 512, read all keys of a batch -in one pass: every read loads each part's sparse index, which is large with small -blocks, so one read per key would multiply that cost. The block size comes from -the index definition, then the table's `text_index_dictionary_block_size`, then -the server's. +separately and stop after 20,000 tokens, so a high-cardinality key can't hold up +the others. Indexes with smaller blocks, including ClickHouse's default of 512, +read all keys of a batch in one pass: every read loads each part's sparse index, +which is large with small blocks, so one read per key would multiply that cost. +The block size comes from the index definition, then the table's +`text_index_dictionary_block_size`, then the server's. diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 43395d700..29ceef9a2 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1461,7 +1461,8 @@ describe('Metadata', () => { expect(sql).toContain( "startsWith(token, 'requestId=') OR startsWith(token, 'region=')", ); - expect(sql).toContain('LIMIT 20000 BY key'); + expect(sql).not.toContain('BY key\n'); + expect(sql).not.toMatch(/LIMIT \d+/); expect(sql).toContain('GROUP BY key'); expect(call.clickhouse_settings.max_rows_to_read).toBe('0'); }); diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index e56ae9fcc..eb530c32a 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -1829,7 +1829,8 @@ export class Metadata { // each key gets its own read whose LIMIT stops it early, and a key // with a value per row can't slow down the others. With small blocks // (ClickHouse's default 512) the sparse index is large, so all keys - // share one read; LIMIT BY bounds their values but not the read. + // share one read. That read can't stop early per key, so topKWeighted + // sees every token and ranks by the full counts. const blockSize = await this.getTextIndexDictionaryBlockSize({ databaseName, tableName, @@ -1852,7 +1853,6 @@ export class Metadata { info.keys.map(k => chSql`startsWith(token, ${keyPrefix(k)})`), )}) AND v != '' - LIMIT ${{ UNSAFE_RAW_SQL: String(TEXT_INDEX_TOKENS_PER_KEY) }} BY key ) GROUP BY key`); continue; From 45a166f5e11e9069def5562e24c8ca3565afcf3d Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:41:07 -0500 Subject: [PATCH 15/18] perf: keep main's batching for small-block Map text index values --- .changeset/topk-text-index-values.md | 21 ++++---- .../src/__tests__/metadata.test.ts | 4 +- packages/common-utils/src/core/metadata.ts | 50 +++++++++++++++---- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index 9730d039c..055b3c83c 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -2,20 +2,21 @@ '@hyperdx/common-utils': patch --- -perf: read text index values in batches of 10 keys, most frequent first +perf: show the most frequent text index values, and read large-block Map +indexes per key "All values" lookups that read a text index (Map key=value indexes and text -indexes on plain columns) read every token of up to 100 keys in one query, so -one key with a value per row (request IDs, UUIDs) slowed every other key down. -These lookups now run in parallel queries of at most 10 keys. Values come from -`topKWeighted(limit)` weighted by each token's row count, so a capped list holds -the most common values, most common first. +indexes on plain columns) kept the alphabetically first values. Values now come +from `topKWeighted(limit)` weighted by each token's row count, so a capped list +holds the most common values, most common first. Map key=value indexes with large dictionary blocks (4096 or more, e.g. `dictionary_block_size = 8192` as the seed schemas now use) read each key -separately and stop after 20,000 tokens, so a high-cardinality key can't hold up -the others. Indexes with smaller blocks, including ClickHouse's default of 512, -read all keys of a batch in one pass: every read loads each part's sparse index, +separately, in parallel queries of at most 10 keys, and stop after 20,000 tokens +per key, so a key with a value per row (request IDs, UUIDs) can't hold up the +others. Indexes with smaller blocks, including ClickHouse's default of 512, keep +one read for all keys as before: every read loads each part's sparse index, which is large with small blocks, so one read per key would multiply that cost. The block size comes from the index definition, then the table's -`text_index_dictionary_block_size`, then the server's. +`text_index_dictionary_block_size`, then the server's. Text indexes on plain +columns run in parallel queries of at most 10 columns. diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 29ceef9a2..e5ad161bc 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1577,9 +1577,9 @@ describe('Metadata', () => { it.each([ [8192, [10, 10, 5]], - [512, [1, 1, 1]], + [512, [1]], ])( - 'splits Map text index values into queries of at most 10 keys (dictionary blocks of %d)', + 'batches Map text index values by dictionary block size (%d)', async (blockSize, expectedBranchCounts) => { setupDefaultLogsSchema(); withDictionaryBlockSize(blockSize); diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index eb530c32a..9289c140f 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -83,7 +83,8 @@ export const GET_ALL_KEY_VALUES_CHUNK_SIZE = 100; // so a key with more distinct values than this gets the most frequent among // its alphabetically first ones. Keep it above "Load more"'s 10,000 values. const TEXT_INDEX_TOKENS_PER_KEY = 20_000; -// Keys per text index values query; see dispatchKeyValueQueries. +// Keys per text index values query when each key gets its own read, and +// columns per plain-column text index query; see dispatchKeyValueQueries. const TEXT_INDEX_KEYS_PER_QUERY = 10; // Smallest dictionary block size that gets one read per key; see // getMapTextIndexKeyValues. @@ -1786,6 +1787,7 @@ export class Metadata { tableName, connectionId, queryOptions, + perKey, maxParts, partSelection, dateRange, @@ -1793,6 +1795,8 @@ export class Metadata { signal, }: TableConnection & { queryOptions: TextIndexMapColumnQueryOptions; + /** One read per key instead of one per column; see the comment below. */ + perKey: boolean; /** null reads every part in the time range. */ maxParts: number | null; partSelection: MapKeyIndexPartSelection; @@ -1801,7 +1805,7 @@ export class Metadata { signal?: AbortSignal; }): Promise { const queryOptionsHash = objectHash(queryOptions); - const cacheKey = `${databaseName}.${tableName}.${connectionId}.${dateRange[0].toString()}.${dateRange[1].toString()}.${queryOptionsHash}.${maxParts}.${partSelection}.${timestampValueExpression}.getMapTextIndexKeyValues`; + const cacheKey = `${databaseName}.${tableName}.${connectionId}.${dateRange[0].toString()}.${dateRange[1].toString()}.${queryOptionsHash}.${perKey}.${maxParts}.${partSelection}.${timestampValueExpression}.getMapTextIndexKeyValues`; return this.cache.getOrFetch(cacheKey, async () => { try { const sqlBranches: Array = []; @@ -1831,13 +1835,7 @@ export class Metadata { // (ClickHouse's default 512) the sparse index is large, so all keys // share one read. That read can't stop early per key, so topKWeighted // sees every token and ranks by the full counts. - const blockSize = await this.getTextIndexDictionaryBlockSize({ - databaseName, - tableName, - connectionId, - indexName: info.indexName, - }); - if (blockSize < PER_KEY_TEXT_INDEX_MIN_DICTIONARY_BLOCK_SIZE) { + if (!perKey) { sqlBranches.push(chSql` SELECT ${{ String: columnName }} AS column, key, @@ -3118,9 +3116,22 @@ export class Metadata { // fire all the kv fetch queries const promises: Array> = []; - // Text index lookups run in small parallel batches, so a slow, - // high-cardinality key holds up only its own batch's values. + // Map columns whose index has large dictionary blocks read each key + // separately, in small parallel batches, so a slow, high-cardinality key + // holds up only its own batch's values. The rest share one read of all + // their keys; see getMapTextIndexKeyValues. + const sharedReadOptions: TextIndexMapColumnQueryOptions = new Map(); for (const [column, options] of mapTextIndexQueryOptions) { + const blockSize = await this.getTextIndexDictionaryBlockSize({ + databaseName, + tableName, + connectionId, + indexName: options.indexName, + }); + if (blockSize < PER_KEY_TEXT_INDEX_MIN_DICTIONARY_BLOCK_SIZE) { + sharedReadOptions.set(column, options); + continue; + } for (const keys of chunk(options.keys, TEXT_INDEX_KEYS_PER_QUERY)) { promises.push( this.getMapTextIndexKeyValues({ @@ -3128,6 +3139,7 @@ export class Metadata { tableName, connectionId, queryOptions: new Map([[column, { ...options, keys }]]), + perKey: true, maxParts: mapKeyIndexMaxParts, partSelection: mapKeyIndexPartSelection, dateRange, @@ -3137,6 +3149,22 @@ export class Metadata { ); } } + if (sharedReadOptions.size > 0) { + promises.push( + this.getMapTextIndexKeyValues({ + databaseName, + tableName, + connectionId, + queryOptions: sharedReadOptions, + perKey: false, + maxParts: mapKeyIndexMaxParts, + partSelection: mapKeyIndexPartSelection, + dateRange, + timestampValueExpression, + signal, + }), + ); + } for (const entries of chunk( [...nativeTextIndexQueryOptions], TEXT_INDEX_KEYS_PER_QUERY, From 09028d6bb9ac928597bf4daedf378e608747e27d Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:50:26 -0500 Subject: [PATCH 16/18] perf: keep main's query and row cap for small-block Map text index values --- .changeset/topk-text-index-values.md | 33 +++++----- .../src/__tests__/metadata.test.ts | 60 +++++++++++-------- packages/common-utils/src/core/metadata.ts | 51 +++++++++------- 3 files changed, 82 insertions(+), 62 deletions(-) diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index 055b3c83c..99adf0b18 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -2,21 +2,22 @@ '@hyperdx/common-utils': patch --- -perf: show the most frequent text index values, and read large-block Map -indexes per key +perf: read large-block Map text index values per key, most frequent first -"All values" lookups that read a text index (Map key=value indexes and text -indexes on plain columns) kept the alphabetically first values. Values now come -from `topKWeighted(limit)` weighted by each token's row count, so a capped list -holds the most common values, most common first. +"All values" lookups on Map key=value text indexes with large dictionary blocks +(4096 or more, e.g. `dictionary_block_size = 8192` as the seed schemas now use) +now read each key separately, in parallel queries of at most 10 keys, and stop +after 20,000 tokens per key, so a key with a value per row (request IDs, UUIDs) +can't hold up the others. Values come from `topKWeighted(limit)` weighted by +each token's row count, so a capped list holds the most common values, most +common first. -Map key=value indexes with large dictionary blocks (4096 or more, e.g. -`dictionary_block_size = 8192` as the seed schemas now use) read each key -separately, in parallel queries of at most 10 keys, and stop after 20,000 tokens -per key, so a key with a value per row (request IDs, UUIDs) can't hold up the -others. Indexes with smaller blocks, including ClickHouse's default of 512, keep -one read for all keys as before: every read loads each part's sparse index, -which is large with small blocks, so one read per key would multiply that cost. -The block size comes from the index definition, then the table's -`text_index_dictionary_block_size`, then the server's. Text indexes on plain -columns run in parallel queries of at most 10 columns. +Indexes with smaller blocks, including ClickHouse's default of 512, keep the +previous query: one read for all keys, the alphabetically first values, and the +`max_rows_to_read` cap. Every read loads each part's sparse index, which is +large with small blocks, so one read per key would multiply that cost. The block +size comes from the index definition, then the table's +`text_index_dictionary_block_size`, then the server's. + +Text indexes on plain columns now return the most frequent values with +`topKWeighted`, in parallel queries of at most 10 columns. diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index e5ad161bc..272d6ca3f 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -2,6 +2,7 @@ import { ColumnMeta } from '@/clickhouse'; import { ClickhouseClient } from '@/clickhouse/node'; import { COLUMNS_CACHE_TTL_MS, + DEFAULT_METADATA_MAX_ROWS_TO_READ, GET_ALL_KEY_VALUES_CHUNK_SIZE, Metadata, MetadataCache, @@ -1461,10 +1462,12 @@ describe('Metadata', () => { expect(sql).toContain( "startsWith(token, 'requestId=') OR startsWith(token, 'region=')", ); - expect(sql).not.toContain('BY key\n'); - expect(sql).not.toMatch(/LIMIT \d+/); - expect(sql).toContain('GROUP BY key'); - expect(call.clickhouse_settings.max_rows_to_read).toBe('0'); + expect(sql).toContain('groupUniqArray('); + expect(sql).toContain('GROUP BY column, key'); + expect(call.clickhouse_settings).toMatchObject({ + max_rows_to_read: String(DEFAULT_METADATA_MAX_ROWS_TO_READ), + read_overflow_mode: 'break', + }); }); it.each([ @@ -1474,6 +1477,8 @@ describe('Metadata', () => { 'reads the %s text index without a row cap, even when one is configured', async (_, keyExpression) => { setupDefaultLogsSchema(); + // Small-block Map indexes keep the cap; see the one-pass test. + withDictionaryBlockSize(8192); jest .spyOn(metadata, 'getClickHouseSettings') .mockReturnValue({ max_rows_to_read: '1000' }); @@ -1926,27 +1931,34 @@ describe('Metadata', () => { // param dump when a test fails. const MAX_VALUES = 7777; - it('threads maxValuesPerKey into the map text-index query', async () => { - setupDefaultLogsSchema(); - - await metadata.getAllKeyValues({ - ...baseArgs, - keyExpressions: ["LogAttributes['requestId']"], - maxValuesPerKey: MAX_VALUES, - }); + it.each([ + [8192, 'topKWeighted'], + [512, 'groupUniqArray'], + ])( + 'threads maxValuesPerKey into the map text-index query (dictionary blocks of %d)', + async (blockSize, aggregate) => { + setupDefaultLogsSchema(); + withDictionaryBlockSize(blockSize); + + await metadata.getAllKeyValues({ + ...baseArgs, + keyExpressions: ["LogAttributes['requestId']"], + maxValuesPerKey: MAX_VALUES, + }); - const mapTextIndexCall = ( - mockClickhouseClient.query as jest.Mock - ).mock.calls.find( - (c: any[]) => - typeof c[0].query === 'string' && - c[0].query.includes('startsWith(token,'), - ); - expect(mapTextIndexCall).toBeDefined(); - expect(mapTextIndexCall![0].query).toContain( - `topKWeighted(${MAX_VALUES})(`, - ); - }); + const mapTextIndexCall = ( + mockClickhouseClient.query as jest.Mock + ).mock.calls.find( + (c: any[]) => + typeof c[0].query === 'string' && + c[0].query.includes('startsWith(token,'), + ); + expect(mapTextIndexCall).toBeDefined(); + expect(mapTextIndexCall![0].query).toContain( + `${aggregate}(${MAX_VALUES})(`, + ); + }, + ); it('threads maxValuesPerKey into the native text-index query', async () => { setupDefaultLogsSchema(); diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 9289c140f..74fca2449 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -1825,34 +1825,28 @@ export class Metadata { // may reject. Keys are ingest-controlled, hence SqlString.escape. const keyPrefix = (k: string) => chSql`${{ UNSAFE_RAW_SQL: SqlString.escape(`${k}${info.separator}`) }}`; - // `cardinality` is the token's row count in the part, so - // topKWeighted keeps the most frequent values among the tokens read. - // // Every read of the index loads each part's sparse index, which has // one entry per dictionary block. With large blocks that is cheap, so // each key gets its own read whose LIMIT stops it early, and a key - // with a value per row can't slow down the others. With small blocks + // with a value per row can't slow down the others; `cardinality` is + // the token's row count in the part, so topKWeighted keeps the most + // frequent values among the tokens read. With small blocks // (ClickHouse's default 512) the sparse index is large, so all keys - // share one read. That read can't stop early per key, so topKWeighted - // sees every token and ranks by the full counts. + // share one read, as on main. if (!perKey) { sqlBranches.push(chSql` - SELECT ${{ String: columnName }} AS column, - key, - topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(v, cardinality) AS value - FROM ( - SELECT substring(token, 1, position(token, ${{ String: info.separator }}) - 1) AS key, - ${valueSql} AS v, - cardinality + SELECT ${{ String: columnName }} AS column, + substring(token, 1, position(token, ${{ String: info.separator }}) - 1) AS key, + groupUniqArray(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(info.limit, 'limit') }})(${valueSql}) AS value FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: info.indexName }}) WHERE ${partsFilter} AND (${concatChSql( ' OR ', info.keys.map(k => chSql`startsWith(token, ${keyPrefix(k)})`), )}) - AND v != '' - ) - GROUP BY key`); + AND ${valueSql} != '' + GROUP BY column, key + `); continue; } for (const k of info.keys) { @@ -1877,12 +1871,25 @@ export class Metadata { query: sql.sql, query_params: sql.params, connectionId, - clickhouse_settings: { - ...this.getClickHouseSettings(), - // A row cap would stop the sorted dictionary read partway and - // leave the keys that sort after it with no values. - max_rows_to_read: '0', - }, + clickhouse_settings: perKey + ? { + ...this.getClickHouseSettings(), + // The per-key LIMIT bounds each read. A row cap would stop + // the sorted dictionary read partway and leave the keys that + // sort after it with no values. + max_rows_to_read: '0', + } + : { + // shortcut: bounds the shared read, but keys that sort after + // a high-cardinality key can come back with no values; drop + // it if that matters more than the read's cost. + max_rows_to_read: String( + this.getClickHouseSettings().max_rows_to_read ?? + DEFAULT_METADATA_MAX_ROWS_TO_READ, + ), + read_overflow_mode: 'break', + ...this.getClickHouseSettings(), + }, abort_signal: signal, }) .then(res => From 8f0b114fae01605e85a3325d19015b93c67a2a83 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:51:57 -0500 Subject: [PATCH 17/18] perf: batch small-block Map text index values by 10 keys --- .changeset/topk-text-index-values.md | 8 +-- .../src/__tests__/metadata.test.ts | 2 +- packages/common-utils/src/core/metadata.ts | 51 ++++++------------- 3 files changed, 21 insertions(+), 40 deletions(-) diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index 99adf0b18..11d50a120 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -13,9 +13,11 @@ each token's row count, so a capped list holds the most common values, most common first. Indexes with smaller blocks, including ClickHouse's default of 512, keep the -previous query: one read for all keys, the alphabetically first values, and the -`max_rows_to_read` cap. Every read loads each part's sparse index, which is -large with small blocks, so one read per key would multiply that cost. The block +previous query shape (one read for all of a query's keys, the alphabetically +first values, and the `max_rows_to_read` cap), now in parallel queries of at +most 10 keys, so the cap's cut-off affects at most 9 other keys. Every read loads +each part's sparse index, which is large with small blocks, so one read per key +would multiply that cost. The block size comes from the index definition, then the table's `text_index_dictionary_block_size`, then the server's. diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 272d6ca3f..197d484ff 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1582,7 +1582,7 @@ describe('Metadata', () => { it.each([ [8192, [10, 10, 5]], - [512, [1]], + [512, [1, 1, 1]], ])( 'batches Map text index values by dictionary block size (%d)', async (blockSize, expectedBranchCounts) => { diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 74fca2449..dca6609b7 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -83,8 +83,7 @@ export const GET_ALL_KEY_VALUES_CHUNK_SIZE = 100; // so a key with more distinct values than this gets the most frequent among // its alphabetically first ones. Keep it above "Load more"'s 10,000 values. const TEXT_INDEX_TOKENS_PER_KEY = 20_000; -// Keys per text index values query when each key gets its own read, and -// columns per plain-column text index query; see dispatchKeyValueQueries. +// Keys or columns per text index values query; see dispatchKeyValueQueries. const TEXT_INDEX_KEYS_PER_QUERY = 10; // Smallest dictionary block size that gets one read per key; see // getMapTextIndexKeyValues. @@ -1831,8 +1830,8 @@ export class Metadata { // with a value per row can't slow down the others; `cardinality` is // the token's row count in the part, so topKWeighted keeps the most // frequent values among the tokens read. With small blocks - // (ClickHouse's default 512) the sparse index is large, so all keys - // share one read, as on main. + // (ClickHouse's default 512) the sparse index is large, so the + // batch's keys share one read. if (!perKey) { sqlBranches.push(chSql` SELECT ${{ String: columnName }} AS column, @@ -3123,22 +3122,18 @@ export class Metadata { // fire all the kv fetch queries const promises: Array> = []; - // Map columns whose index has large dictionary blocks read each key - // separately, in small parallel batches, so a slow, high-cardinality key - // holds up only its own batch's values. The rest share one read of all - // their keys; see getMapTextIndexKeyValues. - const sharedReadOptions: TextIndexMapColumnQueryOptions = new Map(); + // Text index lookups run in small parallel batches, so a slow, + // high-cardinality key holds up only its own batch's values. Within a + // batch, large dictionary blocks get one read per key; see + // getMapTextIndexKeyValues. for (const [column, options] of mapTextIndexQueryOptions) { - const blockSize = await this.getTextIndexDictionaryBlockSize({ - databaseName, - tableName, - connectionId, - indexName: options.indexName, - }); - if (blockSize < PER_KEY_TEXT_INDEX_MIN_DICTIONARY_BLOCK_SIZE) { - sharedReadOptions.set(column, options); - continue; - } + const perKey = + (await this.getTextIndexDictionaryBlockSize({ + databaseName, + tableName, + connectionId, + indexName: options.indexName, + })) >= PER_KEY_TEXT_INDEX_MIN_DICTIONARY_BLOCK_SIZE; for (const keys of chunk(options.keys, TEXT_INDEX_KEYS_PER_QUERY)) { promises.push( this.getMapTextIndexKeyValues({ @@ -3146,7 +3141,7 @@ export class Metadata { tableName, connectionId, queryOptions: new Map([[column, { ...options, keys }]]), - perKey: true, + perKey, maxParts: mapKeyIndexMaxParts, partSelection: mapKeyIndexPartSelection, dateRange, @@ -3156,22 +3151,6 @@ export class Metadata { ); } } - if (sharedReadOptions.size > 0) { - promises.push( - this.getMapTextIndexKeyValues({ - databaseName, - tableName, - connectionId, - queryOptions: sharedReadOptions, - perKey: false, - maxParts: mapKeyIndexMaxParts, - partSelection: mapKeyIndexPartSelection, - dateRange, - timestampValueExpression, - signal, - }), - ); - } for (const entries of chunk( [...nativeTextIndexQueryOptions], TEXT_INDEX_KEYS_PER_QUERY, From 0d5c6953214fff46db5f66c101d33188654ca7d8 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:03:57 -0500 Subject: [PATCH 18/18] docs: fold the row cap change into the text index values changeset --- .changeset/text-index-values-no-row-cap.md | 11 ----------- .changeset/topk-text-index-values.md | 11 ++++++----- 2 files changed, 6 insertions(+), 16 deletions(-) delete mode 100644 .changeset/text-index-values-no-row-cap.md diff --git a/.changeset/text-index-values-no-row-cap.md b/.changeset/text-index-values-no-row-cap.md deleted file mode 100644 index 103ed81b0..000000000 --- a/.changeset/text-index-values-no-row-cap.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@hyperdx/common-utils': patch ---- - -fix: show values for every key in text index value lookups - -"All values" lookups that read a text index (Map key=value indexes and text -indexes on plain columns) stopped at `max_rows_to_read` with -`read_overflow_mode = 'break'`. The index is read in sorted token order, so once -a high-cardinality key used up the budget, every key that sorts after it came -back with no values and no error. These lookups no longer apply a row cap. diff --git a/.changeset/topk-text-index-values.md b/.changeset/topk-text-index-values.md index 11d50a120..86398f1f3 100644 --- a/.changeset/topk-text-index-values.md +++ b/.changeset/topk-text-index-values.md @@ -10,16 +10,17 @@ now read each key separately, in parallel queries of at most 10 keys, and stop after 20,000 tokens per key, so a key with a value per row (request IDs, UUIDs) can't hold up the others. Values come from `topKWeighted(limit)` weighted by each token's row count, so a capped list holds the most common values, most -common first. +common first. These reads no longer apply `max_rows_to_read`: the index is read +in sorted token order, so the cap left every key that sorts after a +high-cardinality key with no values. Indexes with smaller blocks, including ClickHouse's default of 512, keep the previous query shape (one read for all of a query's keys, the alphabetically first values, and the `max_rows_to_read` cap), now in parallel queries of at most 10 keys, so the cap's cut-off affects at most 9 other keys. Every read loads each part's sparse index, which is large with small blocks, so one read per key -would multiply that cost. The block -size comes from the index definition, then the table's -`text_index_dictionary_block_size`, then the server's. +would multiply that cost. The block size comes from the index definition, then +the table's `text_index_dictionary_block_size`, then the server's. Text indexes on plain columns now return the most frequent values with -`topKWeighted`, in parallel queries of at most 10 columns. +`topKWeighted`, in parallel queries of at most 10 columns, without the row cap.