Skip to content

feat: ClickHouse as the store, for horizontal scale-out - #22

Merged
sylvesterdamgaard merged 50 commits into
mainfrom
feat/clickhouse
Oct 25, 2026
Merged

sylvesterdamgaard merged 50 commits into
mainfrom
feat/clickhouse

Conversation

@sylvesterdamgaard

@sylvesterdamgaard sylvesterdamgaard commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

What

ClickHouse as an optional store for logs, traces and metrics, so several telemetryd instances can run behind one load balancer as stateless ingest and query nodes over one central database. The Loki, Tempo and Prometheus APIs stay the same, so laravel-telemetry and laravel-telemetry-ui need no changes.

When [clickhouse] is not set, nothing changes: the local store works exactly as before.

How

  • [clickhouse] config: url, database, user, password, cluster, create_schema, storage_policy, cold_after, cold_volume, allow_plaintext, timeout.
    • TTL comes from retention.*.
    • Startup refuses plaintext to a non-loopback host, credentials in the URL, and relay plus ClickHouse.
  • Schema: replicated tables when a cluster is set. The log and span stream summaries, the log volume rollups (1 s and 1 m), the trace-id index and the trace attribute keys are all kept up by materialized views. Metrics live in samples, series and folds; the folds are coordinated through a lease.
  • Restarts: a restart runs no DDL, and TTL is only changed when retention changes.
  • Queries:
    • LogQL, TraceQL and PromQL are pushed down to SQL wherever they can be answered there exactly.
    • What SQL can't decide exactly, such as RE2 regexes that differ from Rust's, is checked in Rust.
    • Every read runs with readonly=2 and max_execution_time, and is cancelled when the client goes away.
  • Operations:
    • /ready is 503 when ClickHouse doesn't answer, and writes are refused with 503 plus Retry-After.
    • New metrics telemetryd_clickhouse_*, and /status and telemetryd status show the store.
    • New commands telemetryd clickhouse schema and telemetryd clickhouse check.
  • Docs:
    • New cookbook page, docs/cookbook/scale-out-on-clickhouse.md.
    • New runbook, docs/cookbook/operate-clickhouse.md: ClickHouse, a replica or Keeper down, a disk filling, too many parts, slow queries, upgrades, password rotation, backup and restore, cluster config, and what to watch in ClickHouse.
    • New docs/configuration/cli.md, every command and flag.
    • A [clickhouse] section in reference.md.
    • Threat-model update.

Measured (laptop, ClickHouse 25.8 in Docker)

Ingest 232k records/s with 4 instances (batch 500), 440k/s at batch 2000
Logs, 2B lines/day stream with limit 0.05 s over 24h, volume 24h 0.01 s, logfmt 1h 0.16 s
Traces, 600M spans search 0.06–0.15 s, trace by id 0.01 s, metrics by attribute 1h 0.6 s
Metrics, 3.7B samples / 30 days instant p95 0.6–1.5 s, rate chart over 30 days 1.4 s
Storage 18.7 B per log line, about 3.4 TB for 90 days at 60B/month

Tests

  • Parity tests check that the ClickHouse store answers the same as the local store for logs, traces and metrics.
  • The LogQL, Tempo and PromQL conformance corpora also run on ClickHouse.
  • Further tests cover live tail across instances, two instances on different replicas of one cluster, and a ClickHouse that goes away.
  • The CI job "ClickHouse as the store" runs all of these against a two-replica cluster with Keeper. TELEMETRYD_TEST_CLICKHOUSE_REQUIRED=1 makes a skip fail.

Shards, migration and import

  • Several shards: with a cluster of more than one shard, each table is <name>_local per shard with a Distributed table under the old name. Logs are spread with rand() for scan parallelism. Spans are keyed by TraceId (one shard per trace). Samples, series, counts and folds are keyed by SeriesHash, so a series meets on one shard. telemetryd_meta is one replication group across all servers. Writes are forwarded synchronously, so a 200 still means stored. Startup refuses a cluster without internal_replication, without {shard}/{replica} macros, or with a changed shard count. The whole ClickHouse suite also runs on 2 shards × 2 replicas in CI.
  • telemetryd clickhouse migrate:
    • Rebuilds tables an earlier version laid out differently while instances keep writing: create, then EXCHANGE TABLES, then copy back partition by partition, verified, with a lease, and resumable.
    • --backfill fills rollups for rows older than their completeness markers, exactly once.
    • --materialize-indexes builds the new body index on old partitions, then drops the token index it replaces.
    • Drops objects earlier versions created, by name.
  • telemetryd clickhouse import --from-data-dir: copies a stopped single node's segments and WAL into ClickHouse through the write path, resumably. It also fixes an export bug where a full page could stop partway through records sharing one timestamp, so the next page skipped the rest.

