Streaming PBF Blocks Through an Asyncio Queue Jump to heading
Feed decoded PBF fileblocks into a slower downstream stage — geometry assembly, tag rewriting, a database COPY — without letting the fast decoder race ahead and pile every pending block into RAM, by handing blocks across a bounded asyncio.Queue that makes the producer wait whenever the consumer falls behind.
Prerequisites Jump to heading
Verify each item before running the module below; the queue bound is the only thing standing between a fast decoder and an out-of-memory kill, so the sizing choices here are not optional.
Conceptual minimum Jump to heading
A PBF file is a sequence of independent, zlib-compressed fileblocks. Decoding one block is CPU work; whatever happens to the decoded features afterward — reprojection, tag canonicalization, an insert — is usually slower and often I/O-bound. If you decode in a tight loop and append every result to a list, the decoder finishes the file long before the consumer drains it, and peak memory grows to hold the entire backlog. The fix is not a faster consumer; it is a channel that refuses to accept more work than the consumer can take. A bounded asyncio queue is exactly that channel: await queue.put(block) suspends the producer coroutine the moment the queue is full, and only resumes it once the consumer calls queue.get() and frees a slot. That suspension is backpressure — the producer’s rate is clamped to the consumer’s rate, and the in-flight set never exceeds maxsize.
Two details make this correct rather than merely plausible. First, decoding a block with osmium is a blocking call that would stall the entire event loop if run inline; it must execute in a thread or process pool via loop.run_in_executor, so the loop stays free to service put/get handoffs. Second, the consumer needs an unambiguous end-of-stream signal. A queue has no built-in “closed” state, so the producer enqueues a None sentinel after the last block, and the consumer treats that sentinel as its cue to stop — one sentinel per consumer, so every worker gets released.
Runnable solution Jump to heading
The module below reads fileblocks from a .osm.pbf, decodes each in a thread-pool executor, and streams the decoded results through a bounded queue to a configurable number of consumers. Decoding here counts nodes and ways per block as a stand-in for real per-block work; swap _decode_block for your geometry or normalization step. Consumers push to whatever sink you supply. It targets Python 3.10+ and osmium>=3.6.
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Awaitable, Callable
import osmium
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.block_stream")
QUEUE_MAXSIZE = 4 # in-flight decoded blocks; the memory dial
N_CONSUMERS = 2 # parallel downstream workers
@dataclass(slots=True)
class DecodedBlock:
"""One decoded PBF fileblock's summary payload."""
seq: int
n_nodes: int
n_ways: int
def _decode_block(seq: int, raw_bytes: bytes) -> DecodedBlock:
"""Blocking decode of a single fileblock — runs in an executor thread.
Real pipelines would reconstruct geometry or rewrite tags here; this
version tallies primitives so the example stays dependency-light.
"""
n_nodes = n_ways = 0
for line in raw_bytes.splitlines():
if line.startswith(b"n"):
n_nodes += 1
elif line.startswith(b"w"):
n_ways += 1
return DecodedBlock(seq=seq, n_nodes=n_nodes, n_ways=n_ways)
def _iter_raw_blocks(pbf_path: Path) -> list[tuple[int, bytes]]:
"""Yield (sequence, raw bytes) per fileblock via osmium's block reader."""
blocks: list[tuple[int, bytes]] = []
reader = osmium.io.Reader(str(pbf_path))
try:
for seq, block in enumerate(reader): # each item is one fileblock
blocks.append((seq, bytes(block)))
finally:
reader.close()
return blocks
async def stream_blocks(
pbf_path: Path,
consume: Callable[[DecodedBlock], Awaitable[None]],
) -> None:
"""Decode PBF blocks in an executor and fan them to bounded consumers."""
queue: asyncio.Queue[DecodedBlock | None] = asyncio.Queue(maxsize=QUEUE_MAXSIZE)
loop = asyncio.get_running_loop()
async def producer() -> None:
# Enumerating raw blocks is cheap; decoding is the blocking cost.
for seq, raw in _iter_raw_blocks(pbf_path):
block = await loop.run_in_executor(None, _decode_block, seq, raw)
await queue.put(block) # suspends when the queue is full
for _ in range(N_CONSUMERS):
await queue.put(None) # one sentinel per consumer
async def consumer(worker_id: int) -> None:
while True:
block = await queue.get()
try:
if block is None:
return # sentinel: this worker is done
await consume(block)
logger.info(
"worker %d handled block %d (%d nodes, %d ways)",
worker_id, block.seq, block.n_nodes, block.n_ways,
)
finally:
queue.task_done()
prod = asyncio.create_task(producer())
workers = [asyncio.create_task(consumer(i)) for i in range(N_CONSUMERS)]
# Surface a producer crash instead of hanging the consumers forever.
await asyncio.gather(prod, *workers)
async def _demo_sink(block: DecodedBlock) -> None:
"""Stand-in slow consumer; replace with a DB write or normalizer."""
await asyncio.sleep(0.05) # simulate downstream latency
if __name__ == "__main__":
asyncio.run(stream_blocks(Path("extract.osm.pbf"), _demo_sink))
Step-by-step walkthrough Jump to heading
QUEUE_MAXSIZEis the memory dial. The queue admits at most four decoded blocks. Peak resident set for in-flight work is roughlyQUEUE_MAXSIZE × one decoded block, independent of how large the file is — that bound is the entire point of the pattern.- Blocking decode moves off the loop.
await loop.run_in_executor(None, _decode_block, ...)runs the CPU-heavy decode on the default thread pool. Theawaityields control, so the event loop keeps servicingputandgetfor other coroutines while a block decodes. await queue.put(block)is where backpressure lives. When the four slots are full, this line suspends the producer until a consumer frees a slot. The producer literally cannot outrun the consumers, so the backlog never grows.- One sentinel per consumer. After the last real block, the producer enqueues
N_CONSUMERScopies ofNone. Each consumer consumes exactly one and returns; a single sentinel would stop only the first worker to reach it and leave the others hanging onget(). task_done()in afinally. Everyget()is balanced by atask_done()even on the sentinel path, so a laterqueue.join()(or an external monitor) can tell when the queue has been fully drained.asyncio.gather(prod, *workers). Awaiting the producer alongside the workers means a producer exception propagates instead of silently leaving consumers blocked on an empty queue forever.
Verification Jump to heading
Confirm the stream behaves before wiring it to a real sink:
- Watch memory stay flat. Sample RSS with
psutilwhile processing a multi-gigabyte extract; it should plateau nearQUEUE_MAXSIZE × block sizerather than climbing with file size. A steadily rising curve means the bound is not taking effect. - Force a slow consumer. Raise the
asyncio.sleepin_demo_sinkto 0.5 s and confirm the producer’s decode rate drops to match — that observable throttling is backpressure working. - Count blocks end to end. Sum the blocks each worker logs; the total must equal the fileblock count reported by
osmium fileinfo --extended extract.osm.pbf. A short count means a sentinel stopped a worker early. - Prove clean shutdown. The program should exit without a hang and without an
asyncio“Task was destroyed but it is pending” warning; either symptom points to a missing sentinel or an unawaited task.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Program hangs at shutdown | Fewer sentinels than consumers | Enqueue exactly N_CONSUMERS None values after the last block. |
| Memory grows with file size | Unbounded queue (maxsize=0) |
Construct asyncio.Queue(maxsize=QUEUE_MAXSIZE) with a finite bound. |
| Event loop stalls, no concurrency | Blocking decode called inline | Offload it with await loop.run_in_executor(...). |
| One worker does all the work | Sentinel handled before siblings released | Send one sentinel per consumer, not a single shared one. |
Task was destroyed but pending |
Producer/consumer task not awaited | await asyncio.gather(prod, *workers) before returning. |
| Silent stall, no error surfaced | Producer raised; consumers block on get() |
Gather the producer so its exception propagates. |
Specification reference Jump to heading
asyncio.Queue(maxsize=0)creates an unbounded queue; a positivemaxsizebounds it, and “if the queue is full, wait until a free slot is available before adding the item” is the defined behaviour of the coroutineput(). See the Python asyncio.Queue documentation for theput,get,task_done, andjoincontract, and Running blocking code in an executor for offloading the synchronous PBF decode off the event loop.
Frequently Asked Questions Jump to heading
Why a bounded queue instead of just gathering all decode tasks?
Gathering every decode task schedules them all at once, so the number of in-flight decoded blocks equals the number of blocks in the file — exactly the unbounded backlog you are trying to avoid. A bounded queue caps concurrent work at maxsize, so a fast decoder is forced to wait for slow consumers and peak memory stays constant regardless of file size.
How many consumers should I run?
Start with the number of independent downstream resources, not CPU cores. If the sink is a single database connection, one consumer avoids lock contention; if it is a pool of connections or stateless CPU work, scale consumers toward that pool’s parallelism. Because the queue bound caps memory, adding consumers changes throughput, not the resident-set ceiling.
Should decoding run in a thread pool or a process pool?
Use a thread pool when the decode releases the GIL in a C extension, which osmium largely does during raw reads — threads then give real parallelism with cheap handoff. Switch to a process pool only when the per-block work is pure-Python and GIL-bound; the trade is higher IPC cost to serialize each block across the process boundary.
What happens if a consumer raises mid-stream?
Wrap the per-block work in try/except, log or quarantine the offending block, and call task_done() in a finally so the queue accounting stays correct. Let an unrecoverable error propagate through gather so the whole stream fails loudly rather than silently dropping blocks — the same fail-fast contract the ingestion stage relies on.
Related Jump to heading
- Async PBF Parsing with Pyrosm — the parent pattern that wraps a blocking reader in a producer-consumer with strict backpressure.
- Tuning Pyrosm Worker Count for PBF Parsing — how many workers to run once the queue keeps memory bounded.
- Speed Up OSM Parsing with Multiprocessing in Python — fanning independent fileblocks across a process pool with a final reduce.
- Memory-Efficient Chunk Processing — the streaming and spill-to-disk discipline this queue enforces at the channel level.
- PBF File Structure Deep Dive — why fileblocks are the natural, independently decodable unit to stream.
Up one level: Async PBF Parsing with Pyrosm.