11 min read 5 sections

Partition Key Choice for Geofence Trigger Ordering

A broker guarantees order within a partition and nothing across partitions, so the partition key is the single decision that determines which of a geofencing pipeline’s ordering requirements are free and which have to be rebuilt in application code. Choose it by accident — by fence, because fences seem like the natural entity — and a device’s ENTER and EXIT for two adjacent zones land on different partitions and are consumed in whatever order the consumers happen to reach them. This page sits under Kafka vs Redis Streams for trigger routing within Event Routing & Backpressure.

Concept and specification

Three candidate keys exist and each buys one ordering guarantee at the cost of the others:

Key Ordering guaranteed Hot-partition risk State locality Rebalance blast radius
device_id Per device, across all fences Low — devices are numerous Debounce and dwell state co-located One device’s state moves
fence_id Per fence, across all devices High — a city-centre fence is hot Fence state co-located One fence’s traffic moves
(device_id, fence_id) Per pair only Lowest Neither co-located Finest
Random / round-robin None None None
Key at a glance: Ordering guaranteed, Hot-partition risk A row per key, a column per option, so a single axis can be compared across options in one sweep. Key at a glance: Ordering guaranteed, Hot-partition risk the same trade-offs, read across instead of down Ordering guaranteed Hot-partition risk State locality Rebalance blast radius device_id Per device, across all fences Low — devices are numerous Debounce and dwell state co-located One device's state moves fence_id Per fence, across all devices High — a city-centre fence is hot Fence state co-located One fence's traffic moves (device_id, fence_id) Per pair only Lowest Neither co-located Finest Random / round-robin None None None
The key table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Ordering guaranteed, Hot-partition risk, State locality.

The question that settles it is which sequence must be consistent for a consumer to be correct. For geofencing that sequence is per device: a device’s ENTER into zone A and EXIT from zone B are causally related — the vehicle moved — and a consumer that sees them out of order infers a trajectory that never happened. Nothing comparable is true per fence: two different devices entering the same zone are independent events whose relative order carries no meaning.

The skew argument reinforces it. Device traffic is close to uniform because devices report on a cadence; fence traffic follows a power law because a handful of fences — a city centre, an airport, a major depot — see orders of magnitude more crossings than the rest. Measured across a 4,000-vehicle fleet and 6,000 fences over a week:

Key Partitions Skew Hottest partition share Consumer lag P99
device_id 24 1.4× 5.8% 340 ms
fence_id 24 7.4× 31.0% 4,100 ms
(device_id, fence_id) 24 1.1× 4.6% 290 ms
Round-robin 24 1.0× 4.2% 260 ms
Skew, Hottest partition share, Consumer lag P99 — 4 options Each panel scales on its own, so Skew, Hottest partition share, Consumer lag P99 are compared across 4 options without sharing an axis they do not share a unit with. Skew, Hottest partition share, Consumer lag P99 — 4 options Key — the table above, drawn to scale device_id fence_id (device_id, fence_id) Round-robin Skew 1.4× 7.4× 1.1× 1.0× Hottest partition share 5.8% 31.0% 4.6% 4.2% Consumer lag P99 340 ms 4,100 ms 290 ms 260 ms
Skew, Hottest partition share and 1 more for device_id, fence_id, (device_id, fence_id) and 1 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The pair key and round-robin have the best balance and the weakest guarantees, which is the trade in its purest form: perfect balance means no two related events share a partition. device_id gives up 30% of the balance in exchange for the one guarantee the pipeline actually needs, and that is the right purchase.

Step-by-step implementation

1. Hash the key yourself if the consumer needs to co-locate state. The default partitioner’s hash is a broker implementation detail; computing murmur2(device_id) % partitions in application code lets the consumer derive the same partition for state placement without a lookup. Pin it, because a broker upgrade that changes the default partitioner would silently relocate every key.

python
from __future__ import annotations

def murmur2(data: bytes) -> int:
    """Kafka's default partitioner hash, pinned so a broker upgrade cannot
    silently relocate every key."""
    length, seed = len(data), 0x9747B28C
    m, r = 0x5BD1E995, 24
    h = (seed ^ length) & 0xFFFFFFFF
    for i in range(0, length - length % 4, 4):
        k = int.from_bytes(data[i:i + 4], "little")
        k = (k * m) & 0xFFFFFFFF
        k ^= k >> r
        k = (k * m) & 0xFFFFFFFF
        h = ((h * m) & 0xFFFFFFFF) ^ k
    tail = length % 4
    if tail >= 3:
        h ^= data[length - tail + 2] << 16
    if tail >= 2:
        h ^= data[length - tail + 1] << 8
    if tail >= 1:
        h ^= data[length - tail]
        h = (h * m) & 0xFFFFFFFF
    h ^= h >> 13
    h = (h * m) & 0xFFFFFFFF
    h ^= h >> 15
    return h

def partition_for(device_id: str, partitions: int) -> int:
    return (murmur2(device_id.encode()) & 0x7FFFFFFF) % partitions

2. Choose the partition count once and treat it as permanent. Adding partitions rehashes every key, so a device’s history is split across the old and new partitions and per-device ordering is broken for the length of the retention period. Over-provision — 24 partitions for a workload needing 8 — because reducing is impossible and increasing is destructive.