Also fixed

  • Relay fairness: a client over its share held a queue permit while being refused, so a quiet client sometimes got 429. This was the flaky soak check, which failed on main too.
  • Body index: a 4-gram index replaces the token index. |= "<id>" now skips granules; before, it read all of them.
  • Scan sizing: pages scale with what a Rust-side predicate keeps (logfmt 1h: 1.8 s → 0.16 s), and windows grow by the hit rate found so far.
  • ON CLUSTER DDL: it waits for every server (wait_end_of_query).
  • Test isolation: test databases are unique per test.
  • Request timeout: a request past server.request_timeout was a bare 503 with no Retry-After. With Keeper down, every insert hit it. It now answers like every other refusal to wait.

Known limits

  • A line filter on a common term over many hours still reads every body in range, at roughly 50M lines/s per server. Shards divide that.
  • Changing the shard count needs a new database plus export --to; there is no in-place rebalancing.

https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL

…ront of it

With [clickhouse] set, logs are written to and read from ClickHouse instead
of files in the data directory, so any number of instances behind one load
balancer answer for all of them. The Loki API, OTLP ingest and every client
are unchanged.

- otel_logs is the OpenTelemetry Collector's table, as
  cboxdk/laravel-telemetry-store creates it, plus a Stream column holding
  the stream labels ingest computed; otel_log_streams, kept by a
  materialized view, answers label names, values and series without
  scanning the logs.
- Records keep their verbatim resource attributes in ClickHouse mode, so
  ResourceAttributes is what other readers expect, and a row reads back as
  the record ingest produced.
- The log engines take a LogStore trait; ClickHouse selects on time range,
  stream matchers and line filters, and the rest of LogQL runs on the rows
  as before. All 62 cases of the LogQL conformance corpus answer as Loki
  does on ClickHouse, run when TELEMETRYD_TEST_CLICKHOUSE_URL is set.

Traces and metrics are still local. Queries that aggregate over a wide range
still read every row; pushing aggregation into SQL comes next.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
count_over_time, rate, bytes_over_time and bytes_rate over nothing but |=
and != line filters, grouped by stream labels, are counted by ClickHouse
into buckets as wide as gcd(step, range), laid on the windows' own bounds.
Every window is a whole number of buckets, so its value is theirs summed,
with the same staleness markers emit gives from lines. This is every log
volume chart, which read every line of its range before.

A grouped label an attribute could stand in for, or a row with no stream,
is grouped the old way, one record at a time, so the answer is exact. 13 of
the 62 conformance queries take this path and all still answer as Loki.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
…first

A query's stream matchers are resolved against otel_log_streams, exactly,
by the matcher code every store uses, and reach otel_logs as
ServiceName IN (...) AND StreamHash IN (...): a UInt64 column instead of a
map, five times faster over a billion rows, and an app list that lets the
sort key prune even without an app matcher.

- Log volume groups by StreamHash and reads the body only for the bytes
  functions; the labels it groups by come from the resolved streams.
- AttributeLabels records the label names a row's attributes give it, so a
  grouped count finds the rows an attribute regroups with hasAny instead of
  reading their maps.
- A limited scan reads windows from the end it starts at, the first sized
  from how densely the selected streams wrote there, each four times the
  last, so the newest hundred lines of a day read seconds of it.

The 62 conformance queries still answer as Loki.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
Holds ClickHouse to the local store on what the Loki corpus does not reach:
resource attributes over OTLP, attributes standing in for a grouped label,
detected_level, offsets, and limited reads cut through bursts of a thousand
lines sharing one timestamp, in both directions. Lines that share a
timestamp are compared as a set: their order is insertion order on one
instance and has no meaning across several.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
/ready pings ClickHouse and answers 503 with the reason when it does not
answer, so a load balancer sends writes to an instance that can store them.
/status carries every request by operation and outcome, the time spent,
rows and bytes written and the last error; /metrics the same as
telemetryd_clickhouse_* counters.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
Stream resolution, windowed newest-first reads and keyset pages move out of
the log store into clickhouse::scan, generic over the row type, for traces
to share. The schema now also creates otel_traces (the Collector's columns
plus the stream columns, sorted by app and time so search reads newest
first), the trace-id index the Collector keeps beside it, the trace streams
summary and the attribute keys per hour.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
otel_log_volume, kept by a materialized view, holds lines and bytes per
stream per second per set of attribute label names. A count with no line
filter reads it instead of the lines: a day of two billion lines, grouped by
level at a five-minute step, went from past the 30 s timeout to 27 ms.

