Computing tumbling and sliding windows over telemetry streams

The arithmetic of a windowed aggregate is trivial; the engineering is entirely about state. A tumbling window keeps one accumulator per entity, a sliding window keeps one per open window, and the difference between an aggregation that updates from a single number and one that needs its members is the difference between a job that runs for months and one that dies at peak traffic.

Why this happens

A window is a set of events, but keeping the set is almost never necessary. Count, sum, mean, min, max, variance and any percentile approximation can all be maintained as a fixed-size accumulator updated once per event. Exact median, exact distinct count and “the list of vehicles seen” cannot: their state grows with the number of events in the window.

For mobility telemetry that distinction decides the architecture, because the entity count is large and the windows are long. Fifty thousand vehicles with a one-hour sliding window advancing every minute means three million open windows at any instant. At 200 bytes of accumulator each that is manageable; at “retain the fixes” it is not, and no amount of tuning rescues it. The window-shape trade-offs are set out in streaming window processing; this page implements them.

Core pipeline

  1. Anchor bin edges to the epoch, never to the first event seen, so every worker and every re-run agrees on the boundaries.
  2. Assign each event to its window(s) — one for tumbling, ⌈width ÷ slide⌉ for sliding.
  3. Update an incremental accumulator rather than retaining members, and reach for a sketch when a quantile is genuinely required.
  4. Emit and evict on the watermark, keying every result by window and entity so replays are idempotent.

Production-ready Python implementation

PYTHON
import math
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta

EPOCH = datetime(1970, 1, 1)


@dataclass
class Welford:
    """Numerically stable mean and variance in constant space.

    A naive sum-of-squares accumulator loses precision badly once the values
    are large and similar — which is exactly the case for cumulative distance
    or UTM coordinates. Welford's method costs one extra multiply and does not.
    """
    n: int = 0
    mean: float = 0.0
    m2: float = 0.0
    vmin: float = math.inf
    vmax: float = -math.inf

    def add(self, x: float) -> None:
        self.n += 1
        d = x - self.mean
        self.mean += d / self.n
        self.m2 += d * (x - self.mean)
        self.vmin = min(self.vmin, x)
        self.vmax = max(self.vmax, x)

    @property
    def variance(self) -> float:
        return self.m2 / (self.n - 1) if self.n > 1 else 0.0


@dataclass
class SlidingWindower:
    """
    Event-time sliding windows with bounded state and epoch-anchored edges.

    Parameters
    ----------
    width, slide : timedelta
        width must be an exact multiple of slide, or windows do not tile and
        an event's membership depends on where the stream happened to start.
    allowed_lateness : timedelta
        How long a window stays open past its end before it is sealed.
    """
    width: timedelta
    slide: timedelta
    allowed_lateness: timedelta
    state: dict = field(default_factory=lambda: defaultdict(Welford))
    watermark: datetime | None = None

    def __post_init__(self):
        if self.slide <= timedelta(0) or self.width <= timedelta(0):
            raise ValueError("width and slide must be positive.")
        if self.width % self.slide != timedelta(0):
            raise ValueError(
                f"width {self.width} is not a multiple of slide {self.slide}; "
                "windows would not tile consistently."
            )

    def _windows_for(self, t: datetime) -> list[datetime]:
        """Every window start whose span contains t. Anchored to the epoch."""
        epoch = EPOCH.replace(tzinfo=t.tzinfo)
        k = (t - epoch) // self.slide
        n_open = self.width // self.slide
        return [epoch + (k - i) * self.slide for i in range(n_open)
                if epoch + (k - i) * self.slide + self.width > t]

    def push(self, entity: str, event_time: datetime, value: float) -> list[tuple]:
        if event_time.tzinfo is None:
            raise ValueError("event_time must be timezone-aware (UTC).")
        if self.watermark is None or event_time - self.allowed_lateness > self.watermark:
            self.watermark = event_time - self.allowed_lateness

        for w in self._windows_for(event_time):
            if w + self.width + self.allowed_lateness > self.watermark:
                self.state[(entity, w)].add(value)

        return self._emit_sealed()

    def _emit_sealed(self) -> list[tuple]:
        """Emit and REMOVE every window now behind the watermark."""
        if self.watermark is None:
            return []
        out, done = [], []
        for (ent, w), acc in self.state.items():
            if w + self.width <= self.watermark:
                out.append((ent, w, acc.n, acc.mean, acc.variance, acc.vmin, acc.vmax))
                done.append((ent, w))
        for k in done:
            del self.state[k]        # eviction is not optional
        return out

    def open_windows(self) -> int:
        """Expose the state size as a metric; alert on its growth."""
        return len(self.state)

Validation block

PYTHON
def validate_windower(w: SlidingWindower, results: list, expected_per_entity: int) -> None:
    """Assert the three properties that distinguish a correct windower."""
    # 1. Every entity emitted the same number of windows over a full stream.
    from collections import Counter
    per_entity = Counter(r[0] for r in results)
    assert len(set(per_entity.values())) == 1, (
        f"uneven window counts {dict(per_entity)} — an entity's stream ended "
        "early, or edges were anchored to first-seen rather than the epoch"
    )
    # 2. Window starts are multiples of the slide from the epoch.
    for _, start, *_ in results:
        offset = (start - EPOCH.replace(tzinfo=start.tzinfo)) % w.slide
        assert offset == timedelta(0), f"window start {start} is not on the grid"
    # 3. State is bounded — nothing is retained past sealing.
    assert w.open_windows() <= expected_per_entity * len(per_entity), (
        f"{w.open_windows()} open windows — eviction is not running"
    )
    print(f"OK — {len(results)} results, {w.open_windows()} windows still open")

Common mistakes and gotchas

  • Anchoring window starts to the first event. Two workers then disagree about the bin edges, and a re-run produces different numbers. Anchor to the epoch, always.

  • A width that is not a multiple of the slide. Windows stop tiling, and an event’s membership depends on stream start time. The constructor above rejects it rather than producing subtly wrong output.

  • Never evicting. A windowed job without eviction is a memory leak on a schedule, and it always fails at peak load — the moment the state is largest.

  • Summing sliding-window results. Each event appears in ⌈width ÷ slide⌉ windows. Any figure that has to reconcile against a source count must come from tumbling windows.

  • Naive sum-of-squares for variance. With large, similar values — cumulative distance, UTM easting — catastrophic cancellation produces negative variances. Welford costs one multiply and does not.

  • Retaining members for an “exact” percentile that nobody checks. A t-digest gives a 0.1% error at a thousandth of the state. Confirm the exactness is genuinely required before paying for it.

FAQ

How do I choose between tumbling and sliding?

By what the number is for. Anything that has to reconcile against a source count — trips, distance billed, events processed — must be tumbling, because sliding windows deliberately double-count. Anything that is a smoothed signal for a dashboard or an alert is better sliding, because a tumbling window makes a metric jump at the bin boundary rather than move.

Can session windows use the same accumulator?

Yes, and they are cheaper: one open session per entity rather than one per open window. The difference is the closing condition — a session closes after a configured gap of inactivity rather than at a fixed time — and the risk that a single long-running entity keeps a session open indefinitely, which needs a maximum session duration as a backstop.

What state size should I expect?

Entities × open windows per entity × accumulator size. For 50 000 vehicles, a one-hour window sliding every minute and a 40-byte accumulator, that is 50 000 × 60 × 40 ≈ 120 MB of pure accumulator, typically two to four times that after framework overhead. Track open_windows() as a metric; it is the earliest warning that eviction has stopped working.

Back to Streaming Window Processing