Skip to content

bench(db): Turso cluster per-vhost matrix + v0.8.x pin sweep + repro fixes - #4

Merged
luthermonson merged 3 commits into
mainfrom
bench/turso-cluster-vhost-matrix
Sep 1, 2026
Merged

bench(db): Turso cluster per-vhost matrix + v0.8.x pin sweep + repro fixes#4
luthermonson merged 3 commits into
mainfrom
bench/turso-cluster-vhost-matrix

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

Modernizes the lab for the v0.8.x line and delivers the suite DB-BENCH.md
promised when it marked the engines/admission lanes historical:

"A future v0.7.0 pin bump replaces them with a Turso-single vs
Turso-CDC-clustered matrix rather than editing these lanes."

Added alongside those lanes, not by editing them. The historical suites are
untouched and stay pinned to v0.6.3.

The new cluster suite

Five lanes, one vhost, on the local podman tier (one host, --cpus 1 per
node, oha, warmup + two timed reps) — the same shape as every other suite
in DB-BENCH.md. Cells are bridge-point (10 sequential point SELECTs via
ephpm_db_query()) and bridge-write (1 INSERT via ephpm_db_execute()) at
c=1 and c=16.

Lane Shape Measured on Answers
S-turso-single single-site, 1 node the node Anchor; ties back to the historical bridge suite's B-turso lane
W-cluster-primary single-site, whole-DB clustered, 2 nodes the primary What CDC capture + shipping cost. S→W = "what does clustering cost"
P1-persite-single multi-tenant, 1 node, 1 DB per vhost the node The v0.7.0+ multi-tenant default; the reference point
P2-persite-owner multi-tenant clustered, 3 nodes the site's HRW owner P1→P2 = "what does clustering cost a tenant"
P3-persite-remote same cluster, same site a non-owner The sql/<site> forward hop. P2→P3 is the headline

