EveDB: the Eve Test Database
evedb, built next to the Eve virtual machine but never part of it. Its first job is to play the remote database in the tests of the Eve database layer: it speaks Eve's own protocol, keeps Eve's types without loss, and can fail on purpose, exactly when a test asks, so that commit, rollback and retry can be tested again and again with the same result.
evedb/README.md of the Eve repository.Why a test database
Eve has two database engines with two different roles:
| Engine | Role | Where it runs |
|---|---|---|
| SQLite | the core database: the Eve database of each machine (run history, checkpoints, lineage), the cache, staging tables of ETL, small applications | inside the virtual machine, compiled into eve |
| DuckDB | the secondary database: EveDB, a remote database tuned for Eve; first for tests, later for analytics | in its own program, evedb |
A program that reads and writes a database on another machine has failures that a local database never shows: the network breaks before a commit, or after it; another program changed the row; the schema changed since the code was written. Eve handles these cases with jobs, recover and retry (see Processing and Databases). Each case needs a test, and each test needs a remote database. A real server such as PostgreSQL can play that role, but it must be installed, it can't be told to fail at the right moment, and it does not speak Eve. EveDB is made for that job:
- it starts on
localhostin a fraction of a second, with a fresh database for each test; - it speaks the Eve Wire Protocol, so no vendor driver is needed on the Eve side;
- it can drop a connection, refuse a commit or report a conflict on demand;
- it keeps a log of every statement it ran, so a test can check the SQL that Eve sent.
Where EveDB fits
The Eve database layer has three tiers. A local Eve program holds memory tables and reads files; an Eve server holds the sessions, the drivers and a cache; the remote database holds the data. EveDB stands in the place of the remote database, and also takes the server side of the sessions, so a test needs only eve and evedb:
+------------------------------+ +------------------------------+
| Eve program (eve) | EWP | EveDB (evedb) |
| memory tables, files, jobs | -------> | sessions, DuckDB, test log |
+------------------------------+ +------------------------------+
The program does not know which database it uses: the name of the connection is resolved by the configuration of the machine. The same script runs against SQLite, PostgreSQL or EveDB:
** eve.cfg of the machine that runs the tests (sketch)
database "shop" = "sqlite://db/shop.db" ** local: the embedded engine
database "sales" = "postgresql://etl@db1/sales" ** a real remote server
database "test" = "evedb://localhost/eve_test" ** the Eve test database
Why DuckDB
DuckDB is an embedded SQL database, like SQLite, but it stores tables column by column. EveDB uses it for what it gives for free:
- ACID transactions with several connections working at once: two transactions that change the same row conflict at the commit, which is exactly the case a test of optimistic locking needs;
- fast bulk loads: rows are appended in column batches, the same shape in which the Eve Wire Protocol sends a table;
- rich types: 128-bit integers, decimals of 38 digits, time stamps with a zone, intervals, UUIDs;
- upserts (
insert … on conflict do update), used by the bulk loads of ETL; - CSV, JSON and Parquet read and written in SQL, for test fixtures now and analytics later;
- one file per database, or a database in memory that a test creates and throws away in milliseconds.
EveDB is not meant to replace PostgreSQL in production: DuckDB is built for analytics, so many tiny transactions are slower than on a classic server. For tests and for bulk ETL that does not matter.
Tuned for Eve
The Eve Wire Protocol
EveDB listens on a TCP port and answers the database messages of the Eve Wire Protocol (see Protocols): open a session, begin a transaction, run a query and send the rows in batches, apply the changes of a flush, commit, roll back, and answer whether a transaction was committed. Only the fields that changed travel: an update of one field of a record with fifty fields sends one value, the key and the version. EveDB is the first program that implements the server side of these messages; the Eve server can reuse the same code later.
Eve types without loss
Every Eve type has a DuckDB column type that keeps it exactly, so a value written by Eve is read back the same:
| Eve | DuckDB |
|---|---|
Integer | BIGINT |
Natural | UBIGINT |
Huge (128 bits) | HUGEINT |
Decimal | DECIMAL(p, s) |
Real, Float | DOUBLE, FLOAT |
Logic | BOOLEAN |
String | VARCHAR |
Instant | TIMESTAMPTZ |
Date, Time, Duration | DATE, TIME, INTERVAL |
Uuid, Bytes | UUID, BLOB |
EveDB describes its tables in Eve types, so the check of a mapping (does every field of a record class have its column, with a type that fits?) is exact. evedb describe eve_test orders --eve prints the Eve record class of a table, ready to paste into a script.
Tables of Eve built in
Two small tables exist in every EveDB database. eve_tx keeps the ids of the committed transactions: when the network breaks right after a commit, the program asks with that id and learns whether its work was saved. eve_checkpoint keeps the checkpoints of long loads, committed together with the batch they describe, so a load that restarts never loads a batch twice.
Sessions and leases
Each process of an Eve program that uses the connection gets its own session, with its own DuckDB connection and its own transaction. A session has a lease: the program renews it while it works. When a program crashes, its lease runs out, EveDB rolls back the open transaction and frees the connection, so a dead program never holds locks for long.
Fault injection
The most useful feature of a test database is that it can fail on purpose. In test mode (evedb serve --test), a test arms a fault with a pragma statement, sent like any SQL; the fault fires once, at the next matching point:
| Fault | What happens | What it tests |
|---|---|---|
drop before or after the commit | EveDB closes the connection at that point | a lost connection is a transient error: retry; after the commit, the program asks and finds its work saved |
conflict on a table | the next update of that table conflicts | optimistic locking, ConflictError, a retry that reads fresh data |
deadlock | the next statement fails as a deadlock | DeadlockError, a pause before the retry |
commit_fail | the commit fails and rolls back | a job that fails at its end, with its memory restored |
delay | every message waits | time-outs and leases |
drift on a table | a column is added, changed or dropped | the mapping check: a warning for a harmless change, a hard stop for a wrong one |
** a test: the network breaks after the commit was sent
new test := database.connect("test");
test.execute!("pragma eve_fault('drop', 'after_commit')");
Outside test mode, a pragma eve_fault is an error: a production database never fails on request.
Statement log
In test mode EveDB writes every statement it runs to the table eve_log.statements: the session, the transaction, the kind of statement, the table, the columns written and the number of rows. A test reads it to prove what is otherwise invisible, for example that an update wrote only the field that changed:
** only the dirty field and the version were written
class Logged = {columns: String} <: Record;
new last := test.query_one!(:Logged)("select columns from eve_log.statements where kind = 'update' order by seq desc limit 1");
expect last != null and last.columns == "city,version";
Commands
| Command | Meaning |
|---|---|
evedb | print the versions and run the self-check (available now) |
evedb serve [--port 8044] [--data folder] [--test] | listen; --test allows faults and the statement log and listens on localhost only |
evedb create name [from script.sql] | create a database |
evedb sql name "statement" | run one statement and print the result |
evedb describe name table [--eve] | the columns in Eve types; --eve prints the record class |
evedb snapshot name file, evedb reset name from file | save a database and put it back, for test fixtures |
evedb import, evedb export | load or write a table as CSV, JSON or Parquet |
evedb status | sessions, leases and open transactions |
The test runner of Eve starts evedb serve --test with a fresh data folder before the database tests, resets the fixtures before each test that uses the connection "test", and stops it at the end.
Build and run
EveDB lives in the folder evedb/ of the Eve repository, next to the virtual machine in evevm/, and is written in Zig 0.16 like the machine. DuckDB is not copied into the repository: the build downloads the prebuilt library of the system once, checks it against its hash, and keeps it in a cache.
cd evedb
zig build -p .. # installs bin/evedb.exe and bin/duckdb.dll, next to bin/eve.exe
zig build test # the unit tests
../bin/evedb.exe
evedb 0.0.1, DuckDB v1.5.6, self-check 42
The same build gives SQLite to the machine: evevm/ compiles the SQLite source into eve, so the core database needs no installation at all.
Inside the project
| File | Content |
|---|---|
build.zig, build.zig.zon | the build and the pinned DuckDB library of each system |
src/main.zig | the command line |
src/duckdb.zig | the binding over the C interface of DuckDB (written) |
src/server.zig, src/session.zig | the listener, the sessions and their leases (planned) |
src/ewp.zig | the messages of the Eve Wire Protocol (planned) |
src/types.zig, src/catalog.zig | Eve types and the description of tables (planned) |
src/changes.zig | the changes of a flush turned into SQL (planned) |
src/fault.zig, src/log.zig | fault injection and the statement log (planned) |
Versions
| EveDB | With Eve | Content |
|---|---|---|
| 0.1 | 0.4 (data and database) | the test server: sessions, the protocol, Eve types, bulk loads and upserts, faults, statement log, snapshots |
| 0.2 | 0.5 (server) | several clients with tokens, import and export, the protocol code shared with the Eve server |
| later | an analytic database for Eve: Parquet, large aggregates, users and rights |
Read next: Index