Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ bencher_otel_provider = { path = "plus/bencher_otel_provider" }
bencher_oci_storage = { path = "plus/bencher_oci_storage" }
bencher_rate_limiter = { path = "plus/bencher_rate_limiter" }
bencher_recaptcha = { path = "plus/bencher_recaptcha" }
bencher_replica = { path = "plus/bencher_replica" }
# plus - runner
bencher_runner = { path = "plus/bencher_runner" }
bencher_oci = { path = "plus/bencher_oci" }
Expand Down
1 change: 1 addition & 0 deletions docker/bench.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ RUN cargo init --lib bencher_otel
RUN cargo init --lib bencher_otel_provider
RUN cargo init --lib bencher_rate_limiter
RUN cargo init --lib bencher_recaptcha
RUN cargo init --lib bencher_replica
RUN cargo init --lib bencher_rootfs
RUN cargo init --lib bencher_runner
RUN cargo init --lib bencher_init
Expand Down
4 changes: 4 additions & 0 deletions lib/api_server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ slog.workspace = true
[dev-dependencies]
bencher_api_tests.workspace = true
bencher_json = { workspace = true, features = ["server", "schema"] }
bencher_replica = { workspace = true, features = ["plus", "testing"] }
camino.workspace = true
http.workspace = true
rusqlite.workspace = true
tempfile.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

[lints]
Expand Down
8 changes: 8 additions & 0 deletions lib/api_server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@
#[cfg(test)]
use bencher_api_tests as _;
#[cfg(test)]
use bencher_replica as _;
#[cfg(test)]
use camino as _;
#[cfg(test)]
use http as _;
#[cfg(test)]
use rusqlite as _;
#[cfg(test)]
use tempfile as _;
#[cfg(test)]
use tokio as _;

mod backup;
Expand Down
198 changes: 198 additions & 0 deletions lib/api_server/tests/replica.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
#![cfg(feature = "plus")]
#![expect(unused_crate_dependencies, reason = "integration test file")]
//! Integration tests for in-process replication (`bencher_replica`) against
//! a full API server: real Dropshot server, real writes through the API,
//! step-driven replication engine, restore-and-compare verification.

#[cfg(test)]
mod cases {
use bencher_api_tests::TestServer;
use bencher_json::system::config::{JsonReplication, ReplicationTarget};
use bencher_replica::testing::assert_replica_equivalent;
use bencher_replica::{
CheckpointOutcome, EngineState, ReplicaConfig, RestoreOutcome, SyncEngine,
restore_if_missing,
};
use camino::{Utf8Path, Utf8PathBuf};

fn dir_path(tmp: &tempfile::TempDir) -> Utf8PathBuf {
Utf8Path::from_path(tmp.path())
.expect("tempdir path is UTF-8")
.to_path_buf()
}

fn replica_json(root: &Utf8Path) -> JsonReplication {
JsonReplication {
target: ReplicationTarget::File {
path: root.to_path_buf().into_std_path_buf(),
},
sync_interval_secs: None,
checkpoint_interval_secs: None,
min_checkpoint_pages: None,
snapshot_interval_secs: None,
snapshot_throttle_mib: None,
retention_generations: None,
verification_interval_secs: None,
shutdown_sync_timeout_secs: None,
}
}

fn logger() -> slog::Logger {
slog::Logger::root(slog::Discard, slog::o!())
}

/// Drive the engine through the fresh-replica bootstrap snapshot.
async fn until_streaming<C: Send + 'static>(engine: &mut SyncEngine<C>) {
for _ in 0u8..64 {
if engine.state() == EngineState::Streaming {
return;
}
engine.sync_once().await.expect("bootstrap sync");
}
panic!(
"engine never reached Streaming; state: {:?}",
engine.state()
);
}

// Real API writes replicate, and the replica restores to an equivalent
// database.
#[tokio::test]
async fn server_writes_replicate_and_restore() {
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
let replica_root = dir_path(&replica_tmp);
let (server, mut engine) =
TestServer::new_with_replica(replica_json(&replica_root), None, false).await;
until_streaming(&mut engine).await;

// Writes through the real API.
let _admin = server.signup("Replica Admin", "replica@example.com").await;
let _user = server
.signup("Replica User", "replicauser@example.com")
.await;
engine.sync_once().await.expect("ship API writes");

// The replica restores to a logically equivalent database.
let restore_tmp = tempfile::tempdir().expect("restore tempdir");
let target_db = dir_path(&restore_tmp).join("restored.db");
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
let outcome = restore_if_missing(&logger(), &config, &target_db)
.await
.expect("restore");
assert!(
matches!(outcome, RestoreOutcome::Restored { .. }),
"expected Restored, got {outcome:?}"
);
assert_replica_equivalent(
Utf8Path::from_path(server.db_path()).expect("db path is UTF-8"),
&target_db,
);
}

