Docs/Deploy & operatev0.0.1-preview

Limits and sizing

Hard limits, measured throughput characteristics, the hot-account rule, and the capacity math for a real deployment.

On this page
  1. Hard limits
  2. Throughput shape
  3. The hot-account rule
  4. Memory and disk math
  5. Known limits of 0.0.1-preview

TulaDB sizes its core structures up front and tells you its limits precisely — including the ones that only bite at scale. Plan against this page before a pilot, not during one.

Hard limits

Limit Value
txn_id length 36 bytes of printable ASCII, no spaces or control characters
Transfers per batch frame 1,024 (LEDGER_TRANSFER_BATCH_MAX; the SDKs’ TXN_BATCH_MAX matches)
Max amount per transaction Per-ledger max_txn_amount (0 = no cap); over-cap transfers decline durably with ERR_AMOUNT_EXCEEDS_MAX, changeable at runtime
Account creates per batch 1,024
In-flight batch frames per connection (SDK) 64
Query page size 256 max (0 ⇒ 100 default)
Cluster size 128 nodes (3–5 voters is the practical design point)
Currencies 256 registry entries
Amount 64-bit signed integer, minor units, > 0 (a zero or negative amount declines with ERR_LIMIT)
Account id 128-bit unsigned integer; an account is named by (ledger_id, currency, id), and in this release an id can’t be reused under a second ledger/currency (ERR_ACCOUNT_CONFLICT)
partition_count ≤ 4,096

Capacity keys (max_accounts, max_transfers, partition_count, index slot counts) are declared in configuration and preallocated — an approaching ceiling is visible in metrics long before it’s an outage, and capacity exhaustion is an explicit ERR_CAPACITY, not a mystery stall. The same retryable status is how the engine sheds load when the free-space reserve or memory budget is reached.

Throughput shape

Two regimes matter, and they’re easy to tell apart:

  • Clustered, full durability: the bound is the quorum round-trip, not the CPU. Throughput scales with batching — bigger batches amortise the same consensus round — which is why the SDKs make batching and buffering first-class rather than an optimisation you bolt on later.
  • Single node: the bound is the apply path. Group commit already amortises one fsync across a whole group of batches, so async_flush has little left to remove here and adds a background sync thread of its own — reach for it only with a measured reason and an accepted loss window.

A reserve/commit flow is two writes per payment (the reserve, then the commit or rollback) where a single-phase transfer is one, so size it by measuring that flow, not from single-phase figures. Published numbers depend heavily on hardware, topology and client batch size — the chaos-campaign figures carry their instance class and workload shape for exactly that reason, and the honest way to size is still to measure your own workload on your own hardware.

The hot-account rule

The most important sizing fact on this page: a single account’s throughput budget is cluster-aggregate, not per-node. An account is a serialization point — every mutation of it must apply in sequence, on every node — so pointing more nodes at the same account doesn’t multiply its throughput, and offering a hot account more than it can serialize buys queueing, not throughput.

What an earlier chaos campaign (2026-08-26 → 08-29, on a development build that predates 0.0.1-preview; 3× m8gd.2xlarge across three availability zones, mTLS on every hop, 16 transfers per request) measured — no campaign has yet run on the 0.0.1-preview build:

  • Undegraded floor, single node: one account offered 6,667 TPS delivered 6,665 with zero shed and p99 under 15 ms — a target-met result, so a floor, not a ceiling.
  • Sustained through a continuous fault schedule, 3 nodes: ~7,400–9,300 TPS aggregate on one account across six runs, with money conserved exactly — under deliberate overload (20,001 TPS offered) so shedding and recovery are continuously exercised.
  • Neither figure is the account’s ceiling. The first met its target and the second ran under faults and deliberate overload. Undegraded capacity on this hardware is not published yet.

The number is instance-class- and configuration-dependent: an earlier campaign on smaller instances (m8gd.xlarge) measured materially lower figures, part of which was later traced to an internal dedup queue that overflowed at that rate (the campaign’s profile now sizes it at 8M slots; see dedup_index_queue_slots). Never carry a per-account figure across hardware — the published campaign figures state their instance class for exactly this reason.

Design accordingly:

  • Fan out hot balances. A merchant settlement account absorbing more than one account can serialize becomes N sub-accounts, swept periodically — the sweep is itself just transfers, so it stays balanced and auditable.
  • Let the admission guard protect you. write_admit_max_peer_lag and the write-SLO guard shed excess load with a clean retryable status instead of queueing into collapse — sustained overload can otherwise degrade a cluster’s ability to catch a restarted member back up.
  • Aggregate cluster throughput across many accounts is a different number, not bound by one account’s serialization; no multi-account cluster figure is published yet.

