inro

Why inro

There are at least three engines that already work here: Arkeion, when the data needs to be auditable over time; SQLite, when there is one node and one database; and the LMDB family, when the workload needs embedded copy-on-write with years of production hardening. This is the argument for building a fourth engine anyway — where inro gains, what it gives up to get there, and the one case where the honest answer is: don't.

Against Arkeion

Arkeion and inro are siblings, not competitors, and they optimise for opposite things. Arkeion optimises for auditability, versioning and query power. inro optimises for the smallest possible consumption of disk, RAM and CPU. Arkeion is the system of record for data that must be auditable over time; inro is the local store for nodes with scarce resources, or deployed by the thousand.

What inro gives up to get its footprint: SQL — no parser, planner or executor; full-text search, vector indexes, triggers, views and foreign keys; access from more than one process; and time-travel, branches and AS OF queries.

Time-travel is the one that needs unpacking, because it looks cheap and isn't. Measured against a real edge workload — batches of a thousand records, about 166 commits a day — retaining every commit's root-to-leaf path (roughly four 4 KB pages each) would pile up 2.6 MB of immortal pages a day: a 40% overhead on file size. Worse than the size: pruning by retention would stop freeing space, because an old version would still see the rows it deleted. That forces a vacuum with retention — exactly the periodic compaction that gets LSM rejected below.

For the one case that would actually justify versioning — proving nobody tampered with a past billing figure — inro's answer is a thousand times cheaper: a hash chain over the billing aggregates, 768 bytes a day, 280 KB a year, with no file versioning at all.

One node, one database, and a need for SQL, branches or time-travel: choose Arkeion. Thousands of footprint-constrained instances that just need durable key-value storage: choose inro.

Against SQLite

Where SQLite wins

An empty file with 512-byte pages and one table takes about 1 KB — less than inro's 1.5 KB after the meta B relocation. And more important than the byte count: SQLite's maturity. Its test suite has full branch coverage and a volume of test code on the order of 600 times the engine's own. No byte saved makes up for that, and it needs to be said plainly.

Where inro wins, on quota's workload

DimensionSQLiteinro
Bytes per 16-column row42-45 B (about 17 from type codes alone, plus rowid and cell pointer)37 B, from prefix compression on the key and the absence of type codes
Engine RAM per open database~2 MB cache per connection by default; on the order of 100 KB even tuned to the minimum20-30 KB
Total RAM per database, with interning and aggregatesEquivalent, plus the parsed schema and prepared statements~832 KB after phase 1; ~34 KB after phase 2
Satellite filesIn WAL mode: a -wal growing to 4 MB before checkpoint, and a -shm of 32 KB mapped per connectionNone
Pruning by retentionDELETE ... WHERE ts < X is O(rows): 833,000 daily deletions per nodedelete_range by subtree unhook: ~25 pages read
Binary size~700 KB, ~300 KB in a minimal build100-250 KB estimated, plus 60-100 KB with encryption

SQLite and LMDB figures are estimates from the design phase and are to be confirmed in phase 1's exit measurements.

inro is not justified by SQLite being big. It is justified by aggregate RAM across thousands of instances, by the cost of pruning, by the absence of satellite files, and by control of the format. With one node and one database, a well-configured SQLite is the right call and writing an engine is a waste of time.

Against LMDB, libmdbx and redb

These three share inro's architecture: B+tree copy-on-write, single file, no background threads, non-blocking readers, constant-cost opens. Together they cover about 70% of the core with code that has been hardened in production for years.

DimensionLMDB / libmdbx / redbinro
Bytes per entry45-55 B — no prefix compression, no separator truncation35-37 B
Floor per database8-16 KB, from a fixed 4 KB page — 40-80 MB across five thousand tenants, in empty databases alone1.5 KB

SQLite and LMDB figures are estimates from the design phase and are to be confirmed in phase 1's exit measurements.

What the shared architecture doesn't include is exactly what produces this design's numbers:

  • No range deletion by subtree unhook: pruning falls back to O(rows).
  • No encryption in LMDB or redb.
  • LMDB and libmdbx use mmap, which exhausts virtual memory regions once thousands of databases are open.
  • None of the three is designed for thousands of instances in one process: no shared page pool.

Why copy-on-write, and what was rejected

Three storage architectures were evaluated before settling on one.

Chosen: B+tree copy-on-write with a free list. A modified page is written into a free slot, never over live data; a commit is just writing a new meta page. Occupancy is stable, between 1.2× and live data in the worst case. No compaction, no background threads, no auxiliary file, constant RAM. Non-blocking readers fall out of the design for free: a reader pins one meta page, and the tree hanging from it is immutable for as long as it holds it.

file1. read page2. write copy into a free slot3. commit = new metapagecopymeta
A commit frees the old page rather than deleting it; it returns to the free list for reuse. Append-only, without that list, would grow the file forever.

Rejected: LSM (memtable plus SSTables). Background compaction is unaffordable in IoT — CPU and battery — and catastrophic with thousands of tenants per process. Worse, every open database would need its own memtable, multiplying RAM by the tenant count.

Rejected: B+tree in-place with a WAL. It writes every page twice, forces a second file that has to be truncated, and concurrent reads need a page cache with latches or explicit MVCC — more code than the option that was chosen, not less.

The nuance almost nobody says out loud: a pure append-only copy-on-write would be the worst option on disk, because the file would grow without bound. It's the free list, with page reuse, that makes this architecture serve the project's goal — not copy-on-write by itself.

Supply chain

Zero unsafe in inro's own code. unsafe_code = "forbid" sits under [lints.rust] in Cargo.toml — not just #![forbid(unsafe_code)] in lib.rs — so the prohibition also reaches tests, benchmarks and examples.

Zero runtime dependencies by default. The parser, the B+tree, the encodings, varint, CRC32, bitpacking and compression are all hand-written. The only planned exception is the cryptographic primitives behind the encryption feature, and even there inro doesn't write its own cryptography — it uses RustCrypto's audited implementations.

The honest caveat: forbid(unsafe_code) does not propagate to dependencies. The RustCrypto crates use unsafe in CPU feature detection and in their SIMD backends. Whether ChaCha20's portable backend can drop that unsafe — and at what performance cost — is an open question for phase 1, to be measured and audited with cargo geiger in CI, not assumed.

Concrete consequence for phase 4: LZ4 compression will be hand-written, around 300 lines, rather than pulling in lz4_flex, which does contain unsafe.