Skip to content

Tuning Backpressure for High-Volume Supplier CTE Feeds

An ingestion tier fails in a distinctive way when a supplier feed produces Critical Tracking Events faster than the worker pool can commit them. Nothing crashes at first. The broker queue simply grows — a thousand deep, then ten thousand, then a hundred thousand — while the memory footprint of the broker and any in-flight prefetch buffers climbs with it. End-to-end latency stretches from seconds to hours because a CTE enqueued now sits behind an ever-lengthening backlog, and eventually the facility blows past the FDA’s 24-hour retrievability guarantee not because a record was lost but because it was still waiting in line. This is the classic missing-backpressure failure, and it is the most common way a High-Volume CTE Ingestion tier degrades under peak-harvest load. This guide diagnoses it and fixes it with bounded queues, prefetch limits, rate limiting, and a watermark-based pause-resume control loop.

Root Cause Analysis

Backpressure is the signal a consumer sends to a producer that says slow down. When it is missing, the producer runs open-loop: it accepts and enqueues events at the supplier’s rate regardless of whether the workers can keep up. In an FSMA 204 feed the producer is often a webhook receiver or a delta poller that has no reason of its own to stop — a supplier pushing receiving scans during a distribution-center surge will happily deliver twenty thousand events in a minute, and an open-loop producer forwards every one of them straight into the broker.

Three things then compound. First, the queue grows without bound because arrival rate exceeds drain rate, and a Redis or RabbitMQ queue that grows without bound is a memory leak with a delay — the broker’s resident set tracks the backlog until it is killed or starts rejecting writes. Second, if the workers use a generous prefetch, each one pulls a large slice of that backlog into its own process memory, multiplying the pressure across the pool exactly as described in the guide on configuring Celery workers. Third — the compliance-critical one — latency becomes unbounded. Because the queue is first-in-first-out, the time a fresh CTE waits equals queue depth divided by drain rate, so a hundred-thousand-deep queue draining at four hundred per second imposes a four-minute delay that only grows. Let it run through a shift and the oldest unprocessed receiving event is hours stale, and a recall query that needs it cannot be answered inside the mandated window. The root cause is never the workers being slow in absolute terms; it is the absence of a feedback path telling the producer to match the drain rate.

Minimal Reproducible Example

The producer below is open-loop. It enqueues every event it receives with no ceiling and no awareness of queue depth, and the consumer drains slower than the producer fills. Run it and the depth counter climbs monotonically.

# mre_backpressure.py -- open-loop producer with no backpressure.
import asyncio
import random

QUEUE: asyncio.Queue = asyncio.Queue()   # unbounded: maxsize defaults to 0


async def supplier_feed() -> None:
    """Bursts CTEs far faster than the worker can drain them."""
    event_id = 0
    while True:
        # Peak-harvest burst: ~500 events enqueued per tick.
        for _ in range(500):
            event_id += 1
            await QUEUE.put({"traceability_lot_code": "KALE-2026-0718", "id": event_id})
        await asyncio.sleep(0.1)          # producer tick: 5000 events/second


async def worker() -> None:
    """Commits one CTE at a time, slower than the feed produces."""
    while True:
        _event = await QUEUE.get()
        await asyncio.sleep(0.002)        # ~500 events/second drain
        QUEUE.task_done()


async def main() -> None:
    asyncio.create_task(supplier_feed())
    asyncio.create_task(worker())
    while True:
        await asyncio.sleep(1)
        print(f"queue depth = {QUEUE.qsize()}")   # climbs without bound

Because asyncio.Queue() is created with the default maxsize=0 it is unbounded, and the feed enqueues at roughly ten times the drain rate. The printed depth rises every second — 4,500, then 9,000, then 13,500 — and never recovers. In production that counter is your broker’s backlog, and the memory behind it is real.

Fix Implementation

The fix installs a closed control loop with four cooperating mechanisms: a bounded queue so the producer cannot enqueue past a hard ceiling, a prefetch limit so workers never hoard the backlog, a rate limiter so the producer’s steady-state rate cannot exceed the drain rate, and high/low watermarks that pause the producer when depth crosses the high mark and resume it only after depth falls back below the low mark. The watermarks give the loop hysteresis, so it does not flap on and off around a single threshold.

The diagram shows the control loop: the queue reports its depth to a controller, and the controller toggles the producer between running and paused based on where depth sits relative to the two watermarks.