Memory and disk math

Resource Rule of thumb
RAM for accounts Preallocated per configured max_accounts slot, not per active account: the 128-byte account record plus several per-slot structures (queues, the account-store index, history and emission state). Plan on hundreds of bytes per slot and check the exact figure with tuladbd --memory-plan-json
RAM for idempotency With the run-tier dedup backend (the default on a fresh data directory): the resident identity set stays near dedup_hot_target_entries, and the durable set grows on disk at 48 B per identity — but each run’s absence filter stays in RAM, about 19 bits per identity with fuse16 (default) or 38 bits with fuse32. With the memory backend, the whole set is resident RAM
WAL disk 2 × peak write rate × retention window (a planning rule, not a measurement)
Transaction records 384 bytes per committed transfer in the segment store
Free-space reserve Keep max(1%, 2 GiB) free per filesystem by default; below it new transfers are refused
Utilization target Size for ~60% at projected peak (a planning rule: above it, a brief surge starts queueing in the apply path)
Minimum footprint Set by the capacity keys, because memory is preallocated: the demo profile sets small capacity keys, while the safe/secure profiles preallocate for their shipped capacity and need larger hosts. Run the memory fit check (tuladbd --check-config, or --memory-plan-json) for your own capacity keys

The idempotency line is the one to respect: duplicate protection lasts exactly as long as history, and the default (history_retention_hours = 0) keeps both forever, which is what makes the duplicate guarantee unconditional. If you turn on history retention, you are explicitly accepting that a retry of a purged transfer is admitted as new — make the window generous relative to your longest possible retry path (think stalled queues and disaster recovery, not p99 latency). Purge also needs an export pipeline acknowledging the leader, and export leases on (export_lease_ttl_ms > 0, default 0); without both, nothing is purged.

Also genuinely unbounded by design: account balance counters accumulate forever (astronomically far away at realistic volumes, and overflow is checked, never wrapped), and history is append-only until you opt into retention — disk is the planned-for cost of a permanent audit record (each transfer adds a 384-byte record), managed with WAL archiving, snapshots and, if you choose, history retention.

Known limits of 0.0.1-preview

0.0.1-preview is the first release: a developer preview for evaluation, benchmarking, integration testing and architecture review, not certified for production financial workloads. What to know before an evaluation:

  • Validation evidence. Behind this release are the local test suite, the deterministic simulators, and fault-injected chaos runs and multi-hour soaks on development builds. Development-build soaks have run for hours, and no multi-day soak result is claimed. On this build, the 50K TPS release chaos gate passed on 3 November 2026 (verdict); its six-hour multi-region soak has not run yet, and no result is claimed for it.
  • No upgrade path from development builds. Data directories, configs and clients from earlier builds are not an upgrade source — the wire protocol is version 4 and writes are batch-only, several on-disk formats changed, and several config keys are refused. Re-create the store (or re-seed a node from a peer) and use SDKs built for wire version 4. Formats a build doesn’t write are refused at startup, naming the file, and there is no in-place downgrade; to go back, restore a backup taken on the older build. Upgrades from this release follow the per-node procedure.
  • The opt-in async posture loses acknowledged writes on power loss. async_flush = 1 with allow_single_node_async_ack_loss = 1 acknowledges before fdatasync; in a modelled machine-loss test (2026-10-26, 40 cuts per posture) it lost 0.8–1.4% of acknowledged transfers, where the durable default lost none of 37,392. It is for benchmarks only.
  • History purge needs an exporter acknowledging the leader through an export lease (export_lease_ttl_ms > 0; leases are off by default), or history_purge_requires_export = false; without one, history and the idempotency index grow without bound.
  • One WAL lane. The multi-lane WAL is not supported (wal_lane_count = 1).
  • The account key is partly built. Accounts are named by (ledger_id, currency, id) on the wire, but an id can’t yet repeat under a second ledger/currency.
  • Deferred format work. 128-bit amounts (a larger transaction record and a new wire version) are planned, not in this release.
  • Reservation expiry is per ledger and off by default: a hold expires only on a ledger given a reservation TTL.
  • Distribution. SDKs are files in the sdk download, installed from disk; they are not published to public package registries yet.