pro/sql and key-value database/0.5, source not yet published

TRIC+ A high-performance SQL and key-value database You choose how long. It chooses where.

TRIC+ is an embedded database you can address in SQL or by key, and it works out for itself where each value should live. Speak to it either way: the same data, the same engine, and no bridge between two systems to keep in step. Where a value belongs is not something you configure. Hand it a schema and an analyser derives the storage plan; write a value with a lifetime and that lifetime decides its tier. What follows is one argument in seven steps, and the figures were taken on a machine that is named.

Runs on

  • FreeBSD
  • Linux
  • macOS

Built with

  • Rust
  • SQLite

Bridges ready

  • C
  • C++
  • JavaScript
  • Lua
  • Python
  • Swift
  • Tcl
The TRIC+ mark: a blocky T with a cross at its centre, extruded
Kind
SQL and key-value database, as a library and a server
Architecture
Supervised modules over a pluggable data bus
Analyser
An SQL schema becomes a storage plan, by seven rules
Host
Rust
Transient tier
A BTreeMap in memory
Persistent tier
SQLite, WAL, one file per namespace
Routing
A time to live sends it to memory, its absence to disk
Primitives
Six: read, write, delete, compare-and-delete, TTL, prefix scan
Wire
Unix datagram locally, UDP over a network
On the wire
ChaCha20-Poly1305, padded so the shape says nothing
SQL dialect
A subset via sqlparser: MySQL, PostgreSQL and SQLite syntax all read
Authentication
ed25519 and X25519 handshake, sessions, authorised keys
Deploy
One binary: tric server, and tric is the CLI
Bridges
Ten ready today, of twenty planned
Licence
Business Source, Apache-2.0 four years on

Startbefore the argument

Before the argument, the facts

Version 0.5, and the source is not yet published. The benchmarks below were taken on FreeBSD 15 on an AMD Ryzen 5 3600 with ZFS, inside a jail, which is the deployment target this is built for and tuned on; they will read differently on other hardware and the page says which machine rather than leaving you to assume yours. Ten of twenty planned language bridges work today. The licence is the only one in this house with a delay in it, and it gets a chapter rather than a badge.

The argument, in seven steps

Then, for reference

01one decision, not two systems

Placement is a decision it makes, not one it asks you for

A cache and a database, two systems to install, two to configure, two to keep in step, and a developer in the middle deciding for every value which one it belongs in. The decision is nearly always the same one, made again and again: does this need to survive a restart, or not.

TRIC+ asks it once, in the only place where the answer is already known. You state how long the value should live. Everything else follows.

    tric.write(b"session:9f2", token, Some(Duration::from_secs(900)));  // memory
    tric.write(b"user:4711",   record, None);                            // disk
        

That is the whole of it. A time to live places the value in the transient tier, a BTreeMap in memory. Its absence places the value in the persistent tier, SQLite on disk. The call site does not name a tier, does not open a second connection and does not consult a configuration file, because there is no knob to turn: the routing is a property of what you said, not of how the system was set up.

The name for it is permutive storage, which sounds grander than the idea deserves. The idea is that a lifetime already implies a home, and that asking the developer to state both is asking twice.

And the same again, one level up

The write-time rule is the small version. Hand the server an ordinary SQL schema and an analyser reads it and derives a storage plan from it: the tables, their columns and types, which columns are the primary key, which are foreign keys into other tables, and, among all of them, which column is a lifetime. Seven deterministic rules, the same schema always giving the same plan.

    CREATE TABLE sessions (
        id      TEXT PRIMARY KEY,
        user_id TEXT REFERENCES users(id),
        payload BLOB,
        ttl     INTEGER          -- the analyser finds this by itself
    );
        

So the layout is not something you design against the store's two tiers, and it is not something a configuration file describes. It falls out of the schema you would have written anyway. That is the whole of what is meant by a database that decides where things belong: not a heuristic watching traffic and moving data about behind your back, but a plan derived from what you said the data is, deterministically, before a byte is written.

Which leaves the question of what you actually say to it.

02six verbs and no more

Six primitives, and SQL that lands on them

The whole surface, and it is deliberately short. A store that grows a verb for every pattern its users invent ends up with three hundred of them and a manual nobody finishes.

The six
read · write · delete

The ordinary three, with the lifetime on the write deciding the tier

compare-and-delete

Atomic on the server, so a lock does not have to be built on top of it

time to live

Set, read and changed after the fact, not only at write time

prefix scan

A first-class operation rather than a full sweep wearing a filter

SQL is not a second system beside these. The parser is sqlparser with the generic dialect, so MySQL, PostgreSQL and SQLite syntax all read without switching modes, and each statement lands on the same six verbs: WHERE key = '42' becomes a read, WHERE key LIKE 'admin-%' becomes a prefix scan, and a query with no WHERE at all scans the table. There is no second query engine to keep in step, because there is no second engine.

The last two verbs are the ones that matter in practice and the ones most stores make you improvise. Compare-and-delete is how a lease is released safely by whoever holds it and nobody else; done in a script on the client it is a race with better manners. Prefix scan is how a namespace is enumerated; done by listing every key and filtering it is a habit that works beautifully until the day the store is large.

Six verbs over two tiers. What actually happens underneath them?

03two tiers, one engine

A map, a database, and the traffic between them

The transient tier is a BTreeMap, which is to say memory with an ordering, which is what makes prefix scan cheap rather than clever. The persistent tier is SQLite in WAL mode, one database file per namespace, so concurrent readers do not queue behind a writer.

They are not two systems with a wrapper around them. A read of a persistent key that is being asked for often promotes that key into the transient tier for a minute, so the second caller pays memory prices for something the first paid disk prices for. Nothing about that is visible at the call site, and nothing about it needs configuring.

Two properties of the arrangement are easy to miss and are the reason it stays honest. A key has one home and never two: writing without a lifetime drops whatever copy memory was holding, and writing with one drops whatever copy the disk was holding, so there is no second answer anywhere to go stale. And attaching a lifetime to something already on disk does not copy it into memory, it moves it, which is what makes a lifetime a statement about the value rather than a hint to a cache.

Where a value is put

Where a value is put A vertical flow. A write arrives carrying a key and a value. One question is asked of it: does it carry a lifetime? Without one the value goes to the persistent tier, SQLite on disk. With one it goes to the transient tier, a map in memory with the clock running. Either way the copy in the other tier is dropped, so a key has one home and never two. Attaching a lifetime to a key that is already on disk moves the value into memory. A write arrives a key, a value, maybe a clock Does it carry a lifetime? the only question asked Persistent tier SQLite, disk Transient tier a map, memory no yes a lifetime added later moves it The other copy is dropped one key, one home, never two the lifetime is the placement decision
One question decides the tier, and it is the one the caller has already answered by writing the value the way he did. A lifetime means the value is transient by nature, so it lives in memory with the clock running; no lifetime means it is meant to survive, so it goes to SQLite. The other copy is dropped either way, because a key with two homes is a key with two answers. Attaching a lifetime to something already on disk moves it rather than duplicating it.

How a value is found

How a value is found A vertical flow. A query arrives as SQL and is parsed. The shape of the WHERE decides the operation: one key for an equality, a prefix scan for a LIKE, the whole table for no condition. The key is composed as the table name, a colon and the key. The map in memory is asked first and answers on a hit. On a miss SQLite is read, and a value found there is copied into memory with a sixty second lifetime, so the next read of the same key does not reach the disk. A query arrives SQL, parsed What does the WHERE say? one key, a prefix, or all The key is composed table, colon, key The map in memory, first on a hit, that is the answer SQLite, on a miss the persistent tier is read Copied back for 60 seconds the next read stays in memory hit: answered miss a read warms the tier above it
SQL comes in and is parsed; the shape of the WHERE decides what actually happens. An equality is one key read, a LIKE with a prefix is an ordered scan, and no condition at all is the whole table. Then the same two tiers in order: the map answers if it can, and a value found on disk is copied up for a minute on its way out, so the second caller pays memory prices for what the first paid disk prices for.

