m0serve Realtime from a synchronous Python app

Pacing the pump's loop thread

A design note from the engineering record, moved out of ROADMAP.md on 2026-09-03 and kept as written. The roadmap itself now holds only the project's state; the reasoning lives here.

What the sweep measurement exposed: at c16 the pump's throughput depends on how long its loop thread spends per pass, in a way that an accidental 1.2 µs improved by 3%. An explicit pause before flushing a partial batch — spin N ns, optionally re-poll once and fold the new events' submits into the same datagram — is the deliberate form of that, and tunable where the sweep was not.

Measured the same day, as an experiment patch, and recorded rather than built (bench/results/outbox-sweep/pacing/, fourteen arms, pump, c16, stdlib asyncio, uvicorn asyncio within 58.3–59.3k on every one):

pump's loop thread, per pass rps @ cores
the sweep (today) 59.95k @0.97
no sweep, no pause 58.70k @1.01
spin 500 / 1000 / 2000 / 4000 ns before a partial flush only 58.6–59.0k @0.97–1.01
the same, then wait(0) and fold the new events into the pass 58.9–59.3k @1.05
spin 1200 / 2000 ns on every pass 60.04k / 60.13k @0.97
the same, with the re-poll 55.6–55.8k @1.08

Two things settled. The sweep's effect is a delay on every pass — including the completion-only passes that write responses and park — not on the pass that has submits to flush: pausing only before a partial flush is too late, because by then the batch is whatever the previous wait returned, while a pause after writing responses lets the clients' next requests arrive before the loop parks. An explicit per-pass spin of 1.2–2 µs reproduces the sweep to within noise, and no longer pause improves on it. And the re-poll is simply worse: a nested pass costs the loop thread more than a merged batch saves the executor. So the pump keeps its pacing through the sweep it already runs, which is neither prettier nor uglier than a spin and needs no knob; the finding is that ~2% at c16 (and nothing at c256) is what per-pass latency on the pump's loop thread is worth, and that it is already collected.

The design, so it is not re-derived:

  • Scope: unmounted single-executor ASGI only — the benchmark shape. Every other topology stays on the pump. Behind M0_INVERTED=1 until the gate passes, so the A/B is one environment variable.
  • Submit: a defaulted HTTPService hook (direct_job(slot) -> Bool, default False — Phase 1 made adding one non-breaking). The loop still parks the request; on an executor lane it asks the handler first and sends the datagram only when it declines. WSGIHandler in inverted mode answers by running the port's job branch, factored into one function shared with the port.
  • Complete: the port keeps parking responses; its per-iteration _flush calls service_direct_completions[T,B](handler, backend, st, slots) — the per-slot body of _service_completions — instead of a datagram. The port grows the loop state's and backend's addresses; the backend is the platform one, no DetachingBackend, because wait(0) never blocks attached.
  • The ordering rule that makes it safe: a stream's begin frame rides the chunk channel and is drained by a PASS, while its head is a direct completion. _flush therefore runs a pass first and completions second, or a head could precede its own begin frame — the recycled-slot hazard the 0.14.1 rules exist for. Deterministic on one thread.
  • Driver: add_reader(kq_fd, _on_mojo)port.pass_(), plus a 1 Hz call_later for the idle sweep, the date cache and the heartbeats, which assume a wake per second. Acks and credit unchanged.
  • Shutdown — designed, not built, and deliberately skipped (2026-08-29): _run_shutdown's drain waits in backend.wait, which inside an asyncio callback blocks the very tasks it waits for. The design was to poll the drain from call_later passes until active_count == 0 or the 5 s budget, then loop.stop(). The first cut runs the drain as it is, blocking, and the consequence is measured: with a 1.5 s request mid-await at SIGTERM the pump answers it at 1.50 s, the inversion at 5.30 s — the drain deadline, after which the shim runs the in-flight tasks to completion and the response goes out. Not dropped, but any stop grace under ~6 s drops it. The reshaping is half a day (split _run_shutdown into prepare / step / finish as run_event_loop was split, drive the step from a call_later cadence) and buys that only under the flag, which nothing runs in production; it is the inversion's promotion bar, not 0.15.0 work. Until then an inverted server wants a stop grace of 10 s or more (docker stop's default), and the comment beside the flag in m0serve.mojo says so.