Exactly-Once Semantics for Streaming Trajectory Matching

The windowed consumer in the previous page gives at-least-once delivery: on a crash between emit and offset-commit, fixes replay and matched edges are re-produced, deduplicated only by an application-level emitted_upto guard. That guard is fragile — it lives in volatile per-vehicle state that a rebalance can reset. This page removes the fragility by making the emit and the offset advance a single atomic Kafka transaction, so each matched segment on the output topic appears exactly once no matter how many times the input is retried or which consumer instance ends up owning the partition. It is the delivery-guarantee layer of the broader streaming map-matching pipeline.

The specific hazard exactly-once addresses in trajectory matching is duplicate mileage. If the same matched edge is emitted twice, downstream distance, toll, and billing aggregations over-count, and the error is invisible until a reconciliation months later. A transaction binds the matched-edge output to the read position atomically: either both advance or neither does.

Compatibility and Configuration Requirements

Requirement Minimum version / value Notes
Python 3.10 consume-process-produce loop with transaction scoping
confluent-kafka 2.3 transactions need the librdkafka producer API; kafka-python cannot do this
Broker Kafka 2.5+ stable transactional.id semantics and offset fencing
transactional.id stable per instance e.g. matcher-tx-{partition_group}; must survive restarts to fence zombies
enable.idempotence true (implied by transactions) dedupes producer retries within a session
transaction.timeout.ms 60000 (default), < broker max must exceed worst-case batch processing time
Consumer isolation.level read_committed downstream readers must skip aborted/open transactions
Consumer enable.auto.commit false offsets are committed via the transaction, never by the consumer

Tables scroll horizontally on narrow viewports.

Transactional Consume-Process-Produce

The example below wraps one poll batch in a Kafka transaction. It produces matched edges and stages the consumed offsets inside the same transaction, then commits both atomically. On any failure it aborts, and the batch replays with no partial output. This is the exactly-once counterpart of the plain consumer’s produce-flush-commit sequence.

import json
from confluent_kafka import Consumer, Producer, TopicPartition, KafkaError


class ExactlyOnceMatcher:
    """
    Transactional streaming map-matcher: each matched edge is emitted exactly
    once across the consume-process-produce cycle.

    Parameters
    ----------
    brokers : str
        Kafka bootstrap servers.
    group_id : str
        Consumer group; splits input partitions across instances.
    transactional_id : str
        STABLE id that outlives restarts. Fences zombie producers after a
        rebalance so a stale instance cannot commit a duplicate segment.
    in_topic / out_topic : str
        Source fixes topic and destination matched-edges topic.
    matcher : object
        Windowed decoder exposing match_window(fixes, lag) -> [edge dicts].
    """

    def __init__(self, brokers, group_id, transactional_id,
                 in_topic, out_topic, matcher, lag=8):
        self.in_topic = in_topic
        self.out_topic = out_topic
        self.matcher = matcher
        self.lag = lag
        self.group_id = group_id
        self.windows = {}  # vehicle_id -> list[fix]; see note on state below

        self.consumer = Consumer({
            "bootstrap.servers": brokers,
            "group.id": group_id,
            "enable.auto.commit": False,        # offsets ride inside the txn
            "auto.offset.reset": "latest",
            # read_committed so we never process another stage's aborted output
            "isolation.level": "read_committed",
        })
        self.producer = Producer({
            "bootstrap.servers": brokers,
            "transactional.id": transactional_id,   # enables idempotence + txns
            "enable.idempotence": True,
            "transaction.timeout.ms": 60000,        # must exceed batch time
        })
        # init_transactions fences any older producer with the same id (zombie)
        self.producer.init_transactions()

    def _positions(self, batch):
        """Highest consumed offset per partition, +1 = next to read."""
        pos = {}
        for msg in batch:
            key = (msg.topic(), msg.partition())
            pos[key] = max(pos.get(key, -1), msg.offset())
        return [TopicPartition(t, p, off + 1) for (t, p), off in pos.items()]

    def run(self):
        self.consumer.subscribe([self.in_topic])
        try:
            while True:
                batch = self.consumer.consume(num_messages=500, timeout=1.0)
                batch = [m for m in batch if m and not m.error()]
                if not batch:
                    continue

                self.producer.begin_transaction()
                try:
                    for msg in batch:
                        vehicle_id = msg.key().decode("utf-8")
                        fix = json.loads(msg.value())
                        for edge in self._match(vehicle_id, fix):
                            self.producer.produce(
                                self.out_topic,
                                key=vehicle_id.encode("utf-8"),
                                value=json.dumps(edge).encode("utf-8"))

                    # stage consumed offsets INSIDE the transaction so the read
                    # position advances atomically with the matched-edge output
                    self.producer.send_offsets_to_transaction(
                        self._positions(batch),
                        self.consumer.consumer_group_metadata())
                    self.producer.commit_transaction()
                except Exception:
                    # abort: neither edges nor offsets take effect; batch replays
                    self.producer.abort_transaction()
                    raise
        finally:
            self.consumer.close()

    def _match(self, vehicle_id: str, fix: dict) -> list:
        """Append to the per-vehicle window and return committed edges."""
        window = self.windows.setdefault(vehicle_id, [])
        window.append(fix)
        if len(window) > 60:               # bound the window
            window.pop(0)
        if len(window) <= self.lag:
            return []                      # not enough context to commit
        return self.matcher.match_window(list(window), lag=self.lag)

