Deduplicating AIS Messages in Real-Time Streams

Problem Framing

The same vessel position lands in your topic three times a second and every count downstream is wrong. This is the defining nuisance of the Real-Time AIS Stream Ingestion Pipelines workflow: a ship transmits one Type 1 position report, but two shore receivers with overlapping coverage both hear it, a satellite AIS provider relays a third copy, and the Kafka or MQTT transport re-delivers at-least-once after a rebalance or reconnect. The payload is byte-identical or nearly so, yet vessel-density heatmaps inflate, port-call counts double, and dwell-time analytics skew high. This page defines a dedup key that survives multi-receiver fan-in, implements it as a memory-bounded seen-set, and pushes idempotency all the way to the write so a replay cannot resurrect a duplicate you already dropped.

The diagram traces one physical transmission through three ingestion paths that converge on the dedup stage. The key insight: duplicates are not a bug in any single receiver — they are the expected consequence of redundant coverage, and the pipeline must be built to expect them.

Multi-Receiver Fan-In and the Dedup Stage A single vessel transmission at top splits into three arrows to three receiver boxes: Terrestrial RX A, Terrestrial RX B, and Satellite AIS. All three feed a Kafka topic box, which feeds a Dedup stage box keyed on MMSI plus timestamp plus rounded latitude and longitude. The dedup stage emits one canonical record to downstream analytics. 1 vessel transmission Terrestrial RX A rx_id=SHORE-A Terrestrial RX B rx_id=SHORE-B Satellite AIS rx_id=SAT-3 Kafka topic (at-least-once) Dedup: (mmsi, ts, round(lat,lon)) emits 1 canonical record

Root Cause: Where the Duplicates Come From

Three mechanisms generate duplicates, and each needs the same key but a different window:

  1. Multi-receiver fan-in. Two terrestrial stations that both hear a vessel forward the identical NMEA payload; the only difference is a receiver-added rx_id and possibly an arrival timestamp a few milliseconds apart.
  2. Terrestrial + satellite overlap. A satellite provider relays the same report the shore network already delivered, but with 30–90 seconds of backhaul latency, so the two copies can straddle a time window boundary.
  3. Transport redelivery. Kafka at-least-once semantics and MQTT QoS1 both replay on reconnect or rebalance — the exact scenario produced by the fixes in troubleshooting AIS Kafka & MQTT connector failures.

A raw duplicate pair looks like this — identical except the receiver id:

# Two rows for ONE physical position report
{"mmsi": 235098765, "timestamp": "2026-07-13T09:14:07Z", "lat": 50.80213, "lon": -1.08841, "rx_id": "SHORE-A"}
{"mmsi": 235098765, "timestamp": "2026-07-13T09:14:07Z", "lat": 50.80213, "lon": -1.08841, "rx_id": "SHORE-B"}

The dedup key must therefore exclude rx_id and any receiver-local metadata, and include the identity of the position: mmsi, the reported timestamp, and the position rounded to a tolerance that absorbs sub-metre float noise. Rounding latitude/longitude to five decimal places (~1.1 m at the equator) collapses receivers that decoded the same bits into slightly different floats without merging genuinely distinct positions.

Step-by-Step Fix

Step 1 — Define the dedup key

from __future__ import annotations

import logging
from typing import Any

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
logger = logging.getLogger("ais.dedup")

# ~1.1 m at the equator; absorbs float-decode jitter, keeps distinct fixes distinct
_POS_DECIMALS = 5


def dedup_key(msg: dict[str, Any]) -> tuple[int, str, float, float]:
    """Build a receiver-independent identity for one AIS position report.

    Raises KeyError if a required field is missing so a malformed record
    cannot silently collapse into another vessel's key.
    """
    try:
        mmsi = int(msg["mmsi"])
        ts = str(msg["timestamp"])
        lat = round(float(msg["lat"]), _POS_DECIMALS)
        lon = round(float(msg["lon"]), _POS_DECIMALS)
    except (KeyError, TypeError, ValueError) as exc:
        raise KeyError(f"cannot build dedup key from {msg!r}: {exc}") from exc
    return (mmsi, ts, lat, lon)

Step 2 — A bounded TTL seen-set

