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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/topk-map-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@hyperdx/common-utils': patch
---

perf: find the most frequent Map keys with topKWeighted instead of grouping
every text index token

On tables whose Map keys include unique values (IDs, hashes), the keys text
index holds millions of distinct tokens, and grouping them all took seconds and
many GiB of memory. Keys now come from `topKWeighted`, which uses a fixed amount
of memory. When the keys fit in its table (3 × the key limit), it picks the
same keys as before.
5 changes: 3 additions & 2 deletions packages/common-utils/src/__tests__/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3189,7 +3189,7 @@ describe('Metadata', () => {
});

it.each(textIndexLookups)(
'ranks keys by row count before the LIMIT on the %s text index path',
'keeps the maxKeys most frequent keys with topKWeighted on the %s text index path',
async (_, textIndexLookup) => {
const md = new Metadata(mockClickhouseClient, new MetadataCache());
jest.spyOn(md, 'getServerVersion').mockResolvedValue([26, 3, 0, 0]);
Expand Down Expand Up @@ -3220,8 +3220,9 @@ describe('Metadata', () => {
.calls[0][0];
expect(query).toContain('FROM mergeTreeTextIndex(');
expect(query).toMatch(
/GROUP BY key HAVING key != ''\s+ORDER BY sum\(cardinality\) DESC, key\s+LIMIT/,
/arrayJoin\(topKWeighted\(2\)\(\w+, cardinality\)\)/,
);
expect(query).not.toContain('GROUP BY');
},
);

Expand Down
27 changes: 15 additions & 12 deletions packages/common-utils/src/core/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1208,13 +1208,14 @@ export class Metadata {
});
const index = textIndexInfo.key.indexName;
// `cardinality` is the token's row count in the part, not a distinct count.
// topKWeighted keeps a fixed-size table instead of grouping every token,
// so millions of one-off keys cost MiBs, not GiBs. While the distinct
// keys fit in that table (3 * maxKeys) it picks the same keys as an exact
// count, apart from ties at the cutoff; the order can differ slightly.
const sql = chSql`
SELECT token AS key
SELECT arrayJoin(topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(maxKeys, 'maxKeys') }})(token, cardinality)) AS key
FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: index }})
WHERE ${partsFilter}
GROUP BY key HAVING key != ''
ORDER BY sum(cardinality) DESC, key
LIMIT ${{ Int32: maxKeys }}`;
WHERE ${partsFilter} AND token != ''`;
try {
const keys = await this.clickhouseClient
.query<'JSON'>({
Expand Down Expand Up @@ -1253,14 +1254,16 @@ export class Metadata {
});
const index = textIndexInfo.kv.indexName;
const separator = textIndexInfo.kv.separator;
// `cardinality` is the token's row count in the part, not a distinct count.
// `cardinality` is the token's row count in the part, not a distinct
// count. See the key text index query for why topKWeighted.
const sql = chSql`
SELECT splitByString(${{ String: separator }}, token)[1] AS key
FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: index }})
WHERE ${partsFilter}
GROUP BY key HAVING key != ''
ORDER BY sum(cardinality) DESC, key
LIMIT ${{ Int32: maxKeys }}`;
SELECT arrayJoin(topKWeighted(${{ UNSAFE_RAW_SQL: inlineNonNegativeInt(maxKeys, 'maxKeys') }})(mapKey, cardinality)) AS key
FROM (
SELECT splitByString(${{ String: separator }}, token)[1] AS mapKey, cardinality
FROM mergeTreeTextIndex(${{ String: databaseName }}, ${{ String: tableName }}, ${{ String: index }})
WHERE ${partsFilter}
)
WHERE mapKey != ''`;
try {
const keys = await this.clickhouseClient
.query<'JSON'>({
Expand Down
Loading