Subclass PinnedAPI: lifespan yields once (setup before, teardown after), and @route.* methods are the instance's HTTP surface. Instance memory is lost on eviction. Anything that must outlive the process goes in a tray, a durable queue whose fields live in the state store.
A first class
from fastapi import FastAPI
from pinned import PinnedAPI, PinnedWorker, route
from pinned.durability import MemoryStateStore
class Counter(PinnedAPI):
async def lifespan(self):
self.count = 0 # setup
yield
# teardown
@route.get("/value")
async def value(self):
return {"id": self.id, "count": self.count}
@route.post("/increment")
async def increment(self):
self.count += 1
return {"count": self.count}
worker = PinnedWorker(hosts={"/counters": Counter}, state_store=MemoryStateStore())
app = FastAPI()
app.include_router(worker)PinnedWorkeris a FastAPI router serving every entry inhosts.uvicorn app:appserves/counters/<id>/value.- Signatures:
PinnedAPI,route. The model: durable objects.
Routes
- Handlers are
async definstance methods decorated withroute.get/post/put/delete/patch/websocket(path, auth=...). Every request for one id runs on that process's event loop. - Parameters are FastAPI parameters (query, body,
WebSocket, ...). WebSocket handlers work as in FastAPI.idandpinned_recordare reserved names. The routed id isself.id. - A parameter typed with a Pydantic model or
dictbinds as the JSON body. - Ids are client-chosen names (Ids). The first request for an id activates it.
- Return a
StreamingResponse(any StarletteResponsewith a body iterator) to stream: the stream counts as in-flight work, advances only as the client reads, and ends when the instance closes. Streams are pull-based and unbuffered. A slow client backpressures only its own stream.
Auth
authorize(conn)gates every route.auth=on a route swaps the resolver (Nonedisables it). A per-route resolver is anasync def authorize_admin(self, conn)defined above the route in the class body; each call binds it to the instance:@route.post("/wipe", auth=authorize_admin).Depends(auth_result)reads the resolver's return value inside a handler.- Auth runs after
__init__and beforelifespan:self.idis available, lifespan state is not. A denied cold request discards its constructed instance unless another request is still authorizing on it. - Deny by raising
AuthRejected, or return a StarletteResponseon an HTTP route to answer with it; a returnedResponseon a WebSocket route is aTypeError.
Trays
A tray is a durable execution queue inside an instance: its fields live in the state store (PinnedWorker(state_store=...)), and its pump drives items(). A crash resumes it from the store on the next activation.
A tray with a queue and a handler
from fastapi import FastAPI
from pinned import (
PinnedAPI,
PinnedWorker,
Tray,
inbox,
persistent,
persistent_queue,
route,
tray,
)
from pinned.durability import MemoryStateStore
class Jobs(Tray):
queue = persistent_queue()
done = persistent([])
def items(self):
while self.queue.value:
yield self.queue.peek()
self.queue.popleft() # the durable ack
@inbox
def add(self, job: dict) -> None:
self.kick() # the mark leads the payload
self.queue.append(job)
class Runner(PinnedAPI):
@tray(Jobs)
async def jobs(self, job, ctx) -> None:
ctx.tray.done.value = [*ctx.tray.done.value, job["name"]]
@route.post("/enqueue")
async def enqueue(self, job: dict) -> dict:
jobs = await self.jobs.get()
await jobs.add(job)
return {"admitted": job["name"]}
@route.get("/done")
async def done(self) -> dict:
jobs = await self.jobs.get()
return {"done": list(jobs.done.value)}
worker = PinnedWorker(hosts={"/runners": Runner}, state_store=MemoryStateStore())
app = FastAPI()
app.include_router(worker)Run it with uvicorn app:app --port 8000:
$ curl -X POST localhost:8000/runners/r1/enqueue -H 'content-type: application/json' -d '{"name":"build"}'
{"admitted":"build"}
$ curl localhost:8000/runners/r1/done
{"done":["build"]}MemoryStateStore empties with the process. Postgres keeps the rows across restarts (stores):
import os
from pinned.durability.postgres import PostgresStateStore # uv add "pinned[postgres]"
store = PostgresStateStore(dsn=os.environ["DATABASE_URL"])
worker = PinnedWorker(hosts={"/runners": Runner}, state_store=store)
# in the app lifespan, once at boot:
await store.setup()A tray that only holds durable fields: items() yields nothing (yield from ()) and the handler raises. Read and write the fields through await self.<tray>.get() (durable host).
Fields
Durable fields are class attributes on the Tray, one doc entry each (reference).
| Field | Holds |
|---|---|
persistent(initial) | one value, read and written through .value |
persistent_queue() | a queue: append, peek, popleft, take |
persistent_outbox() | the one item in flight plus its attempt count |
tray.storage.get/set/delete | ad-hoc keys outside the declared fields |
Declaring
@tray(C)declares a singleton:await self.<name>.get()is the one instance, opened at every activation.@tray.map(C)declares a keyed type.- The handler is the decorated
async def (self, item, ctx), the same for both kinds. await self.<name>.get()works insidelifespanbeforeyield: the tray namespace is created on the firstself.<name>access.items()yields the head item. The generator is re-created on each drain: derive it from durable state only.lifecycle()on the tray and@<name>.on_activate(api, id, tray)on the API, both async generators, bracket each hydration. A tray that drains dry unmarks its row and passivates unlessalways_open. The next access re-hydrates it.- A marked stored row with no matching
@trayon the class raisesRuntimeErrorat activation, as does a row whose kind changed between@trayand@tray.map. Unmarked rows of a removed tray stay on disk unread. - Bump
Tray.versionto discard a tray's durable state: a stored row of another version activates blank.
Admission
- An
@inboxmethod is sync and runs as one atomic admission segment. Mark and payload land in one row write. - The mark is the row flag claiming in-flight work.
kick()sets it, before the payload append. - The call returns a
Receipt. Awaiting it waits for the write to be durable; dropping it is fire-and-forget. - The durable ack is the code after the
yieldinitems(): the item is gone from the queue only once that write lands.
Dispatch
await jobs.add(item)kick() before appenditems() yields the headctx.attempts resetsdispatch_timeout: abort; the item redelivers with ctx.attempts + 1max_attempts: parked, row still marked, until unpark()- At-least-once and crash-only.
ctx.attemptsis recorded before the handler runs. - To keep one item's failure inside the instance, catch it in the handler and return.
tray.unpark()raisesRuntimeErroron a tray that is not parked;kick()and@inboxcalls raise it on a retired tray.tray.cancel_dispatch()setsctx.cancelledand consumes the item.tray.delete()admits the built-in delete command: the in-flight dispatch finishes, then one atomic row delete, re-run on the next activation if the crash came first.- A
persistent_outbox()holds the item across those returns:dispatch(item)records it and bumpsattemptfor the same item,ack()clears it. An unacked outbox re-yields on the next loop ofitems(), at once and in the same drain. Pace it withawait self.domain.sleep(...)in the handler. ctx.attemptscounts crash redeliveries;outbox.attemptcounts in-handler retries. Cap those in the handler:ack()the entry and record the failure.
Effects
-
The frontier (
self.frontier) is the instance's ordered log of durable effects. Storage writes and enlisted awaitables land on it in enlist order; enlisting is the append. -
Adjacent writes coalesce into one batch on a store with
apply_batch(a tray's writes compress to a single row upsert); without it, one write per row. An opaque effect, an awaitable the log only awaits, ends the batch in progress. -
A
setwhose value matches the last known value enlists nothing. An@inboxbody's writes flush as one batch. -
A flush failure poisons the log and aborts the instance. Every later enlist or wait raises
RuntimeErrorchained from the failure. Memory never runs ahead of the store. -
Tray.drain_on_eviction = Truekeeps admission handlers running during wind-down; their tracked work finishes before release. Statewire uses this for authenticated command intake, which finishes before deadline cancellation reaches command tails. Execution trays pause new dispatches by default.
Keyed trays
@tray.map(C)reaches instances by id:get_or_create(id)creates on first use,get(id)raisesKeyErrorfor a never-created id,delete(id)opens even a passivated instance and frees the id.- A keyed instance reads its own key as
ctx.tray.id; the instance object isctx.tray. always_open=Trueopens every stored instance of that type at activation and keeps it open. The default opens marked rows only.
Background work and lifecycle
self.create_task(coro) runs instance-owned work.
| Event | Effect |
|---|---|
| task running | idle eviction is blocked; a poll loop (while True: await self.domain.sleep(60); ...) keeps the instance alive |
| plain close | waits for the task |
wind-down deadline or detached_timeout | aborts anyway, cancelling the task at its await |
| task raises | logged; the instance keeps running |
self.abort() | cancels in-flight requests and background tasks, then runs teardown; cancelled requests answer 503 {"detail": "instance aborted"} |
self.lease(InstanceLease) exposeshealthy,expires_at,winding_down, andeviction_deadline.self.lease.wind_down(deadline=None)requests cooperative eviction. A finite, nonnegative deadline cancels remaining execution after that many seconds;0cancels immediately. Repeated calls can tighten the local deadline.await self.eviction_checkpoint()raisespinned.EvictionRequestedwhile winding down. Call it at safe task boundaries; cleanup runs before placement release.configure(on_idle=, on_wind_down=, on_unhealthy=, on_restored=, buffer=)installs lifecycle callbacks.on_idlecontrols ordinary idle retention; wind-down evicts idle instances immediately.on_wind_down(deadline)fires when the effective deadline or wind-down state changes. Checkself.lease.winding_downto distinguish cooperative wind-down from revocation whendeadlineisNone.self.domainsuppliesnow/monotonic/sleep/wait/spawn/rng/token_hex. Use it overtime/asyncioso the instance runs under simulation.
Calling other instances
self.cluster is an httpx.AsyncClient addressing instances by data-plane path.
await self.cluster.post(f"/threads/{target}/accept")reaches/<ns>/<id>/....- A
POSTto an unplaced id places and wakes it. - Targets on any host attached to the caller's
PinnedWorker(its own namespace or a sibling's) dispatch in-process, the caller attested. Others go through pinboard, which verifies the caller and stampscaller/depthclaims into thePinboard-Attestationtoken. An attested call reaches only worlds the caller's own registration covers (instance to instance). - On the receiving side,
attestation(conn)names the calling instance, a passport-verified principal, orNone.
match attestation(request):
case Attestation.Instance("/threads", id): ...
case _: raise HTTPException(status_code=403)Where next
- Every option and signature: trays and durability, pinned reference.
- Stores, fencing, what survives what: Persist across restarts.
- Placement, leases, epochs: durable objects.