At scale, analytical query systems face a recurring pattern: the same time-range queries are issued over and over by dashboards, scheduled jobs, and ad-hoc exploration. When each of those queries traverses the full compute path, it wastes cycles re-deriving answers that are already known.

This post walks through how we built an interval-aware caching layer on top of Druid that dramatically reduces redundant work while keeping results correct as data arrives late.

The problem

Druid is optimized for slicing time-series data by interval. A typical dashboard query asks for metrics over the last 24 hours, the last 7 days, or a specific business window. Two queries that differ only by a few seconds of range can still require independent scans, even though 99% of the underlying segments are shared.

A naive whole-query cache fails here for two reasons:

Interval-aware keys

Instead of caching the full query result, we decompose each query into closed and open intervals. Closed intervals point at segments Druid has already sealed; these are safe to cache indefinitely. Open intervals cover the live edge of the data and are always recomputed from source.

T-7d T-5d T-3d T-1d now sealed sealed sealed sealed open Closed intervals → cache hit Open → live recompute
Query time range split into sealed (cacheable) and open (live) intervals

A simplified key derivation looks like this:

def cache_keys(query, segments):
    closed, open_ = [], []
    for seg in segments.covering(query.interval):
        if seg.is_sealed():
            closed.append(("seg", seg.id, seg.version, query.agg))
        else:
            open_.append(seg)
    return closed, open_
The key insight: once a segment is sealed, any aggregate over it is a pure function of the segment ID. That's a cache hit we can reuse across every dashboard asking the same question.

Late arrivals and correctness

Real-world pipelines don't always deliver events in order. To handle this, each cached interval records the segment version it was computed against. When a segment is replaced, due to compaction, late events, or re-ingestion, the cache entry is invalidated atomically.

on_segment_replaced(old_id, new_id):
    for key in cache.scan(prefix=("seg", old_id)):
        cache.delete(key)
    metrics.inc("cache.invalidations")

Results

After rolling this out across the analytics fleet we observed:

What's next

We're exploring adaptive TTLs on open intervals based on observed event-arrival patterns, and a shared cache layer across regions so a rebuilt dashboard in one datacenter warms the others.

If you're operating analytics systems at scale and want to chat about interval caching, reach out, we'd love to compare notes.