An unbounded set of keys will exhaust memory on a busy feed within hours. The seen-set must evict by time, not just by count, because a satellite duplicate can arrive 90 seconds after the terrestrial original. The implementation below keeps insertion order via a plain dict (ordered since Python 3.7) and evicts entries older than the TTL on every insert. It raises if the structure grows past a hard ceiling — a growth runaway means the TTL is misconfigured or the clock is broken, and silently unbounded memory is worse than a loud crash:

import time


class TtlSeenSet:
    """Time-bounded membership test for dedup keys.

    Keys expire after `ttl_s`. A hard `max_size` ceiling guards against
    unbounded growth (misconfigured TTL, clock skew) by raising rather than
    leaking memory until the pod is OOM-killed.
    """

    def __init__(self, ttl_s: float = 120.0, max_size: int = 5_000_000) -> None:
        if ttl_s <= 0:
            raise ValueError("ttl_s must be positive")
        self._ttl_s = ttl_s
        self._max_size = max_size
        self._seen: dict[tuple, float] = {}

    def _evict_expired(self, now: float) -> None:
        cutoff = now - self._ttl_s
        # dict preserves insertion order; oldest keys are at the front
        stale = 0
        for key, inserted in self._seen.items():
            if inserted >= cutoff:
                break
            stale += 1
        for key in list(self._seen.keys())[:stale]:
            del self._seen[key]

    def seen_before(self, key: tuple) -> bool:
        """Return True if `key` is a duplicate within the TTL window; else record it."""
        now = time.monotonic()
        self._evict_expired(now)
        if key in self._seen:
            return True
        self._seen[key] = now
        if len(self._seen) > self._max_size:
            raise MemoryError(
                f"seen-set exceeded {self._max_size} keys — TTL {self._ttl_s}s too long "
                "or upstream clock skew is inflating unique keys"
            )
        return False

Step 3 — Wire it into the poll loop

def filter_duplicates(
    records: list[dict[str, Any]],
    seen: TtlSeenSet,
) -> list[dict[str, Any]]:
    """Drop exact duplicates; log the duplicate ratio for observability."""
    kept: list[dict[str, Any]] = []
    dupes = 0
    for rec in records:
        key = dedup_key(rec)
        if seen.seen_before(key):
            dupes += 1
            continue
        kept.append(rec)
    if records:
        logger.info("dedup: kept %d, dropped %d (%.1f%% dup)",
                    len(kept), dupes, 100.0 * dupes / len(records))
    return kept

Step 4 — Near-duplicate collapse and idempotent writes

Exact-key dedup misses a subtle case: two receivers that report the same fix one second apart (their clocks differ) produce two keys. Collapse near-duplicates with a short per-MMSI window using pandas, keeping the first arrival:

import pandas as pd
import numpy as np


def collapse_near_duplicates(df: pd.DataFrame, window_s: float = 2.0) -> pd.DataFrame:
    """Within each MMSI, drop rows whose position matches the previous kept row
    and fall inside `window_s`. Assumes df is timestamp-sorted per MMSI."""
    if not np.issubdtype(df["timestamp"].dtype, np.datetime64):
        raise TypeError("timestamp column must be datetime64 before collapsing")
    out: list[pd.DataFrame] = []
    for mmsi, grp in df.sort_values("timestamp").groupby("mmsi", sort=False):
        grp = grp.copy()
        dt = grp["timestamp"].diff().dt.total_seconds().fillna(np.inf)
        same_pos = (
            grp["lat"].round(_POS_DECIMALS).diff().fillna(1).eq(0)
            & grp["lon"].round(_POS_DECIMALS).diff().fillna(1).eq(0)
        )
        drop = same_pos & (dt <= window_s)
        out.append(grp.loc[~drop])
    return pd.concat(out, ignore_index=True)

Finally, make the downstream write idempotent so a replayed batch cannot reintroduce a row you already dropped: use the dedup key as a primary/merge key. For a warehouse, MERGE ... ON (mmsi, timestamp, lat, lon); for object storage, name Parquet objects deterministically from the batch’s key range so a re-write overwrites rather than appends.

