mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-27 15:37:02 +00:00
fix(replication): stop active-active replay loops (#4878)
This commit is contained in:
@@ -152,7 +152,7 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
# the nightly profile derives its set as "the replication module MINUS this
|
# the nightly profile derives its set as "the replication module MINUS this
|
||||||
# allowlist", so any new replication test lands in nightly by default (never
|
# allowlist", so any new replication test lands in nightly by default (never
|
||||||
# silently unrun) until it is explicitly blessed as fast here. Keep the two
|
# silently unrun) until it is explicitly blessed as fast here. Keep the two
|
||||||
# regexes byte-identical. Count invariant: 20 here + 23 nightly = 43 total
|
# regexes byte-identical. Count invariant: 20 here + 24 nightly = 44 total
|
||||||
# (authority: `cargo nextest list`; docs/testing/e2e-suite-inventory.md).
|
# (authority: `cargo nextest list`; docs/testing/e2e-suite-inventory.md).
|
||||||
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
|
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
|
||||||
# (#4724) because they set a loopback (127.0.0.1) replication target that the
|
# (#4724) because they set a loopback (127.0.0.1) replication target that the
|
||||||
@@ -202,10 +202,12 @@ fail-fast = false
|
|||||||
# backlog#1147 repl-1 (deps: ci-4). Runs the SLOW / cross-process replication
|
# backlog#1147 repl-1 (deps: ci-4). Runs the SLOW / cross-process replication
|
||||||
# tests that are unfit for the per-PR e2e-smoke gate:
|
# tests that are unfit for the per-PR e2e-smoke gate:
|
||||||
#
|
#
|
||||||
# * 8 bucket-replication data-plane tests — they PUT/delete objects and poll
|
# * 2 remote-target TLS validation tests.
|
||||||
|
# * 9 bucket-replication data-plane tests — they PUT/delete objects and poll
|
||||||
# until source and target converge; two replicate over HTTPS.
|
# until source and target converge; two replicate over HTTPS.
|
||||||
# * 11 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
|
# * 11 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
|
||||||
# servers and drives the cross-process site-replication control plane.
|
# servers and drives the cross-process site-replication control plane.
|
||||||
|
# * 1 `_real_three_node` site-replication test.
|
||||||
# * 1 `_real_single_node` service-account round-trip test.
|
# * 1 `_real_single_node` service-account round-trip test.
|
||||||
#
|
#
|
||||||
# The set is defined as "everything in replication_extension_test that is NOT
|
# The set is defined as "everything in replication_extension_test that is NOT
|
||||||
@@ -261,7 +263,7 @@ path = "junit.xml"
|
|||||||
# object_lambda) — too heavy for the merge budget; they run in ci-7's
|
# object_lambda) — too heavy for the merge budget; they run in ci-7's
|
||||||
# nightly 4-node lane.
|
# nightly 4-node lane.
|
||||||
# * replication_extension_test — repl-1 already splits it into the PR
|
# * replication_extension_test — repl-1 already splits it into the PR
|
||||||
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (20 slow) lanes and reserves
|
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (24 slow) lanes and reserves
|
||||||
# it for those, so e2e-full does not double-run it.
|
# it for those, so e2e-full does not double-run it.
|
||||||
# * #[ignore]d tests — nextest skips them by default (no --run-ignored); the
|
# * #[ignore]d tests — nextest skips them by default (no --run-ignored); the
|
||||||
# manual-localhost:9000 reliant/policy tests are ci-13's migration.
|
# manual-localhost:9000 reliant/policy tests are ci-13's migration.
|
||||||
|
|||||||
@@ -15,20 +15,22 @@
|
|||||||
# Nightly full replication e2e lane (backlog#1147 repl-1, deps: ci-4).
|
# Nightly full replication e2e lane (backlog#1147 repl-1, deps: ci-4).
|
||||||
#
|
#
|
||||||
# The per-PR gate (ci.yml `e2e-tests` job, `--profile e2e-smoke`) runs the 20
|
# The per-PR gate (ci.yml `e2e-tests` job, `--profile e2e-smoke`) runs the 20
|
||||||
# FAST bucket-replication tests. This scheduled lane runs the remaining 18
|
# FAST replication tests. This scheduled lane runs the remaining 24
|
||||||
# heavier replication e2e tests that are unfit for a per-PR gate:
|
# heavier replication e2e tests that are unfit for a per-PR gate:
|
||||||
#
|
#
|
||||||
# * 8 bucket-replication data-plane tests (PUT/delete + poll for convergence;
|
# * 2 remote-target TLS validation tests.
|
||||||
|
# * 9 bucket-replication data-plane tests (PUT/delete + poll for convergence;
|
||||||
# two replicate over HTTPS).
|
# two replicate over HTTPS).
|
||||||
# * 9 `_real_dual_node` site-replication tests (each spawns TWO rustfs
|
# * 11 `_real_dual_node` site-replication tests (each spawns TWO rustfs
|
||||||
# servers and drives the cross-process site-replication control plane).
|
# servers and drives the cross-process site-replication control plane).
|
||||||
|
# * 1 `_real_three_node` site-replication test.
|
||||||
# * 1 `_real_single_node` service-account round-trip test.
|
# * 1 `_real_single_node` service-account round-trip test.
|
||||||
#
|
#
|
||||||
# The selection is the [profile.e2e-repl-nightly] default-filter in
|
# The selection is the [profile.e2e-repl-nightly] default-filter in
|
||||||
# .config/nextest.toml — the single wiring mechanism (repl-1 / ci-4). Do NOT
|
# .config/nextest.toml — the single wiring mechanism (repl-1 / ci-4). Do NOT
|
||||||
# add ad-hoc cargo-test steps here; change the filterset instead.
|
# add ad-hoc cargo-test steps here; change the filterset instead.
|
||||||
#
|
#
|
||||||
# Explicit division of labor: these 18 tests run ONLY here, never double-run
|
# Explicit division of labor: these 24 tests run ONLY here, never double-run
|
||||||
# in ci-5's future e2e-full merge gate. TODO(ci-7): once the ci domain's
|
# in ci-5's future e2e-full merge gate. TODO(ci-7): once the ci domain's
|
||||||
# consolidated scheduled e2e workflow exists, fold this interim repl-owned lane
|
# consolidated scheduled e2e workflow exists, fold this interim repl-owned lane
|
||||||
# into it rather than growing a second scheduled entrypoint.
|
# into it rather than growing a second scheduled entrypoint.
|
||||||
|
|||||||
Generated
+3
@@ -3661,6 +3661,9 @@ dependencies = [
|
|||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"local-ip-address",
|
"local-ip-address",
|
||||||
"md5",
|
"md5",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ async-compression = { workspace = true, features = ["tokio", "bzip2", "xz"] }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
flate2.workspace = true
|
flate2.workspace = true
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
|
http-body-util.workspace = true
|
||||||
|
hyper.workspace = true
|
||||||
|
hyper-util.workspace = true
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
rustfs-signer.workspace = true
|
rustfs-signer.workspace = true
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ use aws_sdk_s3::primitives::ByteStream;
|
|||||||
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
use http::header::{CONTENT_TYPE, HOST};
|
use http::header::{CONTENT_TYPE, HOST};
|
||||||
|
use http_body_util::{BodyExt, Full};
|
||||||
|
use hyper::body::Incoming;
|
||||||
|
use hyper::server::conn::http1;
|
||||||
|
use hyper::service::service_fn;
|
||||||
|
use hyper::{Request, Response};
|
||||||
|
use hyper_util::rt::TokioIo;
|
||||||
use local_ip_address::local_ip;
|
use local_ip_address::local_ip;
|
||||||
use rcgen::{
|
use rcgen::{
|
||||||
BasicConstraints, CertificateParams, CertifiedIssuer, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose,
|
BasicConstraints, CertificateParams, CertifiedIssuer, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose,
|
||||||
@@ -36,15 +42,22 @@ use rustfs_madmin::{
|
|||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
use rustfs_signer::sign_v4;
|
use rustfs_signer::sign_v4;
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
|
use s3s::header::X_AMZ_REPLICATION_STATUS;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::convert::Infallible;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||||
@@ -888,6 +901,8 @@ struct ReplicatedVersion {
|
|||||||
key: String,
|
key: String,
|
||||||
version_id: String,
|
version_id: String,
|
||||||
delete_marker: bool,
|
delete_marker: bool,
|
||||||
|
is_latest: bool,
|
||||||
|
last_modified: (i64, u32),
|
||||||
e_tag: Option<String>,
|
e_tag: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,6 +921,7 @@ async fn list_replication_state(client: &Client, bucket: &str) -> Result<Vec<Rep
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for version in output.versions() {
|
for version in output.versions() {
|
||||||
|
let last_modified = version.last_modified().ok_or("listed object version omitted LastModified")?;
|
||||||
state.push(ReplicatedVersion {
|
state.push(ReplicatedVersion {
|
||||||
key: version.key().ok_or("listed object version omitted key")?.to_string(),
|
key: version.key().ok_or("listed object version omitted key")?.to_string(),
|
||||||
version_id: version
|
version_id: version
|
||||||
@@ -913,10 +929,13 @@ async fn list_replication_state(client: &Client, bucket: &str) -> Result<Vec<Rep
|
|||||||
.ok_or("listed object version omitted version ID")?
|
.ok_or("listed object version omitted version ID")?
|
||||||
.to_string(),
|
.to_string(),
|
||||||
delete_marker: false,
|
delete_marker: false,
|
||||||
|
is_latest: version.is_latest().unwrap_or(false),
|
||||||
|
last_modified: (last_modified.secs(), last_modified.subsec_nanos()),
|
||||||
e_tag: Some(version.e_tag().ok_or("listed object version omitted ETag")?.to_string()),
|
e_tag: Some(version.e_tag().ok_or("listed object version omitted ETag")?.to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for marker in output.delete_markers() {
|
for marker in output.delete_markers() {
|
||||||
|
let last_modified = marker.last_modified().ok_or("listed delete marker omitted LastModified")?;
|
||||||
state.push(ReplicatedVersion {
|
state.push(ReplicatedVersion {
|
||||||
key: marker.key().ok_or("listed delete marker omitted key")?.to_string(),
|
key: marker.key().ok_or("listed delete marker omitted key")?.to_string(),
|
||||||
version_id: marker
|
version_id: marker
|
||||||
@@ -924,6 +943,8 @@ async fn list_replication_state(client: &Client, bucket: &str) -> Result<Vec<Rep
|
|||||||
.ok_or("listed delete marker omitted version ID")?
|
.ok_or("listed delete marker omitted version ID")?
|
||||||
.to_string(),
|
.to_string(),
|
||||||
delete_marker: true,
|
delete_marker: true,
|
||||||
|
is_latest: marker.is_latest().unwrap_or(false),
|
||||||
|
last_modified: (last_modified.secs(), last_modified.subsec_nanos()),
|
||||||
e_tag: None,
|
e_tag: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -971,6 +992,26 @@ async fn assert_replication_converged(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_version_body(
|
||||||
|
client: &Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
version_id: &str,
|
||||||
|
) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||||
|
Ok(client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(version_id)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes()
|
||||||
|
.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
/// Poll the source object until it reports a not-yet-replicated status.
|
/// Poll the source object until it reports a not-yet-replicated status.
|
||||||
///
|
///
|
||||||
/// A source object with a reachable replication config carries an
|
/// A source object with a reachable replication config carries an
|
||||||
@@ -1425,6 +1466,112 @@ async fn site_replication_status(env: &RustFSTestEnvironment, query: &str) -> Re
|
|||||||
Ok(serde_json::from_slice(&response.bytes().await?)?)
|
Ok(serde_json::from_slice(&response.bytes().await?)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn proxy_error_response(error: impl std::fmt::Display) -> Response<Full<bytes::Bytes>> {
|
||||||
|
Response::builder()
|
||||||
|
.status(reqwest::StatusCode::BAD_GATEWAY)
|
||||||
|
.body(Full::new(bytes::Bytes::from(error.to_string())))
|
||||||
|
.expect("static proxy response must be valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn forward_replication_proxy_request(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
backend_url: &str,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
request_count: &AtomicU64,
|
||||||
|
mut replication_enabled: watch::Receiver<bool>,
|
||||||
|
) -> Response<Full<bytes::Bytes>> {
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let is_replication = parts
|
||||||
|
.headers
|
||||||
|
.get(X_AMZ_REPLICATION_STATUS)
|
||||||
|
.is_some_and(|value| value.as_bytes().eq_ignore_ascii_case(b"REPLICA"));
|
||||||
|
if is_replication {
|
||||||
|
request_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
while !*replication_enabled.borrow() {
|
||||||
|
if replication_enabled.changed().await.is_err() {
|
||||||
|
return proxy_error_response("replication gate closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(path_and_query) = parts.uri.path_and_query() else {
|
||||||
|
return proxy_error_response("request URI omitted path");
|
||||||
|
};
|
||||||
|
let body = match body.collect().await {
|
||||||
|
Ok(body) => body.to_bytes(),
|
||||||
|
Err(error) => return proxy_error_response(error),
|
||||||
|
};
|
||||||
|
let mut forwarded = client.request(parts.method, format!("{backend_url}{path_and_query}"));
|
||||||
|
for (name, value) in &parts.headers {
|
||||||
|
forwarded = forwarded.header(name, value);
|
||||||
|
}
|
||||||
|
let response = match forwarded.body(body).send().await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => return proxy_error_response(error),
|
||||||
|
};
|
||||||
|
let status = response.status();
|
||||||
|
let headers = response.headers().clone();
|
||||||
|
let body = match response.bytes().await {
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(error) => return proxy_error_response(error),
|
||||||
|
};
|
||||||
|
let mut proxied = Response::builder().status(status);
|
||||||
|
for (name, value) in &headers {
|
||||||
|
proxied = proxied.header(name, value);
|
||||||
|
}
|
||||||
|
proxied.body(Full::new(body)).expect("upstream response must be valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_replication_counting_proxy(
|
||||||
|
backend_url: &str,
|
||||||
|
tasks: &mut JoinSet<()>,
|
||||||
|
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>), Box<dyn Error + Send + Sync>> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||||
|
let proxy_url = format!("http://{}", listener.local_addr()?);
|
||||||
|
let backend_url = backend_url.to_string();
|
||||||
|
let request_count = Arc::new(AtomicU64::new(0));
|
||||||
|
let task_request_count = request_count.clone();
|
||||||
|
let (replication_enabled, task_replication_enabled) = watch::channel(true);
|
||||||
|
tasks.spawn(async move {
|
||||||
|
let client = local_http_client();
|
||||||
|
let mut connections = JoinSet::new();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
accepted = listener.accept() => {
|
||||||
|
let Ok((stream, _)) = accepted else { break };
|
||||||
|
let backend_url = backend_url.clone();
|
||||||
|
let client = client.clone();
|
||||||
|
let request_count = task_request_count.clone();
|
||||||
|
let replication_enabled = task_replication_enabled.clone();
|
||||||
|
connections.spawn(async move {
|
||||||
|
let service = service_fn(move |request| {
|
||||||
|
let backend_url = backend_url.clone();
|
||||||
|
let client = client.clone();
|
||||||
|
let request_count = request_count.clone();
|
||||||
|
let replication_enabled = replication_enabled.clone();
|
||||||
|
async move {
|
||||||
|
Ok::<_, Infallible>(
|
||||||
|
forward_replication_proxy_request(
|
||||||
|
request,
|
||||||
|
&backend_url,
|
||||||
|
&client,
|
||||||
|
&request_count,
|
||||||
|
replication_enabled,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = http1::Builder::new().serve_connection(TokioIo::new(stream), service).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ = connections.join_next(), if !connections.is_empty() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok((proxy_url, request_count, replication_enabled))
|
||||||
|
}
|
||||||
|
|
||||||
async fn site_replication_remove(
|
async fn site_replication_remove(
|
||||||
env: &RustFSTestEnvironment,
|
env: &RustFSTestEnvironment,
|
||||||
req: &SRRemoveReq,
|
req: &SRRemoveReq,
|
||||||
@@ -4132,6 +4279,324 @@ async fn test_site_replication_replicates_object_with_bucket_versioning_real_dua
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_site_replication_active_active_converges_without_loops_real_dual_node() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
match tokio::time::timeout(Duration::from_secs(420), async {
|
||||||
|
let mut site_env = replication_fast_env();
|
||||||
|
site_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||||
|
|
||||||
|
let mut site_a_env = RustFSTestEnvironment::new().await?;
|
||||||
|
site_a_env.start_rustfs_server_with_env(vec![], &site_env).await?;
|
||||||
|
|
||||||
|
let mut site_b_env = RustFSTestEnvironment::new().await?;
|
||||||
|
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
|
||||||
|
|
||||||
|
let mut proxy_tasks = JoinSet::new();
|
||||||
|
let (site_a_proxy, site_a_replication_requests, site_a_replication_enabled) =
|
||||||
|
start_replication_counting_proxy(&site_a_env.url, &mut proxy_tasks).await?;
|
||||||
|
let (site_b_proxy, site_b_replication_requests, site_b_replication_enabled) =
|
||||||
|
start_replication_counting_proxy(&site_b_env.url, &mut proxy_tasks).await?;
|
||||||
|
|
||||||
|
let site_a_client = site_a_env.create_s3_client();
|
||||||
|
let site_b_client = site_b_env.create_s3_client();
|
||||||
|
let bucket = "site-repl-active-active";
|
||||||
|
|
||||||
|
let add_status = site_replication_add(
|
||||||
|
&site_a_env,
|
||||||
|
&[
|
||||||
|
PeerSite {
|
||||||
|
name: "active-site-a".to_string(),
|
||||||
|
endpoint: site_a_env.url.clone(),
|
||||||
|
access_key: site_a_env.access_key.clone(),
|
||||||
|
secret_key: site_a_env.secret_key.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
PeerSite {
|
||||||
|
name: "active-site-b".to_string(),
|
||||||
|
endpoint: site_b_env.url.clone(),
|
||||||
|
access_key: site_b_env.access_key.clone(),
|
||||||
|
secret_key: site_b_env.secret_key.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(add_status.success, "unexpected site add result: {add_status:?}");
|
||||||
|
|
||||||
|
let site_info = wait_for_site_replication_enabled(&site_a_env, 2).await?;
|
||||||
|
wait_for_site_replication_enabled(&site_b_env, 2).await?;
|
||||||
|
|
||||||
|
let mut site_a_peer = site_info
|
||||||
|
.sites
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.endpoint == site_a_env.url)
|
||||||
|
.ok_or("site A peer missing from replication info")?
|
||||||
|
.clone();
|
||||||
|
site_a_peer.endpoint = site_a_proxy.clone();
|
||||||
|
site_a_peer.sync_state = SyncStatus::Enable;
|
||||||
|
let site_a_edit = site_replication_edit(&site_a_env, "", &site_a_peer).await?;
|
||||||
|
assert!(site_a_edit.success, "unexpected site A endpoint edit: {site_a_edit:?}");
|
||||||
|
|
||||||
|
let mut site_b_peer = site_info
|
||||||
|
.sites
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.endpoint == site_b_env.url)
|
||||||
|
.ok_or("site B peer missing from replication info")?
|
||||||
|
.clone();
|
||||||
|
site_b_peer.endpoint = site_b_proxy.clone();
|
||||||
|
site_b_peer.sync_state = SyncStatus::Enable;
|
||||||
|
let site_b_edit = site_replication_edit(&site_a_env, "", &site_b_peer).await?;
|
||||||
|
assert!(site_b_edit.success, "unexpected site B endpoint edit: {site_b_edit:?}");
|
||||||
|
|
||||||
|
for env in [&site_a_env, &site_b_env] {
|
||||||
|
wait_for_site_replication_info(env, |info| {
|
||||||
|
info.sites.iter().any(|peer| peer.endpoint == site_a_proxy)
|
||||||
|
&& info.sites.iter().any(|peer| peer.endpoint == site_b_proxy)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
site_a_client.create_bucket().bucket(bucket).send().await?;
|
||||||
|
wait_for_bucket_on_target(&site_b_client, bucket).await?;
|
||||||
|
|
||||||
|
let (site_a_put, site_b_put) = tokio::join!(
|
||||||
|
site_a_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("from-a.txt")
|
||||||
|
.body(ByteStream::from_static(b"written on site A"))
|
||||||
|
.send(),
|
||||||
|
site_b_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("from-b.txt")
|
||||||
|
.body(ByteStream::from_static(b"written on site B"))
|
||||||
|
.send(),
|
||||||
|
);
|
||||||
|
site_a_put?;
|
||||||
|
site_b_put?;
|
||||||
|
wait_for_replicated_object(&site_b_client, bucket, "from-a.txt", "written on site A").await?;
|
||||||
|
wait_for_replicated_object(&site_a_client, bucket, "from-b.txt", "written on site B").await?;
|
||||||
|
|
||||||
|
let pre_conflict_counts = (
|
||||||
|
site_a_replication_requests.load(Ordering::Relaxed),
|
||||||
|
site_b_replication_requests.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
assert_eq!(pre_conflict_counts, (1, 1));
|
||||||
|
site_a_replication_enabled.send(false)?;
|
||||||
|
site_b_replication_enabled.send(false)?;
|
||||||
|
let site_a_conflict_version = site_a_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("conflict.txt")
|
||||||
|
.body(ByteStream::from_static(b"conflict from site A"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("site A conflict PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
sleep(Duration::from_millis(10)).await;
|
||||||
|
let site_b_conflict_version = site_b_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("conflict.txt")
|
||||||
|
.body(ByteStream::from_static(b"conflict from site B"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("site B conflict PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
assert_ne!(site_a_conflict_version, site_b_conflict_version);
|
||||||
|
tokio::time::timeout(Duration::from_secs(70), async {
|
||||||
|
loop {
|
||||||
|
let counts = (
|
||||||
|
site_a_replication_requests.load(Ordering::Relaxed),
|
||||||
|
site_b_replication_requests.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
assert!(counts.0 <= pre_conflict_counts.0 + 1 && counts.1 <= pre_conflict_counts.1 + 1);
|
||||||
|
if counts == (pre_conflict_counts.0 + 1, pre_conflict_counts.1 + 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| "replication requests did not reach both conflict gates")?;
|
||||||
|
let site_a_isolated_conflict = list_replication_state(&site_a_client, bucket)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|version| version.key == "conflict.txt")
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let site_b_isolated_conflict = list_replication_state(&site_b_client, bucket)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|version| version.key == "conflict.txt")
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(site_a_isolated_conflict.len(), 1);
|
||||||
|
assert_eq!(site_a_isolated_conflict[0].version_id, site_a_conflict_version);
|
||||||
|
assert_eq!(site_b_isolated_conflict.len(), 1);
|
||||||
|
assert_eq!(site_b_isolated_conflict[0].version_id, site_b_conflict_version);
|
||||||
|
let (api_expected_winner, api_expected_body) = match site_a_isolated_conflict[0]
|
||||||
|
.last_modified
|
||||||
|
.cmp(&site_b_isolated_conflict[0].last_modified)
|
||||||
|
{
|
||||||
|
std::cmp::Ordering::Greater => (&site_a_conflict_version, b"conflict from site A".as_slice()),
|
||||||
|
std::cmp::Ordering::Less => (&site_b_conflict_version, b"conflict from site B".as_slice()),
|
||||||
|
std::cmp::Ordering::Equal => return Err("staggered conflict writes received equal LastModified values".into()),
|
||||||
|
};
|
||||||
|
site_a_replication_enabled.send(true)?;
|
||||||
|
site_b_replication_enabled.send(true)?;
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(70),
|
||||||
|
assert_replication_converged(&site_a_client, bucket, &site_b_client, bucket),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
for (version_id, expected) in [
|
||||||
|
(site_a_conflict_version.as_str(), b"conflict from site A".as_slice()),
|
||||||
|
(site_b_conflict_version.as_str(), b"conflict from site B".as_slice()),
|
||||||
|
] {
|
||||||
|
assert_eq!(get_version_body(&site_a_client, bucket, "conflict.txt", version_id).await?, expected);
|
||||||
|
assert_eq!(get_version_body(&site_b_client, bucket, "conflict.txt", version_id).await?, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newer LastModified wins; the writes are staggered while replication is
|
||||||
|
// blocked so this test does not claim a tie-break for equal timestamps.
|
||||||
|
let site_a_current = site_a_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("conflict.txt")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes();
|
||||||
|
let site_b_current = site_b_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("conflict.txt")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(site_a_current, site_b_current);
|
||||||
|
let observed_winner_version = if site_a_current.as_ref() == b"conflict from site A" {
|
||||||
|
&site_a_conflict_version
|
||||||
|
} else if site_a_current.as_ref() == b"conflict from site B" {
|
||||||
|
&site_b_conflict_version
|
||||||
|
} else {
|
||||||
|
return Err(format!("unexpected active-active winner: {site_a_current:?}").into());
|
||||||
|
};
|
||||||
|
assert_eq!(site_a_current.as_ref(), api_expected_body);
|
||||||
|
assert_eq!(observed_winner_version, api_expected_winner);
|
||||||
|
|
||||||
|
site_a_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("deleted.txt")
|
||||||
|
.body(ByteStream::from_static(b"delete me"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
site_a_client.delete_object().bucket(bucket).key("deleted.txt").send().await?;
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(70),
|
||||||
|
assert_replication_converged(&site_a_client, bucket, &site_b_client, bucket),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let site_a_deleted = site_a_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("deleted.txt")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("site A deleted object unexpectedly rebounded");
|
||||||
|
let site_b_deleted = site_b_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("deleted.txt")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("site B deleted object unexpectedly exists");
|
||||||
|
assert_eq!(site_a_deleted.as_service_error().and_then(|error| error.code()), Some("NoSuchKey"));
|
||||||
|
assert_eq!(site_b_deleted.as_service_error().and_then(|error| error.code()), Some("NoSuchKey"));
|
||||||
|
|
||||||
|
let stable_state = list_replication_state(&site_a_client, bucket).await?;
|
||||||
|
assert_eq!(stable_state.iter().filter(|version| version.key == "from-a.txt").count(), 1);
|
||||||
|
assert_eq!(stable_state.iter().filter(|version| version.key == "from-b.txt").count(), 1);
|
||||||
|
assert_eq!(stable_state.iter().filter(|version| version.key == "conflict.txt").count(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
stable_state
|
||||||
|
.iter()
|
||||||
|
.filter(|version| version.key == "conflict.txt" && version.is_latest)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stable_state.iter().any(|version| version.key == "conflict.txt"
|
||||||
|
&& version.version_id == *observed_winner_version
|
||||||
|
&& version.is_latest)
|
||||||
|
);
|
||||||
|
assert_eq!(stable_state.iter().filter(|version| version.key == "deleted.txt").count(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
stable_state
|
||||||
|
.iter()
|
||||||
|
.filter(|version| version.key == "deleted.txt" && version.delete_marker)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stable_state
|
||||||
|
.iter()
|
||||||
|
.filter(|version| version.key == "deleted.txt" && !version.delete_marker)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stable_state
|
||||||
|
.iter()
|
||||||
|
.filter(|version| version.key == "deleted.txt" && version.delete_marker && version.is_latest)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stable_state
|
||||||
|
.iter()
|
||||||
|
.filter(|version| version.key == "deleted.txt" && version.is_latest)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
let baseline_counts = (
|
||||||
|
site_a_replication_requests.load(Ordering::Relaxed),
|
||||||
|
site_b_replication_requests.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
assert_eq!(baseline_counts, (2, 4));
|
||||||
|
tokio::time::sleep(Duration::from_secs(4)).await;
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
site_a_replication_requests.load(Ordering::Relaxed),
|
||||||
|
site_b_replication_requests.load(Ordering::Relaxed),
|
||||||
|
),
|
||||||
|
baseline_counts
|
||||||
|
);
|
||||||
|
assert_eq!(list_replication_state(&site_a_client, bucket).await?, stable_state);
|
||||||
|
assert_eq!(list_replication_state(&site_b_client, bucket).await?, stable_state);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err("active-active replication test timed out".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_site_replication_replicates_policy_backed_user_access_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_site_replication_replicates_policy_backed_user_access_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
|||||||
@@ -260,7 +260,9 @@ pub(crate) fn replication_put_object_header_size(put_options: &PutObjectOptions)
|
|||||||
|
|
||||||
fn replication_source_object(object_info: &ObjectInfo) -> ReplicationSourceObject<'_> {
|
fn replication_source_object(object_info: &ObjectInfo) -> ReplicationSourceObject<'_> {
|
||||||
ReplicationSourceObject {
|
ReplicationSourceObject {
|
||||||
mod_time: object_info.mod_time,
|
mod_time: object_info
|
||||||
|
.mod_time
|
||||||
|
.map(|mod_time| OffsetDateTime::from_unix_timestamp(mod_time.unix_timestamp()).unwrap_or(mod_time)),
|
||||||
version_id: object_info.version_id.map(|version_id| version_id.to_string()),
|
version_id: object_info.version_id.map(|version_id| version_id.to_string()),
|
||||||
etag: object_info.etag.as_deref(),
|
etag: object_info.etag.as_deref(),
|
||||||
actual_size: object_info.get_actual_size().unwrap_or_default(),
|
actual_size: object_info.get_actual_size().unwrap_or_default(),
|
||||||
@@ -463,6 +465,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replication_action_for_target_head_compares_http_date_precision() {
|
||||||
|
for (source_nanos, target_secs, expected) in [
|
||||||
|
(10_123_456_789, 10, ReplicationAction::None),
|
||||||
|
(-10_876_543_211, -11, ReplicationAction::None),
|
||||||
|
(10_600_000_000, 11, ReplicationAction::All),
|
||||||
|
] {
|
||||||
|
let mod_time = OffsetDateTime::from_unix_timestamp_nanos(source_nanos).expect("valid timestamp");
|
||||||
|
let object_info = ObjectInfo {
|
||||||
|
mod_time: Some(mod_time),
|
||||||
|
version_id: Some(Uuid::new_v4()),
|
||||||
|
etag: Some("abc123".to_string()),
|
||||||
|
size: 10,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let target = HeadObjectOutput::builder()
|
||||||
|
.last_modified(DateTime::from_secs(target_secs))
|
||||||
|
.version_id(object_info.version_id.expect("version ID").to_string())
|
||||||
|
.e_tag("abc123")
|
||||||
|
.content_length(10)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
replication_action_for_target_head(&object_info, &target, ReplicationType::Object),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replication_remove_options_mark_replication_requests() {
|
fn replication_remove_options_mark_replication_requests() {
|
||||||
let mtime = OffsetDateTime::UNIX_EPOCH + Duration::seconds(10);
|
let mtime = OffsetDateTime::UNIX_EPOCH + Duration::seconds(10);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
> (`.config/nextest.toml`); admission criteria: `crates/e2e_test/README.md`.
|
> (`.config/nextest.toml`); admission criteria: `crates/e2e_test/README.md`.
|
||||||
> 🌙 marks tests in the scheduled `e2e-repl-nightly` profile (backlog#1147
|
> 🌙 marks tests in the scheduled `e2e-repl-nightly` profile (backlog#1147
|
||||||
> repl-1): `replication_extension_test` splits 20 fast tests into the PR smoke
|
> repl-1): `replication_extension_test` splits 20 fast tests into the PR smoke
|
||||||
> lane and 23 slow / `_real_dual_node` / `_real_single_node` tests into the
|
> lane and 24 slow / `_real_dual_node` / `_real_three_node` / `_real_single_node` tests into the
|
||||||
> nightly lane (`.github/workflows/e2e-replication-nightly.yml`).
|
> nightly lane (`.github/workflows/e2e-replication-nightly.yml`).
|
||||||
> Note: counts exclude `#[ignore]`d tests (nextest lists them separately).
|
> Note: counts exclude `#[ignore]`d tests (nextest lists them separately).
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
| quota_test | 13 | |
|
| quota_test | 13 | |
|
||||||
| reliability_disk_fault_test | 3 | |
|
| reliability_disk_fault_test | 3 | |
|
||||||
| reliant | 9 | 3 ✅ |
|
| reliant | 9 | 3 ✅ |
|
||||||
| replication_extension_test | 43 | 20 ✅ +23 🌙 |
|
| replication_extension_test | 44 | 20 ✅ +24 🌙 |
|
||||||
| security_boundary_test | 4 | |
|
| security_boundary_test | 4 | |
|
||||||
| server_startup_failfast_test | 1 | |
|
| server_startup_failfast_test | 1 | |
|
||||||
| snowball_auto_extract_test | 6 | |
|
| snowball_auto_extract_test | 6 | |
|
||||||
@@ -68,4 +68,4 @@
|
|||||||
| tls_gen | 3 | |
|
| tls_gen | 3 | |
|
||||||
| version_id_regression_test | 10 | ✅ |
|
| version_id_regression_test | 10 | ✅ |
|
||||||
|
|
||||||
**Total listed: 424 tests across 52 modules · PR smoke subset: 93 tests / 20 modules** (18 full modules + 3 `reliant::lifecycle` tests + 20 of `replication_extension_test`) **· nightly `e2e-repl-nightly`: 23 tests** · generated 2026-07-15.
|
**Total listed: 425 tests across 52 modules · PR smoke subset: 93 tests / 20 modules** (18 full modules + 3 `reliant::lifecycle` tests + 20 of `replication_extension_test`) **· nightly `e2e-repl-nightly`: 24 tests** · generated 2026-07-15.
|
||||||
|
|||||||
Reference in New Issue
Block a user