Handling Out-of-Order Pings from Intermittent Devices
Order every decision on the device’s own clock, reject any state transition carrying an observation older than the state it would replace, and hold a track open until a measured watermark has passed — a device leaving a tunnel flushes its buffer in whatever order its firmware happens to use, so arrival order makes the reconstructed path a function of connectivity.
This guide sits under Sensor Data Routing Patterns, within Core Event Fundamentals & Architecture. It is the failure mode that breaks the stateful router in Routing Telemetry by Geofence Membership, which assumes each ping is newer than the last.
When to use this pattern
- Devices buffer locally when they lose connectivity — vehicles in tunnels, vessels out of range, sensors on intermittent power.
- Downstream logic is stateful: geofence membership, trip reconstruction, distance accumulation, arrival detection.
- The device supplies its own timestamp, which is the precondition for any of this working.
What a buffered flush does to stateful logic
Complete runnable implementation
Two mechanisms, and they solve different halves. The monotonic guard protects state from going backwards, cheaply and immediately. The watermark buffer produces a correctly ordered track, at the cost of latency.
import heapq
from dataclasses import dataclass, field
from datetime import datetime, timedelta, UTC
from prometheus_client import Counter, Histogram
LATE = Counter("ping_late_total", "Pings older than the current state", ("asset",))
TOO_LATE = Counter("ping_after_watermark_total", "Pings arriving after close")
SKEW = Histogram(
"ping_arrival_skew_seconds", "Arrival minus device timestamp",
buckets=(1, 5, 30, 120, 600, 1800, 3600, float("inf")),
)
# Measured, not chosen: the 99th percentile of observed arrival skew.
WATERMARK_LAG = timedelta(minutes=15)
# A device claiming to be further in the future than this has a broken clock.
MAX_FUTURE_SKEW = timedelta(minutes=2)
class ClockImplausible(Exception):
"""The device timestamp cannot be true, so nothing derived from it can be."""
@dataclass(slots=True)
class AssetState:
latest_observed: datetime | None = None
buffer: list = field(default_factory=list) # min-heap on device time
class OrderedIngest:
def __init__(self, lag: timedelta = WATERMARK_LAG) -> None:
self._lag = lag
self._state: dict[str, AssetState] = {}
def accept(self, ping: dict, now: datetime | None = None) -> bool:
"""Buffer one ping. Returns False if it is too late to be ordered."""
now = now or datetime.now(UTC)
observed = datetime.fromisoformat(ping["occurred_at"]).astimezone(UTC)
SKEW.observe((now - observed).total_seconds())
# A future timestamp is a broken clock, not a late ping, and the two
# need opposite handling: one is quarantined, the other is waited for.
if observed > now + MAX_FUTURE_SKEW:
raise ClockImplausible(f"{ping['asset_id']} claims {observed.isoformat()}")
state = self._state.setdefault(ping["asset_id"], AssetState())
if observed < now - self._lag:
TOO_LATE.inc()
return False
heapq.heappush(state.buffer, (observed, ping["ping_id"], ping))
return True
def release(self, now: datetime | None = None):
"""Yield every ping whose device time is older than the watermark.
Anything still in the buffer could still be overtaken by a later
arrival, so releasing it early is exactly the bug this fixes.
"""
now = now or datetime.now(UTC)
watermark = now - self._lag
for asset_id, state in self._state.items():
while state.buffer and state.buffer[0][0] <= watermark:
observed, _, ping = heapq.heappop(state.buffer)
yield ping
def is_regression(self, asset_id: str, observed: datetime) -> bool:
"""The cheap guard: does this ping predate the state it would change?
Used by stateful consumers that must act immediately and cannot wait
for the watermark — a geofence router, for instance.
"""
state = self._state.setdefault(asset_id, AssetState())
if state.latest_observed is not None and observed <= state.latest_observed:
LATE.labels(asset=asset_id).inc()
return True
state.latest_observed = observed
return False
The two are used together. A geofence router calls is_regression and skips the transition without waiting; a trip reconstructor consumes from release and gets a correctly ordered track fifteen minutes behind real time.
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
WATERMARK_LAG |
timedelta |
~99th percentile of measured arrival skew, not the maximum | 15 min |
MAX_FUTURE_SKEW |
timedelta |
Above NTP drift, below any plausible buffering delay | 2 min |
| Heap key | (datetime, str) |
Ping id breaks ties so two identical timestamps sort deterministically | — |
is_regression |
bool |
Uses <=, so a repeated timestamp is a duplicate, not an update |
— |
| Buffer memory | — | Grows as fleet size × ping rate × lag; bound it explicitly | — |
| Late path | — | Emit corrections; do not silently rewrite a closed track | — |
Gotchas and spatial edge cases
-
A wrong clock is not a late ping and needs the opposite handling. A device whose clock is a year fast has every ping released immediately and permanently blocks the monotonic guard — nothing newer can ever arrive. A device a year slow has every ping discarded as too late. Both look like data loss; only the
ClockImplausiblecheck distinguishes them from a genuine outage, and the correct response is to quarantine the device, not the stream. -
Interpolating across a gap invents positions. It is tempting to draw a straight line across the tunnel, and the line will cross geofences the vehicle never entered. If the reconstructed track is used for anything consequential, mark the gap explicitly and let each consumer decide, rather than producing a track that cannot be distinguished from observed data.
-
Distance accumulated in arrival order is roughly double. Summing consecutive distances over a reversed burst counts every leg twice — once forwards and once backwards — so an odometer built naively over an intermittent fleet overstates by the length of every outage. This is usually the first symptom anyone notices.
-
Deduplication and ordering are different problems with the same smell. A device that retries its flush sends the same pings again; that is Time-Windowed Deduplication for Moving Assets, and it must run before the heap or the buffer fills with duplicates that all sort to the same position.
-
The watermark must advance on wall-clock time, not on arrivals. If it advances only when a ping arrives, an asset that goes quiet never releases its buffer, and the last pings before an outage sit in memory until the device comes back — which for a vessel can be weeks. Drive
releasefrom a timer. -
Per-asset buffers make this stateful, so partition by asset. The same constraint as the geofence router: two instances each holding half an asset’s pings each produce half a track.
Verification
from datetime import datetime, timedelta, UTC
import pytest
T0 = datetime(2026, 8, 8, 12, 0, tzinfo=UTC)
def ping(asset: str, offset_s: int, n: int) -> dict:
return {"asset_id": asset, "ping_id": f"p{n}",
"occurred_at": (T0 + timedelta(seconds=offset_s)).isoformat()}
def test_reversed_burst_is_released_in_device_order():
"""The property the whole design exists for."""
ingest = OrderedIngest(lag=timedelta(minutes=5))
now = T0 + timedelta(minutes=20)
for n, offset in enumerate(reversed(range(0, 600, 10))): # newest first
ingest.accept(ping("veh-1", offset, n), now=now)
released = [p["occurred_at"] for p in ingest.release(now=now)]
assert released == sorted(released)
def test_guard_rejects_a_ping_older_than_current_state():
ingest = OrderedIngest()
assert ingest.is_regression("veh-2", T0 + timedelta(seconds=60)) is False
assert ingest.is_regression("veh-2", T0 + timedelta(seconds=30)) is True
def test_future_clock_is_quarantined_not_buffered():
"""A year-fast device would otherwise block the guard forever."""
ingest = OrderedIngest()
with pytest.raises(ClockImplausible):
ingest.accept(ping("veh-3", 365 * 24 * 3600, 0), now=T0)
The first test is the one to run against a real recorded burst rather than a synthetic reversal. Firmware rarely reverses cleanly — a partial flush interleaved with live reporting produces an order that is neither forwards nor backwards, and a heap handles it while any “detect and reverse” shortcut does not.
Related
- Sensor Data Routing Patterns — the topic this guide belongs to
- Routing Telemetry by Geofence Membership — the stateful consumer the monotonic guard protects
- Idempotent Consumers for Out-of-Order Spatial Events — the same problem for feature edits rather than positions
- Time-Windowed Deduplication for Moving Assets — what must run before the buffer, so it does not fill with repeats