7 min read 5 sections

Late-Arriving Telemetry and Watermarks in Geofence Streams

A geofence stream never receives its telemetry in the order it was recorded. Pings buffer on the device during coverage gaps, retry over lossy radio, and traverse different network paths, so a position recorded at t=10.0 can arrive after one recorded at t=11.2. The evaluator needs a rule for when it is safe to decide that a device’s history up to some instant is complete — that rule is a watermark, and how it treats the pings that arrive after it has passed determines whether a straggler is silently lost or correctly reconciled. This page sits under Streaming vs Batch Geofence Evaluation and the broader Core Architecture & Latency Constraints reference, and it answers one narrow question: given a watermark, what do you do with a late ping, and what does that choice cost in trigger correctness? It builds directly on the ordering discipline in event-time ordering and clock skew, which establishes why the two timelines must stay separate; here we quantify the lateness policy itself.

Concept and specification

A watermark is a monotonically advancing timestamp asserting that no event with an event-time at or below it should still be expected. For a stream whose maximum observed event-time is and an allowed-lateness slack $L$, the watermark is:

Any ping whose event-time $e$ satisfies at its arrival is late: the watermark has already advanced past it, and the transition it might have affected has already been committed. The slack $L$ is the single tuning knob. Too small and the watermark races ahead of legitimately delayed pings, marking valid data late and dropping it; too large and every transition waits $L$ before it can commit, inflating latency for no correctness gain once $L$ exceeds the real lateness distribution. The correct $L$ is the P99 (not the maximum) of the observed event-to-ingest delay — sized so 99% of pings land in time and the rare stragglers are handled explicitly rather than paid for by everyone.

Parameter Symbol Typical value Effect if too small Effect if too large
Allowed lateness $L$ 500 ms – 2 s Valid pings dropped as late Every trigger delayed by $L$
Max observed event-time stream-derived
Watermark lag equals $L$ Under-buffers stragglers Over-buffers, adds latency
Late-drop rate < 0.5% target Rises sharply Falls toward 0

Two disposition policies exist for a ping that crosses the watermark late. Drop discards it — cheapest, but any transition it would have corrected is lost. Side-output diverts it to a separate reconciliation stream that can re-emit or retract a trigger out-of-band — more correct, at the cost of a second processing path and eventual-consistency semantics downstream. The policy is chosen per trigger class: a low-value idle ping is dropped, a compliance-boundary crossing is side-outputted.

Watermark placement and the drop-versus-side-output decision for late pings An event-time axis with a watermark line at max-event-time minus allowed lateness. On-time pings to the right of the watermark flow to the evaluator; a late ping to the left is either dropped or diverted to a side-output stream depending on its trigger class. Watermark = max event-time − allowed lateness event time → watermark W ← late | on-time → E max allowed lateness L late ping DROP low-value idle ping SIDE-OUTPUT compliance crossing → reconcile EVALUATE commit ENTER/EXIT/DWELL
The watermark sits an allowed-lateness slack behind the maximum observed event-time. On-time pings evaluate immediately; a late ping is dropped or diverted to reconciliation according to its trigger class.

Step-by-step implementation

Prerequisites: Python 3.11+, standard library only for the core evaluator (heapq, dataclasses, time); prometheus_client for the counters. Input is a stream of dual-timestamp telemetry records (event_time_ns, ingest_ns) as established in the event-time ordering reference; output is committed transitions plus a side-output stream of late compliance pings.

1. Generate the watermark from the running maximum event-time. The watermark trails by the allowed-lateness slack and only ever moves forward.

python
from __future__ import annotations

from dataclasses import dataclass


@dataclass(slots=True)
class Watermark:
    """Monotonic watermark: max event-time seen minus allowed lateness."""
    allowed_lateness_ns: int          # L, e.g. 500ms = 500_000_000
    _max_event_ns: int = 0

    def observe(self, event_time_ns: int) -> None:
        # Monotonic: a stale ping can never pull the watermark backward.
        if event_time_ns > self._max_event_ns:
            self._max_event_ns = event_time_ns

    @property
    def current_ns(self) -> int:
        return self._max_event_ns - self.allowed_lateness_ns

    def is_late(self, event_time_ns: int) -> bool:
        # <= is deliberate: a ping exactly at the watermark is already covered.
        return event_time_ns <= self.current_ns

Gotcha: advance the watermark from the event-time you just observed, not from wall-clock time. Tying it to wall-clock makes an idle stream drift its watermark forward and mark a resuming device’s backlog late en masse.

2. Route each ping through the watermark before it reaches the evaluator. On-time pings enter the reorder heap; late pings branch by trigger class.

python
import heapq

