Tutorial: a guestbook, from one process to replicas¶
This tutorial builds a small application, a guestbook, and moves it along Myxo's continuum: from one process on SQLite on a laptop to replicas on PostgreSQL under Kubernetes. The code does not change between the steps; the layout file and its environment do. A test runs every step below as written.
| Step | What changes |
|---|---|
| 0. Develop | services and a layout: one process, SQLite |
| 1. One box | a layout with two processes on one machine, myxo up or the files of myxo export |
| 2. A real database | DATABASE_URL |
| 3. Process types on a platform | the platform's files, from myxo export |
| 4. Replicas | an instance count, on PostgreSQL |
You need Python 3.14 and uv, and PostgreSQL from step 2. Download the guestbook, a project that depends on myxo[postgresql], and install it:
$ curl -sO https://myxo.lab.abilian.com/guestbook.tar.gz
$ tar -xzf guestbook.tar.gz
$ cd guestbook
$ uv sync
$ source .venv/bin/activate
Run the commands from guestbook/: the SQLite file, guestbook.db, and the Unix sockets, under run/, land in the current directory. The web service listens on port 8000, or on $PORT. Without the virtual environment active, put uv run before each myxo.
The application¶
The guestbook has two services to begin with. book keeps the entries in the database. web is an HTTP API, written with Litestar, in front of it.
An interface, and its messages¶
A service that others call publishes an interface: a Protocol of async methods, each taking one message and returning one, or None. Messages are frozen msgspec structs of immutable fields, since they may cross a process boundary; the runtime refuses to start a service whose messages could be shared and changed. src/guestbook/api.py:
class NewEntry(msgspec.Struct, frozen=True):
name: str
message: str
class EntryRef(msgspec.Struct, frozen=True):
id: int
class BookAPI(Protocol):
async def sign(self, entry: NewEntry) -> EntryRef: ...
async def get(self, ref: EntryRef) -> Entry: ...
async def entries(self, page: Page) -> Entries: ...
async def flag(self, ref: EntryRef) -> None: ...
Other services import guestbook.api, and never guestbook.book. That keeps a caller independent of where the layout places book. myxo check reports such an import, here as in code split across packages.
A service¶
A service is a class with a name, a stage (services start in order of stage), its interface, and its configuration, which comes from the layout. src/guestbook/book.py, in part:
class BookConfig(msgspec.Struct, forbid_unknown_fields=True):
database: str
class Book(Service):
name = "book"
stage = 1
api = BookAPI
Config = BookConfig
events = Events()
@classmethod
async def migrate(cls, config: BookConfig) -> None:
... # creates the entries table
async def run(self, ready: Callable[[], None]) -> None:
self.engine = make_engine(self.config.database)
try:
await super().run(ready)
finally:
await self.engine.dispose()
async def check(self) -> None:
try:
async with self.engine.connect() as conn:
await conn.execute(select(1))
except DB_ERRORS as exc:
raise Unavailable(f"database: {exc}") from exc
async def sign(self, entry: NewEntry) -> EntryRef:
if not entry.name.strip() or not entry.message.strip():
raise Invalid("an entry needs a name and a message")
insert = ENTRIES.insert().values(name=entry.name, message=entry.message)
async with self.engine.begin() as tx:
entry_id = (await tx.execute(insert.returning(ENTRIES.c.id))).scalar_one()
await self.events.publish(tx, Signed(entry_id))
return EntryRef(entry_id)
migrate creates the service's tables; myxo migrate runs it once, before any process starts. run holds the service's resources while it serves. check is the service's part of its process's readiness (/readyz): a replica whose database does not answer is taken out of the platform's rotation. events = Events() gives the service the framework's events: publish writes Signed in the same transaction as the entry, so the event exists if and only if the entry does. Errors that callers may handle are the runtime's: Invalid (422), NotFound (404) and Unavailable (503), the same in every layout.
web calls book through a client, runtime.client(BookAPI), and serves Litestar with the runtime's server (src/guestbook/web.py):
class Web(Service):
name = "web"
stage = 2
Config = WebConfig
def __init__(self, config: WebConfig, runtime: Runtime) -> None:
super().__init__(config, runtime)
self.book = runtime.client(BookAPI)
async def run(self, ready: Callable[[], None]) -> None:
address = (self.config.host, self.config.port)
await serve_asgi(self.app(), address, ready, self.runtime.stopping.set)
Whether book runs in web's process or another, self.book.sign(...) is the same call, with the same errors and the same timeout.
Entry points¶
The package declares its services, and book's interface, in pyproject.toml. The runtime finds services there, and imports only those a process runs:
[project.entry-points."myxo.services"]
web = "guestbook.web:Web"
book = "guestbook.book:Book"
moderation = "guestbook.moderation:Moderation"
[project.entry-points."myxo.apis"]
book = "guestbook.api:BookAPI"
Next, read step 0, develop.