Exact, not approximate:
- A second is (s, s+1], half open like a window, so whole seconds lie in one
  bucket; seconds a bucket bound splits are counted from the lines.
- The rollup says whether any line in the range has an attribute that would
  regroup it; only then are such lines read and grouped one by one.
- telemetryd_meta records from when the rollup holds every line. A table
  that held rows before the view existed is counted from the lines before
  that moment; the latest value written wins, so a backfill can move it.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
… front of it

Spans are written to otel_traces as the Collector and the store package lay
it out — ResourceAttributes and SpanAttributes split from the span's
attributes and put back together exactly, kind and status as they spell
them — plus the stream columns. The Tempo engines take a SpanStore trait:

- TraceQL conditions are handed down as data and become necessary SQL
  conditions: name, status, kind and duration directly; attributes by value
  with the key matched on its letters, which covers relaxed lookups, behind
  a has(mapValues(...)) a Bloom index can skip granules by; resource.x
  conditions also narrow the streams read. The predicate still decides.
- A trace by id reads the trace-id index first, as the Collector does, then
  only the stretch of otel_traces the trace lies in.
- Search reads its matched traces whole with TraceId IN (...).

The 22 Tempo conformance cases answer as Tempo on ClickHouse, and a new
parity test holds 37 queries — events, links with trace state, relaxed and
resource attributes, numeric comparisons, limits, tag values, TraceQL
metrics — to the local store's answers exactly. Test helpers for
ClickHouse move to tests/common.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
…l store does

With [clickhouse] set, metric samples from OTLP, remote_write and the span
metrics generator are written to ClickHouse and PromQL reads them there.

- The PromQL engine takes a MetricStore trait; the local store implements
  it by delegation, unchanged.
- Samples go into a Null ingest table and two materialized views keep
  telemetryd_samples, sorted by metric, series and time, and
  telemetryd_series, every series per day with its labels. Matchers are
  resolved against the series exactly, in Rust; samples are read by name
  and series hash, inside the query's windows only, in RowBinary.
- A value JSON cannot carry - a staleness marker, any other NaN, an
  infinity - is stored as a flag beside it and reads back bit for bit.
- Counter summaries are declined, so the engine folds the samples itself.
- The PromQL conformance corpus answers as Prometheus on ClickHouse, and a
  parity test holds 85 answers - histograms, resets, gauges, special
  values, windows past an hour at bounds inside a second, label APIs -
  identical to the local store's.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
- TTLs come from retention.logs/traces/metrics, not a ClickHouse setting of
  their own, and follow them on start and reload (MODIFY TTL with
  materialize_ttl_after_modify = 0, so nothing already written is
  rewritten). Up to ten years; DateTime ends in 2106.
- clickhouse.storage_policy, cold_after and cold_volume: logs and spans move
  their older days to the policy's cold volume — object storage or slower
  disks, still queryable — before retention deletes them. Summaries and
  rollups stay hot.
- otel_log_volume_1m beside the per-second rollup. A count answers whole
  minutes from it, the whole seconds of minutes a bound splits from the
  second rollup, and only the seconds a bound splits from the lines, so a
  log volume chart over ninety days reads thousands of rows. Long lists of
  split units go to ClickHouse a few hundred at a time instead of making
  the count fall back to reading every line.
- relay.upstream with [clickhouse] is refused: there is nothing local to
  forward.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
The local tail is fed by the process's own ingest, which behind a load
balancer is a fraction of the lines. With ClickHouse as the store a tail asks
ClickHouse each second for what matched since it last asked, reaching back
ten seconds for lines that landed late, and shows each line once. A busy
second is read on from where the last ask stopped; a tail more than thirty
seconds behind skips ahead and reports the skip as dropped, as Loki's does.
Loki's delay_for is honoured.

