Skip to content

Commit 4c2c1c5

Browse files
committed
Add bencher_replica: in-process SQLite replication to replace Litestream
Litestream 0.5.13 blocked all API writes for ~5.5 minutes in production (2026-07-10): when it decides a full re-snapshot is needed, it copies the entire database into a local LTX file while holding the SQLite write lock, and no configuration reaches that code path. Its LTX compaction also churns whole-database S3 transfers several times a day. bencher_replica is an in-process replacement built around six invariants (documented in src/lib.rs), the prime one being that the SQLite write lock is only ever held for O(WAL-tail) work, never O(database): - WAL parser with full salt and cumulative checksum-chain verification - Local filesystem XOR S3-compatible storage behind one contract - Step-driven sync engine; checkpoints are PASSIVE while the replicator itself holds BEGIN IMMEDIATE, closing the ship-vs-checkpoint race without ever needing RESTART or TRUNCATE checkpoints - Generation-based snapshots via a single-step SQLite online backup into a scratch file (transactionally consistent under concurrent checkpoints), throttled zstd multipart upload, snapshot.json as the atomic commit marker - Latest-only restore in the same startup handshake slot Litestream used, with chain pre-validation and checkpoint-consumption verification - Restore-and-compare verification (default daily) and shadow mode: with both plus.litestream and plus.replica configured, Litestream keeps checkpoint ownership and restore precedence during the burn-in Also included: - Fix: standalone sweep connections (stats, credit grants) now disable wal_autocheckpoint when replication is configured; previously the credit sweep could checkpoint and restart the WAL behind Litestream's back - plus.replica config (JsonReplication), otel Replica* counters, main.rs lifecycle wiring (restore precedence, fatal race arm, final ship inside the Fly kill budget), TestServer::new_with_replica, Dockerfile stubs - JsonLitestream.metrics_port and [[metrics]] in the Fly configs so litestream_* Prometheus metrics are scraped during the shadow period Testing: 275 crate tests (WAL fixtures cross-validated against SQLite itself, a three-backend storage contract suite, 8 fault-injection scenarios, 6 crash kill points, 8 seeded 200-op equivalence workloads, ignored soak and live-S3 tiers) plus 4 server-level integration tests. An adversarial multi-agent review confirmed 18 findings, all fixed with regression tests, including a silent data-loss gap in resume (salt-match resume now proves content against the replica tip, and the meta-verified path requires salt1 continuity).
1 parent 30a740a commit 4c2c1c5

64 files changed

Lines changed: 17567 additions & 49 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 37 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ bencher_otel_provider = { path = "plus/bencher_otel_provider" }
6666
bencher_oci_storage = { path = "plus/bencher_oci_storage" }
6767
bencher_rate_limiter = { path = "plus/bencher_rate_limiter" }
6868
bencher_recaptcha = { path = "plus/bencher_recaptcha" }
69+
bencher_replica = { path = "plus/bencher_replica" }
6970
# plus - runner
7071
bencher_runner = { path = "plus/bencher_runner" }
7172
bencher_oci = { path = "plus/bencher_oci" }

docker/bench.Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ RUN cargo init --lib bencher_otel
6060
RUN cargo init --lib bencher_otel_provider
6161
RUN cargo init --lib bencher_rate_limiter
6262
RUN cargo init --lib bencher_recaptcha
63+
RUN cargo init --lib bencher_replica
6364
RUN cargo init --lib bencher_rootfs
6465
RUN cargo init --lib bencher_runner
6566
RUN cargo init --lib bencher_init

lib/api_server/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ slog.workspace = true
4343
[dev-dependencies]
4444
bencher_api_tests.workspace = true
4545
bencher_json = { workspace = true, features = ["server", "schema"] }
46+
bencher_replica = { workspace = true, features = ["plus", "testing"] }
47+
camino.workspace = true
4648
http.workspace = true
49+
rusqlite.workspace = true
50+
tempfile.workspace = true
4751
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
4852

4953
[lints]

lib/api_server/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@
22
#[cfg(test)]
33
use bencher_api_tests as _;
44
#[cfg(test)]
5+
use bencher_replica as _;
6+
#[cfg(test)]
7+
use camino as _;
8+
#[cfg(test)]
59
use http as _;
610
#[cfg(test)]
11+
use rusqlite as _;
12+
#[cfg(test)]
13+
use tempfile as _;
14+
#[cfg(test)]
715
use tokio as _;
816

917
mod backup;

