Approach: use asyncio to collect requests into a FIFO queue, track per-request enqueue time and deadline, run a background flusher that triggers when either max_batch_size reached or the oldest request's deadline expires or a max_hold_ms global timer fires. Use FIFO ordering to avoid starvation and an "age" priority check to force flush older requests first.python
import asyncio
import time
from typing import Any, List, NamedTuple
class Req(NamedTuple):
payload: Any
future: asyncio.Future
enqueue_ts: float
deadline_ts: float
class DynamicBatcher:
def __init__(self, max_batch_size: int, max_wait_ms: int, max_hold_ms: int=50):
self.max_batch_size = max_batch_size
self.max_wait_s = max_wait_ms / 1000.0
self.max_hold_s = max_hold_ms / 1000.0
self.queue: asyncio.Queue[Req] = asyncio.Queue()
self.flush_event = asyncio.Event()
self._flusher_task = asyncio.create_task(self._flusher())
async def submit(self, payload, per_request_max_wait_ms=None):
fut = asyncio.get_event_loop().create_future()
now = time.monotonic()
per_wait = (per_request_max_wait_ms/1000.0) if per_request_max_wait_ms else self.max_wait_s
req = Req(payload, fut, now, now + per_wait)
await self.queue.put(req)
# notify flusher
self.flush_event.set()
return await fut
async def _flusher(self):
while True:
await self.flush_event.wait()
self.flush_event.clear()
batch: List[Req] = []
start = time.monotonic()
# collect up to max_batch_size while honoring deadlines
while len(batch) < self.max_batch_size:
try:
timeout = self._next_timeout(batch, start)
req = await asyncio.wait_for(self.queue.get(), timeout=timeout)
batch.append(req)
# if full, break to process immediately
if len(batch) >= self.max_batch_size:
break
except asyncio.TimeoutError:
break
if not batch:
continue
# ensure FIFO and that no req misses deadline
now = time.monotonic()
expired = [r for r in batch if r.deadline_ts <= now]
# If any expired, we flush only those plus earliest others up to max_batch_size
# Simple policy: keep FIFO order, but always process when oldest hits deadline
to_process = batch[:self.max_batch_size]
# perform inference asynchronously (simulate)
results = await self._run_inference([r.payload for r in to_process])
for r, res in zip(to_process, results):
if not r.future.done():
r.future.set_result(res)
# requeue leftovers if any (shouldn't happen here because queue.get removed them)
# If implementation removed items beyond to_process, they remain taken; in production use peek or separate buffer
def _next_timeout(self, current_batch, start_ts):
# compute time remaining until oldest deadline in queue or max_hold
now = time.monotonic()
try:
oldest: Req = self.queue._queue[0] # direct access for estimation; OK in pseudocode
time_until_deadline = max(0.0, oldest.deadline_ts - now)
except Exception:
time_until_deadline = self.max_hold_s
time_until_hold = max(0.0, self.max_hold_s - (now - start_ts))
return min(time_until_deadline, time_until_hold)
async def _run_inference(self, batch_payloads: List[Any]):
await asyncio.sleep(0.01) # placeholder for GPU call
return [{"out": p} for p in batch_payloads]
Key points:- FIFO queue prevents starvation; flusher prioritizes oldest deadline.- Timers: wait_for with dynamically computed timeout ensures deadline-triggered flush and a max_hold to avoid indefinite waiting for full batch.- Concurrency: submit can be called concurrently; flusher serializes batch construction to avoid races.Complexity:- Per-request enqueue/dequeue: O(1). Building a batch of size B costs O(B). Memory O(N) queued requests.Edge cases / improvements:- Use a dedicated deque or lock to atomically peek without accessing protected attributes.- Support requeuing leftovers, backpressure, batching by model shape, and prioritization heuristics.