Deduplicating Vehicle Pings in a Sliding Window
Keep one last-accepted record per asset, compare each ping against it on elapsed time OR ground distance, and do the read, the decision and the write in a single Lua script — two round trips lose the race exactly when redeliveries are most likely, and a late ping allowed to overwrite a newer record silently disables the window for that asset.
This guide sits under Time-Windowed Deduplication for Moving Assets, within Idempotency & Spatial Deduplication. It is the sliding alternative to the tumbling bucket that topic describes, and it costs one read per event to remove the boundary pair.
When to use this pattern
- Boundary duplicates have actually been measured — pairs of pings seconds apart that both got admitted because a bucket edge fell between them.
- A read per event is affordable, which for a Redis instance co-located with the consumer usually means it is.
- The stream is high-volume enough that the tumbling scheme’s admitted duplicates cost more than the read.
Where the tumbling bucket lets a pair through
Complete runnable implementation
import math
import redis.asyncio as redis
MIN_SECONDS = 60.0 # emit at most once a minute while stationary
MIN_METRES = 25.0 # …but emit immediately if the asset has moved
STATE_TTL = 6 * 3600 # must outlive the sender's full retry ladder
# One atomic unit: read the last accepted ping, decide, write if accepted.
# Splitting this into GET and SET is a race that admits duplicates exactly
# when two workers are handed the same redelivery.
_SLIDING = """
local raw = redis.call('GET', KEYS[1])
local t = tonumber(ARGV[1])
local lat = tonumber(ARGV[2])
local lon = tonumber(ARGV[3])
local min_s = tonumber(ARGV[4])
local min_m = tonumber(ARGV[5])
local ttl = tonumber(ARGV[6])
if raw then
local prev_t, prev_lat, prev_lon = string.match(raw, '([^|]+)|([^|]+)|([^|]+)')
prev_t, prev_lat, prev_lon = tonumber(prev_t), tonumber(prev_lat), tonumber(prev_lon)
-- A ping older than the stored one is late. Suppress it AND leave the
-- record alone: overwriting rewinds the baseline, so the next genuine
-- ping is compared against a position the vehicle already left.
if t <= prev_t then return 0 end
local dt = t - prev_t
-- Equirectangular approximation: exact enough below a few kilometres and
-- far cheaper than haversine at this call rate.
local x = math.rad(lon - prev_lon) * math.cos(math.rad((lat + prev_lat) / 2))
local y = math.rad(lat - prev_lat)
local dm = 6371000 * math.sqrt(x * x + y * y)
-- OR, not AND. Time alone loses a vehicle that accelerated away inside
-- the window; distance alone never emits for a parked vehicle, so a
-- consumer cannot tell parked from disconnected.
if dt < min_s and dm < min_m then return 0 end
end
redis.call('SET', KEYS[1], ARGV[1] .. '|' .. ARGV[2] .. '|' .. ARGV[3], 'EX', ttl)
return 1
"""
class SlidingWindowDeduplicator:
def __init__(self, client: redis.Redis) -> None:
self._script = client.register_script(_SLIDING)
async def accept(self, asset_id: str, epoch: float,
lat: float, lon: float) -> bool:
"""True if this ping should be processed; False if it is a duplicate."""
result = await self._script(
keys=[f"slide:{asset_id}"],
args=[epoch, lat, lon, MIN_SECONDS, MIN_METRES, STATE_TTL],
)
return bool(result)
The t <= prev_t guard is the part that is easy to leave out and hard to notice missing. Without it a late ping suppresses correctly but still writes, so the baseline moves backwards and the next ping — a genuine one — is compared against stale state.
and for an or in the Lua script is a one-character change that turns a working deduplicator into a filter discarding three quarters of the stream.Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
MIN_SECONDS |
float |
The stationary heartbeat interval; below the consumer’s staleness tolerance | 60.0 |
MIN_METRES |
float |
Above measured GPS jitter, below the shortest movement that matters | 25.0 |
STATE_TTL |
int |
The sender’s full retry ladder, not the window | 21600 |
| Distance formula | — | Equirectangular; error under 0.5% below ~5 km, so unusable for long gaps | — |
| Combining operator | — | OR — AND discards three of the four quadrants |
OR |
t <= prev_t guard |
— | Suppress and do not write, or the baseline rewinds | — |
Gotchas and spatial edge cases
-
The equirectangular approximation degrades over long gaps. It is accurate below a few kilometres and increasingly wrong beyond that, especially at high latitude. For a deduplication threshold of tens of metres this never matters — but if someone later reuses the same script with a five-kilometre threshold, it does. Keep the threshold and the formula documented together.
-
A vehicle crossing the antimeridian produces a longitude difference of 360 degrees. The subtraction gives an enormous distance, so the ping is admitted. That is the safe direction — a false admission rather than a false suppression — but it means the metric for suppression rate will show a spike for any fleet operating in the Pacific.
-
One key per asset means memory scales with the active fleet, not with events. That is the mechanism’s main advantage over a bucket-per-window scheme, and it depends on the TTL actually being set on every write; the
EXin the script is what stops a churning fleet leaking keys forever. -
register_scriptusesEVALSHAwith a fallback, which matters across a restart. After a Redis restart the script cache is empty and the first call falls back toEVAL; that is handled by the client, but a proxy that does not implementSCRIPT LOADcorrectly will fail every call. Test against the deployment topology, not against a local Redis. -
The record is a comparison baseline, not an audit log. It holds only the last accepted ping, so it cannot answer “how many were suppressed” — that needs a counter, and the ratio of suppressed to accepted per asset is the signal that catches a device stuck reporting a cached fix.
-
This suppresses re-observation, not redelivery of an already-accepted ping. A redelivery arriving with the same timestamp is caught by
t <= prev_t, but one arriving after a genuinely newer ping is not — that needs the identity key from Event Key Generation for Spatial Data alongside this window.
Verification
import pytest
BASE = 1_780_000_000.0
LAT, LON = 52.5200087, 13.4049547
@pytest.mark.asyncio
async def test_boundary_pair_collapses(dedup):
"""The failure the tumbling scheme has, four seconds apart."""
assert await dedup.accept("veh-1", BASE, LAT, LON) is True
assert await dedup.accept("veh-1", BASE + 4, LAT, LON) is False
@pytest.mark.asyncio
async def test_movement_inside_the_window_is_admitted(dedup):
"""Distance is an OR, so acceleration is not suppressed."""
await dedup.accept("veh-2", BASE, LAT, LON)
assert await dedup.accept("veh-2", BASE + 5, LAT + 0.002, LON) is True
@pytest.mark.asyncio
async def test_parked_vehicle_still_heartbeats(dedup):
"""Time is an OR too, so a stationary asset is not silent forever."""
await dedup.accept("veh-3", BASE, LAT, LON)
assert await dedup.accept("veh-3", BASE + 30, LAT, LON) is False
assert await dedup.accept("veh-3", BASE + 61, LAT, LON) is True
@pytest.mark.asyncio
async def test_late_ping_does_not_rewind_the_baseline(dedup):
"""The guard that is easy to omit and hard to notice missing."""
await dedup.accept("veh-4", BASE + 100, LAT, LON)
assert await dedup.accept("veh-4", BASE, LAT, LON) is False # late
# If the late ping had overwritten the record, this would be admitted.
assert await dedup.accept("veh-4", BASE + 130, LAT, LON) is False
The last test is the one that distinguishes a correct implementation from one that merely returns the right boolean. Both suppress the late ping; only the correct one still has the right baseline afterwards.
Related
- Time-Windowed Deduplication for Moving Assets — the topic this guide belongs to, and the tumbling alternative
- Sizing a Deduplication Window from Report Intervals — where the two thresholds come from
- Expiring Deduplication Keys Without Losing Late Retries — sizing the TTL that the script sets
- Using Redis to Cache Spatial Webhook Signatures — the same store, used for the identity half of the problem