AI Infra Interviews logo
Coding for Infra / 06
medium★ EssentialNewBasetenTogether AIAnthropic

Implement a batcher that flushes when the batch is full or when a timeout expires. What breaks in the timer path?

Two triggers, one shared queue, and a timer task that will deadlock the whole thing if it cancels itself. The implementation with the bug I hit and its fix, the latency the window costs, and the error path that decides whether one bad batch fails one caller or all of them.

Updated Sep 2026 · Grounded in real AI infrastructure interview loops and written to a senior-engineer editorial bar, with every number worked and every diagram hand-built.

TL;DR: Each caller submits an item and awaits a future. The batcher appends to a shared queue and flushes on either of two triggers: the queue reaching the maximum size, or a timer that started when the first item arrived reaching the maximum wait. Flushing calls the handler once with the whole batch and resolves each caller's future with its result. The bug that appears in almost every first implementation is in the timer path: the flush routine cancels the pending timer task, and when the flush is invoked from inside that same timer task it cancels itself, so the handler never completes and every caller waits forever. The fix is to check whether the task being cancelled is the current one. The cost of the design is latency: a request arriving at an empty queue waits the full window, so a 50 millisecond window adds up to 50 milliseconds to a request that would otherwise have gone immediately, which is the trade against the throughput a larger batch buys.

How to approach it

Describe the two triggers and the per-caller future, since that structure is the answer. Then write it, and be honest about the timer deadlock, because it is the one tricky part and an interviewer who has implemented this will be waiting for it. Then the latency cost, which is what makes the parameters a decision rather than defaults. Close with the error path.

A strong answer

A typical situation: a serving proxy batches inference requests. Under load it works well. Under light load, requests occasionally hang forever, and the pattern is that it happens when a batch is flushed by the timer rather than by reaching its size. The timer task cancels itself partway through the flush.

The implementation:

import asyncio

class Batcher:
    """Flush when the batch is full or when max_wait elapses, whichever comes first."""

    def __init__(self, handler, max_size=8, max_wait=0.05):
        self.handler, self.max_size, self.max_wait = handler, max_size, max_wait
        self._queue = []                 # list of (item, future)
        self._flush_task = None          # the pending timer, if any
        self._lock = asyncio.Lock()
        self._running = set()            # in-flight handler tasks, kept from GC

    async def submit(self, item):
        fut = asyncio.get_running_loop().create_future()
        async with self._lock:
            self._queue.append((item, fut))
            if len(self._queue) >= self.max_size:
                self._dispatch(self._detach_locked())
            elif self._flush_task is None:
                self._flush_task = asyncio.create_task(self._timer())
        return await fut                 # the caller waits outside the lock

    async def _timer(self):
        await asyncio.sleep(self.max_wait)
        async with self._lock:
            self._dispatch(self._detach_locked())

    def _detach_locked(self):
        """Take the pending batch and cancel the timer. Cheap, synchronous, and
        the ONLY thing that happens under the lock."""
        if not self._queue:
            return None
        batch, self._queue = self._queue, []
        # take the reference and clear it BEFORE cancelling, and never cancel the task we
        # are currently running inside: that is the self-cancellation deadlock
        task, self._flush_task = self._flush_task, None
        if task is not None and task is not asyncio.current_task():
            task.cancel()
        return batch

    def _dispatch(self, batch):
        """Run the handler in its own task. The batch's fate must not depend on
        whichever caller happened to fill it: if that caller is cancelled while
        awaiting the handler, its peers would otherwise wait forever."""
        if not batch:
            return
        t = asyncio.create_task(self._run(batch))
        self._running.add(t)
        t.add_done_callback(self._running.discard)

    async def _run(self, batch):
        items = [i for i, _ in batch]
        try:
            results = await self.handler(items)
        except asyncio.CancelledError as exc:
            # NOT caught by `except Exception`: CancelledError derives from
            # BaseException. This is the path that used to strand a batch.
            self._settle(batch, exc=exc)
            raise
        except Exception as exc:
            self._settle(batch, exc=exc)   # every caller in the batch gets the failure
            return
        if len(results) != len(batch):
            self._settle(batch, exc=RuntimeError(
                f"handler returned {len(results)} results for {len(batch)} items"))
            return
        for (_, fut), r in zip(batch, results):
            if not fut.done():
                fut.set_result(r)

    @staticmethod
    def _settle(batch, exc):
        for _, fut in batch:
            if not fut.done():
                fut.set_exception(exc)

