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.
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
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
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.
Related
- Reverse Geocoding Workflows in Python — precision targets, cell sizing and validation.
- Deduplicating Driver Position Pings Before Reverse Geocoding — the filter that runs first.
- How to Set Up Asyncio for Bulk Geocoding — session, connector and gather patterns shared with the forward path.