A Bounded LRU Node Cache for OSM Streaming Jump to heading
Resolve way geometry during a streaming OSM parse while holding at most N node coordinates in RAM, evicting the least-recently-used node_id → (lon, lat) entry each time the cache is full, so a planet-scale pass never lets the location store grow without limit.
Prerequisites Jump to heading
Verify each item before running the cache below; a wrong assumption about primitive ordering is the usual reason a small cap produces a catastrophic miss rate.
Conceptual minimum Jump to heading
A way in OpenStreetMap stores no coordinates of its own — it is an ordered list of node ids, and turning it into a line or polygon means looking each id up in a table of previously seen node positions. The library-managed stores that pyosmium offers (flex_mem, sparse_file_array, dense_file_array) all solve this by keeping every node’s location addressable; that is exactly what you want for random access, but it also means the store’s size is a function of the extract, not of your RAM budget. When you are willing to trade a controlled miss rate for a hard memory ceiling, a bounded least-recently-used (LRU) cache inverts that relationship: you fix the number of resident coordinates, and the cache evicts whichever id has gone longest without a lookup.
The technique only pays off because of a locality property of the PBF format. Nodes and ways are serialized in ascending id blocks, and a way’s member nodes were typically created together, so their ids cluster — which means that when the parser reaches a way, the coordinates it needs were usually seen a short time ago and are still resident. This is the same block-locality that the PBF File Structure Deep Dive describes for decode framing, reused here as a cache-hit assumption. A Python collections.OrderedDict makes the eviction O(1): move_to_end(key) promotes an entry to the most-recently-used position on every hit, and popitem(last=False) drops the least-recently-used entry from the front the moment the cap is exceeded. The cost you accept is the miss: a node evicted before its way arrives — common for long ways or interleaved editing history — forces you to either skip that way or fall back to a full store, so the cache is a deliberate trade against pyosmium’s own sparse_file_array, which never misses but never bounds itself either.
Runnable solution Jump to heading
The NodeCache below wraps an OrderedDict and exposes just the two operations a resolver needs: put(node_id, lon, lat) when a node callback fires, and get(node_id) when a way needs a member’s coordinate. Every access reorders the entry, and a full cache evicts before it inserts, so the resident set is capped at maxsize. Hit and miss counters make the locality assumption measurable rather than a matter of faith.
from __future__ import annotations
import logging
from collections import OrderedDict
import osmium
logger = logging.getLogger(__name__)
Coord = tuple[float, float]
class NodeCache:
"""A fixed-capacity LRU cache mapping node id -> (lon, lat).
Backed by an OrderedDict: a lookup promotes its key to the most-recently
used end via move_to_end, and an insertion past ``maxsize`` evicts the
least-recently used key with popitem(last=False). Peak entries never
exceed ``maxsize``, so the location store's memory is bounded regardless
of how many nodes the extract contains.
"""
def __init__(self, maxsize: int = 2_000_000) -> None:
if maxsize < 1:
raise ValueError("maxsize must be >= 1")
self.maxsize = maxsize
self._store: OrderedDict[int, Coord] = OrderedDict()
self.hits = 0
self.misses = 0
def put(self, node_id: int, lon: float, lat: float) -> None:
if node_id in self._store:
self._store.move_to_end(node_id) # refresh recency
self._store[node_id] = (lon, lat)
if len(self._store) > self.maxsize:
evicted_id, _ = self._store.popitem(last=False) # drop the LRU entry
logger.debug("evicted node %d (cache full)", evicted_id)
def get(self, node_id: int) -> Coord | None:
coord = self._store.get(node_id)
if coord is None:
self.misses += 1
return None
self._store.move_to_end(node_id) # this access is now most-recent
self.hits += 1
return coord
@property
def hit_rate(self) -> float:
total = self.hits + self.misses
return self.hits / total if total else 0.0
def __len__(self) -> int:
return len(self._store)
class WayResolver(osmium.SimpleHandler):
"""Resolve way geometry from a bounded node cache in a single pass.
Nodes populate the cache as they stream past; each way then reads its
member ids back out. A way whose nodes were already evicted is counted
as unresolved rather than crashing the stream.
"""
def __init__(self, maxsize: int = 2_000_000) -> None:
super().__init__() # required by the pyosmium C++ binding
self.cache = NodeCache(maxsize=maxsize)
self.resolved_ways = 0
self.unresolved_ways = 0
def node(self, n: osmium.osm.Node) -> None:
if n.location.valid():
self.cache.put(n.id, n.location.lon, n.location.lat)
def way(self, w: osmium.osm.Way) -> None:
coords: list[Coord] = []
for nr in w.nodes:
coord = self.cache.get(nr.ref)
if coord is None:
self.unresolved_ways += 1
return # a member was evicted; skip this way
coords.append(coord)
self.resolved_ways += 1
# coords now holds the way geometry as (lon, lat) pairs.
def report(self) -> None:
logger.info(
"resolved=%d unresolved=%d cache_len=%d hit_rate=%.4f",
self.resolved_ways, self.unresolved_ways,
len(self.cache), self.cache.hit_rate,
)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
resolver = WayResolver(maxsize=2_000_000)
# No locations=True: the cache IS the location store here.
resolver.apply_file("extract.osm.pbf")
resolver.report()
Step-by-step walkthrough Jump to heading
- Cap enforced on insert, not on read —
putappends first, then checkslen(self._store) > self.maxsizeand evicts. Doing the eviction after the insert keeps the newest entry safe even in the degeneratemaxsize == 1case. - Recency refresh on both paths —
move_to_endruns insideputwhen a key already exists and inside every successfulget, so an id that is looked up repeatedly stays resident even if it is old by insertion order. popitem(last=False)is the LRU eviction — withlast=Falsethe first (oldest-touched) item is removed; the defaultlast=Truewould pop the most-recent entry and turn the structure into a stack, which is the opposite of what you want.- Misses are data, not errors —
getreturnsNoneand increments a counter rather than raising, so the way handler decides the policy (here: skip). This keeps the stream running across the inevitable boundary and long-way misses. - No
locations=Trueonapply_file— the whole point is that this cache replaces pyosmium’s internal store, so you parse without the library location index and let thenode()callback feed the cache directly. hit_rateturns the locality bet into a metric — read it after the run: a healthy nodes-then-ways extract should land well above 0.9 with a few-million-entry cap. If it does not, the extract’s ordering, not your code, is the problem.
Verification Jump to heading
Confirm the cache behaves and that the trade is paying off before you trust the resolved geometry:
- Resident set stays capped. Assert
len(resolver.cache) <= resolver.cache.maxsizeafter the run; it can equal the cap but must never exceed it. - Hit rate is high on well-ordered input.
resolver.cache.hit_rateshould print above ~0.90 for a standard Geofabrik extract; a value near 0.5 means the file is not in nodes-then-ways id order. - Unresolved count is small and explained.
unresolved_waysshould be a small fraction — dominated by ways clipped at the extract boundary — not a large share, which would signal an undersized cap. - Eviction actually fires. Run with a deliberately tiny
maxsize(say 1000) and watch theevicted nodedebug lines appear, provingpopitemis reached. - Determinism. Two runs over the same file must report identical
resolved/unresolvedcounts, since the ordering and cap fully determine eviction.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Hit rate collapses to ~0.5 | Extract not in nodes-then-ways id order | Sort with osmium sort before parsing, or use a two-pass resolver. |
| Memory still grows unbounded | Forgot the len > maxsize check, or set maxsize too high |
Keep the eviction branch; size maxsize from RAM ÷ per-entry bytes. |
| Every way is unresolved | Parsed with locations=True, so nr.ref coords come from pyosmium, not the cache |
Drop locations=True; let node() populate the cache. |
popitem pops the newest entry |
Called with default last=True |
Use popitem(last=False) to evict the LRU end. |
| Stale coordinate returned after a node move | History file re-versions a node id | Cache only current-version extracts, or key on (id, version). |
KeyError on move_to_end |
Called on a key already evicted between check and use | Guard with the get/in check as shown; never assume residency. |
Specification reference Jump to heading
The eviction order is defined by
collections.OrderedDict: “Thepopitem()method for ordered dictionaries returns and removes a (key, value) pair. The pairs are returned in LIFO order iflastis true or FIFO order if false,” and “move_to_end()moves an existing key to either end of an ordered dictionary.” See the Pythoncollections.OrderedDictdocumentation. The standard library also ships a ready-made general LRU infunctools.lru_cache; it is ideal for pure-function memoization but exposes no manualputfor a streaming callback and no bounded-store introspection, which is why an explicitOrderedDictis the right primitive for resolving OSM way geometry.
Frequently Asked Questions Jump to heading
How large should maxsize be?
Derive it from RAM, not intuition. Each resident entry is roughly the int key plus a two-float tuple plus dict/OrderedDict overhead — on CPython 3.10 that lands near 100–150 bytes per entry in practice. A cap of two million entries therefore costs a few hundred megabytes, which comfortably resolves a country-scale extract at a high hit rate. Measure a sample with sys.getsizeof on a populated store and divide your budget by the observed per-entry cost.
When is pyosmium's own location store the better choice?
When you cannot tolerate any miss. A sparse_file_array or dense_file_array store keeps every node addressable, so no way is ever dropped for want of a coordinate — at the cost of a size that scales with the extract. Prefer the LRU when a fixed memory ceiling matters more than resolving the last few percent of long or boundary-clipped ways; prefer the library store when completeness is non-negotiable.
Why does the cache miss on long ways even with a big cap?
A way’s first member node may have been read millions of primitives ago, and if that many distinct nodes were touched since, the id has been evicted by newer arrivals. Very long ways (coastlines, administrative boundaries) are the classic offenders. Raising maxsize shrinks the miss set; sorting the input so members cluster tightly helps more.
Can I reuse functools.lru_cache instead of writing this?
Only for pure functions. functools.lru_cache decorates a callable and keys on its arguments, so it fits memoizing a computed lookup, but it gives you no way to imperatively insert a coordinate from a streaming node() callback and no clean handle on the resident set for tuning. For a location store fed by parser events, the explicit OrderedDict is the correct tool.
Related Jump to heading
- Memory-Efficient Chunk Processing — the parent stage; this cache bounds the location store while that page bounds the record buffer.
- Sizing PBF Chunk Batches to a Memory Budget — the companion lever for turning a RAM budget into a concrete batch size.
- Node-Way-Relation Data Model — why way geometry is a deferred join over node ids in the first place.
- PBF File Structure Deep Dive — the block and id ordering that makes the cache-hit assumption hold.
- Spatial Indexing for OSM Extracts — where resolved geometries go once the cache has reconstructed them.
- Async PBF Parsing with Pyrosm — parallel ingestion for when throughput, not location memory, is the limit.
Up one level: Memory-Efficient Chunk Processing.