The three multi-tenant lanes add a wire-point cell over stock pdo_mysql.
That is not a duplicate: pdo_mysql is not forwarded to the owner (the
documented ephpm#416 gap), so on a non-owner the bridge cell pays the hop and
the wire cell does not. Measuring both makes "the difference is the hop"
falsifiable rather than asserted.

parse.sh needed no changes — bridge-point, bridge-write and wire-point
were already in its suffix list.

Validation status — please read before merging

Authored and gate-validated. Not recorded. No reference numbers published.

  • Lanes S, W and P1 ran end to end against ephpm/ephpm:v0.8.5-php8.5 on
    podman: mode gates pass, fixtures return the canonical sum:55, whole-DB
    replication converged in ~1 s, every cell 100 % 2xx, parse.sh renders them.
    Those were short runs to prove the lanes produce clean numbers — they are
    not measurements
    and none of them appear in the docs.
  • Lanes P2 and P3 have never run in their intended mode. Per-site clustered
    replication (ephpm#416) is not in any published image: it first appears in
    the v0.8.6 tag, whose Docker images are not on Docker Hub yet. Point
    EPHPM_PERSITE_CLUSTER_IMAGE at a v0.8.6+ build to run them.

The mode gate earned its place on the first run

Pointed at the newest published image, the per-site clustered lanes produced a
healthy, fast, fully-2xx three-node cluster. It was running whole-database
clustered mode, because per_site does not exist in that image and
ephpm-config does not reject unknown fields. Every gate except the log
assertion passed. Without it this PR would have shipped a per-vhost forward-hop
number measured on a topology that has no forward hop.

Each lane now asserts a specific startup line before measuring, and P2/P3
refuse with a message naming the override rather than reporting a mislabelled
result. Two further gates: a negative control (same fixtures without the
vhost Host header must fail with "no per-site database context", proving the
tenant comes from the request, not the mount) and an ownership gate
requiring exactly one node named owner by two independent observations
(ephpm_cdc_subscribers on /metrics and elected as SQLite primary in the
log). /_ephpm/primary cannot serve here — in per-site mode it answers 200 on
every healthy node by design.

An upstream finding worth a look

On a non-owner the bridge hands back a remote proxy and never opens the
site's local database file
, so the registry's open-hook never fires and that
node never starts a replica driver for the site — it replicates nothing. A
stock pdo_mysql request is what opens it locally. The harness therefore hits
count.php on every node before gating on convergence, and the ordering is
documented. Outside the lab this means a per-site clustered deployment whose
apps use only the ephpm_db_* drop-ins may never open a tenant's database on
the nodes that do not own it.
Flagging it here rather than filing upstream.

Pin bump sweep

  • All 12 ePHPm pins in k8s/v0.8.5-php8.4. I checked every ePHPm
    config those manifests use — inline ConfigMaps, start-script heredocs,
    wordpress-v5/ scripts, scale/config/ephpm.tmpl.toml, kv/ and
    containment/ heredocs — and none sets a knob v0.7.0 removed.
  • db/ deliberately not bumped. 14 configs set engine = "sqlite" (a hard
    startup error on v0.7.0+) and 8 sweep the removed write_permits. Bumping
    would replace a measurement with a failed launch. run-db-bench.sh now picks
    a default image per suite: v0.6.3 for the five historical suites, newest
    published for cluster.
  • The pin moved; the recorded numbers did not. README and RUNTIMES-BENCH now
    say which version each table was measured on and that they are due for a
    re-record, per the repo's own rule about comparing across a bump.

Other things found while sweeping

  • bench-proxy.sh never creates its upstreams. Lanes D–I need containers
    named dbbench-mysql/dbbench-pg that no script in the repo starts — so six
    lanes, including the ones behind the published "postgres +117 %" number, fail
    their gates with no explanation. Now checked with the exact two commands
    printed. (Not auto-started: a fresh mysql:8 per run adds minutes of initdb.)
  • bench-proxy.sh cited a parse-proxy.sh that does not exist.
  • Two probes never created their (gitignored) results dir, so they died on a
    redirect on a fresh clone; all three assumed a network a bench script had to
    have created first.
  • render-laravel-v4.sh substituted a token that no longer exists while still
    refusing to run without EPHPM_SOURCE_IMAGE — blocking a documented
    reproduction path behind a value it then discarded.
  • scale/README.md pointed at report/ (actual: reports/), including a
    cd report && go run . that cannot work.
  • OPCACHE-CLUSTER.md claimed a v0.5.0 pin; the manifest already said v0.6.3.
  • README's first sentence named two versions in no manifest, and its roadmap
    asked for a runtimes comparison the repo already ships.
  • New db/cleanup.sh — the suites reclaim volumes at the start of a run,
    so run-db-bench.sh all leaves ~10 named volumes (one holding a seeded
    WordPress tree) plus a network, across five name prefixes. Dry-run by
    default. It never touches db/results-*/.

Not addressed

  • Re-recording any existing table on v0.8.5 — separate, and it is a
    measurement run, not a code change.
  • Failover: every lane measures a settled cluster. Per-site ownership churn is
    where the interesting failure modes live and this suite deliberately does not
    go there.
  • RUNTIMES-BENCH.md's Class A rows are still v0.4.0 numbers under a v0.8.5
    pin. Labelled, not fixed.

DB-BENCH.md's historical banner promised that a future pin bump would
replace the removed rusqlite/sqld lanes with a Turso-single vs
Turso-CDC-clustered matrix, added alongside rather than by editing them.
This is that suite, plus one axis the promise predates: per-VHOST
clustered replication (ephpm#416), where ownership of a site is decided
by rendezvous hashing and a non-owner forwards every ephpm_db_* statement
to the owner over sql/<site>.

Five lanes, one vhost, `bridge-point` / `bridge-write` cells at c=1 and
c=16 with the same warmup-plus-two-reps shape as every other suite:

  S-turso-single      no cluster; the anchor and the tie-back to the
                      historical `bridge` suite's B-turso lane
  W-cluster-primary   whole-DB clustered, measured on the primary
  P1-persite-single   one DB per vhost, single node (v0.7.0+ default)
  P2-persite-owner    one REPLICATED DB per vhost, on the site's owner
  P3-persite-remote   the same cluster and site, on a non-owner

P2 -> P3 is the forward hop. The three multi-tenant lanes also measure a
`wire-point` cell over stock pdo_mysql, which is NOT forwarded, so it
makes the hop claim falsifiable instead of asserted: if P3's wire cell
were also slower, the cause would not be forwarding.

parse.sh needed no changes -- `bridge-point`, `bridge-write` and
`wire-point` were already in its suffix list.

The mode gate earned its place immediately. Four database modes are
selected by a conjunction of keys across four config sections, and
ephpm-config does not reject unknown fields. The first run of the
per-site clustered lanes against the newest published image produced a
healthy, fast, fully-2xx three-node cluster running whole-DATABASE
clustered mode, because `per_site` does not exist in that image. Every
other gate passed. Each lane now asserts a specific startup line before
measuring anything, and P2/P3 refuse to run with a message naming
EPHPM_PERSITE_CLUSTER_IMAGE rather than reporting a mislabelled result.

Two further gates specific to this suite: a negative control (the same
fixtures without the vhost Host header must fail with "no per-site
database context", proving the tenant comes from the request and not the
mount), and an ownership gate requiring exactly one node to be named
owner by two independent observations (ephpm_cdc_subscribers on /metrics
and `elected as SQLite primary` in the log). /_ephpm/primary cannot serve
here -- in per-site mode it answers 200 on every healthy node by design.

The convergence proof writes through the bridge and counts over
pdo_mysql, and the direction is load-bearing: a bridge-side count on a
non-owner forwards to the owner, so it would agree with itself even if
replication were dead.

Setup ordering is documented because it has teeth: on a non-owner the
bridge hands back a remote proxy and never opens the site's local file,
so the open-hook never fires and that node never starts a replica driver.
A stock pdo_mysql request is what opens it. A per-site clustered
deployment whose apps use only the drop-ins may never open a tenant's
database on the nodes that do not own it.

The divergence probe (stock pdo_mysql writes are not forwarded) runs
AFTER the measurements, never before -- the same ordering rule the pooled
proxy lanes learned the hard way when a pre-measurement probe poisoned
the pool and produced 876 req/s of pure HTTP 500.

The driver now picks a default image PER SUITE: the five pre-v0.7.0
suites keep v0.6.3 (bumping them would replace a measurement with a
failed launch -- `engine = "sqlite"` is a hard startup error on v0.7.0+),
and `cluster` tracks the newest published image.

Status: authored and gate-validated on ephpm/ephpm:v0.8.5-php8.5, NOT
recorded. Lanes P2/P3 have never run in their intended mode -- per-site
clustered replication first appears in the unreleased v0.8.6 tag. No
reference numbers are published.
Every ePHPm pin in k8s/ was v0.6.3-php8.4, three minor lines behind the
newest published image. All twelve now pin v0.8.5-php8.4.

This is safe to bump and the db/ tier is not: I checked every ePHPm
config the manifests actually use -- inline ConfigMaps, start-script
heredocs, and the wordpress-v5/ scripts -- and none of them sets a knob
v0.7.0 removed. The blocker is entirely inside db/configs/, where
fourteen files set `engine = "sqlite"` and eight sweep `write_permits`.
Those stay pinned to v0.6.3 as the historical record.

The pin moved; the recorded numbers did not, and the tables say so
rather than being quietly restated against the new pin. README and
RUNTIMES-BENCH now carry an explicit note that their rows were measured
on v0.4.0-v0.6.3 and are due for a re-record -- the repo's own rule is
that a version bump changes the effective configuration (v0.5.0
autotuning being the documented example) so numbers recorded across a
bump are not comparable.

Also corrected, all version claims that had drifted into the present
tense:

- OPCACHE-CLUSTER.md claimed the manifests pin v0.5.0. They pinned
  v0.6.3 even before this change -- the doc was already a bump behind
  its own manifest. It now separates the FEATURE floor (>= v0.4.0) from
  the pin.
- README's first sentence named v0.5.0 and "the current worker retest on
  v0.6.0", neither of which appeared in any manifest.
- README's roadmap asked for an Octane/Swoole/RoadRunner/ePHPm
  comparison that RUNTIMES-BENCH.md already contains.
- RUNTIMES-BENCH's "Experimental: Turso engine db lane" described
  `engine = "turso"` as experimental and warned the lane would "collapse
  on any future v0.7.0+ pin bump". That bump is this commit: the knob is
  now a no-op restatement of the default, so the lane duplicates
  bench-ephpm rather than contrasting with it. The lane is kept but
  relabelled, and the comparison it was reaching for is pointed at the
  new `cluster` suite -- on the local tier, where a microsecond-wide
  effect is actually resolvable.
- The db-engines row in README's benchmark map is marked historical, and
  a row for the new `cluster` suite added, marked authored-not-recorded.
Found while sweeping the repo for the pin bump. Each of these turns a
silent failure into either a working command or an actionable message.

bench-proxy.sh: lanes D-I and the pg-cliff probe need containers named
dbbench-mysql and dbbench-pg, which no script in the repo creates. That
was an unstated assumption, so a first-time user got six lanes of gate
failures with no indication why -- including the lanes behind the
published "postgres +117%" number, which is the first thing a skeptical
reader will try to reproduce. The upstreams are deliberately NOT started
per-run (a fresh mysql:8 would add minutes of initdb to every
invocation); instead the script now checks for them and prints the exact
two podman commands. Its gate list also referenced a parse-proxy.sh that
does not exist; the parser is parse.sh.

probe-pg.sh and probe-clean-vs-dirty.sh never created their results
directory, so on a fresh clone (db/results-*/ is gitignored) both died
on a redirect. All three probes also used --network dbbench-net without
creating it, working only as a side effect of having run a bench script
first -- undocumented, since DB-BENCH.md lists them as standalone.

render-laravel-v4.sh substituted a placeholder token that no longer
exists in laravel-v4.yaml, while still refusing to run without
EPHPM_SOURCE_IMAGE: you had to supply a value that was then discarded,
and run-v4-worker-baseline.sh (which reads .generated/) could not
proceed without inventing one. It now rewrites the real image lines when
the variable is set and passes the manifest through when it is not.

scale/README.md pointed at a report/ directory that is named reports/ --
including a `cd report && go run .` that cannot work. Its own provenance
note already said the directory was renamed on import.

New db/cleanup.sh: the bench scripts reclaim volumes at the START of a
run, which is right for re-running a lane but means everything outlives
it. `run-db-bench.sh all` leaves roughly ten named volumes -- one
holding a full seeded WordPress tree -- plus a network, and no script
removes any of it. The names span five prefixes while sharing one
network, so the obvious `podman rm -f dbbench-*` misses half. Dry-run by
default; --yes to remove. It never touches db/results-*/: that is the
measurement, and a cleanup script must not be able to delete a result.
(bench-cluster.sh already cleans up after itself; the older suites are
left alone because their traps are load-bearing for the historical
lanes.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant