Skip to content

Python API

Myxo 0.x promises no stability: any name on this page may change in a minor release. The changelog lists each change, with what to do about it. The package is typed (py.typed).

myxo.Service

A service subclasses myxo.Service:

Attribute What it is
name the service's name in layouts and entry points
stage within a process, services start by stage, each once the previous stage is ready (1 by default); processes start independently, and calls between them handle Unavailable
api the Protocol it implements for other services, if any
Config a msgspec.Struct, filled from [services.NAME]
migrate(config) a class method that brings the service's storage up to date; it must be safe to run again
run(ready) serves until cancelled, calling ready() once it can take calls; by default it is ready at once
check() raises Unavailable when it cannot serve, because the database is unreachable, say; part of /readyz

The runtime gives each instance self.config and self.runtime. A service that overrides __init__ takes (config, runtime) and calls super().__init__(config, runtime). Each restart makes a new instance.

myxo.Runtime

A service reaches the process through self.runtime:

Member What it is
client(API) a client for the service implementing API, wherever the layout places it; create it in __init__ or later
provides(API) whether a service in the layout implements API, for an optional dependency
stopping an asyncio.Event, set when the process begins to stop
layout, process the layout, and this process's settings

Interfaces and messages

An interface is a Protocol of async methods, each taking one message and returning one or None. An operation that needs no input takes an empty struct. Messages are frozen msgspec.Structs whose fields are immutable all the way down: numbers, strings, bytes, booleans, dates and times, UUIDs, decimals, enums, literals, tuples, frozensets, and other frozen structs. The runtime refuses others when it starts, and myxo check before.

The framework stores an event or a job under its type's module.qualname. Before moving or renaming such a class, give it that name with the decorator @myxo.topic("app.events.Noted"). What is stored under the name then still reaches the class. myxo migrate warns about stored jobs that no service of the layout handles.

Errors

From myxo. Calls raise the same errors in every layout:

Error Status Meaning
NotFound 404 what was asked for does not exist
Invalid 422 the request is wrong, a malformed message included
Unavailable 503 the callee is not running, cannot be reached, or did not answer within call_timeout
RemoteError 500 the callee failed with an error it did not declare, or its answer does not decode: a bug, or two versions of a message

All derive from ServiceError, whose status attribute gives the number.

myxo.retry

The helper await myxo.retry(call, arg, attempts=5, first=0.5, cap=10.0) calls again with backoff while the call raises Unavailable, for operations that are safe to repeat; attempts=None retries until cancelled.

myxo.db

A service keeps its own data with SQLAlchemy:

  • make_engine(url, **options): an engine with the asynchronous drivers, from a postgresql:// or sqlite:// URL, with SQLite in WAL mode and a busy timeout of 5 s. Options go to SQLAlchemy.
  • DB_ERRORS: the exceptions a database operation raises when something is wrong. A service's check() catches them and raises Unavailable.

myxo.transport

await serve_asgi(app, address, ready, stopping) serves any ASGI application until cancelled, on (host, port) or on a Unix socket's path. ready is called once it listens, and stopping before the graceful stop. A web service passes self.runtime.stopping.set, so that long polls and streams end first.

Entry points and extras

A package declares services in the myxo.services entry points, by name, and interfaces in myxo.apis:

[project.entry-points."myxo.services"]
book = "guestbook.book:Book"

[project.entry-points."myxo.apis"]
book = "guestbook.api:BookAPI"

The runtime reads entry points from the installed package: after adding one, install the package again. A process imports only the services it runs, and reaches the others through their interfaces alone, so an interface module must stay light: messages and a Protocol, and no import of the implementation. A service with heavy dependencies, such as machine-learning libraries, declares them as an optional extra of the package (pip install app[render]), so that an image for the other processes leaves them out (E5).

The framework's services

Each runs on SQLite and on PostgreSQL, in the layout's database, whose tables (myxo_*) myxo migrate creates. A service declares what it uses as class attributes or decorators, so that a process loads them only if the services it runs use them.

Events: myxo.events

  • events = Events(), then await self.events.publish(tx, event) with the service's SQLAlchemy connection or session: the event commits or rolls back with the transaction.
  • @subscribe(EventType) on a method: it runs after the commit, at least once for the consumer group (the service's name, or group=), one member at a time, in the order of the commits. A new group starts from the oldest event kept. Unavailable brings the event back after a backoff; any other error stops the consumer, which the supervisor restarts.
  • @subscribe(EventType, in_transaction=True): the method takes (tx, event) and runs inside publish, so that the method's writes commit with the event; it runs only in the publisher's process.
  • await myxo.events.publish(tx, event): the same, from code outside the layout's services, such as a command that imports data; it runs no in_transaction handler.
  • await self.events.read(after, (EventType,), wait): the events after a position, waiting up to wait seconds; it returns at once when the process stops.

Handled events are kept an hour (RETENTION), for readers that resume, then pruned by one process.

Push: myxo.push

Push(bus, (EventType,), keepalive=15.0) is an ASGI app that streams events to browsers as Server-Sent Events, resuming after Last-Event-ID. bus is a service's self.events.

Jobs: myxo.jobs

  • jobs = Jobs(), then await self.jobs.put(job, tx=None, key=None, delay=0.0): durable; with tx, in the caller's transaction; with key, nothing happens while a job with that key is in the queue; a done job's key is free again.
  • @job(JobType, workers=1, attempts=10, lease=30.0, backoff=1.0) on a method: runs each job at least once. A claim lasts lease seconds, renewed while the job runs; a job whose worker died is claimed again. A job that raises is retried after backoff seconds, doubling to 5 minutes (or to backoff, if longer), then kept, marked failed, with the error.

Schedules: myxo.schedule

@scheduled(every=timedelta(hours=1)), or every="period" to read the period in seconds from the service's configuration: the method runs once per period, from one leader in the installation, at most once even when the leader changes. A period that no process led is skipped; for work that must happen, the method puts a job.

Caches: myxo.cache

  • LocalCache(ttl, size=10_000): in the process, for what no other process needs. Its methods are synchronous: get(key), which returns None if the key is absent or expired, set(key, value) and delete(key).
  • cache = Cache(), then await self.cache.get(key, Type), set(key, value, ttl) and delete(key): shared by every process, in an unlogged table on PostgreSQL; a crash of the database empties it. delete is durable, so that a revocation stays revoked.

Health

Every process with a url serves two paths on the listener:

  • /healthz: 200 while it listens;
  • /readyz: 200 once the process's services run and pass their check(), else 503 with what is wrong. The processes it calls are left out: behind services that route only to ready pods, two processes that call each other would wait for each other.

The wire format

A call to another process is POST /rpc/SERVICE/OPERATION on that process's listener, with the message as JSON. A success answers 200, with the result as JSON, or an empty body for None. A failure answers the error's status, with {"error": "Invalid", "detail": "..."}. The caller turns that back into the same exception.

Fault injection

MYXO_FAULTS names a JSON file that makes calls fail or services crash, for experiments. It is off by default, and re-read whenever it changes:

{"calls": {"catalog.get_item": {"raise": true}, "catalog.list_items": {"sleep": 3.0}},
 "crash": {"catalog": 1}}

A call fault applies to every call of that operation, in-process or over HTTP, while the file says so. A crash makes the service's task fail once for each increment of the counter.