Docs/SDKs & toolsv0.0.1-preview
Rust SDK
A zero-dependency Rust client with native u128 account ids and an opt-in TLS feature.
The Rust SDK (crate tuladb) is a deliberate port of the raw client: one connection, the write/read surface, batching and pipelining, plus an auto-batching BufferedLedgerClient in the next SDK release (below). Like the Go SDK, it ships no pool or leader-aware layer; see the Python or Java SDKs for those. Account ids are native u128, the cleanest representation of TulaDB’s 128-bit account space in any language.
The crate is not on crates.io: tuladb-0.0.1-preview.crate is in the sdk download. Unpack it and add it as a path dependency:
mkdir -p vendor && tar -xzf tuladb-0.0.1-preview.crate -C vendor
cargo add tuladb --path vendor/tuladb-0.0.1-preview # zero dependencies
cargo add tuladb --path vendor/tuladb-0.0.1-preview --features tls # adds rustls for TLS/mTLS
The tls feature needs rustls, which is not in the download: on an air-gapped host, cargo vendor it on a connected machine first.
First transfer
use tuladb::{AccountKey, LedgerClient, TransferCreate};
let mut c = LedgerClient::connect("127.0.0.1:7700")?;
let txn = "order-42";
let res = c.transfer(&TransferCreate {
source_id: 1, ledger_id: 1, currency: Some(840),
debit_account_id: 1001, credit_account_id: 1002,
amount: 250, txn_id: txn.into(),
..Default::default()
})?;
println!("phase={} status={}", res.phase, res.status);
let tid = c.derive_transfer_id(1, 1, 840, txn)?;
let bal = c.get_balance(&AccountKey::new(1, 840, 1001))?;
Always set currency: left None, the SDK sends ledger_id × 100, a legacy default that names no real currency for most ledgers, and the engine declines the transfer as a currency mismatch. Account-addressed calls take an AccountKey { ledger_id, currency, id }.
Connecting
LedgerClient::connect(addr)? // TLS if configured via env (feature "tls"); else refuses non-loopback plaintext
LedgerClient::connect_tls(addr, &opts)? // explicit TLS (feature = "tls")
LedgerClient::connect_plaintext(addr)? // explicit cleartext opt-in
TLS options come from a struct or the environment:
use tuladb::client::{LedgerClient, TlsOptions};
let opts = TlsOptions::from_env(); // TULADB_CLIENT_TLS_*
let mut c = LedgerClient::connect_tls(&addr, &opts)?;
c.ping()?;
TlsOptions carries ca_file, cert_file, key_file, and server_name. The client deliberately does not fall back to the OS trust store — no CA file, no connection. Without the tls feature, connect refuses any non-loopback address unless you opt in to cleartext explicitly — connect_plaintext in code, or TULADB_CLIENT_ALLOW_PLAINTEXT=1 in the environment.
The surface
fn transfer(&mut self, t: &TransferCreate) -> Result<TransferResult, Error>;
fn reserve(&mut self, t: &TransferCreate) -> Result<TransferResult, Error>;
fn commit(&mut self, transfer_id: &str) -> Result<TransferResult, Error>;
fn rollback(&mut self, transfer_id: &str) -> Result<TransferResult, Error>;
fn reject(&mut self, r: &RejectCreate) -> Result<TransferResult, Error>; // durable rejected outcome; code recorded verbatim
fn create_account(&mut self, a: &AccountCreate) -> Result<(), Error>;
fn get_balance(&mut self, key: &AccountKey) -> Result<Balance, Error>;
fn get_balance_after(&mut self, key: &AccountKey, min_batch_seq: u64) -> Result<Balance, Error>;
fn list_account_balances(&mut self, ledger_id: u32, account_id: u128) -> Result<Vec<AccountBalance>, Error>;
fn get_history(&mut self, key: &AccountKey, after_seq: u64, limit: u32) -> Result<Vec<TxnRecord>, Error>;
fn get_history_ex(&mut self, key: &AccountKey, after_seq: u64, after_index: u32, limit: u32) -> Result<Vec<TxnRecord>, Error>; // composite cursor
fn get_transaction(&mut self, transfer_id: &str) -> Result<TxnRecord, Error>;
fn transfer_batch(&mut self, transfers: &[TransferCreate]) -> Result<Vec<TransferResult>, Error>;
fn transfer_batch_pipelined(&mut self, batches: &[&[TransferCreate]], depth: usize) -> ...;
fn derive_transfer_id(&self, source_id: u64, ledger_id: u64, currency: u64, txn_id: &str) -> Result<String, Error>;
Page with the composite cursor: get_history_ex, and list_transfers / list_pending_reservations / list_rejected_transfers(after_seq, limit, after_index), resume from the last record’s batch_seq and batch_index — get_history alone can’t resume mid-batch (see pagination).
Buffered writes
Next SDK release.
BufferedLedgerClientis on the SDK’sfeat/go-rust-buffered-clientbranch and ships with the next SDK release. It works the way the Python and Java buffered clients do, with the same defaults.
Many threads each posting one transfer at a time get one transfer per round trip on a plain client. BufferedLedgerClient owns one connection and a sender thread, and sends their calls as batch frames. It is Send + Sync and its methods take &self, so share it with Arc. Each call blocks for its own result.
use std::sync::Arc;
use tuladb::{BufferedLedgerClient, BufferedOptions, LedgerClient};
let c = LedgerClient::connect("127.0.0.1:7700")?;
let b = Arc::new(BufferedLedgerClient::new(c, BufferedOptions::default()));
// from any number of threads:
let res = b.transfer(&t)?; // Err: invalid request, whole-frame refusal, transport failure
if res.status != 0 { /* this item was declined */ }
b.close()?; // sends anything still queued; Drop does the same
BufferedOptions |
Default | Meaning |
|---|---|---|
batch_size |
32 | a frame goes as soon as this many items wait; also the smallest frame |
flush_interval |
2 ms | the longest the oldest item waits for company; sub-millisecond values work; Duration::ZERO sends whenever the connection is free |
max_batch |
1024 | the largest frame |
pipeline_depth |
1 | frames in flight per round trip |
transfer,reserve,commit,rollback, the tiered four andrejectare buffered, each kind in its own queue.- Frame size follows demand: it doubles after a full frame and halves only after 64 frames in a row under a quarter full, never below
batch_size. A slow engine never shrinks it. - As with
transfer_batch, a declined item isOkwith itsstatus. A whole-frame refusal or transport failure is theErrof every item in that frame. - Reads and other calls flush first, then run directly (
with_client(|c| ...)covers anything not wrapped).flush()sends what is queued and waits for it.
On a single fsync’d node with 400 threads at 10,000 transfers/s, it sustained 10,000 TPS at about 73 transfers per frame (p99 16.3 ms), where the same threads sharing one plain client reached 160 TPS.
Decimal amounts
Next SDK release. These helpers are on the SDK’s
feat/decimal-amountsbranch and ship with the next SDK release. Every SDK passes the same shared conversion vectors; see Amounts.
use tuladb::{to_minor, format_minor, CurrencyScales};
let scales = CurrencyScales::new(); // registry-backed cache
let amount = scales.to_minor(&mut client, 124, "800.00")?; // 80000 for CAD (scale 2)
assert_eq!(format_minor(-1, 8)?, "-0.00000001");
assert!(to_minor("800.000", 2).is_err()); // too_many_decimals — never rounded
Found scales are cached for good; “not registered” (ScaleError::CurrencyNotRegistered) is remembered for two seconds; create_currency registers and caches at once.
Errors
enum Error {
Io(std::io::Error), // transport
Protocol(String), // framing / CRC — reconnect
#[non_exhaustive]
Ledger { status: i32, message: String, result: Option<Box<DeclinedResult>> },
}
A single-op write returns Ok when the item is accepted (0), a duplicate (1 — treat as success), or an explicit reject you asked for (41). Any other item status is Err(Error::Ledger { status, .. }), with the declined item’s decoded result attached; match it with .. so later fields don’t break you. err.is_final() tells you whether a retry can only repeat the answer, and err.is_retryable() whether nothing was written. Batch calls return a status per item instead. Every write is a batch on the wire: the single-op methods send a batch of one. See Errors and rejections.
This SDK has no leader-aware client, so RetryPolicy wraps a call instead. It retries retryable statuses and I/O errors only. A final status, a protocol error and an “other” status come back at once. The first retry after an I/O error is immediate. Every other retry sleeps a full-jitter delay in [0, min(max_delay, initial_delay × 2ⁿ)]. The defaults are 50 ms initial delay, 2 s maximum delay and 15 s maximum elapsed time. The first argument is the number of retries after the first call: tuladb::retry::RETRY_ATTEMPTS (1) is the shared default, and 0 calls once.
use tuladb::RetryPolicy;
let r = RetryPolicy::new().retry(tuladb::retry::RETRY_ATTEMPTS, || {
if !c.is_connected() { c.reconnect()?; } // an I/O error closed it
c.transfer(&t) // the same txn_id every time
});
Don’t wrap an operation that has no identity, such as create_ledger with ledger_id 0, where the server assigns the id. Resent after an I/O error, it could run twice.