Multiworker SPA integration

Status: Draft; implementation checked against the development source on 2026-09-08.

The genro-asgi distribution contains two top-level Python packages:

Package

Responsibility

genro_asgi

Server, routed apps, HTTP, WSX, middleware and core services

genro_asgi_multiworker_spa

SPA front, commander, worker processes, registers and hosted-app adapters

There is no separate [spa] extra. Importing the core does not load SPA orchestration. Since 0.44 the old genro_asgi.spa and genro_asgi.applications.spa_app paths have no compatibility re-exports. Update configuration strings (entry_module, worker_class, python -m) as well as Python imports.

from genro_asgi_multiworker_spa.spa_app import SpaApplication
from genro_asgi_multiworker_spa.orchestration.spa_worker import SpaWorker
from genro_asgi_multiworker_spa.environ import AsgiSeam, WsgiSeam

Composing the front and pool

Mount a SpaApplication in the server and declare its orchestration subtree, with a commander and worker groups. See the configuration recipe. This requires a real hosted application and worker class; an empty base worker can serve control orders but cannot serve an HTTP application.

The front owns HTTP translation and the connection cookie. The hosted site supplies the connection id; the front writes it as spa_connection_id. The commander owns placement and global indexes. All pages of one user share a worker and that user’s store. A user moving through freeze/unfreeze meets the same barrier whether arriving by HTTP or WSX. Worker failure can require the affected users to restart their application state.

Use one orchestrated application per server. The inspector detects a second attach when enabled, but there is no unconditional general boot validation of that restriction. Configure the group policies and read the pool’s status; there is no fixed worker-count setting in this configuration.

At a glance

SPA front, commander and worker lead to a hosted application; worker requests and responses are buffered.

Placement keeps all pages of one user on the same worker. Replies return through the front; the global store remains commander-owned.

Hosting ASGI or WSGI

A consumer’s SpaWorker subclass assigns its hosted application to asgi_app. For a WSGI-only site it may assign wsgi_app instead: hosted_app_seam wraps it with AsgiSeam(WsgiSeam(wsgi_app, worker)). Assigning both is a boot error. For a mixed site the consumer’s ASGI router delegates legacy paths through a WsgiSeam; the core has no special legacy path prefixes.

WsgiSeam runs the synchronous callable through the worker’s traffic pool. The worker’s request context follows the thread. The hosted scope/environ carries genro.identity and, for a page message, genro.page_id and genro.reply_path. The hosted ASGI scope has an empty root_path and a mount-relative path. A consumer doing additional WSGI routing must set root_path/path consistently for SCRIPT_NAME and PATH_INFO.

The front packs a complete request body and the adapters collect the complete response body before replying. This worker path provides neither streaming uploads nor incremental HTTP downloads/SSE. An endless hosted response never completes its call. Serve streaming routes directly on the core when that behavior is required; see Streaming.

Worker lifecycle events belong to the request that produced them and travel on that request’s reply. Events created outside a served request use their own announcement call. The bridge’s database events, datachanges, subscriptions and GenroPy-specific delivery code live in genropy-asgi; they are not services of this package.

Global store

worker.global_store is a client of one commander-owned dict[str, Any]. Keys are literal strings ("a.b" is one key); values must survive the TYTX transport. There are no replicas or process-shared mutable objects.

The simple get, set and delete methods are synchronous: use them on a worker pool thread, not its event-loop thread. get(key, default) returns the default only for an absent key; a stored None stays None. A read-modify-write lease works with with on a pool thread or async with on the worker loop:

# Inside a synchronous handler running on a worker pool thread:
with worker.global_store.for_update("visits") as turn:
    turn.value = (turn.value if turn.exists else 0) + 1
# Inside asynchronous worker code:
async with worker.global_store.for_update("settings") as turn:
    if not turn.exists:
        turn.value = {}
    turn.value["theme"] = "dark"

These are integration fragments requiring a live connected worker. The lease yields itself with a private value and exists. Exit publishes the complete value by replacement; omitting the key leases and replaces the entire dictionary. One FIFO lock protects all operations, even reads of unrelated keys. Keep turns short; never perform a nested turn or call get/set/delete from the context already holding one (it raises instead of waiting on itself).

An exception in the block or turn.abort() prevents publication. abort() keeps the lock until the block exits. Decode/encode failures also release without applying. If the commit reply is lost, GlobalStoreCommitUnconfirmed means the value may already be published: the client does not retry or pretend the commit was aborted. The store does not promise persistence across commander restarts; arrange durable storage separately when your application needs it.