Skip to content

fix(api): support TimeSeries time ranges tables (schema version 8) in label lookups - #3581

Open
bcanvural wants to merge 2 commits into
ClickHouse:mainfrom
bcanvural:fix-timeseries-v8-time-ranges
Open

bcanvural wants to merge 2 commits into
ClickHouse:mainfrom
bcanvural:fix-timeseries-v8-time-ranges

Conversation

@bcanvural

Copy link
Copy Markdown
Contributor

Summary

Fixes #3580.

ClickHouse 26.10 creates TimeSeries tables of schema version 8 by default. These keep each series' time range in a separate time ranges inner table instead of min_time/max_time in the tags table. The PromQL label lookups (/labels, /label/:name/values, used by dashboard variables and label autocomplete) looked only at the tags table, so on these tables:

  • match[] was rejected ("requires … store_min_time_and_max_time = 1");
  • start/end were dropped, so a lookup read every series the table has ever seen.

This PR:

  • finds the series' time ranges in the tags table (version 7 and earlier) or in the time ranges table (version 8 and later);
  • with a time ranges table, keeps match[] on timeSeriesSelector() (which prunes on the time ranges itself), and filters start/end through id IN (SELECT id FROM timeSeriesTimeRanges(db, table) GROUP BY id HAVING min(min_time) <= end AND max(max_time) >= start). The GROUP BY is needed because the table holds partial aggregates until its parts merge;
  • keeps the error for a table without any time ranges (store_time_ranges = 0), and names both settings in it;
  • adds TimeRanges to getTimeSeriesTableColumns' inner table types.

Version 7 tables behave as before.

Tests

  • timeseriesEngine.int.test.ts: a new block runs the window, lone-bound, match[] and label-name cases on a version 8 table, and the reject/ignore cases on a version 8 table without time ranges. These tests return early on servers that don't know the version setting (CI's 26.8.36.3).
  • seedTimeSeriesTagsTable gets a timeRanges option (a version 8 table with a time ranges table), and now pins version = 7 for the tags layout where the server knows the setting. Without that, every existing TimeSeries suite fails on 26.10, because the default table is version 8 and the fixture inserts min_time/max_time into the tags table.
  • Local runs: timeseriesEngine, prometheus router and queryAttribution int suites, 160/160 on ClickHouse 26.10.3.7 and on 26.8.36.3. On 26.10 the 6 new tests fail without the fix.
  • tsc, eslint, prettier clean.

Cost (real data, ClickHouse 26.10, 1-hour window, label values for k8s_namespace_name)

