Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

API

This document covers the full API surface of roda-ledger. Every operation is shown in both interfaces — gRPC for standalone server usage and Rust library for embedded usage. The concepts are identical; only the syntax differs.

For a full understanding of what operations, transactions, and wait levels mean, read Concepts first.


Setup

gRPC Server

Start the server with Docker:

docker run -p 50051:50051 -v $(pwd)/data:/app/data tislib/roda-ledger:latest

To customize the server, mount a TOML config file:

docker run -p 50051:50051 \
  -v $(pwd)/data:/app/data \
  -v $(pwd)/config.toml:/app/config.toml \
  tislib/roda-ledger:latest

config.toml — full reference:

[server]
host                  = "0.0.0.0"
port                  = 50051
max_connections       = 1000
max_message_size_bytes = 4194304  # 4MB

[ledger]
max_accounts    = 1000000
wait_strategy   = "balanced"   # low_latency | balanced | low_cpu

[ledger.storage]
data_dir                       = "/data"
transaction_count_per_segment  = 10000000  # 10M transactions per segment
snapshot_frequency             = 2         # take a snapshot every N sealed WAL segments

wait_strategy options:

ModeBehaviorUse when
low_latencySpins indefinitely under backpressureLatency is critical, CPU is dedicated
balancedSpins briefly, then yields, then parksDefault — good for most workloads
low_cpuParks quickly under backpressureCPU efficiency matters more than latency

Rust Library

Add to Cargo.toml:

[dependencies]
roda-ledger = { git = "https://github.com/tislib/roda-ledger" }

Initialize and start the ledger:

#![allow(unused)]
fn main() {
use roda_ledger::ledger::{Ledger, LedgerConfig};
use roda_ledger::storage::StorageConfig;

let config = LedgerConfig {
    max_accounts: 1_000_000,
    wait_strategy: WaitStrategy::Balanced,
    storage: StorageConfig {
        data_dir: "./data".to_string(),
        transaction_count_per_segment: 10_000_000,
        snapshot_frequency: 2,
        temporary: false,
    },
    ..LedgerConfig::default()
};

let mut ledger = Ledger::new(config);
ledger.start()?;
}

The ledger recovers its state automatically on start — it finds the latest snapshot, loads it, and replays any WAL segments that followed. No manual recovery step is needed.


Submitting Operations

Fire and Forget

Submit an operation and get a transaction_id back immediately. The operation is sequenced but not yet executed.

gRPC:

# Deposit
grpcurl -plaintext -d '{
  "deposit": {"account": 1, "amount": "1000", "user_ref": "42"}
}' localhost:50051 roda.ledger.v1.Ledger/SubmitOperation

# Withdrawal
grpcurl -plaintext -d '{
  "withdrawal": {"account": 1, "amount": "500", "user_ref": "43"}
}' localhost:50051 roda.ledger.v1.Ledger/SubmitOperation

# Transfer
grpcurl -plaintext -d '{
  "transfer": {"from": 1, "to": 2, "amount": "300", "user_ref": "44"}
}' localhost:50051 roda.ledger.v1.Ledger/SubmitOperation

Response:

{"transactionId": "42"}

Rust library:

#![allow(unused)]
fn main() {
use roda_ledger::transaction::Operation;

let tx_id = ledger.submit(Operation::Deposit {
    account: 1,
    amount: 1000,
    user_ref: 42,
});

let tx_id = ledger.submit(Operation::Withdrawal {
    account: 1,
    amount: 500,
    user_ref: 43,
});

let tx_id = ledger.submit(Operation::Transfer {
    from: 1,
    to: 2,
    amount: 300,
    user_ref: 44,
});
}

Operation Types

Deposit — credits a user account. Account 0 (the system account) is debited automatically to satisfy the zero-sum invariant. Deposits never fail due to insufficient funds.

Withdrawal — debits a user account. Fails with INSUFFICIENT_FUNDS if the account balance is below the requested amount. Account 0 is credited automatically.

Transfer — moves funds between two accounts atomically. Fails with INSUFFICIENT_FUNDS if the source account balance is insufficient. If from == to, the operation succeeds immediately as a no-op.

