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 | — |
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 |
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.
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 |
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 |
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.
Related
- Kafka vs Redis Streams for Trigger Routing — the parent topic and the broker-level differences in partitioning.
- Consumer Group Rebalancing Without Dropping Triggers — what happens to local state when a partition moves.
- Hybrid Logical Clocks for Geofence Event Ordering — the monotonicity assertion that verifies the key is doing its job.