Also: the warning about memory for open segments is not given in ClickHouse
mode, which has none.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
/status and telemetryd_app_* read series and rows per app from the streams
summaries, and apportion each table's compressed bytes by row share, as the
local store apportions a segment's. They were empty with ClickHouse as the
store, which has no local segments to read them from.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
A rate over 90 days read every sample of it. Now telemetryd summarises each
series per hour and per day into telemetryd_folds - the StreamFold a sealed
local segment carries, made with the local store's own add and merge - and
a window of whole days and hours is answered from those, its uneven ends
read as samples, every window of a query in one read.

- The maintenance loop summarises hours five minutes after they close, a
  day's partition per read, and days once their 24 hours are done; a lease
  in telemetryd_meta keeps instances from doing the same work, and a fold
  made twice is kept once by its Version.
- telemetryd_sample_counts, kept by a view on every insert, says what each
  hour holds. A bucket is trusted only where its counts and its folds agree
  over the query's series; late data makes it disagree, so it is read as
  samples until the next pass, which looks back three days, makes it again.
- Tables take their TTL from a retention passed in, with ttl_statements to
  change it later; statements(config, retention) is the new signature.
- The parity tests compare within 1e-9 relative, the standard the PromQL
  corpus holds every road to, and cover three days at hour and day steps,
  late data, and its repair.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
With clickhouse.cluster set every table is created ON CLUSTER with a
Replicated engine and replicated_deduplication_window = 0: a rollup's blocks
are computed, two batches can count one second of one stream to the same
number, and a replica's default deduplication would drop the second.

A new test runs two instances in front of different replicas of one
cluster, both creating the schema at once, writes logs and spans through one
and holds the other's answers to its: lines, log volume, labels, search,
trace by id. Run with TELEMETRYD_TEST_CLICKHOUSE_CLUSTER and
TELEMETRYD_TEST_CLICKHOUSE_REPLICAS.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
scripts/clickhouse/compose.yml runs a Keeper and two replicas, pinned by
digest; a new CI job brings it up and runs the server tests with the
ClickHouse variables set, so the Loki and Tempo corpora, the parity tests,
the cross-instance tail and the cluster test all gate a merge.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
A cookbook page for the ClickHouse store: setup, what lives where and which
tables answer what, retention and cold tiers for ninety days and more, space
and query timings measured on two billion lines, replicated clusters, what
stays per instance (series limits, span-metrics pairing), delivery
semantics, rows other writers left, and why relay and ClickHouse do not
combine. [clickhouse] joins the configuration reference.

The scope statements change with the product: one node is still the default
and needs nothing else, and outgrowing it no longer means outgrowing
telemetryd.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
Metric tables are made through schema::Layout and listed in AGING, so their
TTL follows retention.metrics, moves raw samples to the cold volume with the
rest, and changes on reload; app_usage counts metric series and samples; the
cluster test proves metrics written through one replica read through the
other.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
- Decide from the windows alone whether summaries can help before asking
  ClickHouse which are complete: a chart of five-minute windows holds no
  whole hour and now costs nothing extra.
- No FINAL on the summaries a query reads: a fold made again is kept by its
  Version here, and in the completeness sums an extra row only makes a
  bucket read as samples until the table merges.
- A counter's rate fetches its six fields, not the gauge ones.

A month of 42,500 series: p95 over the month 1.5 s, per-route increase
0.15 s, per-route rate chart at 1 h 1.4 s and at 1 d 0.17 s.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
Measured on 600 million spans a day: filtered searches over 24 hours went
from refused or past the timeout to 60–230 ms, an unfiltered one to 150 ms,
a 95th-percentile duration over an hour from refused to 120 ms.

- Search asks the store for the traces to show. ClickHouse reads matching
  spans newest first and stops once the limit'th latest-starting trace found
  starts after everything read so far — no trace still unread can start
  later — with starts bounded from the trace-id index, and drops any found
  trace that cannot reach the top. Only those are read whole, chunked, and
  only over the stretch the index says they cover. The local store names
  every trace its scan read, as before.
- TraceQL conditions are stated twice, necessary and sufficient, following
  Rust's lookups: exact keys decide alone, a relaxed key that might answer
  is unknown, numbers held as text are certain only as plain decimals, nil
  and negations are explicit. Search selects by the necessary form.
- TraceQL metrics — rate, count, sum, avg, min, max, quantiles and
  histograms of duration, by intrinsics, resource and span attributes — are
  aggregated in ClickHouse over the spans the sufficient forms take; spans
  only the necessary forms take come back and are counted one by one, so
  the answer is the local store's. Quantiles fall back to scanning when any
  span is undecided, since quantiles do not add.

