Streaming Window Processing

Streaming window processing aggregates movement telemetry as it arrives, grouping observations by the time they happened rather than the time they were received, and deciding explicitly how long to wait for stragglers before declaring a window final.

Everything difficult about it follows from one fact: mobility devices deliver late, and they deliver late in ways that correlate with exactly the situations the analysis cares about. A vehicle in an underground car park, a phone in a tunnel, a tracker in a rural notspot — each produces a burst of delayed events that arrive long after the window they belong to would have closed on wall-clock time. A streaming pipeline that ignores this is not faster than a batch one; it is a batch pipeline that silently drops the data hardest to collect.

Prerequisites

  • A synchronized, monotone timeline. Every window boundary is meaningless without it; see time-series synchronization strategies for the UTC normalization and clock-drift work this assumes.
  • Provenance on the timestamp. The stream must record which clock produced the event time — sensor, device OS or gateway — because they differ by up to thirty seconds and the watermark is sized against that difference.
  • A partitioning key. Usually the entity id, so that state and ordering are per device rather than global.
  • Runtime. The concepts below are runtime-agnostic; the examples use plain Python so the semantics stay visible, and map directly onto Flink, Kafka Streams, Spark Structured Streaming or Beam.

Event Time, Processing Time, and the Gap Between Them

The practical consequence is that “how late is late?” is a fleet question rather than an engineering one. A single watermark across mixed populations either drops most of the slow population’s data or holds the fast population’s results hostage to it. The usual resolution is to partition the stream by population and give each its own allowed lateness, accepting that different partitions finalise at different times and that the consumer must handle that.

Window Shapes and What Each Costs

Shape Definition Right for State per entity Trap
Tumbling fixed width, disjoint totals that must reconcile 1 accumulator none — this is the default
Sliding fixed width, advancing continuously smoothed live signals ⌈width ÷ slide⌉ accumulators records counted many times
Hopping fixed width, fixed hop dashboards with overlap ⌈width ÷ hop⌉ accumulators totals do not sum to the input
Session closed by an inactivity gap trips and dwell episodes 1 open session one long trip pins state open
Global never closes, fires on triggers running lifetime metrics unbounded without eviction grows until it fails

The state column is the one that decides architecture. A 60-minute sliding window advancing every minute holds sixty accumulators per entity simultaneously; across 50 000 vehicles with a 200-byte accumulator that is roughly 600 MB before any framework overhead, and it grows linearly with fleet size. Incremental aggregations — those that can be updated with a single number rather than by retaining the members — are what keep that tractable, which is why mean and count are cheap in a sliding window and exact median is not.

The Watermark Decision

Implementation Walkthrough

The state machine below is deliberately framework-free: it shows the semantics that a runtime implements for you, which is exactly what you need to reason about when the runtime does something surprising.

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


@dataclass
class WindowState:
    """Incremental accumulator — never retains members, so state stays O(1)."""
    count: int = 0
    dist_m: float = 0.0
    emitted: bool = False

    def add(self, dist_m: float) -> None:
        self.count += 1
        self.dist_m += dist_m


@dataclass
class TumblingWindower:
    """
    Event-time tumbling windows with a watermark and bounded allowed lateness.

    Emits (entity, window_start, count, dist_m, revision) tuples. Every emission
    is keyed by (entity, window_start), so a downstream upsert makes replay safe.
    """
    width: timedelta
    allowed_lateness: timedelta
    state: dict = field(default_factory=lambda: defaultdict(WindowState))
    watermark: datetime | None = None

    def _window_start(self, t: datetime) -> datetime:
        # Anchor to the epoch so bin edges are identical across every worker
        # and across every re-run. Never anchor to "first event seen".
        epoch = datetime(1970, 1, 1, tzinfo=t.tzinfo)
        n = (t - epoch) // self.width
        return epoch + n * self.width

    def push(self, entity: str, event_time: datetime, dist_m: float) -> list[tuple]:
        """Ingest one event; return any window results that became final."""
        if event_time.tzinfo is None:
            raise ValueError("event_time must be timezone-aware (UTC).")

        # The watermark is the highest event time seen, minus allowed lateness.
        if self.watermark is None or event_time > self.watermark + self.allowed_lateness:
            self.watermark = event_time - self.allowed_lateness

        w_start = self._window_start(event_time)
        w_end = w_start + self.width

        if w_end + self.allowed_lateness <= self.watermark:
            # Beyond the seal: count it, do not silently discard it.
            return [("__dropped__", w_start, entity, dist_m, -1)]

        st = self.state[(entity, w_start)]
        st.add(dist_m)

        out = []
        # Close every window whose end is now behind the watermark.
        for (ent, start), s in list(self.state.items()):
            if start + self.width <= self.watermark and not s.emitted:
                s.emitted = True
                out.append((ent, start, s.count, s.dist_m, 0))
            elif start + self.width <= self.watermark and s.emitted and (ent, start) == (entity, w_start):
                # A late-but-allowed event revised an already-emitted window.
                out.append((ent, start, s.count, s.dist_m, 1))
        return out

    def evict(self) -> int:
        """Drop sealed windows. Call periodically or state grows without bound."""
        if self.watermark is None:
            return 0
        cutoff = self.watermark - self.allowed_lateness
        stale = [k for k, _ in self.state.items() if k[1] + self.width <= cutoff]
        for k in stale:
            del self.state[k]
        return len(stale)

