Skip to content

Commit 176b289

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 176b289

68 files changed

Lines changed: 21261 additions & 101 deletions

Some content is hidden

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

.github/workflows/test.yml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,76 @@ jobs:
281281
env:
282282
RUST_BACKTRACE: full
283283
run: cargo test-api smoke docker
284+
285+
replica_live_s3:
286+
name: Replica Live S3 (${{ matrix.server.name }})
287+
runs-on: ubuntu-22.04
288+
strategy:
289+
fail-fast: false
290+
matrix:
291+
server:
292+
# Two S3-compatible implementations: MinIO exercises the full API
293+
# (including ListMultipartUploads for the orphan sweep); RustFS
294+
# pins compatibility with a second, Rust-native implementation.
295+
- name: minio
296+
start: >-
297+
docker run -d --name live-s3 -p 9000:9000
298+
-e MINIO_ROOT_USER=bencher-test
299+
-e MINIO_ROOT_PASSWORD=bencher-test-secret
300+
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
301+
- name: rustfs
302+
start: >-
303+
docker run -d --name live-s3 -p 9000:9000
304+
-e RUSTFS_ACCESS_KEY=bencher-test
305+
-e RUSTFS_SECRET_KEY=bencher-test-secret
306+
rustfs/rustfs:1.0.0-beta.10
307+
steps:
308+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
309+
with:
310+
persist-credentials: false
311+
- uses: ./.github/actions/setup-rust
312+
with:
313+
cache-key: replica-live-s3
314+
mold-version: ${{ inputs.mold-version }}
315+
- name: Start S3 server
316+
env:
317+
START_SERVER: ${{ matrix.server.start }}
318+
run: |
319+
${START_SERVER}
320+
for _ in $(seq 1 30); do
321+
if curl -s -o /dev/null http://127.0.0.1:9000/; then
322+
exit 0
323+
fi
324+
sleep 2
325+
done
326+
echo "S3 server did not come up" >&2
327+
docker logs live-s3 >&2 || true
328+
exit 1
329+
- name: Create bucket
330+
env:
331+
AWS_ACCESS_KEY_ID: bencher-test
332+
AWS_SECRET_ACCESS_KEY: bencher-test-secret
333+
AWS_DEFAULT_REGION: us-east-1
334+
run: |
335+
for _ in $(seq 1 15); do
336+
if aws --endpoint-url http://127.0.0.1:9000 s3api create-bucket \
337+
--bucket bencher-replica-test; then
338+
exit 0
339+
fi
340+
sleep 2
341+
done
342+
echo "failed to create the test bucket" >&2
343+
docker logs live-s3 >&2 || true
344+
exit 1
345+
- name: Run live S3 tier
346+
env:
347+
RUST_BACKTRACE: "1"
348+
BENCHER_REPLICA_TEST_S3_BUCKET: bencher-replica-test
349+
BENCHER_REPLICA_TEST_S3_ACCESS_KEY_ID: bencher-test
350+
BENCHER_REPLICA_TEST_S3_SECRET_ACCESS_KEY: bencher-test-secret
351+
BENCHER_REPLICA_TEST_S3_ENDPOINT: http://127.0.0.1:9000
352+
BENCHER_REPLICA_TEST_S3_REGION: us-east-1
353+
BENCHER_REPLICA_TEST_S3_PREFIX: ci-live-s3
354+
run: |
355+
cargo nextest run -p bencher_replica --features plus,testing \
356+
--run-ignored ignored-only --no-fail-fast -E 'binary(live_s3)'

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/config.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub async fn server_config_options(
2727
/// View server configuration
2828
///
2929
/// View the API server configuration.
30+
/// Secrets in the configuration are masked in the response.
3031
/// The user must be an admin on the server to use this route.
3132
#[endpoint {
3233
method = GET,
@@ -53,7 +54,7 @@ async fn get_one_inner(log: &Logger) -> Result<JsonConfig, HttpError> {
5354
)
5455
})?
5556
.unwrap_or_default()
56-
.into())
57+
.sanitized())
5758
}
5859

5960
#[endpoint {

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

0 commit comments

Comments
 (0)