Function — invokes a registered WASM function by name with up to 8 i64 parameters. See WASM Runtime for the full guide on writing, registering, versioning, and invoking functions.

The user_ref Field

Every operation carries a user_ref — a uint64 supplied by the caller. It serves two purposes:

  • Idempotency key — when user_ref > 0, if the same user_ref appears within the active window — the deduplication cache uses a flip-flop (active + previous) keyed off transaction_count_per_segment, giving an effective window of N to 2N transactions — the second submission is detected as a duplicate. It is sequenced and recorded, but linked to the original via a TxLinkRecord { kind: DUPLICATE } rather than re-executed. This prevents double-processing on client retries. Deduplication is always on and cannot be disabled. Pass user_ref = 0 to opt out of the idempotency check for individual transactions.
  • Correlation reference — stored in the WAL alongside the transaction. Use it to link to your own database record — an order ID, payment ID, or any external reference.

Custom Operations (WASM Function)

Arbitrary multi-account, multi-step atomic logic is expressed as a registered WASM function and invoked via Operation::Function. The function can call the ledger.credit / ledger.debit / ledger.get_balance host imports; the zero-sum invariant is enforced after it returns, identically to built-in operations.

See the WASM Runtime guide for the full story: writing functions in Rust / AssemblyScript / WAT, registering them over gRPC or the Rust library, versioning, recovery, and performance numbers.


Submit and Wait

Block until the operation reaches a specific pipeline stage. Returns the full result including whether the transaction was rejected and why.

gRPC:

grpcurl -plaintext -d '{
  "deposit": {"account": 1, "amount": "1000", "user_ref": "46"},
  "wait_level": "WAIT_LEVEL_SNAPSHOT"
}' localhost:50051 roda.ledger.v1.Ledger/SubmitAndWait

Response:

{"transactionId": "43", "failReason": 0}

Rust library:

#![allow(unused)]
fn main() {
use roda_ledger::transaction::WaitLevel;

let result = ledger.submit_and_wait(
    Operation::Deposit { account: 1, amount: 1000, user_ref: 46 },
    WaitLevel::OnSnapshot,
);

if result.fail_reason.is_none() {
    println!("committed: tx {}", result.tx_id);
}
}

Wait levels:

LevelgRPCLibrary
ComputedWAIT_LEVEL_COMPUTEDWaitLevel::Computed
CommittedWAIT_LEVEL_COMMITTEDWaitLevel::Committed
On SnapshotWAIT_LEVEL_SNAPSHOTWaitLevel::OnSnapshot

WAIT_LEVEL_COMPUTED — The Transactor has executed the operation. Validation has run. If the operation violated any constraint (insufficient funds, zero-sum violation, etc.), fail_reason is set and the transaction is permanently rejected. If fail_reason = 0, the transaction is accepted and balance changes are live in memory — but not yet durable. A crash at this point would lose the transaction.

WAIT_LEVEL_COMMITTED — The WAL Storer has flushed the transaction to disk. Durability is guaranteed. The transaction will survive a crash and be replayed on restart. Balance changes are not yet visible via get_balance.

WAIT_LEVEL_SNAPSHOT — The Snapshotter has applied the transaction to the balance cache. get_balance now reflects this transaction and all transactions before it. This is the linearizable read guarantee — any get_balance call after this point will see a consistent, fully settled balance.

To get a transaction ID without waiting for anything, use SubmitOperation / submit instead.


Batch Operations

Submit multiple operations in a single round trip. Each operation is independent — failure of one does not affect others. There is no atomicity guarantee across operations in a batch.

gRPC:

grpcurl -plaintext -d '{
  "operations": [
    {"deposit":  {"account": 1, "amount": "1000", "user_ref": "50"}},
    {"deposit":  {"account": 2, "amount": "2000", "user_ref": "51"}},
    {"transfer": {"from": 1, "to": 2, "amount": "500", "user_ref": "52"}}
  ]
}' localhost:50051 roda.ledger.v1.Ledger/SubmitBatch

