Meta-transactions¶
A Unit of Work is one meta-transaction (dat_txn) with one branch per
store it touches. It is flat: one global transaction, branches underneath, no
nesting.
- One branch → the store's own transaction, one-phase commit. No journal.
- Several branches → the commit protocol below, coordinated through the journal.
Writes never have to cover a whole entity: updating only the ClickHouse part of
an Order is a meta-transaction with a single branch.
Commit protocol (several branches)¶
- Begin. Generate
dat_txn. - Work. Run statements in all branches, in parallel: PostgreSQL and drevo in open transactions, ClickHouse rows buffered, Iceberg data files written (invisible until a snapshot references them).
- Stage (slow, holds no locks). Assign
dat_tx_timefrom the HLC. Iceberg: finish data files and pre-check conflicts. ClickHouse: prepare the payload. Failures here are the cheapest to abort, so everything likely to fail is checked here. Then journal recordACTIVEwith every branch and its gid, plus staged payloads (e.g. the ClickHouse batch). - Prepare (fast, holds locks). PostgreSQL
PREPARE TRANSACTIONand drevoprepare(gid), last. Journal recordPREPAREDwithdat_tx_time. Any failure up to here → journalABORTED, roll everything back. - Decide. Journal
PREPARED → COMMITTED(compare-and-set). This is the atomic commit point. - Finish. First release locks:
COMMIT PREPARED/commit_prepared. Then the Iceberg snapshot commit and the ClickHouse insert withinsert_deduplication_token = dat_txn:branch. Every step is idempotent. - Done. Journal record
DONE.
The journal record is written once the branch list is known (after staging),
not at begin: before it exists nothing is prepared, so a crash leaves only open
transactions that die with their connections and unreferenced Iceberg files.
A branch's gid is dat:<dat_txn>:<store>; recovery recognises its own
prepared transactions by that prefix.
Ordering rule: slow, lock-free work before prepare; lock-holding branches prepare last and commit first. The window between prepare and commit must be as short as possible:
- A prepared PostgreSQL transaction holds its row locks until
COMMIT PREPARED. - A prepared drevo transaction raises the graph's prepared fence: while any
prepared transaction exists, every other commit to that graph fails fast with
a retryable
TransactionConflict(reads keep working). See drevodocs/rfc-two-phase-commit.md. - drevo's opt-in
DREVO_PREPARED_TX_TIMEOUT_SECSheuristically rolls back a transaction that stays prepared too long, overriding the coordinator. The coordinator must finish well inside that timeout, and treats a refused late commit as a heuristic outcome.
Order within step 6 does not affect correctness (the decision is already durable); it only shortens how long other writers are blocked.
Journal¶
- Required for meta-transactions with several branches:
journal=<dsn>pointing to an ACID store (PostgreSQL or drevo). Missing → a clear error, not a silent unsafe default. - Must be shared and durable: recovery may run on another host than the one that crashed.
- A local file (
journal="file:///…") is allowed only explicitly, for development and single-process embedded use. - A drevo journal keeps one node per meta-transaction (label
DatJournalby default,title=<label>:<dat_txn>for uniqueness); each compare-and-set is one drevo transaction, and drevo rejects the second of two concurrent writers. It must be a separate graph from every drevo store of the deployment: a prepared branch raises its graph's fence, which would block the coordinator's ownPREPARED/COMMITTEDwrites.Rhizodatrefuses such a configuration. - The journal is a coordination role, not a data master: it holds transaction states, not entity data.
States and the only legal transitions:
ACTIVE ──► PREPARED ──► COMMITTED ──► DONE
│ │
└───────────┴──► ABORTED
- Every change is a compare-and-set from the expected state. A coordinator
writing
COMMITTEDand a recovery worker writingABORTEDfor the samePREPAREDrecord cannot both succeed; the loser getsJournalConflict. PREPAREDcarries thedat_tx_timeassigned at stage.ABORTEDandDONEare final and may be compacted away.- Table layout, the same for every SQL journal:
| Column | Type | |
|---|---|---|
dat_txn |
UUID | primary key |
state |
text | one of the states above |
dat_tx_time |
bigint | HLC, set at PREPARED |
branches |
text | JSON list of {store, gid, two_phase, payload} (payload base64, e.g. a staged ClickHouse batch) |
created_ms, updated_ms |
bigint | coordinator wall clock, used only to find stale records |
Recovery¶
A recovery worker (any host, run periodically) first sweeps the prepared
transactions each store holds (list_prepared / pg_prepared_xacts): a
dat: gid whose journal record is missing or ABORTED is rolled back. Then it
handles journal records unchanged for stale_after_ms (default 30 s, well above
a normal commit, so it does not race live coordinators; the compare-and-set
settles it if it does):
| Journal state | Action |
|---|---|
ACTIVE or PREPARED (no decision), stale |
write ABORTED (compare-and-set), then roll back prepared branches (presumed abort) |
COMMITTED (not DONE) |
roll forward every branch, idempotently |
DONE or ABORTED |
nothing; eligible for journal compaction |
A decided transaction still unfinished after alert_after_ms (default 10 min)
is reported as stuck; it is never dropped.
Retries¶
Retries follow one line: before the decision a meta-transaction may be aborted and re-run; after the decision it may only be finished.
Before the decision (steps 1–4): retry the whole Unit of Work.
- A transient failure in any branch aborts every branch, and the Unit of Work
is re-run from the start with a new
dat_txnand a newdat_tx_time. Retrying a single statement is not enough: reads made earlier in the work may be stale. - So a retryable Unit of Work is a function, not a
withblock (see the API sketch). Awithblock runs once and fails on the first transient error. - Policy: maximum attempts, overall deadline, exponential backoff with full jitter. Jitter matters: drevo's prepared fence makes concurrent writers fail fast at the same moment, and without jitter they retry in lockstep.
| Store | Retryable (transient) | Not retryable |
|---|---|---|
| PostgreSQL | 40001 serialization failure, 40P01 deadlock, connection loss before prepare |
constraint violations, syntax / schema errors |
| drevo | TransactionConflict (commit conflict or prepared fence), Bolt TransientError, HTTP 503 |
constraint violations, Cypher errors |
| Iceberg | commit conflict found by the pre-check, catalog unavailable | schema mismatch, validation errors |
| ClickHouse | network errors, timeouts, too many parts | type / schema errors |
| rhizodat | journal unreachable before the decision (→ presumed abort) | invariant / reference validation failures |
Error kinds and a full store. Adapters classify their errors:
classify(error) -> ErrorKind on a branch and a participant, one of
TRANSIENT (worth re-running), STORE_FULL, CONSTRAINT, PERMANENT.
is_retryable stays, true only for TRANSIENT. An adapter without
classify is classified by is_retryable, and an error caused by a device out
of space (ENOSPC, EDQUOT) is STORE_FULL whoever raised it.
| Store | STORE_FULL |
CONSTRAINT |
free space (Capacity) |
|---|---|---|---|
| PostgreSQL | 53100 disk full |
class 23 (unique, foreign key, check, not null) |
not visible from SQL (none) |
| ClickHouse | code 243 NOT_ENOUGH_SPACE |
code 469 VIOLATED_CONSTRAINT |
least free_space of system.disks |
| Iceberg | ENOSPC writing a staged file |
— | a file:// warehouse's disk; object storage: none |
| drevo | ENOSPC (embedded) |
— | an embedded graph file's disk; memory, Bolt: none |
A full store fails the meta-transaction with StoreFull (an Aborted,
not retryable, naming the store): from a branch before the decision, or from
the work itself inside a transactional function. A store written after the
decision (ClickHouse, Iceberg) can only fail before it if asked first:
Rhizodat(min_free=<bytes>) makes the coordinator ask every touched store with
Capacity before anything is written and refuse with StoreFull (and the
reported free) when one is below. A store that cannot tell, or whose query
fails, passes. min_free is off by default: it costs one query per store and
commit. Once decided, a full post-decision store leaves the transaction
CommittedPending until recovery can finish it.
After the decision (steps 5–7): retry each branch until it is done, never abort.
- The coordinator retries in-process up to a short deadline, then hands the transaction to the recovery worker, which keeps retrying with backoff.
commit()then returnsCommittedPending(dat_txn), not an error. The transaction is committed; raising would make callers re-run it and write it twice.- If the
COMMITTEDwrite cannot be confirmed and the journal cannot be read back,commit()raisesInDoubt(dat_txn): the outcome is unknown, the Unit of Work must not be re-run, and recovery decides from the journal. - A transaction that stays unfinished past a threshold raises an alert; it is never silently dropped.
Ambiguous outcomes. A timeout on a commit call does not say whether it applied. The coordinator never guesses; it checks the store before retrying:
Every two-phase branch writes an applied marker inside its own transaction
at stage: a row (dat_txn, dat_tx_time) in the store's dat_applied table
(PostgreSQL) or the equivalent in drevo. "Did this branch commit?" is then one
key lookup, whatever tables the application wrote, and works for one-phase
commits too.
| Store | How "already applied" is detected |
|---|---|
| PostgreSQL | gid absent from pg_prepared_xacts and a dat_applied marker row for dat_txn exists → done; gid absent and no marker → heuristic outcome, reported, never "done" |
| drevo | commit_prepared on an unknown gid returns UnknownGid → already resolved |
| Iceberg | a snapshot whose summary carries dat.txn exists, or the current snapshot already references the staged files (the tagged snapshot may have been expired) → skip. Staged files are deleted on abort, never while a snapshot may reference them; after an ambiguous one-phase commit they are kept for orphan-file maintenance |
| ClickHouse | an applied marker (dat_txn, dat_tx_time) in dat_applied (ReplacingMergeTree), written after every batch. Before a batch the participant counts the table's rows with this dat_txn: none → insert, all → skip, some (a crash left part of an insert) → lightweight DELETE of them and insert again. Each insert also carries insert_deduplication_token = <gid>:<n> for replicated tables; correctness does not depend on the dedup window |
| Journal | a failed or timed-out COMMITTED write is read back; if the state cannot be read, the transaction is left to recovery, which decides from the journal once it is reachable |
One marker table per store. Two stores of one deployment must not share
where their markers live: the same PostgreSQL server, database and schema (both
branches would insert the same dat_txn into one dat_applied, and the second
would wait for the first forever), the same drevo graph (the first branch's
prepared fence refuses the second's prepare on every retry), or the same
ClickHouse database (one store's marker would make the other's branch look
applied). Rhizodat refuses such a configuration up front; give each store its
own schema, database or graph (for PostgreSQL also by DSN:
postgresql://…/erp?schema=orders). Stores report the place as
applied_location.
Guarantees¶
| What rhizodat guarantees | |
|---|---|
| A | All branches commit or none do. PostgreSQL and drevo commit atomically via 2PC; Iceberg and ClickHouse are rolled forward after the decision, so the outcome is atomic but may become visible later. |
| C | Invariants and cross-store references are validated in the Unit of Work before prepare; each store enforces its own constraints. |
| I | Native isolation inside each store. Across stores, readers that go through rhizodat filter by committed dat_txn and dat_tx_time, so they do not see half-applied meta-transactions. Direct reads that bypass rhizodat can. |
| D | Durability of each store plus the journal; a decided transaction survives crashes of the application, the coordinator host, or a store (recovery finishes it). |
Known limit (once overwrite/delete are supported; appends never conflict):
an Iceberg overwrite/delete may conflict with a concurrent writer after the
decision. Conflicts are pre-checked at prepare; a residual
conflict is retried and, if it cannot be resolved, reported as a heuristic
outcome for that branch.