Signatures quoted from source. Usage: Author a PinnedAPI, Persist across restarts. Related: pinned reference.
Entry points
| Import from | Exports |
|---|---|
pinned.durability | StateStore, MemoryStateStore, FenceLost, InstanceDurability, Frontier, Receipt |
pinned.trays | Tray, TrayStorage, tray, tray.map, TrayDescriptor, TrayNamespace, TrayMapNamespace, TrayCtx, inbox, persistent, persistent_queue, persistent_outbox |
pinned.durability.postgres | PostgresStateStore |
pinned.durability
StateStore
class StateStore(Protocol):
apply_batch: Callable[[str, str, list[tuple], int], Awaitable[None]] | None # read-only property
async def set_tray(self, ns: str, id: str, tray_ns: str, tray_id: str, marked: bool, doc: dict, epoch: int) -> None
async def delete_tray(self, ns: str, id: str, tray_ns: str, tray_id: str, epoch: int) -> None
async def get_tray(self, ns: str, id: str, tray_ns: str, tray_id: str, epoch: int) -> tuple[bool, dict] | None
async def list(self, ns: str, id: str, epoch: int, *, tray_ns: str | None = None, marked: bool | None = None) -> list[tuple[str, str, bool, dict]]Durable instance state as one row per tray. Every op carries the caller's epoch and acquires the instance atomically: at or above the stored epoch advances it and executes, below raises FenceLost. An instance with no stored epoch is at 0.
StateStore.* | Shape |
|---|---|
apply_batch(ns, id, ops, epoch) | optional fast path: one atomic ordered batch of ("tray", tray_ns, tray_id, marked, doc) ops, marked=None deletes, an unknown op kind raises |
set_tray, delete_tray, get_tray | idempotent whole-row upsert and removal; point read as (marked, doc), None when absent |
list | rows as (tray_ns, tray_id, marked, doc); the tray_ns and marked filters AND together, store-side; a filter of the wrong type raises TypeError |
MemoryStateStore
class MemoryStateStore: # pinned.durability
epochs: dict[tuple[str, str], int]; trays: dict[tuple[str, str, str, str], tuple[bool, dict]]; ops: intIn-memory StateStore (deep-copied docs). set_tray raises TypeError on a non-bool mark and ValueError on a non-dict doc.
FenceLost
class FenceLost(RuntimeError):
def __init__(self, key: str, epoch: int) -> None
key: str; epoch: intA newer activation owns the record; the write is permanently fenced and the host aborts the instance.
InstanceDurability
class InstanceDurability:
def __init__(self, store: StateStore, ns: str, id: str, epoch: int, domain: Domain, *, dispatch_slots: asyncio.Semaphore | None = None) -> None
store; ns; id; epoch; domain; dispatch_slots
frontier: Frontier | None # set by PinnedContextBuilt by the host at activation with the placement epoch and the worker's dispatch_slots.
Frontier, Receipt
class Frontier:
def __init__(self, durability: InstanceDurability, abort: Callable[[], None], spawn: Callable[[Coroutine], asyncio.Task]) -> None
def write_tray(self, tray_ns: str, tray_id: str, marked: bool | None, doc: dict | None) -> int
def enlist(self, effect: Awaitable[Any]) -> int
def receipt(self) -> Receipt
async def wait(self, position: int | None = None) -> None
def close(self) -> None
class Receipt:
def __init__(self, frontier: Frontier, position: int) -> None
def __await__(self)Instance-wide ordered log of durable effects, flushed in enlist order. Receipt awaits as frontier.wait(position); dropping it is fire-and-forget. Batch coalescing and flush-failure poisoning: Effects.
Frontier methods
| Name | Type | Default | Meaning |
|---|---|---|---|
write_tray | (tray_ns: str, tray_id: str, marked: bool | None, doc: dict | None) -> int | — | one row write, marked=None deletes; returns the log position. ValueError on a delete carrying a doc, TypeError on a non-bool mark or non-dict doc |
enlist | (effect: Awaitable[Any]) -> int | — | opaque effect; returns the log position |
receipt | () -> Receipt | — | Receipt covering everything enlisted so far |
wait | (position: int | None = None) -> None | None | resolves once every effect at or before position is durable; None is everything enlisted so far |
close | () -> None | — | disposes unflushed opaque effects at teardown |
pinned.trays
Tray
class Tray:
version = 0; max_attempts = 10; dispatch_timeout = 12 * 3600.0 # class attributes, table below
id: str; storage: TrayStorage; handler: Callable[..., Coroutine]
parked: bool # property
def items(self) -> Generator[Any, None, None]; async def lifecycle(self) -> AsyncIterator[None]
def kick(self) -> None; def unpark(self) -> None; def cancel_dispatch(self) -> None
def close(self) -> asyncio.Task[None]; def delete(self) -> asyncio.Task[None]Subclass it, declare durable fields at class level (cloned per instance, hydrated in dependency order) and implement items(); the pump drives it with an at-least-once seam. Errors, parking, and redelivery: Dispatch.
| Name | Type | Default | Meaning |
|---|---|---|---|
version | int | 0 | field schema version: a stored row of another version activates blank and is rewritten on the first write; a marked stale row drops its mark durably at activation |
max_attempts | int | 10 | deliveries of one dispatch before the tray parks; a non-positive value is TypeError at class definition, as is a subclass without items() |
dispatch_timeout | float | 12 * 3600.0 | seconds per handler invocation, exceeding it aborts the instance; non-positive is TypeError at class definition |
Tray.* | Shape |
|---|---|
items(), lifecycle() | yields work items, the code after each yield being the durable ack, re-created each drain; yields exactly once around the tray's open lifetime |
kick(), unpark() | marks the row and starts the pump; clears the park and delivery count so the head item redelivers fresh |
close(), delete() | passivates (teardown once the pump exits, row kept, next access re-hydrates), RuntimeError on an always-open tray (@tray, or @tray.map(..., always_open=True)); admits the built-in delete command, the task resolving once the id is free; await either task from outside the pump |
cancel_dispatch() | consumes the in-flight dispatch (ctx.cancelled set, task cancelled), never redispatched; no-op when idle |
TrayStorage
class TrayStorage:
marked: bool # property
def get(self, name: str, default: Any = ABSENT) -> Any; def set(self, name: str, value: Any) -> None; def delete(self, name: str) -> None
def set_marked(self, marked: bool) -> NoneOne tray's durable row: the marked flag plus a doc with one entry per field name. Every mutation enlists the whole row on the instance frontier in call order. An unchanged value or mark enlists nothing. A stored None stays distinct from an absent entry. RuntimeError on a write to a retired tray. ValueError on a name that is not a non-empty string or starts with ~. TypeError on a non-bool mark.
tray, tray.map
def tray(tray_cls: type[Tray]) -> Callable[[handler], TrayDescriptor]
def tray.map(tray_cls: type[Tray], *, always_open: bool = False) -> Callable[[handler], TrayDescriptor]@tray(C) declares a singleton tray (always open; await self.<name>.get() is the one instance). @tray.map(C) declares a keyed tray type reached by id. The decorated async def method is the handler, called (item, ctx). The tray name is its row's tray_ns; pinned is reserved and raises ValueError.
TrayDescriptor
class TrayDescriptor:
def __init__(self, tray_cls: type[Tray], handler, *, always_open: bool) -> None
tray_cls; handler; name: str; always_open: bool
def on_activate(self, fn: Callable[..., AsyncIterator[None]]) -> Callable[..., AsyncIterator[None]]
def peek(self, obj: Any) -> Any # the namespace already created on obj, else NoneAt class level the declared tray type. On an API instance it resolves to the namespace. on_activate registers a generator hook (api, id, tray): mount before the yield, teardown after (RuntimeError unless it yields exactly once). peek returns the namespace already created on obj. TypeError when tray_cls is not a Tray subclass, tray_cls.version is not an int, the handler is not async def, or always_open is not a bool.
TrayNamespace, TrayMapNamespace
class TrayNamespace: # @tray
async def get(self) -> Tray
async def close(self) -> None
class TrayMapNamespace: # @tray.map
instances: dict[str, Tray] # property, a copy
def retiring(self, id: str) -> bool
async def get_or_create(self, id: str) -> Tray; async def get(self, id: str) -> Tray
async def delete(self, id: str) -> None; async def close(self) -> Noneget() creates the singleton on first access. get(id) raises KeyError for a never-created id. delete(id) opens a passivated tray first and resolves once the id is free. retiring(id) is True while a prior instance's retirement drains. close() stops pumps, runs teardowns and drains flushes. RuntimeError on access without PinnedWorker(state_store=...) or after close().
TrayCtx
class TrayCtx:
tray: Tray; attempts: int; state: Any; cancelled: asyncio.Event; consumed: bool
def enlist(self, effect: Awaitable[Any]) -> None
def set_state(self, value: Any) -> None
async def dispatch(self, key: Any, target: Callable[..., Any], *args: Any) -> str # "admitted" | "absorbed"Per-dispatch surface handed to the handler; all three methods raise RuntimeError after the dispatch settled.
TrayCtx.* | Shape |
|---|---|
attempts, state | durable delivery count of this dispatch, this one included; the dispatch's durable state, None on first delivery |
cancelled, consumed | cooperative cancel signal; set by cancel_dispatch() |
enlist(effect), set_state(value) | appends an opaque durable effect to the instance log, awaited after every effect before it; durably records the dispatch state at its log position, redelivered with the dispatch and deleted at the ack |
dispatch(key, target, *args) | keyed admission into a bound @inbox method, exactly once per key across redeliveries: "admitted" when its appends landed, "absorbed" when the key was already admitted; TypeError on a non-@inbox target or a non-plain key; RuntimeError when the key was recorded against another tray or the target already has an admission running |
inbox
def inbox(fn: Callable[..., Any]) -> Callable[..., Receipt]Marks a sync tray method whose body runs as one atomic admission segment. The call returns the Receipt for it. TypeError on an async def. RuntimeError when called on a retired tray. Under a keyed admission, queue appends and extend take dict items only (TypeError), reject a ~admit key (ValueError), and insert raises RuntimeError.
Tray storage
persistent, persistent_queue, persistent_outbox
def persistent(initial: Any = None) -> Persistent
def persistent_queue() -> PersistentQueue
def persistent_outbox() -> PersistentOutbox
class Persistent(Observable):
def __init__(self, initial: Any = None) -> None
value: Any # assign and read
class PersistentQueue(Observable):
def __init__(self, items: Any = ()) -> None
value: list
def append(self, item: Any) -> None
def extend(self, items: Any) -> None
def insert(self, index: int, item: Any) -> None
def peek(self) -> Any
def popleft(self) -> Any
async def take(self) -> AsyncIterator[Any]
def take_now(self, count: int | None = None) -> list[Any]
def untake(self, count: int) -> None
class PersistentOutbox(Observable):
def __init__(self, initial: Any = None) -> None
value: {"item": Any, "attempt": int} | None
attempt: int # property; 0 when empty
def dispatch(self, item: Any) -> int
def ack(self) -> NoneDurable fields declared at Tray class level, each one doc entry. A value equal to the declared initial, an empty queue, or an empty outbox occupies no storage. Doc values are wire values (bool, None, str, finite float, int within ±(2**53 - 1), aware datetime, list/tuple, dict with str keys). An unsupported type or a non-str key raises TypeError at write, a non-finite float, an out-of-range int, or a naive datetime a ValueError. Reserved entries: ~inbox, ~state, ~v, ~attempts, ~parked, ~dispatch, ~ignore.
PersistentQueue.* | Shape |
|---|---|
| entry | the plain pending list, or {"items": [...], "taken": [...]} while a take is pending (each list only when non-empty) |
peek(), popleft(), insert(index, item) | the head as stored (a mounted node; value[0] is the plain copy) and its pop, IndexError when empty; head or tail inserts only, RuntimeError on a middle insert or a head insert into a non-empty queue while a take is pending |
take(), take_now(count=None), untake(count) | async-iterates entries as they arrive inside a dispatch, each moving durably to taken and popping for good at the dispatch ack (a crash before the ack re-presents it); pops count entries (default: all) as the same deferred takes; returns the newest count taken entries to the head; ValueError on counts beyond what is available |
PersistentOutbox.dispatch bumps attempt when the item equals the current one and resets it to 1 otherwise. ack() on an empty outbox raises RuntimeError.
observable, Observable
Re-exports of statepatch's observable and Observable. Op is (kind, path, value) with kind in "replace" | "add" | "remove". Wrapping an Observable in another raises TypeError.
pinned.durability.postgres
PostgresStateStore
class PostgresStateStore:
def __init__(self, conn: AsyncConnection[Any] | None = None, *, dsn: str | None = None, pool_size: int = 8, env: str = "*", hostname: str = "*", configure_transaction: Callable[[AsyncConnection[Any]], Awaitable[None]] | None = None) -> None
async def setup(self) -> NoneStateStore on Postgres: every op runs in its own transaction holding the instance's pinned_epoch row lock (SELECT ... FOR UPDATE) and writes that row only when the op's epoch is newer. Ops on one (ns, id) stay ordered, different instances proceed in parallel. A dsn opens a lazy pool of pool_size connections, a conn is a pool of one. env/hostname stamp every row with the worker's placement identity for pinboard's tray index. "*" matches single-world mode.
configure_transaction(conn)runs after transaction-local timeouts and before epoch acquisition on every attempt, including retries.setup()invokes the hook before creating tables.- Hook failures use the transaction's rollback, deadline, and retry handling.
setup()rolls back hook failures. configure_transactionmust be callable orNone.
setup() creates pinned_epoch (ns, id, epoch) and pinned_tray_index (ns, id, tray_ns, tray_id, marked, doc, env, hostname). TypeError when env or hostname is not a string or pool_size not an int. ValueError unless exactly one of conn/dsn is given or when pool_size < 1. Importing without the extra raises ImportError (pip install 'pinned[postgres]').