Skip to content

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

Merged
kodiakhq[bot] merged 4 commits into
ClickHouse:mainfrom
bcanvural:fix-timeseries-v8-time-ranges
Nov 9, 2026
Merged

kodiakhq[bot] merged 4 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.

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

None could be anchored to changed lines; they are listed below.

Findings outside the changed lines

1 minor
  • 🔵 packages/api/src/controllers/__tests__/timeseriesEngine.int.test.ts:108 — The new version 8 tests pass without running on CI's ClickHouse 26.8 → On 26.8, beforeAll sets supported = false and each test hits if (!supported) return;, so all six tests report green while nothing runs. A regression in the timeRanges branch of getSeriesFilterConditions would go unnoticed. Mark them skipped instead, e.g. probe version = 8 support before the suite and use (supported ? it : it.skip) or describe.skip, or call ctx.skip() if the runner supports it. Better still, add a CI job against ClickHouse 26.10 so the new SQL actually runs.

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. No P0/P1 after re-grading — the change is additive (version 7 behavior is untouched) and the new version 8 path is strictly better than main, which dropped the window filter entirely on these tables. The items below are recommended follow-ups.

🟡 P2 -- recommended

  • packages/api/src/controllers/__tests__/timeseriesEngine.int.test.ts:529 -- The six new version 8 tests each if (!supported) return;, and beforeAll sets supported = false on the Unknown setting 'version' error that CI's ClickHouse 26.8 raises, so the entire new version 8 code path reports PASS on CI while executing zero assertions.
    • Fix: Surface the skip loudly (use it.skip/a capability-gated describe so the reporter shows SKIPPED, not PASSED) and add a CI lane on ClickHouse 26.10+ that requires the block to actually run.
    • testing, kieran-typescript, previous-comments
  • packages/api/src/controllers/timeseriesEngine.ts:76 -- On a correctly configured version 8 table, any error from the TimeRanges DESCRIBE that isNoTimeRangesTableError does not match (timeout, permission, a reworded ClickHouse message) is logged and the loop returns undefined, which then rejects match[] at line 161 with the misleading store_time_ranges = 1 error and silently drops the window bounds for bounds-only lookups.
    • Fix: Distinguish "time ranges table genuinely absent" from "probe failed" so a transient failure on a valid table does not masquerade as an unsupported-schema rejection.
    • correctness, adversarial
  • packages/api/src/controllers/timeseriesEngine.ts:199 -- The version 8 window filter id IN (SELECT id FROM timeSeriesTimeRanges(...) GROUP BY id HAVING min(min_time) <= end AND max(max_time) >= start) excludes any series absent from the time ranges inner table or whose aggregate bounds are NULL, diverging from the tags branch (lines 178-185) which keeps such series via min_time IS NULL OR ...; a series present in tags but not yet materialized into the time ranges table disappears from windowed label lookups.
    • Fix: Confirm whether time ranges rows can lag or be NULL for a live series and, if so, mirror the tags branch's NULL-tolerant inclusion; otherwise document the version 7/8 divergence at the branch.
    • adversarial, correctness
🔵 P3 nitpicks (5)
  • packages/api/src/controllers/timeseriesEngine.ts:38 -- isNoTimeRangesTableError classifies errors by matching ClickHouse message substrings with no test pinning them, so a future reword silently reclassifies an expected error (degrading to a warn log plus no-bounds fallback).
    • Fix: Add a test asserting both substrings against the ClickHouse versions they come from, or key off an error code if one is available.
  • packages/api/src/controllers/timeseriesEngine.ts:51 -- timeSeriesTimeBounds and its timeBounds/bounds result names read as if they return bound values, but they return a storage-location discriminant ('tags' | 'timeRanges' | undefined).
    • Fix: Rename to convey location, e.g. getTimeBoundsSource returning timeBoundsSource.
  • packages/api/src/controllers/timeseriesEngine.ts:147 -- getSeriesFilterConditions builds new Metadata(client, new MetadataCache()), a throwaway cache, so the schema-shape DESCRIBE is re-issued on every label request; the diff raises this to up to two round-trips for version 8 tables.
    • Fix: Use the shared process-wide metadata cache so the column lookup is reused across requests.
  • packages/api/src/controllers/__tests__/timeseriesEngine.int.test.ts:337 -- expect(logger.warn).not.toHaveBeenCalled() is a global negative assertion on the shared logger and breaks on any unrelated warn without a behavior change.
    • Fix: Narrow the spy to the specific message/predicate, or assert the positive case (an unexpected failure does warn).
  • packages/api/src/fixtures.ts:356 -- quoted() escapes single quotes but not backslashes, so a fixture tag value or metric name containing a backslash produces malformed interpolated SQL (test-only, pre-existing).
    • Fix: Escape backslashes before single quotes, or bind values via query params.

Reviewers (8): correctness, testing, maintainability, performance, api-contract, kieran-typescript, adversarial, previous-comments.

Testing gaps:

  • The GROUP BY id HAVING min()/max() partial-aggregate merge is never exercised: the fixture writes exactly one time-ranges row per id, so GROUP BY reduces to identity and a regression dropping it would still pass (even where the suite runs on 26.10+).
  • No test covers timeSeriesTimeBounds when the Tags probe succeeds but a non-schema error hits the TimeRanges probe, nor the new Tags-throws-then-TimeRanges-succeeds fall-through.
  • The new store_time_ranges = 1 error-text clause is unverified — existing .rejects.toThrow(/store_min_time_and_max_time = 1/) assertions match the new message only by coincidence.

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
@karl-power
karl-power requested a review from a team as a code owner November 9, 2026 11:59
@kodiakhq
kodiakhq Bot merged commit b1d4e60 into ClickHouse:main Nov 9, 2026
25 of 27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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)

2 participants