Instances live under /var/db/tric/, one directory per project, each with numbered slots so a staging copy or a migration rehearsal is a clone rather than a second installation. Data goes in and out as native SQL dumps for MySQL, PostgreSQL and SQLite, or as a Brotli-compressed .tric archive, and an import can be differential: it applies what changed rather than everything.

All of which is on one machine. What happens when the caller is on another?

04what crosses the wire

The shape of a request says more than people think

Locally the protocol runs over a Unix datagram socket, which never leaves the machine. Across a network it runs over UDP, and there each datagram is encrypted with ChaCha20-Poly1305 and padded with random noise.

The padding is the part worth pausing on, because encryption alone would not have been enough. An observer who cannot read your traffic can still count it, and the length of a request is a surprisingly talkative thing: reads and writes have different shapes, a large value looks different from a small one, and a burst of one operation type at a known hour tells a story. Padded to a uniform shape, the wire carries the same silhouette whatever it is carrying, and the observer is left with the fact that something happened.

There is also a SQL door into the persistent tier, tric query, which reads the SQLite side directly for the times when what you want is a question rather than a key.

So much for what it does. The next question is the one a reader has been holding since the first paragraph.

05measured, on a named machine

Faster than Redis, on the platform it is built for

Both quadrants, read and write, in the comparison that is actually like for like: TRIC+ over a Unix datagram socket against Redis over TCP, both serving the single-shot SET k v EX t and GET k that is Redis's home discipline. No pipelining, no batching, single-threaded and synchronous on both sides.

measured67,570Writes per second, 1.03× Redis
measured91,675Reads per second, 1.16× Redis
measured10.6 µsRead, median
measured2.7 MCache-promoted reads in-process

FreeBSD 15, AMD Ryzen 5 3600, ZFS, inside a jail. That machine is written down because it is the whole context of the number: FreeBSD is the target this is built and tuned for, and the same code on another operating system is a different measurement that has not been taken here. A benchmark without its machine is a wish.

The in-process figures are a different question and worth keeping apart from the server ones. Without any transport at all, the transient tier reads at about 2.2 million operations a second at 370 nanoseconds, and a cache-promoted read at 2.7 million at 320. Those are the numbers a library user sees; a server user pays for the round trip and gets the ones above.

All of that is close. Is anything not?

06where it is not close

Two operations where the difference is a factor

A three per cent lead on writes is worth reporting and not worth choosing a database over. Two operations are not like that, and both are ones the six primitives make ordinary while the alternative makes them a project.

measured292×Atomic compare-and-delete
measured65×Prefix scan

Against the Redis equivalents on the same machine: a Lua script for compare-and-delete, and KEYS * for the scan. Characterised in the server manual under TRIC+-specific workloads.

The reason is not cleverness, it is placement. Compare-and-delete happens on the server as one operation, where the Redis version is a script shipped to the server to make two operations look like one. Prefix scan reads an ordered map, where KEYS * walks everything and discards what does not match. Both differences come from having decided, early, that these were primitives rather than patterns.

Which leaves the practical question of getting at it from whatever you write in.

07one binary, and the bridges

Ten languages today, and C underneath most of them

One binary does both jobs: tric server starts the daemon and tric on its own is the command line. Nothing else is installed, and there is no runtime to provide.

Ten bridges that work today
on the C foundation

C, C++, Swift, Nim, Lua, Tcl, Zig

native socket clients

Python, JavaScript, TypeScript

and Rust

Not a bridge at all: the engine is Rust, so it is simply the library

Twenty are planned in four waves, of which two are complete. The rest is enterprise languages and then the remaining ecosystems, each with a client that is idiomatic in its own language rather than a C header in a trench coat. Eight content and shop systems are meant to follow on top of those. This page counts the ten, because ten is what exists.

