mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-30 08:49:26 +00:00
fix(ecstore): fence rebalance and decommission activation (#6400)
* fix(ecstore): fence rebalance and decommission activation * fix(ecstore): fence lost activation locks * fix(ecstore): bind rebalance workers to activation id * fix(ecstore): close rebalance activation races * test(ecstore): exercise lost rebalance commit fence * fix(ecstore): repair rebalance fence test wiring * test(ecstore): reuse rebalance metadata fixture * fix(ecstore): satisfy rebalance activation clippy checks * fix(ecstore): fence stale rebalance workers * fix(ecstore): commit rebalance activation after persistence * fix(ecstore): fence rebalance commits and unblock stop * test(ecstore): exercise real rebalance fences * fix(rebalance): cancel admin stop before activation wait * fix(ecstore): fence multipart staging on rebalance lock loss * fix(ecstore): adopt activations after durable commit * fix(rebalance): preserve committed activation recovery * fix(rebalance): make prepared stop terminal-safe * fix(ecstore): repair rebalance test imports * fix(ecstore): repair rebalance entry runtime failures * test(ecstore): fix activation fence synchronization * test(ecstore): scope rebalance disk trait import * test(ecstore): observe decommission lock attempt * fix(ecstore): align activation fence test imports * fix(ecstore): remove duplicate activation test import * fix(ecstore): resolve CI clippy failures * fix: satisfy activation merge lint gates --------- Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
@@ -51,7 +51,7 @@ const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
|
||||
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 1;
|
||||
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 2;
|
||||
|
||||
/// Cached result from the last successful admin call to a peer.
|
||||
struct PeerAdminCache {
|
||||
@@ -210,6 +210,24 @@ pub fn cross_pool_fence_fleet_proof_matches(proof: &CrossPoolFenceFleetProofToke
|
||||
fleet_capability_proof_matches(cross_pool_fence_fleet_proof_slot(), &proof.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_cross_pool_fence_fleet_proof_for_test() {
|
||||
let topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "pool-activation-test-topology".to_string());
|
||||
let _ = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology.clone());
|
||||
let mut state = cross_pool_fence_fleet_proof_slot()
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.topology_conflict = false;
|
||||
state.proof = Some(FleetCapabilityProof {
|
||||
topology_fingerprint: topology,
|
||||
peer_epochs: Arc::new(BTreeMap::new()),
|
||||
expires_at: Instant::now() + Duration::from_secs(60 * 60),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn rotate_cross_pool_fence_fleet_proof_for_test() -> bool {
|
||||
let mut state = cross_pool_fence_fleet_proof_slot()
|
||||
@@ -352,7 +370,7 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
||||
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
|
||||
capability = "cross_pool_fence_v1",
|
||||
capability = "cross_pool_fence_v2",
|
||||
state = "failed_closed",
|
||||
error = %err,
|
||||
"notification capability probe"
|
||||
@@ -1121,9 +1139,21 @@ impl NotificationSys {
|
||||
}
|
||||
}
|
||||
|
||||
match store.stop_rebalance_for_id(expected_rebalance_id).await {
|
||||
let local_rebalance_id = match expected_rebalance_id {
|
||||
Some(expected_id) => Some(expected_id.to_owned()),
|
||||
None => store.current_rebalance_id().await,
|
||||
};
|
||||
match store.stop_rebalance_for_id(local_rebalance_id.as_deref()).await {
|
||||
Ok(_) => {
|
||||
if let Err(err) = store.save_rebalance_stats(usize::MAX, RebalSaveOpt::StoppedAt).await {
|
||||
let save_result = match local_rebalance_id.as_deref() {
|
||||
Some(expected_id) => {
|
||||
store
|
||||
.save_rebalance_stats_for_id(usize::MAX, RebalSaveOpt::StoppedAt, expected_id)
|
||||
.await
|
||||
}
|
||||
None => Ok(()),
|
||||
};
|
||||
if let Err(err) = save_result {
|
||||
error!(
|
||||
event = EVENT_NOTIFICATION_PEER_PROPAGATION,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -155,7 +155,7 @@ impl RebalanceMeta {
|
||||
self.save_with_opts(store, ObjectOptions::default()).await
|
||||
}
|
||||
|
||||
pub async fn save_with_opts<S>(&self, store: Arc<S>, opts: ObjectOptions) -> Result<()>
|
||||
pub async fn save_with_opts<S>(&self, store: Arc<S>, mut opts: ObjectOptions) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
@@ -188,6 +188,14 @@ impl RebalanceMeta {
|
||||
let msg = rmp_serde::to_vec(self)?;
|
||||
data.extend(msg);
|
||||
|
||||
if self.stopped_at.is_none() && is_rebalance_conflicting_with_decommission(self) {
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut opts.user_defined,
|
||||
rustfs_utils::http::metadata_compat::SUFFIX_REBALANCE_RUN_ID,
|
||||
self.id.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
save_config_with_opts(store, REBAL_META_NAME, data, &opts).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -864,10 +872,6 @@ pub(super) fn merge_rebalance_meta(remote: &mut RebalanceMeta, local: &Rebalance
|
||||
RebalanceMetaMergeOutcome::Merged
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "stop-transition helper retained beside stop_rebalance_meta_snapshot; no caller yet (backlog#1823)"
|
||||
)]
|
||||
pub(super) fn mark_started_rebalance_pools_stopped(meta: &mut RebalanceMeta, stop_time: OffsetDateTime) {
|
||||
for pool_stat in meta.pool_stats.iter_mut() {
|
||||
if pool_stat.info.status == RebalStatus::Started {
|
||||
@@ -935,6 +939,9 @@ pub(super) fn stop_rebalance_meta_snapshot_for_id(
|
||||
}
|
||||
|
||||
stop_rebalance_state(meta, now);
|
||||
// The caller holds the activation writer after admission was cancelled,
|
||||
// so all entry readers have drained and no later entry can be admitted.
|
||||
mark_started_rebalance_pools_stopped(meta, now);
|
||||
meta.last_refreshed_at = Some(now);
|
||||
Ok(Some(meta.clone()))
|
||||
}
|
||||
|
||||
@@ -102,11 +102,20 @@ pub(crate) trait MigrationBackend: Send + Sync {
|
||||
pub(crate) struct RebalanceMigrationBackend<'a> {
|
||||
source: &'a SetDisks,
|
||||
store: &'a ECStore,
|
||||
lock_lost_signal: Option<std::sync::Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
}
|
||||
|
||||
impl<'a> RebalanceMigrationBackend<'a> {
|
||||
pub(crate) fn new(source: &'a SetDisks, store: &'a ECStore) -> Self {
|
||||
Self { source, store }
|
||||
pub(crate) fn new(
|
||||
source: &'a SetDisks,
|
||||
store: &'a ECStore,
|
||||
lock_lost_signal: Option<std::sync::Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
store,
|
||||
lock_lost_signal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +139,11 @@ impl MigrationBackend for RebalanceMigrationBackend<'_> {
|
||||
fi: &FileInfo,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
self.store.decommission_tiered_object(bucket, object, fi, opts).await
|
||||
let mut opts = opts.clone();
|
||||
if let Some(signal) = self.lock_lost_signal.as_ref() {
|
||||
opts.add_namespace_lock_lost_signal(std::sync::Arc::clone(signal));
|
||||
}
|
||||
self.store.decommission_tiered_object(bucket, object, fi, &opts).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::disk::DiskAPI;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
use tokio::time::Duration;
|
||||
@@ -45,6 +47,8 @@ mod runtime;
|
||||
mod types;
|
||||
mod worker;
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use entry::test_util::PausedRebalanceEntryTestFixture;
|
||||
pub(crate) use meta::is_rebalance_conflicting_with_decommission;
|
||||
pub use meta::{decode_rebalance_stop_propagation_record, encode_rebalance_stop_propagation_record};
|
||||
pub use types::{
|
||||
@@ -53,5 +57,130 @@ pub use types::{
|
||||
};
|
||||
use types::{RebalanceBucketConfigs, RebalanceBucketOutcome, RebalanceEntryOutcome};
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub async fn test_store_with_persisted_rebalance_meta(
|
||||
meta: RebalanceMeta,
|
||||
) -> (Vec<tempfile::TempDir>, std::sync::Arc<crate::store::ECStore>) {
|
||||
let ctx = std::sync::Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let (temp_dirs, pool) = crate::core::sets::make_local_two_set_sets_with_ctx(ctx.clone()).await;
|
||||
meta.save(pool.clone())
|
||||
.await
|
||||
.expect("rebalance test metadata should be persisted");
|
||||
let endpoint_pools: crate::layout::endpoints::EndpointServerPools = vec![pool.endpoints.clone()].into();
|
||||
let store = std::sync::Arc::new(crate::store::ECStore {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
disk_map: std::collections::HashMap::new(),
|
||||
pools: vec![pool],
|
||||
peer_sys: crate::cluster::rpc::S3PeerSys::new_with_instance_ctx(&endpoint_pools, ctx.clone()),
|
||||
pool_meta: tokio::sync::RwLock::new(crate::core::pools::PoolMeta::default()),
|
||||
rebalance_meta: tokio::sync::RwLock::new(Some(meta)),
|
||||
decommission_cancelers: tokio::sync::RwLock::new(vec![None]),
|
||||
start_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::new(()),
|
||||
ctx,
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
});
|
||||
(temp_dirs, store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn test_two_pool_stores(
|
||||
rebalance_meta: Option<RebalanceMeta>,
|
||||
) -> (
|
||||
Vec<tempfile::TempDir>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
) {
|
||||
test_two_pool_stores_with_contexts(rebalance_meta, false).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn test_two_pool_stores_with_isolated_node_contexts(
|
||||
rebalance_meta: Option<RebalanceMeta>,
|
||||
) -> (
|
||||
Vec<tempfile::TempDir>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
) {
|
||||
test_two_pool_stores_with_contexts(rebalance_meta, true).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn test_two_pool_stores_with_contexts(
|
||||
rebalance_meta: Option<RebalanceMeta>,
|
||||
isolate_node_contexts: bool,
|
||||
) -> (
|
||||
Vec<tempfile::TempDir>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
) {
|
||||
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
|
||||
use crate::core::pools::PoolMeta;
|
||||
use crate::layout::endpoints::{EndpointServerPools, SetupType};
|
||||
|
||||
let ctx = std::sync::Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
let (mut temp_dirs, first_pool) =
|
||||
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(std::sync::Arc::clone(&ctx), 0).await;
|
||||
let (second_temp_dirs, second_pool) =
|
||||
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(std::sync::Arc::clone(&ctx), 1).await;
|
||||
temp_dirs.extend(second_temp_dirs);
|
||||
let pools = vec![first_pool, second_pool];
|
||||
{
|
||||
let local_disk_map = ctx.local_disk_map();
|
||||
let mut local_disk_map = local_disk_map.write().await;
|
||||
for pool in &pools {
|
||||
for set in &pool.disk_set {
|
||||
for disk in set.disks.read().await.iter().flatten() {
|
||||
local_disk_map.insert(disk.endpoint().to_string(), Some(disk.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
|
||||
pool_meta
|
||||
.save(pools.clone())
|
||||
.await
|
||||
.expect("baseline pool metadata should be persisted");
|
||||
if let Some(meta) = rebalance_meta.as_ref() {
|
||||
meta.save(pools[0].clone())
|
||||
.await
|
||||
.expect("active rebalance metadata should be persisted");
|
||||
}
|
||||
let endpoint_pools: EndpointServerPools = pools.iter().map(|pool| pool.endpoints.clone()).collect::<Vec<_>>().into();
|
||||
ctx.set_endpoints(endpoint_pools.clone());
|
||||
let other_ctx = if isolate_node_contexts {
|
||||
let other_ctx = std::sync::Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
other_ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
*other_ctx.local_disk_map().write().await = ctx.local_disk_map().read().await.clone();
|
||||
other_ctx.set_endpoints(endpoint_pools.clone());
|
||||
other_ctx
|
||||
} else {
|
||||
std::sync::Arc::clone(&ctx)
|
||||
};
|
||||
let make_store = |store_ctx: std::sync::Arc<crate::runtime::instance::InstanceContext>| {
|
||||
std::sync::Arc::new(crate::store::ECStore {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
disk_map: std::collections::HashMap::new(),
|
||||
pools: pools.clone(),
|
||||
peer_sys: crate::cluster::rpc::S3PeerSys::new_with_instance_ctx(&endpoint_pools, std::sync::Arc::clone(&store_ctx)),
|
||||
pool_meta: tokio::sync::RwLock::new(pool_meta.clone()),
|
||||
rebalance_meta: tokio::sync::RwLock::new(rebalance_meta.clone()),
|
||||
decommission_cancelers: tokio::sync::RwLock::new(vec![None, None]),
|
||||
start_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::new(()),
|
||||
ctx: store_ctx,
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
})
|
||||
};
|
||||
let store = make_store(ctx);
|
||||
let other_store = make_store(other_ctx);
|
||||
if isolate_node_contexts {
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(std::sync::Arc::clone(&store), Vec::new()).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(std::sync::Arc::clone(&other_store), Vec::new()).await;
|
||||
}
|
||||
(temp_dirs, store, other_store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rebalance_unit_tests;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::control::validate_rebalance_disk_stats_coverage;
|
||||
use super::control::{fail_next_rebalance_activation_save_for_test, validate_rebalance_disk_stats_coverage};
|
||||
use super::meta::{
|
||||
RebalanceMetaMergeOutcome, RebalanceTerminalEvent, apply_rebalance_save_option, apply_rebalance_terminal_event,
|
||||
apply_stopped_at, classify_rebalance_terminal_event, clone_arc_by_index, clone_first_arc, clone_rebalance_pool_stats,
|
||||
@@ -32,7 +32,11 @@ use super::migration::{
|
||||
MigrationBackend, MigrationVersionResult, migrate_entry_version, migrate_entry_version_with_retry_wait,
|
||||
rebalance_delete_marker_opts,
|
||||
};
|
||||
use super::runtime::{should_fail_repeated_rebalance_bucket_defer, source_cleanup_defer_attempt};
|
||||
use super::runtime::{
|
||||
RebalanceLocalActivationOutcome, commit_local_rebalance_worker_activation,
|
||||
commit_local_rebalance_worker_activation_candidate, should_fail_repeated_rebalance_bucket_defer,
|
||||
source_cleanup_defer_attempt, stage_local_rebalance_worker_activation,
|
||||
};
|
||||
use super::worker::{
|
||||
RebalanceEntryCleanupResult, ensure_rebalance_listing_disks_available, is_transient_rebalance_error,
|
||||
parse_rebalance_max_attempts, rebalance_listing_retry_delay, rebalance_migration_retry_delay,
|
||||
@@ -48,6 +52,7 @@ use super::worker::{
|
||||
use super::{
|
||||
DiskStat, GetObjectReader, ObjectInfo, ObjectOptions, RebalSaveOpt, RebalStatus, RebalanceBucketConfigs,
|
||||
RebalanceBucketOutcome, RebalanceCleanupWarnings, RebalanceEntryOutcome, RebalanceInfo, RebalanceMeta, RebalanceStats,
|
||||
RebalanceStopPropagationRecord,
|
||||
};
|
||||
use super::{REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX, REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX};
|
||||
use crate::bucket::replication::{ReplicationState, ReplicationStatusType, replication_state_to_filemeta};
|
||||
@@ -2708,6 +2713,281 @@ async fn test_start_rebalance_for_id_rejects_stopped_metadata() {
|
||||
assert!(err.to_string().contains("was stopped before start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopped_activation_state_prevents_worker_token_commit() {
|
||||
let mut meta = RebalanceMeta {
|
||||
id: "rebalance-a".to_string(),
|
||||
stopped_at: Some(OffsetDateTime::now_utc()),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let outcome = commit_local_rebalance_worker_activation(&mut meta, "rebalance-a", tokio_util::sync::CancellationToken::new())
|
||||
.expect("stopped metadata should produce a non-start outcome");
|
||||
assert_eq!(outcome, RebalanceLocalActivationOutcome::NotStartedTerminal);
|
||||
assert!(meta.cancel.is_none(), "stopped rebalance must not receive a worker token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_activation_candidate_does_not_clobber_replacement_token() {
|
||||
let mut local = RebalanceMeta {
|
||||
id: "rebalance-a".to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
buckets: vec!["bucket-a".to_string()],
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let (candidate, outcome, must_persist) =
|
||||
stage_local_rebalance_worker_activation(&local, "rebalance-a", CancellationToken::new(), OffsetDateTime::UNIX_EPOCH)
|
||||
.expect("active activation candidate should be staged");
|
||||
assert_eq!(outcome, RebalanceLocalActivationOutcome::Started);
|
||||
assert!(!must_persist);
|
||||
|
||||
let replacement = CancellationToken::new();
|
||||
local.cancel = Some(replacement.clone());
|
||||
let err = commit_local_rebalance_worker_activation_candidate(&mut local, "rebalance-a", None, candidate)
|
||||
.expect_err("a replacement token must reject the stale activation candidate");
|
||||
assert!(err.to_string().contains("worker token changed"));
|
||||
assert_eq!(local.cancel.as_ref(), Some(&replacement));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_rebalance_start_save_failure_retries_persisted_completed_state() {
|
||||
let active = RebalanceMeta {
|
||||
id: "rebalance-real-save-completed".to_string(),
|
||||
percent_free_goal: 0.5,
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
init_free_space: 400,
|
||||
init_capacity: 1_000,
|
||||
bytes: 100,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let (_temp_dirs, store) = super::test_store_with_persisted_rebalance_meta(active).await;
|
||||
|
||||
fail_next_rebalance_activation_save_for_test("rebalance-real-save-completed");
|
||||
let err = store
|
||||
.start_rebalance_under_gate()
|
||||
.await
|
||||
.expect_err("the injected first activation save must fail through the real start path");
|
||||
assert!(err.to_string().contains("injected rebalance activation save failure"));
|
||||
{
|
||||
let local = store.rebalance_meta.read().await;
|
||||
let local = local.as_ref().expect("local rebalance metadata should remain present");
|
||||
assert_eq!(local.pool_stats[0].info.status, RebalStatus::Started);
|
||||
assert!(local.cancel.is_none(), "failed persistence must not publish a worker token");
|
||||
}
|
||||
let mut after_failure = RebalanceMeta::new();
|
||||
after_failure
|
||||
.load(store.pools[0].clone())
|
||||
.await
|
||||
.expect("active metadata should remain readable after the failed save");
|
||||
assert_eq!(after_failure.pool_stats[0].info.status, RebalStatus::Started);
|
||||
|
||||
store
|
||||
.start_rebalance_under_gate()
|
||||
.await
|
||||
.expect("the real start path must retry and persist the terminal candidate");
|
||||
|
||||
let mut persisted = RebalanceMeta::new();
|
||||
persisted
|
||||
.load(store.pools[0].clone())
|
||||
.await
|
||||
.expect("retry-persisted completed metadata should be readable");
|
||||
assert_eq!(persisted.pool_stats[0].info.status, RebalStatus::Completed);
|
||||
let local = store.rebalance_meta.read().await;
|
||||
let local = local.as_ref().expect("local rebalance metadata should remain present");
|
||||
assert_eq!(local.pool_stats[0].info.status, RebalStatus::Completed);
|
||||
assert!(local.cancel.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_rebalance_start_save_failure_retries_persisted_stopped_state() {
|
||||
let active = RebalanceMeta {
|
||||
id: "rebalance-real-save-stopped".to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let (_temp_dirs, store) = super::test_store_with_persisted_rebalance_meta(active).await;
|
||||
let stopped_at = OffsetDateTime::from_unix_timestamp(1_000).expect("test timestamp should be valid");
|
||||
{
|
||||
let mut local = store.rebalance_meta.write().await;
|
||||
let local = local.as_mut().expect("local rebalance metadata should remain present");
|
||||
local.stopped_at = Some(stopped_at);
|
||||
local.pool_stats[0].info.status = RebalStatus::Stopped;
|
||||
local.pool_stats[0].info.end_time = Some(stopped_at);
|
||||
}
|
||||
|
||||
fail_next_rebalance_activation_save_for_test("rebalance-real-save-stopped");
|
||||
let err = store
|
||||
.start_rebalance_under_gate()
|
||||
.await
|
||||
.expect_err("the injected first stopped-state save must fail through the real start path");
|
||||
assert!(err.to_string().contains("injected rebalance activation save failure"));
|
||||
let mut after_failure = RebalanceMeta::new();
|
||||
after_failure
|
||||
.load(store.pools[0].clone())
|
||||
.await
|
||||
.expect("active metadata should remain readable after the failed save");
|
||||
assert_eq!(after_failure.pool_stats[0].info.status, RebalStatus::Started);
|
||||
{
|
||||
let local = store.rebalance_meta.read().await;
|
||||
let local = local.as_ref().expect("local rebalance metadata should remain present");
|
||||
assert_eq!(local.pool_stats[0].info.status, RebalStatus::Stopped);
|
||||
assert!(local.cancel.is_none(), "failed persistence must not publish a worker token");
|
||||
}
|
||||
|
||||
store
|
||||
.start_rebalance_under_gate()
|
||||
.await
|
||||
.expect("the real start path must retry and persist the stopped candidate");
|
||||
|
||||
let mut persisted = RebalanceMeta::new();
|
||||
persisted
|
||||
.load(store.pools[0].clone())
|
||||
.await
|
||||
.expect("retry-persisted stopped metadata should be readable");
|
||||
assert_eq!(persisted.stopped_at, Some(stopped_at));
|
||||
assert_eq!(persisted.pool_stats[0].info.status, RebalStatus::Stopped);
|
||||
let local = store.rebalance_meta.read().await;
|
||||
let local = local.as_ref().expect("local rebalance metadata should remain present");
|
||||
assert_eq!(local.pool_stats[0].info.status, RebalStatus::Stopped);
|
||||
assert!(local.cancel.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_old_worker_cannot_mutate_replacement_rebalance_state() {
|
||||
let meta = RebalanceMeta {
|
||||
id: "rebalance-b".to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
buckets: vec!["bucket-a".to_string()],
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let store = test_store_with_rebalance_meta(meta);
|
||||
let fi = FileInfo {
|
||||
size: 128,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for err in [
|
||||
store
|
||||
.next_rebal_bucket(0, "rebalance-a")
|
||||
.await
|
||||
.expect_err("old worker must not read replacement work"),
|
||||
store
|
||||
.bucket_rebalance_done(0, "bucket-a".to_string(), "rebalance-a")
|
||||
.await
|
||||
.expect_err("old worker must not complete replacement bucket"),
|
||||
store
|
||||
.update_pool_stats_batch_for_rebalance(0, "bucket-a".to_string(), &[&fi], "rebalance-a")
|
||||
.await
|
||||
.expect_err("old worker must not update replacement stats"),
|
||||
store
|
||||
.check_if_rebalance_done(0, "rebalance-a")
|
||||
.await
|
||||
.expect_err("old worker must not complete replacement pool"),
|
||||
store
|
||||
.save_rebalance_stats_for_id(0, RebalSaveOpt::Stats, "rebalance-a")
|
||||
.await
|
||||
.expect_err("old save task must not persist replacement metadata"),
|
||||
store
|
||||
.save_rebalance_stats_for_id(usize::MAX, RebalSaveOpt::StoppedAt, "rebalance-a")
|
||||
.await
|
||||
.expect_err("old stop path must not persist replacement metadata"),
|
||||
] {
|
||||
assert!(err.to_string().contains("stale rebalance worker rejected"));
|
||||
}
|
||||
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let meta = meta.as_ref().expect("replacement metadata should remain present");
|
||||
assert_eq!(meta.id, "rebalance-b");
|
||||
assert!(meta.pool_stats[0].rebalanced_buckets.is_empty());
|
||||
assert_eq!(meta.pool_stats[0].bytes, 0);
|
||||
assert_eq!(meta.pool_stats[0].info.status, RebalStatus::Started);
|
||||
assert!(meta.stopped_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rebalance_metadata_reload_under_start_gate_does_not_reacquire_gate() {
|
||||
let store = test_store_with_rebalance_meta(RebalanceMeta::default());
|
||||
let _start_guard = store.start_gate.lock().await;
|
||||
|
||||
let err = store
|
||||
.load_rebalance_meta_under_start_gate()
|
||||
.await
|
||||
.expect_err("empty test store should reach the metadata load without waiting on start_gate again");
|
||||
|
||||
assert!(err.to_string().contains("no pools available"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stale_stop_propagation_cannot_mutate_replacement_rebalance() {
|
||||
let meta = RebalanceMeta {
|
||||
id: "rebalance-b".to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let store = test_store_with_rebalance_meta(meta);
|
||||
let record = RebalanceStopPropagationRecord {
|
||||
stop_failures: vec!["old rebalance stop failed".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = store
|
||||
.record_rebalance_stop_propagation("rebalance-a", record)
|
||||
.await
|
||||
.expect_err("old propagation failure must not mutate replacement metadata");
|
||||
|
||||
assert!(err.to_string().contains("stale rebalance worker rejected"));
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let meta = meta.as_ref().expect("replacement metadata should remain present");
|
||||
assert_eq!(meta.id, "rebalance-b");
|
||||
assert!(meta.last_refreshed_at.is_none());
|
||||
assert!(meta.pool_stats[0].info.last_error.is_none());
|
||||
}
|
||||
|
||||
fn test_store_with_rebalance_meta(meta: RebalanceMeta) -> Arc<crate::store::ECStore> {
|
||||
let endpoint_pools: crate::layout::endpoints::EndpointServerPools = Vec::new().into();
|
||||
Arc::new(crate::store::ECStore {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::control::RebalanceWorkerActivationFence;
|
||||
use super::meta::{
|
||||
apply_rebalance_save_option, apply_rebalance_terminal_event, classify_rebalance_terminal_event, clone_first_arc,
|
||||
complete_rebalance_pools_at_goal, complete_rebalance_pools_with_empty_queue, ensure_valid_rebalance_pool_index,
|
||||
@@ -39,18 +40,103 @@ pub(super) fn source_cleanup_defer_attempt(deferred_attempts: &mut HashMap<Strin
|
||||
*attempts
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum RebalanceLocalActivationOutcome {
|
||||
Started,
|
||||
NotStartedTerminal,
|
||||
}
|
||||
|
||||
pub(super) fn commit_local_rebalance_worker_activation(
|
||||
meta: &mut super::RebalanceMeta,
|
||||
expected_id: &str,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<RebalanceLocalActivationOutcome> {
|
||||
if meta.id != expected_id {
|
||||
return Err(Error::other(format!(
|
||||
"rebalance metadata changed before local worker activation: expected {expected_id}, found {}",
|
||||
meta.id
|
||||
)));
|
||||
}
|
||||
if meta.stopped_at.is_some() || !is_rebalance_in_progress(meta) {
|
||||
return Ok(RebalanceLocalActivationOutcome::NotStartedTerminal);
|
||||
}
|
||||
meta.cancel = Some(cancel);
|
||||
Ok(RebalanceLocalActivationOutcome::Started)
|
||||
}
|
||||
|
||||
pub(super) fn stage_local_rebalance_worker_activation(
|
||||
meta: &super::RebalanceMeta,
|
||||
expected_id: &str,
|
||||
cancel: CancellationToken,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<(super::RebalanceMeta, RebalanceLocalActivationOutcome, bool)> {
|
||||
let mut candidate = meta.clone();
|
||||
let completed_at_goal = complete_rebalance_pools_at_goal(&mut candidate, now);
|
||||
let completed_empty_queue = complete_rebalance_pools_with_empty_queue(&mut candidate, now);
|
||||
let outcome = commit_local_rebalance_worker_activation(&mut candidate, expected_id, cancel)?;
|
||||
let must_persist =
|
||||
completed_at_goal || completed_empty_queue || outcome == RebalanceLocalActivationOutcome::NotStartedTerminal;
|
||||
Ok((candidate, outcome, must_persist))
|
||||
}
|
||||
|
||||
pub(super) fn commit_local_rebalance_worker_activation_candidate(
|
||||
current: &mut super::RebalanceMeta,
|
||||
expected_id: &str,
|
||||
expected_cancel: Option<&CancellationToken>,
|
||||
candidate: super::RebalanceMeta,
|
||||
) -> Result<()> {
|
||||
if current.id != expected_id || candidate.id != expected_id {
|
||||
return Err(Error::other(format!(
|
||||
"rebalance metadata changed before local worker activation commit: expected {expected_id}, found {}",
|
||||
current.id
|
||||
)));
|
||||
}
|
||||
if !Arc::ptr_eq(¤t.activation_gate, &candidate.activation_gate) {
|
||||
return Err(Error::other(format!(
|
||||
"rebalance activation gate changed before local worker activation commit: {expected_id}"
|
||||
)));
|
||||
}
|
||||
if current.cancel.as_ref() != expected_cancel {
|
||||
return Err(Error::other(format!(
|
||||
"rebalance worker token changed before local worker activation commit: {expected_id}"
|
||||
)));
|
||||
}
|
||||
*current = candidate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn rollback_local_rebalance_worker_activation(
|
||||
meta: Option<&mut super::RebalanceMeta>,
|
||||
expected_id: &str,
|
||||
activation_token: &CancellationToken,
|
||||
) -> bool {
|
||||
let Some(meta) = meta else {
|
||||
return false;
|
||||
};
|
||||
if meta.id != expected_id || meta.cancel.as_ref() != Some(activation_token) {
|
||||
return false;
|
||||
}
|
||||
if let Some(cancel) = meta.cancel.take() {
|
||||
cancel.cancel();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl ECStore {
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn start_rebalance(self: &Arc<Self>) -> Result<()> {
|
||||
let _start_guard = self.start_gate.lock().await;
|
||||
let _activation_guard = self.rebalance_activation_write_guard(None, "start rebalance").await?;
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.write().await;
|
||||
if self.start_rebalance_inner().await? {
|
||||
if self.start_rebalance_under_gate().await? {
|
||||
self.ctx.advance_data_movement_operation_epoch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn start_rebalance_inner(self: &Arc<Self>) -> Result<bool> {
|
||||
pub(super) async fn start_rebalance_under_gate(self: &Arc<Self>) -> Result<bool> {
|
||||
info!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -58,13 +144,27 @@ impl ECStore {
|
||||
state = "starting",
|
||||
"Starting rebalance"
|
||||
);
|
||||
let expected_id: Arc<str> = {
|
||||
let rebalance_meta = self.rebalance_meta.read().await;
|
||||
Arc::from(rebalance_meta.as_ref().ok_or(Error::ConfigNotFound)?.id.as_str())
|
||||
};
|
||||
let pool = clone_first_arc(self.pools.as_slice(), "start_rebalance: no pools available")?;
|
||||
let activation_fence = match self
|
||||
.fence_rebalance_worker_activation(pool.clone(), expected_id.as_ref())
|
||||
.await?
|
||||
{
|
||||
RebalanceWorkerActivationFence::Ready(fence) => fence,
|
||||
RebalanceWorkerActivationFence::NotStartedTerminal => return Ok(false),
|
||||
};
|
||||
|
||||
let decommission_running = self.is_decommission_running().await;
|
||||
// let rebalance_meta = self.rebalance_meta.read().await;
|
||||
|
||||
let cancel_tx = CancellationToken::new();
|
||||
let rx = cancel_tx.clone();
|
||||
let mut meta_to_save = None;
|
||||
let mut movement_changed = false;
|
||||
let activation_outcome;
|
||||
let candidate;
|
||||
let expected_cancel;
|
||||
let must_persist;
|
||||
|
||||
{
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
@@ -84,27 +184,70 @@ impl ECStore {
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
let now = OffsetDateTime::now_utc();
|
||||
if complete_rebalance_pools_at_goal(meta, now) {
|
||||
meta_to_save = Some(meta.clone());
|
||||
movement_changed = true;
|
||||
expected_cancel = meta.cancel.clone();
|
||||
(candidate, activation_outcome, must_persist) = stage_local_rebalance_worker_activation(
|
||||
meta,
|
||||
expected_id.as_ref(),
|
||||
cancel_tx.clone(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)?;
|
||||
if let Err(err) = activation_fence.ensure_held() {
|
||||
cancel_tx.cancel();
|
||||
return Err(err);
|
||||
}
|
||||
if complete_rebalance_pools_with_empty_queue(meta, now) {
|
||||
meta_to_save = Some(meta.clone());
|
||||
movement_changed = true;
|
||||
if !must_persist
|
||||
&& let Err(err) = commit_local_rebalance_worker_activation_candidate(
|
||||
meta,
|
||||
expected_id.as_ref(),
|
||||
expected_cancel.as_ref(),
|
||||
candidate.clone(),
|
||||
)
|
||||
{
|
||||
cancel_tx.cancel();
|
||||
return Err(err);
|
||||
}
|
||||
meta.cancel = Some(cancel_tx);
|
||||
|
||||
drop(rebalance_meta);
|
||||
}
|
||||
|
||||
if let Some(meta) = meta_to_save {
|
||||
let pool = clone_first_arc(self.pools.as_slice(), "start_rebalance: no pools available")?;
|
||||
resolve_rebalance_meta_save_result(
|
||||
self.save_rebalance_meta_with_merge(pool, &meta, "start_rebalance complete pools at goal")
|
||||
.await,
|
||||
"start_rebalance complete pools at goal",
|
||||
)?;
|
||||
if must_persist {
|
||||
let save_result = resolve_rebalance_meta_save_result(
|
||||
self.save_rebalance_meta_under_activation_fence(
|
||||
pool,
|
||||
&candidate,
|
||||
"start_rebalance persist activation candidate",
|
||||
activation_fence.as_ref(),
|
||||
expected_id.as_ref(),
|
||||
)
|
||||
.await,
|
||||
"start_rebalance persist activation candidate",
|
||||
);
|
||||
if let Err(err) = save_result {
|
||||
cancel_tx.cancel();
|
||||
return Err(err);
|
||||
}
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
let Some(meta) = rebalance_meta.as_mut() else {
|
||||
cancel_tx.cancel();
|
||||
return Err(Error::ConfigNotFound);
|
||||
};
|
||||
if let Err(err) = commit_local_rebalance_worker_activation_candidate(
|
||||
meta,
|
||||
expected_id.as_ref(),
|
||||
expected_cancel.as_ref(),
|
||||
candidate,
|
||||
) {
|
||||
cancel_tx.cancel();
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
if !must_persist && let Err(err) = activation_fence.ensure_held() {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
rollback_local_rebalance_worker_activation(rebalance_meta.as_mut(), expected_id.as_ref(), &rx);
|
||||
return Err(err);
|
||||
}
|
||||
drop(activation_fence);
|
||||
|
||||
if activation_outcome != RebalanceLocalActivationOutcome::Started {
|
||||
return Ok(must_persist);
|
||||
}
|
||||
|
||||
let participants = if let Some(ref meta) = *self.rebalance_meta.read().await {
|
||||
@@ -122,6 +265,8 @@ impl ECStore {
|
||||
};
|
||||
|
||||
if !participants.iter().any(|participating| *participating) {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
rollback_local_rebalance_worker_activation(rebalance_meta.as_mut(), expected_id.as_ref(), &rx);
|
||||
debug!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -130,9 +275,14 @@ impl ECStore {
|
||||
reason = "no_participants",
|
||||
"Skipped rebalance start because no pools are participating"
|
||||
);
|
||||
return Ok(movement_changed);
|
||||
return Ok(must_persist);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let endpoints = self.instance_endpoints().unwrap_or_else(|| self.endpoints());
|
||||
#[cfg(not(test))]
|
||||
let endpoints = self.endpoints();
|
||||
|
||||
let mut workers_started = 0usize;
|
||||
for (idx, participating) in participants.iter().enumerate() {
|
||||
if !*participating {
|
||||
@@ -148,7 +298,7 @@ impl ECStore {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !runtime_sources::endpoint_pool_is_local(idx) {
|
||||
if !runtime_sources::endpoint_pool_is_local(&endpoints, idx) {
|
||||
debug!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -164,9 +314,10 @@ impl ECStore {
|
||||
let pool_idx = idx;
|
||||
let store = self.clone();
|
||||
let rx_clone = rx.clone();
|
||||
let worker_id = Arc::clone(&expected_id);
|
||||
workers_started += 1;
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = store.rebalance_buckets(rx_clone, pool_idx).await {
|
||||
if let Err(err) = store.rebalance_buckets(rx_clone, pool_idx, worker_id).await {
|
||||
error!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -190,6 +341,8 @@ impl ECStore {
|
||||
}
|
||||
|
||||
if workers_started == 0 {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
rollback_local_rebalance_worker_activation(rebalance_meta.as_mut(), expected_id.as_ref(), &rx);
|
||||
debug!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -198,7 +351,7 @@ impl ECStore {
|
||||
reason = "no_local_participants",
|
||||
"Skipped rebalance start because no local pools are participating"
|
||||
);
|
||||
return Ok(movement_changed);
|
||||
return Ok(must_persist);
|
||||
}
|
||||
|
||||
info!(
|
||||
@@ -213,13 +366,14 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, rx))]
|
||||
async fn rebalance_buckets(self: &Arc<Self>, rx: CancellationToken, pool_index: usize) -> Result<()> {
|
||||
async fn rebalance_buckets(self: &Arc<Self>, rx: CancellationToken, pool_index: usize, rebalance_id: Arc<str>) -> Result<()> {
|
||||
ensure_valid_rebalance_pool_index(self.pools.len(), pool_index)?;
|
||||
|
||||
let (done_tx, mut done_rx) = tokio::sync::mpsc::channel::<Result<()>>(1);
|
||||
|
||||
// Save rebalance metadata periodically
|
||||
let store = self.clone();
|
||||
let save_rebalance_id = Arc::clone(&rebalance_id);
|
||||
let save_task = tokio::spawn(async move {
|
||||
let mut timer = tokio::time::interval_at(Instant::now() + Duration::from_secs(30), Duration::from_secs(10));
|
||||
let mut msg: String;
|
||||
@@ -238,6 +392,11 @@ impl ECStore {
|
||||
let previous_meta = store.rebalance_meta.read().await.clone();
|
||||
let terminal_state_present = {
|
||||
let mut rebalance_meta = store.rebalance_meta.write().await;
|
||||
super::control::ensure_rebalance_run_id(
|
||||
rebalance_meta.as_ref(),
|
||||
save_rebalance_id.as_ref(),
|
||||
"apply rebalance terminal event",
|
||||
)?;
|
||||
if let Some(meta) = rebalance_meta.as_mut() {
|
||||
let meta_stopped = meta.stopped_at.is_some();
|
||||
if let Some(pool_stat) = meta.pool_stats.get_mut(pool_index) {
|
||||
@@ -287,7 +446,14 @@ impl ECStore {
|
||||
};
|
||||
|
||||
if terminal_state_present {
|
||||
if let Err(err) = store.save_rebalance_stats_inner(pool_index, RebalSaveOpt::Stats).await {
|
||||
if let Err(err) = store
|
||||
.save_rebalance_stats_inner(
|
||||
pool_index,
|
||||
RebalSaveOpt::Stats,
|
||||
Some(save_rebalance_id.as_ref()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let mut rebalance_meta = store.rebalance_meta.write().await;
|
||||
*rebalance_meta = previous_meta;
|
||||
drop(movement_guard);
|
||||
@@ -305,7 +471,11 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
if !terminal_state_saved && let Err(err) = store.save_rebalance_stats(pool_index, RebalSaveOpt::Stats).await {
|
||||
if !terminal_state_saved
|
||||
&& let Err(err) = store
|
||||
.save_rebalance_stats_for_id(pool_index, RebalSaveOpt::Stats, save_rebalance_id.as_ref())
|
||||
.await
|
||||
{
|
||||
let wrapped = Error::other(format!("rebalance save_task stats save failed for pool {pool_index}: {err}"));
|
||||
error!("{} err: {:?}", msg, wrapped);
|
||||
if quit {
|
||||
@@ -371,7 +541,7 @@ impl ECStore {
|
||||
break;
|
||||
}
|
||||
|
||||
let next_bucket = match self.next_rebal_bucket(pool_index).await {
|
||||
let next_bucket = match self.next_rebal_bucket(pool_index, rebalance_id.as_ref()).await {
|
||||
Ok(bucket) => bucket,
|
||||
Err(err) => {
|
||||
error!(
|
||||
@@ -403,7 +573,8 @@ impl ECStore {
|
||||
);
|
||||
|
||||
let outcome = match resolve_rebalance_bucket_result(
|
||||
self.rebalance_bucket(rx.clone(), bucket.clone(), pool_index).await,
|
||||
self.rebalance_bucket(rx.clone(), bucket.clone(), pool_index, Arc::clone(&rebalance_id))
|
||||
.await,
|
||||
pool_index,
|
||||
&bucket,
|
||||
) {
|
||||
@@ -466,7 +637,7 @@ impl ECStore {
|
||||
"Deferred rebalance bucket after transient object failures"
|
||||
);
|
||||
if let Err(err) = self
|
||||
.defer_rebalance_bucket(pool_index, bucket.clone(), last_error.clone())
|
||||
.defer_rebalance_bucket(pool_index, bucket.clone(), last_error.clone(), rebalance_id.as_ref())
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
@@ -530,7 +701,7 @@ impl ECStore {
|
||||
"Completed rebalance bucket"
|
||||
);
|
||||
source_cleanup_deferred_attempts.remove(&bucket);
|
||||
if let Err(err) = self.bucket_rebalance_done(pool_index, bucket).await {
|
||||
if let Err(err) = self.bucket_rebalance_done(pool_index, bucket, rebalance_id.as_ref()).await {
|
||||
error!(
|
||||
event = EVENT_REBALANCE_BUCKET,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -591,8 +762,9 @@ impl ECStore {
|
||||
final_result
|
||||
}
|
||||
|
||||
pub(super) async fn check_if_rebalance_done(&self, pool_index: usize) -> bool {
|
||||
pub(super) async fn check_if_rebalance_done(&self, pool_index: usize, expected_id: &str) -> Result<bool> {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
super::control::ensure_rebalance_worker_active(rebalance_meta.as_ref(), expected_id, "check rebalance completion")?;
|
||||
|
||||
if let Some(meta) = rebalance_meta.as_mut()
|
||||
&& let Some(pool_stat) = meta.pool_stats.get_mut(pool_index)
|
||||
@@ -607,7 +779,7 @@ impl ECStore {
|
||||
state = "already_completed",
|
||||
"Rebalance pool is already completed"
|
||||
);
|
||||
return true;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Mark pool rebalance as done only after it reaches the PercentFreeGoal.
|
||||
@@ -635,11 +807,11 @@ impl ECStore {
|
||||
percent_free = pfi,
|
||||
"Rebalance pool reached completion goal"
|
||||
);
|
||||
return true;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,12 +820,26 @@ impl ECStore {
|
||||
pub async fn save_rebalance_stats(&self, pool_idx: usize, opt: RebalSaveOpt) -> Result<()> {
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.write().await;
|
||||
self.save_rebalance_stats_inner(pool_idx, opt).await
|
||||
self.save_rebalance_stats_inner(pool_idx, opt, None).await
|
||||
}
|
||||
|
||||
pub(super) async fn save_rebalance_stats_inner(&self, pool_idx: usize, opt: RebalSaveOpt) -> Result<()> {
|
||||
pub async fn save_rebalance_stats_for_id(&self, pool_idx: usize, opt: RebalSaveOpt, expected_id: &str) -> Result<()> {
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.write().await;
|
||||
self.save_rebalance_stats_inner(pool_idx, opt, Some(expected_id)).await
|
||||
}
|
||||
|
||||
pub(super) async fn save_rebalance_stats_inner(
|
||||
&self,
|
||||
pool_idx: usize,
|
||||
opt: RebalSaveOpt,
|
||||
expected_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let meta_to_save = {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
if let Some(expected_id) = expected_id {
|
||||
super::control::ensure_rebalance_run_id(rebalance_meta.as_ref(), expected_id, "save rebalance stats")?;
|
||||
}
|
||||
let Some(meta) = rebalance_meta.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -675,10 +861,14 @@ impl ECStore {
|
||||
"Rebalance metadata save requested"
|
||||
);
|
||||
let stage = format!("save_rebalance_stats for pool {pool_idx} opt {opt:?}");
|
||||
resolve_rebalance_meta_save_result(
|
||||
self.save_rebalance_meta_with_merge(pool, &meta_to_save, stage.as_str()).await,
|
||||
stage.as_str(),
|
||||
)?;
|
||||
let save_result = match expected_id {
|
||||
Some(expected_id) => {
|
||||
self.save_rebalance_meta_for_id_with_merge(pool, &meta_to_save, stage.as_str(), expected_id)
|
||||
.await
|
||||
}
|
||||
None => self.save_rebalance_meta_with_merge(pool, &meta_to_save, stage.as_str()).await,
|
||||
};
|
||||
resolve_rebalance_meta_save_result(save_result, stage.as_str())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -144,6 +144,8 @@ pub struct RebalanceMeta {
|
||||
#[serde(skip)]
|
||||
pub cancel: Option<CancellationToken>, // To be invoked on rebalance-stop
|
||||
#[serde(skip)]
|
||||
pub activation_gate: std::sync::Arc<tokio::sync::RwLock<()>>,
|
||||
#[serde(skip)]
|
||||
pub last_refreshed_at: Option<OffsetDateTime>,
|
||||
#[serde(rename = "stopTs")]
|
||||
pub stopped_at: Option<OffsetDateTime>, // Time when rebalance-stop was issued
|
||||
|
||||
@@ -100,17 +100,17 @@ pub(super) fn resolve_rebalance_meta_save_result(result: Result<()>, stage: &str
|
||||
result.map_err(|err| Error::other(format!("rebalance meta save failed during {stage}: {err}")))
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_meta_lock_error(err: rustfs_lock::LockError) -> Error {
|
||||
pub(super) fn rebalance_meta_lock_error(err: rustfs_lock::LockError, mode: &'static str) -> Error {
|
||||
match err {
|
||||
rustfs_lock::LockError::QuorumNotReached { required, achieved } => Error::NamespaceLockQuorumUnavailable {
|
||||
mode: "write",
|
||||
mode,
|
||||
bucket: crate::disk::RUSTFS_META_BUCKET.to_string(),
|
||||
object: REBAL_META_NAME.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
},
|
||||
other => Error::other(format!(
|
||||
"failed to acquire rebalance metadata write lock on {}/{}: {other}",
|
||||
"failed to acquire rebalance metadata {mode} lock on {}/{}: {other}",
|
||||
crate::disk::RUSTFS_META_BUCKET,
|
||||
REBAL_META_NAME
|
||||
)),
|
||||
|
||||
Reference in New Issue
Block a user