11 min read 8 sections

Backpressure & Flow-Control Strategies for Geofence Pipelines

A geofence router fails in one of two ways under overload. Either it buffers everything and dies when the heap exhausts — the OOM killer takes the process mid-burst and every in-flight trigger vanishes — or it drops indiscriminately and loses the compliance-boundary crossing that the whole system exists to catch, right next to the idle-vehicle ping nobody would miss. Backpressure is the machinery that forces a deliberate third option: when demand outruns capacity, the pipeline signals upstream, sheds by priority, and holds its tail-latency contract instead of collapsing. This page expands the queue-semantics model introduced in event routing and backpressure, and the failure mode it addresses is unbounded buffering under burst — the traffic shape where a router that looks healthy at 1x load detonates at 3x because nothing ever told the producer to slow down.

The reader here owns a Python routing service that consumes a GPS telemetry stream, evaluates containment, and emits ENTER/EXIT/DWELL triggers to downstream consumers. The core insight is that a bounded asyncio.Queue is not a buffer — it is a signal. Its full() state is the single most important control point in the pipeline, because that boolean is where the system decides whether to shed, throttle, or accept. Everything below is about making that decision correct, fast, and priority-aware, so that under a 3x burst the router sheds roughly 8% of low-value telemetry while holding a held-time P99 under 40ms and never dropping a compliance event.

Priority-lane flow-control valve keyed to bounded-queue depth thresholds Three priority input lanes feed a depth gauge with accept, rate-limit, and shed bands; compliance is always admitted via reserved headroom, idle pings are shed first. Flow-control valve: priority lanes gated by queue depth Compliance events always admitted Enter / Exit transitions shed second Idle pings shed first Bounded queue depth ≥ 85% · SHED 60–85% · RATE-LIMIT < 60% · ACCEPT reserved headroom Evaluation worker accepted work Dead-letter path shed idle / transitions
Queue depth is the control variable. Below 60% every lane is accepted; between 60% and 85% idle pings are token-bucket throttled; above 85% the valve sheds idle pings then transitions, while compliance events drain a reserved headroom slice and are never dropped.

Algorithmic Divergence and Latency Profiles

There are four flow-control primitives worth distinguishing, and they answer different questions. Blocking (an unbounded await queue.put()) propagates backpressure perfectly but converts a downstream slowdown into unbounded latency and, eventually, an OOM. Shedding (drop at the boundary when full) bounds latency and memory absolutely but discards work. Rate limiting (token-bucket or leaky-bucket) smooths the admitted rate to a sustainable ceiling so that shedding is rare and graceful rather than cliff-edged. Credit-based flow control makes the consumer, not the producer, authoritative: downstream advertises how many events it can absorb, and the router emits exactly that many. Real pipelines compose all four — a bounded queue that sheds by priority above a watermark, fronted by a token bucket that throttles the admitted rate, coupled to credit windows from slow consumers.

The decision that matters most is shed-vs-block, and it is not a matter of taste. Block when the producer can slow down without losing data — a Kafka consumer that simply stops polling holds its offset and resumes, so blocking is free correctness. Shed when the producer is a live physical stream that cannot be paused — GPS devices emit at 1–10Hz whether or not you are ready, so refusing to read does not stop the data, it only moves the buffer into the kernel socket and then drops it there, invisibly. For geofence ingestion the producer is almost always the unpausable kind, which is why shedding by priority is the dominant pattern and blocking is reserved for the internal hops between the router and a pausable broker.

The measured profile below uses a 12-core host, one async worker draining a bounded queue of maxsize=100_000, telemetry at a 30k events/sec baseline driven to a 3x burst, with compliance events at ~2% of volume and idle pings at ~55%.

Strategy Steady P99 held-time 3x-burst P99 held-time Shed rate @ 3x Compliance loss
Unbounded block 6ms 4200ms (then OOM) 0% total at OOM
Naive shed (drop tail) 6ms 34ms 31% ~2% (indiscriminate)
Token-bucket + tail shed 7ms 38ms 12% ~0.6%
Priority shed + reserved headroom 8ms < 40ms ~8% 0%

