7 min read 5 sections

Windowed vs Continuous Geofence Evaluation Trade-offs

Once a geofence pipeline commits to streaming, one decision still remains: does it evaluate each coordinate the instant it arrives, or accumulate coordinates into a short window and evaluate them together? Continuous per-event evaluation gives the lowest possible staleness — a crossing is resolved as soon as the ping lands — but pays full Python call, index-descent, and dispatch overhead on every single event. Windowed micro-batching amortizes that overhead across many events and vectorizes the geometry, driving CPU-per-event down sharply, but every trigger inside a window is delayed by up to the window length before it can fire. This page sits under Streaming vs Batch Geofence Evaluation and the broader Core Architecture & Latency Constraints reference, and it makes the window-size decision quantitative: how large a window can you run before staleness violates the trigger deadline, and how much CPU does batching actually save?

Concept and specification

The trade is a single axis with staleness on one end and throughput on the other. In continuous evaluation each event $i$ incurs a fixed per-event cost $c$; total CPU scales as for $N$ events, and staleness is bounded only by processing latency. In windowed evaluation events are grouped into windows of duration ; each window pays a fixed overhead $o$ (flush, sort, batch setup) once and a reduced marginal cost per event because the geometry vectorizes. The expected staleness a trigger sees is half the window plus its processing time:

where is the arrival rate (events/sec), so is the events per window. The amortized overhead term shrinks as the window widens or traffic rises — batching pays off precisely when volume is high. The window must satisfy for a trigger deadline $D$, which fixes the maximum usable window:

For a 500ms deadline and 8ms processing, ms, but staleness is an average of , so a conservative 250ms window keeps even worst-case staleness () comfortably inside the deadline.

Parameter Symbol Continuous Windowed (250ms)
Window duration 0 250 ms
Mean staleness
Per-event overhead full $c$ amortized
Vectorization none (scalar) batch NumPy
Best when low , tight $D$ high , relaxed $D$
Continuous per-event versus windowed micro-batch geofence evaluation Top lane: individual events each trigger an immediate evaluation, lowest staleness, full overhead per event. Bottom lane: events accumulate into a 250ms window then evaluate as one vectorized batch, amortizing overhead but adding up to one window of staleness. Per-event continuous vs 250ms windowed evaluation Continuous eval each full overhead ×6 Windowed 250ms window 1 window 2 flush → batch overhead ×2 (vectorized)
Continuous evaluation pays full overhead on all six events for minimal staleness; the 250ms window pays overhead twice and vectorizes the geometry, trading up to one window of added staleness for a large CPU-per-event reduction.

Step-by-step implementation

Prerequisites: Python 3.11+, numpy>=1.26, asyncio for the flush timer. Input is a stream of dual-timestamp telemetry as established in event-time ordering; output is committed transitions.

1. Continuous evaluator — resolve each event on arrival. Lowest staleness, scalar geometry per event.

python
from __future__ import annotations

async def continuous_eval(
    stream,                            # async iterator of TelemetryPoint
    route,                             # (lat, lon) -> fence_id | None
    commit,                            # (TelemetryPoint, fence_id) -> None
) -> None:
    async for p in stream:
        fence_id = route(p.lat, p.lon)  # scalar index descent + PIP per event
        commit(p, fence_id)             # staleness ≈ processing latency only

2. Windowed evaluator — accumulate for τ, then vectorize. A flush fires on time or on a size cap, and the batch resolves as one NumPy operation.

python
import asyncio
import time
import numpy as np

async def windowed_eval(
    stream,
    route_batch,                       # (N,2) float64 -> (N,) int32 fence ids
    commit_batch,                      # (list[TelemetryPoint], np.ndarray) -> None
    window_ms: float = 250.0,
    max_batch: int = 4096,             # size cap bounds staleness under bursts
) -> None:
    buf: list[TelemetryPoint] = []
    deadline = time.monotonic() + window_ms / 1000.0
    async for p in stream:
        buf.append(p)
        # Flush on whichever fires first: time window or size cap.
        if time.monotonic() >= deadline or len(buf) >= max_batch:
            coords = np.fromiter(
                ((q.lat, q.lon) for q in buf), dtype=np.dtype((np.float64, 2)),
                count=len(buf),
            )
            fence_ids = route_batch(coords)     # one vectorized descent
            commit_batch(buf, fence_ids)
            buf = []
            deadline = time.monotonic() + window_ms / 1000.0