The Dedup Window Is Bought in Megabytes A histogram of the observed delay between an original AIS message and its duplicate, on a logarithmic time axis. A dominant mode below four seconds is labelled overlapping terrestrial receivers. A second, smaller mode near ninety seconds is labelled satellite feed replay. A thin tail extending past ten minutes is labelled nightly backfill. Two vertical window markers are drawn: a two-minute window, annotated as capturing 99.6 per cent of duplicates with a cache of about 1.1 million keys and 180 MB; and a fifteen-minute window, annotated as capturing 99.9 per cent for about 8.3 million keys and 1.4 GB. A caption states that the tail beyond the window is not a leak if the downstream sink upserts on the same key, and that the window only has to cover the duplicates the sink cannot absorb. delay between a message and its duplicate count overlapping receivers satellite replay nightly backfill 2 min window 99.6% caught · ~1.1 M keys · ~180 MB 15 min window 99.9% caught · ~8.3 M keys · ~1.4 GB 1 s10 s90 s10 min1 h The last 0.3% costs eight times the memory — and costs nothing at all if the sink upserts on the same key, which is the reason to size the window against what the sink cannot absorb rather than against the duplicate rate.

Verification

Assert the duplicate ratio falls and the seen-set stays bounded on a replayed fixture:

def test_dedup_drops_duplicates() -> None:
    seen = TtlSeenSet(ttl_s=120.0)
    base = {"mmsi": 235098765, "timestamp": "2026-07-13T09:14:07Z", "lat": 50.80213, "lon": -1.08841}
    stream = [
        {**base, "rx_id": "SHORE-A"},
        {**base, "rx_id": "SHORE-B"},   # duplicate
        {**base, "rx_id": "SAT-3"},     # duplicate
        {**base, "timestamp": "2026-07-13T09:14:09Z", "rx_id": "SHORE-A"},  # new fix
    ]
    kept = filter_duplicates(stream, seen)
    unique_keys = {dedup_key(r) for r in kept}
    assert len(kept) == 2, f"expected 2 unique, got {len(kept)}"
    assert len(unique_keys) == len(kept), "kept set still contains duplicates"
    print("PASS — 4 messages collapsed to 2 unique positions")
Three Candidate Dedup Keys, Two of Them Wrong Three key definitions tested against the same two scenarios. Scenario A: two shore stations receive one transmission and each append their own receiver identifier and arrival timestamp. Scenario B: a vessel at anchor sends two separate transmissions three minutes apart carrying an identical decoded payload. Key one, a hash of the raw sentence including receiver metadata, marks scenario A as two distinct messages, which is a missed duplicate, and correctly separates scenario B. Key two, a hash of the decoded payload alone, correctly merges scenario A but also merges scenario B, deleting a real report. Key three, MMSI plus message type plus the decoded transmission timestamp plus the payload, merges scenario A and separates scenario B, which is the only combination that is right in both. A: 2 receivers, 1 transmission B: 2 transmissions, at anchor verdict sha256(raw sentence) kept as 2 — duplicate missed kept as 2 — correct ✗ receiver tags differ sha256(decoded payload) merged to 1 — correct merged to 1 — report deleted ✗ identical while moored (MMSI, type, ts, payload) merged to 1 — correct kept as 2 — correct ✓ right in both The decisive field is the transmission timestamp carried inside the message, not the receiver’s arrival time — the first is a property of the transmission, the second a property of the path it took. Type 1 reports carry only seconds-within-minute, so the decoder has to reconstruct the minute before this key is usable.

Edge Cases and Gotchas

  • Legitimate identical-timestamp messages. A vessel’s Type 5 static/voyage report and its Type 1 position can share a second-of-minute timestamp yet carry different payloads. Because the dedup key includes lat/lon (absent or sentinel on Type 5), do not extend the key to fields that differ by message type, and never dedup across message classes on timestamp alone.
  • Clock skew makes duplicates look distinct. If one receiver’s NTP is wrong, the same physical fix carries two different timestamps and slips past the key — the exact failure covered in handling AIS timestamp drift & clock skew. Normalise timestamps to a corrected reference before dedup, or the seen-set inflates and eventually trips its MemoryError ceiling.
  • Memory bound of the seen-set. The seen-set size is roughly peak_msg_rate × ttl_s × unique_fraction. At 10 k msg/s and a 120 s TTL, that is up to 1.2 M keys — comfortably under the 5 M ceiling, but push the TTL to 900 s and you approach it. Size max_size from your measured rate, and export len(seen._seen) as a gauge so you see it climbing before it raises. Residual gaps from over-aggressive dedup should be reconciled in AIS data quality & gap filling.

Up: Real-Time AIS Stream Ingestion Pipelines