Skip to content

Adapters

The interfaces a host supplies, and the reference implementations that ship with the package. These live outside the core modules deliberately: they are hosts, not library internals.

The interfaces a host supplies.

The library owns the order of the durability contract. It never owns the mechanism. Storage, transport and the clock are all supplied by the caller through the structural protocols below, which is what keeps this package free of sockets, TLS policy, retry loops and filesystem assumptions -- exactly as the Odin core keeps them out of src/.

None of these are base classes. They are :class:typing.Protocol definitions, so any object with the right shape satisfies them without importing anything.

Journal

Bases: Protocol

Ordered, durable storage for the records a transition produces.

The contract is the one the core states: persist every record of a transition, in order, and make it durable before any message of that transition leaves. A journal that reordered or dropped a record could let a crash revert a promise a peer has already acted on.

open

open(*, node_id: NodeId, configuration_id: int) -> None

Bind the journal to one participant before anything is read or written.

The session calls this with its own identity, so a caller states it once. A durable journal uses it to refuse a file that belongs to another member or another configuration: adopting a foreign journal would import its promises as this node's own.

Parameters:

Name Type Description Default
node_id NodeId

The member this journal belongs to.

required
configuration_id int

The configuration it belongs to.

required

Raises:

Type Description
InvalidArgument

If existing storage names a different identity.

StorageError

If the storage cannot be claimed.

append

append(records: Sequence[WriteRecord]) -> None

Append records in order.

Parameters:

Name Type Description Default
records Sequence[WriteRecord]

The batch, in the order the core produced it.

required

Raises:

Type Description
StorageError

If the records could not be written.

sync

sync() -> None

Make every appended record durable.

Raises:

Type Description
StorageError

If durability could not be established. The caller must then treat persistence as uncertain and replay, never confirm.

replay

replay() -> Sequence[WriteRecord]

Return every durable record, in the order it was appended.

Returns:

Type Description
Sequence[WriteRecord]

The records needed to rebuild the node.

Raises:

Type Description
JournalCorrupt

If a complete record fails its checksum. That is corruption, not a truncated tail, and must stop recovery.

close

close() -> None

Release the journal's resources.

Transport

Bases: Protocol

Moves opaque frames between peers.

The adapter owns framing, connection management and, critically, authentication: a sender id inside a frame is a claim, not proof. It must preserve frame boundaries and bound the bytes it queues.

A failed send may still have reached the peer. That is safe here, because protocol retransmission tolerates duplicates -- but it means a send failure must never roll back a durable transition.

send

send(*, peer: NodeId, frame: bytes) -> None

Deliver one frame to one peer, best effort.

Parameters:

Name Type Description Default
peer NodeId

The recipient's identity.

required
frame bytes

The encoded envelope.

required

Raises:

Type Description
TransportError

If the frame could not be queued.

receive

receive(*, timeout: float) -> tuple[NodeId, bytes] | None

Wait for one frame.

Parameters:

Name Type Description Default
timeout float

Seconds to wait, on a monotonic clock.

required

Returns:

Type Description
tuple[NodeId, bytes] | None

The authenticated sender and the frame, or None on timeout.

close

close() -> None

Release the transport's resources.

Clock

Bases: Protocol

A monotonic time source.

Monotonic, not wall-clock: a timeout measured against a clock that can step backwards is not a timeout. The core itself reads no clock at all; this exists only so the session can give its logical ticks a duration.

monotonic

monotonic() -> float

Return a monotonically non-decreasing time in seconds.

sleep

sleep(seconds: float) -> None

Pause for approximately seconds.

Parameters:

Name Type Description Default
seconds float

How long to wait. Never negative.

required

History

Bases: Protocol

Durable storage for entries released past the node's memory window.

Advancing the memory floor transfers responsibility for those entries from the bounded node to the host. They must already be here when that happens, because the node will not hold them again and a catch-up peer may ask.

record

record(slot: Slot, payload: bytes, kind: int) -> None

Durably retain one released entry.

Parameters:

Name Type Description Default
slot Slot

Its position in the log.

required
payload bytes

Its bytes.

required
kind int

Its entry kind, so a no-op stays distinguishable from a command that carried no bytes.

required

read

read(
    first: Slot, count: int
) -> Sequence[tuple[Slot, bytes, int]]

Return retained entries for a catch-up peer.

Parameters:

Name Type Description Default
first Slot

The first slot wanted.

required
count int

How many to return at most.

required

Returns:

Type Description
Sequence[tuple[Slot, bytes, int]]

(slot, payload, kind) triples, in slot order.

cursor

cursor() -> Slot

Return the contiguous prefix this host has durably retained.

This is the application cursor, and it is deliberately separate from the node's released prefix. It is what a restart resumes from: a node told a floor it cannot actually serve would advertise history it has lost.

Returns:

Type Description
Slot

The last slot retained with no gap before it, or zero.

close

close() -> None

Release the history's resources.

Reference storage adapters.

These are hosts, not library internals. They live here the way examples/counter.odin and bench/durable.odin live outside the Odin src/: useful, replaceable, and deliberately not the only way to satisfy the :class:~paxodin.protocols.Journal contract.