Queue-depth backpressure control loop with high and low watermarks A producer feeds a bounded queue that drains to a worker pool. A controller samples the queue depth and compares it to two thresholds: when depth rises above the high watermark it pauses the producer, and when depth falls below the low watermark it resumes the producer. The two watermarks give the loop hysteresis so it does not flap around a single threshold. high watermark low watermark depth sample pause / resume Producer supplier feed Bounded queue Worker pool drain rate Depth controller pause > high, resume < low
The controller samples queue depth and toggles the producer: it pauses acceptance when depth crosses the high watermark and resumes only after depth falls below the low watermark, keeping the backlog inside a bounded band.
# backpressure.py -- closed-loop producer with bounded queue and watermarks.
import asyncio
import logging
import time

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("fsma204.backpressure")

HIGH_WATERMARK = 8_000     # pause the producer at or above this depth
LOW_WATERMARK = 2_000      # resume only after depth drains below this
MAX_ENQUEUE_RATE = 450     # steady-state ceiling (events/sec), below drain rate


class TokenBucket:
    """Simple rate limiter: refills `rate` tokens per second, capacity `rate`."""

    def __init__(self, rate: float) -> None:
        self.rate = rate
        self.tokens = rate
        self.updated = time.monotonic()

    async def acquire(self) -> None:
        while True:
            now = time.monotonic()
            self.tokens = min(self.rate, self.tokens + (now - self.updated) * self.rate)
            self.updated = now
            if self.tokens >= 1:
                self.tokens -= 1
                return
            await asyncio.sleep((1 - self.tokens) / self.rate)


async def controlled_feed(
    queue: asyncio.Queue,
    resume_event: asyncio.Event,
    fetch_next,          # async callable returning the next CTE, or None when idle
) -> None:
    """Enqueue supplier CTEs under a rate limit, honoring the pause signal."""
    bucket = TokenBucket(MAX_ENQUEUE_RATE)
    while True:
        await resume_event.wait()        # blocks while the controller has paused us
        await bucket.acquire()           # steady-state rate can never exceed the drain
        event = await fetch_next()
        if event is None:
            await asyncio.sleep(0.05)
            continue
        # put() blocks if the bounded queue is full -> a hard ceiling on backlog.
        await queue.put(event)


async def depth_controller(queue: asyncio.Queue, resume_event: asyncio.Event) -> None:
    """Toggle the producer between running and paused on watermark crossings."""
    while True:
        depth = queue.qsize()
        if depth >= HIGH_WATERMARK and resume_event.is_set():
            resume_event.clear()         # PAUSE: stop accepting new events
            logger.warning("Backpressure engaged | depth=%d >= high=%d", depth, HIGH_WATERMARK)
        elif depth <= LOW_WATERMARK and not resume_event.is_set():
            resume_event.set()           # RESUME: drained enough to accept again
            logger.info("Backpressure released | depth=%d <= low=%d", depth, LOW_WATERMARK)
        await asyncio.sleep(0.25)         # sample four times a second


async def main(fetch_next) -> None:
    # Bounded queue: maxsize is the hard ceiling; producer blocks on a full queue.
    queue: asyncio.Queue = asyncio.Queue(maxsize=HIGH_WATERMARK)
    resume_event = asyncio.Event()
    resume_event.set()                   # start in the running state

    asyncio.create_task(controlled_feed(queue, resume_event, fetch_next))
    asyncio.create_task(depth_controller(queue, resume_event))

    async def worker() -> None:
        while True:
            event = await queue.get()
            await commit_cte(event)      # idempotent ledger append
            queue.task_done()

    await asyncio.gather(*(worker() for _ in range(12)))


async def commit_cte(event: dict) -> None:
    raise NotImplementedError

Four decisions make this loop hold the line. The asyncio.Queue(maxsize=HIGH_WATERMARK) is a hard ceiling — put() blocks when the queue is full, so even if the controller lags a sample the backlog physically cannot exceed the bound. The TokenBucket caps the producer’s steady-state rate below the measured drain rate, so under normal load the loop never even reaches the high watermark. The watermark pair gives hysteresis: pausing at 8,000 and resuming at 2,000 means the producer stops and starts in long, efficient sweeps instead of oscillating every sample. And because acceptance is what gets throttled — never processing — no CTE is ever dropped; a paused producer simply leaves events on the supplier side or in its own bounded intake until the workers catch up. For feeds fetched by polling, the same restraint is enforced upstream by the API Polling Strategies that slow the poll cadence when the queue is deep.

Verification Steps

