Skip to content

Commit dcd02f4

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 d2895af commit dcd02f4

65 files changed

Lines changed: 20805 additions & 77 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
@@ -67,6 +67,7 @@ bencher_otel_provider = { path = "plus/bencher_otel_provider" }
6767
bencher_oci_storage = { path = "plus/bencher_oci_storage" }
6868
bencher_rate_limiter = { path = "plus/bencher_rate_limiter" }
6969
bencher_recaptcha = { path = "plus/bencher_recaptcha" }
70+
bencher_replica = { path = "plus/bencher_replica" }
7071
# plus - runner
7172
bencher_runner = { path = "plus/bencher_runner" }
7273
bencher_oci = { path = "plus/bencher_oci" }

docker/bench.Dockerfile

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

lib/api_server/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,12 @@ slog.workspace = true
4242

4343
[dev-dependencies]
4444
bencher_api_tests.workspace = true
45-
bencher_json = { workspace = true, features = ["server", "schema"] }
45+
bencher_json = { workspace = true, features = ["server", "schema", "test-clock"] }
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/src/stats.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use bencher_json::{
44
};
55
use bencher_schema::{
66
auth_conn,
7-
context::{ApiContext, DbConnection},
7+
context::{ApiContext, DbConnection, configure_standalone_connection},
88
error::{forbidden_error, issue_error, not_found_error},
99
model::{
1010
server::QueryServer,
@@ -49,8 +49,23 @@ pub async fn server_stats_get(
4949

5050
async fn get_one_inner(log: &Logger, context: &ApiContext) -> Result<JsonServerStats, HttpError> {
5151
let query_server = QueryServer::get_server(auth_conn!(context))?;
52-
let conn = DbConnection::establish(context.database.path.to_string_lossy().as_ref())
52+
let mut conn = DbConnection::establish(context.database.path.to_string_lossy().as_ref())
5353
.map_err(not_found_error)?;
54+
// Route this standalone connection through the shared configuration so it
55+
// does not bypass the busy_timeout / autocheckpoint settings the pools and
56+
// the writer use: under replication a stray write must never checkpoint.
57+
configure_standalone_connection(
58+
&mut conn,
59+
context.database.busy_timeout,
60+
context.database.replicated,
61+
)
62+
.map_err(|e| {
63+
issue_error(
64+
"Failed to configure server stats connection",
65+
"Failed to configure the server stats database connection PRAGMAs",
66+
e,
67+
)
68+
})?;
5469
query_server
5570
.get_stats(log.clone(), conn, context.is_bencher_cloud)
5671
.await

lib/api_server/tests/replica.rs

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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+
// A fixed, injected clock. The repo rule is that time-based tests inject a
45+
// clock instead of racing the wall clock, and a frozen clock is also
46+
// strictly safer here: the SyncEngine's only clock-driven work is the
47+
// due-ness of periodic checkpoints, verification, and snapshots. With time
48+
// frozen, none of those become due, so these tests exercise exactly the
49+
// steps they drive explicitly (the bootstrap snapshot, ship, and
50+
// checkpoint). The initial-generation snapshot is driven by
51+
// `pending_new_generation` at construction, not the clock, so freezing time
52+
// does not gate `until_streaming` into a hang.
53+
fn test_clock() -> bencher_json::Clock {
54+
bencher_json::Clock::Custom(std::sync::Arc::new(|| bencher_json::DateTime::TEST))
55+
}
56+
57+
/// Drive the engine through the fresh-replica bootstrap snapshot.
58+
async fn until_streaming<C: Send + 'static>(engine: &mut SyncEngine<C>) {
59+
for _ in 0u8..64 {
60+
if engine.state() == EngineState::Streaming {
61+
return;
62+
}
63+
engine.sync_once().await.expect("bootstrap sync");
64+
}
65+
panic!(
66+
"engine never reached Streaming; state: {:?}",
67+
engine.state()
68+
);
69+
}
70+
71+
// Real API writes replicate, and the replica restores to an equivalent
72+
// database.
73+
#[tokio::test]
74+
async fn server_writes_replicate_and_restore() {
75+
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
76+
let replica_root = dir_path(&replica_tmp);
77+
let (server, mut engine) =
78+
TestServer::new_with_replica(replica_json(&replica_root), Some(test_clock()), false)
79+
.await;
80+
until_streaming(&mut engine).await;
81+
82+
// Writes through the real API.
83+
let _admin = server.signup("Replica Admin", "replica@example.com").await;
84+
let _user = server
85+
.signup("Replica User", "replicauser@example.com")
86+
.await;
87+
engine.sync_once().await.expect("ship API writes");
88+
89+
// The replica restores to a logically equivalent database.
90+
let restore_tmp = tempfile::tempdir().expect("restore tempdir");
91+
let target_db = dir_path(&restore_tmp).join("restored.db");
92+
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
93+
let outcome = restore_if_missing(&logger(), &config, &target_db)
94+
.await
95+
.expect("restore");
96+
assert!(
97+
matches!(outcome, RestoreOutcome::Restored { .. }),
98+
"expected Restored, got {outcome:?}"
99+
);
100+
assert_replica_equivalent(
101+
Utf8Path::from_path(server.db_path()).expect("db path is UTF-8"),
102+
&target_db,
103+
);
104+
}
105+
106+
// The startup handshake: a missing database file is rebuilt from the
107+
// replica, and the signed-up user is present in the restored database.
108+
#[tokio::test]
109+
async fn server_reboot_missing_db_auto_restores() {
110+
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
111+
let replica_root = dir_path(&replica_tmp);
112+
let (server, mut engine) =
113+
TestServer::new_with_replica(replica_json(&replica_root), Some(test_clock()), false)
114+
.await;
115+
until_streaming(&mut engine).await;
116+
let _admin = server.signup("Reboot Admin", "reboot@example.com").await;
117+
engine.sync_once().await.expect("ship API writes");
118+
drop(engine);
119+
drop(server);
120+
121+
// "Reboot": the volume is gone; the same restore-if-missing handshake
122+
// that main.rs runs rebuilds the database before any connection opens.
123+
let boot_tmp = tempfile::tempdir().expect("boot tempdir");
124+
let new_db = dir_path(&boot_tmp).join("bencher.db");
125+
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
126+
let outcome = restore_if_missing(&logger(), &config, &new_db)
127+
.await
128+
.expect("restore");
129+
assert!(
130+
matches!(outcome, RestoreOutcome::Restored { .. }),
131+
"expected Restored, got {outcome:?}"
132+
);
133+
let conn = rusqlite::Connection::open(&new_db).expect("open restored db");
134+
let users: i64 = conn
135+
.query_row(
136+
"SELECT count(*) FROM user WHERE email = 'reboot@example.com'",
137+
[],
138+
|row| row.get(0),
139+
)
140+
.expect("query restored user");
141+
assert_eq!(users, 1, "signed-up user must survive the reboot restore");
142+
}
143+
144+
// A fresh server over an empty replica is a clean fresh start, and the
145+
// existing database is never touched.
146+
#[tokio::test]
147+
async fn server_boot_empty_replica_fresh_start() {
148+
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
149+
let replica_root = dir_path(&replica_tmp);
150+
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
151+
152+
// Empty replica, missing database: NoReplica (fresh server).
153+
let boot_tmp = tempfile::tempdir().expect("boot tempdir");
154+
let new_db = dir_path(&boot_tmp).join("bencher.db");
155+
let outcome = restore_if_missing(&logger(), &config, &new_db)
156+
.await
157+
.expect("restore");
158+
assert!(
159+
matches!(outcome, RestoreOutcome::NoReplica),
160+
"expected NoReplica, got {outcome:?}"
161+
);
162+
assert!(!new_db.exists(), "fresh start must not create a database");
163+
164+
// Existing database: Skipped, file untouched.
165+
let (server, mut engine) =
166+
TestServer::new_with_replica(replica_json(&replica_root), Some(test_clock()), false)
167+
.await;
168+
until_streaming(&mut engine).await;
169+
let outcome = restore_if_missing(
170+
&logger(),
171+
&config,
172+
Utf8Path::from_path(server.db_path()).expect("db path is UTF-8"),
173+
)
174+
.await
175+
.expect("restore");
176+
assert!(
177+
matches!(outcome, RestoreOutcome::Skipped),
178+
"expected Skipped, got {outcome:?}"
179+
);
180+
}
181+
182+
// Shadow mode: the replica ships and restores alongside a (nominal)
183+
// Litestream, but never checkpoints.
184+
#[tokio::test]
185+
async fn server_shadow_mode_ships_without_checkpoints() {
186+
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
187+
let replica_root = dir_path(&replica_tmp);
188+
let (server, mut engine) =
189+
TestServer::new_with_replica(replica_json(&replica_root), Some(test_clock()), true)
190+
.await;
191+
until_streaming(&mut engine).await;
192+
let _admin = server.signup("Shadow Admin", "shadow@example.com").await;
193+
engine.sync_once().await.expect("ship API writes");
194+
195+
// Shadow never checkpoints: Litestream keeps checkpoint ownership.
196+
let outcome = engine.checkpoint_once().await.expect("checkpoint");
197+
assert_eq!(outcome, CheckpointOutcome::SkippedShadow);
198+
199+
// The shadow replica still restores to an equivalent database.
200+
let restore_tmp = tempfile::tempdir().expect("restore tempdir");
201+
let target_db = dir_path(&restore_tmp).join("restored.db");
202+
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
203+
let outcome = restore_if_missing(&logger(), &config, &target_db)
204+
.await
205+
.expect("restore");
206+
assert!(
207+
matches!(outcome, RestoreOutcome::Restored { .. }),
208+
"expected Restored, got {outcome:?}"
209+
);
210+
assert_replica_equivalent(
211+
Utf8Path::from_path(server.db_path()).expect("db path is UTF-8"),
212+
&target_db,
213+
);
214+
}
215+
}

0 commit comments

Comments
 (0)