Three properties of that implementation are the ones worth carrying into any runtime. Window starts are anchored to the epoch rather than to the first event, so two workers processing different partitions agree on the bin edges and a re-run reproduces them. Emissions carry a revision number, so a consumer can tell a first result from a correction. And evict exists at all — a windowed stream without eviction is a memory leak with a schedule.

Calibration and Parameter Tuning

Parameter How to choose Consequence of getting it wrong
Allowed lateness p99 of measured delivery lag Too low: silent loss concentrated in garages and tunnels
Window width the smallest period a consumer acts on Too narrow: unstable counts; too wide: late detection
Idle-partition timeout 2 × the expected inter-event interval Watermark stalls behind one quiet partition
Eviction horizon allowed lateness + a safety margin State grows until the job dies
Emission mode update on late data Consumers see revisions they cannot reconcile

Of these, the idle-partition timeout is the one that surprises people. A watermark advances with the minimum across partitions, so a single quiet device or an empty Kafka partition holds every window open indefinitely. Every serious runtime offers an idleness timeout; it needs to be set, not left at its default.

Integration and Compatibility

The strongest requirement on a streaming windower is that it agrees with the batch pipeline computing the same thing. That agreement is not automatic, and it usually breaks in one of three places: bin edges anchored differently, a stop-gap threshold that differs between the session window and trajectory segmentation, or a batch job that sees late data the stream had already sealed.

The workable pattern is to make the batch job authoritative and the stream provisional. The stream emits fast, revisable results keyed by window and entity; the batch job recomputes the same keys from the archive once the data is complete and upserts over them. Consumers read one table and get low latency now and correctness later, without either pipeline having to pretend it is the other. That also gives a free correctness check — the size and sign of the batch corrections is a direct measurement of whether the watermark is right.

Downstream, rolling statistics for mobility metrics and dynamic time binning strategies both assume windows that mean the same thing every time they are computed, which is precisely what epoch-anchored edges and idempotent emission provide.

Validation and Testing Patterns

Streaming pipelines fail in ways that batch pipelines cannot, and the tests have to target those specifically rather than checking that the aggregation arithmetic is right.

Replay determinism. Feed the same recorded event sequence twice and assert the emitted results are byte-identical. Anything that breaks this — wall-clock defaults, unordered map iteration, a window start anchored to the first event seen — is a bug that will show up as unreproducible numbers long before anyone suspects the windower.

Out-of-order injection. Take a clean stream, shuffle a controlled fraction of events backwards in time by a known amount, and assert the final totals match the in-order run. This is the only test that actually exercises the watermark, and it is the one most often missing. Vary the shuffle distance across the allowed-lateness boundary so both the update path and the drop path are covered.

Idle-partition simulation. Stop feeding one partition and assert that windows on the others still close. A watermark held back by a silent partition is the most common cause of a stream that “stops producing output” while every health check reports green.

State-size assertion. Track open windows per entity and total state bytes as a metric, and alert on growth. A missing eviction is invisible until the job dies, and it always dies at the least convenient moment — the point of highest traffic, which is also the point of highest state.

In This Section

FAQ

What is the difference between event time and processing time?

Event time is when the observation happened on the device; processing time is when it reached the pipeline. For mobility telemetry the gap is routinely minutes and occasionally hours, because vehicles park in garages, phones sleep and networks drop. Windowing on processing time is fast and produces numbers that change whenever the network does; windowing on event time is slower and produces numbers that mean what they say.

How long should the watermark allow for late data?

Measure, do not guess. Collect arrival minus event time over a representative week and set allowed lateness at a high percentile, typically the 99th. For urban scooter fleets that is often under a minute; for HGVs parking in underground bays it can exceed four hours. Then decide explicitly what happens beyond it — dropped and counted, or routed to a correction path.

Do I need exactly-once processing for movement aggregates?

You need idempotent output, which is easier and usually sufficient. If each result is keyed by window plus entity and written as an upsert, a replay overwrites rather than adds, and the aggregate is correct however many times the record was processed. True exactly-once delivery is only necessary when the sink cannot be keyed.

How much state does a windowed stream hold?

Roughly active entities × open windows per entity × per-window state. A 60-minute sliding window advancing every minute keeps 60 windows open per entity, so 50 000 vehicles with a 200-byte accumulator is about 600 MB before overheads. This is why sliding windows are expensive and why incremental aggregations matter.

Should session windows or fixed windows define a trip?

Session windows, because a trip is defined by inactivity rather than by the clock. A session window closes after a configurable gap of no events, which is exactly the trip-splitting rule used in batch segmentation. Keeping the same gap threshold in both paths is what allows live and next-day trip counts to agree.

Back to Temporal Aggregation & Window Mapping