# With wait:
grpcurl -plaintext -d '{
  "operations": [
    {"deposit": {"account": 1, "amount": "1000", "user_ref": "53"},
     "wait_level": "WAIT_LEVEL_COMMITTED"}
  ]
}' localhost:50051 roda.ledger.v1.Ledger/SubmitBatchAndWait

Rust library:

#![allow(unused)]
fn main() {
let ops = vec![
    Operation::Deposit { account: 1, amount: 1000, user_ref: 50 },
    Operation::Deposit { account: 2, amount: 2000, user_ref: 51 },
    Operation::Transfer { from: 1, to: 2, amount: 500, user_ref: 52 },
];

let results = ledger.submit_batch_and_wait(ops, WaitLevel::Committed);

for result in results {
    println!("tx {}: {:?}", result.tx_id, result.fail_reason);
}
}

Operations within a batch are sequenced in the order provided. However, other clients’ transactions may be interleaved between them — there is no reservation of a contiguous block in the global order.

Prefer batch submission over repeated single calls for high-throughput ingestion — it reduces round trips significantly.


Transaction Status

Poll the pipeline stage of a transaction by ID.

gRPC:

# Single
grpcurl -plaintext -d '{"transaction_id": "42"}' \
  localhost:50051 roda.ledger.v1.Ledger/GetTransactionStatus

# Batch
grpcurl -plaintext -d '{"transaction_ids": ["42", "43", "44"]}' \
  localhost:50051 roda.ledger.v1.Ledger/GetTransactionStatuses

Rust library:

#![allow(unused)]
fn main() {
let status = ledger.get_transaction_status(tx_id);
}

Transaction status values:

StatusMeaning
PENDINGSequenced, not yet executed. Balance unchanged. Not durable.
COMPUTEDExecuted by Transactor. Balance updated in memory. Not yet durable.
COMMITTEDFlushed to WAL. Durable. Survives a crash. get_balance does not yet reflect it.
ON_SNAPSHOTApplied to balance cache. get_balance reflects it. Final successful state.
ERRORRejected. No balance changes. Check fail_reason for the cause.

Rejection codes (fail_reason):

CodeNameMeaning
0NONESuccess
1INSUFFICIENT_FUNDSAccount balance too low for the requested operation
2ACCOUNT_NOT_FOUNDReferenced account does not exist
3ZERO_SUM_VIOLATIONCredits and debits in the transaction do not net to zero
4ENTRY_LIMIT_EXCEEDEDTransaction emitted more than 255 entries (per-transaction limit)
5INVALID_OPERATIONOperation is malformed or contains invalid parameters
6ACCOUNT_LIMIT_EXCEEDEDAccount ID exceeds max_accounts configuration
7DUPLICATEuser_ref was seen within the dedup window — linked to the original transaction
8–127—Reserved for future standard reasons
128–255—User-defined custom reasons

Reading Balances

Single Account

gRPC:

grpcurl -plaintext -d '{"account_id": 1}' \
  localhost:50051 roda.ledger.v1.Ledger/GetBalance

Response:

{"balance": "1500", "lastSnapshotTxId": "42"}

Rust library:

#![allow(unused)]
fn main() {
let balance = ledger.get_balance(1);
// balance.balance: i64
// balance.last_snapshot_tx_id: u64
}

Multiple Accounts

gRPC:

grpcurl -plaintext -d '{"account_ids": [1, 2, 3]}' \
  localhost:50051 roda.ledger.v1.Ledger/GetBalances

Rust library:

#![allow(unused)]
fn main() {
// call get_balance per account
}

Linearizable Reads

get_balance reflects all transactions that have reached ON_SNAPSHOT. By default this lags behind committed transactions by nanoseconds — almost always negligible.

If you need a guaranteed linearizable read after a specific write — the balance must reflect your transaction and everything before it — you have two options:

Option 1 — wait at submit time:

# gRPC
grpcurl -plaintext -d '{
  "deposit": {"account": 1, "amount": "1000", "user_ref": "55"},
  "wait_level": "WAIT_LEVEL_SNAPSHOT"
}' localhost:50051 roda.ledger.v1.Ledger/SubmitAndWait
# then call GetBalance — guaranteed to reflect your transaction
#![allow(unused)]
fn main() {
// Library
ledger.submit_and_wait(op, WaitLevel::OnSnapshot);
let balance = ledger.get_balance(1); // linearizable
}

