Skip to content

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.