Turso Engine — One Database Engine for Both Modes
Status: SHIPPED in v0.7.0. Turso is now the only embedded engine. Single-node Turso is the default; clustered replication is the in-process Turso CDC path (experimental). The rusqlite (SQLite C engine) backend and the sqld sidecar were removed — legacy
[db.sqlite] engine = "sqlite"/"rusqlite"is now a hard startup error, and there is no longer acdc_experimentalopt-in flag (clustering always uses CDC).The default swap (Phase 3 below) was driven forward in v0.7.0 by the security case — removing the rusqlite backend removes the cross-tenant
ATTACHprimitive from the shipped binary. Turso Database is still Beta upstream (multiprocess andVACUUMremain unsupported), so single-node Turso ships “eyes open” and clustered CDC is explicitly experimental. Of the five decision gates below, the file-format round-trip (gate 3) is now MET; the rest are tracked there.For the user-facing description — how it behaves, its limits, and file-format compatibility — see Database engines. This page is the plan, the evidence, and the risks.
The thesis
Before v0.7.0, ePHPm ran two SQLite lineages:
- Single-node: the genuine SQLite C engine, compiled into the binary
via rusqlite’s
bundledfeature, behind litewire’s wire-protocol translation. - Clustered: sqld (Turso’s libSQL server) embedded as an extracted child process, doing page-level WAL replication over gRPC.
Turso Database — the ground-up Rust rewrite of SQLite (MIT) — replaced both with one in-process engine, which is what v0.7.0 ships:
| Before v0.7.0 | With the Turso engine (v0.7.0) |
|---|---|
| SQLite C via FFI, blocking calls on tokio’s pool | Rust-native engine, native async I/O |
| Single writer; per-connection WAL + busy_timeout | MVCC concurrent writes |
| Clustering via the sqld sidecar (child process, health checks, binary extraction) | In-process CDC feeding ePHPm’s own replication layer — no sidecar at all |
The last row is the strategic one. ePHPm already owns gossip membership, primary election, and a replication data plane; sqld only ever supplied the WAL-streaming leg. Turso’s engine exposes change-data-capture in-process, and its sync wire protocol is deliberately open (documented endpoints, reference implementation in their repo). litewire consuming the CDC stream and handing it to the existing cluster layer completes the single-binary story: clustered SQLite with no child processes.
Why now (the sqld sunset)
Turso has refocused on the rewrite: libSQL/sqld remain maintained but feature-frozen, and page-level edge replicas are being discontinued for new cloud users. The old pinned sqld (v0.24.32) still worked, but it was a sunset dependency — so v0.7.0 removed it rather than deepening it. The replacement primitive Turso built (CDC + open sync protocol) is a better foundation for a project that wants to own its cluster layer than the black-box sidecar ever was.
Verified facts (2026-07-10)
- Engine, CDC, and the client-side sync engine are MIT, in the main
repo (
core/,sync/engine). - The sync protocol is published as an open contract
(
/v2/pipeline,/pull-updates) with a reference local server — self-hosting is a supported premise, not a loophole. - Near-complete SQLite surface compatibility; missing: multiprocess
support, vacuum. Beta,
v0.7.0-prerelease line. (Update 2026-07-14:v0.7.0non-pre is out on crates.io; upstream positioning is still Beta and multiprocess/vacuum are still experimental flags — gate 1 remains open.) - SQLite file-format compatibility is claimed by upstream. Verified by
us (gate 3, now MET): existing rusqlite/sqlite3
.dbfiles open in place for cleanly-shut-down databases (WAL and rollback-journal), so the 0.6.x → 0.7.0 upgrade needs no dump/reload. Caveats in the gates section.
Plan
Phase 1 — experimental backend (can start before GA)
Status: SHIPPED, 2026-07; became the default and only engine in v0.7.0. litewire has a
litewire-tursobackend (engine pinnedturso =0.7.2as of v0.7.1) and ePHPm builds it as the sole embedded engine. Gate 2–4 evidence lives indocs/turso-phase1-results.md; Phase 2 design notes indocs/turso-phase2-cdc-design.md.
The turso-backend crate in litewire sits beside its other backends
(rusqlite stays in litewire for other consumers; ePHPm no longer enables
it). The Backend/BackendConn trait split shipped in July 2026 was
exactly the seam this needed. Deliverable was data, not adoption:
- The existing DB latency matrix (point SELECT, insert, connect) — Turso engine vs rusqlite vs MySQL baselines. Partially done (2026-08-01): SELECT and INSERT measured against rusqlite; connect latency and the MySQL baselines are not yet measured.
- A concurrent-writers benchmark (N wire connections inserting), where
MVCC should beat WAL + busy_timeout — this is the headline claim to
verify, not assume.
Done (2026-08-01) — claim verified. At c=16 single-INSERT writes,
CDC-native clustered Turso sustained 694 RPS with no
SQLITE_BUSY, against 37 RPS for clustered SQLite via sqld, which surfacedSQLITE_BUSYas HTTP 500 and hung connections. Numbers and bounds in Results; the failure mode in Findings. - A durability/crash-recovery smoke (kill -9 mid-write, reopen, integrity check) — beta engines earn trust here or nowhere. Not started.
Phase 2 — CDC-native replication (shipped; became the only clustered path in v0.7.0)
Replace the sqld sidecar: litewire tails the engine’s CDC stream on the
primary; ePHPm’s cluster layer ships changes to replicas (own transport
or Turso’s open sync protocol — decide on measured simplicity). Election
and failover machinery is unchanged. In v0.7.0 sqld was removed outright
rather than run through a deprecation-with-overlap cycle, because dropping
the rusqlite backend in the same release already made engine = "sqlite"
(the config that selected sqld’s clustered path) a hard error.
Status (2026-07-14): experimental implementation landed; in v0.7.0 it
became the only clustered path. Enabling clustering with [db.sqlite]
selects a CDC-native replication path that runs a
litewire::litewire_turso::cdc::CdcTailer on the primary and applies
batches to replicas via apply_batch — no sqld sidecar, no child
process, no gRPC. The old cdc_experimental opt-in flag was removed in
v0.7.0: with sqld gone there is no alternative clustered path to opt out
to.
Headline empirical finding (from building this): Turso 0.7.0 CDC
captures DDL. CREATE TABLE/CREATE INDEX/ALTER TABLE ADD COLUMN/
DROP TABLE all appear in the same turso_cdc stream as row DML,
encoded as mutations on sqlite_schema. This means the replication
path is a single ordered stream with no schema-sync side channel.
Landed in this experimental cut:
- litewire
CdcTailer+apply_batchAPI (per-transaction batches, monotonic__litewire_cdc_watermarkfor exactly-once apply, SQLite record-format decoder for DML replay, sqlite_schema-SQL replay for DDL). 45 unit + integration tests inlitewire-turso. - ephpm
turso_cdcmodule: twoTursofactories per node (wire + mgmt); on the primary each inbound subscriber stream gets its ownCdcTailer, anchored at the watermark the subscriber sends in its first frame; replica dial + subscribe + apply loop; JSON-framed protocol (base64 for record blobs). 2-node e2e integration test proves DDL + INSERT + UPDATE + DELETE land on the replica through a real authenticated, multiplexed cluster channel, and that a reconnect both resumes (rows written during the gap arrive) and does not double-apply. - Transport = the cluster channel:
a single, lazy-bound,
yamux-multiplexed TCP listener shared by all opt-in cluster features, with a mutual ChaCha20-Poly1305 challenge/response handshake and per-connection sealing of every post-handshake byte. CDC is registered as stream typecdc/<vhost>and snapshot bootstrap assnapshot/<vhost>. The channel only binds when a feature asks — single-node configs (no clustering) open no extra port and are byte-identical to before. - Cold-replica snapshot bootstrap over
snapshot/<vhost>: an online logical dump captured under one read view, plus the aligned watermark. Served only by the elected primary, size-capped by[db.sqlite.replication] max_snapshot_bytes, and validated against aCREATE/INSERTstatement allowlist before it is applied. - Snapshot size cap:
[db.sqlite.replication] max_snapshot_bytesbounds the cold-replica bootstrap payload (still used by the CDC path). (Thecdc_experimentalopt-in knob that originally gated this path was removed in v0.7.0 — clustering always uses CDC now.) - Schema replay is allowlisted (v0.6.2, litewire#17). A CDC batch
carrying a
sqlite_schemarow used to have its storedsqltext run directly, so a peer whose frames reachedapply_batchcould run arbitrary SQL on a replica — includingATTACHandPRAGMA. That asymmetry with the snapshot path (dump checked, CDC not) is closed:classify_replayed_ddlnow parses the text first and admits only whatsqlite_schema.sqlcan hold — a singleCREATE TABLE,CREATE [UNIQUE] INDEX,CREATE VIEWorCREATE TRIGGER. That is the whole legitimate set, becauseALTER TABLE ... ADD COLUMNsurfaces as an UPDATE whose text is the rewrittenCREATE TABLEandDROParrives as a DELETE that litewire answers with aDROP ... IF EXISTSof its own construction.CREATE VIRTUAL TABLEis refused as well, deliberately: it invokes a module with peer-chosen arguments, which is a larger capability than building a b-tree. The statement scan is quote- and comment-aware, so a;inside a literal, a quoted identifier or a comment is not a separator, and an unterminated quote or comment is refused rather than guessed at. Triggers are recognised and skipped rather than replayed (see the deferred note below). Reachingapply_batchstill requires the cluster secret and gossip membership; what this removes is a trusted peer’s ability to do more than corrupt rows. - Empty CDC batch frames no longer depend on a local workaround
(v0.6.2, litewire#17).
TxnBatch::commit_change_id()returnedi64and panicked on an empty batch, which a peer could reach by sending{"rows":[]}— taking down an applying task nothing joins. It now returnsOption<i64>, and litewire’sapply_batchreports an empty batch as an error. ePHPm keeps its frame-level rejection as defence in depth: validating a peer’s frame is ePHPm’s job rather than the pinned revision’s, and rejecting at decode time keeps the diagnostics legible (every failure past that point is reported “at change_id N”, which an empty batch has no value for). - Failover-safe watermarks (v0.7.2, issue #315).
turso_cdc.change_idis a per-database sequence, and the replication watermark used to be treated as if it were cluster-global — so after a failover, a survivor subscribed to the promoted node with a cursor from the dead primary’s log, the promoted node’s own writes landed below it, and they were silently never shipped (unbounded post-failover write loss, with the replica reporting itself caught up). Every database now carries a persistent random log identity; the primary announces it as the first frame of every replication stream and in the snapshot header, and a replica keeps its cursor per log (saving/restoring across source switches, including primary → replica → re-promotion cycles). This is a CDC wire-protocol change: mixed-version clustered nodes are not supported. - Election incumbent protection (v0.7.2, issue #314). A node joining a running cluster used to take the primary role from a healthy incumbent (its pre-convergence membership view made “lowest-ordinal alive” trivially true, and the last claim written to gossip won), re-rooting the cluster onto an empty database. A freshly started node now waits out a startup grace (longer than claim TTL + heartbeat) before its first self-election, and live claim conflicts resolve deterministically to the lowest node id instead of the last writer. The cost: a genuinely fresh cluster elects its first primary ~15–20s after boot instead of immediately.
- Data-identity guard on the fast reclaim (issue #344). The startup grace deliberately exempts a restarting incumbent whose own claim is still unexpired in gossip (TTL ~10s), so a fast restart does not bounce the role. But “same node id” is not “same data”: a redeploy onto an empty volume brings the same node back with an empty database and a fresh CDC log identity, and it would win that fast reclaim inside the TTL window — re-rooting the cluster onto the empty database (#314’s blast radius through a narrower door). The primary claim now carries the node’s CDC log identity (the same per-database fingerprint from the log-identity fix above), and the fast reclaim requires both the node id and the log id to match. A node that comes back with a different log identity refuses the reclaim and falls through to the normal grace + election; a genuine fast restart with unchanged data still reclaims immediately.
- Prometheus instrumentation for the whole path — batches and rows shipped and applied, subscriber count, the applied watermark, apply and tail-poll errors, reconnects by outcome, snapshot bytes and outcome, and a replication-lag gauge. Full table in the metrics reference. The lag is measured in change-log rows, not seconds; a time-based lag is listed under “still deferred” below because it needs a wire change, not a calculation.
Still deferred:
- A time-based replication lag.
ephpm_cdc_replication_lag_changescounts change-log rows, which answers “how far behind” but not “how many seconds behind”. A seconds-valued lag needs a commit timestamp travelling with each change:turso_cdcstores one (change_time), but litewire’sCdcRowdoes not expose it, so this needs a litewire PR adding the field plus a matching CDC wire-format change here. Not fakeable from row counts, so nothing is published in the meantime. - TLS wrapping of the cluster channel. The channel is authenticated and encrypted with the operator’s shared secret, but there is no PKI peer identity — see the cluster channel roadmap.
turso_cdcretention pruning (v1: table grows unbounded — no operational issue on the small-write experimental workloads Phase 2 targets, but must be solved before Phase 3 default). Note this interacts with snapshot bootstrap: once the log is truncated, a snapshot watermark below the oldest retainedchange_idis not replayable.- Read-only enforcement on replica wire frontends. litewire has no read-only mode, so a replica’s MySQL/Hrana frontend accepts writes that nothing replicates; the replica logs a warning at startup.
- Triggers reach a replica by neither path. The snapshot deliberately does not ship them and CDC replay deliberately skips them — CDC already carries the rows a trigger produced on the primary, so a replica holding the trigger would fire it again on top of those replayed rows and diverge. That is correct for replay, but it means a replica promoted to primary has no triggers until an operator recreates them.
- 2-node podman/kind e2e test running the full ephpm binary against a
real MySQL wire client. The in-process integration test proves the
replication pipeline; the podman lift is largely test-orchestration.
Manually validated 2026-08-01, still not automated. Two release
containers on a podman network completed cold snapshot bootstrap
(watermark 24) → CDC subscribe → convergence, driven by PHP
pdo_mysqlthrough litewire, and the replica held 57,634 rows after a ~57k-row write benchmark. That was a scripted one-off during benchmarking, not a checked-in test — nothing in CI covers it yet. - Wire-frontend session capture without the factory-level flag: capture
is enabled on every node’s wire factory (
enable_cdc_on_connect = true, so a node promoted mid-life still captures), but a session that usesraw_connection()bypasses it — a documented gotcha.
Phase 3 — default engine (a major-version decision)
Status: SHIPPED in v0.7.0. Turso is now the default and only embedded engine; the rusqlite backend and sqld sidecar were removed.
Swapping the single-node default off the genuine SQLite C engine changes
what user data sits on, so per the versioning policy it was a new-minor
event, not a patch. In v0.7.0 the swap was driven forward by the security
case (removing the rusqlite ATTACH cross-tenant primitive) rather than
waiting on every gate — single-node Turso ships GA-intent with the
crash-recovery soak (gate 4) and upstream GA (gate 1) still tracked as
open, and clustered CDC ships explicitly experimental. See the gates
below for exactly what is and isn’t closed.
Decision gates — all of them, no exceptions
- Upstream GA: a stable (non-pre) release and upstream’s own production-readiness statement; multiprocess + vacuum landed.
- Phase 1 benchmarks at parity-or-better on our matrix, including
tails. Substantially evidenced (release build, 2026-08-02), gate
still open. Reads: Turso +15% RPS at c=1 with the steadier c=16
tail (30.9 vs 46.0 ms p99). Writes: parity — 666 vs 648 RPS at c=1,
and at c=16 rusqlite edges throughput (1130 vs 1120) with the better
p99 (18.7 vs 20.8 ms); the mid-cycle run’s “Turso write tail is
worse” finding did not persist into the release build. What keeps the
gate open:
connectlatency and the MySQL baselines are still unmeasured, and one c=16 write-p99 cell in rusqlite’s favor is within this harness’s run-to-run spread. See Results. - File-format round-trip verified by us (SQLite-written DB opened by
Turso and back, checksummed). MET. The Turso engine opens existing
rusqlite/sqlite3-created
.dbfiles in place for cleanly-shut-down databases — both WAL and rollback-journal modes;PRAGMA integrity_checkreturnsokand rows are intact. So the normal 0.6.x → 0.7.0 upgrade of a stopped node is seamless, no dump/reload. Two caveats carried into the docs: a database left with an uncheckpointed hot-walfrom a hard crash was not verified to replay (shut down cleanly before upgrading), and non-UTF-8 TEXT cells may not round-trip (Turso surfaces TEXT asString— an upstream limitation). - Crash-recovery soak clean. Open — this is why single-node Turso ships “eyes open” rather than declared GA.
- WordPress + Laravel e2e suites green on the Turso backend. Open as
an automated gate, but WordPress-on-Turso now has real informal
validation beyond the original front-page + drop-in pass: the live
WooCommerce demo store runs WordPress + WooCommerce on the per-site
Turso path, and the lab’s
wp-realworkload drives full WordPress installs against per-site Turso databases. Those are demos and benchmarks, not an e2e suite — automate in CI and confirm Laravel before calling this gate met.
v0.7.0 shipped Turso-only despite gates 1 and 4 remaining open, because
removing rusqlite removes the cross-tenant ATTACH RCE primitive from
the binary — a more dangerous property to keep than a Beta engine is to
adopt. The remaining gates gate the GA label, not the ship.
Risks, stated plainly
- Beta engine under user data is the whole risk; everything above is scaffolding to avoid finding its bugs in production.
- Semantic drift: MVCC concurrency changes locking-visible behavior
vs SQLite’s writer serialization; some apps observe
SQLITE_BUSYsemantics. - Velocity risk: Turso the company is mid-pivot; the engine’s roadmap is theirs, not ours. The mitigation is that everything we depend on (engine, CDC, protocol) is MIT — forkable at worst, and litewire’s backend seam means reversing course is a feature flag.