Backpressure & Flow Control for Spatial Streams

Backpressure for a spatial stream has to be measured in work rather than in messages, because a single partition can carry point pings costing two milliseconds and multipolygons costing nine hundred — so a prefetch window that is safe for one is three orders of magnitude wrong for the other, and which you receive is decided by geography.

This topic sits under Queue Management, Retries & Delivery Guarantees, which covers how spatial events move from producer to consumer without loss or duplication. Backpressure is the mechanism that keeps a consumer inside its own capacity; when it fails, the symptoms show up as the consumer lag described in Consumer Lag & Partition Skew Monitoring, and the retries it triggers are governed by Exponential Backoff & Jitter for Spatial Webhooks.


Prerequisites


Why message-count prefetch fails here

Every broker’s flow control is expressed in messages: max.poll.records, prefetch_count, COUNT on XREADGROUP. That unit assumes messages are roughly interchangeable in cost, which is true of order events and false of spatial ones.

One prefetch setting, three very different workloads A prefetch window of one hundred messages fetched against three payload mixes from the same topic. A hundred point pings at about two milliseconds each is roughly 0.2 seconds of work and a few hundred kilobytes held in memory, so the window is far too small to keep the consumer busy. A realistic mixed batch of ninety points and ten mid-size polygons is about 2.5 seconds and comfortable. A hundred land-cover multipolygons at about nine hundred milliseconds each is roughly ninety seconds of work and close to three hundred megabytes resident — the same setting, now catastrophic, because the consumer has committed to processing all of it before it can poll again and will be evicted from the group long before it finishes. Which of the three arrives is decided by which region the partition covers and what is happening there, not by anything the operator configured. prefetch = 100 messages, on three payload mixes from one topic 100 point pings — 2 ms each ≈ 0.2 s of work · a few hundred KB resident window far too small — the consumer idles between polls 90 points + 10 mid-size polygons ≈ 2.5 s of work · comfortably inside the poll interval the mix the setting was tuned against 100 land-cover multipolygons — 900 ms each ≈ 90 s of work · ~300 MB resident the consumer cannot poll again until it finishes, so the broker evicts it from the group — and the rebalance redelivers the whole batch to someone else
Figure 1. The third row is not a tuning mistake; it is the same setting meeting different geography. A count-based window cannot distinguish these because it never looks at what it is fetching.

The eviction in that third row is the part that turns a slow consumer into an outage. Kafka expects a poll() within max.poll.interval.ms; a consumer that is busy rather than polling is presumed dead, its partitions are reassigned, and the batch it was halfway through is redelivered to another member — which is holding the same prefetch setting and meets the same fate.


Architecture: bound the work, not the count

The fix is to keep the broker’s own window small and enforce a second, work-aware bound inside the consumer. Vertex count, precomputed at ingest and carried in the envelope, is a good cost proxy: it is cheap to read, correlates well with shapely runtime, and does not require deserialising the geometry to obtain.

A vertex budget between the poll loop and the workers Messages are polled in small batches and each is charged against a shared vertex budget before dispatch. A point ping costs about forty vertices and passes straight through; a multipolygon costing forty-one thousand consumes most of the budget alone. While the budget has room, work is dispatched to the process pool and the loop keeps polling. When the budget is exhausted the loop pauses the partition — it continues to call poll, which returns nothing, so the broker's session timer is satisfied and the consumer is not evicted, but no new work is accepted. Completed work returns its vertices to the budget and the partition resumes. If the pause persists beyond a bounded window, the consumer escalates to shedding: it drops self-superseding events by geographic priority rather than continuing to accumulate a backlog it will never clear. The budget is the whole mechanism — it converts an unbounded queue of unknown-cost work into a bounded amount of known-cost work. poll loop small batches vertex budget charge before dispatch point ≈ 40 · polygon ≈ 41,000 one polygon can fill it alone room dispatch to process pool completion returns the vertices full pause the partition keep calling poll — it returns nothing, so the session survives still full after N s shed by priority self-superseding topics only never boundary edits The budget converts an unbounded queue of unknown-cost work into a bounded amount of known-cost work — which is the whole mechanism.
Figure 2. Pausing rather than merely slowing down is what keeps the broker session alive: the loop still polls, it just returns nothing. Shedding is a separate escalation, reached only when the pause has not cleared.

Layer breakdown:

  1. Small broker window — set max.poll.records low (10–25) so the broker never hands you an unbounded amount of unknown work in one call.
  2. Vertex budget — a semaphore denominated in vertices rather than tasks. Each message is charged its envelope’s vertex count before dispatch and refunded on completion.
  3. Pause on exhaustion — when the budget cannot admit the next message, pause the partition and keep polling. This is the critical detail: a paused consumer still polls, so the session timer is satisfied.
  4. Shed on sustained pressure — if the pause has not cleared within a bounded window, drop self-superseding events by geographic priority rather than accumulating a backlog that will never drain.

Step-by-step implementation

Step 1 — Charge work against a budget, not tasks against a counter

python
import asyncio
from dataclasses import dataclass


