Skip to content

Adapters and capabilities

rhizodat.core.adapter

Store adapters as plug-ins: what a store must declare to join rhizodat.

The core knows no store. An Adapter names its kind, the DSN schemes it accepts and how to open a store (and, if the store is a shared ACID one, the journal). rhizodat's own adapters register exactly like a third-party package, which declares one entry point::

[project.entry-points."rhizodat.adapters"]
mysql = "rhizodat_mysql:ADAPTER"

or calls register_adapter itself. Adapters are loaded on first use; a plug-in that fails to load is skipped and reported when a DSN needs a missing scheme.

What a store can do beyond Store is a capability protocol here (ReadOnlyQueries, AppliedLocation, Fenced, Migrates, Loads, LoadsGraphs, Vectors), checked with isinstance.

Adapter dataclass

A store kind as rhizodat sees it.

  • open_store(name, dsn): the Store; None for a journal-only kind;
  • open_journal(dsn, wall_ms): the journal, only for a shared ACID store;
  • journal_location(dsn): where a journal at dsn lives, compared with the fence_location of Fenced stores;
  • query_help: one line on the store's native query language, for clients such as rhizodat-MCP.

Scheme dataclass

One DSN scheme and its rules: host and path required, optional or forbidden ("none"), the port used when a host has none.

ReadOnlyQueries

Bases: Protocol

Native reads the store itself refuses to turn into writes.

AppliedLocation

Bases: Protocol

Where the store's dat_applied markers live. Two stores in one place would block or confuse each other, so Rhizodat refuses them.

Fenced

Bases: Protocol