// The startup handshake: a missing database file is rebuilt from the
// replica, and the signed-up user is present in the restored database.
#[tokio::test]
async fn server_reboot_missing_db_auto_restores() {
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
let replica_root = dir_path(&replica_tmp);
let (server, mut engine) =
TestServer::new_with_replica(replica_json(&replica_root), None, false).await;
until_streaming(&mut engine).await;
let _admin = server.signup("Reboot Admin", "reboot@example.com").await;
engine.sync_once().await.expect("ship API writes");
drop(engine);
drop(server);

// "Reboot": the volume is gone; the same restore-if-missing handshake
// that main.rs runs rebuilds the database before any connection opens.
let boot_tmp = tempfile::tempdir().expect("boot tempdir");
let new_db = dir_path(&boot_tmp).join("bencher.db");
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
let outcome = restore_if_missing(&logger(), &config, &new_db)
.await
.expect("restore");
assert!(
matches!(outcome, RestoreOutcome::Restored { .. }),
"expected Restored, got {outcome:?}"
);
let conn = rusqlite::Connection::open(&new_db).expect("open restored db");
let users: i64 = conn
.query_row(
"SELECT count(*) FROM user WHERE email = 'reboot@example.com'",
[],
|row| row.get(0),
)
.expect("query restored user");
assert_eq!(users, 1, "signed-up user must survive the reboot restore");
}

// A fresh server over an empty replica is a clean fresh start, and the
// existing database is never touched.
#[tokio::test]
async fn server_boot_empty_replica_fresh_start() {
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
let replica_root = dir_path(&replica_tmp);
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");

// Empty replica, missing database: NoReplica (fresh server).
let boot_tmp = tempfile::tempdir().expect("boot tempdir");
let new_db = dir_path(&boot_tmp).join("bencher.db");
let outcome = restore_if_missing(&logger(), &config, &new_db)
.await
.expect("restore");
assert!(
matches!(outcome, RestoreOutcome::NoReplica),
"expected NoReplica, got {outcome:?}"
);
assert!(!new_db.exists(), "fresh start must not create a database");

// Existing database: Skipped, file untouched.
let (server, mut engine) =
TestServer::new_with_replica(replica_json(&replica_root), None, false).await;
until_streaming(&mut engine).await;
let outcome = restore_if_missing(
&logger(),
&config,
Utf8Path::from_path(server.db_path()).expect("db path is UTF-8"),
)
.await
.expect("restore");
assert!(
matches!(outcome, RestoreOutcome::Skipped),
"expected Skipped, got {outcome:?}"
);
}

// Shadow mode: the replica ships and restores alongside a (nominal)
// Litestream, but never checkpoints.
#[tokio::test]
async fn server_shadow_mode_ships_without_checkpoints() {
let replica_tmp = tempfile::tempdir().expect("replica tempdir");
let replica_root = dir_path(&replica_tmp);
let (server, mut engine) =
TestServer::new_with_replica(replica_json(&replica_root), None, true).await;
until_streaming(&mut engine).await;
let _admin = server.signup("Shadow Admin", "shadow@example.com").await;
engine.sync_once().await.expect("ship API writes");

// Shadow never checkpoints: Litestream keeps checkpoint ownership.
let outcome = engine.checkpoint_once().await.expect("checkpoint");
assert_eq!(outcome, CheckpointOutcome::SkippedShadow);

// The shadow replica still restores to an equivalent database.
let restore_tmp = tempfile::tempdir().expect("restore tempdir");
let target_db = dir_path(&restore_tmp).join("restored.db");
let config = ReplicaConfig::try_from(replica_json(&replica_root)).expect("config");
let outcome = restore_if_missing(&logger(), &config, &target_db)
.await
.expect("restore");
assert!(
matches!(outcome, RestoreOutcome::Restored { .. }),
"expected Restored, got {outcome:?}"
);
assert_replica_equivalent(
Utf8Path::from_path(server.db_path()).expect("db path is UTF-8"),
&target_db,
);
}
}
5 changes: 5 additions & 0 deletions lib/bencher_api_tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ plus = [
"bencher_api/plus",
"bencher_config/plus",
"bencher_oci_storage/plus",
"bencher_replica/plus",
"bencher_schema/plus",
"dep:bencher_license",
"dep:bencher_oci_storage",
"dep:bencher_replica",
"dep:camino",
"dep:hex",
"dep:http",
"dep:serde_json",
Expand All @@ -30,6 +33,8 @@ bencher_endpoint.workspace = true
bencher_json = { workspace = true, features = ["server", "schema", "db", "test-clock"] }
bencher_license = { workspace = true, optional = true }
bencher_oci_storage = { workspace = true, optional = true, features = ["test-clock"] }
bencher_replica = { workspace = true, optional = true, features = ["test-clock"] }
camino = { workspace = true, optional = true }
bencher_rbac.workspace = true
bencher_schema.workspace = true
bencher_token.workspace = true
Expand Down
Loading
Loading