Execution and Tuning Guidelines

Run one instance per partition group, each with its own stable transactional_id. Downstream consumers of out_topic — dashboards, mileage aggregators, billing — must set isolation.level=read_committed; otherwise they read aborted records and the guarantee is lost at the last hop.

  • transactional.id — the fencing key. It must be stable across restarts and unique per logical producer. When an instance restarts, init_transactions() reuses the id, bumps the producer epoch, and fences the old (zombie) instance. A random id per boot silently disables fencing and re-admits duplicates. Derive it deterministically from the assigned partition group, never from a UUID or hostname that changes.
  • isolation.level=read_committed — set on every consumer that reads matched edges, including this matcher’s own input if an upstream stage is transactional. It makes aborted and in-flight transactional records invisible. Leaving it at the default read_uncommitted means a downstream reader sees edges from transactions that later abort — the exact duplicate you were preventing.
  • enable.idempotence=true — implied once transactional.id is set, but state it explicitly. It dedupes producer retries within a session via sequence numbers; transactions extend that to atomicity across the consume-process-produce cycle. Idempotence alone is not exactly-once for the pipeline.
  • transaction.timeout.ms — must exceed the worst-case time to process one batch (window decode plus any routing-engine calls) and stay under the broker’s transaction.max.timeout.ms. Too low and the coordinator aborts a healthy transaction under load, forcing needless replays; too high and a genuinely stuck instance holds the partition’s LSO (last stable offset) and stalls downstream read_committed readers.
  • Batch size — larger transactions amortise the commit round-trip but widen the replay window on abort and delay the LSO advance, adding downstream latency. Tune against the emission latency budget from the pipeline page.

Exactly-once covers only what lives in Kafka: the matched-edge output and the consumed offsets. It does not cover per-vehicle window state held outside Kafka. Rebuild that state from a changelog on assignment (as the windowed consumer does) or accept that windows rewarm after a restart while the delivery guarantee still holds. Feeding the matcher clean, Kalman-filtered fixes keeps decode time — and therefore transaction.timeout.ms headroom — predictable.

Common Pitfalls

Zombie producer commits a duplicate after a rebalance

A consumer stalls (long GC, network partition), the group rebalances its partitions to another instance, then the original wakes and produces matched edges for partitions it no longer owns. Without fencing, both instances emit the same segment. The fix is a stable transactional.id plus init_transactions() at startup: the broker bumps the producer epoch so the zombie’s commit_transaction() is rejected with a fatal fencing error. A random per-boot id defeats this entirely — the zombie has a different id, is never fenced, and its duplicate commits succeed.

Non-transactional state makes the guarantee partial

The transaction spans only Kafka reads and writes. If the matcher mutates an external store — writing a matched edge to PostGIS, incrementing a Redis mileage counter — inside the same loop, that side effect is not rolled back when the transaction aborts, so an abort-then-replay double-applies it. Keep all authoritative output on the transactional Kafka topic and let a separate, idempotent sink consume read_committed from it; never perform non-idempotent external writes inside the transaction scope.

Duplicates reappear downstream from read_uncommitted consumers

The producer side can be flawless while a downstream service still double-counts, because its consumer left isolation.level at the default read_uncommitted. It then reads matched edges from transactions that later abort, resurrecting exactly the duplicate the design prevents. Every consumer in the chain that reads a transactional topic — aggregators, billing jobs, and this matcher when an upstream stage is transactional — must be configured read_committed. Exactly-once is an end-to-end property, not a producer-only setting.


Up: Streaming Map-Matching Pipelines | Trajectory Analysis & Map Matching Techniques