The unbounded queue is the only row that loses everything, and it does so precisely when the system is under the most load — the worst possible time. Naive tail-shedding bounds latency but drops 31% of traffic and, because it drops without regard to priority, loses ~2% compliance events. The winning row combines a token bucket to keep the admitted rate near a sustainable ceiling with a priority-aware shed that reserves headroom for compliance. It holds held-time P99 under 40ms while shedding only ~8% — all of it idle pings and a sliver of transitions — and loses zero compliance events. That reserved-headroom trick is the crux: the queue never fully commits its last ~5% of slots to anything but the top priority lane, so a compliance ENTER always finds a slot even when idle-ping demand is saturating the rest.

The choice of rate-limiter shapes the burst response. A token bucket admits bursts up to its capacity, then throttles to the refill rate; a leaky bucket admits at a constant rate regardless of burst, smoothing but never absorbing. The trade-off and both Python implementations are worked in token-bucket vs leaky-bucket for telemetry shedding; the short version is that geofence telemetry is bursty by nature (a fleet clusters at shift change), so the token bucket’s burst tolerance usually wins, provided the capacity is sized so it cannot mask a sustained overload.

Implementation Trade-offs and the Critical Path

The per-event admission decision runs at the full ingest rate, so it must be allocation-free and branch-cheap. The bounded queue’s qsize() is the control input; the priority lane is a small integer; the token bucket is two floats and a subtraction. Nothing on this path may await on the downstream, or the backpressure signal inverts into head-of-line blocking.

python
from __future__ import annotations

import asyncio
import time
from dataclasses import dataclass, field
from enum import IntEnum


class Priority(IntEnum):
    IDLE = 0        # idle-vehicle pings — shed first
    TRANSITION = 1  # ENTER/EXIT/DWELL — shed second
    COMPLIANCE = 2  # boundary crossings — never shed


@dataclass(slots=True)
class TokenBucket:
    """Admitted-rate limiter. capacity absorbs bursts; refill_per_s is the
    sustainable ceiling. All float math — no allocation on the hot path."""
    capacity: float
    refill_per_s: float
    _tokens: float = field(default=0.0)
    _last: float = field(default_factory=time.monotonic)

    def try_admit(self, cost: float = 1.0) -> bool:
        now = time.monotonic()
        # Lazy refill: no background task, no timer — compute elapsed on demand.
        self._tokens = min(self.capacity, self._tokens + (now - self._last) * self.refill_per_s)
        self._last = now
        if self._tokens >= cost:
            self._tokens -= cost
            return True
        return False


@dataclass(slots=True)
class FlowController:
    queue: asyncio.Queue
    bucket: TokenBucket
    reserved_headroom: float = 0.05   # last 5% of slots reserved for COMPLIANCE
    shed_count: int = 0
    admit_count: int = 0

    def admit(self, item: object, priority: Priority) -> bool:
        maxsize = self.queue.maxsize
        depth = self.queue.qsize()
        frac = depth / maxsize                       # normalized fill, 0..1
        # Reserved headroom: below-top priorities cannot touch the last slice.
        ceiling = 1.0 - (self.reserved_headroom if priority < Priority.COMPLIANCE else 0.0)
        if frac >= ceiling:
            self.shed_count += 1
            return False                             # shed by priority — bounded loss
        # Rate-limit idle pings once the queue is meaningfully full.
        if priority == Priority.IDLE and frac >= 0.60 and not self.bucket.try_admit():
            self.shed_count += 1
            return False
        self.queue.put_nowait(item)                  # never blocks: capacity checked above
        self.admit_count += 1
        return True

Two decisions carry the weight. First, admit is fully synchronous and uses put_nowait after a capacity check, so it can never block the ingest coroutine — the backpressure is expressed as the boolean return, and the caller routes a False to the dead-letter path. Second, the reserved-headroom ceiling is computed per call from the item’s priority: a COMPLIANCE event is measured against a ceiling of 1.0 (it may use every slot), while everything else is measured against 0.95, so the top 5% of queue capacity is structurally unreachable to low-priority traffic. That is what makes “zero compliance loss under 3x burst” a property of the code rather than a hope. The token bucket’s lazy refill avoids a background timer task, so there is no second coroutine competing for the loop during exactly the burst window when the loop is busiest.