The trace parity test grows to 49 queries, 16 of them metrics, compared
within a relative 1e-9 where summation order moves the last bits. The
cookbook page carries the trace timings.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
/ready answers 503 naming ClickHouse, a write is refused with 503 and
Retry-After so the sender keeps it, a read is refused: nothing is accepted
that was not stored. Runs without a ClickHouse.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
The cookbook page names the metric tables and why they are not the
Collector's, the folds and how late samples are handled, Prometheus API
timings on 3.7 billion samples over thirty days, and what stays expensive.
The ClickHouse CI job also runs the query crate's tests, where the PromQL
corpus's ClickHouse variant lives.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
Security:
- Reads run readonly = 2 with max_execution_time and
  cancel_http_readonly_queries_on_client_close, so ClickHouse stops what
  telemetryd stopped waiting for, and a read never carries write rights.
- Plain http to a ClickHouse on another machine is refused unless
  clickhouse.allow_plaintext says otherwise, as relay.upstream is; a URL
  with credentials or parameters is refused, pointing at user and password.
- Clients — senders, dashboards, unauthenticated /ready — get a short
  reason; the host, the SQL and ClickHouse's own text go to the log and
  /status.

Robustness:
- One ureq agent per client: connections and TLS sessions are reused instead
  of made per request.
- /ready runs an authenticated query in the configured database, reused for
  a second, so a wrong password or a missing database takes an instance out
  of the balancer instead of leaving it ready and refusing every write.

Operators:
- A reload refuses every [clickhouse] change by name, as it does the other
  keys a restart is needed for; a TTL ClickHouse refuses is logged as an
  error, and one not managed (create_schema = false) as a warning.
- Startup failures from ClickHouse say so, instead of blaming a locked data
  directory; with create_schema = false a missing table stops startup,
  named.
- telemetryd clickhouse schema prints every statement for a DBA;
  telemetryd clickhouse check checks the account, database, tables,
  system.parts, cluster and storage policy before a deployment.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
Two instances starting together against different replicas: the second's
CREATE DATABASE ON CLUSTER can return before its own replica has run the
first's, and a CREATE TABLE in it is then refused. Wait, up to a minute, for
the database to appear here. The cluster test passes five runs in five.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
…r, day partitions

- completeness and repair take argMax(Seen, Version) per series and bucket, so a
  refold that has not merged yet is not counted twice
- the name index is UInt32 and index 0 (not found) is skipped, no UInt16 wrap
- repair refolds only the incomplete series of each day (whole day past 5k series)
- completeness checks whole days first, then only the hours a plan still needs
- the fold lease is read back before it is trusted and renewed after each day
- the raw read behind a fold is capped; past it the window answers from samples
- counts are partitioned by day and folds by (tier, day): no over-retention
- explicit 'UTC' in date conversions; a Starts column of type DateTime('UTC')
- selectors cap at 1M series and switch to a series subquery past 10k

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
…regex, resolved keys

- search_traces no longer caps the window read in SQL: spans the residual
  predicate turns down no longer end a search early (regression test).
- A regex is decided in ClickHouse only when RE2 reads it as Rust does;
  \w \d \s \b are ASCII in RE2, so such patterns are left to the residual.
- Undecided rows in an aggregate are capped (100k); past that, or for
  quantiles, the scan path answers instead.
- of_traces reads each trace's own time bounds from the trace-id table and
  fetches spans by (TraceId, Timestamp) in chunks, ordered and limited.
- otel_trace_keys (+ MV, trace_keys_since marker): the hourly attribute keys
  per service, so relaxed and unscoped fields resolve to the keys that exist
  instead of every sanitize-equal spelling; unscoped group-bys push down.
- otel_traces_trace_id_ts is an AggregatingMergeTree keeping min Start /
  max End per trace, partitioned by day (new deployments).

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
- existing_columns qualified its columns: the alias name shadowed
  system.columns.name, so every ADD COLUMN ran again on each start
- CREATE DATABASE only when the database is missing
- logs planner rewrite; stream matcher regexes the same in RE2 and Rust
- unreachable test: a stand-in ClickHouse for create_schema=false

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
- /status: ClickHouse requests and last error as objects; app usage read
  off the runtime and cached for 60 s; /metrics gauges off the runtime
- telemetryd status shows the ClickHouse store and a recent failure
  instead of a local disk budget that guards nothing
- startup says it stores in ClickHouse, and where
- TELEMETRYD_TEST_CLICKHOUSE_REQUIRED=1 turns a skip into a failure; CI
  sets it. README and BUILD-STATUS say how to run them
