Reverse Geocoding GPS Pings at Scale in Python

TL;DR: Resolve cells, not pings. Group the surviving coordinates by cache cell, coalesce concurrent requests for the same cell so it is fetched once, dispatch the misses under a semaphore and rate limiter, then broadcast each answer back to every ping in its cell. The upstream compression step is deduplicating driver position pings.

The Shape of the Dispatcher

The dispatcher is a fan-in followed by a fan-out. Many pings collapse onto few cells, the cells are resolved once each, and the answers expand back across the original rows. Getting that shape right is what keeps the provider call count proportional to places visited rather than to reports received.

Fan-in to cells, fan-out to pings Five stages left to right. Forty thousand pings are grouped into eight thousand distinct cells. A single pipelined cache read resolves most of them. The remaining misses are coalesced so each cell is fetched once and dispatched under concurrency limits. The answers are then broadcast back to all forty thousand pings. pings 41 000 distinct cells 8 100 cache misses 1 200 provider calls 1 200 broadcast 41 000 rows The provider sees 3% of the pings. Everything else is answered by grouping and caching, both of which are local. Coalescing matters at the third arrow: several tasks wanting the same missing cell must produce one call, not many.

The Implementation

from __future__ import annotations

import asyncio
import json
from collections import defaultdict

import redis.asyncio as aioredis
from aiolimiter import AsyncLimiter


class ReverseResolver:
    """Resolve reverse-geocoding answers per cache cell, once each."""

    def __init__(
        self,
        r: aioredis.Redis,
        fetch,                                   # async (lat, lon) -> dict
        *,
        concurrency: int = 16,
        rate: AsyncLimiter | None = None,
        ttl_s: int = 30 * 86_400,
    ) -> None:
        self._r = r
        self._fetch = fetch
        self._sem = asyncio.Semaphore(concurrency)
        self._rate = rate or AsyncLimiter(300, 60)
        self._ttl = ttl_s
        self._inflight: dict[str, asyncio.Future] = {}

    async def resolve_many(self, cells: dict[str, tuple[float, float]]) -> dict[str, dict]:
        """Resolve a mapping of cell key -> representative coordinate."""
        keys = list(cells)
        cached = await self._r.mget(keys)                 # one round trip
        out: dict[str, dict] = {}
        misses: list[str] = []
        for key, raw in zip(keys, cached):
            if raw is None:
                misses.append(key)
            else:
                out[key] = json.loads(raw)

        results = await asyncio.gather(
            *(self._resolve_one(k, *cells[k]) for k in misses),
            return_exceptions=True,
        )
        for key, res in zip(misses, results):
            if not isinstance(res, Exception):
                out[key] = res
        return out

    async def _resolve_one(self, key: str, lat: float, lon: float) -> dict:
        """Fetch one cell, coalescing concurrent callers onto a single request."""
        existing = self._inflight.get(key)
        if existing is not None:
            return await existing                          # someone else is fetching

        fut: asyncio.Future = asyncio.get_running_loop().create_future()
        self._inflight[key] = fut
        try:
            async with self._sem, self._rate:
                value = await self._fetch(lat, lon)
            await self._r.set(key, json.dumps(value), ex=self._ttl)
            fut.set_result(value)
            return value
        except Exception as exc:                            # noqa: BLE001 - propagated
            fut.set_exception(exc)
            raise
        finally:
            self._inflight.pop(key, None)

The _inflight map is the coalescing mechanism, and it matters more here than in forward geocoding. A vehicle sitting at a depot produces many pings in one cell within the same batch; without coalescing, the concurrent tasks each see a cache miss and each call the provider for an identical answer.

Coalescing, Drawn

Four requests for one cell, one provider call Four concurrent tasks all need the same missing cell. The first creates an in-flight future and issues the provider call. The other three find the future already present and await it. When the call returns, all four receive the same result and one call was billed. task A task B task C task D in-flight future created once per cell key one provider call result returned to all four awaiters Without the shared future, a vehicle parked in one cell produces one billable call per ping in the batch.

Choosing the Representative Coordinate

Each cell needs one coordinate to send to the provider, and the choice is not arbitrary. The cell centre is tempting and can fall in the middle of a road or a river; a real observed ping is always a place a vehicle actually was.

