Two-node ePHPm cluster fixture with side-by-side comparison of the two clustered-SQLite paths ePHPm ships:
- mode-turso-cdc β
[db.sqlite] engine = "turso"+[db.sqlite.replication] cdc_experimental = true+ cluster.channel v1. The primary tails its local Turso engine'sturso_cdctable and ships per-txn batches over an authenticated yamux stream; replicas apply. Turso Database is Beta upstream; this entire path isEXPERIMENTAL-labeled. - mode-sqld β the shipping-production default. rusqlite in-process for reads, sqld sidecar spawned as a child process for writes, WAL frames streamed between primary and replica over sqld's gRPC channel.
Same topology, same static container IPs, same WordPress+wp-cli workload, same cluster-secret. Engine swap is the only change.
Everything here is upstream except five load-bearing temporary
patches that all carry the marker UPSTREAM_PENDING_7855 so we can
grep them out cleanly once the upstream fix lands. See
BUILDING.md and evidence/pin-lines.txt.
βββββββββββββββββββββββββββββββ podman network cluster-e2e-net (10.89.42.0/24)
β
β βββββββββββββ ephpm-a (10.89.42.10) βββββββββββββ
β β HTTP :8080 β host :18100 β
β β MySQL :3306 β host :13306 β
β β gossip :8093 (cluster-internal) β
β β channel :8094 (cluster-internal, mode-turso-cdc)β
β β sqld gRPC :5001 (cluster-internal, mode-sqld) β
β β KV data :7947 (cluster-internal) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββ
β ββ gossip + KV replication
β ββ turso-cdc: yamux stream on :8094
β ββ sqld: gRPC on :5001
β βββββββββββββ ephpm-b (10.89.42.11) βββββββββββββ
β β HTTP :8080 β host :18200 β
β β MySQL :3306 β host :13307 β
β β (same ports internally, dedicated container IP) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββ wp-runner / bench-runner (docker.io/library/wordpress:cli, ghcr.io/hatoo/oha)
also on the same network to eliminate host-network variance
Host-side ports (18100/18200/13306/13307) avoid 8080/9090/18443/9944/ 9950/9951/9960 per the ephemerd runner-fleet conventions.
./scripts/build-binary.sh # ~5-40 min (WSL Rust build)
./scripts/down.sh --wipe-data # clean slate
./scripts/up.sh turso-cdc # or: scripts/up.sh sqld
./scripts/wp-install.sh # wp core install through ephpm-a
./scripts/replicate-check.sh turso-cdc # siteurl match + post-lag histogram
./scripts/bench-read.sh turso-cdc 1 # oha home-page load (c=1,4,16,64)
./scripts/bench-write.sh turso-cdc 1 # parallel wp post create clients
./scripts/failover.sh turso-cdc # kill primary, measure promotionscripts/bench-run-all.sh chains the full sweep (both modes Γ three
iterations Γ three workloads); expect ~1 hour end-to-end on this
laptop when everything works.
Both modes now reach the "replication proven" step end-to-end. The
mode-sqld leg required a one-line ephpm-sqld fix (PR #189, commit
93e920c) that omits --grpc-listen-addr when spawning a Replica
sqld β see evidence/sqld-phase-c-transcript.txt
for the post-fix bring-up and
evidence/sqld-mode-startup-failure.txt
for the pre-fix runtime error that surfaced it.
The ePHPm side has been carrying a heal in litewire-turso that
force-restarts autocommit after every pragma-TVF read
(SELECT ... FROM pragma_table_info(...)) because the first such read
leaves the connection in a phantom-transaction state: subsequent
writes appear locally but never commit. Without the heal, dbDelta()
(WordPress upgrade path) silently drops schema.
Upstream root-caused it (PragmaVirtualTableCursor outlives Halt so
is_nested_stmt() misclassifies the outer statement) and shipped
turso#7855 with a
regression test suite mirroring the exact incident shape.
Verification method: point litewire's [patch.crates-io] at
luthermonson/turso@fix/pragma-vtab-txn-state, replace
sql_uses_pragma_tvf with false (heal inert), run the regression
suite plus WordPress wp core install end-to-end.
Result: PASS. All 256 tests across litewire backend/translate/mysql
/turso pass with the heal off; wp core install completes on the
primary through both modes (see evidence/phase-a-transcript.txt and
evidence/phase-c-transcript.txt Β§2).
Update 2026-07-16: #7855 merged upstream. The pin has moved from the
fork branch to the immutable upstream main merge commit
tursodatabase/turso@f2ba5a2, and the litewire-turso suite (44 tests,
incl. pragma_tvf_read_does_not_poison_session) re-passes on that rev
with the heal still off. litewire#11 also merged (699e634). Remaining
unwind waits only on a turso crates.io release containing the fix β see
BUILDING.md.
scripts/up.sh <mode> creates a /24 podman network at 10.89.42.0/24,
assigns each ephpm container a static IP (10.89.42.10 / .11), and
starts them 8 seconds apart. Static IPs are non-negotiable in both
modes:
- turso-cdc β the ephpm@bc24b2a plumbing fix computes the cluster
channel's advertise address by falling back to gossip's listen IP
when the channel is bound on a wildcard. If both are wildcards, the
ephpm process refuses to start with a clear config error pointing
at
[cluster] bind/[cluster.channel] listen. - sqld β
sqlite_electionpublishes--grpc-listen-addrverbatim to peers as the "dial the primary here" URL. If the primary bound0.0.0.0, the replica dialshttp://0.0.0.0:5001on its own local stack and gets refused. Same class of bug, no fix yet in the sqld path.
The 8-second stagger between node-a and node-b start is deliberate. The
election is gossip-KV lowest-ordinal-wins; both nodes bootstrap as
Primary in the ~1s before they can see each other via gossip. If they
race, the second writer wins the last-write-wins gossip KV
sqlite:primary slot and the first-started node has to demote. Not a
consensus algorithm β see Phase D item #6.
scripts/replicate-check.sh turso-cdc on a fresh cluster + wp-install
returns:
| Test | Result | Detail |
|---|---|---|
wp option get siteurl on both nodes |
PASS | matches on node-a and node-b |
| Single post, node-a β node-b | PASS | ~14 s wall clock |
| 50 posts sequential, replica lag distribution | PASS | n=50, p50=13.1 s, p99=17.6 s |
Machine-readable line: REPLCHECK mode=turso-cdc siteurl_ab=PASS post_lag_ms=13976 p50_ms=13068 p99_ms=17561 samples=50.
Full transcript: evidence/phase-c-transcript.txt Β§3.
Pre-plumbing-fix transcript (before ephpm@bc24b2a) preserved at
evidence/archive-pre-plumbing-fix/ β that's
the run that surfaced the URL-form parse bug and the wildcard-advertise
bug in the first place.
mode-sqld: PASS (post ephpm/ephpm#189)
scripts/up.sh sqld boots the cluster, both nodes bootstrap as
Primary in the ~4s before gossip converges, then the last-write-wins
KV slot resolves and one node demotes to Replica. The
just-demoted node re-spawns its sqld child in Replica mode; before
ephpm/ephpm#189 (commit
93e920c) this failed with:
error: the argument '--grpc-listen-addr <GRPC_LISTEN_ADDR>' cannot be
used with '--primary-grpc-url <PRIMARY_GRPC_URL>'
The fix in ephpm-sqld/src/lib.rs::build_command composes the sqld
CLI conditionally β Primary gets --grpc-listen-addr, Replica gets
--primary-grpc-url, never both β and adds regression tests. With
that binary in bin/ephpm, mode-sqld now runs the same three checks
as mode-turso-cdc:
scripts/replicate-check.sh sqld on a fresh cluster + wp-install
returns:
| Test | Result | Detail |
|---|---|---|
wp option get siteurl on both nodes |
PASS | matches on node-a and node-b |
| Single post, node-a β node-b | PASS | ~15.3 s wall clock |
| 50 posts sequential, replica lag distribution | PASS | n=50, p50=14.0 s, p99=97.0 s |
Machine-readable line: REPLCHECK mode=sqld siteurl_ab=PASS post_lag_ms=15268 p50_ms=14031 p99_ms=96988 samples=50.
Full transcript: evidence/sqld-phase-c-transcript.txt.
Pre-fix runtime error preserved at
evidence/sqld-mode-startup-failure.txt.
Same fixture, same rig, engine swap only. mode-sqld numbers below
are from this run; mode-turso-cdc from the previous chapter
(bench/results/mode-turso-cdc/).
Read-heavy (oha 20 s, WP home page rendered through node-a):
| c | turso-cdc rps | sqld rps | turso-cdc p50 | sqld p50 | turso-cdc p99 | sqld p99 | success |
|---|---|---|---|---|---|---|---|
| 1 | 1.60 | 1.60 | 628 ms | 657 ms | 718 ms | 730 ms | 100% |
| 4 | 12.40 | 12.05 | 336 ms | 287 ms | 423 ms | 546 ms | 100% |
| 16 | 26.25 | 27.55 | 576 ms | 542 ms | 1735 ms | 1713 ms | 100% |
| 64 | 34.44 | 39.60 | 1640 ms | 1439 ms | 6622 ms | 5635 ms | 100% |
Write-heavy (serial round-robin wp post create, 5 per client β see
Phase D #9):
| c | turso-cdc write rps | sqld write rps | turso-cdc catchup | sqld catchup | replicated (both) |
|---|---|---|---|---|---|
| 1 | 0.20 | 0.18 | 5695 ms | 7299 ms | 5/5 |
| 4 | 0.20 | 0.20 | 7198 ms | 4793 ms | 20/20 |
| 16 | 0.19 | 0.20 | 4608 ms | 4144 ms | 80/80 |
Install-time (wp core install wall clock through the primary,
bench/results/mode-*/install/iter-1.txt):
| Mode | Install wall (ms) | Homepage HTTP |
|---|---|---|
| turso-cdc | 64 530 | 200 |
| sqld | 64 683 | 200 |
Replication-lag distribution (50 sequential posts):
| Mode | post_lag (single) | p50 | p99 | samples | passed |
|---|---|---|---|---|---|
| turso-cdc | 13 976 ms | 13 068 ms | 17 561 ms | 50 | 50/50 |
| sqld | 15 268 ms | 14 031 ms | 96 988 ms | 50 | 50/50 |
Read the A/B:
- Reads scale identically across c=1β16; sqld pulls +15% rps at c=64. Both saturate around 35β40 rps at c=64 β the ceiling is the PHP+MySQL-wire path on the primary, not the storage engine.
- Writes are near-parity in throughput (both ~0.2 rps, floor bound by wp-cli wall time β Phase D #9). Catchup on the replica is faster on sqld at c=4/16 (WAL-frame streaming is tighter than the turso CDC batch tail), slower at c=1 (cold-path setup dominates).
- Zero writes dropped in either mode across all concurrencies.
- p99 replication lag is sqld's outlier β 97 s vs turso-cdc's 17.6 s. Median is comparable (14 s vs 13 s). The p99 blowout came from one iteration in the 50-post loop; the single-post Test 2 didn't reproduce it. Worth revisiting if this bench ever gates a release; for a demonstration fixture, functional correctness end-to-end is proven.
Same fixture, no rate-limiter taint, no cluster-side flag flips. Engine swap is the only variable.
Discovered by this exercise (all real bugs, all filed against the
built ephpm binary of feat/turso-cdc-replication @ 41a3726):
FIXED in ephpm/ephpm#189 (commitephpm-sqld/src/lib.rs::spawn_childpasses--grpc-listen-addreven for Replica roles β sqld exits immediately β mode-sqld can't replicate at all.93e920c), cherry-picked into the fixture build. See Phase C mode-sqld section above for the post-fix bring-up. 0.5. The turso-cdc tail loop starts before any wire session enablescapture_data_changes_conn, so both nodes spamERROR ephpm_server::turso_cdc: CDC tail poll error: SQLite error: Parse error: no such table: turso_cdcat ~10 Hz until the first PHP request lands. Loop recovers cleanly; user-hostile in the interim.
Split-brain-at-boot β election is lowest-ordinal wins over gossip
KV, but if both nodes bootstrap before gossip finishes exchanging
state each writes itself into the sqlite:primary KV slot and the
second writer wins by last-write-wins. Workaround: scripts/up.sh
stages 8s between node starts. Consensus (Raft/Paxos) would eliminate
this.
Design gaps still applicable (from the original readme):
- Fresh replica bootstrap is not implemented β a brand-new node-b
started against an already-populated node-a only sees writes from
the moment its
cdc/defaultstream opens. Operator ships the initial DB file out-of-band or accepts forward-only replication. Snapshot-over-transport is reserved inephpm_cluster::stream_type::SNAPSHOT_PREFIXbut not built. (The bench works around this by starting both nodes empty and letting wp-install run through the primary.) - Kill-primary loses unshipped batches β a committed transaction on the primary whose CDC row hadn't yet been polled + broadcast + written by the subscriber is gone. Same divergence class as sqld async replication. No sync/quorum knob.
- Watermark resets on restart β
CdcTailer::new(&mgmt, 0)starts from cursor 0; on a primary restart it re-scans and re-broadcasts already-replayed batches.apply_batchis idempotent so replicas aren't corrupted, but bandwidth is wasted. - Turso engine limitations β
VACUUMis rejected. Empty result sets from unexecuted prepared SELECTs needcolumn_decltypeto fill in types; when Turso 0.7 can't provide it, the wire frontend falls back to a LIMIT 0 probe. Some pragma paths still surprising. Also:wp core install's transient-cleanup step uses MySQL multi-tableDELETE a, b FROM t a, t b WHERE ...β not translated by litewire, logged as a WordPress database error but install still succeeds. - TLS between nodes is deferred.
[cluster.channel]handshakes with a shared secret (HKDF-derived ChaCha20-Poly1305 for the KV plane); the cluster channel data plane is opaque yamux over TCP β authenticated by the channel handshake, not TLS-wrapped. - Election is gossip-KV lowest-ordinal-wins. Stable and simple, no Raft/Paxos underneath. Split brains during partitions avoided by TTL-heartbeat expiry, not consensus.
- PHP mode = "fpm" (request-per-thread). WordPress runs stock; the
ePHPm
workeradapter is a separate story and not tested here. - This is a loopback-network single-host bench, not a bare-metal-cluster bench. Numbers below reflect a single Windows 11 laptop running podman-machine (WSL2) with both containers on the same podman bridge β network latency is zero-ish, contention is CPU-only. A real cluster on real network would be different (probably worse on lag, similar on rps ceilings).
- Write-heavy bench is serial-interleaved, not true-parallel.
On this box's git-bash + podman-remote combination,
( inner ) &background subshells hit a container-launch race (podman-run inside the backgrounded subshell never spawned). Thebench-write.shworkaround is to round-robin the wp-cli calls one client at a time, soc=Nmeasures N clients each firing sequentially in round-robin order rather than N truly simultaneous. Absolute throughput is a lower bound; relative comparison acrosscis still meaningful for saturation / lag behaviour.
Rig: Windows 11 laptop, podman-machine (WSL2 Ubuntu, 16 vCPU / 2 GB
guest), ephemerd fleet OFF during bench (no runner CPU competition).
Both modes measured; see the Phase C side-by-side comparison above
and bench/CHART.txt for the two-column ASCII rendering.
Because the write bench is serial (see Phase D #9), the "write rps"
is essentially "1 / per-write wall time" β 0.2 rps = 5 s per commit
through wp-cli β MySQL wire β litewire β (Turso | rusqlite β sqld).
The important number is replica catchup β after N Γ 5 writes on the
primary, node-b sees the FULL SET in 4.1β7.3 s across both modes.
Zero writes were dropped (replicated = total_posts for every c
and every mode).
Numbers here are for one iteration; scripts/bench-run-all.sh chains
three iterations and scripts/bench-summarise.py computes medians.
For headline numbers: both modes reach ~35β40 rps at c=64 with
100% success at ~1.5 s p50 latency (sqld slightly faster); both
replicate every write with zero drops; cross-node write visibility
is limited by the ~14 s wp-cli/podman outer-loop cost β not by the
storage engine, which streams under 15 s at the median in both
modes.
bin/ built ephpm binary (glibc-dynamic Linux)
configs/mode-*/ per-mode node-a.toml and node-b.toml
docker/ Dockerfile.quick β slim runtime image (binary + tini)
scripts/ build-binary.sh, up.sh, down.sh, wp-install.sh,
replicate-check.sh, failover.sh,
bench-install.sh, bench-read.sh, bench-write.sh,
bench-run-all.sh, bench-summarise.py,
lib-wp.sh (shared wp-cli helper)
bench/results/ raw bench output per mode/workload/iteration
evidence/ phase-a-transcript.txt (Phase A verification),
phase-c-transcript.txt (post-plumbing-fix
cluster + replication run for mode-turso-cdc,
plus the original mode-sqld failure that
motivated ephpm/ephpm#189)
sqld-phase-c-transcript.txt (post-fix mode-sqld
cluster + replication + bench run β this
chapter's deliverable)
replcheck-turso-cdc.txt (raw replicate-check output)
sqld-mode-startup-failure.txt (pre-fix runtime log
β kept for the ephpm#189 backstory)
archive-pre-plumbing-fix/ (the original Phase C
run that surfaced the bugs since fixed)
pin-lines.txt (every UPSTREAM_PENDING_7855 site
plus the ephpm/ephpm#189 marker)
BUILDING.md how the temp patches are applied and how to unwind
MIT. This is a demonstration fixture; the interesting code is upstream in ephpm/ephpm and ephpm/litewire.
Assembled by Claude Fable.