class VertexBudget:
    """A semaphore denominated in vertices rather than in tasks.

    A plain asyncio.Semaphore(N) bounds the *number* of in-flight messages,
    which is exactly the unit that tells us nothing here. This bounds the work.
    """

    def __init__(self, capacity: int) -> None:
        self._capacity = capacity
        self._available = capacity
        self._cond = asyncio.Condition()

    async def acquire(self, cost: int) -> None:
        # A single geometry larger than the whole budget would deadlock, so it
        # is clamped: it runs alone, which is the correct behaviour for a
        # payload that genuinely exceeds our capacity estimate.
        cost = min(cost, self._capacity)
        async with self._cond:
            while self._available < cost:
                await self._cond.wait()
            self._available -= cost

    async def release(self, cost: int) -> None:
        cost = min(cost, self._capacity)
        async with self._cond:
            self._available += cost
            self._cond.notify_all()

    @property
    def saturated(self) -> bool:
        return self._available <= 0

The clamp in acquire matters. Without it, a single 41,000-vertex multipolygon arriving at a budget of 40,000 waits forever for capacity that can never exist, and the consumer stops with no error.

Step 2 — Pause the partition instead of blocking the poll loop

python
from aiokafka import AIOKafkaConsumer
from aiokafka.structs import TopicPartition

BUDGET = VertexBudget(capacity=60_000)


async def consume(consumer: AIOKafkaConsumer, pool) -> None:
    paused: set[TopicPartition] = set()

    while True:
        batches = await consumer.getmany(timeout_ms=500, max_records=25)

        for tp, messages in batches.items():
            for msg in messages:
                cost = msg.headers_dict.get("vertex_count", 40)

                if BUDGET.saturated and tp not in paused:
                    # Pause, but keep polling. getmany() continues to be called
                    # and simply returns nothing for this partition, so the
                    # broker's session timer stays satisfied and we are not
                    # evicted from the group mid-batch.
                    consumer.pause(tp)
                    paused.add(tp)

                await BUDGET.acquire(cost)
                asyncio.create_task(_process(pool, msg, cost, consumer, tp, paused))

        for tp in list(paused):
            if not BUDGET.saturated:
                consumer.resume(tp)
                paused.discard(tp)

Step 3 — Refund on completion, including on failure

python
async def _process(pool, msg, cost, consumer, tp, paused) -> None:
    try:
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(pool, _repair_geometry, msg.value)
    except Exception:
        # A failed message still returns its budget. Forgetting this is a slow
        # leak: every failure permanently shrinks capacity until the consumer
        # stalls at a budget of zero with no message in flight.
        raise
    finally:
        await BUDGET.release(cost)

The finally is the whole reliability story of this component. A refund that happens only on success leaks capacity on every failure, and the consumer degrades over hours into a stall that looks nothing like the geometry error that caused it.

Step 4 — Shed by geographic priority, and only where it is safe

python
SHEDDABLE_TOPICS = {"vehicle-positions", "sensor-telemetry"}

async def should_shed(msg, pause_seconds: float) -> bool:
    """Shed only self-superseding events, and only under sustained pressure."""
    if pause_seconds < 30:
        return False
    if msg.topic not in SHEDDABLE_TOPICS:
        # Cadastral edits, boundary changes, tile invalidations: nothing will
        # resend these, so dropping one is data loss rather than degradation.
        return False
    # Within a sheddable topic, drop the lowest-priority regions first.
    return msg.headers_dict.get("region_priority", 0) < 2

Spatial validation and error handling

Vertex count is a proxy, and proxies drift. A 2,000-vertex polygon with many interior rings can cost more to repair than a 5,000-vertex simple one, because topology repair is not linear in vertex count. Treat the budget as approximate and set it against measured p99 rather than against a computed ideal; the point is to bound the error, not to eliminate it.

A missing vertex count must default high, not low. If an envelope arrives without the field — an old schema version, a producer that has not been updated — defaulting to a small cost lets an unbounded payload through the one control designed to stop it. Default to the budget’s clamp value so an unmeasured payload is treated as expensive until proven otherwise.

Shedding must never apply to a stream with ordering guarantees. Dropping one event from a version-guarded stream is survivable, because a later version supersedes it. Dropping one from a stream whose consumer applies deltas is corruption, because the state never converges. Enumerate sheddable topics explicitly, as above, rather than deriving the decision from a priority field alone.

Only self-superseding streams may be shed Streams sorted by whether dropping one event is recoverable. Vehicle position pings and sensor telemetry are self-superseding: another arrives within seconds carrying strictly newer information, so a dropped one leaves a momentary gap rather than a permanent hole, and shedding them under saturation is a degradation the system recovers from on its own. Cadastral boundary edits, tile invalidations and dead-letter replays are not: nothing will resend them, so a drop is silent permanent data loss, and a map that is wrong stays wrong. Delta-encoded streams are the worst case, because dropping one event does not merely lose it — every subsequent event is applied to a state that never existed, so the error compounds rather than healing. The policy therefore has to be enumerated per topic rather than derived from a priority field, since priority describes how much an event matters and this question is about whether losing it is reversible. Sheddable — a later event supersedes this one vehicle positions · sensor telemetry · presence pings a drop leaves a momentary gap the next event closes by itself Never shed — nothing will resend it cadastral boundary edits · tile invalidations · dead-letter replays a drop is permanent, silent data loss — the map stays wrong Worst case — delta-encoded streams every later event is applied to a state that never existed, so the error compounds instead of healing enumerate the policy per topic — priority says how much an event matters, not whether losing it is reversible
Figure 3. The test is not importance but recoverability. A high-priority position ping is safer to shed than a low-priority boundary edit, because only one of them will be sent again.