Option 2 — poll last_snapshot_tx_id:

#![allow(unused)]
fn main() {
let tx_id = ledger.submit(op);
loop {
let balance = ledger.get_balance(1);
if balance.last_snapshot_tx_id >= tx_id {
// balance is now linearizable with respect to your transaction
break;
}
}
}

Pipeline Index

Observe the current progress of each pipeline stage. Useful for health checks, lag monitoring, and determining when a bulk load is complete.

gRPC:

grpcurl -plaintext -d '{}' \
  localhost:50051 roda.ledger.v1.Ledger/GetPipelineIndex

Response:

{
  "computedIndex": "10500",
  "committedIndex": "10498",
  "snapshotIndex": "10496"
}

Rust library:

#![allow(unused)]
fn main() {
let computed   = ledger.last_compute_id();   // processed by Transactor
let committed  = ledger.last_commit_id();  // flushed to WAL
let snapshotted = ledger.last_snapshot_id();  // applied to balance cache
}

Common patterns:

  • Health check — all three indexes should be advancing. A stalled index indicates a stuck pipeline stage.
  • Commit lag — computed_index - committed_index. High lag means WAL writes are falling behind.
  • Snapshot lag — committed_index - snapshot_index. Should be near zero under normal load.
  • Bulk load completion — submit all operations, then poll until snapshot_index >= last_tx_id.

Transaction Details

Retrieve the full record of a transaction — its entries and any links to related transactions.

gRPC:

grpcurl -plaintext -d '{"tx_id": "42"}' \
  localhost:50051 roda.ledger.v1.Ledger/GetTransaction

Response:

{
  "txId": "42",
  "entries": [
    {"accountId": "1", "amount": "1000", "kind": "DEBIT",  "computedBalance": "1000"},
    {"accountId": "0", "amount": "1000", "kind": "CREDIT", "computedBalance": "-1000"}
  ],
  "links": []
}

Each entry shows the account, amount, direction (CREDIT or DEBIT), and the balance of that account immediately after the entry was applied.

Links connect related transactions:

Link kindMeaning
DUPLICATEThis transaction is a duplicate of the linked one (same user_ref within dedup window)
REVERSALThis transaction reverses the linked one

Account History

Retrieve the transaction history for an account, newest first, with pagination.

gRPC:

# First page
grpcurl -plaintext -d '{"account_id": 1, "from_tx_id": 0, "limit": 20}' \
  localhost:50051 roda.ledger.v1.Ledger/GetAccountHistory

# Next page — pass next_tx_id from previous response
grpcurl -plaintext -d '{"account_id": 1, "from_tx_id": "38", "limit": 20}' \
  localhost:50051 roda.ledger.v1.Ledger/GetAccountHistory

Response:

{
  "entries": [
    {"accountId": "1", "amount": "500", "kind": "CREDIT", "computedBalance": "500"},
    ...
  ],
  "nextTxId": "38"
}

next_tx_id = 0 means there are no more entries. Pass from_tx_id = 0 to start from the latest transaction. Default limit is 20, maximum is 1000.

Use cases: account statements, balance verification, dispute resolution, reconciliation.


Library Extras

These methods are available in the Rust library only and have no gRPC equivalent.

wait_for_transaction(tx_id) — blocks until the given transaction reaches ON_SNAPSHOT. Times out after 10 seconds.

wait_for_transaction_until(tx_id, duration) — same as above with a custom timeout.

wait_for_transaction_level(tx_id, level) — blocks until the given transaction reaches the specified WaitLevel. Returns immediately on rejection regardless of wait level.

wait_for_pass() — blocks until the last submitted transaction reaches ON_SNAPSHOT. Useful after a bulk load to confirm everything is visible.

get_rejected_count() — returns the total number of rejected transactions since startup. Useful for metrics and health monitoring.

query(request) / query_block(request) — low-level access to the Snapshot stage query queue. query is non-blocking; query_block blocks until the Snapshot stage processes the request. Used for advanced read patterns not covered by get_balance.