Tuning Backpressure and Semaphores for Async Ingest

An asyncio meter-read ingest that runs fine all month can fall over on the one night it matters. Month-end pulls the whole service territory at once, and the naive pattern — asyncio.gather over every read — spawns hundreds of thousands of coroutines that each want a database connection and each hold a parsed record in memory. The pool has fifty connections; the heap is not infinite. The result is TimeoutError on connection acquisition, an OOM kill, or a slow-motion collapse where retries pile on top of an already-saturated pool. The fix is not a bigger machine; it is bounding the work with a bounded queue and an asyncio.Semaphore sized to the downstream pool, so producers slow down when consumers fall behind. This page sits under async batch processing for high-volume reads, part of the broader Meter Data Ingestion & Validation Pipelines subsystem, and shows how to keep concurrency and memory flat through the peak.

Minimal Prerequisites

  • Python 3.11+ — for asyncio.TaskGroup, structured cancellation, and zoneinfo in the standard library.
  • An async database driver with a bounded connection pool — the pool’s maximum size is the single most important number in this design; the semaphore is sized to it.
  • decimal.Decimal for every metered quantity — concurrency changes throughput, never numeric representation; reads stay decimal-safe end to end.
  • zoneinfo (not pytz) — reads carry timezone-aware timestamps so per-meter ordering is compared on true instants, not wall-clock strings.

Data assumptions: reads arrive from an upstream source (a file, an export endpoint, a topic) faster than the database can absorb them, each carries a stable meter_id and a monotonically increasing seq, and correctness requires that reads for the same meter are written in sequence order. Reads for different meters may be written concurrently.

Annotated Implementation

The shape is a classic producer/consumer with two bounds working together. The bounded queue caps how many parsed records live in memory at once — when it fills, await queue.put() blocks the producer, which is backpressure. The semaphore caps how many writes hit the database concurrently, sized to the connection pool so the pool is never oversubscribed. Per-meter ordering is preserved by routing each meter to a stable worker rather than letting any worker grab any read.

from __future__ import annotations

import asyncio
from dataclasses import dataclass
from decimal import Decimal

# --- Tunables: the semaphore is sized to the pool, the queue to memory ---
DB_POOL_SIZE = 20                       # hard ceiling from the async driver
CONCURRENCY = DB_POOL_SIZE - 2          # leave headroom for health checks / retries
QUEUE_MAXSIZE = CONCURRENCY * 8         # bounded buffer → backpressure when full
NUM_WORKERS = 8                         # stable shards for per-meter ordering


@dataclass(frozen=True)
class Read:
    meter_id: str
    seq: int
    register: Decimal                   # never float


async def producer(source, queue: asyncio.Queue[Read]) -> None:
    """Feed reads in; block on a full queue instead of buffering unboundedly."""
    async for raw in source:
        # If consumers fall behind, put() blocks here — the producer feels backpressure
        # and stops pulling from the source, so memory stays flat.
        await queue.put(Read(**raw))
    for _ in range(NUM_WORKERS):
        await queue.put(None)           # one sentinel per worker to drain cleanly


async def worker(
    queue: asyncio.Queue[Read],
    sem: asyncio.Semaphore,
    pool,
    last_seq: dict[str, int],
) -> None:
    """Consume reads; the semaphore gates concurrent DB writes to the pool size."""
    while True:
        read = await queue.get()
        try:
            if read is None:
                return                  # sentinel: this worker is done
            prev = last_seq.get(read.meter_id, 0)
            if read.seq <= prev:
                continue                # duplicate / late — already written, skip
            # Acquire before touching the pool: never more than CONCURRENCY writes at once.
            async with sem:
                async with pool.acquire() as conn:
                    await conn.execute(
                        "INSERT INTO reads(meter_id, seq, register) VALUES($1,$2,$3)"
                        " ON CONFLICT (meter_id, seq) DO NOTHING",
                        read.meter_id, read.seq, read.register,
                    )
            last_seq[read.meter_id] = read.seq
        finally:
            queue.task_done()


def shard_for(meter_id: str) -> int:
    # Stable hash → the same meter always lands on the same worker, so its reads
    # are dequeued and written in the order the producer enqueued them.
    return hash(meter_id) % NUM_WORKERS


async def run_ingest(source, pool) -> None:
    # One queue per shard keeps per-meter ordering without a global lock.
    queues = [asyncio.Queue[Read](maxsize=QUEUE_MAXSIZE) for _ in range(NUM_WORKERS)]
    sem = asyncio.Semaphore(CONCURRENCY)
    last_seq: dict[str, int] = {}

    async def fan_out() -> None:
        async for raw in source:
            read = Read(**raw)
            await queues[shard_for(read.meter_id)].put(read)   # backpressure per shard
        for q in queues:
            await q.put(None)

    async with asyncio.TaskGroup() as tg:
        tg.create_task(fan_out())
        for q in queues:
            tg.create_task(worker(q, sem, pool, last_seq))

Two numbers do the load-bearing work. CONCURRENCY is deliberately set below DB_POOL_SIZE, not equal to it — leaving a couple of connections free means a health check or a retry can still acquire one when the ingest is at full tilt, instead of deadlocking against itself. QUEUE_MAXSIZE bounds memory: with a full queue the producer blocks in put(), propagating backpressure all the way up to the source so the process never holds more than a fixed number of parsed records. Sharding by a stable hash of meter_id gives per-meter ordering for free — the same meter always enqueues on the same worker’s queue, so its reads are written in enqueue order — while different meters still write concurrently up to the semaphore ceiling. The ON CONFLICT DO NOTHING clause makes each write idempotent so a retried batch never double-inserts, the same contract the AMI/AMR feed synchronization layer relies on.

