typedb-lean vs the Rust driver
typedb-lean wraps TypeDB's official C driver, which is itself built on the Rust driver. The question is what the Lean layer costs. The answer: nothing measurable on scans, lookups and inserts, and less than the Rust baseline under concurrency.
vs Rust
(within noise)
tail latency
concurrent reads
Results
One run of lake exe bench on an Apple M-series laptop, TypeDB 3.14.0 in Docker, both clients on driver version 3.14.0, against the same server and the same data.
Scan of 100,000 rows into structures
Lean 9% fasterPoint lookup, p50
on parPoint lookup, p99
Lean 36% lowerBulk insert of 10,000 entities
on par16 concurrent read transactions of 10,000 rows
Lean 1.65× faster| Workload | typedb-lean | Rust driver | Lean / Rust |
|---|---|---|---|
| Scan, rows/s | 155,368 | 142,061 | 1.09 |
| Lookup p50 | 1,048 µs | 1,033 µs | 1.02 |
| Lookup p99 | 1,521 µs | 2,393 µs | 0.64 |
| Bulk insert | 4,749 ms | 4,708 ms | 1.01 |
| 16 concurrent reads | 367 ms | 607 ms | 0.60 |
Workloads
The benchmark loads 100,000 person entities, each with an email key, a name and an age. Then it runs the same four workloads with this library and with a Rust program that uses typedb-driver directly (bench/rust). Both clients warm up first.
define
attribute email, value string;
attribute name, value string;
attribute age, value integer;
entity person, owns email @key, owns name, owns age;
- Scan. One read transaction runs
match $p isa person, has email $e, has name $n, has age $a; limit 100000;and decodes every row into a structure of three fields. In Lean that isquery (α := Person)withderiving FromRow; in Rust, adecodefunction that reads the three columns. - Point lookup. 1,000 lookups by email, each in its own read transaction, so the time includes opening and closing the transaction. p50 and p99 are taken over all of them.
- Bulk insert. A fresh database, then one write transaction with 10,000 separate
insertqueries and a commit. - Concurrent reads. 16 read transactions at once, each scanning 10,000 rows. Lean uses
EAsync.concurrentlyAlloverDriver.run; Rust uses 16 scoped threads.
-- The Lean side of the scan and the concurrent reads, from bench/Bench.lean
structure Person where
e : String
n : String
a : Int64
deriving FromRow
let rows ← d.runSync db .read (opts := { batchSize := 512 }) (query (α := Person) (scanQuery s.scan))
discard <| (EAsync.concurrentlyAll ((Array.range 16).map fun _ =>
d.run db .read (opts := { batchSize := 512 }) (query (α := Person) (scanQuery 10000)))).block
Why a wrapper can match the native driver
Calling Rust through C from Lean adds a boundary crossing per call. The library makes those crossings rare and cheap:
- Batched decoding. A
RowDecoderknows its columns and what it needs from each (a value, a type label or an IID). One native call returns those for up tobatchSizerows, with no intermediate object per concept. - Prefetch 512. The server streams up to
prefetchanswers ahead of the client. typedb-lean sets 512 by default; with the C driver's smaller default, a scan waits on the network. - Closing in the background. Closing a transaction does not wait for the driver to finish, like the Rust driver's
drop. - Loops compile to loops.
Txis aReaderToverEIO, soforin a transaction body compiles to a plain loop. - Dedicated threads. Each async transaction blocks only its own thread, and one driver connection multiplexes all of them.
The Rust baseline uses the driver's defaults. It is not tuned, so read the concurrency result as "the Lean layer adds no contention", not as "Lean is faster than Rust".
Targets and CI
The benchmark is a test, not just a report. It fails unless:
- the scan reaches at least 80% of the Rust driver's rows per second, and
- the lookup p50 is at most 1.1× the Rust driver's.
CI runs lake exe bench --ci on Linux x86_64 for pushes to main and pull requests: 20,000 rows, 300 lookups and 2,000 inserts, with the targets relaxed by another 10% for shared runners.
Reproduce
You need Docker, a Rust toolchain and protoc. Without TYPEDB_ADDRESS, the benchmark starts a typedb/typedb container at the pinned version and removes it afterwards.
lake exe bench # full sizes, enforces the targets, writes bench/report.json
lake exe bench --ci # reduced sizes, targets relaxed by 10%
lake exe bench --profile # also times draining, fetching and decoding separately
TYPEDB_ADDRESS=127.0.0.1:1729 lake exe bench # use a server you already run
The report has both clients' numbers side by side:
{"sizes": {"scan": 100000, "lookups": 1000, "bulk": 10000},
"rust": {"scan_rows_per_s": 142060.9, "lookup_p50_us": 1032.5, "lookup_p99_us": 2393,
"bulk_insert_ms": 4707.6, "concurrent_reads_ms": 607.3, ...},
"lean": {"scan_rows_per_s": 155367.8, "lookup_p50_us": 1048.5, "lookup_p99_us": 1521.4,
"bulk_insert_ms": 4749.2, "concurrent_reads_ms": 367.2, ...}}
Caveats
- These numbers are from one laptop with the server in Docker. Absolute values depend on the machine and the network; the ratios are what the benchmark checks.
- Bulk insert time is dominated by the server, which is why both clients take the same time. The benchmark's loader puts 100 entities in each
insertquery to cut round trips; the measured workload deliberately does not. - The upstream driver (3.14.0) keeps about 600 bytes per transaction it has opened, in its Rust and C APIs alike, so processes that open millions of transactions grow accordingly.