Docs/Ledger APIv0.0.1-preview
Queries and the read path
Balances, history, search, pagination cursors, and exactly what consistency each read gives you.
TulaDB separates reading from writing at the network level: alongside the write port (7700), every node runs a read-only query port (7800) that accepts only reads. A reporting job, a dashboard, or a support tool pointed at the query port structurally cannot mutate the ledger or block the write path — and anything write-shaped sent there is refused with ERR_WRONG_MODE.
The read surface
| Operation | Returns |
|---|---|
get_balance(key, min_batch_seq) |
Six-field balance snapshot: posted, pending, available — key is (ledger_id, currency, id) |
list_account_balances(ledger_id, account_id) |
Every balance one id holds in a ledger, one row per currency |
get_transaction(transfer_id) |
The durable record for one transfer |
get_transaction_by_key(source, ledger, currency, txn_id) |
Same, addressed by idempotency tuple |
get_history(key, after_seq, limit, after_index) |
The account’s transfers in committed order |
get_account_history(key) |
Metadata event trail (creation and limit changes) |
list_accounts / list_transfers |
Paged enumeration |
list_pending_reservations |
Every open hold, in commit order |
list_rejected_transfers |
Every durable decline |
search_transactions(search) |
Filtered search: ledger, currency, source, account, phase, result code, time range |
get_cluster_status() |
This node’s id and role, the leader’s id, term, commit/apply indexes, and peer count — other nodes’ roles are not included |
Consistency: what each read means
Reads are fail-closed: no query ever returns uncommitted or in-flight state. Beyond that, know which of three consistency levels you’re using:
Balance reads are applied state. get_balance reflects everything applied on the serving node. Which node that is, and whether the answer is linearizable, depends on the port:
- Client port (7700): linearizable by default (
cluster_linearizable_reads = true). A follower redirects the read to the leader, and the leader confirms leadership quorum before answering, so you cannot read a stale balance from a deposed leader. - Query port (7800): a local read by default. Whichever node serves it answers from its own applied state, and on a follower that can be behind the leader. The same leader-confirmed barrier runs there only when
cluster_linearizable_query_readsis enabled — off by default; the safe and secure profiles turn it on. Passing a write’sbatch_seqasmin_batch_seqstill guarantees you see that write.
Make admission decisions (“is there enough to spend?”) from a client-port read, or enable cluster_linearizable_query_reads.
Derived queries are eventually consistent by default. History, listings, and search are served from query indexes built asynchronously from the committed log — this keeps indexing off the write path. The lag is typically small, but “I wrote, then immediately listed” can miss the newest entry. Two remedies:
- gate on the write’s receipt: your
TransferResult.batch_seqtells you exactly how far the node must have caught up before your transfer is visible, and a read can carry it (min_batch_seq) to wait for that point. The wait is bounded: a position beyond every batch the node knows of is refused at once, and too many waiting reads are shed — both with the retryableERR_QUORUM_TIMEOUT; - or configure
index_read_wait_msto make derived reads wait for the index to reach applied state.
Follower reads are explicit on the client port. By default, a follower doesn’t serve client-port reads that could be stale. Clients may opt in (the ALLOW_FOLLOWER_READ request flag — set_default_flags in the Python SDK) for scale-out reads with two protections retained: the follower proves recent leader contact, and it will not answer below anything the same client has already written — read-your-writes is preserved. It is still not linearizable; use it for dashboards and analytics, not for admission decisions.
Pagination
History and listing cursors are composite: (after_seq, after_index). A batch of transfers shares one batch_seq and fans out by batch_index, so a cursor built on after_seq alone would drop or duplicate entries at batch boundaries. Always resume from both fields of the last record you consumed:
key = AccountKey(ledger_id=1, currency=840, id=1001)
page = q.get_history(key, after_seq=0, limit=100)
while page:
last = page[-1]
page = q.get_history(key, after_seq=last.batch_seq,
after_index=last.batch_index, limit=100)
The server caps limit at 256 (a limit of 0 means 100). Paged reads are stable: a page boundary never tears a batch.
Search
search_transactions filters on any combination of ledger, currency, source, account, phase, result code, flags, txn_id, and a timestamp range — unset fields match everything. An account filter names an account by its full key, so it needs a concrete ledger_id and currency alongside account_id. Because rejected transactions are records, “every decline from source 3 in the last hour” is a search, not a log-diving expedition.
When a read can’t be answered
A read never guesses. Four statuses tell you why there is no row, and they mean different things:
| Status | Means | Do |
|---|---|---|
ERR_NOT_FOUND |
This node has never seen that transfer or account key | A lagging node may not have it yet; check the identity |
ERR_QUERY_UNAVAILABLE |
The node knows the record exists but cannot vouch for it here — its query index is rebuilding, or it joined by snapshot and has not yet synced that part of history | Retry, or read from another node |
ERR_CORRUPTION |
The stored record failed its integrity check | Read from another node; the node never serves an older match past it |
ERR_HISTORY_PURGED |
The record is below the cluster’s replicated history-retention floor | Read it from your analytics export |
A history, listing, or search read that reaches a range a node cannot serve refuses rather than returning a partial page. A node that joined a cluster by snapshot pulls the history it lacks from the voters and verifies it before it serves those reads — and before it can be promoted to voter.
Keeping heavy analytics off the ledger
The query port is for operational reads. For warehouse-scale analytics — daily aggregations, BI, ML features — export the ledger’s committed history to your lakehouse instead and query it there. That’s what the analytics export pipeline is for.