- otel_trace_keys in the cluster test's replica sync

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
- every table and view, partitions, the fold lease, the meta table
- clickhouse check/schema, startup refusal, same config on every instance
- upgrades keep existing layouts; otel_trace_keys backfill
- storage policy on existing tables, move_factor, metric samples move too
- 18.7 bytes a line, sizing, monitoring metrics and alerts, /status shape
- grants, readonly reads and max_execution_time, TLS, no mTLS
- delivery: any refused insert is a 503 with Retry-After
- migration via export --to, BACKUP DATABASE, WebSocket tail behind a balancer
- threat model: query input to SQL, credentials, the database as the boundary

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
…ls.ca_file

One window over the whole range read some 70M rows for a page of 100:
ClickHouse reads every part in the range at once before the limit stops
it. Windows sized from the hourly rate find the page in about 1M rows
(errors over 24h of 2B lines: 7.5 s -> 0.05 s).

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
…ile refused

The global permit was taken before the share was checked, so a dozen
refused requests from a noisy client held permits for the moment it took
to refuse them, and a quiet client found the queue full: the soak's
"the quiet client is never refused" failed intermittently, on main too.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
… skips granules

The token index skipped granules only on tokens a pattern holds whole, so
|= "9f8e7d6c" read every granule. A 4-gram bloom filter skips on any
substring of four characters or more: on 20M lines it skipped 2541 of 2544
granules for an id the token index skipped none of, 2439 of 2544 for
key=k49999 against 2148, and is smaller (58 MB against 63). Existing
tables get it for new parts; their token index stays.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
…it found

Growing fourfold from a window sized for the unfiltered rate took a dozen
windows to find a page for a rare term, each a request reading its skip
indexes again. The next window is now sized from the matches the range read
so far held, half again, and never less than fourfold.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL

@cbox-agents cbox-agents Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Talking to Cbox
  • Reply under a finding to ask Cbox about it.
  • /cbox fix in reply to a finding: Cbox fixes it on this pull request's branch, or, when it can't push there, on a new branch whose push waits for a confirm in Cbox.
  • /cbox review reviews this pull request again now.
  • /cbox <question> asks Cbox about this pull request.
  • /cbox remember <rule> proposes a rule for Cbox's reviews; an owner or admin approves it in Cbox.
  • /cbox config shows the review settings this pull request uses and where each comes from.
  • /cbox help shows this list.

Only people who can push to this repository and have linked their GitHub account in Cbox can use /cbox.

cursor = last + 1;
write_meta(client, HOURS_CURSOR, &cursor.to_string())?;
// Renewed as it goes: a pass over weeks of backlog outlasts one lease.
write_meta(client, LEASE, &format!("{owner}@{}", now + LEASE_NANOS))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lease "renewal" reuses the pass's fixed now, so it never extends past the lease's first expiry. Not blocking.

Suggested change
write_meta(client, LEASE, &format!("{owner}@{}", now + LEASE_NANOS))?;
write_meta(client, LEASE, &format!("{owner}@{}", crate::now_nanos() + LEASE_NANOS))?;

A filter decided here, not in ClickHouse, kept one line in a hundred and the
scan still asked for pages of a hundred: | logfmt | status >= 500 over an
hour made some 96 round trips for its page. Pages are now what is still
needed times the rate the predicate kept so far, half again, within
100..10k (1.8 s -> 0.16 s on 2B lines).

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
telemetryd clickhouse migrate [--dry-run] [--backfill] [--materialize-indexes]
- finds tables laid out differently from what this version creates by
  probing ClickHouse with the statements an instance would run, and
  rebuilds them while writes continue: new layout under a temporary
  name, EXCHANGE TABLES, old partitions copied back through a staging
  table, checked, and MOVE PARTITION'd in, each recorded as staged and
  moved so an interrupted run resumes without copying one twice
- drops objects earlier versions created, by name only
- adds missing indexes; materializes them on partitions older than the
  index (ensure now records when it adds one) and then drops the index
  they replace
- fills in rollups younger than their rows, a day at a time, by
  difference per key, so a day done twice adds nothing
- one run at a time (lease, --take-over); NDJSON with --json

telemetryd clickhouse import --from-data-dir DIR
- reads a stopped node's segments and WAL and writes the records through
  the ClickHouse write path, in windows of time halved until they fit a
  batch, never cutting through a timestamp; resumable; asks the fold
  maker to summarise imported hours again

