A tiny toy implementation of core TigerBeetle concepts in Rust. Built as a learning exercise in systems programming — zero-copy I/O, non-blocking network, append-only durable storage, and static memory allocation.
Weevil is a single-threaded TCP server that accepts financial transfers from concurrent clients, appends them to a single append-only WAL file, and responds with account balance information. Each transfer atomically debits one account and credits another — both sides land in the WAL as a single 64-byte record. It is not a real financial system.
- Zero-copy wire protocol — all messages are fixed 64-byte
#[repr(C)]structs cast directly from network buffers usingbytemuck. No serialization layer. Responses are the same: a fixed 64-byteAccountResponsestruct cast directly to the wire. - Non-blocking I/O — a single-threaded
mioevent loop handles multiple concurrent connections without threads. - Append-only WAL — all transactions are written as raw bytes to a single
wal.logfile. Onefdatasyncper event loop batch regardless of how many accounts were touched. Periodically checkpointed: account balances are snapshotted to acheckpointfile via atomic temp-file rename, then the WAL is truncated. On startup, the checkpoint is loaded first and the WAL tail is replayed on top. - Batch commit with response ordering — transactions accumulate across one poll iteration. At the end of each iteration, all pending transactions are written to the WAL and flushed with a single
fdatasync. Only after the flush completes are sessions promoted fromAwaitingCommittoWriting, guaranteeing no client receives a response before its transaction is durable on disk. - Separate debit and credit accumulators —
AccountEntrytracksdebit_balance: u128andcredit_balance: u128as independent unsigned accumulators rather than a single signed balance. Unsigned types cannot go negative, transaction volume is preserved in both directions, and the net balance is derived by comparison and safe subtraction at display time. Matches the TigerBeetle model. (64-Bit Bank Balances 'Ought to be Enough for Anybody'?) - Batched disk writes — all pending transactions for an account are written in a single
write_all(bytemuck::cast_slice(...))call rather than one syscall per transaction.bytemuck::cast_slicereinterprets the contiguous[Transfer; N]array as a flat&[u8]with no copying. - Balance replay — on startup, account balances are restored from the checkpoint file (if present), then the WAL is replayed 64 bytes at a time to recover any transactions that postdate the last checkpoint.
- Static connection table — connections are stored in a fixed
[Option<Session>; MAX_CONNECTIONS]array. The mioTokenis a direct array index. NoHashMap, no hashing, no pointer chasing — O(1) lookup by design. - Static account cache with open addressing — accounts are stored in a fixed
[Option<AccountEntry>; MAX_ACCOUNTS]array. Slot selection uses modulo hashing with linear probing and full wrap-around — noHashMap, no heap allocation.MAX_ACCOUNTSis prime (257) to reduce probe clustering. - Atomic double-entry transfers — each
Transfercarries both adebit_account_idandcredit_account_id. Both sides of the transfer are written to the WAL as a single 64-byte record and applied together on flush. There is no moment where one account has been debited but the other has not been credited — the WAL record is either fully replayed or not at all. - Cache-level pending transfer buffer —
AccountEntryCacheholds a single[Transfer; MAX_BATCH]array shared across all accounts. All in-flight transfers accumulate in one buffer regardless of which accounts they touch. One buffer, onewrite_all, onefdatasyncper event loop batch. - Type-state response buffer —
SessionStatus::AwaitingCommit([u8; 64])andWriting([u8; 64])carry the response payload inside the state. The type system enforces that a session cannot be inWritingstate without a response ready to send. No separatewrite_buffield, noOptionto unwrap. - CRC32 checksums — every wire message and every log record carries a CRC32 checksum in repurposed padding bytes. Computed in
new()with the checksum field zeroed, verified on network ingress and during startup log replay. Table-free, no dependencies
All messages are 64 bytes. Client-to-server messages are distinguished by the final byte (message_kind). Server-to-client responses are always AccountResponse.
| Byte offset | Field | Type |
|---|---|---|
Transfer (message_kind = 1) |
||
| 0–15 | amount | u128 |
| 16–23 | debit_account_id | u64 |
| 24–31 | credit_account_id | u64 |
| 32–35 | checksum | u32 |
| 36–62 | padding | [u8; 27] |
| 63 | message_kind = 1 | u8 |
Account (message_kind = 0) |
||
| 0–7 | account_id | u64 |
| 8–11 | checksum | u32 |
| 12–62 | padding | [u8; 51] |
| 63 | message_kind = 0 | u8 |
| Byte offset | Field | Type |
|---|---|---|
| 0–15 | debit_balance | u128 |
| 16–31 | credit_balance | u128 |
| 32–39 | account_id | u64 |
| 40–43 | checksum | u32 |
| 44–62 | padding | [u8; 19] |
| 63 | status | u8 |
The response reflects the committed balances at the previous flush boundary — the pending transfer has been accepted into the batch but balances are updated when the batch is written to disk, not at enqueue time. For transfers the response reflects the credit account's balance.
status |
Meaning |
|---|---|
| 0 | Success |
| 1 | Account not found |
| 2 | Account cache full |
# start the server (creates ./data_files/ automatically on first run)
cargo run --bin server
# run the test client (NUM_THREADS concurrent connections, NUM_TRANSACTIONS each)
cargo run --bin client <NUM_THREADS> <NUM_TRANSACTIONS>The client registers each account, sends a series of random transfers, then queries the final balance. ./data_files/wal.log and ./data_files/checkpoint persist across restarts.
Measured on a MacBook Pro (Apple Silicon) with NUM_THREADS varied and total transfers fixed at 10,000. All transfers are fully durable — each batch is flushed with fdatasync before any client receives a response.
| Threads | Transfers/thread | Total transfers | TPS (avg 3 runs) |
|---|---|---|---|
| 10 | 1,000 | 10,000 | ~1,252 |
| 20 | 500 | 10,000 | ~2,531 |
| 40 | 250 | 10,000 | ~4,976 |
| 100 | 100 | 10,000 | ~10,787 |
| 200 | 50 | 10,000 | ~11,793 |
| 250 | 40 | 10,000 | ~12,227 |
| 251 | 1000 | 251,000 | ~13,058 |
TPS scales with concurrency because fdatasync cost is amortized across all transfers that arrive during a single event loop iteration. At low concurrency (10 threads), transfer batches range between 1 and 9 transfers per sync. At ~200 threads transfer batches grow to about 56-72.
Assuming the following configuration (I landed on these arbitrarily), the peak memory footprint is 1.2MB regardless of the throughput.
// Maximum number of connections the server will accept
pub const MAX_CONNECTIONS: usize = 256;
// Maximum number of accounts to handle
pub const MAX_ACCOUNTS: usize = 257; // prime number to reduce probe clustering
// Maximum size of the WAL file before a snapshot
pub const MAX_WAL_SIZE: u64 = 1024 * 1024; // 1MB max
// Maximum number of transfers to store in memory before flushing to disk
pub const MAX_BATCH: usize = 1000;
Weevil omits most of what makes TigerBeetle production-worthy: O_DIRECT, cluster replication, and anything resembling fault tolerance. It is a learning artifact.
Transaction history is not preserved — the WAL is truncated after each checkpoint. Only current account balances survive a restart. There is no audit log, no way to replay individual transactions, and no mechanism to answer "what happened to this account."
-
Transfer TimeStamps — add a
timestamp: u64field toTransfer, repurposed from padding. Allows temporal reconstruction of account balance change history. This won't matter much with current checkpointing behavior, but once a storage engine is in place it will be useful. -
Transfer IDs for idempotency — add a
txid: u64field toTransfer, repurposed from padding. Clients assign an ID to each transfer; the server echoes it back inAccountResponse. Duplicate submissions with the same ID can be detected and rejected, making retries safe. -
Testing — coverage is thin. Missing: checksum rejection on corrupt data; WAL-only replay (no checkpoint); checkpoint + WAL tail replay; partial
checkpoint.tmpdoesn't corrupt state on restart;MAX_BATCHboundary (100th transaction succeeds, 101st returnsPendingTransactionsFull); account cache full response; open-addressing correctness under hash collisions.