Memory Footprint and Streaming Churn

The queue’s maxsize is a memory contract as much as a latency one. At maxsize=100_000 and a TelemetryPoint of roughly 180 bytes with slots=True, the queue’s steady worst case is ~18MB of live payload plus the deque’s own pointer array — a bounded, predictable ceiling. An unbounded queue has no such ceiling: under a sustained 3x burst it grows at (admitted - drained) items per second, and at a 20k/sec deficit it adds ~3.6MB/sec, crossing a 1.5GB RSS cap in under seven minutes. The whole point of the bound is to convert that slow-motion OOM into an explicit shed decision the moment the watermark is crossed.

Sizing maxsize correctly is its own problem, governed by Little’s law — the queue only needs to be as deep as the arrival rate times the tolerable wait, and any deeper just converts loss into latency. That calculation, the per-item memory accounting, and the qsize() monitoring loop are worked in sizing bounded asyncio queues for geofence pipelines. The churn hazard specific to flow control is the shed path itself: if a shed item allocates a new dead-letter envelope per drop, a 30% shed rate at 90k/sec becomes 27k allocations/sec of transient objects — enough generation-0 pressure to inject the exact GC pause the shedding was meant to avoid. Route shed items through a pre-allocated ring buffer or a batched dead-letter flush so that dropping is cheaper than keeping, not more expensive.

Async Mutation Boundaries and Queue Semantics

Credit-based flow control is where the router stops guessing at downstream capacity and starts being told. Instead of the router pushing until something breaks, each downstream consumer advertises a credit — the number of events it can absorb before it needs to acknowledge — and the router decrements a per-consumer credit counter on each emit, refusing to emit past zero. When the consumer drains and acks, it replenishes credit. This inverts the control direction: a slow pricing engine that grants only 500 credits caps the router’s emit rate to that engine at exactly the pace the engine can sustain, and the backpressure propagates backward into the router’s own queue, where the priority shed absorbs it. The coupling to the producer flush matters here — a producer that batches emits (an aiokafka producer with linger_ms) must flush when credit is exhausted rather than accumulate a batch it cannot send, or the batch becomes a hidden unbounded buffer downstream of the queue you carefully bounded.

python
async def emit_with_credit(
    controller: FlowController,
    producer,                                  # aiokafka-style producer
    credits: dict[str, int],
    consumer_id: str,
    trigger: object,
) -> bool:
    if credits[consumer_id] <= 0:
        # Downstream is saturated: apply backpressure at the emit boundary,
        # not by growing an internal batch. Flush what we have and refuse.
        await producer.flush()                 # bound the producer-side buffer
        return False
    credits[consumer_id] -= 1                   # spend one credit per emit
    await producer.send("triggers", value=trigger, key=consumer_id.encode())
    return True

The priority-shed and credit mechanisms compose cleanly: shedding governs what enters the router’s queue, credits govern what leaves it toward a specific slow consumer. Instrument both boundaries — export shed_count by priority, queue_depth, credit_remaining per consumer, and producer_flush_latency_ms — and alert on a shed rate that stays above a few percent for more than a burst window, because a sustained shed means the system is under-provisioned, not merely bursting. When a downstream consumer degrades past what credits can absorb, the correct escalation is a circuit breaker at that phase boundary, covered next in circuit breakers for downstream trigger consumers, which trips the emit path to a buffered dead-letter fallback rather than letting a stuck consumer drain all credit and stall the router.

Operational Runbook and Failure Mitigation

When held-time P99 climbs or the shed rate spikes, the cause is one of four modes. Diagnose with py-spy dump for a live stack, the exported queue and shed metrics for the control state, and tracemalloc for the transient-allocation hazard on the shed path.

Failure mode Detection signal Mitigation
Unbounded buffering (queue not actually bounded) RSS climbing linearly, qsize unbounded, no shed events Set maxsize; convert put() to the capacity-checked admit(); alert on RSS slope.
Over-shedding (bucket/watermark too tight) Shed rate > 15% at baseline load, admit_count starved Raise maxsize or bucket capacity per Little’s law; confirm burst is real, not a sizing bug.
Compliance loss under burst shed_count[COMPLIANCE] > 0 Reserved-headroom ceiling misconfigured; assert COMPLIANCE ceiling == 1.0 in a test.
Credit deadlock credit_remaining pinned at 0, consumer alive but not acking Trip the phase circuit breaker; time-bound credit with a lease so a lost ack self-heals.