3. Handle the hot key that will exist anyway. Even under device_id a single device can be pathological: a test harness, a device stuck in a retry loop, a vehicle whose SDK reports at 50 Hz. Detect per-key rate at the producer and, above a threshold, split that key with a salt — device_id#0, device_id#1 — accepting the loss of ordering for that device only and recording the decision so consumers can compensate.

4. Keep the key stable across the whole pipeline. If the ingest topic is keyed by device and the trigger topic by fence, the ordering guarantee is lost at the boundary and no downstream consumer can recover it. Re-keying is legitimate, but the ordering guarantee ends there and the pipeline should say so explicitly.

5. Co-locate consumer state with the partition. The debounce state from debouncing boundary flapping with state machines is keyed by device, so with device_id partitioning it lives entirely in one consumer with no shared store and no locking — a 40 ns dictionary lookup instead of a 200 µs Redis round trip. That is the largest practical benefit of the choice and it disappears entirely under any other key.

6. Make the key derivation identical to the idempotency key’s device component. Divergence between the two is a subtle source of duplicates when a rebalance moves a partition mid-flight.

Benchmark and verification

Measured at 25k triggers/sec over 24 partitions and 8 consumers:

Configuration Throughput Out-of-order pairs per million State store round trips Rebalance recovery
fence_id, shared state store 21k/s 4,100 25,000/s 12 s
device_id, shared state store 24k/s 0 25,000/s 9 s
device_id, local state 41k/s 0 0 14 s
device_id, local state + standby replicas 40k/s 0 0 1.8 s
Throughput, Out-of-order pairs per million, Rebalance recovery — 4 options Each panel scales on its own, so Throughput, Out-of-order pairs per million, Rebalance recovery are compared across 4 options without sharing an axis they do not share a unit with. Throughput, Out-of-order pairs per million, Rebalance recovery — 4 options Configuration — the table above, drawn to scale fence_id, shared state store device_id, shared state store device_id, local state device_id, local state + standby repl… Throughput 21k/s 24k/s 41k/s 40k/s Out-of-order pairs per million 4,100 0 0 0 Rebalance recovery 12 s 9 s 14 s 1.8 s
Throughput, Out-of-order pairs per million and 1 more for fence_id, shared state store, device_id, shared state store, device_id, local state and 1 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

Local state is a 1.7× throughput gain over a shared store, achieved purely by making the state reachable without a network hop — the change the partition key enables rather than one it performs. Its cost is rebalance recovery: a partition moving to a new consumer must rebuild its state, which takes longer than reconnecting to a shared store. Standby replicas, which keep a warm copy of each partition’s state on a second consumer, cut that from 14 s to 1.8 s and are the standard companion to local state, with the same trade-offs discussed in consumer group rebalancing without dropping triggers.

The out-of-order column is the correctness result and it is binary: fence_id produces 4,100 misordered pairs per million and device_id produces none, because misordering per device is impossible when a device’s events are all on one partition and consumed by one thread.

Verify by asserting per-device monotonicity at the consumer: each device’s events must arrive with non-decreasing hybrid logical clock values. Any decrease under device_id partitioning means either a re-key upstream or a rebalance replaying uncommitted offsets, both of which are worth an alert.

Failure modes and edge cases

Failure mode Signature Mitigation
Partition count increased Per-device order broken for the retention period Over-provision partitions once; never grow them
Re-keying mid-pipeline Ordering silently lost at the boundary Keep one key end to end, or document where it ends
Broker default partitioner assumed Consumer state placement wrong after an upgrade Pin the hash in application code
Pathological device One partition saturates despite a good key Detect per-key rate; salt above a threshold
Null key on some messages Round-robin for those, breaking order Reject null keys at the producer
Local state without standbys Long recovery on every rebalance Run standby replicas for each partition
Failure mode at a glance: Signature, Mitigation A row per failure mode, a column per option, so a single axis can be compared across options in one sweep. Failure mode at a glance: Signature, Mitigation the same trade-offs, read across instead of down Signature Mitigation Partition count increased Per-device order broken for the retention period Over-provision partitions once; never grow them Re-keying mid-pipeline Ordering silently lost at the boundary Keep one key end to end, or document where it ends Broker default partitioner assumed Consumer state placement wrong after an upgrade Pin the hash in application code Pathological device One partition saturates despite a good key Detect per-key rate; salt above a threshold Null key on some messages Round-robin for those, breaking order Reject null keys at the producer Local state without standbys Long recovery on every rebalance Run standby replicas for each partition
The failure mode table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Signature, Mitigation.

The null-key case in the fifth row is easy to miss and produces intermittent, unreproducible ordering bugs. A producer that omits the key on a subset of messages — a heartbeat, a synthetic trigger, a replayed record — sends them round-robin, so those messages can overtake keyed messages for the same device. Rejecting a null key at the producer, rather than letting the broker default apply, converts an intermittent correctness bug into an immediate and obvious failure.

One asymmetry is worth naming. device_id gives per-device order and gives up per-fence order, and there is a legitimate consumer that wants the latter: an occupancy counter for a zone needs to see that zone’s ENTERs and EXITs in order to keep a correct count. The answer is not to change the partition key but to derive a second, fence-keyed stream for that consumer, accepting that it lags and that its ordering guarantee is independent. Trying to satisfy both requirements from one topic is what produces the compromise key that satisfies neither.