Skip to content

blackbull.server.deadline

blackbull.server.deadline

Per-connection rescheduled deadline (Sprint 26 Phase B — scanner form).

Replaces async with asyncio.timeout(...) on the per-request hot path. Sprint 23 used one loop.call_later TimerHandle per connection, cancelled and rescheduled at every phase transition. Sprint 26 Phase B replaces the per-arm call_later with a per-process tick scanner: one singleton TimerHandle re-arms itself every :data:_TICK_S and walks the registry of armed :class:ConnectionDeadline instances for expirations. Per-arm cost drops from ~1.7 µs to ~0.34 µs (WSL2 microbench, Sprint 26 Phase B spike).

Trade-off: a fired deadline lands within [now, now + _TICK_S] rather than at the exact requested instant. At the default _TICK_S = 0.3 s this is ~3 % slop on the tightest configurable deadline (BB_HEADER_TIMEOUT default 10 s) and ~1 % on the body_timeout default 30 s. Tune via BB_DEADLINE_TICK_MS (milliseconds, default 300, floor 10).

The scanner is loop-scoped and lazily started on the first arm; it quiesces (cancels its own handle) when the registry empties and auto-resurrects on the next arm. Each worker process has its own scanner.

ConnectionDeadline

One reusable deadline per connection.

The instance binds to the task that constructed it (in practice, the connection actor's task). When the per-process scanner observes that the deadline's monotonic _deadline_at has passed, the bound task is cancelled — the cancellation propagates into whichever reader.readuntil / read / readexactly is currently awaiting. Call sites translate the cancellation into TimeoutError via :meth:guard (the common case) or manually by checking :attr:fired.

Why a scanner instead of one call_later per arm? At saturation the per-arm path costs ~1.7 µs (TimerHandle + heap push + cancel). The scanner replaces that with one loop.time() call + one comparison + one set membership check on the registry (~0.34 µs). Sprint 26 Phase B microbench: 80 % per-call reduction; cascade-multiplied estimate ~15–25 % B1.

arm(seconds)

(Re-)set the deadline; seconds <= 0 disables it.

Safe to call repeatedly. Resets :attr:fired so a recovered deadline can be reused across phases on the same connection.

disarm()

Drop the deadline. Idempotent.

guard(seconds)

Arm the deadline and return self as a context manager.

Caller pattern::

with dl.guard(cfg.header_timeout):
    await reader.readuntil(...)

Matches the observable behaviour of async with asyncio.timeout(d): — a fired deadline manifests as TimeoutError. The same-loop-iteration race where the underlying read completes and the deadline fires in the same tick is treated as a timeout (same convention as asyncio.timeout).

Returns self rather than allocating a wrapper object. Safe because each connection owns its own ConnectionDeadline and uses it sequentially from a single task.