fix(heal): aggregate status across cluster nodes (#4990)

* fix(rpc): bind internode auth to exact targets

* fix(heal): initialize the runtime atomically

* fix(heal): aggregate status across cluster nodes

---------

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
This commit is contained in:
cxymds
2026-07-19 23:25:52 +08:00
committed by GitHub
parent 056ebcee38
commit 4290f390dd
10 changed files with 1494 additions and 230 deletions
+60 -8
View File
@@ -243,6 +243,19 @@ pub enum HealAdmissionResult {
Dropped(HealAdmissionDropReason),
}
/// Admission decision together with the canonical task identifier.
///
/// A merged request must return the identifier of the task that already owns
/// the work instead of exposing the discarded request identifier as a new
/// client token.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealAdmissionReceipt {
/// Admission decision for the submitted request.
pub result: HealAdmissionResult,
/// Canonical identifier of the accepted or merged task.
pub task_id: String,
}
impl HealAdmissionResult {
pub fn result_label(self) -> &'static str {
match self {
@@ -382,8 +395,25 @@ pub type HealChannelSender = mpsc::UnboundedSender<HealChannelCommand>;
/// Heal channel receiver
pub type HealChannelReceiver = mpsc::UnboundedReceiver<HealChannelCommand>;
/// Canonical-receipt start command kept separate from the legacy public enum.
#[derive(Debug)]
pub struct HealReceiptCommand {
/// Heal request to admit.
pub request: HealChannelRequest,
/// Completion channel for the admission receipt.
pub response_tx: oneshot::Sender<Result<HealAdmissionReceipt, String>>,
}
/// Canonical-receipt command receiver.
pub type HealReceiptReceiver = mpsc::UnboundedReceiver<HealReceiptCommand>;
struct HealChannelSenders {
command: HealChannelSender,
receipt: mpsc::UnboundedSender<HealReceiptCommand>,
}
/// Global heal channel sender
static GLOBAL_HEAL_CHANNEL_SENDER: OnceLock<HealChannelSender> = OnceLock::new();
static GLOBAL_HEAL_CHANNEL_SENDERS: OnceLock<HealChannelSenders> = OnceLock::new();
type HealResponseSender = broadcast::Sender<HealChannelResponse>;
@@ -392,17 +422,24 @@ static GLOBAL_HEAL_RESPONSE_SENDER: OnceLock<HealResponseSender> = OnceLock::new
/// Initialize global heal channel
pub fn init_heal_channel() -> Result<HealChannelReceiver, &'static str> {
let (tx, rx) = mpsc::unbounded_channel();
if GLOBAL_HEAL_CHANNEL_SENDER.set(tx).is_ok() {
Ok(rx)
} else {
Err("Heal channel sender already initialized")
}
let (receiver, receipt_receiver) = init_heal_channels()?;
drop(receipt_receiver);
Ok(receiver)
}
/// Initialize the legacy command and canonical-receipt channels atomically.
pub fn init_heal_channels() -> Result<(HealChannelReceiver, HealReceiptReceiver), &'static str> {
let (command, command_receiver) = mpsc::unbounded_channel();
let (receipt, receipt_receiver) = mpsc::unbounded_channel();
GLOBAL_HEAL_CHANNEL_SENDERS
.set(HealChannelSenders { command, receipt })
.map_err(|_| "Heal channel sender already initialized")?;
Ok((command_receiver, receipt_receiver))
}
/// Get global heal channel sender
pub fn get_heal_channel_sender() -> Option<&'static HealChannelSender> {
GLOBAL_HEAL_CHANNEL_SENDER.get()
GLOBAL_HEAL_CHANNEL_SENDERS.get().map(|senders| &senders.command)
}
/// Send heal command through global channel
@@ -436,6 +473,21 @@ pub fn subscribe_heal_responses() -> broadcast::Receiver<HealChannelResponse> {
heal_response_sender().subscribe()
}
/// Send heal start request and wait for structured admission feedback.
pub async fn send_heal_request_with_receipt(request: HealChannelRequest) -> Result<HealAdmissionReceipt, String> {
let (response_tx, response_rx) = oneshot::channel();
let senders = GLOBAL_HEAL_CHANNEL_SENDERS
.get()
.ok_or_else(|| "Heal channel not initialized".to_string())?;
senders
.receipt
.send(HealReceiptCommand { request, response_tx })
.map_err(|err| format!("Failed to send heal receipt command: {err}"))?;
response_rx
.await
.map_err(|e| format!("Failed to receive heal admission response: {e}"))?
}
/// Send heal start request and wait for structured admission feedback.
pub async fn send_heal_request_with_admission(request: HealChannelRequest) -> Result<HealAdmissionResult, String> {
let (response_tx, response_rx) = oneshot::channel();
@@ -29,11 +29,11 @@ use rustfs_madmin::{
};
use rustfs_protos::evict_failed_connection;
use rustfs_protos::proto_gen::node_service::{
CancelDecommissionRequest, ClearDecommissionRequest, DeleteBucketMetadataRequest, DeletePolicyRequest,
DeleteServiceAccountRequest, DeleteUserRequest, GetCpusRequest, GetLiveEventsRequest, GetMemInfoRequest, GetMetricsRequest,
GetNetInfoRequest, GetOsInfoRequest, GetPartitionsRequest, GetProcInfoRequest, GetSeLinuxInfoRequest, GetSysConfigRequest,
GetSysErrorsRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
BackgroundHealStatusRequest, CancelDecommissionRequest, ClearDecommissionRequest, DeleteBucketMetadataRequest,
DeletePolicyRequest, DeleteServiceAccountRequest, DeleteUserRequest, GetCpusRequest, GetLiveEventsRequest, GetMemInfoRequest,
GetMetricsRequest, GetNetInfoRequest, GetOsInfoRequest, GetPartitionsRequest, GetProcInfoRequest, GetSeLinuxInfoRequest,
GetSysConfigRequest, GetSysErrorsRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest,
LoadPolicyRequest, LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ScannerActivityRequest,
ScannerActivityResponse, ServerInfoRequest, SignalServiceRequest, StartDecommissionRequest, StartProfilingRequest,
StopRebalanceRequest, node_service_client::NodeServiceClient,
@@ -60,6 +60,7 @@ pub const PEER_RESTSUB_SYS: &str = "sub-sys";
pub const PEER_RESTDRY_RUN: &str = "dry-run";
pub const SERVICE_SIGNAL_REFRESH_CONFIG: u64 = 1;
pub const SERVICE_SIGNAL_RELOAD_DYNAMIC: u64 = 2;
const BACKGROUND_HEAL_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024;
const PEER_REST_RECOVERY_MAX_ATTEMPTS: u32 = 60;
const PEER_REST_RECOVERY_MAX_BACKOFF: Duration = Duration::from_secs(30);
const SCANNER_ACTIVITY_MAX_MESSAGE_SIZE: usize = 1024;
@@ -664,6 +665,38 @@ impl PeerRestClient {
Err(Error::NotImplemented)
}
pub async fn background_heal_status(&self) -> Result<Option<Vec<u8>>> {
self.finalize_result(
async {
let mut client = self
.get_client()
.await?
.max_decoding_message_size(BACKGROUND_HEAL_STATUS_MAX_MESSAGE_SIZE);
let response = match client
.background_heal_status(Request::new(BackgroundHealStatusRequest::default()))
.await
{
Ok(response) => response.into_inner(),
Err(status) if status.code() == tonic::Code::Unimplemented => {
// RUSTFS_COMPAT_TODO(heal-status-rpc-v1): accept old peers without node heal snapshots. Remove after the minimum supported RustFS peer version implements BackgroundHealStatus.
return Ok(None);
}
Err(status) => return Err(status.into()),
};
if !response.success {
return Err(Error::other(
response
.error_info
.unwrap_or_else(|| "peer background heal status failed without an error".to_string()),
));
}
Ok(Some(response.bg_heal_state.to_vec()))
}
.await,
)
.await
}
pub async fn load_bucket_metadata(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
self.finalize_result(
async {
+242 -23
View File
@@ -20,8 +20,8 @@ use crate::heal::{
};
use crate::{Error, Result};
use rustfs_common::heal_channel::{
HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealChannelReceiver, HealChannelRequest, HealChannelResponse,
HealRequestSource, HealScanMode, publish_heal_response,
HealAdmissionReceipt, HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealChannelReceiver, HealChannelRequest,
HealChannelResponse, HealReceiptCommand, HealReceiptReceiver, HealRequestSource, HealScanMode, publish_heal_response,
};
use rustfs_madmin::heal_commands::HealResultItem;
use serde::Serialize;
@@ -79,8 +79,19 @@ impl HealChannelProcessor {
}
}
/// Start processing heal channel requests
pub async fn start(&mut self, mut receiver: HealChannelReceiver) -> Result<()> {
/// Start processing legacy heal channel requests.
pub async fn start(&mut self, receiver: HealChannelReceiver) -> Result<()> {
let (receipt_sender, receipt_receiver) = mpsc::unbounded_channel();
drop(receipt_sender);
self.start_with_receipts(receiver, receipt_receiver).await
}
/// Start processing legacy and canonical-receipt heal channel requests.
pub async fn start_with_receipts(
&mut self,
mut receiver: HealChannelReceiver,
mut receipt_receiver: HealReceiptReceiver,
) -> Result<()> {
info!(
target: "rustfs::heal::channel",
event = EVENT_HEAL_CHANNEL_STATE,
@@ -90,6 +101,7 @@ impl HealChannelProcessor {
"Heal channel started"
);
let mut receipt_channel_open = true;
loop {
tokio::select! {
command = receiver.recv() => {
@@ -120,6 +132,23 @@ impl HealChannelProcessor {
}
}
}
command = receipt_receiver.recv(), if receipt_channel_open => {
let Some(HealReceiptCommand { request, response_tx }) = command else {
receipt_channel_open = false;
continue;
};
if let Err(e) = self.process_start_request(request, false, true, response_tx).await {
error!(
target: "rustfs::heal::channel",
event = EVENT_HEAL_CHANNEL_REQUEST,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_CHANNEL,
state = "receipt_process_failed",
error = %e,
"Heal receipt request processing failed"
);
}
}
response = self.response_receiver.recv() => {
if let Some(response) = response {
// Handle response if needed
@@ -152,7 +181,7 @@ impl HealChannelProcessor {
/// Process heal command
async fn process_command(&self, command: HealChannelCommand) -> Result<()> {
match command {
HealChannelCommand::Start { request, response_tx } => self.process_start_request(request, response_tx).await,
HealChannelCommand::Start { request, response_tx } => self.process_legacy_start_request(request, response_tx).await,
HealChannelCommand::Query {
heal_path,
client_token,
@@ -166,11 +195,28 @@ impl HealChannelProcessor {
}
}
async fn process_legacy_start_request(
&self,
request: HealChannelRequest,
response_tx: oneshot::Sender<std::result::Result<HealAdmissionResult, String>>,
) -> Result<()> {
let (receipt_tx, receipt_rx) = oneshot::channel();
self.process_start_request(request, true, false, receipt_tx).await?;
let result = receipt_rx
.await
.map_err(|err| Error::other(format!("heal receipt channel closed: {err}")))?
.map(|receipt| receipt.result);
let _ = response_tx.send(result);
Ok(())
}
/// Process start request
async fn process_start_request(
&self,
request: HealChannelRequest,
response_tx: oneshot::Sender<std::result::Result<HealAdmissionResult, String>>,
preserve_alias: bool,
publish_canonical_id: bool,
response_tx: oneshot::Sender<std::result::Result<HealAdmissionReceipt, String>>,
) -> Result<()> {
debug!(
target: "rustfs::heal::channel",
@@ -201,8 +247,13 @@ impl HealChannelProcessor {
};
// Submit to heal manager
match self.heal_manager.submit_heal_request(heal_request).await {
Ok(admission) => {
match self
.heal_manager
.submit_heal_request_with_receipt_and_alias(heal_request, preserve_alias)
.await
{
Ok(receipt) => {
let admission = receipt.result;
debug!(
target: "rustfs::heal::channel",
event = EVENT_HEAL_CHANNEL_REQUEST,
@@ -214,9 +265,13 @@ impl HealChannelProcessor {
"Heal admission decided"
);
let _ = response_tx.send(Ok(admission));
self.publish_response(admission_response(request.id, admission));
let response_id = if publish_canonical_id {
receipt.task_id.clone()
} else {
request.id.clone()
};
self.publish_response(admission_response(response_id, receipt.result));
let _ = response_tx.send(Ok(receipt));
}
Err(e) => {
let error_text = e.to_string();
@@ -537,6 +592,7 @@ mod tests {
HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealChannelRequest, HealRequestSource, HealScanMode,
};
use std::sync::Arc;
use std::time::Duration;
// Mock storage for testing
struct MockStorage;
@@ -1097,27 +1153,190 @@ mod tests {
let (tx, rx) = oneshot::channel();
processor
.process_start_request(request.clone(), tx)
.process_start_request(request.clone(), false, true, tx)
.await
.expect("first admission should succeed");
assert_eq!(
rx.await
.expect("oneshot should resolve")
.expect("admission should be returned"),
HealAdmissionResult::Accepted
);
let first = rx
.await
.expect("oneshot should resolve")
.expect("admission should be returned");
assert_eq!(first.result, HealAdmissionResult::Accepted);
assert_eq!(first.task_id, "admission-id");
let mut duplicate = request;
duplicate.id = "duplicate-id".to_string();
let (tx, rx) = oneshot::channel();
processor
.process_start_request(duplicate, false, true, tx)
.await
.expect("duplicate admission should succeed");
let merged = rx
.await
.expect("oneshot should resolve")
.expect("admission should be returned");
assert_eq!(merged.result, HealAdmissionResult::Merged);
assert_eq!(merged.task_id, "admission-id");
}
#[tokio::test]
async fn test_legacy_start_preserves_duplicate_alias_and_submitted_response_id() {
let manager = create_test_heal_manager();
let processor = HealChannelProcessor::new(manager.clone());
let request = HealChannelRequest {
id: "legacy-original-id".to_string(),
bucket: "bucket".to_string(),
object_prefix: Some("object".to_string()),
priority: HealChannelPriority::Low,
source: HealRequestSource::Admin,
..Default::default()
};
let mut responses = rustfs_common::heal_channel::subscribe_heal_responses();
let (tx, rx) = oneshot::channel();
processor
.process_start_request(request, tx)
.process_command(HealChannelCommand::Start {
request: request.clone(),
response_tx: tx,
})
.await
.expect("duplicate admission should succeed");
.expect("legacy start should be processed");
assert_eq!(
rx.await
.expect("oneshot should resolve")
.expect("admission should be returned"),
.expect("legacy response should arrive")
.expect("legacy start should succeed"),
HealAdmissionResult::Accepted
);
let mut duplicate = request;
duplicate.id = "legacy-duplicate-id".to_string();
let (tx, rx) = oneshot::channel();
processor
.process_command(HealChannelCommand::Start {
request: duplicate,
response_tx: tx,
})
.await
.expect("legacy duplicate should be processed");
assert_eq!(
rx.await
.expect("legacy duplicate response should arrive")
.expect("legacy duplicate should succeed"),
HealAdmissionResult::Merged
);
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let response = responses.recv().await.expect("legacy broadcast should stay open");
if response.request_id == "legacy-duplicate-id" {
break;
}
}
})
.await
.expect("legacy broadcast must retain the submitted request id");
assert_eq!(
manager
.get_task_status_for_path("bucket/object", "legacy-duplicate-id")
.await
.expect("legacy alias should resolve"),
HealTaskStatus::Pending
);
manager
.cancel_task("legacy-duplicate-id")
.await
.expect("legacy alias should cancel the canonical task");
}
#[tokio::test]
async fn test_public_legacy_start_processes_commands_with_receipt_channel_closed() {
let manager = create_test_heal_manager();
let mut processor = HealChannelProcessor::new(manager);
let (command_tx, command_rx) = mpsc::unbounded_channel();
let processor_task = tokio::spawn(async move { processor.start(command_rx).await });
let (response_tx, response_rx) = oneshot::channel();
command_tx
.send(HealChannelCommand::Start {
request: HealChannelRequest {
id: "legacy-start-loop-id".to_string(),
bucket: "bucket".to_string(),
object_prefix: Some("object".to_string()),
source: HealRequestSource::Admin,
..Default::default()
},
response_tx,
})
.expect("legacy command should send");
assert_eq!(
tokio::time::timeout(Duration::from_secs(1), response_rx)
.await
.expect("legacy processor should not starve")
.expect("legacy response should arrive")
.expect("legacy start should succeed"),
HealAdmissionResult::Accepted
);
drop(command_tx);
tokio::time::timeout(Duration::from_secs(1), processor_task)
.await
.expect("legacy processor should stop when command channel closes")
.expect("legacy processor task should join")
.expect("legacy processor should stop cleanly");
}
#[tokio::test]
async fn test_receipt_channel_returns_canonical_id_end_to_end() {
let manager = create_test_heal_manager();
let mut processor = HealChannelProcessor::new(manager);
let (command_tx, command_rx) = mpsc::unbounded_channel();
let (receipt_tx, receipt_rx) = mpsc::unbounded_channel();
let processor_task = tokio::spawn(async move { processor.start_with_receipts(command_rx, receipt_rx).await });
let request = HealChannelRequest {
id: "receipt-original-id".to_string(),
bucket: "bucket".to_string(),
object_prefix: Some("object".to_string()),
source: HealRequestSource::Admin,
..Default::default()
};
let (response_tx, response_rx) = oneshot::channel();
receipt_tx
.send(HealReceiptCommand {
request: request.clone(),
response_tx,
})
.expect("receipt command should send");
let accepted = tokio::time::timeout(Duration::from_secs(1), response_rx)
.await
.expect("receipt processor should not starve")
.expect("receipt response should arrive")
.expect("receipt start should succeed");
assert_eq!(accepted.result, HealAdmissionResult::Accepted);
assert_eq!(accepted.task_id, "receipt-original-id");
let mut duplicate = request;
duplicate.id = "receipt-duplicate-id".to_string();
let (response_tx, response_rx) = oneshot::channel();
receipt_tx
.send(HealReceiptCommand {
request: duplicate,
response_tx,
})
.expect("duplicate receipt command should send");
let merged = tokio::time::timeout(Duration::from_secs(1), response_rx)
.await
.expect("duplicate receipt should not starve")
.expect("duplicate receipt response should arrive")
.expect("duplicate receipt should succeed");
assert_eq!(merged.result, HealAdmissionResult::Merged);
assert_eq!(merged.task_id, "receipt-original-id");
drop(receipt_tx);
drop(command_tx);
tokio::time::timeout(Duration::from_secs(1), processor_task)
.await
.expect("receipt processor should stop when channels close")
.expect("receipt processor task should join")
.expect("receipt processor should stop cleanly");
}
#[tokio::test]
@@ -1147,7 +1366,7 @@ mod tests {
let (tx, rx) = oneshot::channel();
processor
.process_start_request(request, tx)
.process_start_request(request, false, true, tx)
.await
.expect("processor should surface invalid request through response channel");
assert!(rx.await.expect("oneshot should resolve").is_err());
+397 -146
View File
@@ -19,9 +19,11 @@ use crate::heal::{
};
use crate::{Error, Result};
use metrics::{counter, gauge};
use rustfs_common::heal_channel::{HealAdmissionDropReason, HealAdmissionResult, HealRequestSource};
use rustfs_common::heal_channel::{HealAdmissionDropReason, HealAdmissionReceipt, HealAdmissionResult, HealRequestSource};
use rustfs_concurrency::{AdmissionState, WorkloadAdmissionSnapshotProvider, WorkloadClass};
use rustfs_madmin::heal_commands::HealResultItem;
#[cfg(test)]
use std::sync::LazyLock;
use std::{
collections::{BinaryHeap, HashMap, HashSet},
sync::Arc,
@@ -52,6 +54,54 @@ const EVENT_HEAL_UNCLEAN_SHUTDOWN: &str = "heal_unclean_shutdown";
const MAX_RECOVERABLE_HEAL_RETRIES: u32 = 3;
const MAX_RECOVERABLE_HEAL_RETRY_DELAY: Duration = Duration::from_secs(30);
#[cfg(test)]
struct RetryOwnershipTestHook {
task_id: String,
active_to_retrying_reached: Notify,
active_to_retrying_release: Notify,
retrying_to_queue_reached: Notify,
retrying_to_queue_release: Notify,
}
#[cfg(test)]
static RETRY_OWNERSHIP_TEST_HOOK: LazyLock<Mutex<Option<Arc<RetryOwnershipTestHook>>>> = LazyLock::new(|| Mutex::new(None));
#[cfg(test)]
struct DuplicateAdmissionTestHook {
request_id: String,
active_lock_reached: Notify,
active_lock_release: Notify,
}
#[cfg(test)]
static DUPLICATE_ADMISSION_TEST_HOOK: LazyLock<Mutex<Option<Arc<DuplicateAdmissionTestHook>>>> =
LazyLock::new(|| Mutex::new(None));
#[cfg(test)]
async fn pause_retry_ownership_transition(task_id: &str, to_queue: bool) {
let hook = RETRY_OWNERSHIP_TEST_HOOK.lock().await.clone();
let Some(hook) = hook.filter(|hook| hook.task_id == task_id) else {
return;
};
if to_queue {
hook.retrying_to_queue_reached.notify_one();
hook.retrying_to_queue_release.notified().await;
} else {
hook.active_to_retrying_reached.notify_one();
hook.active_to_retrying_release.notified().await;
}
}
#[cfg(test)]
async fn pause_duplicate_admission_after_active_lock(request_id: &str) {
let hook = DUPLICATE_ADMISSION_TEST_HOOK.lock().await.clone();
let Some(hook) = hook.filter(|hook| hook.request_id == request_id) else {
return;
};
hook.active_lock_reached.notify_one();
hook.active_lock_release.notified().await;
}
type WorkloadSnapshotProviderRef = Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>;
/// Priority queue wrapper for heal requests
@@ -151,8 +201,8 @@ pub struct HealTaskReport {
pub progress: Option<HealProgress>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HealPriorityCounts {
pub low: u64,
pub normal: u64,
@@ -171,8 +221,8 @@ impl HealPriorityCounts {
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HealSourceCounts {
pub scanner: u64,
pub admin: u64,
@@ -193,15 +243,18 @@ impl HealSourceCounts {
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HealOperationsSnapshot {
pub queue_length: u64,
pub active_tasks: u64,
pub retrying_tasks: u64,
pub queued_by_priority: HealPriorityCounts,
pub active_by_priority: HealPriorityCounts,
pub retrying_by_priority: HealPriorityCounts,
pub queued_by_source: HealSourceCounts,
pub active_by_source: HealSourceCounts,
pub retrying_by_source: HealSourceCounts,
}
fn usize_to_u64_saturated(value: usize) -> u64 {
@@ -1391,110 +1444,47 @@ impl HealManager {
}
/// Submit heal request
pub async fn submit_heal_request(&self, request: HealRequest) -> Result<HealAdmissionResult> {
pub async fn submit_heal_request_with_receipt(&self, request: HealRequest) -> Result<HealAdmissionReceipt> {
self.submit_heal_request_with_receipt_and_alias(request, false).await
}
pub(crate) async fn submit_heal_request_with_receipt_and_alias(
&self,
request: HealRequest,
preserve_alias: bool,
) -> Result<HealAdmissionReceipt> {
let config = self.config.read().await;
let dedup_key = PriorityHealQueue::make_dedup_key(&request);
// Keep this lock order aligned with the scheduler so a request cannot
// slip between queued and active states while duplicate admission runs.
let active_duplicate = {
let active_heals = self.active_heals.lock().await;
(!request.force_start)
.then(|| active_heal_for_dedup_key(&active_heals, &dedup_key))
.flatten()
};
if let Some((merged_task_id, _)) = active_duplicate {
let admission = Self::duplicate_admission_for_request(&request, &config);
match admission {
HealAdmissionResult::Merged => {
self.insert_task_alias(&request.id, &merged_task_id).await;
info!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_QUEUE_ADMISSION,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
request_id = %request.id,
merged_task_id = %merged_task_id,
priority = ?request.priority,
result = "merged_active_duplicate",
"Heal queue admission decided"
);
}
HealAdmissionResult::Dropped(reason) => {
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_QUEUE_ADMISSION,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
request_id = %request.id,
priority = ?request.priority,
reason = reason.as_str(),
result = "dropped_active_duplicate",
"Heal queue admission decided"
);
}
HealAdmissionResult::Accepted | HealAdmissionResult::Full => {}
}
return Ok(admission);
}
let retrying_duplicate = {
let retrying_heals = self.retrying_heals.lock().await;
(!request.force_start)
.then(|| retrying_heal_for_dedup_key(&retrying_heals, &dedup_key))
.flatten()
};
if let Some((merged_task_id, _)) = retrying_duplicate {
let admission = Self::duplicate_admission_for_request(&request, &config);
match admission {
HealAdmissionResult::Merged => {
self.insert_task_alias(&request.id, &merged_task_id).await;
info!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_QUEUE_ADMISSION,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
request_id = %request.id,
merged_task_id = %merged_task_id,
priority = ?request.priority,
result = "merged_retrying_duplicate",
"Heal queue admission decided"
);
}
HealAdmissionResult::Dropped(reason) => {
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_QUEUE_ADMISSION,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
request_id = %request.id,
priority = ?request.priority,
reason = reason.as_str(),
result = "dropped_retrying_duplicate",
"Heal queue admission decided"
);
}
HealAdmissionResult::Accepted | HealAdmissionResult::Full => {}
}
return Ok(admission);
}
// Match the scheduler's active -> queue order and keep retry ownership
// in the same atomic view. Otherwise queue -> active and
// active -> retrying transitions can slip between duplicate checks.
let active_heals = self.active_heals.lock().await;
#[cfg(test)]
pause_duplicate_admission_after_active_lock(&request.id).await;
let mut queue = self.heal_queue.lock().await;
let queued_duplicate = (!request.force_start)
.then(|| queue.request_for_dedup_key(&dedup_key).map(|queued| queued.id.clone()))
.flatten();
if let Some(merged_task_id) = queued_duplicate {
let retrying_heals = self.retrying_heals.lock().await;
let duplicate = (!request.force_start).then(|| {
active_heal_for_dedup_key(&active_heals, &dedup_key)
.map(|(task_id, _)| (task_id, "active"))
.or_else(|| {
queue
.request_for_dedup_key(&dedup_key)
.map(|queued| (queued.id.clone(), "queued"))
})
.or_else(|| retrying_heal_for_dedup_key(&retrying_heals, &dedup_key).map(|(task_id, _)| (task_id, "retrying")))
});
if let Some((merged_task_id, duplicate_state)) = duplicate.flatten() {
let admission = Self::duplicate_admission_for_request(&request, &config);
drop(retrying_heals);
drop(queue);
drop(active_heals);
match admission {
HealAdmissionResult::Merged => {
self.insert_task_alias(&request.id, &merged_task_id).await;
if preserve_alias {
self.insert_task_alias(&request.id, &merged_task_id).await;
}
debug!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_QUEUE_ADMISSION,
@@ -1503,8 +1493,9 @@ impl HealManager {
request_id = %request.id,
merged_task_id = %merged_task_id,
priority = ?request.priority,
duplicate_state,
result = "merged_duplicate",
"Heal queue request merged"
"Heal queue admission decided"
);
}
HealAdmissionResult::Dropped(reason) => {
@@ -1516,25 +1507,45 @@ impl HealManager {
request_id = %request.id,
priority = ?request.priority,
reason = reason.as_str(),
duplicate_state,
result = "dropped_duplicate",
"Heal queue request dropped"
"Heal queue admission decided"
);
}
HealAdmissionResult::Accepted | HealAdmissionResult::Full => {}
}
return Ok(admission);
return Ok(HealAdmissionReceipt {
result: admission,
task_id: merged_task_id,
});
}
let mut task_id = request.id.clone();
let admission = Self::admit_request_to_queue(&mut queue, request, &config, "submit");
if admission == HealAdmissionResult::Merged
&& let Some(queued) = queue.request_for_dedup_key(&dedup_key)
{
task_id.clone_from(&queued.id);
}
let should_notify = matches!(admission, HealAdmissionResult::Accepted) && config.event_driven_scheduler_enable;
drop(retrying_heals);
drop(queue);
drop(active_heals);
if should_notify {
self.notify.notify_one();
}
Ok(admission)
Ok(HealAdmissionReceipt {
result: admission,
task_id,
})
}
/// Submit heal request.
pub async fn submit_heal_request(&self, request: HealRequest) -> Result<HealAdmissionResult> {
Ok(self.submit_heal_request_with_receipt_and_alias(request, true).await?.result)
}
/// Get task status
@@ -2012,43 +2023,51 @@ impl HealManager {
}
pub async fn operations_snapshot(&self) -> HealOperationsSnapshot {
let (queue_length, queued_by_priority, queued_by_source) = {
let queue = self.heal_queue.lock().await;
let (priority, source) = queue.operation_counts();
publish_heal_queue_length(&queue);
(usize_to_u64_saturated(queue.len()), priority, source)
};
let (active_tasks, active_by_priority, active_by_source) = {
let active_heals = self.active_heals.lock().await;
let mut priority = HealPriorityCounts::default();
let mut source = HealSourceCounts::default();
for task in active_heals.values() {
priority.increment(task.priority);
source.increment(task.source);
}
publish_active_heal_count(&active_heals);
(usize_to_u64_saturated(active_heals.len()), priority, source)
};
// Match the scheduler's active -> queue order. Retry transitions also
// acquire active before retrying, so the three sets form one snapshot.
let active_heals = self.active_heals.lock().await;
let queue = self.heal_queue.lock().await;
let retrying_heals = self.retrying_heals.lock().await;
let (queued_by_priority, queued_by_source) = queue.operation_counts();
let mut active_by_priority = HealPriorityCounts::default();
let mut active_by_source = HealSourceCounts::default();
for task in active_heals.values() {
active_by_priority.increment(task.priority);
active_by_source.increment(task.source);
}
let mut retrying_by_priority = HealPriorityCounts::default();
let mut retrying_by_source = HealSourceCounts::default();
for retrying in retrying_heals.values() {
retrying_by_priority.increment(retrying.request.priority);
retrying_by_source.increment(retrying.request.source);
}
publish_active_heal_count(&active_heals);
publish_heal_queue_length(&queue);
HealOperationsSnapshot {
queue_length,
active_tasks,
queue_length: usize_to_u64_saturated(queue.len()),
active_tasks: usize_to_u64_saturated(active_heals.len()),
retrying_tasks: usize_to_u64_saturated(retrying_heals.len()),
queued_by_priority,
active_by_priority,
retrying_by_priority,
queued_by_source,
active_by_source,
retrying_by_source,
}
}
pub async fn active_progress_snapshot(&self) -> Option<HealProgress> {
let active_heals = self.active_heals.lock().await;
if active_heals.is_empty() {
let active_tasks = {
let active_heals = self.active_heals.lock().await;
active_heals.values().cloned().collect::<Vec<_>>()
};
if active_tasks.is_empty() {
return None;
}
let mut snapshot = HealProgress::default();
for task in active_heals.values() {
for task in active_tasks {
let progress = task.get_progress().await;
snapshot.objects_scanned = snapshot.objects_scanned.saturating_add(progress.objects_scanned);
snapshot.objects_healed = snapshot.objects_healed.saturating_add(progress.objects_healed);
@@ -2556,10 +2575,40 @@ impl HealManager {
retry_attempt: request.retry_attempts,
});
let retry_request_for_queue = retry_request;
let retry_cancel_token = retry_request_for_queue.as_ref().map(|_| CancellationToken::new());
let mut active_heals_guard = active_heals_clone.lock().await;
if let Some(completed_task) = active_heals_guard.remove(&task_id) {
// Keep retry ownership continuous: status snapshots acquire
// these locks in the same active -> retrying order.
let mut retrying_heals_guard = if let (Some((request, _, error)), Some(cancel_token)) =
(retry_request_for_queue.as_ref(), retry_cancel_token.as_ref())
{
let mut retrying = retrying_heals_clone.lock().await;
if active_heals_guard.contains_key(&task_id) {
retrying.insert(
request.id.clone(),
RetryingHeal {
request: request.clone(),
error: error.clone(),
cancel_token: cancel_token.clone(),
},
);
#[cfg(test)]
pause_retry_ownership_transition(&task_id, false).await;
}
Some(retrying)
} else {
None
};
let completed_task = active_heals_guard.remove(&task_id);
if let Some(completed_task) = completed_task.as_ref() {
publish_active_heal_count(&active_heals_guard);
update_task_running_metric_for_task(&active_heals_guard, completed_task.as_ref());
}
let active_count = active_heals_guard.len();
drop(retrying_heals_guard.take());
drop(active_heals_guard);
if let Some(completed_task) = completed_task {
let completed_status = if let Some(status) = retry_request_for_status {
status
} else {
@@ -2585,10 +2634,12 @@ impl HealManager {
stats.update_task_completion(false);
}
}
stats.update_running_tasks(active_heals_guard.len() as u64);
stats.update_running_tasks(usize_to_u64_saturated(active_count));
}
if let Some((retry_request, retry_delay, retry_error)) = retry_request_for_queue {
if let (Some((retry_request, retry_delay, retry_error)), Some(retry_cancel_token)) =
(retry_request_for_queue, retry_cancel_token)
{
let retry_request_id = retry_request.id.clone();
let retry_attempt = retry_request.retry_attempts;
let retry_key = PriorityHealQueue::make_dedup_key(&retry_request);
@@ -2598,17 +2649,8 @@ impl HealManager {
let retrying_heals_for_spawn = retrying_heals_clone.clone();
let retry_completed_heals = completed_heals_clone.clone();
let retry_notify = notify_clone.clone();
let retry_cancel_token = CancellationToken::new();
let retry_manager_cancel_token = manager_cancel_token.clone();
let retry_config = config_for_spawn.clone();
retrying_heals_clone.lock().await.insert(
retry_request_id.clone(),
RetryingHeal {
request: retry_request.clone(),
error: retry_error.clone(),
cancel_token: retry_cancel_token.clone(),
},
);
tokio::spawn(async move {
loop {
tokio::select! {
@@ -2667,8 +2709,12 @@ impl HealManager {
&& retry_config.event_driven_scheduler_enable;
match admission {
HealAdmissionResult::Accepted => {
drop(queue);
// Transfer ownership while holding queue -> retrying,
// matching operations_snapshot's lock order.
#[cfg(test)]
pause_retry_ownership_transition(&retry_request_id, true).await;
retrying_heals_for_spawn.lock().await.remove(&retry_request_id);
drop(queue);
retry_completed_heals.lock().await.remove(&retry_request_id);
info!(
target: "rustfs::heal::manager",
@@ -2689,8 +2735,8 @@ impl HealManager {
return;
}
HealAdmissionResult::Merged => {
drop(queue);
retrying_heals_for_spawn.lock().await.remove(&retry_request_id);
drop(queue);
info!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_QUEUE_ADMISSION,
@@ -2963,8 +3009,8 @@ mod tests {
Ok(Vec::new())
}
async fn object_exists(&self, _bucket: &str, _object: &str) -> Result<bool> {
Ok(false)
async fn object_exists(&self, bucket: &str, _object: &str) -> Result<bool> {
Ok(bucket == "retry-transition")
}
async fn get_object_size(&self, _bucket: &str, _object: &str) -> Result<Option<u64>> {
@@ -2977,11 +3023,20 @@ mod tests {
async fn heal_object(
&self,
_bucket: &str,
bucket: &str,
_object: &str,
_version_id: Option<&str>,
_opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
if bucket == "retry-transition" {
return Ok((
HealResultItem::default(),
Some(Error::Storage(EcstoreError::InsufficientReadQuorum(
bucket.to_string(),
"object".to_string(),
))),
));
}
Ok((HealResultItem::default(), None))
}
@@ -3478,6 +3533,103 @@ mod tests {
);
}
#[tokio::test]
async fn test_admin_duplicate_receipt_returns_canonical_task_without_alias() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = HealManager::new(storage, None);
let mut original = HealRequest::object("bucket".to_string(), "object".to_string(), None);
original.source = HealRequestSource::Admin;
let original_id = original.id.clone();
let mut duplicate = HealRequest::object("bucket".to_string(), "object".to_string(), None);
duplicate.source = HealRequestSource::Admin;
let duplicate_id = duplicate.id.clone();
let accepted = manager
.submit_heal_request_with_receipt(original)
.await
.expect("first request should be accepted");
let merged = manager
.submit_heal_request_with_receipt(duplicate)
.await
.expect("duplicate request should merge");
assert_eq!(accepted.result, HealAdmissionResult::Accepted);
assert_eq!(accepted.task_id, original_id);
assert_eq!(merged.result, HealAdmissionResult::Merged);
assert_eq!(merged.task_id, original_id);
assert_eq!(manager.canonical_task_id(&duplicate_id).await, duplicate_id);
}
#[tokio::test]
async fn test_duplicate_admission_is_atomic_with_queue_to_active_transition() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = Arc::new(HealManager::new(storage.clone(), None));
let mut original = HealRequest::object("bucket".to_string(), "object".to_string(), None);
original.source = HealRequestSource::Admin;
let original_id = original.id.clone();
assert_eq!(
manager
.submit_heal_request(original)
.await
.expect("original request should be accepted"),
HealAdmissionResult::Accepted
);
let mut duplicate = HealRequest::object("bucket".to_string(), "object".to_string(), None);
duplicate.source = HealRequestSource::Admin;
let hook = Arc::new(DuplicateAdmissionTestHook {
request_id: duplicate.id.clone(),
active_lock_reached: Notify::new(),
active_lock_release: Notify::new(),
});
*DUPLICATE_ADMISSION_TEST_HOOK.lock().await = Some(hook.clone());
let duplicate_manager = manager.clone();
let duplicate_task = tokio::spawn(async move { duplicate_manager.submit_heal_request_with_receipt(duplicate).await });
tokio::time::timeout(Duration::from_secs(1), hook.active_lock_reached.notified())
.await
.expect("duplicate admission should reach the active lock hook");
let transition_manager = manager.clone();
let transition_storage = storage.clone();
let (attempting_active_tx, attempting_active_rx) = tokio::sync::oneshot::channel();
let (active_acquired_tx, mut active_acquired_rx) = tokio::sync::oneshot::channel();
let transition = tokio::spawn(async move {
let _ = attempting_active_tx.send(());
let mut active = transition_manager.active_heals.lock().await;
let _ = active_acquired_tx.send(());
let mut queue = transition_manager.heal_queue.lock().await;
let request = queue.pop_next().expect("original request should remain queued");
let task = Arc::new(HealTask::from_request(request, transition_storage));
active.insert(task.id.clone(), task);
});
tokio::time::timeout(Duration::from_secs(1), attempting_active_rx)
.await
.expect("transition should attempt the active lock")
.expect("transition attempt signal should be delivered");
assert!(matches!(
active_acquired_rx.try_recv(),
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
));
hook.active_lock_release.notify_one();
let receipt = tokio::time::timeout(Duration::from_secs(1), duplicate_task)
.await
.expect("duplicate admission should not hang")
.expect("duplicate task should join")
.expect("duplicate admission should succeed");
tokio::time::timeout(Duration::from_secs(1), transition)
.await
.expect("queue to active transition should not hang")
.expect("queue to active transition should join");
*DUPLICATE_ADMISSION_TEST_HOOK.lock().await = None;
assert_eq!(receipt.result, HealAdmissionResult::Merged);
assert_eq!(receipt.task_id, original_id);
assert_eq!(manager.get_queue_length().await, 0);
assert_eq!(manager.get_active_task_count().await, 1);
}
#[tokio::test]
async fn test_submit_heal_request_returns_merged_for_active_duplicate() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
@@ -3988,6 +4140,105 @@ mod tests {
assert_eq!(snapshot.active_by_source.scanner, 0);
}
#[tokio::test]
async fn test_operations_snapshot_counts_retry_backoff_as_owned_work() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = HealManager::new(storage, None);
let mut request = HealRequest::bucket("bucket-retry".to_string());
request.priority = HealPriority::Urgent;
request.source = HealRequestSource::Admin;
let task_id = request.id.clone();
manager.retrying_heals.lock().await.insert(
task_id,
RetryingHeal {
request,
error: "transient".to_string(),
cancel_token: CancellationToken::new(),
},
);
let snapshot = manager.operations_snapshot().await;
assert_eq!(snapshot.queue_length, 0);
assert_eq!(snapshot.active_tasks, 0);
assert_eq!(snapshot.retrying_tasks, 1);
assert_eq!(snapshot.retrying_by_priority.urgent, 1);
assert_eq!(snapshot.retrying_by_source.admin, 1);
}
#[tokio::test]
async fn test_scheduler_retry_transitions_keep_continuous_single_ownership() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = Arc::new(HealManager::new(storage, None));
{
let mut config = manager.config.write().await;
config.enable_auto_heal = false;
config.heal_interval = Duration::from_millis(10);
config.event_driven_scheduler_enable = true;
}
manager.start().await.expect("manager should start");
let request = HealRequest::object("retry-transition".to_string(), "object".to_string(), None);
let task_id = request.id.clone();
let hook = Arc::new(RetryOwnershipTestHook {
task_id: task_id.clone(),
active_to_retrying_reached: Notify::new(),
active_to_retrying_release: Notify::new(),
retrying_to_queue_reached: Notify::new(),
retrying_to_queue_release: Notify::new(),
});
*RETRY_OWNERSHIP_TEST_HOOK.lock().await = Some(hook.clone());
manager
.submit_heal_request(request)
.await
.expect("retry test request should be accepted");
tokio::time::timeout(Duration::from_secs(2), hook.active_to_retrying_reached.notified())
.await
.expect("active to retrying transition should be reached");
let snapshot_manager = manager.clone();
let mut snapshot = tokio::spawn(async move { snapshot_manager.operations_snapshot().await });
assert!(
tokio::time::timeout(Duration::from_millis(20), &mut snapshot).await.is_err(),
"snapshot must wait while active and retrying ownership locks are held"
);
hook.active_to_retrying_release.notify_one();
let snapshot = tokio::time::timeout(Duration::from_secs(1), snapshot)
.await
.expect("snapshot should resume after active to retrying handoff")
.expect("snapshot task should complete");
assert_eq!(snapshot.active_tasks + snapshot.queue_length + snapshot.retrying_tasks, 1);
assert_eq!(snapshot.retrying_tasks, 1);
tokio::time::timeout(Duration::from_secs(5), hook.retrying_to_queue_reached.notified())
.await
.expect("retrying to queue transition should be reached");
let snapshot_manager = manager.clone();
let mut snapshot = tokio::spawn(async move { snapshot_manager.operations_snapshot().await });
assert!(
tokio::time::timeout(Duration::from_millis(20), &mut snapshot).await.is_err(),
"snapshot must wait while queue ownership is transferred"
);
hook.retrying_to_queue_release.notify_one();
// The scheduler may immediately execute the retried request again;
// leave a permit so a second test-only active handoff cannot stall it.
hook.active_to_retrying_release.notify_one();
let snapshot = tokio::time::timeout(Duration::from_secs(1), snapshot)
.await
.expect("snapshot should resume after retrying to queue handoff")
.expect("snapshot task should complete");
assert_eq!(snapshot.active_tasks + snapshot.queue_length + snapshot.retrying_tasks, 1);
*RETRY_OWNERSHIP_TEST_HOOK.lock().await = None;
hook.active_to_retrying_release.notify_one();
hook.retrying_to_queue_release.notify_one();
tokio::time::timeout(Duration::from_secs(1), manager.stop())
.await
.expect("manager stop should not stall")
.expect("manager should stop");
}
#[tokio::test]
async fn test_active_progress_snapshot_sums_active_task_progress() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
+4 -4
View File
@@ -156,10 +156,10 @@ pub async fn init_heal_manager_with_workload_provider(
let channel_receiver = if force_channel_failure {
Err("forced heal channel initialization failure")
} else {
rustfs_common::heal_channel::init_heal_channel()
rustfs_common::heal_channel::init_heal_channels()
};
let receiver = match channel_receiver {
Ok(receiver) => receiver,
let (receiver, receipt_receiver) = match channel_receiver {
Ok(receivers) => receivers,
Err(err) => {
stop_initializing_manager(&heal_manager).await?;
return Err(Error::Config(err.to_string()));
@@ -175,7 +175,7 @@ pub async fn init_heal_manager_with_workload_provider(
tokio::spawn(async move {
let mut processor = channel_processor.lock().await;
if let Err(e) = processor.start(receiver).await {
if let Err(e) = processor.start_with_receipts(receiver, receipt_receiver).await {
error!(
target: "rustfs::heal",
event = EVENT_HEAL_RUNTIME_STATE,