Strategy Behaviour Use when
cell centre deterministic, may land nowhere meaningful cells are small and the terrain is dense
first ping in cell cheap, biased toward arrival point order within the cell is meaningful
medoid of pings in cell most representative, costs a small computation dwell periods dominate the cell
longest-dwell ping matches where the vehicle actually stopped activity and stop reporting

For delivery workloads the last row is usually right: the address you want is where the vehicle stood for eleven minutes, not where it happened to enter the cell. Computing it is trivial once pings are grouped, and it materially improves the usefulness of the answer.

Writing the Answers Back

The broadcast step is a join, and doing it as a join rather than a per-row lookup is what keeps it fast. Build a small frame of cell to address and merge it onto the ping frame in one operation.

import pandas as pd


def attach_addresses(pings: pd.DataFrame, resolved: dict[str, dict]) -> pd.DataFrame:
    """Broadcast per-cell answers back onto every ping in that cell."""
    lookup = pd.DataFrame(
        [{"cell": k, **v} for k, v in resolved.items()]
    ).set_index("cell")
    return pings.join(lookup, on="cell", how="left", rsuffix="_addr")

Keep the cell column on the output. It is the natural join key for any later enrichment, it makes the cache behaviour auditable after the fact, and it lets a re-run answer from the cache without recomputing the encoding.

Throughput and Failure Handling

return_exceptions=True on the gather is deliberate: one provider failure must not discard the answers already obtained for other cells. The failed cells are simply absent from the result mapping, their pings keep a null address, and a later pass can retry them without re-resolving anything that succeeded.

That partial-result behaviour also shapes the retry policy. Because the unit of work is a cell rather than a ping, a retry costs one call regardless of how many pings depended on it — which is a good reason to let the resilience stack be relatively patient here compared with an interactive path.

Tuning Concurrency for This Workload

Reverse geocoding tunes differently from the forward path, because the work is far more uniform. Every request is a coordinate pair, responses are similarly sized, and there is no long tail of unusually hard inputs — which means the concurrency setting can sit closer to the rate limiter’s ceiling than it safely could on the forward side.

Start by computing the concurrency that the rate actually implies: multiply the permitted requests per second by the observed median latency in seconds. A provider allowing five requests per second with a 200 millisecond median needs only one concurrent request to saturate the rate, and setting the semaphore to sixteen simply means fifteen tasks are always waiting on the limiter rather than on the network.

That calculation usually reveals the limiter as the binding constraint, which is the comfortable place to be: the pipeline is bounded by a number you agreed with the provider rather than by a resource of your own. When the reverse is true — latency high enough that concurrency binds first — the useful lever is a second provider or a local snap, not a larger semaphore.

Watch memory as well as rate. Each in-flight request holds a response buffer and a task frame, and a batch that raises concurrency into the hundreds against a provider returning verbose payloads will find its ceiling in the container’s memory limit rather than in any setting you chose deliberately.

Picking the Representative Point Matters

Cell centre versus longest-dwell ping A single cache cell contains a cluster of pings at a loading bay and a few pings on the road passing through. The geometric centre of the cell falls on the road and reverse-geocodes to a street. The longest-dwell ping falls at the loading bay and reverse-geocodes to the building the driver actually visited. one cache cell cell centre → the road deterministic, and not where anyone stopped longest-dwell ping → the loading bay where the driver actually spent eleven minutes For activity and delivery reporting, the dwell point is almost always the answer worth paying for.

Edge Cases and Failure Modes

A cell spanning a boundary. A cell that straddles two streets returns one of them for every ping in it. If that matters, drop to a finer precision for the affected regions rather than globally — the cost scales with cell count, so a targeted change is far cheaper than a global one.

Cache stampede after a deploy. Changing the cell precision or the key prefix invalidates every entry at once. Roll the change out behind a version segment in the key and let the old entries expire, exactly as described for cache key versioning.

Unbounded in-flight map. The coalescing dictionary must be cleaned in a finally, or a failed fetch leaves a completed future in place and every later caller for that cell receives the old exception. The version above pops the key unconditionally.

Rate limiter shared across batches. If several batches run concurrently in one process, they must share one limiter instance, not construct their own — otherwise the effective rate is multiplied by the number of concurrent batches.

Integration Note

This dispatcher sits between the ping compression filter and whatever consumes addresses downstream. It shares its rate limiter and resilience settings with the forward path, since both compete for the same provider quota — and if the reverse volume is large, it is usually the reverse path that determines what the forward path has left to spend.