Skip to content

Development

compose.yaml runs the stores the tests need (ports on 127.0.0.1 only, data in tmpfs, so every up starts empty):

docker compose up -d --wait   # PostgreSQL (2PC enabled) and a disposable drevo
docker compose --profile '*' up -d --wait   # every store, plus toxiproxy
uv sync --group drevo         # optional: embedded drevo-py from the pinned commit (Rust)
uv sync --group native        # optional: the core in Rust (crates/rhizodat-py, maturin)
uv run pytest
docker compose down

The tests read store DSNs from the environment, else from .env.test. A DSN from .env.test whose store is not running skips that store's tests with a hint; a DSN set in the environment is used as is, so a store that is down fails the run (CI exports .env.test for that reason). Point the tests at other stores with RHIZODAT_TEST_PG_DSN, RHIZODAT_TEST_DREVO_BOLT, RHIZODAT_TEST_CLICKHOUSE_DSN, RHIZODAT_TEST_ICEBERG_REST_DSN and RHIZODAT_TEST_TOXIPROXY. The drevo server must be disposable: 2PC raises the prepared fence and blocks other writers.

tests/scenarios/ holds the five end-to-end scenarios of the roadmap (cloud operations, a bug tracker, an ERP, an IT task manager, a story editor), each run against the compose stores on the Python facade and on the native one; they are the reference for how an application uses the package.

uv sync                  # dev environment
uv run ruff check .      # lint
uv run ruff format .     # format
uv run mypy              # types (strict)

The Rust core is a Cargo workspace at the repository root (stable Rust, MSRV 1.94); its tests include the vectors in conformance/, and the adapter crates' tests use the compose PostgreSQL, drevo, ClickHouse and Iceberg REST catalog (skipped with a hint when a store is not running, unless its RHIZODAT_TEST_* variable is set):

cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check

crates/rhizodat-py is the Python module rhizodat_native (PyO3): UUIDv7, the hybrid logical clock, gids and migration files from the Rust core. uv sync --group native builds and installs it (uv sync alone removes it again: list both groups, e.g. --group drevo --group native); the package then makes new_id with it, and RHIZODAT_NATIVE=0 turns it off. tests/conformance/test_native_vectors.py runs the vectors through it.

It also exposes the commit protocol: rhizodat_native.Coordinator(journal) runs a Unit of Work's branches (any Python objects with the branch methods) through the core on a single-threaded executor, calling back into each object along the way and raising the same AbortedError / InDoubtError / InvalidUnitOfWork as the Python reference. The core runs with the GIL released and each callback re-takes it for its one call. tests/core/test_native_coordinator.py drives it with fake branches.

A branch may also be a native store — rhizodat_native.PostgresStore(dsn, name, schema) with rhizodat_native.PostgresJournal(dsn, schema), or rhizodat_native.DrevoStore(dsn, name) over Bolt with rhizodat_native.DrevoJournal(dsn, label), or rhizodat_native.ClickHouseStore(dsn, name) or rhizodat_native.IcebergStore(dsn, name) over a REST catalog (both one-phase, applied after the decision; no journal of their own) — whose Rust branch, participant and journal (crates/rhizodat-postgres, crates/rhizodat-drevo, crates/rhizodat-clickhouse, crates/rhizodat-iceberg) run the whole protocol against the real store with no Python callbacks on the path. A Unit of Work may mix native stores (of different kinds) and Python branch objects; a meta-transaction over several native stores commits through one of the native journals. A commit with any native store runs on a current-thread tokio runtime (so it stays single-threaded, like the callback path) and needs the compose stores. tests/core/test_native_postgres.py, tests/core/test_native_drevo.py, tests/core/test_native_clickhouse.py and tests/core/test_native_iceberg.py drive it.

A Unit of Work's own data reaches a store through store.unit(). For a ClickHouse or a PostgreSQL store it is tabular — buffer rows with unit.insert(table, columns, rows) (the core's row format, so no Arrow is needed); the target table carries the user columns plus dat_txn and dat_tx_time, which the commit stamps onto every row. A ClickHouse unit is one-phase (its rows travel in the journal, applied after the decision) and is spent by its commit; a PostgreSQL unit writes its rows inside the branch's two-phase transaction and may be reused across a retry. PostgreSQL rows carry scalars, decimals, bytes and temporal values (bound as $n::text::<column type>, so the server parses each per its own column); lists and maps are not carried yet.