Retry, backoff and delivery guarantees

Backpressure and retries push in opposite directions on the same pipe, and under saturation they can amplify each other. A saturated consumer begins timing out; those timeouts generate retries; the retries arrive as additional load on the consumer that was already saturated. This is the mechanism by which a slow consumer becomes an unavailable one.

Gate retries on the same saturation signal that drives pausing. When the budget is exhausted, the retry admission check should fail first, so a consumer under pressure sheds redelivered work before it sheds fresh work — a retry is by definition something the system has already attempted, while a fresh event is something it has not yet seen at all. The token-bucket budget in Tuning Retry Budgets for Webhook Provider SLAs is the right place to apply that gate.


Verification

The test that matters is a burst of heavy geometry against a running consumer, asserting that the broker session survives.

python
import asyncio
import pytest


@pytest.mark.asyncio
async def test_heavy_burst_does_not_exceed_poll_interval(consumer, pool, clock):
    """A burst of large geometries must not stall the poll loop.

    The failure this guards against is subtle: the consumer keeps working and
    still gets evicted, because it stopped polling while it worked.
    """
    heavy = [_message(vertex_count=41_000) for _ in range(50)]
    await _produce(heavy)

    gaps = []
    async for gap in _poll_gaps(consumer, pool, duration=60):
        gaps.append(gap)

    assert max(gaps) < 300, f"poll gap {max(gaps):.1f}s exceeds max.poll.interval.ms"


@pytest.mark.asyncio
async def test_budget_is_refunded_on_failure():
    """The gate's negative case: a failing message must return its budget.

    Without this assertion the leak is invisible — capacity shrinks slowly and
    the consumer stalls hours later, long after the failing payload is gone.
    """
    budget = VertexBudget(capacity=1000)
    await budget.acquire(600)
    with pytest.raises(RuntimeError):
        try:
            raise RuntimeError("topology repair failed")
        finally:
            await budget.release(600)
    assert not budget.saturated

Troubleshooting

Symptom Likely spatial cause Fix
Consumers repeatedly evicted and rebalanced under load A batch of heavy geometries exceeded max.poll.interval.ms Lower max.poll.records and pause on budget exhaustion rather than blocking
Consumer stalls with no messages in flight Budget leaked on failed messages Refund in a finally, not on the success path
One partition never resumes after a burst A single geometry larger than the whole budget waits for impossible capacity Clamp per-message cost to the budget so oversized payloads run alone
Memory climbs steadily during a regional burst Prefetch measured in messages, not work Charge a vertex budget before dispatch
Retries pile up exactly when the consumer is busiest Retry admission not gated on the saturation signal Shed retries before fresh events under pressure
Positions go missing during a shed, tracks look wrong A stream with delta semantics was marked sheddable Restrict shedding to self-superseding topics only

FAQ

Why is a message-count prefetch wrong for spatial payloads?

Because message count says nothing about work. A prefetch of 100 might be 100 point pings costing 200 milliseconds in total, or 100 land-cover multipolygons costing 90 seconds. The same setting is simultaneously far too small for one and catastrophically too large for the other, and which one you get is decided by geography rather than by anything you control. Size the window in estimated work — vertex count is a good proxy and is available from the envelope without deserialising the geometry.

Should I pause the partition or just process more slowly?

Pause it. Processing slowly while continuing to fetch means the broker keeps handing you messages you have not started, which grows unbounded memory and, worse, does not stop the broker’s session timer. Kafka expects a poll within max.poll.interval.ms; a consumer that is busy rather than polling gets evicted from the group and its partitions rebalanced, which produces duplicate delivery on top of the backlog you already had. Pausing keeps you polling — returning no records — so the session stays alive.

Is load shedding ever acceptable for spatial events?

It is acceptable when the alternative is losing everything, and only for streams where a later event supersedes an earlier one. Shedding a vehicle position ping is defensible because another arrives in seconds and the newer one is strictly more useful. Shedding a cadastral boundary edit is data loss, because nothing will resend it. Split those streams by topic so the shedding policy can differ, and never shed a stream whose events are not self-superseding.

How does backpressure interact with the retry budget?

They control opposite ends of the same pipe and can fight each other. Backpressure slows intake when the consumer is saturated; a retry ladder increases intake when deliveries fail. A saturated consumer that starts timing out generates retries, which arrive as additional load on the consumer that was already saturated. Gate retries on the same saturation signal that drives backpressure, so a consumer under pressure sheds retries first and fresh events last.


Explore this section