lib/api_server/tests/replica.rs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
#![cfg(feature = "plus")]
2+
#![expect(unused_crate_dependencies, reason = "integration test file")]
3+
//! Integration tests for in-process replication (`bencher_replica`) against
4+
//! a full API server: real Dropshot server, real writes through the API,
5+
//! step-driven replication engine, restore-and-compare verification.
6+
7+
#[cfg(test)]
8+
mod cases {
9+
use bencher_api_tests::TestServer;
10+
use bencher_json::system::config::{JsonReplication, ReplicationTarget};
11+
use bencher_replica::testing::assert_replica_equivalent;
12+
use bencher_replica::{
13+
CheckpointOutcome, EngineState, ReplicaConfig, RestoreOutcome, SyncEngine,
14+
restore_if_missing,
15+
};
16+
use camino::{Utf8Path, Utf8PathBuf};
17+
18+
fn dir_path(tmp: &tempfile::TempDir) -> Utf8PathBuf {
19+
Utf8Path::from_path(tmp.path())
20+
.expect("tempdir path is UTF-8")
21+
.to_path_buf()
22+
}
23+
24+
fn replica_json(root: &Utf8Path) -> JsonReplication {
25+
JsonReplication {
26+
target: ReplicationTarget::File {
27+
path: root.to_path_buf().into_std_path_buf(),
28+
},
29+
sync_interval_secs: None,
30+
checkpoint_interval_secs: None,
31+
min_checkpoint_pages: None,
32+
snapshot_interval_secs: None,
33+
snapshot_throttle_mib: None,
34+
retention_generations: None,
35+
verification_interval_secs: None,
36+
shutdown_sync_timeout_secs: None,
37+
}
38+
}
39+
40+
fn logger() -> slog::Logger {
41+
slog::Logger::root(slog::Discard, slog::o!())
42+
}
43+
44+
/// Drive the engine through the fresh-replica bootstrap snapshot.
45+
async fn until_streaming<C: Send + 'static>(engine: &mut SyncEngine<C>) {
46+
for _ in 0u8..64 {
47+
if engine.state() == EngineState::Streaming {
48+
return;
49+
}
50+
engine.sync_once().await.expect("bootstrap sync");
51+
}
52+
panic!(
53+
"engine never reached Streaming; state: {:?}",
54+
engine.state()
55+
);
56+
}
57+
58+
// Real API writes replicate, and the replica restores to an equivalent
59+
// database.
60+
#[tokio::test]
61+
async fn server_writes_replicate_and_restore() {
62+
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
63+
let replica_root = dir_path(&replica_tmp);
64+
let (server, mut engine) =
65+
TestServer::new_with_replica(replica_json(&replica_root), None, false).await;
66+
until_streaming(&mut engine).await;
67+
68+
// Writes through the real API.
69+
let _admin = server.signup("Replica Admin", "replica@example.com").await;
70+
let _user = server
71+
.signup("Replica User", "replicauser@example.com")
72+
.await;
73+
engine.sync_once().await.expect("ship API writes");
74+
75+
// The replica restores to a logically equivalent database.
76+
let restore_tmp = tempfile::tempdir().expect("restore tempdir");
77+
let target_db = dir_path(&restore_tmp).join("restored.db");
78+
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
79+
let outcome = restore_if_missing(&logger(), &config, &target_db)
80+
.await
81+
.expect("restore");
82+
assert!(
83+
matches!(outcome, RestoreOutcome::Restored { .. }),
84+
"expected Restored, got {outcome:?}"
85+
);
86+
assert_replica_equivalent(
87+
Utf8Path::from_path(server.db_path()).expect("db path is UTF-8"),
88+
&target_db,
89+
);
90+
}
91+
92+
// The startup handshake: a missing database file is rebuilt from the
93+
// replica, and the signed-up user is present in the restored database.
94+
#[tokio::test]
95+
async fn server_reboot_missing_db_auto_restores() {
96+
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
97+
let replica_root = dir_path(&replica_tmp);
98+
let (server, mut engine) =
99+
TestServer::new_with_replica(replica_json(&replica_root), None, false).await;
100+
until_streaming(&mut engine).await;
101+
let _admin = server.signup("Reboot Admin", "reboot@example.com").await;
102+
engine.sync_once().await.expect("ship API writes");
103+
drop(engine);
104+
drop(server);
105+
106+
// "Reboot": the volume is gone; the same restore-if-missing handshake
107+
// that main.rs runs rebuilds the database before any connection opens.
108+
let boot_tmp = tempfile::tempdir().expect("boot tempdir");
109+
let new_db = dir_path(&boot_tmp).join("bencher.db");
110+
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
111+
let outcome = restore_if_missing(&logger(), &config, &new_db)
112+
.await
113+
.expect("restore");
114+
assert!(
115+
matches!(outcome, RestoreOutcome::Restored { .. }),
116+
"expected Restored, got {outcome:?}"
117+
);
118+
let conn = rusqlite::Connection::open(&new_db).expect("open restored db");
119+
let users: i64 = conn
120+
.query_row(
121+
"SELECT count(*) FROM user WHERE email = 'reboot@example.com'",
122+
[],
123+
|row| row.get(0),
124+
)
125+
.expect("query restored user");
126+
assert_eq!(users, 1, "signed-up user must survive the reboot restore");
127+
}
128+
129+
// A fresh server over an empty replica is a clean fresh start, and the
130+
// existing database is never touched.
131+
#[tokio::test]
132+
async fn server_boot_empty_replica_fresh_start() {
133+
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
134+
let replica_root = dir_path(&replica_tmp);
135+
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
136+
137+
// Empty replica, missing database: NoReplica (fresh server).
138+
let boot_tmp = tempfile::tempdir().expect("boot tempdir");
139+
let new_db = dir_path(&boot_tmp).join("bencher.db");
140+
let outcome = restore_if_missing(&logger(), &config, &new_db)
141+
.await
142+
.expect("restore");
143+
assert!(
144+
matches!(outcome, RestoreOutcome::NoReplica),
145+
"expected NoReplica, got {outcome:?}"
146+
);
147+
assert!(!new_db.exists(), "fresh start must not create a database");
148+
149+
// Existing database: Skipped, file untouched.
150+
let (server, mut engine) =
151+
TestServer::new_with_replica(replica_json(&replica_root), None, false).await;
152+
until_streaming(&mut engine).await;
153+
let outcome = restore_if_missing(
154+
&logger(),
155+
&config,
156+
Utf8Path::from_path(server.db_path()).expect("db path is UTF-8"),
157+
)
158+
.await
159+
.expect("restore");
160+
assert!(
161+
matches!(outcome, RestoreOutcome::Skipped),
162+
"expected Skipped, got {outcome:?}"
163+
);
164+
}
165+
166+
// Shadow mode: the replica ships and restores alongside a (nominal)
167+
// Litestream, but never checkpoints.
168+
#[tokio::test]
169+
async fn server_shadow_mode_ships_without_checkpoints() {
170+
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
171+
let replica_root = dir_path(&replica_tmp);
172+
let (server, mut engine) =
173+
TestServer::new_with_replica(replica_json(&replica_root), None, true).await;
174+
until_streaming(&mut engine).await;
175+
let _admin = server.signup("Shadow Admin", "shadow@example.com").await;
176+
engine.sync_once().await.expect("ship API writes");
177+
178+
// Shadow never checkpoints: Litestream keeps checkpoint ownership.
179+
let outcome = engine.checkpoint_once().await.expect("checkpoint");
180+
assert_eq!(outcome, CheckpointOutcome::SkippedShadow);
181+
182+
// The shadow replica still restores to an equivalent database.
183+
let restore_tmp = tempfile::tempdir().expect("restore tempdir");
184+
let target_db = dir_path(&restore_tmp).join("restored.db");
185+
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
186+
let outcome = restore_if_missing(&logger(), &config, &target_db)
187+
.await
188+
.expect("restore");
189+
assert!(
190+
matches!(outcome, RestoreOutcome::Restored { .. }),
191+
"expected Restored, got {outcome:?}"
192+
);
193+
assert_replica_equivalent(
194+
Utf8Path::from_path(server.db_path()).expect("db path is UTF-8"),
195+
&target_db,
196+
);
197+
}
198+
}

lib/bencher_api_tests/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ plus = [
1212
"bencher_api/plus",
1313
"bencher_config/plus",
1414
"bencher_oci_storage/plus",
15+
"bencher_replica/plus",
1516
"bencher_schema/plus",
1617
"dep:bencher_license",
1718
"dep:bencher_oci_storage",
19+
"dep:bencher_replica",
20+
"dep:camino",
1821
"dep:hex",
1922
"dep:http",
2023
"dep:serde_json",
@@ -30,6 +33,8 @@ bencher_endpoint.workspace = true
3033
bencher_json = { workspace = true, features = ["server", "schema", "db", "test-clock"] }
3134
bencher_license = { workspace = true, optional = true }
3235
bencher_oci_storage = { workspace = true, optional = true, features = ["test-clock"] }
36+
bencher_replica = { workspace = true, optional = true, features = ["test-clock"] }
37+
camino = { workspace = true, optional = true }
3338
bencher_rbac.workspace = true
3439
bencher_schema.workspace = true
3540
bencher_token.workspace = true

0 commit comments

Comments
 (0)