Multiworker SPA integration

These are integration surfaces of the separate genro_asgi_multiworker_spa package. See Multiworker SPA integration before configuring a pool.

Front and worker

class genro_asgi_multiworker_spa.spa_app.SpaApplication(*, env_settings=None, **kwargs)[source]

Bases: RoutedApplication

A single-page-application front backed by the new user-sticky pool.

Parameters:
  • env_settings (dict[str, Any] | None)

  • kwargs (Any)

The connection cookie a websocket handshake must carry to reach this front.

Returns:

The name of the SPA’s own connection cookie.

Every message on that socket is a request of the user the cookie names, so a socket opened without one could never be served: the handshake is accepted and closed 1008, and the browser reads why. The first live probe found this property unimplemented and the socket left open for ever (#70).

property commander: SpaCommander

The pool this front owns.

Raises:

RuntimeError – it is not built yet — the vertex is born at startup, out of a configuration that only a mounted application can read.

class genro_asgi_multiworker_spa.orchestration.spa_worker.SpaWorker(name, *, freeze_handler, group='', deposit_lock_retry_interval=0.05, deposit_lock_wait_limit=30.0, transfer_start_delay=2.0, main_threadpool_size=None, aux_threadpool_size=None, worker_snapshot_ttl=0.5)[source]

Bases: object

The users, connections and pages one worker process holds.

Parameters:
  • name (str) – the worker’s name, the one its handler minted; it stamps every worker event and holds the deposit semaphore.

  • freeze_handler (FreezeHandler) – the deposit surface — the only way to the parcels.

  • group (str) – the group this worker serves in; it goes in the diagnostic header of every parcel, which is read for counting and for the sysop.

  • deposit_lock_retry_interval (float) – how often a busy user folder is looked at again while waiting for its semaphore.

  • deposit_lock_wait_limit (float) – how long that wait goes on before it gives up loud.

  • transfer_start_delay (float) – how long the gate stays shut between announcing the departures and parking them.

  • main_threadpool_size (int | None) – the traffic pool’s size — the WSGI stitching and the long calls; None leaves the interpreter’s own default.

  • aux_threadpool_size (int | None) – the service pool’s size — the deposit IO, and much smaller.

  • worker_snapshot_ttl (float) – how long a photo already sent stays fresh enough.

property hosted_app_seam: AsgiSeam

The seam onto the hosted application, the one seam a consumer assigned.

Returns:

on asgi_app when a consumer assigned one, on the WSGI adapter around wsgi_app when it took the shortcut instead.

Return type:

The ASGI seam

Raises:

RuntimeError – both seams are assigned, or neither is — and the two are not the same kind of trouble. BOTH is a contradiction somebody declared, and WorkerEntry reads this at boot for exactly that case, so the process dies before the wire exists. NEITHER is the base worker, which is legitimate: it serves its orders and hosts nothing, and it learns so here, when an http CALL finally asks it to serve a request.

async run_sync(work)[source]

Run one piece of synchronous work on the traffic pool, in this CALL’s slot.

Parameters:

work (Callable[[], Any]) – what to run there — a WSGI callable, a legacy database call.

Return type:

Any

Returns:

Whatever the work returned.

The thread runs under a COPY of the calling task’s context, so it finds the request slot of the CALL it serves and whatever it announces rides that CALL’s reply. This is how a hosted ASGI application does its synchronous work: the legacy database its group engine built is synchronous, and neither the loop nor the service pool may be held behind it.

build_request_slot()[source]

Build the slot of one request: the seam a consumer replaces with its own slot class.

Return type:

RequestSlot

Returns:

A fresh RequestSlot; a subclass adds the per-request state its verbs carry.

on_request_served()[source]

What runs at the end of every served request, failed ones included.

Called in the finally of the stitching, on the pool thread, with the request’s slot still open. The seam a consumer overrides to deliver what its verbs left on the slot; the core leaves nothing there.

Return type:

None

property global_store: GlobalStoreClient

simple operations and for_update.

NEVER open a turn while holding dispatch_lock: both halves place a lane call, and a holder would park on its own lock.

Type:

This worker’s side of the global store

send_message(page_id, path, data=None)[source]

Write one message of the site onto the page’s websocket.

Parameters:
  • page_id (str) – the page to address — one of this worker’s own.

  • path (str) – what the client routes the message on.

  • data (Any) – the payload, as a Python value.

Return type:

bool

Returns:

True when the message reached a socket, False when that page speaks on none.

Callable from a traffic-pool thread, which is where the site’s own code runs: the CALL goes up on the loop and this thread waits for its REPLY. Fire and forget in meaning, not in mechanics — delivered says written to the socket, never executed by the page (W-12).

The payload is serialised HERE, before it reaches the lane: the lane is opaque bytes. The browser’s codec reads the JSON TYTX value; the frontend only wraps that serialized text in the public WSX envelope.

Hosted application adapters

The hosted application’s ASGI and WSGI endpoint adapters.

AsgiSeam reconstructs the scope from the HTTP record opened by the worker, adds trusted genro.identity/page_id/reply_path, and delegates to the neutral BufferedAsgiEndpoint. Bodies are bytes in both directions. The original SPA second-read disconnect and finite response-chunk behavior remain available.

WsgiSeam wraps a synchronous application as ASGI and runs it through the worker’s traffic pool, preserving the active request slot. It translates root_path/path to SCRIPT_NAME/PATH_INFO and preserves duplicate headers and cookies using the WSGI joining rules. No Python session or Avatar crosses the transport; the worker receives only explicitly defined routing context.

class genro_asgi_multiworker_spa.environ.AsgiSeam(asgi_app)[source]

Bases: object

One ASGI application, called from the facts of a CALL.

Parameters:

asgi_app (Any)

__init__(asgi_app)[source]

Initialize this instance.

Parameters:

asgi_app (Any) – the application to call, (scope, receive, send).

Return type:

None

build_scope(http, identity=None)[source]

The ASGI scope for one http dict.

Parameters:
  • http (dict[str, Any]) – the facts the front packed.

  • identity (str | None) – the CALL’s identity, None when the caller named none.

Returns:

the path the front forwards is already mount-relative, so the whole of it is path. server comes from the Host header, the only place the front’s own address survives the packing.

Return type:

An http scope. root_path is empty

server_address(host_header)[source]

Split a Host header into (name, port).

Return type:

tuple[str, str]

Parameters:

host_header (str)

async serve(http, identity=None)[source]

Call the application on one http dict and shape its reply.

Parameters:
  • http (dict[str, Any]) – the facts the front packed.

  • identity (str | None) – the CALL’s identity.

Return type:

dict[str, Any]

Returns:

{"status", "headers", "body"}, the body raw bytes — the same shape the WSGI road produced before this seam existed.

Raises:

RuntimeError – the application answered nothing.

class genro_asgi_multiworker_spa.environ.WsgiSeam(wsgi_app, worker)[source]

Bases: object

One WSGI callable, reached as an ASGI application.

What a hosted ASGI application calls to delegate one request to the legacy, and the road the core itself takes for the wsgi_app shortcut: one way in, whether the consumer delegates or hosts nothing else.

It holds no state of a request: the same instance serves concurrent requests, because the router of a consumer calls it that way.

Parameters:
  • wsgi_app (Callable[..., Iterable[bytes]])

  • worker (Any)

__init__(wsgi_app, worker)[source]

Initialize this instance.

Parameters:
  • wsgi_app (Callable[..., Iterable[bytes]]) – the consumer’s WSGI callable, (environ, start_response).

  • worker (Any) – the worker whose traffic pool runs it — WSGI is synchronous, and the request’s slot follows the work onto that thread.

Return type:

None

build_environ(scope, body)[source]

The PEP 3333 environ for one ASGI scope.

Parameters:
  • scope (dict[str, Any]) – the http scope of the request being delegated.

  • body (bytes) – the whole request body, already drained.

Return type:

dict[str, Any]

Returns:

The environ. SCRIPT_NAME is the scope’s root_path and PATH_INFO what is left of path once that prefix is taken off — so the legacy site’s view of its own URLs does not change, whether the router in front of it moved the prefix into root_path or left the path whole.

header_environ(headers)[source]

The header half of the environ: HTTP_* keys, duplicates joined.

A repeated header is one environ key holding the values comma-joined — the reassembly PEP 3333 prescribes, and the reason the wire carries a pair-list instead of a mapping. Cookie is the one exception: its pairs rejoin with "; " (RFC 6265, restated by RFC 7540 §8.1.2.5) — a comma would fuse two cookies into one mangled value.

Return type:

dict[str, str]

Parameters:

headers (list[tuple[str, str]])

async read_body(receive)[source]

Drain the request body to its last chunk.

Return type:

bytes

Parameters:

receive (Any)

serve_environ(environ)[source]

Run the WSGI callable on one environ, on the calling thread.

Return type:

tuple[int, list[tuple[str, str]], bytes]

Returns:

The status, the headers and the whole body.

Parameters:

environ (dict[str, Any])

The iterable is consumed FIRST and closed as PEP 3333 requires; the deprecated write callable is supported the only way a whole-body reply can support it — its chunks lead the bytes the iterable yields.

Global store

class genro_asgi_multiworker_spa.global_store.GlobalStoreClient(worker)[source]

Bases: object

A worker’s side of the global store: three simple operations and the turn.

Parameters:

worker (Any) – the SpaWorker whose lane the CALLs travel on.

The simple operations are synchronous and block a pool thread on the worker’s loop; for_update answers a lease usable with with from a pool thread or async with on the loop.

get(key, default=None)[source]

Read one key: the stored value, None included, or default when absent.

Return type:

Any

Parameters:
set(key, value=None)[source]

Write one key; the master holds the value when this returns.

Return type:

None

Parameters:
delete(key)[source]

Remove one key; an absent key is a no-op.

Return type:

None

Parameters:

key (str)

for_update(key=None)[source]

One read-modify-write turn on key, or on the whole dictionary when None.

Return type:

GlobalStoreLease

Parameters:

key (str | None)

class genro_asgi_multiworker_spa.global_store.GlobalStoreLease(client, key)[source]

Bases: object

One turn on the global store: with or async with, yielding itself.

Parameters:
  • client (GlobalStoreClient) – the worker’s GlobalStoreClient.

  • key (str | None) – the selected key, or None for the whole dictionary.

value is the private working copy the grant decoded — assign it or mutate it, the master sees nothing until the exit; exists says whether the key was there at grant time (always True for the whole dictionary). A body that raises releases with apply=False; so does a grant that cannot be decoded or a value that cannot be encoded, the original error re-raised; so does a turn on which abort was called, whatever the body did to value afterwards — the lock stays held until the exit either way.

abort()[source]

Mark this turn as not to be published: the exit sends apply=False.

The lock stays held until the with block exits; once called, nothing the body does to value reaches the master.

Return type:

None

exception genro_asgi_multiworker_spa.global_store.GlobalStoreCommitUnconfirmed(request_id, key, cause)[source]

Bases: Exception

The commit of a turn was sent and its answer never came: the value MAY be published.

Parameters:
  • request_id (str) – the turn whose commit is unconfirmed.

  • key (str | None) – the key it selected, None for the whole dictionary.

  • cause (BaseException) – what ended the wait — the transport failure, for the log.

Return type:

None

The caller must not repeat the write on its own: the commander may hold it already. Raised by GlobalStoreLease on the commit path alone.