From 8a5fead2fd75db0bef5474697dc6f64213b3f364 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:13:52 -0500 Subject: [PATCH 1/3] perf: share one Map key read between concurrent callers --- .changeset/dedupe-map-keys-reads.md | 10 ++ .../src/__tests__/metadata.test.ts | 33 +++++ packages/common-utils/src/core/metadata.ts | 130 +++++++++++------- 3 files changed, 126 insertions(+), 47 deletions(-) create mode 100644 .changeset/dedupe-map-keys-reads.md diff --git a/.changeset/dedupe-map-keys-reads.md b/.changeset/dedupe-map-keys-reads.md new file mode 100644 index 0000000000..8459fcefc5 --- /dev/null +++ b/.changeset/dedupe-map-keys-reads.md @@ -0,0 +1,10 @@ +--- +'@hyperdx/common-utils': patch +--- + +perf: share one Map key read between concurrent callers + +The search page's filter panel, search bar and group-by each load the same Map +keys when the page opens. Each sent its own text index read, which on large +tables can use several GiB of memory. Concurrent requests for the same keys now +share one query. diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index d8e6121181..6e00688398 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -3224,6 +3224,39 @@ describe('Metadata', () => { ); }, ); + + it.each(textIndexLookups)( + 'shares one read between concurrent callers on the %s text index path', + async (_, textIndexLookup) => { + const md = new Metadata(mockClickhouseClient, new MetadataCache()); + jest.spyOn(md, 'getServerVersion').mockResolvedValue([26, 3, 0, 0]); + jest + .spyOn(md, 'getMapColumnTextIndexes') + .mockResolvedValue(textIndexLookup); + (mockClickhouseClient.query as jest.Mock).mockResolvedValue({ + json: () => Promise.resolve({ data: [{ key: 'http.method' }] }), + }); + const args = { + databaseName: 'default', + tableName: 'otel_logs', + column: 'LogAttributes', + connectionId: 'test_connection', + dateRange: [ + new Date('2024-01-01T00:00:00Z'), + new Date('2024-01-01T01:00:00Z'), + ] as [Date, Date], + timestampValueExpression: 'Timestamp', + }; + + const results = await Promise.all([ + md.getMapKeys(args), + md.getMapKeys(args), + ]); + + expect(results).toEqual([['http.method'], ['http.method']]); + expect(mockClickhouseClient.query).toHaveBeenCalledTimes(1); + }, + ); }); describe('getMapValues', () => { diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 8cc68f9317..6eb9ac386e 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -1117,12 +1117,62 @@ export class Metadata { const cacheKey = conditionSql ? `${unscopedCacheKey}.${objectHash(conditionSql)}.condition` : unscopedCacheKey; - const cachedKeys = this.cache.get(cacheKey); - if (cachedKeys != null) { - return cachedKeys; - } + // getOrFetch shares one in-flight read between concurrent callers (the + // filter panel, search bar and group-by all load fields at once); a text + // index read can take GiBs of memory. A condition changes with variable + // selections, so its entries expire instead of piling up. + return this.cache.getOrFetch( + cacheKey, + () => + this.queryMapKeys({ + databaseName, + tableName, + column, + maxKeys, + connectionId, + metricName, + metadataMVs, + dateRange, + alignedDateRange, + timestampValueExpression, + conditionSql, + cacheKey, + signal, + }), + { ttlMs: conditionSql ? CONDITION_KEYS_CACHE_TTL_MS : undefined }, + ); + } + private async queryMapKeys({ + databaseName, + tableName, + column, + maxKeys, + connectionId, + metricName, + metadataMVs, + dateRange, + alignedDateRange, + timestampValueExpression, + conditionSql, + cacheKey, + signal, + }: { + databaseName: string; + tableName: string; + column: string; + maxKeys: number; + connectionId: string; + metricName?: string; + metadataMVs?: MetadataMaterializedViews; + dateRange: [Date, Date]; + alignedDateRange?: [Date, Date]; + timestampValueExpression?: string; + conditionSql?: string; + cacheKey: string; + signal?: AbortSignal; + }): Promise { const textIndexInfoLookup = await this.getMapColumnTextIndexes({ databaseName, tableName, @@ -1176,10 +1226,7 @@ export class Metadata { }) .then(r => r.json<{ key: string }>()) .then(d => d.data.map(r => r.key).filter(Boolean)); - if (keys.length > 0) { - this.cache.set(cacheKey, keys); - return keys; - } + if (keys.length > 0) return keys; } catch (e) { // The caller gave up; don't start the fallback with a dead signal and // cache its instant rejection under the rollup key. @@ -1225,10 +1272,7 @@ export class Metadata { }) .then(r => r.json<{ key: string }>()) .then(d => d.data.map(r => r.key).filter(Boolean)); - if (keys.length > 0) { - this.cache.set(cacheKey, keys); - return keys; - } + if (keys.length > 0) return keys; } catch (e) { // See the key text index catch above. if (signal?.aborted) throw e; @@ -1403,42 +1447,34 @@ export class Metadata { `; } - const fetchKeys = async () => { - const keys = await this.clickhouseClient - .query<'JSON'>({ - query: sql.sql, - query_params: sql.params, - connectionId, - clickhouse_settings: { - ...this.getClickHouseSettings(), - // Max 15 seconds to get keys - timeout_overflow_mode: 'break', - max_execution_time: 15, - // Set the value to 0 (unlimited) so that the LIMIT is used instead - max_rows_to_read: '0', - }, - abort_signal: signal, - }) - .then(res => res.json<{ keysArr?: string[]; key?: string }>()) - .then(d => { - let output: string[]; - if (strategy === 'groupUniqArrayArray') { - output = d.data[0].keysArr ?? []; - } else { - output = d.data - .map(row => row.key) - .filter((k): k is string => Boolean(k)); - } + return this.clickhouseClient + .query<'JSON'>({ + query: sql.sql, + query_params: sql.params, + connectionId, + clickhouse_settings: { + ...this.getClickHouseSettings(), + // Max 15 seconds to get keys + timeout_overflow_mode: 'break', + max_execution_time: 15, + // Set the value to 0 (unlimited) so that the LIMIT is used instead + max_rows_to_read: '0', + }, + abort_signal: signal, + }) + .then(res => res.json<{ keysArr?: string[]; key?: string }>()) + .then(d => { + let output: string[]; + if (strategy === 'groupUniqArrayArray') { + output = d.data[0].keysArr ?? []; + } else { + output = d.data + .map(row => row.key) + .filter((k): k is string => Boolean(k)); + } - return output.filter(r => r); - }); - return keys; - }; - // A condition changes with variable selections; expire its entries so - // they don't pile up. - return this.cache.getOrFetch(cacheKey, fetchKeys, { - ttlMs: conditionSql ? CONDITION_KEYS_CACHE_TTL_MS : undefined, - }); + return output.filter(r => r); + }); } async getJSONKeys({ From e9d9dd0082bc9ff84b473b74384d6892c862de10 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:46:26 -0500 Subject: [PATCH 2/3] fix: don't cache rollup Map keys under a key without a date range --- .changeset/dedupe-map-keys-reads.md | 8 +- .../src/__tests__/metadata.test.ts | 60 +++++++ packages/common-utils/src/core/metadata.ts | 151 ++++++++++-------- 3 files changed, 147 insertions(+), 72 deletions(-) diff --git a/.changeset/dedupe-map-keys-reads.md b/.changeset/dedupe-map-keys-reads.md index 8459fcefc5..fbabf73ff7 100644 --- a/.changeset/dedupe-map-keys-reads.md +++ b/.changeset/dedupe-map-keys-reads.md @@ -4,7 +4,7 @@ perf: share one Map key read between concurrent callers -The search page's filter panel, search bar and group-by each load the same Map -keys when the page opens. Each sent its own text index read, which on large -tables can use several GiB of memory. Concurrent requests for the same keys now -share one query. +Two Map key lookups with the same arguments, made while the first was still +running, each sent their own query. On large tables a Map key text index read +can use several GiB of memory. Concurrent lookups for the same keys now share +one query. diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 6e00688398..bc5db2789c 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -3146,6 +3146,66 @@ describe('Metadata', () => { expect(mockClickhouseClient.query).toHaveBeenCalledTimes(2); }); + + it('reads the rollup per range when timestampValueExpression is missing', async () => { + const md = buildMetadata(); + jest + .spyOn(md, 'getMapColumnTextIndexes') + .mockResolvedValue(new Map() as TextIndexInfoLookup); + (mockClickhouseClient.query as jest.Mock) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ data: [{ Key: 'first.range' }] }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ data: [{ Key: 'second.range' }] }), + }); + + const { + timestampValueExpression: _timestampValueExpression, + ...noTsArgs + } = baseArgs; + const secondRange: [Date, Date] = [ + new Date('2026-05-12T10:00:00Z'), + new Date('2026-05-12T11:00:00Z'), + ]; + + expect(await md.getMapKeys(noTsArgs)).toEqual(['first.range']); + expect( + await md.getMapKeys({ ...noTsArgs, dateRange: secondRange }), + ).toEqual(['second.range']); + }); + + it('does not share an in-flight rollup read across ranges', async () => { + const md = buildMetadata(); + jest + .spyOn(md, 'getMapColumnTextIndexes') + .mockResolvedValue(new Map() as TextIndexInfoLookup); + (mockClickhouseClient.query as jest.Mock) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ data: [{ Key: 'first.range' }] }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ data: [{ Key: 'second.range' }] }), + }); + + const { + timestampValueExpression: _timestampValueExpression, + ...noTsArgs + } = baseArgs; + + const results = await Promise.all([ + md.getMapKeys(noTsArgs), + md.getMapKeys({ + ...noTsArgs, + dateRange: [ + new Date('2026-05-12T10:00:00Z'), + new Date('2026-05-12T11:00:00Z'), + ], + }), + ]); + + expect(results).toEqual([['first.range'], ['second.range']]); + }); }); describe('getMapKeys (text index paths)', () => { diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 6eb9ac386e..20939b13e6 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -317,25 +317,21 @@ export class MetadataCache { return cachedValue; } - // Check if there is a pending query - if (this.pendingQueries.has(key)) { - return this.pendingQueries.get(key)!; - } + return this.dedupe(key, async () => { + const result = await query(); + this.set(key, result, ttlMs); + return result; + }); + } - // If no pending query, initiate the new query - const queryPromise = query(); + /** Shares one in-flight call per key between concurrent callers, without caching its result. */ + dedupe(key: string, query: () => Promise): Promise { + const pending = this.pendingQueries.get(key); + if (pending) return pending; - // Store the pending query promise + const queryPromise = query().finally(() => this.pendingQueries.delete(key)); this.pendingQueries.set(key, queryPromise); - - try { - const result = await queryPromise; - this.set(key, result, ttlMs); - return result; - } finally { - // Clean up the pending query map - this.pendingQueries.delete(key); - } + return queryPromise; } set(key: string, value: T, ttlMs?: number) { @@ -1117,30 +1113,35 @@ export class Metadata { const cacheKey = conditionSql ? `${unscopedCacheKey}.${objectHash(conditionSql)}.condition` : unscopedCacheKey; + const cachedKeys = this.cache.get(cacheKey); - // getOrFetch shares one in-flight read between concurrent callers (the - // filter panel, search bar and group-by all load fields at once); a text - // index read can take GiBs of memory. A condition changes with variable - // selections, so its entries expire instead of piling up. - return this.cache.getOrFetch( - cacheKey, - () => - this.queryMapKeys({ - databaseName, - tableName, - column, - maxKeys, - connectionId, - metricName, - metadataMVs, - dateRange, - alignedDateRange, - timestampValueExpression, - conditionSql, - cacheKey, - signal, - }), - { ttlMs: conditionSql ? CONDITION_KEYS_CACHE_TTL_MS : undefined }, + if (cachedKeys != null) { + return cachedKeys; + } + + // Concurrent callers share one read, since a text index read can take + // GiBs of memory. The in-flight key carries the aligned range: without + // timestampValueExpression cacheKey has none, but a rollup answer depends + // on it. + const inFlightKey = alignedDateRange + ? `${cacheKey}.${alignedDateRange[0].getTime()}.${alignedDateRange[1].getTime()}.inFlight` + : `${cacheKey}.inFlight`; + return this.cache.dedupe(inFlightKey, () => + this.queryMapKeys({ + databaseName, + tableName, + column, + maxKeys, + connectionId, + metricName, + metadataMVs, + dateRange, + alignedDateRange, + timestampValueExpression, + conditionSql, + cacheKey, + signal, + }), ); } @@ -1226,7 +1227,10 @@ export class Metadata { }) .then(r => r.json<{ key: string }>()) .then(d => d.data.map(r => r.key).filter(Boolean)); - if (keys.length > 0) return keys; + if (keys.length > 0) { + this.cache.set(cacheKey, keys); + return keys; + } } catch (e) { // The caller gave up; don't start the fallback with a dead signal and // cache its instant rejection under the rollup key. @@ -1272,7 +1276,10 @@ export class Metadata { }) .then(r => r.json<{ key: string }>()) .then(d => d.data.map(r => r.key).filter(Boolean)); - if (keys.length > 0) return keys; + if (keys.length > 0) { + this.cache.set(cacheKey, keys); + return keys; + } } catch (e) { // See the key text index catch above. if (signal?.aborted) throw e; @@ -1447,34 +1454,42 @@ export class Metadata { `; } - return this.clickhouseClient - .query<'JSON'>({ - query: sql.sql, - query_params: sql.params, - connectionId, - clickhouse_settings: { - ...this.getClickHouseSettings(), - // Max 15 seconds to get keys - timeout_overflow_mode: 'break', - max_execution_time: 15, - // Set the value to 0 (unlimited) so that the LIMIT is used instead - max_rows_to_read: '0', - }, - abort_signal: signal, - }) - .then(res => res.json<{ keysArr?: string[]; key?: string }>()) - .then(d => { - let output: string[]; - if (strategy === 'groupUniqArrayArray') { - output = d.data[0].keysArr ?? []; - } else { - output = d.data - .map(row => row.key) - .filter((k): k is string => Boolean(k)); - } + const fetchKeys = async () => { + const keys = await this.clickhouseClient + .query<'JSON'>({ + query: sql.sql, + query_params: sql.params, + connectionId, + clickhouse_settings: { + ...this.getClickHouseSettings(), + // Max 15 seconds to get keys + timeout_overflow_mode: 'break', + max_execution_time: 15, + // Set the value to 0 (unlimited) so that the LIMIT is used instead + max_rows_to_read: '0', + }, + abort_signal: signal, + }) + .then(res => res.json<{ keysArr?: string[]; key?: string }>()) + .then(d => { + let output: string[]; + if (strategy === 'groupUniqArrayArray') { + output = d.data[0].keysArr ?? []; + } else { + output = d.data + .map(row => row.key) + .filter((k): k is string => Boolean(k)); + } - return output.filter(r => r); - }); + return output.filter(r => r); + }); + return keys; + }; + // A condition changes with variable selections; expire its entries so + // they don't pile up. + return this.cache.getOrFetch(cacheKey, fetchKeys, { + ttlMs: conditionSql ? CONDITION_KEYS_CACHE_TTL_MS : undefined, + }); } async getJSONKeys({ From 698c541fcea347cd935fd2ffd9829231ed83fdae Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:52:15 -0500 Subject: [PATCH 3/3] Revert "fix: don't cache rollup Map keys under a key without a date range" This reverts commit e9d9dd0082bc9ff84b473b74384d6892c862de10. --- .changeset/dedupe-map-keys-reads.md | 8 +- .../src/__tests__/metadata.test.ts | 60 ------- packages/common-utils/src/core/metadata.ts | 151 ++++++++---------- 3 files changed, 72 insertions(+), 147 deletions(-) diff --git a/.changeset/dedupe-map-keys-reads.md b/.changeset/dedupe-map-keys-reads.md index fbabf73ff7..8459fcefc5 100644 --- a/.changeset/dedupe-map-keys-reads.md +++ b/.changeset/dedupe-map-keys-reads.md @@ -4,7 +4,7 @@ perf: share one Map key read between concurrent callers -Two Map key lookups with the same arguments, made while the first was still -running, each sent their own query. On large tables a Map key text index read -can use several GiB of memory. Concurrent lookups for the same keys now share -one query. +The search page's filter panel, search bar and group-by each load the same Map +keys when the page opens. Each sent its own text index read, which on large +tables can use several GiB of memory. Concurrent requests for the same keys now +share one query. diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index bc5db2789c..6e00688398 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -3146,66 +3146,6 @@ describe('Metadata', () => { expect(mockClickhouseClient.query).toHaveBeenCalledTimes(2); }); - - it('reads the rollup per range when timestampValueExpression is missing', async () => { - const md = buildMetadata(); - jest - .spyOn(md, 'getMapColumnTextIndexes') - .mockResolvedValue(new Map() as TextIndexInfoLookup); - (mockClickhouseClient.query as jest.Mock) - .mockResolvedValueOnce({ - json: () => Promise.resolve({ data: [{ Key: 'first.range' }] }), - }) - .mockResolvedValueOnce({ - json: () => Promise.resolve({ data: [{ Key: 'second.range' }] }), - }); - - const { - timestampValueExpression: _timestampValueExpression, - ...noTsArgs - } = baseArgs; - const secondRange: [Date, Date] = [ - new Date('2026-05-12T10:00:00Z'), - new Date('2026-05-12T11:00:00Z'), - ]; - - expect(await md.getMapKeys(noTsArgs)).toEqual(['first.range']); - expect( - await md.getMapKeys({ ...noTsArgs, dateRange: secondRange }), - ).toEqual(['second.range']); - }); - - it('does not share an in-flight rollup read across ranges', async () => { - const md = buildMetadata(); - jest - .spyOn(md, 'getMapColumnTextIndexes') - .mockResolvedValue(new Map() as TextIndexInfoLookup); - (mockClickhouseClient.query as jest.Mock) - .mockResolvedValueOnce({ - json: () => Promise.resolve({ data: [{ Key: 'first.range' }] }), - }) - .mockResolvedValueOnce({ - json: () => Promise.resolve({ data: [{ Key: 'second.range' }] }), - }); - - const { - timestampValueExpression: _timestampValueExpression, - ...noTsArgs - } = baseArgs; - - const results = await Promise.all([ - md.getMapKeys(noTsArgs), - md.getMapKeys({ - ...noTsArgs, - dateRange: [ - new Date('2026-05-12T10:00:00Z'), - new Date('2026-05-12T11:00:00Z'), - ], - }), - ]); - - expect(results).toEqual([['first.range'], ['second.range']]); - }); }); describe('getMapKeys (text index paths)', () => { diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 20939b13e6..6eb9ac386e 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -317,21 +317,25 @@ export class MetadataCache { return cachedValue; } - return this.dedupe(key, async () => { - const result = await query(); - this.set(key, result, ttlMs); - return result; - }); - } + // Check if there is a pending query + if (this.pendingQueries.has(key)) { + return this.pendingQueries.get(key)!; + } - /** Shares one in-flight call per key between concurrent callers, without caching its result. */ - dedupe(key: string, query: () => Promise): Promise { - const pending = this.pendingQueries.get(key); - if (pending) return pending; + // If no pending query, initiate the new query + const queryPromise = query(); - const queryPromise = query().finally(() => this.pendingQueries.delete(key)); + // Store the pending query promise this.pendingQueries.set(key, queryPromise); - return queryPromise; + + try { + const result = await queryPromise; + this.set(key, result, ttlMs); + return result; + } finally { + // Clean up the pending query map + this.pendingQueries.delete(key); + } } set(key: string, value: T, ttlMs?: number) { @@ -1113,35 +1117,30 @@ export class Metadata { const cacheKey = conditionSql ? `${unscopedCacheKey}.${objectHash(conditionSql)}.condition` : unscopedCacheKey; - const cachedKeys = this.cache.get(cacheKey); - - if (cachedKeys != null) { - return cachedKeys; - } - // Concurrent callers share one read, since a text index read can take - // GiBs of memory. The in-flight key carries the aligned range: without - // timestampValueExpression cacheKey has none, but a rollup answer depends - // on it. - const inFlightKey = alignedDateRange - ? `${cacheKey}.${alignedDateRange[0].getTime()}.${alignedDateRange[1].getTime()}.inFlight` - : `${cacheKey}.inFlight`; - return this.cache.dedupe(inFlightKey, () => - this.queryMapKeys({ - databaseName, - tableName, - column, - maxKeys, - connectionId, - metricName, - metadataMVs, - dateRange, - alignedDateRange, - timestampValueExpression, - conditionSql, - cacheKey, - signal, - }), + // getOrFetch shares one in-flight read between concurrent callers (the + // filter panel, search bar and group-by all load fields at once); a text + // index read can take GiBs of memory. A condition changes with variable + // selections, so its entries expire instead of piling up. + return this.cache.getOrFetch( + cacheKey, + () => + this.queryMapKeys({ + databaseName, + tableName, + column, + maxKeys, + connectionId, + metricName, + metadataMVs, + dateRange, + alignedDateRange, + timestampValueExpression, + conditionSql, + cacheKey, + signal, + }), + { ttlMs: conditionSql ? CONDITION_KEYS_CACHE_TTL_MS : undefined }, ); } @@ -1227,10 +1226,7 @@ export class Metadata { }) .then(r => r.json<{ key: string }>()) .then(d => d.data.map(r => r.key).filter(Boolean)); - if (keys.length > 0) { - this.cache.set(cacheKey, keys); - return keys; - } + if (keys.length > 0) return keys; } catch (e) { // The caller gave up; don't start the fallback with a dead signal and // cache its instant rejection under the rollup key. @@ -1276,10 +1272,7 @@ export class Metadata { }) .then(r => r.json<{ key: string }>()) .then(d => d.data.map(r => r.key).filter(Boolean)); - if (keys.length > 0) { - this.cache.set(cacheKey, keys); - return keys; - } + if (keys.length > 0) return keys; } catch (e) { // See the key text index catch above. if (signal?.aborted) throw e; @@ -1454,42 +1447,34 @@ export class Metadata { `; } - const fetchKeys = async () => { - const keys = await this.clickhouseClient - .query<'JSON'>({ - query: sql.sql, - query_params: sql.params, - connectionId, - clickhouse_settings: { - ...this.getClickHouseSettings(), - // Max 15 seconds to get keys - timeout_overflow_mode: 'break', - max_execution_time: 15, - // Set the value to 0 (unlimited) so that the LIMIT is used instead - max_rows_to_read: '0', - }, - abort_signal: signal, - }) - .then(res => res.json<{ keysArr?: string[]; key?: string }>()) - .then(d => { - let output: string[]; - if (strategy === 'groupUniqArrayArray') { - output = d.data[0].keysArr ?? []; - } else { - output = d.data - .map(row => row.key) - .filter((k): k is string => Boolean(k)); - } + return this.clickhouseClient + .query<'JSON'>({ + query: sql.sql, + query_params: sql.params, + connectionId, + clickhouse_settings: { + ...this.getClickHouseSettings(), + // Max 15 seconds to get keys + timeout_overflow_mode: 'break', + max_execution_time: 15, + // Set the value to 0 (unlimited) so that the LIMIT is used instead + max_rows_to_read: '0', + }, + abort_signal: signal, + }) + .then(res => res.json<{ keysArr?: string[]; key?: string }>()) + .then(d => { + let output: string[]; + if (strategy === 'groupUniqArrayArray') { + output = d.data[0].keysArr ?? []; + } else { + output = d.data + .map(row => row.key) + .filter((k): k is string => Boolean(k)); + } - return output.filter(r => r); - }); - return keys; - }; - // A condition changes with variable selections; expire its entries so - // they don't pile up. - return this.cache.getOrFetch(cacheKey, fetchKeys, { - ttlMs: conditionSql ? CONDITION_KEYS_CACHE_TTL_MS : undefined, - }); + return output.filter(r => r); + }); } async getJSONKeys({