Migrating to 1.10.0¶
1.10.0 makes every scripting API that waits on the network awaitable. If a
script calls one of them, it has to await it, and the handler has to be
async def.
This is the only breaking change in the release. Nothing else about the API moved.
Why¶
These APIs used to block the thread that called them. For an async def
handler that thread is its asyncio driver — one of a small pool
(script.async_pool_size, defaulting to the CPU count), each running many
coroutines at once. While one was blocked the whole loop stopped: every
coroutine on it, including ones belonging to calls that never touched the API.
On a two-core node that is two loops. A handful of concurrent Diameter requests, credential lookups or DNS resolutions could stall async dispatch across the whole process.
1.9.1 bounded the worker-side wait so a wedged handler could no longer abort the node. Nothing could release a driver.
What changed¶
| Namespace | Methods |
|---|---|
diameter |
cx_uar, cx_sar, cx_lir, s6a_air, s6a_ulr, s6a_purge_ue, rx_aar, rx_str, sh_udr, sh_pur, sh_snr, s6c_srr, s6c_rsr, sgd_tfr, send_request, rf_acr_start, rf_acr_interim, rf_acr_stop, rf_acr_event |
sbi |
discover_pcf_binding, create_session, update_session, delete_session |
auth |
require_www_digest, require_proxy_digest, require_digest, verify_digest, require_ims_digest |
presence |
notify, terminate |
subscribe_state |
get, send; a handle's notify, terminate, refresh |
proxy |
send_request |
Not changed¶
auth.require_aka_digest derives its vectors locally with Milenage and performs
no I/O, so it stays synchronous. So do auth.stamp_integrity_protected and
auth.verify_integrity_protected, and every registrar, SDP, header and logging
call.
SubscribeHandle's properties (event, expires, local_tag, …) also stay
synchronous. A Python property cannot be awaited, so they read through a loader
that still blocks on an L2 (Redis) miss. An L1 hit — the common case, and always
the case for a dialog this instance created — touches no network.
Migrating¶
Add await, and make the handler async def if it is not already:
# before
@proxy.on_request("REGISTER")
def handle_register(request):
if not auth.require_digest(request, realm=REALM):
return
registrar.save(request)
# after
@proxy.on_request("REGISTER")
async def handle_register(request):
if not await auth.require_digest(request, realm=REALM):
return
registrar.save(request) # unchanged — the registrar is synchronous
A handler can be async def whether or not it awaits anything; siphon detects
which it is at decoration time and dispatches accordingly.
If a helper function of yours calls one of these, it becomes async def too,
and its callers must await it — all the way up to the handler.
Keep the per-message path synchronous¶
Converting a handler is not free, and on a per-message path it is expensive.
An async def handler is dispatched through an asyncio driver; a def handler
runs on the synchronous worker pool. For a handler that awaits, the driver is
what stops one slow call from blocking the others — that is the whole point of
this release. For a handler that reaches no await on the path a given message
takes, it is a coroutine built, handed across threads and resolved, per message,
for nothing.
It is worth about half the CPU. Measured on scale_test.sh 40000 10000 8
(proxy, UDP, 10 000 cps), same binary, only the handler shape differing:
| handler shape | peak CPU | peak CPS |
|---|---|---|
one async def catch-all |
645 % | 9 928 |
def catch-all + async REGISTER handler |
297 % | 9 904 |
Throughput is identical; what the coroutine costs is headroom.
So put the await in a handler only that method reaches. An unfiltered handler
matches every method and a filtered one only its own, so splitting the one
awaiting branch out leaves the hot path synchronous:
# before — every INVITE, ACK and BYE pays asyncio dispatch so that REGISTER
# can await its challenge
@proxy.on_request
async def route(request):
if request.method == "REGISTER":
if not await auth.require_digest(request, realm=REALM):
return
registrar.save(request)
return
request.relay()
# after
@proxy.on_request("REGISTER")
async def register(request):
if not await auth.require_digest(request, realm=REALM):
return
registrar.save(request)
@proxy.on_request
def route(request):
if request.method == "REGISTER":
return # handled by `register`
request.relay()
Both handlers run for REGISTER, and they share one action slot, so route
returning without acting leaves register's decision standing. Every other
method reaches only route, synchronously.
The shipped scripts/proxy_default.py and scripts/b2bua_default.py are
written this way, and scripts/check_hot_path_dispatch.py fails CI on an
unfiltered per-message handler whose every await sits under a single-method
branch. If your handler genuinely awaits on the path every message takes, leave
it async — there the driver is doing its job.
The two failure modes, and how to tell them apart¶
You forgot to make the handler async def. The call raises immediately:
RuntimeError: this siphon API is awaitable and needs a running event loop:
call it with `await` from an `async def` handler. A synchronous handler cannot
await, so change `def handler(...)` to `async def handler(...)`.
Loud, and it names the fix.
You forgot the await itself. This one is quiet and it is the dangerous
one. The call returns a coroutine, which is truthy, so:
never challenges, and the request is treated as authenticated. The same shape hides a NOTIFY that is never sent and a Diameter request that never leaves.
Python does flag it, as a warning on stderr:
Treat that warning as an error. In a script under test, turn it into one:
The shipped scripts and examples are all converted, and the SDK mocks mirror the
awaitable/synchronous split exactly, so pytest against siphon-sip catches a
missing await in your own scripts the same way siphon would.
Ordering¶
await is sequencing, so ordering is preserved for free: two requests a handler
sends in order still leave in that order. No queue, no special case.
One consequence is worth stating plainly. proxy.send_request used to put the
message on the wire before it handed back its coroutine, so a script that
never awaited it still sent. That is no longer true: an un-awaited
send_request sends nothing.
Validation¶
Arguments are still read, and still validated, at the call rather than on
await — the Python objects cannot cross into the future. A malformed
rx_aar media component, a bad specific_actions, a password= and ha1=
supplied together: all still raise where you called them.