export: a full page no longer ends partway through a timestamp, which
silently skipped the rest of it (a scrape writes thousands of samples
at one).

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
On a cluster with more than one shard each table is <name>_local on every
shard, with <name> a Distributed table over them, so every read and write
names the table as before; the views are on the local tables and each
shard rolls up its own rows. One shard and one server are laid out as
before.

- Spread: logs and their rollups at random, for scans over every shard's
  disks; spans and the trace-id index by trace id, so a lookup asks one
  shard; every metric table by series, so samples, series, counts and a
  fold's versions meet on one shard; telemetryd_meta one copy everywhere
- Writes acknowledged once every shard holds its rows
  (distributed_foreground_insert); reads and writes to each shard's first
  replica in order, so an instance reads what it wrote; subqueries run on
  each shard over its own (distributed_product_mode = local); a trace-id
  read asks one shard (optimize_skip_unused_shards)
- Refused at startup: no {shard}/{replica} macros, several replicas
  without internal_replication, tables laid out for another number of
  shards (one vs several, or a recorded count that has changed)
- system.parts for what each app holds summed over one replica per shard
- telemetryd clickhouse schema asks the server, or --shards one|many;
  check reports the layout
- DDL waits for the end of the statement (wait_end_of_query): ON CLUSTER
  answered with its first server's row, so the next statement could run
  before this one had everywhere, and a later server's failure went unread
- Tests: scripts/clickhouse/sharded (2 shards x 2 replicas), the whole
  ClickHouse suite again on it (TELEMETRYD_TEST_CLICKHOUSE_URL_CLUSTER),
  tests/clickhouse_shards.rs; unique test database names (two tests
  sharing a microsecond shared a database)

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
The body index goes on each shard's local table; migrate leaves a layout
over several shards alone, which only this version creates.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
…rded

An earlier version never laid tables out over several shards, so the old
layouts are built on the server alone, and on a sharded cluster migrate is
held to saying there is nothing older to bring up.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL

@cbox-agents cbox-agents Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 of 1 addressed.

Talking to Cbox
  • Reply under a finding to ask Cbox about it.
  • /cbox fix in reply to a finding: Cbox fixes it on this pull request's branch, or, when it can't push there, on a new branch whose push waits for a confirm in Cbox.
  • /cbox review reviews this pull request again now.
  • /cbox <question> asks Cbox about this pull request.
  • /cbox remember <rule> proposes a rule for Cbox's reviews; an owner or admin approves it in Cbox.
  • /cbox config shows the review settings this pull request uses and where each comes from.
  • /cbox help shows this list.