:class:FileJournal is the durable one. :class:MemoryJournal is for tests and examples and says so loudly -- it survives nothing.

MemoryJournal

A journal that keeps records in memory.

Warning

This is not durable. It exists for tests and in-process examples. A process that exits loses everything it held, so a node backed by one can never be restarted -- use :class:FileJournal for anything real.

open

open(*, node_id: int, configuration_id: int) -> None

Accept any identity; there is no storage to bind it to.

Parameters:

Name Type Description Default
node_id int

Ignored.

required
configuration_id int

Ignored.

required

append

append(records: Sequence[WriteRecord]) -> None

Append records in order.

Parameters:

Name Type Description Default
records Sequence[WriteRecord]

The batch, in the order the core produced it.

required

sync

sync() -> None

Mark everything appended as "durable", which here means nothing.

replay

replay() -> Sequence[WriteRecord]

Return every record synced so far.

Returns:

Type Description
Sequence[WriteRecord]

The records, in append order. Anything appended but never synced is

Sequence[WriteRecord]

excluded, mirroring what a crash would have left behind.

close

close() -> None

Drop the records.

FileJournal

A durable, checksummed, append-only journal in one node directory.

The directory is locked exclusively for the journal's lifetime, so two processes cannot both believe they are the same node. The header binds the file to a node identity, a configuration and a capacity profile: opening it as a different node, or with a differently sized build, is refused rather than silently misread.

Recovery distinguishes two things that look alike. A trailing record that is demonstrably incomplete is a torn tail from a crash mid-write, and is discarded. A complete record whose checksum fails is corruption, and stops recovery with the offset -- because skipping it would silently drop a promise or a vote that a peer already acted on.

open

open(*, node_id: int, configuration_id: int) -> None

Claim the directory and validate or write the header.

Parameters:

Name Type Description Default
node_id int

This member's identity, bound into the header.

required
configuration_id int

The configuration, bound into the header.

required

Raises:

Type Description
StorageError

If the directory is already locked by another process.

InvalidArgument

If the header names a different node, configuration or capacity profile.

JournalCorrupt

If the header itself is unreadable.

append

append(records: Sequence[WriteRecord]) -> None

Append records in order.

Parameters:

Name Type Description Default
records Sequence[WriteRecord]

The batch, in the order the core produced it.

required

Raises:

Type Description
StorageError

If the write fails. Persistence is then uncertain and the caller must replay rather than confirm.

sync

sync() -> None

Flush and fsync, making every appended record durable.

Raises:

Type Description
StorageError

If durability could not be established.

replay

replay() -> Sequence[WriteRecord]

Return every intact record, in append order.

Returns:

Type Description
Sequence[WriteRecord]

The records needed to rebuild the node.

Raises:

Type Description
JournalCorrupt

If a complete record fails its checksum. The offset is reported; recovery stops rather than skipping it.

close

close() -> None

Sync, release the lock, and close the file. Safe before open.

MemoryHistory

Retained history kept in memory, for tests and in-process examples.

Warning

Not durable. A node backed by one cannot serve catch-up after a restart.

record

record(slot: Slot, payload: bytes, kind: int) -> None

Retain one released entry.

Parameters:

Name Type Description Default
slot Slot

Its position in the log.

required
payload bytes

Its bytes.

required
kind int

Its entry kind.

required

read

read(
    first: Slot, count: int
) -> Sequence[tuple[Slot, bytes, int]]

Return retained entries for a catch-up peer.

Parameters:

Name Type Description Default
first Slot

The first slot wanted.

required
count int

How many to return at most.

required

Returns:

Type Description
Sequence[tuple[Slot, bytes, int]]

(slot, payload, kind) triples, in slot order.

cursor

cursor() -> Slot

Return the contiguous prefix retained.

Returns:

Type Description
Slot

The last slot retained with no gap before it, or zero.

close

close() -> None

Drop the retained entries.

FileHistory

Durable retained history in one append-only file.

Advancing the node's memory floor hands an entry over to the host. This is where it lands, so a restarted member can still answer a catch-up peer for slots its bounded window let go.

V1 never trims this file. Bounded memory is not bounded disk, and saying so plainly is better than a compaction scheme with no trim-anchor contract behind it.

record

record(slot: Slot, payload: bytes, kind: int) -> None

Durably retain one released entry.

Parameters:

Name Type Description Default
slot Slot

Its position in the log.

required
payload bytes

Its bytes.

required
kind int

Its entry kind.

required

Raises:

Type Description
StorageError

If the write fails.

read

read(
    first: Slot, count: int
) -> Sequence[tuple[Slot, bytes, int]]

Return retained entries for a catch-up peer.

Parameters:

Name Type Description Default
first Slot

The first slot wanted.

required
count int

How many to return at most.

required

Returns:

Type Description
Sequence[tuple[Slot, bytes, int]]

(slot, payload, kind) triples, in slot order.

cursor

cursor() -> Slot

Return the contiguous prefix durably retained.

Returns:

Type Description
Slot

The last slot retained with no gap before it, or zero.

close

close() -> None

Close the history file.