Docs/Ledger APIv0.0.1-preview

Queries and the read path

Balances, history, search, pagination cursors, and exactly what consistency each read gives you.

On this page
  1. The read surface
  2. Consistency: what each read means
  3. Pagination
  4. Search
  5. When a read can’t be answered
  6. Keeping heavy analytics off the ledger

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_reads is enabled — off by default; the safe and secure profiles turn it on. Passing a write’s batch_seq as min_batch_seq still 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_seq tells 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 retryable ERR_QUORUM_TIMEOUT;
  • or configure index_read_wait_ms to 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_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.