Examples¶
The runnable versions of the two-node demo below live in examples/
(app_node_a.py / app_node_b.py). This page shows the same shape running
across different deployment targets, plus two extra patterns (crash recovery,
custom types) that don't fit on the Getting started
page.
Two local processes¶
The simplest case: both nodes on 127.0.0.1, different ports.
# terminal 1
NODE_NAME=node_a@127.0.0.1:9001 PEER=node_b@127.0.0.1:9002 python examples/app_node_a.py
# terminal 2
NODE_NAME=node_b@127.0.0.1:9002 PEER=node_a@127.0.0.1:9001 python examples/app_node_b.py
Docker Compose (two containers)¶
docker-compose.yml in the repository root runs the same two actors as two
containers on a bridge network, addressing each other by container name
instead of an IP:
services:
node_a:
build: .
command: python app_node_a.py
environment:
- NODE_NAME=node_a@node_a:9001
- PEER=node_b@node_b:9002
ports: ["9001:9001"]
networks: [lapinbeam-net]
node_b:
build: .
command: python app_node_b.py
environment:
- NODE_NAME=node_b@node_b:9002
- PEER=node_a@node_a:9001
ports: ["9002:9002"]
networks: [lapinbeam-net]
networks:
lapinbeam-net:
driver: bridge
docker compose up --build
The only thing that changes versus two local processes is the host part of
NODE_NAME/PEER: Docker's embedded DNS resolves node_a/node_b to the
right container IP on the bridge network. The CI pipeline
(.github/workflows/ci.yml) runs exactly this compose file and asserts
node_a's logs show Total: 100 ACKs before tearing it down.
Two more compose files in the repository root exercise the same demo under different conditions, each with its own CI job:
docker-compose.secure.yml— the same two containers, but with a matchingCLUSTER_SECRETenvironment variable on both sides, wired through toNode(..., cluster_secret=...)(see Security). Proves the handshake works between two genuinely separate processes, not just within one.docker-compose.restart.yml— a longer-running variant (examples/e2e_restart_node_a.py/e2e_restart_node_b.py) that sends more slowly, giving CI room to restart node_b's container mid-stream and confirm node_a's automatic reconnection actually resumes delivery afterward, instead of just firing a"peer_disconnected"event and going quiet.
docker compose -f docker-compose.secure.yml up --build
docker compose -f docker-compose.restart.yml up --build
Real, separate hosts¶
See Getting started
for a diagram of exactly what "server A" and "server B" mean here — each is
its own OS process, and this section just changes their addresses from
loopback to real machines. Nothing about lapinbeam is loopback-specific —
NodeId is just
name@host:port, and host can be any address the other side can route to.
Running the two actors on two different machines only changes the
environment variables:
# machine at 10.0.0.1
NODE_NAME=node_a@10.0.0.1:9001 PEER=node_b@10.0.0.2:9002 python examples/app_node_a.py
# machine at 10.0.0.2
NODE_NAME=node_b@10.0.0.2:9002 PEER=node_a@10.0.0.1:9001 python examples/app_node_b.py
Two things to plan for once you leave loopback:
- Firewall the listening port (
9001/9002above) between the hosts —Node.start()binds and accepts from anywhere by default. - Expect real network RTT to dominate. The loopback benchmarks isolate lapinbeam's own overhead (sub-millisecond); across real hosts your latency floor is whatever the network between them gives you, plus that overhead on top.
A multi-node pipeline behind an HTTP API¶
examples/sales_warehouse/ is a bigger, more realistic example than the
two-node demo above: a FastAPI order submission fans out across three
separate node containers (api → fulfillment → archive), each doing a
real network hop to the next, with progress tracked back to the originating
node as the order moves through four chained local actors. Its README
documents a full CPU/RAM measurement (idle vs. under load) using nothing but
docker, docker compose, and uv.
cd examples/sales_warehouse
docker compose up --build
Streaming progress over an HTTP API¶
examples/order_stream/ is a different three-container setup —
postgres, app (FastAPI), and worker (a lapinbeam node) — for a
different problem: a client that wants live progress on one order, not
just a final result, and can disconnect and reconnect without losing its
place. worker runs Supervisor.spawn_pool() for its fixed pool of
order-processing workers, and each order's steps are reported back via
ActorRef.ask_stream()/reply_stream()/reply_final() — app relays
that one stream into an in-process pub/sub so any number of browser tabs
watching the same order's /orders/{id}/stream get every update, not just
whichever request happened to call ask_stream(). See its README for the
full walkthrough, including the migration from hand-rolled queues/relays
to these primitives.
cd examples/order_stream
docker compose up --build
Node discovery via a seed node¶
Every example above configures each node with the exact address of every
peer it needs — fine for two or three nodes, but that's up to N·(N-1)/2
addresses to hand-configure for a mesh of N. lapinbeam.discovery (see the
Features list) turns that into "every node needs one shared seed
address": connect to a seed, ask it who it knows, connect to those too,
recursively, until nothing new turns up.
from lapinbeam import Node, Supervisor, register_discovery, join_via_seeds
node = Node("app@app:9001")
await node.start()
register_discovery(node, Supervisor(node=node))
found: set[str] = await join_via_seeds(node, seeds=["seed@seed:9000"])
examples/seed_discovery/ runs this across four containers — one seed and
three joiners that only ever hear the seed's address — and shows all four
converging on a full mesh in the logs:
cd examples/seed_discovery
docker compose up --build
Supervision trees, links, monitors, groups, and a name registry across real nodes¶
See OTP-inspired patterns for the API and standalone
snippets for nested supervision trees, lapinbeam.links,
lapinbeam.monitors, lapinbeam.groups, and lapinbeam.registry.
examples/cluster_supervision/ runs all five across three real
containers — a hub with a nested supervision tree that links and
monitors two workers (so the same crash delivers both an Exit and a
Down) and watches a cluster-wide "workers" group and a
"task_worker_primary" registered name as each worker fails for good:
cd examples/cluster_supervision
docker compose up --build
Recovering from a crash¶
Supervisor restarts an actor whose receive (or @on handler) raises,
using the one_for_one strategy: only the crashed actor is restarted, with
exponential backoff, up to max_restarts within restart_window seconds
before giving up and re-raising:
import asyncio
from lapinbeam import ActorRef, Node, Supervisor, actor
# Kept outside the actor on purpose — see the note below.
attempts = {"n": 0}
@actor(name="flaky")
class Flaky:
async def receive(self, msg):
attempts["n"] += 1
if attempts["n"] < 3:
raise RuntimeError(f"transient failure #{attempts['n']}")
print("succeeded on attempt", attempts["n"])
async def main():
async with Node("app@127.0.0.1:0") as node:
sup = Supervisor(strategy="one_for_one", node=node,
max_restarts=5, restart_window=10.0)
ref: ActorRef = sup.spawn(Flaky)
for _ in range(3):
await ref.send({})
# Give the restart (with backoff) time to finish before the next
# send — a send while the actor is mid-restart briefly has no
# mailbox to land in and raises ValueError, same as sending to
# any other name that isn't registered yet.
await asyncio.sleep(0.4)
Restarts create a fresh instance — state does not survive
Every restart runs actor_cls(*args, **kwargs) again, so anything stored
on self (like self.attempts) resets to its initial value on every
crash — only the registration (the mailbox and its name) survives, so
senders never need to know a restart happened. That's why attempts
lives outside the actor above: if it were self.attempts, it would
never reach 3 no matter how many times you send, since each crash
hands the next message to a brand new instance starting over. If an
actor needs state to survive its own crashes, persist it externally
(a database, Redis, or — as above — a plain object the actor closes
over) rather than on self.
Custom (non-dataclass, non-Pydantic) types¶
Typed messages covers dataclasses and Pydantic models, which round-trip automatically. Anything else needs an explicit codec registered on both ends of the cluster:
from lapinbeam import register_codec
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point({self.x}, {self.y})"
register_codec(
Point,
encode=lambda p: {"x": p.x, "y": p.y},
decode=lambda d: Point(d["x"], d["y"]),
)
# From here on, sending a Point works exactly like a dataclass:
# await remote.send(Point(1, 2))
Register the codec once, at import time, on every node that will either send
or receive Point instances — the tag lookup on decode needs the codec (or
the class itself, if it's a dataclass/Pydantic model) to already be
registered/importable.