Tablethe benchmark in full

Three layers, one payload, one method

TRIC+ over a Unix datagram socket Redis over TCP on localhost
TRIC+ against Redis, across a socket Four bars on one scale. Reads: TRIC+ over a Unix datagram socket at 91,675 operations a second, Redis over TCP at 79,129. Writes: TRIC+ at 67,570, Redis at 65,383. TRIC+ leads on both, by sixteen per cent on reads and three on writes. 0k 20k 40k 60k 80k 100k operations a second, 128 byte payload READ WRITE TRIC+ over UDS 91,675 Redis over TCP 79,129 TRIC+ over UDS 67,570 Redis over TCP 65,383
The like-for-like fight, and the only one worth drawing: both across a socket, both single-threaded, neither pipelining. TRIC+ leads on reads by sixteen per cent and on writes by three, and three per cent is a lead worth calling small. The in-process rows in the table below are the same engine with no transport at all, which is a different measurement rather than a better score, so they are not on this axis. There is no MySQL bar because MySQL was not run: it would have to be measured on the same machine by the same method before it could appear here.
Benchmark results by layer, with percentiles
Layer and workloadOps/sp50p99
In-process, transient write 128 B1,645,225500 ns1.47 µs
In-process, transient read 128 B2,199,671370 ns770 ns
In-process, cache-promoted read2,737,603320 ns570 ns
In-process, SQLite write on ZFS18,11423.9 µs68.1 µs
Server over UDS, write 128 B67,57014.5 µs22.9 µs
Server over UDS, read 128 B91,67510.6 µs17.5 µs
Redis over TCP, write 128 B65,38315.2 µs16.8 µs
Redis over TCP, read 128 B79,12912.3 µs22.1 µs

FreeBSD 15, AMD Ryzen 5 3600, ZFS, in a jail. Layer one is the engine with no transport at all, layer two is the server over a Unix datagram socket, layer three is Redis over TCP on localhost. Everything single-threaded and synchronous, no pipelining and no batching on either side. Redis holds the p99 on writes, which is in the table because it is in the results.

Licencewhat you may do with it

Free on one host, and Apache in four years

The server is under a Business Source Licence, which is not an open-source licence and does not pretend to be. What it grants is worth stating plainly, because licences of this family are usually explained in a way that leaves a reader unsure whether they are allowed to start.

  • Any single-host production use is free. Not a trial, not a developer edition. If it runs your site on one machine, that is a use the licence grants.
  • Every tagged version becomes Apache-2.0 four years after its release. The clock is per version and it is written into the licence, so it does not depend on anybody remaining well disposed.
  • What it holds back is the multi-host and hosted-service case, which is the case where somebody else's business is built on this one.

The four-year conversion is the part that makes the rest of it liveable. A licence that merely restricts asks you to trust the licensor indefinitely; one that expires into Apache on a fixed date asks you to wait. The version you deploy today has a date on which it becomes yours outright, and that date does not move.

Edgeswhere it stops

Where it stops, and what that leaves you

  • The measurements are FreeBSD measurements. That is the target it is built and tuned for, and the figures on this page were taken there. Linux and macOS are supported and have not been benchmarked here, so the comparison with Redis is a FreeBSD comparison until somebody takes the other one.
  • Ten bridges of twenty. Two waves are complete. If your language is in a later wave, the socket protocol is documented and reachable, but the idiomatic client is still on the way.
  • Version 0.5, and the source is not published. Every figure here rests on trust until it is, and the benchmark harness ships with the project so the numbers become reproducible the day it ships.
  • Six primitives is a decision, not a stage. There are no lists, sets, sorted sets or streams, and there is no plan for them. What composes from six verbs composes; what does not, does not, and a store that wanted to be a data-structure server would be a different product.
  • It is not a distributed store. One host, one engine. Replication and consensus are not hiding behind a flag, and a deployment that needs them needs something else.