How a query becomes points — the four-stage read path, parse to aggregate

A read is four sequential stages — parse selectors, resolve labels to series IDs, decompress the matching chunks for the time range, then aggregate — and only stage 3's cost scales with how far back you look.

Previously

Writes are durable. Now flip the system: a query says {method=POST, status=500} and we need to walk from those labels to actual bytes on disk.

Scene 07

How a query becomes points

  1. Watch
  2. Try it
  3. Predict
  4. Capture
sum(rate(http_requests_total{method=POST,status=500}[5m]))QUERY1 · PARSE0 msSELECTORS{__name__=http_requests_to…{method=POST}{status=500}[5m]range2 · SERIES RESOLVE0 msINDEXposting list0 hitsSERIES IDS3 · DECOMPRESS0 msCHUNKSRAW POINTS4 · AGGREGATE0 msREDUCEΔ/trate()OUTPUT—TIMING · per stage0 ms totalRead path idle — query waiting (range [5m]).
What to watch for

A PromQL query enters on the left. Watch it walk the four stages: parse splits text into selectors and a range, resolve hits a (still-opaque) index that emits a small set of series IDs, decompress unpacks the matching chunks for the [5m] window, and aggregate folds them into one number. The timing bar at the bottom shows where the milliseconds went.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

TSDB.query
the read path: four sequential stages, one per call
def query(text, t0, t1, fn):
selectors, range = parseQuery(text)
series_ids = resolveSeries(selectors)
points = decompressAndAggregate(
series_ids, t0, t1, fn,
)
return points
parseQuery
stage 1 — tokenise text into selectors and a range
def parseQuery(text):
ast = promql.parse(text)
selectors = []
for matcher in ast.label_matchers:
selectors.append(
(matcher.name, matcher.value),
)
range = ast.range # e.g. [5m], [1h], [30d]
return selectors, range
resolveSeries
stage 2 — selectors hit the inverted index (black box)
def resolveSeries(selectors):
# postings list per (label, value)
# cost depends on cardinality, NOT on range
postings = [
index.postings(label, value)
for (label, value) in selectors
]
return intersect(postings) # → {S3, S7}
decompressAndAggregate
stages 3 & 4 — unpack chunks in [t0,t1], then fold
def decompressAndAggregate(ids, t0, t1, fn):
points = []
for sid in ids:
for chunk in chunksFor(sid):
if chunk.overlaps(t0, t1):
# delta-of-delta + XOR decode
points += chunk.decompress()
return fn(points) # rate / sum / avg

Where this sits in Build a Prometheus-style time-series database

Scene 07 of 12. A read is four stages — parse, resolve label-selectors to series IDs, decompress the matching chunks, then aggregate. Stage 3 dominates.

Up next. Three of the four stages are clear. Stage 2 is still a black box — how does a database go from {method=POST} to a set of integer series IDs in microseconds?

All 12 scenes in Build a Prometheus-style time-series database · Every curriculum

Built with Arqly
Every scene in Build a Prometheus-style time-series database builds on the one before it.All 12 Build a Prometheus-style time-series database scenes