def ingest_ping(
    p: TelemetryPoint,
    wm: Watermark,
    heap: list[tuple[int, TelemetryPoint]],
    late_out: list[TelemetryPoint],
    late_counter,                      # prometheus Counter
) -> None:
    wm.observe(p.event_time_ns)        # update W first
    if wm.is_late(p.event_time_ns):
        late_counter.inc()
        if _is_compliance(p):          # correctness-critical → reconcile
            late_out.append(p)         # side-output path
        # else: drop (low-value idle ping) — nothing committed for it
        return
    heapq.heappush(heap, (p.event_time_ns, p))   # on-time → ordered buffer

3. Drain and evaluate everything the watermark has passed, in event order. Only a prefix guaranteed complete is committed, so no transition is resolved against a partial history.

python
def drain_and_commit(
    wm: Watermark,
    heap: list[tuple[int, TelemetryPoint]],
    evaluate,                          # callable: TelemetryPoint -> None
) -> int:
    committed = 0
    w = wm.current_ns
    while heap and heap[0][0] <= w:    # everything at/below W is final
        _, point = heapq.heappop(heap)
        evaluate(point)                # commit ENTER/EXIT/DWELL
        committed += 1
    return committed

Gotcha: never feed a side-outputted late ping back into heap. Re-inserting it below the watermark would reopen an already-committed transition; reconciliation must emit a compensating trigger out-of-band instead.

Benchmark and verification

The change to measure is trigger correctness and late-drop rate before and after introducing a watermark with a P99-sized allowed-lateness slack, replaying the same captured stream of 2M pings from a 40k-device fleet with injected coverage gaps (a store-and-forward burst distribution). Correctness is the fraction of committed transitions that match the ground-truth transition log computed from the fully-ordered stream.

Metric No watermark (arrival order) L = 200 ms L = 500 ms (P99-sized)
Trigger correctness 96.2% 99.71% 99.97%
Late-drop rate n/a 1.8% 0.3%
P50 commit latency 2 ms 202 ms 502 ms
P95 commit latency 41 ms 233 ms 548 ms
P99 commit latency 121 ms 254 ms 561 ms
Phantom transitions / 100k 3100 290 31
python
import time, statistics

def bench_correctness(stream, evaluate, ground_truth, runs: int = 20) -> dict[str, float]:
    lat_samples: list[float] = []
    matched = total = 0
    for _ in range(runs):
        t0 = time.perf_counter()
        emitted = list(evaluate(stream))          # committed transitions
        lat_samples.append((time.perf_counter() - t0) / len(stream) * 1e3)  # ms/ping
        matched += sum(1 for e in emitted if e in ground_truth)
        total += len(emitted)
    lat_samples.sort()
    return {
        "correctness": matched / total,
        "p95_ms": lat_samples[int(0.95 * runs)],
        "p99_ms": lat_samples[int(0.99 * runs)],
    }

The 500ms watermark lifts correctness from 96.2% to 99.97% — a 100× reduction in phantom transitions per 100k — while dropping only 0.3% of pings, and every dropped ping is a low-value one the side-output would have reconciled anyway. The P99-sized slack is the sweet spot: the 200ms window still drops 1.8% because it undercuts the real lateness tail, while widening past 500ms only adds latency. Verify against the ground-truth log before promoting: replay through a shadow evaluator, require correctness parity above 99.9%, and confirm late_drop_rate stays under the 0.5% budget before shifting live traffic.

Failure modes and edge cases

  • Watermark too tight → valid pings dropped. If $L$ is set below the real lateness P99, legitimately delayed pings cross the watermark and are marked late; a compliance crossing routed to drop instead of side-output is then silently lost. Size $L$ from the measured event-to-ingest P99, and alert when late_drop_rate exceeds 0.5% — a rising drop rate is the first sign the slack undercuts the tail.
  • Idle stream drifts the watermark. With no new pings, a wall-clock-driven watermark advances and marks a resuming device’s entire backlog late. Advance the watermark only from observed event-times, and give silent devices a bounded idle flush rather than a running clock.
  • Non-monotonic event-times. A clock that jumps backward would pull and the watermark backward if unguarded, reopening committed transitions. The observe guard keeps monotonic; clamp implausible stamps per the clock-skew discipline before they reach the watermark.
  • Side-output backlog unbounded. A flood of late compliance pings can grow the reconciliation queue without limit. Bound it and apply backpressure exactly as the pipeline sheds on-path load; reconciliation is best-effort and must not stall the primary evaluator.
  • NaN or empty event-time. A record with a missing or NaN event-time cannot be ordered at all. Reject it at ingest to a dead-letter path rather than letting it corrupt .