Producers feeding a bounded queue drained by semaphore-gated workers into a database pool A backpressure pipeline for async meter-read ingest. On the left a producer reads from the upstream source and enqueues parsed reads into a bounded queue with a fixed maximum size; when the queue is full the producer blocks on put, which is backpressure that keeps memory flat. In the middle a fixed pool of workers dequeues reads, with each meter routed to a stable worker by a hash shard so per-meter ordering is preserved. Before every database write a worker must acquire one slot from an asyncio semaphore whose count equals the connection concurrency, set below the pool size to leave headroom. On the right the bounded database connection pool absorbs only as many concurrent writes as the semaphore permits, so the pool is never oversubscribed even at month-end peak. Producer reads source await put() Bounded queue maxsize = fixed full → producer blocks (backpressure) Semaphore count = concurrency async with sem gates DB writes Worker 1 shard by meter_id Worker k per-meter order Worker N stable routing DB pool bounded within pool cap

Edge Cases and Billing Gotchas

  • Unbounded queue OOM. Replacing the bounded asyncio.Queue with an unbounded one — or with a plain list the producer appends to — removes backpressure entirely. On a month-end run the producer races ahead and buffers the whole territory in memory until the process is OOM-killed. The maxsize is not a tuning nicety; it is the only thing that keeps memory flat when the source is faster than the database.
  • Semaphore set too high exhausts the pool. If CONCURRENCY meets or exceeds DB_POOL_SIZE, workers acquire semaphore slots and then block waiting for a connection that never frees, and a health check or retry that also needs a connection deadlocks. Always size the semaphore below the pool and leave headroom, so acquiring a slot guarantees a connection is available.
  • A slow consumer stalls the whole pipeline. One worker stuck on a slow query holds its semaphore slot, shrinking effective concurrency, while its shard’s queue fills and backpressures the producer. Put a timeout on each write and route persistent failures to a dead-letter path via error handling and retry workflows so one bad meter cannot freeze the run.
  • Ordering broken by a shared worker pool. If any worker can pull any read, two reads for the same meter can be written out of sequence and corrupt the register delta. Sharding by a stable hash of meter_id binds each meter to one worker, preserving order per meter while different meters still write concurrently — the same ordering guarantee the reading anomaly detection algorithms downstream assume.

Verification Snippet

Assert the two invariants that make the design safe under peak: concurrent DB writes never exceed the semaphore count, and reads for one meter are written in sequence order.

import asyncio
from decimal import Decimal


async def test_semaphore_caps_concurrent_writes() -> None:
    sem = asyncio.Semaphore(4)
    live = 0
    peak = 0

    async def fake_write() -> None:
        nonlocal live, peak
        async with sem:
            live += 1
            peak = max(peak, live)
            await asyncio.sleep(0.01)        # simulate a DB round-trip
            live -= 1

    await asyncio.gather(*(fake_write() for _ in range(50)))
    assert peak <= 4                          # never oversubscribed the "pool"


def test_shard_preserves_per_meter_order() -> None:
    # Every read for a meter routes to one worker, so enqueue order == write order.
    reads = [("MTR-1", 1), ("MTR-1", 2), ("MTR-1", 3)]
    shards = {shard_for(m) for m, _ in reads}
    assert len(shards) == 1                    # one meter → exactly one worker
    seqs = [s for _, s in reads]
    assert seqs == sorted(seqs)                # sequence order is monotonic

The first test proves the semaphore is a real ceiling: fifty simultaneous writers never put more than four in flight, so a pool of that size is never oversubscribed. The second proves a single meter’s reads all land on one worker, which is what turns FIFO queue order into correct per-meter write order.

FAQ

How do I size the semaphore?
Set it a few slots below the database connection pool's maximum size, never equal to or above it. If the pool holds twenty connections, a semaphore of around eighteen keeps the ingest busy while leaving headroom for health checks, retries, and other callers. Sizing at or above the pool means a worker can hold a semaphore slot while blocking forever on a connection that will not free, which deadlocks the run.
What actually creates backpressure here?
The bounded queue. When consumers fall behind, the queue reaches its maxsize and await queue.put() blocks the producer, which stops pulling from the upstream source. That block propagates all the way up, so the process never holds more than a fixed number of parsed records in memory. An unbounded queue removes this signal entirely and lets the producer buffer the whole territory until the process is killed.
Why not just use asyncio.gather over all the reads?
Because gather schedules every coroutine at once. On a month-end run that is hundreds of thousands of coroutines each trying to acquire a database connection and each holding a record in memory, which exhausts the pool and the heap. A bounded queue with a fixed set of semaphore-gated workers processes the same volume with flat, predictable resource use instead of an unbounded spike.
How is per-meter ordering preserved with concurrent workers?
By sharding: a stable hash of the meter id routes every read for that meter to the same worker, so its reads are dequeued and written in the order the producer enqueued them. Different meters hash to different workers and write concurrently up to the semaphore ceiling, so you keep both correctness for a single meter and throughput across meters.
What happens when one write hangs?
A hung write holds its semaphore slot, which lowers effective concurrency, and its shard's queue backs up and backpressures the producer. Guard every write with a timeout so a stuck query is cancelled rather than held indefinitely, and route reads that keep failing to a dead-letter path so one problem meter cannot stall the entire run.

Up: Async Batch Processing for High-Volume Reads · Meter Data Ingestion & Validation Pipelines