Only people who can push to this repository and have linked their GitHub account in Cbox can use /cbox.

) else {
return Ok(records);
};
if first == last {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a full page is one timestamp from end to end, all_at reads every record at that nanosecond with no cap, which bypasses MAX_LIMIT. Not…

In the at scan, set abort_over to a bound such as MAX_LIMIT * 10 so the request fails with BadRequest instead of materialising an unbounded timestamp.

tower_http's timeout answered a bare 503, with no body and no Retry-After.
With ClickHouse as the store an insert waits up to clickhouse.timeout, 30 s
by default like server.request_timeout, so with Keeper down every sender got
a 503 that did not say when to come back. The limit is now our own
middleware, answering Unavailable like every other refusal to wait.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
A runbook for a fleet on ClickHouse: ClickHouse, a replica or Keeper down
(drilled on the test cluster), a disk filling, too many parts, slow queries,
upgrading telemetryd and ClickHouse, rotating the password, backup and
restore on a cluster, the cluster's server config, and what to watch in
ClickHouse itself. Plus every command and flag on one page, and the body
index named for what it is now.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL

@cbox-agents cbox-agents Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 of 1 addressed.

Talking to Cbox
  • Reply under a finding to ask Cbox about it.
  • /cbox fix in reply to a finding: Cbox fixes it on this pull request's branch, or, when it can't push there, on a new branch whose push waits for a confirm in Cbox.
  • /cbox review reviews this pull request again now.
  • /cbox <question> asks Cbox about this pull request.
  • /cbox remember <rule> proposes a rule for Cbox's reviews; an owner or admin approves it in Cbox.
  • /cbox config shows the review settings this pull request uses and where each comes from.
  • /cbox help shows this list.

Only people who can push to this repository and have linked their GitHub account in Cbox can use /cbox.

done.folds_written += roll_hours(client, (cursor, last), None, now)?;
done.hours += last - cursor + 1;
cursor = last + 1;
write_meta(client, HOURS_CURSOR, &cursor.to_string())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roll_up writes its own in-memory cursor after each day and overwrites a rewind that refold_from made in the meantime. Not a blocker.

Before writing HOURS_CURSOR, and the same for DAYS_CURSOR, re-read it and write min(stored, cursor) only when the stored value has not moved below the value this pass started from; or have refold_from record a separate pending-rewind key that roll_up honours.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2a5473c.

let value = super::metrics::joined_value(f64::from_bits(read(16)), row[24]);
samples.entry(read(0)).or_default().push((read(8), value));
held += 1;
if held > MAX_RAW_SAMPLES {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fold shortcut's raw read buffers up to a fixed 20M samples and ignores limits.max_query_samples and whole_reads_allowed. Not a blocker.

Pass the query's resolved sample limit (or the whole_reads_allowed budget) into metric_folds::fold_windows, and stop at the lower of that and MAX_RAW_SAMPLES.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2a5473c.

- export: a full page that is one timestamp end to end read every record at
  that nanosecond, past MAX_LIMIT. Now up to ten pages' worth, and refused
  with limit_exceeded beyond, rather than cut short.
- folds: roll_up wrote its hours and days cursors over a rewind refold_from
  made meanwhile (an import), so the imported hours were never folded
  again. It now stops the pass when the stored cursor moved below it.
- folds: the fold shortcut's raw read held up to a fixed 20M samples
  whatever limits.max_query_samples said. It now declines past the query's
  own ceiling, and the ordinary read refuses with the usual reason.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL

@cbox-agents cbox-agents Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 of 2 addressed.

Talking to Cbox
  • Reply under a finding to ask Cbox about it.
  • /cbox fix in reply to a finding: Cbox fixes it on this pull request's branch, or, when it can't push there, on a new branch whose push waits for a confirm in Cbox.
  • /cbox review reviews this pull request again now.
  • /cbox <question> asks Cbox about this pull request.
  • /cbox remember <rule> proposes a rule for Cbox's reviews; an owner or admin approves it in Cbox.
  • /cbox config shows the review settings this pull request uses and where each comes from.
  • /cbox help shows this list.

Only people who can push to this repository and have linked their GitHub account in Cbox can use /cbox.

Comment thread crates/server/src/export.rs Outdated
limit: "records at one timestamp",
detail: format!(
"more than {AT_ONE_TIMESTAMP} records share the timestamp {last}, and a \
page cannot end partway through one; narrow the export by selector"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refusal tells the client to narrow the export by selector, but ExportParams has no selector. The range can then never be exported. Not a…

Either let the export continue part-way through a timestamp (for example with an offset or cursor parameter), or remove the selector advice and tell the user to narrow start/end, which is the only lever the endpoint has.

…-ups

- folds: an import rewound the fold cursors itself, and a pass in flight
  could write its progress over the rewind in the moment between its check
  and its write. An import now leaves a request under a key of its own, and
  only the lease holder moves the cursors, taking every pending request
  before it reads them. The single-node import test now imports behind a
  fleet that has already summarised, and fails without this.
- export: the refusal past two million records at one timestamp told the
  client to narrow by selector, which the export API does not take. It now
  says what it is.
- PromQL: the fold shortcut's sample ceiling is shared by a query's
  selectors, so together they hold no more than one read could.

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
@sylvesterdamgaard
sylvesterdamgaard merged commit a7d42d9 into main Oct 25, 2026
14 checks passed
sylvesterdamgaard added a commit that referenced this pull request Oct 25, 2026
Everything since 0.72.2:

- ClickHouse as an optional store for logs, traces and metrics (#22):
  stateless instances behind a load balancer over one database, on one
  server, a replicated cluster or several shards. Loki, Tempo and
  Prometheus APIs unchanged. Retention as TTL, with hot and cold volumes.
- telemetryd clickhouse schema, check, migrate and import.
- Docs: scaling out on ClickHouse, operating it, and a command-line
  reference.
- Fixes found on the way: relay fairness under load, a timed-out request
  now says when to retry, and export no longer skips records sharing one
  timestamp at a page boundary.
- tokio 1.54.1 (RUSTSEC-2026-0386), and dependency bumps (#16, #17, #18,
  #20).

Claude-Session: https://claude.ai/code/session_01LtsaMBeethcCYvKx8PTBYL
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant