Our engine never sees your warehouse password
datahashi

BlogQuerying

Rollups: answer hot questions without the warehouse

Rollups now ship in datahashi, hosted and self-hosted. Declare one in YAML, and the engine builds it through your Query Runner and answers matching queries from it - with the limits stated up front.

The datahashi team4 min read

A dashboard asks the same coarse question all day: revenue by region, orders by status. Until now, every one of those requests compiled to SQL and ran against your warehouse, bounded only by a few-second result cache. Rollups change that. You declare the question once, the engine builds the answer, and later requests for it never touch the warehouse.

Rollups ship in both editions: the hosted engine and the self-hosted community binary.

Declare a rollup

A rollup is a rollups: block in the model YAML. It names one entity, the dimensions to group by, and the measures to pre-aggregate:

rollups:
  - name: order_totals_by_status
    entity: order
    dimensions: [status]
    measures: [revenue, order_count]
    refresh: {every: 1h}   # or {cron: "0 6 * * *"}, UTC

It can also carry filters, in the same shape a measure’s filters use. It adds no new name to query. Callers keep asking for revenue by status; the rollup is a serving path, not part of the vocabulary.

How it gets built

A build is an ordinary governed query. The engine turns the rollup into a Semantic Query, compiles it, and sends it through your Query Runner like any other request. The runner sees a plain SELECT, because that is all it is.

The result is stored as Parquet on the engine’s side - never in your warehouse. That is deliberate: the Query Runner only runs SELECT, so a stolen runner key can never write, and rollups did not reopen that door. Self-hosted, the Parquet lives in a folder you choose with ENGINE_ROLLUP_STORE_DIR. Hosted, it lives in storage the engine owns.

Builds share the same concurrency limit as live queries, so a burst of rebuilds cannot starve your users.

How a query uses it

When a query arrives, the engine checks the rollups declared on the model:

  • The rollup covers it. The dimensions, measures, and filters all fit, so the answer is read from the Parquet with no warehouse call.
  • The query is coarser. Total revenue with no status still fits: the rollup is re-aggregated (sums and counts summed again, min and max taken again).
  • Anything else runs live, exactly as before.

The rows are the same shape either way. meta.rollup says whether a rollup answered (used, and joined when several did) or why each candidate missed. The query log also records rollup_hit.

Freshness

A rollup is rebuilt on its refresh: schedule. Without one, it is rebuilt when a default interval passes, if one is set, or only when the model changes.

When you publish a change to the model, or change the rollup itself, the old build goes stale. A stale build is never served: those queries run live until the rebuild lands.

Between rebuilds, a rollup answers as of its last build. That is the trade. Pick a refresh: that matches how fresh the question needs to be: hourly for an operations dashboard, daily for a monthly report.

Governance does not bend

A rollup is built without any caller’s row rules, so the engine is careful about who it serves:

  • Grants are checked before a rollup is considered, exactly as for a live query.
  • A query under a row-level security filter on the rollup’s entity runs live. A rollup built for everyone must not answer for someone who may only see some rows.
  • The catalog lists only the rollups a caller could actually use. Agents calling list_catalog see them, so they can prefer a shape a rollup answers when two shapes answer the question equally well. An agent must never drop a filter or change the metric to hit one.

The limits, stated plainly

  • One entity’s measures per rollup. The rollup can store a dimension from another entity when the path is one chain of many_to_one or one_to_one. A query that joins anything else runs live. A query that mixes measures from two entities can be answered by joining one rollup per entity. If any entity has no matching rollup, the whole query runs live.
  • Exact distinct counts are served only at the stored grain. count_distinct_approx and percentiles can be listed under sketches (HyperLogLog, about 1.6% error) and then re-aggregated to a coarser grain.
  • You declare rollups in the model. The community binary does not invent them. Hosted can suggest and build them from traffic.
  • No latency guarantee. Rollups make repeated questions cheap and fast. They are not a p99 SLO.

When to add one

Start from what you can see. Metering shows which query shapes run most and what they cost. A dashboard panel that refreshes all day, or a report that runs every morning, is a good candidate. A one-off exploration is not.

Model for clarity first: good grains, lean joins, filters that push down. Then add rollups for the few questions that are genuinely hot.

Try it self-hosted

The community quickstart already declares order_totals_by_status and sets ENGINE_ROLLUP_STORE_DIR in docker-compose.yml. Run make up, and the engine logs rollup refreshed at boot. Ask for revenue by status, and the query log shows rollup_hit: true.

Product notes

An occasional email when we ship something or write something worth reading.