The standing diagnostic loop:

  1. Confirm the symptom. Pull held_time_p99 and shed_rate from Prometheus. If P99 is inside 40ms and shed is a brief burst-time spike, the system is behaving as designed — shedding is the correct response, not a regression. Proceed only if P99 breaches or shed is sustained.
  2. Read the control state. Sample queue_depth / maxsize on a 1s interval. Depth pinned near 1.0 with a rising shed rate is a genuine capacity deficit; depth oscillating below 0.6 with shedding means the token bucket is too tight and is throttling admissible traffic.
  3. Attribute the cost. Run py-spy record for a 30s flame graph under live load. Time in admit/put_nowait means the control path itself is hot (rare); time in the drain worker’s geometry means the consumer is the bottleneck and no amount of flow control fixes throughput — scale the worker.
  4. Check the shed allocation path. Diff two tracemalloc snapshots 60s apart under load. Growth concentrated in dead-letter envelopes means the shed path allocates per drop; move it to a pre-allocated ring buffer.
  5. Verify priority correctness. Replay a captured burst against a shadow controller and assert shed_count[COMPLIANCE] == 0. This is the one invariant that must never regress; gate it in CI.
  6. Validate credit self-healing. Kill a downstream consumer mid-stream and confirm its credit lease expires, the breaker trips, and the router keeps serving other consumers rather than stalling globally.

Continuous load tests should target held-time P99 under 40ms at a 3x burst with a shed rate near 8% and zero compliance loss — the exact figures the design promises.

Architectural Guidance: When to Choose Which

No single primitive is a default; the choice is driven by whether the producer can pause and by how uneven the load is.

Condition Choose
Producer can pause without data loss (pausable broker, internal hop) Block on a bounded queue — free correctness
Live unpausable stream (GPS devices), bursty, mixed priority Priority shed + reserved headroom + token bucket
Sustained high rate with hard uniform ceiling downstream Leaky-bucket smoothing to the ceiling
Downstream capacity varies and is knowable Credit-based flow control at the emit boundary
Downstream failing/slow, not merely full Circuit breaker at the phase boundary

In production these hybridize: ingest is a live stream, so the router sheds by priority into a bounded queue fronted by a token bucket; the emit side talks to pausable brokers and slow consumers, so it blocks under credit windows and trips a breaker when a consumer fails outright. The invariant to preserve across every variant is that the bound is real and the top priority lane is protected — a bounded queue whose last slice is reserved for compliance events turns overload from a correctness failure into a graceful, measured loss of exactly the telemetry you can afford to lose. Flow control at scale is the discipline of choosing what to drop before the machine chooses for you.

Frequently Asked Questions

Should I ever use an unbounded queue?

Only when the producer is genuinely pausable and you would rather block than drop — for example, an internal replay job reading from disk. For any live telemetry stream the answer is no: an unbounded queue does not prevent loss under overload, it just relocates the loss to the kernel socket buffer or the OOM killer, where you cannot see it or prioritize it. A bounded queue makes the same loss explicit and lets you choose its victims.

Where does the token bucket belong — at ingress or at the queue?

At the admission decision, keyed to queue depth, not as a standalone ingress throttle. A pure ingress rate limiter throttles even when the queue is empty and the worker is idle, wasting capacity. Gating the bucket on frac >= 0.60 means it only engages once the queue is actually filling, so it smooths bursts without penalizing normal load. The mechanics of sizing its capacity and refill are in the token-bucket vs leaky-bucket page.

How do I keep priority shedding from starving transitions entirely?

Reserve headroom per level, not just for the top. The implementation above reserves the last 5% for compliance; in a three-tier scheme you can reserve, say, the last 5% for compliance and the 5–15% band for transitions, so idle pings shed first and hardest, transitions shed only when the queue is genuinely deep, and each tier has a structurally guaranteed floor. Tune the bands from the measured priority mix, and assert the floors in a replay test so a refactor cannot silently erase them.