A drevo store is a graph, so its unit buffers parameterized Cypher instead: unit.run(cypher, params). The statements run inside the branch's two-phase transaction, so they commit atomically with the meta-transaction; each gets dat_txn and dat_tx_time parameters injected, so a statement may stamp its nodes with $dat_txn (those names are reserved). A drevo unit is reusable. Hand any unit to commit like a store.

An Iceberg store takes Arrow: unit.append(table, data) with any object that has __arrow_c_stream__ (a pyarrow Table or RecordBatchReader, a polars frame) or the bytes of an Arrow IPC stream, for a namespace.table. Columns are matched by name and cast to the table's types at stage, which writes Parquet files; the participant appends them in one snapshot tagged with the meta-transaction after the decision. The data crosses as an IPC stream (pyarrow writes it: one copy) — reading the C stream directly would need unsafe, which the workspace forbids. The store connects to the catalog inside each commit, so building it does no I/O. An Iceberg unit is reusable; each commit appends its batches anew.

The rhizodat.Rhizodat facade runs on this native core, opt-in: build it with native store objects and a native journal (or none) —

import rhizodat_native as native
from rhizodat import Rhizodat

r = Rhizodat(
    stores={"pg": native.PostgresStore(dsn, "pg", schema)},
    journal=native.PostgresJournal(dsn, journal_schema),  # or None for one store
)

@r.transactional
def write(uow):
    uow.store("pg").insert("people", ["name"], [["ada"]])

and its Units of Work commit through rhizodat_native.Coordinator instead of the Python coordinator, with no Python callbacks on the path. A Unit of Work buffers its data on each store's native unit (uow.store(name).insert(...) for a tabular store, uow.store(name).run(cypher, params) for drevo, uow.store(name).append(table, arrow_data) for Iceberg), and transactional's retry loop drives it unchanged. Every store must be a native store object and the journal a native journal or none (mixing native and Python stores, or a DSN-string journal, is refused). r.recovery(...) runs the core's recovery worker (rhizodat_native.Recovery(journal, stores)) with the same settings and RecoveryReport as the Python one: stale undecided records are presumed aborted and rolled back, committed ones finished and marked DONE, prepared gids without a decision rolled back; a participant that still fails is reported (unfinished, then stuck) and retried by a later run. tests/core/test_native_recovery.py crashes a child process (os._exit) before and after the decision with PostgreSQL prepared, then recovers it. r.repository(T) and uow.repository(T) run the Python facade's repository logic with their parts on the core (rhizodat.core.native_parts): every native store reads committed versions, its unit buffers part writes that the commit applies inside the meta-transaction (ClickHouse and Iceberg: in the journal payload, after the decision; an Iceberg erase copy-on-write through the REST catalog). The layout is the Python adapters', so either facade reads the other's versions (tests/core/test_native_parts*.py check both ways). r.store(name).execute(...) on any native store runs on the core and answers with the facade's Result (rhizodat.core.native_result, tests/core/test_native_execute.py): ClickHouse (native SQL with {name:Type} parameters) and Iceberg (a scan with selected_fields, snapshot_id, limit) send the full result as one Arrow IPC stream, so to_arrow() is the stream itself; PostgreSQL (native SQL with $1..$n parameters, bound by position and checked against the statement's parameter types; numeric and other undecoded column types are refused with the col::text cast to use) and drevo (Cypher with named parameters) answer with rows. r.migrate(...) and r.schema_versions() run on the native facade too (tests/core/test_native_migrations.py): the native PostgresJournal keeps the history in the Python adapter's <table>_migrations table (either facade continues the other's history) and MemoryJournal in memory; the native PostgreSQL store runs sql steps in one transaction, ClickHouse one statement per request, drevo cypher steps in their own transactions, and the native Iceberg store runs iceberg steps through the Python adapter's declarative operations (iceberg-rust 0.10 has no column rename or partition update). Iceberg scans take row_filter in pyiceberg's syntax too (rhizodat_iceberg::filter), so the native facade now covers the Python facade's whole surface except what each adapter documents as its own.