Confirm the loop keeps depth bounded and latency inside the SLA before trusting it at peak.

  • Depth stays within the band. Re-run the burst workload against the controlled feed and log queue.qsize() once a second. Instead of climbing monotonically, depth must oscillate between the low and high watermarks and never exceed the ceiling. Assert the observed maximum is at or below HIGH_WATERMARK:
# Under a sustained 10x burst, depth must never breach the ceiling.
observed_peak = max(depth_samples)
assert observed_peak <= HIGH_WATERMARK, f"backlog escaped the bound: {observed_peak}"
# And the loop must recover: the final depth should trend toward the low watermark.
assert depth_samples[-1] <= LOW_WATERMARK * 1.5
  • The producer actually pauses. Grep the structured logs for the paired transitions. A healthy loop shows Backpressure engaged followed later by Backpressure released, never a run of engage-with-no-release, which would indicate the workers cannot drain even at zero input.
  • Latency is bounded. Timestamp each event at enqueue and at commit, and track the p99 residence time. With depth capped at the high watermark and a known drain rate, worst-case latency is HIGH_WATERMARK / drain_rate — verify the measured p99 sits comfortably under the 24-hour SLA with room to spare.
  • Broker memory plateaus. Sample the broker’s memory during the burst. It should rise to the level implied by the ceiling and then flatten, rather than tracking an unbounded backlog. A continued climb means a queue somewhere is still unbounded.

Once the primary loop is bounded, three variants deserve a check.

  • Multiple suppliers sharing one queue. A single watermark loop throttles all producers together, so one noisy supplier can pause ingestion for everyone. Give each high-volume supplier its own bounded queue and controller, or weight the token buckets, so a burst from one feed does not stall the others. The dead-letter and retry mechanics for a feed that stays saturated live in Error Handling Workflows.
  • A permanently under-drained pool. If the producer pauses and the queue still never falls below the low watermark, backpressure is masking an undersized pool, not a burst. Raise worker_concurrency or the number of nodes as covered in Async Batch Processing rather than lowering the watermarks, which would only shorten the pause cycles.
  • Watermarks set too close together. If the high and low marks are within a sample’s worth of drain, the controller flaps — pausing and resuming every quarter second — which thrashes the producer. Widen the gap so each pause drains a meaningful fraction of the ceiling before resuming.

FSMA 204 requires that traceability records be producible within 24 hours of an FDA request, so unbounded ingestion latency is a compliance failure even when no record is technically lost — a CTE stuck behind a runaway backlog is unavailable exactly when a recall query needs it. Bounding the queue, rate-limiting the producer, and pausing on a high watermark keep both depth and latency inside a known envelope, which is what makes the 24-hour guarantee defensible under peak load. For the authoritative requirement, see the FDA final rule on food traceability.

Frequently Asked Questions

Why do I need two watermarks instead of one queue-depth threshold?

A single threshold makes the controller flap: as soon as depth dips one event below the line it resumes, the next sample crosses back over, and it pauses again, thrashing the producer many times a second. Two watermarks add hysteresis — the producer pauses at the high mark and does not resume until depth falls all the way to the low mark. That turns thousands of tiny toggles into a few long, efficient pause-and-drain sweeps.

Does pausing the producer risk dropping Critical Tracking Events?

No. Backpressure throttles acceptance, never processing, so a paused producer simply stops pulling new events for a moment while the workers drain the backlog. The events wait on the supplier side or in a bounded intake buffer and are enqueued once the low watermark is reached. Nothing is discarded, which is exactly the property FSMA 204 requires, because every CTE eventually reaches the ledger even during a burst.

How do I choose the high and low watermark values?

Derive them from your latency budget and drain rate. The high watermark divided by the drain rate is your worst-case queue-residence latency, so pick a high mark low enough that even a full queue clears well inside the 24-hour SLA. Set the low watermark far enough below it that each pause drains a meaningful fraction of the ceiling, typically a quarter to a fifth of the high mark, so the loop does not resume prematurely.

What if the producer keeps pausing but the queue never drains to the low mark?

That is not a backpressure problem; it is an undersized worker pool. Backpressure can only stop the backlog from growing, not shrink it, so if the queue stays high even with the producer paused, the drain rate is structurally below the average arrival rate. Add worker concurrency or nodes until sustained drain exceeds average arrival, and keep the watermarks where they are.

Up: High-Volume CTE Ingestion — this control loop keeps that intake tier’s queue bounded under peak supplier load.