Skip to content

Query performance

Query performance

The design goal is that a query costs the size of its answer, not the size of the data. Five mechanisms get there, and each follows from knowing the query shapes in advance. Full reasoning and measurements are in

What happens to a query

Segments are pruned before they are opened. Each manifest carries the segment's time bounds, a per-label value index, and the distinct label sets it contains. A query that cannot match is skipped with no file I/O at all.

Matchers run once per stream, not once per row. Label sets are interned into a per-segment dictionary, so a segment with a million rows across fifty streams evaluates a selector fifty times.

Only surviving rows are decoded. Row selection reads two columns — a timestamp and a u32 stream id — and produces a row-index list. Decoding a record allocates strings and maps; doing that before filtering means a limit=100 query pays for every row it scans.

The predicate is pushed into Parquet. Wide columns — bodies, attribute maps, span events — are never decompressed for rows that will be discarded.

Limits stop the scan. A bounded collector holds limit records rather than every match, so peak memory is set by what was asked for. Once full, its cutoff lets whole segments be skipped: a limit=10 query over a 30-segment store opens at most three.

Queries that touch no data at all

/loki/api/v1/labels, /api/v1/label/{name}/values and /series are answered from segment metadata. No Parquet file is opened, and the answer is exact — there is no cardinality cutoff to under-report from.

Trace by id

A trace id has no useful ordering and cardinality equal to the row count, so nothing statistical can prune it. Each segment carries a Bloom filter over its trace ids, which answers "definitely not here" exactly. A lookup reads about one segment instead of every segment in the retention window.

Correctness is layered

Every one of these mechanisms may over-select. None may under-select. The record-level predicate remains the sole authority on what is returned.

That is what makes the optimisations safe to add and safe to remove: getting one subtly wrong costs speed, never correctness. The test suite asserts both halves — that pruning happens, and that the result equals a full scan.

Watching it

curl -s http://127.0.0.1:4319/metrics | grep query_segments

telemetryd_query_segments_scanned_total against telemetryd_query_segments_pruned_total is the ratio to watch when queries feel slow. A high scan count means something is defeating pruning — usually a query with no selective matcher.

Where a general engine stays ahead

Named so the trade is explicit: large aggregations over billions of rows, arbitrary GROUP BY and joins, specialised compression codecs, and mature parallel query execution. telemetryd does not compete on any of them, and that is the trade that makes a single binary with no planner and no merge scheduler possible.