Repository navigation
fix(api): support TimeSeries time ranges tables (schema version 8) in label lookups #3581
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
base: main
Are you sure you want to change the base?
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,6 @@ | ||
| --- | ||
| '@hyperdx/api': patch | ||
| '@hyperdx/common-utils': patch | ||
| --- | ||
|
|
||
| fix: PromQL label lookups (dashboard variables, label autocomplete) now work on TimeSeries tables of schema version 8, the default from ClickHouse 26.10. These keep each series' time range in a separate time ranges table instead of the tags table's min_time/max_time, so match[] failed with "requires store_min_time_and_max_time = 1" and time-bounded lookups read the whole tags table. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,11 +32,23 @@ export type TimeSeriesTagsQueryArgs = { | |
| signal?: AbortSignal; | ||
| }; | ||
|
|
||
| // What a table without a time ranges table answers (version 7 or earlier, or | ||
| // store_time_ranges off): servers before 26.10 have no timeSeriesTimeRanges(), | ||
| // newer ones reject the table. Any other error is unexpected. | ||
| const isNoTimeRangesTableError = (e: unknown): boolean => | ||
| e instanceof Error && | ||
| /Unknown table function timeSeriesTimeRanges|has no TimeRanges target table/.test( | ||
| e.message, | ||
| ); | ||
|
|
||
| /** | ||
| * Indicates whether the tags inner table associated with the given TimeSeries table | ||
| * includes the optional `min_time` and `max_time` columns. | ||
| * Where the given TimeSeries table keeps each series' time range, if anywhere: | ||
| * - `tags`: the `min_time`/`max_time` columns of the tags inner table (schema | ||
| * version 7 or earlier, with `store_min_time_and_max_time` on); | ||
| * - `timeRanges`: the time ranges inner table (version 8 and later, with | ||
| * `store_time_ranges` on, the default from ClickHouse 26.10). | ||
| */ | ||
| async function timeSeriesTagsTableHasTimeBounds({ | ||
| async function timeSeriesTimeBounds({ | ||
| metadata, | ||
| connectionId, | ||
| database, | ||
|
|
@@ -46,24 +58,31 @@ async function timeSeriesTagsTableHasTimeBounds({ | |
| connectionId: string; | ||
| database: string; | ||
| table: string; | ||
| }): Promise<boolean> { | ||
| try { | ||
| const columns = await metadata.getTimeSeriesTableColumns({ | ||
| connectionId, | ||
| databaseName: database, | ||
| tableName: table, | ||
| innerTableType: 'Tags', | ||
| }); | ||
|
|
||
| const columnNames = new Set(columns.map(column => column.name)); | ||
| return columnNames.has('min_time') && columnNames.has('max_time'); | ||
| } catch (e) { | ||
| logger.warn( | ||
| { err: e, database, table }, | ||
| 'Failed to check if TimeSeries tags table has time bounds columns', | ||
| ); | ||
| return false; | ||
| }): Promise<'tags' | 'timeRanges' | undefined> { | ||
| for (const [innerTableType, bounds] of [ | ||
|
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. 🔵 minor — Each version 8 lookup, and each version 7 lookup with time bounds off, now runs two uncached DESCRIBE queries, and the second can fail on every request
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
Contributor
Author
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. Not changing. A version 7 table with bounds still runs one DESCRIBE, as on main. A version 8 table adds one more, which reads only table metadata, next to a lookup that reads millions of rows. The per-call |
||
| ['Tags', 'tags'], | ||
| ['TimeRanges', 'timeRanges'], | ||
| ] as const) { | ||
| try { | ||
| const columns = await metadata.getTimeSeriesTableColumns({ | ||
| connectionId, | ||
| databaseName: database, | ||
| tableName: table, | ||
| innerTableType, | ||
| }); | ||
| const columnNames = new Set(columns.map(column => column.name)); | ||
| if (columnNames.has('min_time') && columnNames.has('max_time')) | ||
| return bounds; | ||
| } catch (e) { | ||
|
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 — TimeRanges probe silently swallows every error, so any failure reads as 'table has no time ranges' The 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
Contributor
Author
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. Fixed in 445043d. Only the errors a table without a time ranges table gives are expected now: servers before 26.10 have no I didn't switch to
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. 🔵 minor — When the Tags probe fails, the loop still probes TimeRanges and logs a second warning If the 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
Contributor
Author
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. Not changing. After a failed tags check, main already falls back to no bounds and rejects |
||
| if (innerTableType === 'TimeRanges' && isNoTimeRangesTableError(e)) | ||
| continue; | ||
| logger.warn( | ||
| { err: e, database, table, innerTableType }, | ||
| 'Failed to check if TimeSeries table has time bounds', | ||
| ); | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| // `timeSeriesSelector()` requires both bounds, so we define the minimum and maximum possible times | ||
|
|
@@ -122,10 +141,11 @@ async function getSeriesFilterConditions({ | |
| const selectors = match?.length ? match : undefined; | ||
| if (startMs == null && endMs == null && selectors == null) return []; | ||
|
|
||
| // Both filters lean on min_time/max_time, which the tags inner table carries | ||
| // only when the table was created with store_min_time_and_max_time on. | ||
| // Both filters lean on each series' time range: min_time/max_time in the | ||
| // tags inner table up to schema version 7, a separate time ranges inner | ||
| // table from version 8. | ||
| const metadata = new Metadata(client, new MetadataCache()); | ||
| const tableHasTimeBounds = await timeSeriesTagsTableHasTimeBounds({ | ||
| const timeBounds = await timeSeriesTimeBounds({ | ||
| metadata, | ||
| connectionId, | ||
| database: databaseName, | ||
|
|
@@ -135,12 +155,12 @@ async function getSeriesFilterConditions({ | |
| const conditions: ChSql[] = []; | ||
|
|
||
| if (selectors != null) { | ||
| // timeSeriesSelector() prunes on min_time/max_time unconditionally, so | ||
| // without those columns it cannot run. Unlike the bounds it also cannot | ||
| // degrade to "no filter" — that would answer a different question. | ||
| if (!tableHasTimeBounds) { | ||
| // timeSeriesSelector() prunes on the series' time range, so without one it | ||
| // cannot run. Unlike the bounds it also cannot degrade to "no filter" — | ||
| // that would answer a different question. | ||
| if (timeBounds == null) { | ||
| throw new Error( | ||
| 'match[] requires a TimeSeries table created with store_min_time_and_max_time = 1', | ||
| 'match[] requires a TimeSeries table that stores time ranges (store_time_ranges = 1, or store_min_time_and_max_time = 1 before version 8)', | ||
| ); | ||
| } | ||
| conditions.push( | ||
|
|
@@ -154,7 +174,7 @@ async function getSeriesFilterConditions({ | |
| ); | ||
| } | ||
|
|
||
| if (tableHasTimeBounds) { | ||
| if (timeBounds === 'tags') { | ||
| if (endMs != null) | ||
| conditions.push( | ||
| chSql`(min_time IS NULL OR min_time <= fromUnixTimestamp64Milli(${{ Int64: endMs }}))`, | ||
|
|
@@ -163,6 +183,27 @@ async function getSeriesFilterConditions({ | |
| conditions.push( | ||
| chSql`(max_time IS NULL OR max_time >= fromUnixTimestamp64Milli(${{ Int64: startMs }}))`, | ||
| ); | ||
| } else if (timeBounds === 'timeRanges' && selectors == null) { | ||
| // The selector above already keeps only series with samples in the | ||
| // window. Without one, the time ranges table answers the window; its rows | ||
| // are partial aggregates until merged, hence the GROUP BY. | ||
| const having: ChSql[] = []; | ||
| if (endMs != null) | ||
| having.push( | ||
| chSql`min(min_time) <= fromUnixTimestamp64Milli(${{ Int64: endMs }})`, | ||
| ); | ||
| if (startMs != null) | ||
| having.push( | ||
| chSql`max(max_time) >= fromUnixTimestamp64Milli(${{ Int64: startMs }})`, | ||
| ); | ||
| conditions.push( | ||
| chSql`id IN ( | ||
| SELECT id | ||
| FROM timeSeriesTimeRanges(${{ Identifier: databaseName }}, ${{ Identifier: tableName }}) | ||
| GROUP BY id | ||
| HAVING ${concatChSql(' AND ', ...having)} | ||
| )`, | ||
| ); | ||
| } | ||
|
|
||
| return conditions; | ||
|
|
||
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 — Table layout is detected by matching ClickHouse error text when a version lookup already exists
Metadata.getTimeSeriesTableVersion(packages/common-utils/src/core/metadata.ts:714) already reads the table's schema version fromsystem.tables, and this file already uses it intimeSeriesSamplesColumn. Use it to choose which inner table to DESCRIBE: TimeRanges for version >= 8, Tags otherwise. Then the regex on 'Unknown table function timeSeriesTimeRanges|has no TimeRanges target table' is not needed, and it would break silently if ClickHouse rewords either message.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
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.
Answered in the thread on line 67: a version 8 table can have time ranges off, so knowing the version doesn't remove the check, and reading it adds a query to every version 7 lookup. If ClickHouse rewords either message, the check logs a warning and falls back as before, and CI's no-bounds test fails on the 26.8 wording.