Clustered SQLite in ePHPm, Part 2: Adding Turso
How I decided a beta database engine was ready by testing it instead of reading its version number. ePHPm v0.7.0 replaces both rusqlite and sqld with Turso.
Part 1 ended with ePHPm running two SQLite engines. rusqlite ran in-process for a single node, and an embedded sqld child process ran for a cluster. The sidecar fell off a cliff past four concurrent writers. The best fix I could ship was a semaphore set to 1. This post is about replacing both engines with one, and about how I decided that engine was ready.
- Part 1: Replication by sidecar with sqld
- Part 2: Adding Turso (this post)
- Part 3: CDC over the cluster channel
The thesis
Turso Database is a rewrite of SQLite in Rust:
- MIT licensed
- file-format compatible
- MVCC for concurrent writers
- change data capture built into the engine
Read that list again from the perspective of Part 1. An in-process engine means no child process and no Hrana hop. MVCC means writers don’t queue up behind one lock. Built-in CDC means the engine itself can tell me what changed, so ePHPm’s existing cluster layer can ship it. One engine for both modes, and clustered SQLite with zero child processes.
I wrote that down as a roadmap page on 2026-07-10 (#166). At the time Turso was on its v0.7.0-pre line and labeled beta. I’m comfortable running a mature beta. The engine is MIT and the sync protocol is open. It has a real user base, and it holds to SQLite’s file format. What I’m not comfortable with is a label deciding for me in either direction. “Beta” doesn’t tell you whether it’s safe for your workload, and neither does “1.0”. So the roadmap set five gates:
- Upstream GA
- Performance at parity or better on my matrix, tails included
- File-format round trip, verified by me
- Crash recovery
- WordPress and Laravel end to end
Gate 1 is somebody else’s decision. The other four I could close or fail with my own evidence.
Phase 1: one knob, nothing else changes
The first PR (#183) added exactly one setting:
[db.sqlite]
engine = "turso" # default "sqlite"
It was single node only, with a warning at startup that it’s experimental. Clustered plus Turso was a hard startup error. Unknown values are also a startup error rather than a quiet fallback to the default. If you typo the engine name, you should find out at boot. You shouldn’t find out after a week of writing data into the engine you didn’t pick.
litewire got a Turso backend (litewire#9) behind the same Backend trait
the other engines use. So the gate measurements could run at that seam: same SQL, same harness, back to back, on WSL2.
| op | rusqlite p50 | Turso p50 |
|---|---|---|
| point SELECT | 81.2 µs | 2.9 µs |
| INSERT (autocommit) | 138.5 µs | 17.3 µs |
| 10-query page | 840.1 µs | 30.2 µs |
| 8 concurrent writers | 3,609 inserts/s | 14,451 inserts/s |
Before anybody quotes the 28x on point SELECT: most of that gap is litewire’s rusqlite backend paying a
spawn_blocking round trip. That’s about 80 µs on that host, and the async-native Turso backend doesn’t pay it. It’s
a real cost ePHPm was paying, but it’s not “Turso reads 28x faster than SQLite.” The INSERT gap and the concurrent
writers number are the engine. That run was eight writers and 2,000 rows, with zero busy errors on either side. MVCC
did about 4x the throughput.
Gate 3 was a round trip with 500 rows of hostile data. One engine wrote it and the other read it, in both directions, and the checksums were compared. The data had:
i64::MIN/MAX- floats near ±1e308
- NULLs in every column
- CJK and combining characters
- BLOBs
- a UNIQUE index
Both matched. Gate 4 got a smoke test: kill -9 mid-write ten times, ten clean recoveries. That is a smoke test and
not the soak the gate asks for. The gate stayed open.
Benchmark the defaults before you benchmark the engines
The first run of that matrix made Turso look terrible on writes. An autocommit INSERT p50 was 1,815 µs, and the eight writers did 351 inserts/s. That’s 41x slower than the number in the table above.
The cause was in the Turso source, not my harness. Turso’s default sync mode is FULL. litewire’s rusqlite backend
had always set synchronous=NORMAL per session. So I was comparing an engine that fsyncs every commit to one that
doesn’t. The Turso backend now sets PRAGMA synchronous = NORMAL per session, the same as the rusqlite one. If you
are about to compare Turso to a tuned SQLite, check this first. Otherwise you are benchmarking two durability settings
and calling it an engine comparison.
Gate 5: the failures weren’t Turso’s
Gate 5 (#188) installed WordPress and Laravel on both engines through
pdo_mysql, with rusqlite as the control. As pinned, it failed. Neither app installed on either engine. Running the
control first is what made that easy to read. All nine blockers were in litewire’s MySQL frontend and SQL translation
layer, and they fired before any engine code ran. With a local litewire patch for those nine, both apps went green on
both engines. Turso matched the control on all 20 matrix rows.
The one Turso-specific thing it found was a real bug. Selecting from pragma_table_info() left the connection in a
phantom transaction state. Later writes reported success and were never durable. WordPress “installed” into a 4 KB
database with an empty WAL. Turso already had this as
issue #7466. I traced it into the VDBE and found that virtual table
cursors weren’t closed before transaction finalization. I sent the fix upstream as
turso#7855, and it was merged the next day.
That’s what I mean by a mature beta. The failures in my integration were mostly mine. The one engine bug was already reported upstream. A fix from an outside contributor went from PR to merged in about a day. That tells me more about whether I can depend on a project than the word “beta” does.
The benchmark that made the case
For v0.6.0 I measured four lanes with the same fixtures on one machine in one session. Each node got --cpus 1, and
every cell was checked for 100% 2xx (#216, release numbers in the
benchmark results):
- A: rusqlite, single node
- B: Turso, single node
- C: rusqlite + sqld sidecar, clustered (the shipping clustered path at the time)
- D: Turso + CDC replication, clustered, no sidecar (experimental, Part 3)
| lane | reads c=16 RPS | reads p99 | writes c=1 RPS | writes c=16 RPS | writes p99 |
|---|---|---|---|---|---|
| A rusqlite single | 612 | 46.02 ms | 648 | 1130 | 18.68 ms |
| B Turso single | 646 | 30.93 ms | 666 | 1120 | 20.77 ms |
| C sqld cluster | 240 | 92.07 ms | 384 | 0 completed | - |
| D Turso cluster | 601 | 33.19 ms | 558 | 876 | 26.34 ms |
Reads are the 10-SELECT db.php fixture. Writes are one INSERT per request. The zero for lane C is not a rounding
error. That cell completed nothing in both 20 second reps.
Three things I take from this:
- Single node is a wash on writes and a win on read tails. Writes are even. rusqlite has a slightly better c=16 p99, within this harness’s run-to-run spread. Reads get 15% more throughput at c=1 (371 vs 322 RPS in the release tables) and a much steadier c=16 tail.
- Clustered is not close. CDC replication does 2.5x the read throughput of the sidecar with a third of the p99.
- Cost of replication against each engine’s own baseline. At c=16 writes, clustered Turso keeps 78% of single-node Turso’s throughput while shipping every change to a replica. Clustered rusqlite keeps 0%.
These are A/B numbers on one box under podman on WSL2. The absolute RPS won’t transfer to your hardware. The deltas are the claim.
v0.7.0: Turso only
Gate 2 was substantially evidenced and gate 3 was met. Gate 5 had the matrix, plus a live WordPress and WooCommerce
demo on Turso. It didn’t have an automated suite in CI yet. Gates 1 and 4 were open. Then a pen-test of multi-site
mode turned up #274. ATTACH DATABASE from one tenant’s PHP could write
a PHP file into another tenant’s docroot and execute it. Cross-tenant RCE, through the rusqlite path. That changed the
trade-off. Keeping an engine with that primitive around was more dangerous than adopting a beta engine that had passed
my gates. So the remaining gates now gate the GA label, not the ship.
#278 is the demolition, +681 / -2075 lines. Gone:
- the
ephpm-sqldcrate, theinclude_bytes!embedding, the extract/chmod/spawn/health/SIGTERM state machine - the sqld download in
xtaskand the Windows special case in the release build - the rusqlite single-node path in ePHPm (litewire still ships the backend for its other users)
[db.sqlite.sqld]andwrite_permits, kept as parsed-but-warned shims so an old config tells you what happened
The engine knob now defaults to "turso", and that’s the only accepted value. engine = "sqlite" is a hard startup
error that points you at the v0.7.0 upgrade notes. The PR explains why. A silent fallback would have run Turso on data
the user thought was rusqlite. That’s exactly the failure mode the error exists to prevent. A silent swap would be
worse than an error you have to read once.
The migration got verified rather than assumed. The test used standard SQLite files made by the sqlite3 CLI 3.46.1,
in WAL and in rollback journal mode. They had non-ASCII text, BLOBs and an index. They were opened through ePHPm’s
actual Turso path. PRAGMA integrity_check returned ok, and every row was intact. So a cleanly shut down 0.6.x
database upgrades in place, with no dump and reload. There are two caveats in the docs:
- A database with a hot, uncheckpointed
-walleft by a crash wasn’t verified to replay. Shut down cleanly before you upgrade. - Non-UTF-8 TEXT may not round trip, because Turso surfaces TEXT as a Rust
String.
v0.7.0 was tagged on 2026-08-18 with turso_core 0.7.2, which includes the #7855 fix. Clustering now always goes
through the CDC path. The cluster channel binds whenever SQLite resolves to clustered mode. Windows doesn’t have a
sqld blocker anymore. But clustered on Windows is untested, so the docs don’t claim it.
What’s next
The engine is in-process now and so is replication, so one binary is actually one process. But the replication path in v0.7.0 has only been exercised the friendly way: bring up nodes, write, watch rows show up. Nobody has killed the primary in a test with data on it yet. That’s in Part 3, along with the cluster channel that carries the CDC stream.