While a branch of this store is prepared, nothing else commits in fence_location (drevo's prepared fence), so the journal must live elsewhere.

Migrates

Bases: Protocol

Runs schema migration steps of the kinds it names (sql, cypher, ...).

A step must be safe to run again (IF NOT EXISTS, a check before a change): a migration over several stores is not atomic, and after a failure the whole migration runs again.

Loads

Bases: Protocol

Imports files: tables from portable columns, and rows loaded as one unit.

The table is created and widened only through migration steps (the store's Migrates kinds), so every schema change is recorded. load takes pyarrow record batches whose columns are a subset of the table's (left-out columns are null) and makes all of them visible at once, or none: a failure halfway, the source's or the store's, leaves the table as it was. replace swaps the table's contents for the batches, as one unit too.

table_columns

table_columns(table: str) -> list[str] | None

The table's column names in order; None if there is no such table.

create_table_steps

create_table_steps(
    table: str,
    columns: Sequence[Column],
    *,
    order_by: Sequence[str] = (),
) -> list[Step]

Idempotent migration steps creating the table; order_by is the sort key for stores that have one (others ignore it).

add_column_steps

add_column_steps(
    table: str, columns: Sequence[Column]
) -> list[Step]

Idempotent migration steps adding nullable columns.

load

load(
    table: str,
    batches: Iterable[Any],
    *,
    replace: bool = False,
) -> int

Load the batches as one unit; the number of rows loaded.

LoadsGraphs

Bases: Protocol

Imports graphs: nodes keyed by their IRI and typed edges, written as one unit.

Every node gets the base label Resource and its classes as further labels; a node or edge that is already there is updated, not duplicated, so a second load of the same file changes only what changed. A failure halfway writes nothing.

load_graph

load_graph(
    graph: GraphData, *, batch: int = 500
) -> tuple[int, int]

Write graph; the numbers of nodes and edges written.

Vectors

Bases: Protocol

Holds embeddings beside the nodes they describe and finds the nearest ones.

A vector is stored in a node property, with the model that made it and a hash of the text it was made from (<property>_model, <property>_hash), so an embedding run can skip what has not changed.

vector_candidates

vector_candidates(
    label: str, text_property: str, vector_property: str
) -> list[VectorCandidate]

Every label node with text in text_property.

write_vectors

write_vectors(
    label: str,
    vector_property: str,
    rows: list[dict[str, Any]],
    *,
    batch: int = 500,
) -> int

Write {key, vector, hash, model} rows as one unit; the number written.

nearest

nearest(
    label: str,
    vector_property: str,
    vector: list[float],
    *,
    k: int = 10,
) -> list[Neighbour]

The k label nodes closest to vector by cosine, closest first.

register_adapter

register_adapter(adapter: Adapter) -> None

Register a store kind in this process (entry points do it on installation).

adapter_for

adapter_for(kind: str) -> Adapter

adapters

adapters() -> tuple[Adapter, ...]

Every registered adapter, rhizodat's own first.

rhizodat.core.parts

Entity parts: what a store keeps of an entity, and how the repository asks for it.

An entity's fields are split by store (@entity(stores=...)); each store's share is a part. A store keeps every version of a part: one per write, tagged with the meta-transaction (dat_txn) and its transaction time (dat_tx_time, HLC). A soft delete is one more version marked deleted; a hard delete erases every version.

Stores join by implementing two capabilities, checked with isinstance:

  • PartBranch on a branch: writes inside a Unit of Work, and reads that see them. dat_tx_time is only known at stage, so the branch fills it (and a missing creation time) then;
  • PartStore on a store: committed reads outside any Unit of Work;
  • ScansParts on a store: every entity's committed versions at once, for searches over all entities of a type (similar).

Values reach the store as plain data: references are their dat_id.

PartStore

Bases: Protocol

install_part

install_part(part: Part) -> None

Create what the part needs (table, indexes) if missing. Idempotent.

read_part

read_part(
    part: Part, dat_id: UUID, *, known_at: int | None = None
) -> Version | None

The latest committed version with dat_tx_time <= known_at (any, if None).

part_history

part_history(part: Part, dat_id: UUID) -> list[Version]

Every committed version, oldest first.

expired_parts

expired_parts(
    part: Part, *, deleted_before: int, limit: int
) -> list[uuid.UUID]

Entities whose latest version is a soft delete older than deleted_before.

PartBranch

Bases: Protocol

write_part

write_part(
    part: Part,
    dat_id: UUID,
    values: Mapping[str, Any],
    *,
    created: int | None,
    deleted: bool = False,
) -> None

Add a version. created=None: this version is the first (its own time).

erase_part

erase_part(part: Part, dat_id: UUID) -> None

Physically remove every version of the part.

read_part

read_part(part: Part, dat_id: UUID) -> Version | None

The latest version, this branch's own writes included.

Part dataclass

One store's share of one entity type.

types class-attribute instance-attribute

types: Mapping[str, Any] = field(
    default_factory=dict, compare=False
)

Each field's resolved type; object when unknown.

table property

table: str

A storage name derived from the type name: tracker.Issue → tracker_issue.

Version dataclass

One stored version of one part.

dat_tx_time instance-attribute

dat_tx_time: int | None

HLC; None for a write of the reading branch that is not staged yet.

created instance-attribute

created: int | None

Transaction time of the entity's first version.

rhizodat.core.errors

What a store's error means: worth a retry, a full store, a constraint, or a bug.

Adapters classify their own errors: a branch or participant may implement classify(error) -> ErrorKind (Classifies). One that only has is_retryable is classified by it (TRANSIENT or PERMANENT), and any error caused by a device out of space (ENOSPC, EDQUOT) is STORE_FULL whoever raised it.

A full store fails the meta-transaction with StoreFull (rhizodat.core.coordinator), an Aborted that names the store. Before the decision that is all it takes: nothing is committed. A store written after the decision (ClickHouse, Iceberg) can only fail before it if it is asked first: a store with Capacity reports its free space, and with min_free set the coordinator refuses a meta-transaction touching a store below it, before anything is written.

ErrorKind

Bases: Enum

TRANSIENT class-attribute instance-attribute

TRANSIENT = 'transient'

Re-running the Unit of Work may succeed: a conflict, a timeout, a lost connection.

STORE_FULL class-attribute instance-attribute

STORE_FULL = 'store_full'

The store has no room left: disk, quota or memory for the write.

CONSTRAINT class-attribute instance-attribute

CONSTRAINT = 'constraint'

The data breaks a rule of the store: unique key, foreign key, check.

PERMANENT class-attribute instance-attribute

PERMANENT = 'permanent'

Anything else: re-running cannot help.

Capacity

Bases: Protocol

A store that can tell how much room it has left.

free_space

free_space() -> int | None

Bytes the store can still write, None when it cannot tell.

no_space

no_space(error: BaseException) -> bool

Whether error or anything that caused it is a device out of space.

classify

classify(
    source: _Retryable, error: BaseException
) -> ErrorKind

source.classify(error) (a branch or participant), else by is_retryable.