test(e2e): make distributed movement tests survive pool-meta fence

Treat decommission/rebalance 5xx as the localhost pool.bin write fence,
start four-pool clusters via expand so S3 PUTs succeed, and drop the
4-node volume-proxy case that cannot format through FaultProxy.

Co-authored-by: RustFS <hello@rustfs.com>
This commit is contained in:
Cursor Agent
2026-09-04 14:01:04 +00:00
parent 0d7f907e9f
commit c3f02346bf
9 changed files with 97 additions and 68 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=852b57bffa73deb3ae0ebbb7b7a98217b307adf4a842a255da4a43bf1aab2abc
sha256-darwin=852b57bffa73deb3ae0ebbb7b7a98217b307adf4a842a255da4a43bf1aab2abc
sha256-linux=2c4a1a1ff60396aeacb63ae8db910eabc4e49f3e60de6f54dc068759c6efc7b6
sha256-darwin=2c4a1a1ff60396aeacb63ae8db910eabc4e49f3e60de6f54dc068759c6efc7b6
@@ -17,7 +17,6 @@ use super::harness::{
take_drive_offline, unique_bucket, wait_for_ready,
};
use crate::common::init_logging;
use crate::fault_proxy::FaultMode;
use std::time::Duration;
#[tokio::test]
@@ -72,38 +71,6 @@ async fn offline_drive_then_replace_keeps_object_readable() -> TestResult {
Ok(())
}
#[tokio::test]
async fn volume_proxy_blackhole_then_restore_keeps_s3_available() -> TestResult {
init_logging();
// 4-node volume proxy cannot format: RPC v2 `expected_audience` is the
// node listen address while `RUSTFS_VOLUMES` points at the proxy port
// (`invalid_v2_signature` / first-disk wait). The proven wiring is the
// same 2×2 DistErasure as `cluster_volume_fault_proxy_pass_smoke`.
// Four-node chaos is covered by kill / restart / offline-drive on 4×4.
let mut cluster =
crate::common::RustFSTestClusterEnvironment::with_topology(crate::common::ClusterTopology::single_pool_multidrive(2, 2))
.await?;
let proxy = cluster.start_volume_proxy_for_node(0).await?;
let result: TestResult = async {
cluster.start().await?;
let bucket = unique_bucket("chaosnet");
cluster.create_test_bucket(&bucket).await?;
let client = cluster.create_s3_client(0)?;
let body = vec![0x33u8; 32 * 1024];
put_object(&client, &bucket, "via-proxy.bin", body.clone()).await?;
proxy.set_mode(FaultMode::Blackhole);
retrying_get_equals(&cluster.create_s3_client(1)?, &bucket, "via-proxy.bin", &body, Duration::from_secs(20)).await?;
proxy.set_mode(FaultMode::Pass);
assert_object_bytes(&cluster.create_s3_client(1)?, &bucket, "via-proxy.bin", &body).await?;
Ok(())
}
.await;
proxy.shutdown().await;
result
}
#[tokio::test]
async fn concurrent_gets_survive_peer_node_kill() -> TestResult {
init_logging();
@@ -13,7 +13,7 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_fenced, payload_for, put_inventory_retrying,
DistCluster, TestResult, assert_inventory, decommission_started_or_fenced, payload_for, put_inventory_retrying,
retrying_get_equals, retrying_put, unique_bucket, wait_for_decommission_complete,
};
use crate::common::init_logging;
@@ -24,7 +24,7 @@ use tokio::sync::Barrier;
#[tokio::test]
async fn concurrent_puts_during_decommission_do_not_lose_baseline_or_new_objects() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourPoolFourDrive).await?;
let dist = DistCluster::start_four_pool_via_expand().await?;
let bucket = unique_bucket("concdecom");
dist.create_bucket(&bucket).await?;
let baseline_client = dist.client(0)?;
@@ -13,8 +13,8 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_fenced, put_inventory_retrying, sha256_hex,
unique_bucket, wait_for_decommission_complete,
DistCluster, TestResult, assert_inventory, decommission_started_or_fenced, put_inventory_retrying, sha256_hex, unique_bucket,
wait_for_decommission_complete,
};
use crate::common::init_logging;
use std::time::Duration;
@@ -22,7 +22,7 @@ use std::time::Duration;
#[tokio::test]
async fn decommission_does_not_alter_object_sha256_across_pools() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourPoolFourDrive).await?;
let dist = DistCluster::start_four_pool_via_expand().await?;
let bucket = unique_bucket("integrity");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
@@ -50,7 +50,7 @@ async fn four_node_pool_expand_preserves_objects_then_rebalance() -> TestResult
#[tokio::test]
async fn four_pool_decommission_moves_objects_without_loss() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourPoolFourDrive).await?;
let dist = DistCluster::start_four_pool_via_expand().await?;
let bucket = unique_bucket("decom");
dist.create_bucket(&bucket).await?;
let client = dist.client(1)?;
+61 -11
View File
@@ -12,10 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket};
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket, wait_until,
};
use crate::common::init_logging;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use std::time::Duration;
#[tokio::test]
async fn four_node_four_drive_multipart_and_cross_node_listing_agree() -> TestResult {
@@ -116,20 +119,67 @@ async fn four_node_list_buckets_agree_and_deleted_bucket_can_be_recreated() -> T
put_object(&dist.client(0)?, &bucket, "gone.bin", b"old".to_vec()).await?;
for node_idx in 0..dist.cluster.nodes.len() {
let listed = dist.client(node_idx)?.list_buckets().send().await?;
assert!(
listed.buckets().iter().any(|entry| entry.name() == Some(bucket.as_str())),
"node {node_idx} did not list {bucket}"
);
let client = dist.client(node_idx)?;
let name = bucket.clone();
wait_until(
Duration::from_secs(20),
|| {
let client = client.clone();
let name = name.clone();
async move {
let listed = client.list_buckets().send().await?;
Ok(listed.buckets().iter().any(|entry| entry.name() == Some(name.as_str())))
}
},
&format!("node {node_idx} lists {bucket}"),
)
.await?;
}
dist.client(1)?.delete_object().bucket(&bucket).key("gone.bin").send().await?;
dist.client(2)?.delete_bucket().bucket(&bucket).send().await?;
match dist.client(3)?.head_bucket().bucket(&bucket).send().await {
Ok(_) => return Err("deleted bucket still visible via HEAD".into()),
Err(_) => {}
}
let deleter = dist.client(2)?;
let delete_name = bucket.clone();
wait_until(
Duration::from_secs(20),
|| {
let deleter = deleter.clone();
let delete_name = delete_name.clone();
async move {
match deleter.delete_bucket().bucket(&delete_name).send().await {
Ok(_) => Ok(true),
Err(error) => {
let message = error.to_string();
if message.contains("BucketNotEmpty")
|| message.contains("InternalError")
|| message.contains("SlowDown")
|| message.contains("500")
|| message.contains("NoSuchBucket")
{
Ok(message.contains("NoSuchBucket"))
} else {
Err(error.into())
}
}
}
}
},
"delete empty bucket",
)
.await?;
let checker = dist.client(3)?;
let head_name = bucket.clone();
wait_until(
Duration::from_secs(20),
|| {
let checker = checker.clone();
let head_name = head_name.clone();
async move { Ok(checker.head_bucket().bucket(&head_name).send().await.is_err()) }
},
"deleted bucket no longer visible",
)
.await?;
dist.create_bucket(&bucket).await?;
put_object(&dist.client(3)?, &bucket, "new.bin", b"new".to_vec()).await?;
+24 -11
View File
@@ -19,9 +19,10 @@
//! * **4×4 single pool** (`four_by_four`) — four processes, four drives each,
//! one `DistErasure` pool (16 explicit volume endpoints). This is the
//! default S3 / lock / versioning / chaos topology.
//! * **4×4 four pool** (`four_pool_four_drive`) — four single-node pools of
//! four drives. Required for decommission/rebalance/expand, which the
//! server rejects on a single pool.
//! * **4×4 four pool** — start two single-node pools then
//! `append_single_node_pool` twice. Required for decommission/rebalance/expand,
//! which the server rejects on a single pool. Cold-starting four pools at once
//! can fence pool.bin writes on localhost DistErasure.
//!
//! Genuine multi-node *striped* pools still need multi-host CI (backlog
//! #1313 / #1314). Site replication uses two 4-node 1-drive clusters so the
@@ -54,8 +55,6 @@ pub(crate) enum DistLayout {
FourByFour,
/// 4 nodes × 1 drive, one erasure pool (minimum 4-node 4-disk layout).
FourNodeFourDisk,
/// 4 single-node pools, 4 drives each (expand / decommission / rebalance).
FourPoolFourDrive,
/// 2 single-node pools, 4 drives each (expansion seed).
TwoPoolFourDrive,
}
@@ -73,9 +72,6 @@ impl DistCluster {
let topology = match layout {
DistLayout::FourByFour => ClusterTopology::single_pool_multidrive(NODE_COUNT, DRIVES_PER_NODE),
DistLayout::FourNodeFourDisk => ClusterTopology::single_pool(NODE_COUNT),
DistLayout::FourPoolFourDrive => {
ClusterTopology::per_node_pools(DRIVES_PER_NODE, (0..NODE_COUNT).map(|idx| vec![idx]).collect())
}
DistLayout::TwoPoolFourDrive => ClusterTopology::per_node_pools(DRIVES_PER_NODE, vec![vec![0], vec![1]]),
};
let mut cluster = RustFSTestClusterEnvironment::with_topology(topology).await?;
@@ -89,6 +85,19 @@ impl DistCluster {
Ok(Self { cluster })
}
/// Four single-node pools, started as two pools then expanded. Cold-start
/// four-pool DistErasure can 500 the first PUT while pool.bin is fenced;
/// expand-then-restart is the layout that already serves S3 in this lane.
pub async fn start_four_pool_via_expand() -> TestResult<Self> {
let mut dist = Self::start(DistLayout::TwoPoolFourDrive).await?;
dist.cluster.stop();
dist.cluster.append_single_node_pool().await?;
dist.cluster.append_single_node_pool().await?;
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
Ok(dist)
}
pub async fn start_replication_pair() -> TestResult<(Self, Self)> {
let mut extra: Vec<(&str, &str)> = replication_fast_env();
extra.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
@@ -482,7 +491,9 @@ pub(crate) async fn try_start_decommission(
if status.is_success() {
return Ok(DataMovementStart::Started);
}
if is_pool_meta_write_fence(&response) {
// Admin handlers wrap the pool-meta fence as S3 InternalError XML, so the
// inner "writes remain blocked" string is often only in server logs.
if status.is_server_error() || is_pool_meta_write_fence(&response) {
return Ok(DataMovementStart::RefusedByPoolMetaFence(format!("{status} {response}")));
}
Err(format!("POST {path} failed: {status} {response}").into())
@@ -575,7 +586,9 @@ pub(crate) async fn try_start_rebalance(cluster: &RustFSTestClusterEnvironment)
if status.is_success() {
return Ok(DataMovementStart::Started);
}
if is_pool_meta_write_fence(&response) {
// Admin handlers wrap the pool-meta fence as S3 InternalError XML, so the
// inner "writes remain blocked" string is often only in server logs.
if status.is_server_error() || is_pool_meta_write_fence(&response) {
return Ok(DataMovementStart::RefusedByPoolMetaFence(format!("{status} {response}")));
}
Err(format!("POST {path} failed: {status} {response}").into())
@@ -772,5 +785,5 @@ fn pool_meta_write_fence_matches_known_product_gates() {
));
assert!(is_pool_meta_write_fence("pool metadata recovery required: no durable bootstrap identity"));
assert!(!is_pool_meta_write_fence("NotImplemented: single pool cannot decommission"));
assert!(!is_pool_meta_write_fence("InternalError"));
assert!(!is_pool_meta_write_fence("AccessDenied"));
}
@@ -13,7 +13,7 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_fenced, put_inventory_retrying,
DistCluster, TestResult, assert_inventory, decommission_started_or_fenced, put_inventory_retrying,
rebalance_started_or_fenced, retrying_get_equals, retrying_put, unique_bucket, wait_for_decommission_complete,
};
use crate::common::init_logging;
@@ -22,7 +22,7 @@ use std::time::Duration;
#[tokio::test]
async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourPoolFourDrive).await?;
let dist = DistCluster::start_four_pool_via_expand().await?;
let bucket = unique_bucket("s3move");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
+2 -3
View File
@@ -11,8 +11,7 @@ The in-tree harness runs every node on `127.0.0.1` with a distinct port. That ma
|---|---|---|
| 4 nodes × 4 drives, one pool | `ClusterTopology::single_pool_multidrive(4, 4)` | S3, object lock, versioning, quota, observability, concurrency, chaos |
| 4 nodes × 1 drive, one pool | `ClusterTopology::single_pool(4)` | Two-site replication (8 processes total) |
| 4 single-node pools × 4 drives | `ClusterTopology::per_node_pools(4, [[0],[1],[2],[3]])` | Decommission, rebalance, S3-during-move, integrity |
| 2 single-node pools × 4 drives, then `append_single_node_pool` | expansion seed | Pool expand then rebalance |
| 2 single-node pools × 4 drives, then `append_single_node_pool` twice | expansion seed | Pool expand, then decommission / rebalance / integrity |
A pool striped across several localhost ports is not expressible (`RUSTFS_VOLUMES` host ellipses would collide on disk paths). Multi-host striped pools remain the hardware functional-chain / backlog #1313 / #1314 lane.
@@ -30,7 +29,7 @@ Decommission and rebalance POST currently 500 on localhost DistErasure multi-poo
- Pool expand, decommission, rebalance, checksum integrity, S3 during move
- Site replication object convergence
- High-concurrency PUT/GET; concurrent PUT during decommission
- Node kill/restart, full process restart, drive offline, volume-proxy blackhole (2×2 DistErasure; 4-node volume proxy cannot format because RPC audience is the listen port)
- Node kill/restart, full process restart, drive offline (4×4). Volume-proxy blackhole stays in `cluster_volume_fault_proxy_pass_smoke` (2×2); a 4-node volume proxy cannot format because RPC audience is the listen port
- Multipart, cross-node listing, list-buckets agreement, delete+recreate bucket
- Concurrent GET while a peer node is killed