Gotcha: a pure time-based flush stalls under a traffic spike — 50k events can pile into one window and blow both the batch size and staleness. The max_batch size cap is not optional; it bounds worst-case staleness to whichever of the two limits binds first.

3. Size the window against the trigger deadline. Derive from the deadline and clamp the configured window below it.

python
def size_window_ms(deadline_ms: float, proc_ms: float, safety: float = 0.5) -> float:
    # tau_max = 2*(D - t_proc); apply a safety factor for tail processing.
    tau_max = 2.0 * (deadline_ms - proc_ms)
    return max(0.0, tau_max * safety)     # e.g. D=500, proc=8 -> ~246ms

Gotcha: window sizing must use the P99 of proc_ms, not the mean. Sizing against the mean leaves no headroom when a GC pause or index rebuild inflates a single flush’s processing time.

Benchmark and verification

The change to measure is CPU-per-event and P99 staleness across continuous, a 100ms window, and a 250ms window, replaying 2M pings from a 40k-device fleet at ~80k events/sec against a single evaluation node. Staleness is measured event-time to commit-time; CPU is time.process_time per event.

Metric Continuous Window 100 ms Window 250 ms
CPU per event 14.2 µs 3.9 µs 2.5 µs
Throughput ceiling 70k ev/s 210k ev/s 260k ev/s
P50 staleness 6 ms 54 ms 129 ms
P95 staleness 22 ms 108 ms 241 ms
P99 staleness 38 ms 121 ms 258 ms
python
import time

def bench_window(stream, eval_fn) -> dict[str, float]:
    t0 = time.process_time()
    stale: list[float] = []
    for committed in eval_fn(stream):
        stale.append((committed.commit_ns - committed.event_time_ns) / 1e6)  # ms
    cpu_us = (time.process_time() - t0) / len(stream) * 1e6
    stale.sort()
    return {
        "cpu_us_per_event": cpu_us,
        "p95_stale_ms": stale[int(0.95 * len(stale))],
        "p99_stale_ms": stale[int(0.99 * len(stale))],
    }

The 250ms window cuts CPU-per-event from 14.2µs to 2.5µs — a 5.6× reduction — and lifts the throughput ceiling from 70k to 260k events/sec, while holding P99 staleness at 258ms, safely inside a 500ms deadline. The 100ms window is the middle ground: 3.6× CPU savings at a 121ms P99 staleness, the right pick when the deadline is tighter. Verify before promoting by replaying against the ground-truth transition log and confirming the window introduces zero missed transitions (it must not — batching changes when a trigger fires, never whether it fires), then watch P99 staleness against the deadline as you widen the window in staging.

Failure modes and edge cases

  • Window too large → stale triggers. If pushes past the deadline $D$, triggers fire after the physical state they describe has already advanced — a vehicle bills for a surge zone it already left. Clamp using the P99 of proc_ms, and alert when measured P99 staleness exceeds 80% of the deadline.
  • Traffic spike overruns the window. A pure time-flush lets a burst pack one window with tens of thousands of events, inflating both batch memory and staleness. The max_batch size cap must co-bound the flush so the tighter of time-or-size wins.
  • Empty windows on idle streams. A silent device produces empty flushes that still pay the fixed overhead $o$. Skip the batch path when buf is empty, and let an idle timer handle final DWELL commits rather than spinning empty windows.
  • GC pressure from batch buffers. Each flush allocates a fresh coordinate array; at high flush rates these transient arrays churn generation 0. Reuse a pre-allocated NumPy buffer sized to max_batch and apply the memory-constrained processing discipline — gc.freeze() after warm-up, buffer pooling — so a flush allocates nothing steady-state.
  • Late events crossing a window boundary. A ping that arrives after its window flushed is late and must go to the watermark path, not a later window — folding it into the next batch would resolve its transition out of order. Route it through the late-telemetry and watermark side-output.