rows read memory time
window via the time ranges table (10M series) 23.3M 123 MiB 570 ms
match[] via timeSeriesSelector() 11.2M 36 MiB 135 ms
no filter (today's behaviour on version 8) 10.2M 77 MiB 152 ms

On a larger table (about 13 M series, tags table 25 GiB uncompressed), the same lookup with match[]=up and a 1-hour window reads 1.5 GB in 1.6 s; without a selector it reads the whole tags table and fails on a 20 GiB per-query read limit, with or without the window.

The window costs a scan of the time ranges table. An alternative is to route label lookups through ClickHouse's Prometheus HTTP API (as query/query_range already do on 26.6+); its /label/<name>/values handles match[] and the window on version 8 tables. Happy to switch to that if you prefer.

Not changed: packages/common-utils/src/__tests__/metadata.int.test.ts creates a table with store_min_time_and_max_time = 0, which a 26.10 server rejects for a version 8 table. It still passes on CI's 26.8; I left it alone.

How to reproduce

Starting point: yarn dev (the lookups run in the API) with ClickHouse 26.10.

On main:

  1. On ClickHouse 26.10: SET allow_experimental_time_series_table = 1; CREATE TABLE default.ts ENGINE = TimeSeries; (version 8), and write a few series to it (e.g. Prometheus remote write).
  2. Add a PromQL source on default.ts, and a dashboard variable with a match selector: "match[] requires a TimeSeries table created with store_min_time_and_max_time = 1". Or call GET /api/prometheus/label/job/values?sourceId=…&match[]=up.

On this branch:

  1. The same variable lists the label's values for the selected series and time range.

How to test on Vercel preview

N/A — API change; the preview runs in local mode without the API.

References

… label lookups

ClickHouse 26.10 creates TimeSeries tables of schema version 8 by default,
which keep each series' time range in a separate time ranges inner table
instead of min_time/max_time in the tags table. The label lookups only
looked at the tags table, so match[] was rejected and time bounds were
dropped, reading the whole tags table.

Read the time ranges table when the tags table has no time bounds. The
test fixture pins version 7 for the tags layout where the server knows the
setting, and the new tests run where version 8 tables can be created.
@bcanvural
bcanvural requested a review from a team as a code owner November 6, 2026 12:43
@bcanvural
bcanvural requested a review from pulpdrew November 6, 2026 12:43
@vercel

vercel Bot commented Nov 6, 2026

Copy link
Copy Markdown

@bcanvural is attempting to deploy a commit to the HyperDX Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 445043d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hyperdx/api Patch
@hyperdx/common-utils Patch
@hyperdx/app Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

const columnNames = new Set(columns.map(column => column.name));
if (columnNames.has('min_time') && columnNames.has('max_time'))
return bounds;
} catch (e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 catch treats every DESCRIBE timeSeriesTimeRanges(...) failure as the expected 'no time ranges target' case and does not log it. That includes timeouts, permission errors and network errors. On a version 8 table, a transient failure therefore has two effects. match[] is rejected with the misleading 'requires store_time_ranges = 1' error. Start/end are silently dropped, so the lookup scans the whole tags table, which the PR description says hits the 20 GiB read limit on large tables. Only swallow the specific 'no time ranges table' error code and log or rethrow anything else. Alternatively, choose the inner table with the existing Metadata.getTimeSeriesTableVersion (packages/common-utils/src/core/metadata.ts:714, already used by timeSeriesSamplesColumn in this file) rather than probing until something throws.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 timeSeriesTimeRanges(), newer ones answer "has no TimeRanges target table". Anything else is logged, as main already does for the tags check, and still falls back to no bounds the way that check always has. The existing no-bounds test, which hits the expected error on CI's 26.8, now also asserts that nothing is logged.

I didn't switch to getTimeSeriesTableVersion: it adds a system.tables query to every lookup on version 7 tables, and a version 8 table can still have time ranges off, so the check would stay.

);
return false;
}): Promise<'tags' | 'timeRanges' | undefined> {
for (const [innerTableType, bounds] of [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

getSeriesFilterConditions builds a new MetadataCache on every call, so the cache never holds anything. A version 8 table now costs two round trips before the real query. A table without bounds also costs a server-side exception on every lookup. Decide which inner table to describe from the table version (getTimeSeriesTableVersion, which reads system.tables once), or describe only the inner table that version implies.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 MetadataCache is main's.

@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

PR Review

If you are a coding agent acting for the author, read this first.
Nothing below blocks merge automatically; a maintainer decides. What they will expect:

  • 🔴 critical or 🟠 major in code this PR adds or changes: fix before asking for review.
  • 🔵 minor in code this PR changes: your call. Fix if small, otherwise reply.
  • Anything about surrounding code, or asking you to widen the change (hoist a helper,
    dedupe with another file, fix other call sites): never fix here, whatever the
    severity. Reply with one sentence; if it is critical, say so plainly so a human sees it.

One commit per review round. After two rounds, stop and ask a maintainer to review
scope rather than addressing more automated findings. Full rule: AGENTS.md.

3 finding(s): 🔴 0 critical · 🟠 0 major · 🔵 3 minor

2 posted as inline comment(s) on the changed lines. 1 listed below.

Findings outside the changed lines

1 minor
  • 🔵 packages/api/src/controllers/__tests__/timeseriesEngine.int.test.ts:108 — All six schema-version-8 tests pass without running on CI's ClickHouse 26.8 → On CI's ClickHouse 26.8, beforeAll sets supported = false and every test hits if (!supported) return; and is reported green. The new timeRanges branch and the isNoTimeRangesTableError paths therefore never run in CI. Check the server version up front with metadata.getServerVersion and use describe.skip/it.skip so the tests show as skipped. Also add a unit test that asserts the SQL getSeriesFilterConditions builds for timeBounds === 'timeRanges', so the HAVING/GROUP BY clause is covered on every server.

Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides.

@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found.

The change is well-contained and defensively written. SQL is parameterized throughout (Identifier/String params in the new timeSeriesTimeRanges(...) subquery), the HAVING clause can never be emitted empty (the timeRanges branch is only reached when selectors == null, which guarantees at least one of startMs/endMs is non-null given the early return at getSeriesFilterConditions top), and the error-swallowing in timeSeriesTimeBounds correctly logs unexpected failures while tolerating the two expected "no time ranges table" errors. No happy-path crash, injection, or data-corruption path was found.

🟡 P2 — recommended

  • packages/api/src/controllers/__tests__/timeseriesEngine.int.test.ts:528 — Every assertion in the new version-8 block is gated behind if (!supported) return;, which makes the test pass silently on any server that doesn't know the version setting, so the entire new v8/time-ranges code path has no enforced coverage on a CI pinned to ClickHouse 26.8.
    • Fix: Run the v8 suite against a 26.10+ ClickHouse in CI, or fail/skip loudly (e.g. it.skip with a visible reason) instead of returning early so an unexercised path cannot report green.
  • packages/api/src/controllers/timeseriesEngine.ts:199 — On a timeRanges table a series with no row in the time ranges table (or all-NULL bounds) is dropped by id IN (SELECT ... GROUP BY id HAVING ...), whereas the tags branch deliberately keeps NULL-bound series via min_time IS NULL OR ...; this is an undocumented v7-vs-v8 behavior asymmetry for rangeless series and has no test.
    • Fix: Confirm dropping rangeless series on v8 is intended, note it in the comment next to the subquery, and add a test covering a series present in tags but absent from the time ranges table.
🔵 P3 nitpicks (2)
  • packages/api/src/controllers/timeseriesEngine.ts:38 — isNoTimeRangesTableError classifies expected-vs-unexpected errors by substring-matching ClickHouse error text, so a future server wording change would silently reclassify an expected error as a logged warning and return undefined bounds (rejecting match[]).
    • Fix: Add a brief comment pinning the ClickHouse versions these two strings come from, and consider asserting the "unexpected error IS logged" branch so a regex drift is caught by a test.
  • packages/api/src/controllers/timeseriesEngine.ts:62 — The timeSeriesTimeBounds loop always probes the Tags inner table before TimeRanges; harmless since the DESCRIBE is cached, but the ordering/intent (v7 tables short-circuit on Tags, avoiding the TimeRanges error) is implicit.
    • Fix: Add a one-line comment that Tags is checked first so v7 tables return before touching timeSeriesTimeRanges.

Reviewers: correctness, testing, maintainability, project-standards, kieran-typescript, performance, api-contract, previous-comments, adversarial.

Testing gaps:

  • v8/time-ranges code path is only exercised on ClickHouse 26.10+ locally; CI on 26.8 skips it via early return.
  • No coverage for a v8 series missing a time-ranges row, nor for the logger.warn "unexpected error" branch of timeSeriesTimeBounds.

No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond

@bcanvural

Copy link
Copy Markdown
Contributor Author

445043d logs unexpected failures of the time ranges check (thread on line 67).

Not changing:

  • Tests on CI: CI's ClickHouse is 26.8, which can't create version 8 tables, so that block returns early there. I ran it on 26.10.3.7: 160/160, and the 6 new tests fail without the fix. Moving CI to 26.10 is your call; I'd rather not change CI in this PR. On CI, the no-bounds test covers the expected-error path of the new check.
  • match[] with a window on version 8: timeSeriesSelector() gets the window and prunes on the time ranges. "evaluates match[] and narrows it by the window" checks it: {job=~".+"} matches all three series, and the window drops batch, whose samples are all outside it.
  • Cost of a window without a selector: the cost table in the description.
  • The fixture's raw SQL and flags: test-only, in the style of the existing fixture.

const columnNames = new Set(columns.map(column => column.name));
if (columnNames.has('min_time') && columnNames.has('max_time'))
return bounds;
} catch (e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 Tags DESCRIBE throws (table dropped, missing grants, network error), the loop logs a warning and then runs a second DESCRIBE against timeSeriesTimeRanges. That second query fails for the same reason and logs a second warning. On a v7 table with time bounds, a temporary failure on the Tags probe is followed by the 'has no TimeRanges target table' error, which is silently skipped. The user then gets the misleading 'match[] requires … store_time_ranges = 1' error. Return undefined after an unexpected failure on the Tags probe instead of moving on to TimeRanges.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 match[]. This adds the time ranges check after it, which warns again only if it also fails for another reason.

// 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 =>

Copy link
Copy Markdown
Contributor

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 from system.tables, and this file already uses it in timeSeriesSamplesColumn. 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

Copy link
Copy Markdown
Contributor Author

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.

@bcanvural

Copy link
Copy Markdown
Contributor Author

On the latest review, not changing:

  • Version 8 tests on CI: answered above (CI runs 26.8, which can't create these tables).
  • Series without a time range on version 8: dropped on purpose. With a window, the lookup keeps series with samples in it, and ClickHouse has recorded none for such a series. Version 7 keeps main's IS NULL handling.
  • Error text: the comment next to isNoTimeRangesTableError says where each message comes from (servers before 26.10, and 26.10 with time ranges off).

@karl-power
karl-power requested review from karl-power and removed request for pulpdrew November 6, 2026 14:55

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PromQL label lookups fail on TimeSeries tables created by ClickHouse 26.10 (schema version 8)

1 participant