Running it:

10 submissions with max_size 4   -> [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
                                     the last two flushed on the timer
a single submission              -> flushed on the timer, returns
handler raising                  -> every caller in the batch receives RuntimeError
caller cancelled mid-flush       -> its PEERS still resolve
a third submit during a flush    -> enqueues immediately, timer starts
handler returns the wrong count  -> every caller gets a RuntimeError, none hang

The last three lines are the ones worth writing a test for, and they are where a first version of this usually breaks.

The bug and its fix, stated plainly because it is the point of the question:

the first version I wrote
    if self._flush_task is not None:
        self._flush_task.cancel()
        self._flush_task = None

what happens when the timer fires
  _timer awakens, takes the lock, calls _flush_locked
  _flush_locked cancels self._flush_task, which IS the currently running task
  cancellation raises CancelledError at the next await, which is `await self.handler(items)`
  the handler never completes, no future is resolved, and every caller in that batch waits
    forever
why it hides
  it only occurs when the flush is triggered by the timer, so a test that submits enough items
    to fill a batch never sees it
  under load, batches usually fill before the timer, so the bug appears only at low traffic

the fix
  task, self._flush_task = self._flush_task, None
  if task is not None and task is not asyncio.current_task():
      task.cancel()
  clearing the reference first also prevents a second flush from cancelling a task that has
  already completed

Batching Queues and Backpressure covers the pattern; Continuous Batching is the inference-specific version where the batch is re-formed every step rather than once.

The latency the window costs:

a request arriving at an empty queue waits until either
  max_size - 1 more requests arrive, or
  max_wait elapses

worst case added latency = max_wait, paid by a request that arrives alone
expected added latency at arrival rate r, batch size n:
  time to fill = (n - 1) / r
  if (n - 1) / r < max_wait, the batch fills first and the added latency is that fill time
  otherwise the timer fires and the added latency is max_wait

worked
  max_size 8, max_wait 50 ms
  at 200 requests/s: fill time = 7 / 200 = 35 ms, so batches fill first and add 35 ms
  at 20 requests/s:  fill time = 7 / 20 = 350 ms > 50 ms, so the timer fires and adds 50 ms
                     with an average batch of about 1 + 20 x 0.05 = 2 items
sanity: at low rates the batcher adds the full window and delivers batches of two, which may
        be worse than not batching at all. The parameters must be set from the actual arrival
        rate, and a batcher that helps at peak can hurt at trough

The error path:

the choice: does one failure fail the whole batch, or just its own item?
  if the handler raises, it usually failed for the batch, so failing every caller is correct
  if the handler returns per-item results including per-item errors, resolve each future with
    its own outcome, so one bad item does not fail nine good ones
  the second shape is better and requires the handler's contract to support it, which is a
    design decision to make explicitly

what must not happen
  a caller whose future is never resolved. Every exit path from _run must settle every
  future in the batch, which is why _settle loops over the whole batch and why the
  `if not fut.done()` guard is there
  the three exits people miss:
    CancelledError, which derives from BaseException and so slips past `except Exception`
    a handler returning a different number of results than it was given, where zip()
      silently drops the surplus futures and they wait forever
    a batch whose flushing caller is cancelled, which is why the handler runs in its own
      task rather than inside whichever submit() happened to fill the batch
TWO TRIGGERS, AND WHICH ONE FIRES 10 ms of waiting λ = 1,000/s timer fires 6.4 ms λ = 5,000/s size fires The timer task cancelling itself is the deadlock: it passes every test that fires the size trigger. Write the test that forces the timeout to win. It is the only test that matters here.

The reversal condition: batching is worth it only when the handler's cost is dominated by a per-call overhead that the batch amortizes. If the handler's cost is proportional to the number of items, batching adds latency and buys nothing. For inference the per-call overhead is enormous, since a forward pass at batch 1 and batch 8 read the same weights, which is why it pays there. For a handler that is a simple database lookup per item, it usually does not, and the first question is whether the downstream cost is per call or per item.

What interviewers probe next

  • "Why start the timer on the first item rather than on every item?" Because restarting it per item means a steady stream of arrivals never triggers the timer, and a batch could wait indefinitely.
  • "What if the handler is slow?" Items arriving during a flush go into the new queue and start their own timer, so the batcher pipelines. Bounding the queue is a separate backpressure decision.
  • "How would you add backpressure?" Cap the queue and reject or block on submit when full, since an unbounded queue converts a throughput problem into an out-of-memory failure.
  • "How do you test the timer path?" Drive the event loop with a controllable clock, or submit fewer items than max_size and assert the flush happens within a tolerance of max_wait. The second is what caught the deadlock.

Common mistakes

  • Cancelling the timer task from inside itself, which hangs every caller in that batch and only at low load.
  • Restarting the timer on every submission, so a steady stream never flushes on time.
  • Leaving a future unresolved on an error path, which hangs one caller silently.
  • Choosing max_wait without reference to the arrival rate, so the batcher adds its full window and delivers batches of two.

Key takeaways

  • Two triggers, one shared queue, one future per caller; the caller awaits outside the lock.
  • The timer must not cancel itself: compare against the current task before cancelling, and clear the reference first.
  • Added latency is max_wait in the worst case; at 20 requests per second with an 8-item batch the timer always wins and the batch averages two.
  • Every exit path must resolve every future in the batch, and whether one failure fails all of them is a contract decision.
That one was free — and so are 10 answers per topic without an account. Signing in doubles that to 20, opens the Plus lessons in the courses, and remembers which topics you keep getting wrong.no card · Google sign-in · nothing to cancel
HOW DID IT GO?
0
READING SIGNED OUT

Signing in doubles your free answers, from 10 to 20 per topic, and the site starts remembering you: mastery per topic, bookmarks, and a next-focus recommendation. Free, no card.

Sign in free

The concepts behind this question

Ranked by how closely each one overlaps this question's topic, so the first card is the thing to read if the answer above moved too fast.

Advanced
💻 Coding for Infra🔒 Premium
Batching Queues and BackpressureWrite a request batcher is the coding round's version of the serving engine's scheduler: requests arrive one at a time, the GPU wants them in groups, and the batcher decides when a group is full enough to send without holding anyone too long or accepting more than it can hold. The two knobs are the maximum batch size and the maximum wait, the invariant is a bounded queue, and the follow-ups (priorities, cost-aware batching, cancellation, bounded in-flight batches) are the ideas the real engines carry. This page implements the batcher in asyncio, derives what each knob buys, and walks the follow-ups.
Advanced
💻 Coding for Infra🔒 Premium
Producer-Consumer PipelinesA data loader, a log shipper, a batch inference job and a checkpoint writer are the same program: stages connected by bounded buffers, each running at its own pace, the slowest setting the throughput and the buffers absorbing the jitter between them. The coding screen asks you to build one (read, decode, batch, feed a consumer) and then pushes on the production questions: buffer sizes, clean stops, failure propagation, and why it runs at a third of the expected speed. This page derives throughput from stage times, implements the pipeline in threads and asyncio, and works the stop and failure semantics.
Advanced
💻 Coding for Infra🔒 Premium
Concurrency in Python, Go and C++Infrastructure code is concurrent by nature: a loader feeding a GPU, a gateway holding ten thousand streams, a controller reconciling a fleet. The coding screen tests whether you know which primitive fits which problem in the language you claim, and the three languages the field uses answer differently: Python has one interpreter lock and an event loop, Go has cheap goroutines and channels, C++ has threads, mutexes and atomics with no safety net. This page gives the model of each, works the favourite problems (a thread-safe LRU, a worker pool, a bounded fan-out) in each, and derives when threads, processes or async buy throughput.
Core
🚀 Inference & ServingSign in
Continuous BatchingContinuous batching schedules at the granularity of a single decode step instead of a whole request, so a finished sequence's slot is refilled on the next iteration rather than when the longest request in the batch ends. It is the scheduling idea that turned LLM serving from a padded, half-idle GPU into one that stays full, and it decides how the engine's scheduler, memory manager and latency SLOs interact.
UP NEXT ON YOUR JOURNEY
FEDITOR'S NOTE

Scored on the two flush triggers with a per-caller future, on the self-cancellation deadlock in the timer path, and on the latency the batching window adds against the throughput it buys.

DISCUSSION · 0

No comments yet — be the first to share your approach.