Repository navigation
perf: share one Map key read between concurrent callers #3611
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1117,12 +1117,62 @@ export class Metadata { | |
| const cacheKey = conditionSql | ||
| ? `${unscopedCacheKey}.${objectHash(conditionSql)}.condition` | ||
| : unscopedCacheKey; | ||
| const cachedKeys = this.cache.get<string[]>(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<string[]>( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 major — Rollup keys are now cached forever under a cacheKey with no date range, so other ranges get stale keys The outer Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond |
||
| 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<string[]> { | ||
| 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<string[]>(cacheKey, fetchKeys, { | ||
| ttlMs: conditionSql ? CONDITION_KEYS_CACHE_TTL_MS : undefined, | ||
| }); | ||
| return output.filter(r => r); | ||
| }); | ||
| } | ||
|
|
||
| async getJSONKeys({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 minor — New test only covers the text-index path with timestampValueExpression, so the rollup cache-key regression passes
The test always passes
timestampValueExpression: 'Timestamp'and never setsmetadataMVs. The existing 'caches rollup keys by aligned date range' test (around line 2942) also sets tse. Nothing checks that calls without tse, with metadataMVs, for two different date ranges each query the rollup. Add that case:getMapKeys({...baseArgs, timestampValueExpression: undefined})for range A, then for range B, and expect two rollup queries with different results. Also add a concurrent case for the scan fallback.Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond