Luther Monson, Staff Software Engineer at SUSE

Luther Monson

Go by way of PHP · Containers · VMs · Windows · Kubernetes

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.

9-Minute Read

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.

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:

  1. Upstream GA
  2. Performance at parity or better on my matrix, tails included
  3. File-format round trip, verified by me
  4. Crash recovery
  5. 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

Grouped bar charts comparing litewire’s rusqlite and Turso backends: Turso has lower p50 latency on point SELECT (2.9 vs 81.2 µs), autocommit INSERT (17.3 vs 138.5 µs) and a 10-query page (30.2 vs 840.1 µs) on a log scale, and higher throughput with 8 concurrent writers (14,451 vs 3,609 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)

four lanes at c=16

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-sqld crate, the include_bytes! embedding, the extract/chmod/spawn/health/SIGTERM state machine
  • the sqld download in xtask and 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] and write_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 -wal left 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.

Recent Posts

Categories

About

Luther is a 20+ year software veteran specializing in backend APIs and architecting products. Employed at SUSE as the Team Lead for the Containers on Windows Team working on Rancher + Kubernetes