fix(storage): harden offline drive fail-fast paths (#2564)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: houseme <4829346+houseme@users.noreply.github.com>
This commit is contained in:
houseme
2026-04-16 17:21:45 +08:00
committed by GitHub
parent 579b124726
commit 28edfd6190
35 changed files with 4426 additions and 618 deletions
+361 -81
View File
@@ -17,9 +17,11 @@ use crate::disk::{
FileWriter, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
disk_store::{
CHECK_EVERY, CHECK_TIMEOUT_DURATION, DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING, ENV_RUSTFS_DRIVE_ACTIVE_MONITORING,
SKIP_IF_SUCCESS_BEFORE, get_max_timeout_duration,
SKIP_IF_SUCCESS_BEFORE, get_drive_disk_info_timeout, get_drive_list_dir_timeout, get_drive_metadata_timeout,
get_drive_walkdir_stall_timeout, get_drive_walkdir_timeout, get_max_timeout_duration,
},
endpoint::Endpoint,
health_state::{RuntimeDriveHealthState, get_drive_returning_probe_interval, record_drive_runtime_state},
};
use crate::disk::{disk_store::DiskHealthTracker, error::DiskError, local::ScanGuard};
use crate::rpc::client::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client};
@@ -31,7 +33,9 @@ use crate::{
use bytes::Bytes;
use futures::lock::Mutex;
use http::{HeaderMap, HeaderValue, Method, header::CONTENT_TYPE};
use metrics::counter;
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
use rustfs_protos::evict_failed_connection;
use rustfs_protos::proto_gen::node_service::RenamePartRequest;
use rustfs_protos::proto_gen::node_service::{
CheckPartsRequest, DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest, DeleteVolumeRequest,
@@ -43,7 +47,7 @@ use rustfs_protos::proto_gen::node_service::{
use rustfs_rio::{HttpReader, HttpWriter};
use serde::{Serialize, de::DeserializeOwned};
use std::{
io::Cursor,
io::{Cursor, ErrorKind},
path::PathBuf,
sync::{
Arc,
@@ -116,10 +120,38 @@ impl RemoteDisk {
health: Arc::new(DiskHealthTracker::new()),
cancel_token: CancellationToken::new(),
};
record_drive_runtime_state(ep, RuntimeDriveHealthState::Online);
Ok(disk)
}
pub fn runtime_state(&self) -> RuntimeDriveHealthState {
self.health.runtime_state()
}
pub fn offline_duration_secs(&self) -> Option<u64> {
self.health.offline_duration().map(|duration| duration.as_secs())
}
#[cfg(test)]
pub fn force_runtime_state_for_test(&self, state: RuntimeDriveHealthState) {
self.health.force_runtime_state_for_test(state);
}
fn spawn_recovery_monitor_if_needed(&self) {
if !self.health_check {
return;
}
let addr = self.addr.clone();
let endpoint = self.endpoint.clone();
let health = Arc::clone(&self.health);
let cancel_token = self.cancel_token.clone();
tokio::spawn(async move {
Self::monitor_remote_disk_recovery(addr, endpoint, health, cancel_token).await;
});
}
/// Enable health monitoring after disk creation.
/// Used to defer health checks until after startup format loading completes,
/// so that remote peers have time to come online.
@@ -130,27 +162,34 @@ impl RemoteDisk {
let health = Arc::clone(&self.health);
let cancel_token = self.cancel_token.clone();
let addr = self.addr.clone();
let endpoint = self.endpoint.clone();
tokio::spawn(async move {
Self::monitor_remote_disk_health(addr, health, cancel_token).await;
Self::monitor_remote_disk_health(addr, endpoint, health, cancel_token).await;
});
}
/// Monitor remote disk health periodically
async fn monitor_remote_disk_health(addr: String, health: Arc<DiskHealthTracker>, cancel_token: CancellationToken) {
async fn monitor_remote_disk_health(
addr: String,
endpoint: Endpoint,
health: Arc<DiskHealthTracker>,
cancel_token: CancellationToken,
) {
let mut interval = time::interval(CHECK_EVERY);
// Perform basic connectivity check
if Self::perform_connectivity_check(&addr).await.is_err() && health.swap_ok_to_faulty() {
if Self::perform_connectivity_check(&addr).await.is_err() && health.mark_failure(&endpoint, "connectivity_probe_failed") {
warn!("Remote disk health check failed for {}: marking as faulty", addr);
// Start recovery monitoring
let health_clone = Arc::clone(&health);
let addr_clone = addr.clone();
let endpoint_clone = endpoint.clone();
let cancel_clone = cancel_token.clone();
tokio::spawn(async move {
Self::monitor_remote_disk_recovery(addr_clone, health_clone, cancel_clone).await;
Self::monitor_remote_disk_recovery(addr_clone, endpoint_clone, health_clone, cancel_clone).await;
});
}
@@ -183,16 +222,17 @@ impl RemoteDisk {
}
// Perform basic connectivity check
if Self::perform_connectivity_check(&addr).await.is_err() && health.swap_ok_to_faulty() {
if Self::perform_connectivity_check(&addr).await.is_err() && health.mark_failure(&endpoint, "connectivity_probe_failed") {
warn!("Remote disk health check failed for {}: marking as faulty", addr);
// Start recovery monitoring
let health_clone = Arc::clone(&health);
let addr_clone = addr.clone();
let endpoint_clone = endpoint.clone();
let cancel_clone = cancel_token.clone();
tokio::spawn(async move {
Self::monitor_remote_disk_recovery(addr_clone, health_clone, cancel_clone).await;
Self::monitor_remote_disk_recovery(addr_clone, endpoint_clone, health_clone, cancel_clone).await;
});
}
}
@@ -201,8 +241,13 @@ impl RemoteDisk {
}
/// Monitor remote disk recovery and mark as healthy when recovered
async fn monitor_remote_disk_recovery(addr: String, health: Arc<DiskHealthTracker>, cancel_token: CancellationToken) {
let mut interval = time::interval(CHECK_EVERY);
async fn monitor_remote_disk_recovery(
addr: String,
endpoint: Endpoint,
health: Arc<DiskHealthTracker>,
cancel_token: CancellationToken,
) {
let mut interval = time::interval(get_drive_returning_probe_interval());
loop {
tokio::select! {
@@ -211,9 +256,14 @@ impl RemoteDisk {
}
_ = interval.tick() => {
if Self::perform_connectivity_check(&addr).await.is_ok() {
info!("Remote disk recovered: {}", addr);
health.set_ok();
return;
let became_online = health.mark_recovery_success(&endpoint, "connectivity_probe_success");
info!("Remote disk recovery probe succeeded: {}", addr);
if became_online {
info!("Remote disk recovered: {}", addr);
return;
}
} else {
health.mark_failure(&endpoint, "connectivity_probe_failed");
}
}
}
@@ -242,6 +292,19 @@ impl RemoteDisk {
/// Execute operation with timeout and health tracking
async fn execute_with_timeout<T, F, Fut>(&self, operation: F, timeout_duration: Duration) -> Result<T>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
self.execute_with_timeout_for_op("unknown", operation, timeout_duration).await
}
async fn execute_with_timeout_for_op<T, F, Fut>(
&self,
op: &'static str,
operation: F,
timeout_duration: Duration,
) -> Result<T>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
@@ -270,17 +333,81 @@ impl RemoteDisk {
self.health.log_success();
}
self.health.decrement_waiting();
if let Err(err) = &operation_result
&& Self::is_timeout_like_error(err)
{
counter!(
"rustfs_drive_op_timeout_total",
"endpoint" => self.endpoint.to_string(),
"op" => op.to_string()
)
.increment(1);
warn!(
endpoint = %self.endpoint,
addr = %self.addr,
op,
timeout_ms = timeout_duration.as_millis(),
"Remote disk operation returned a timeout-like error"
);
self.mark_faulty_and_evict("operation_timeout_error").await;
}
operation_result
}
Err(_) => {
// Timeout occurred, mark disk as potentially faulty
self.health.decrement_waiting();
warn!("Remote disk operation timeout after {:?}", timeout_duration);
counter!(
"rustfs_drive_op_timeout_total",
"endpoint" => self.endpoint.to_string(),
"op" => op.to_string()
)
.increment(1);
self.mark_faulty_and_evict("operation_timeout").await;
warn!(
endpoint = %self.endpoint,
addr = %self.addr,
op,
timeout_ms = timeout_duration.as_millis(),
"Remote disk operation timed out"
);
Err(Error::other(format!("Remote disk operation timeout after {timeout_duration:?}")))
}
}
}
fn is_timeout_like_error(err: &Error) -> bool {
matches!(err, DiskError::Timeout) || matches!(err, DiskError::Io(io_err) if io_err.kind() == ErrorKind::TimedOut)
}
async fn mark_faulty_and_evict(&self, reason: &'static str) {
if self.health.mark_failure(&self.endpoint, reason) {
self.spawn_recovery_monitor_if_needed();
counter!(
"rustfs_drive_faulty_mark_total",
"endpoint" => self.endpoint.to_string(),
"reason" => reason.to_string()
)
.increment(1);
warn!(
"Remote disk marked faulty after timeout: endpoint={}, addr={}, reason={}",
self.endpoint, self.addr, reason
);
counter!(
"rustfs_drive_connection_evict_total",
"endpoint" => self.endpoint.to_string(),
"reason" => reason.to_string()
)
.increment(1);
info!(
endpoint = %self.endpoint,
addr = %self.addr,
reason,
"Evicting cached remote disk connection after fault transition"
);
evict_failed_connection(&self.addr).await;
}
}
async fn get_client(&self) -> Result<NodeServiceClient<InterceptedService<Channel, TonicInterceptor>>> {
node_service_time_out_client(&self.addr, TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
.await
@@ -757,7 +884,8 @@ impl DiskAPI for RemoteDisk {
let file_info = serde_json::to_string(&fi)?;
let file_info_bin = encode_msgpack(&fi)?;
self.execute_with_timeout(
self.execute_with_timeout_for_op(
"write_metadata",
|| async {
let disk = self.disk_ref().await;
let mut client = self
@@ -786,24 +914,31 @@ impl DiskAPI for RemoteDisk {
}
async fn read_metadata(&self, volume: &str, path: &str) -> Result<Bytes> {
let disk = self.disk_ref().await;
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let request = Request::new(ReadMetadataRequest {
volume: volume.to_string(),
path: path.to_string(),
disk,
});
self.execute_with_timeout_for_op(
"read_metadata",
|| async {
let disk = self.disk_ref().await;
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let request = Request::new(ReadMetadataRequest {
volume: volume.to_string(),
path: path.to_string(),
disk,
});
let response = client.read_metadata(request).await?.into_inner();
let response = client.read_metadata(request).await?.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
Ok(response.data)
Ok(response.data)
},
get_drive_metadata_timeout(),
)
.await
}
#[tracing::instrument(skip(self))]
@@ -814,7 +949,8 @@ impl DiskAPI for RemoteDisk {
let file_info_bin = encode_msgpack(&fi)?;
let opts_bin = encode_msgpack(opts)?;
self.execute_with_timeout(
self.execute_with_timeout_for_op(
"update_metadata",
|| async {
let disk = self.disk_ref().await;
let mut client = self
@@ -967,53 +1103,66 @@ impl DiskAPI for RemoteDisk {
async fn list_dir(&self, _origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
debug!("list_dir {}/{}", volume, dir_path);
if self.health.is_faulty() {
return Err(DiskError::FaultyDisk);
}
let disk = self.disk_ref().await;
self.execute_with_timeout(
|| async {
let disk = self.disk_ref().await;
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let request = Request::new(ListDirRequest {
disk,
volume: volume.to_string(),
dir_path: dir_path.to_string(),
count,
});
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let request = Request::new(ListDirRequest {
disk,
volume: volume.to_string(),
dir_path: dir_path.to_string(),
count,
});
let response = client.list_dir(request).await?.into_inner();
let response = client.list_dir(request).await?.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
Ok(response.volumes)
Ok(response.volumes)
},
get_drive_list_dir_timeout(),
)
.await
}
#[tracing::instrument(skip(self, wr))]
async fn walk_dir<W: AsyncWrite + Unpin + Send>(&self, opts: WalkDirOptions, wr: &mut W) -> Result<()> {
info!("walk_dir {}", self.endpoint.to_string());
if self.health.is_faulty() {
return Err(DiskError::FaultyDisk);
}
let disk = self.disk_ref().await;
self.execute_with_timeout(
|| async {
let disk = self.disk_ref().await;
let url = format!("{}/rustfs/rpc/walk_dir?disk={}", self.endpoint.grid_host(), urlencoding::encode(&disk),);
let url = format!("{}/rustfs/rpc/walk_dir?disk={}", self.endpoint.grid_host(), urlencoding::encode(&disk),);
let opts = serde_json::to_vec(&opts)?;
let opts = serde_json::to_vec(&opts)?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
build_auth_headers(&url, &Method::GET, &mut headers);
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
build_auth_headers(&url, &Method::GET, &mut headers);
let mut reader = HttpReader::new(url, Method::GET, headers, Some(opts)).await?;
let mut reader = HttpReader::new_with_stall_timeout(
url,
Method::GET,
headers,
Some(opts),
Some(get_drive_walkdir_stall_timeout()),
)
.await?;
copy_stream_with_buffer(&mut reader, wr, DEFAULT_READ_BUFFER_SIZE).await?;
copy_stream_with_buffer(&mut reader, wr, DEFAULT_READ_BUFFER_SIZE).await?;
Ok(())
Ok(())
},
get_drive_walkdir_timeout(),
)
.await
}
#[tracing::instrument(level = "debug", skip(self))]
@@ -1424,29 +1573,32 @@ impl DiskAPI for RemoteDisk {
#[tracing::instrument(skip(self))]
async fn disk_info(&self, opts: &DiskInfoOptions) -> Result<DiskInfo> {
if self.health.is_faulty() {
return Err(DiskError::FaultyDisk);
}
self.execute_with_timeout_for_op(
"disk_info",
|| async {
let opts = serde_json::to_string(&opts)?;
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let request = Request::new(DiskInfoRequest {
disk: self.endpoint.to_string(),
opts,
});
let opts = serde_json::to_string(&opts)?;
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let request = Request::new(DiskInfoRequest {
disk: self.endpoint.to_string(),
opts,
});
let response = client.disk_info(request).await?.into_inner();
let response = client.disk_info(request).await?.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
let disk_info = serde_json::from_str::<DiskInfo>(&response.disk_info)?;
let disk_info = serde_json::from_str::<DiskInfo>(&response.disk_info)?;
Ok(disk_info)
Ok(disk_info)
},
get_drive_disk_info_timeout(),
)
.await
}
#[tracing::instrument(skip(self))]
@@ -1459,9 +1611,11 @@ impl DiskAPI for RemoteDisk {
#[cfg(test)]
mod tests {
use super::*;
use rustfs_common::GLOBAL_CONN_MAP;
use std::sync::Once;
use tokio::io::duplex;
use tokio::net::TcpListener;
use tonic::transport::Endpoint as TonicEndpoint;
use tracing::Level;
use uuid::Uuid;
@@ -1793,6 +1947,132 @@ mod tests {
assert!(result.is_ok());
}
#[tokio::test]
async fn test_execute_with_timeout_marks_remote_disk_faulty() {
let url = url::Url::parse("http://remote-timeout:9000").unwrap();
let endpoint = Endpoint {
url,
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: 0,
};
let remote_disk = RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.unwrap();
let err = remote_disk
.execute_with_timeout(
|| async {
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<(), Error>(())
},
Duration::from_millis(10),
)
.await
.expect_err("timeout should fail");
assert!(err.to_string().contains("timeout"));
assert!(!remote_disk.is_online().await, "remote disk should be marked faulty after timeout");
}
#[tokio::test]
async fn test_execute_with_timeout_evicts_cached_connection() {
let addr = "http://127.0.0.1:59991".to_string();
let url = url::Url::parse(&format!("{addr}/data")).unwrap();
let endpoint = Endpoint {
url,
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: 0,
};
let remote_disk = RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.unwrap();
let channel = TonicEndpoint::from_shared(addr.clone()).unwrap().connect_lazy();
GLOBAL_CONN_MAP.write().await.insert(addr.clone(), channel);
assert!(GLOBAL_CONN_MAP.read().await.contains_key(&addr));
let _ = remote_disk
.execute_with_timeout(
|| async {
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<(), Error>(())
},
Duration::from_millis(10),
)
.await
.expect_err("timeout should fail");
assert!(
!GLOBAL_CONN_MAP.read().await.contains_key(&addr),
"timeout should evict cached connection"
);
}
#[tokio::test]
async fn test_execute_with_timeout_marks_faulty_on_timeout_like_error() {
let addr = "http://127.0.0.1:59992".to_string();
let url = url::Url::parse(&format!("{addr}/data")).unwrap();
let endpoint = Endpoint {
url,
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: 0,
};
let remote_disk = RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.unwrap();
let channel = TonicEndpoint::from_shared(addr.clone()).unwrap().connect_lazy();
GLOBAL_CONN_MAP.write().await.insert(addr.clone(), channel);
let err = remote_disk
.execute_with_timeout(
|| async { Err::<(), Error>(DiskError::Io(std::io::Error::new(std::io::ErrorKind::TimedOut, "stall timeout"))) },
Duration::from_secs(1),
)
.await
.expect_err("timeout-like operation error should fail");
assert_eq!(
match &err {
DiskError::Io(io_err) => io_err.kind(),
other => panic!("expected io timeout error, got {other:?}"),
},
std::io::ErrorKind::TimedOut
);
assert!(!remote_disk.is_online().await, "timeout-like errors should mark remote disk faulty");
assert!(
!GLOBAL_CONN_MAP.read().await.contains_key(&addr),
"timeout-like errors should evict cached connection"
);
}
#[test]
fn test_remote_disk_sync_properties() {
let url = url::Url::parse("https://secure-remote:9000/data").unwrap();
+182 -10
View File
@@ -18,8 +18,10 @@ use rustfs_lock::{
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
types::{LockId, LockMetadata, LockPriority},
};
use rustfs_protos::proto_gen::node_service::node_service_client::NodeServiceClient;
use rustfs_protos::proto_gen::node_service::{BatchGenerallyLockRequest, GenerallyLockRequest, PingRequest};
use rustfs_protos::{evict_failed_connection, proto_gen::node_service::node_service_client::NodeServiceClient};
use std::time::Duration;
use tokio::time::timeout;
use tonic::Request;
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;
@@ -62,6 +64,77 @@ impl RemoteClient {
.map_err(|err| LockError::internal(format!("can not get client, err: {err}")))
}
async fn evict_connection(&self, op: &'static str, reason: &str) {
warn!(
addr = %self.addr,
op,
reason,
"Evicting cached remote lock connection after RPC failure"
);
evict_failed_connection(&self.addr).await;
}
fn rpc_timeout(timeout_duration: Duration) -> Duration {
if timeout_duration.is_zero() {
Duration::from_millis(1)
} else {
timeout_duration
}
}
async fn execute_rpc<T, F>(
&self,
op: &'static str,
timeout_duration: Duration,
future: F,
) -> std::result::Result<T, LockError>
where
F: std::future::Future<Output = std::result::Result<T, tonic::Status>>,
{
let timeout_duration = Self::rpc_timeout(timeout_duration);
match timeout(timeout_duration, future).await {
Ok(Ok(response)) => Ok(response),
Ok(Err(err)) => {
let reason = err.to_string();
self.evict_connection(op, &reason).await;
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
}
Err(_) => {
let reason = format!("RPC timed out after {:?}", timeout_duration);
self.evict_connection(op, &reason).await;
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), timeout_duration))
}
}
}
fn timeout_failure_response(request: &LockRequest) -> LockResponse {
LockResponse::failure("Lock acquisition timeout", request.acquire_timeout)
}
fn rpc_failure_response(_request: &LockRequest, err: &LockError) -> LockResponse {
LockResponse::failure(format!("Remote lock RPC failed: {err}"), Duration::ZERO)
}
fn timeout_failure_batch(requests: &[LockRequest]) -> Vec<LockResponse> {
requests.iter().map(Self::timeout_failure_response).collect()
}
fn rpc_failure_batch(requests: &[LockRequest], err: &LockError) -> Vec<LockResponse> {
requests
.iter()
.map(|request| Self::rpc_failure_response(request, err))
.collect()
}
fn batch_rpc_timeout(requests: &[LockRequest]) -> Duration {
requests
.iter()
.map(|request| request.acquire_timeout)
.max()
.map(Self::rpc_timeout)
.unwrap_or_else(|| Duration::from_millis(1))
}
fn build_lock_info(request: &LockRequest, lock_info_json: Option<String>) -> LockInfo {
if let Some(lock_info_json) = lock_info_json {
match serde_json::from_str::<LockInfo>(&lock_info_json) {
@@ -111,11 +184,11 @@ impl LockClient for RemoteClient {
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
});
let resp = client
.lock(req)
.await
.map_err(|e| LockError::internal(e.to_string()))?
.into_inner();
let resp = match self.execute_rpc("lock", request.acquire_timeout, client.lock(req)).await {
Ok(resp) => resp.into_inner(),
Err(LockError::Timeout { .. }) => return Ok(Self::timeout_failure_response(request)),
Err(err) => return Ok(Self::rpc_failure_response(request, &err)),
};
// Check for explicit error first
if let Some(error_info) = resp.error_info {
@@ -138,6 +211,10 @@ impl LockClient for RemoteClient {
}
async fn acquire_locks_batch(&self, requests: &[LockRequest]) -> Result<Vec<LockResponse>> {
if requests.is_empty() {
return Ok(Vec::new());
}
let mut client = self.get_client().await?;
let req = Request::new(BatchGenerallyLockRequest {
args: requests
@@ -148,11 +225,14 @@ impl LockClient for RemoteClient {
.collect::<Result<Vec<_>>>()?,
});
let resp = client
.lock_batch(req)
let resp = match self
.execute_rpc("lock_batch", Self::batch_rpc_timeout(requests), client.lock_batch(req))
.await
.map_err(|e| LockError::internal(e.to_string()))?
.into_inner();
{
Ok(resp) => resp.into_inner(),
Err(LockError::Timeout { .. }) => return Ok(Self::timeout_failure_batch(requests)),
Err(err) => return Ok(Self::rpc_failure_batch(requests, &err)),
};
Ok(requests
.iter()
@@ -380,3 +460,95 @@ impl LockClient for RemoteClient {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_common::GLOBAL_CONN_MAP;
use rustfs_lock::{ObjectKey, types::LockPriority};
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tonic::transport::Endpoint as TonicEndpoint;
async fn spawn_hanging_listener() -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
if let Ok((stream, _)) = listener.accept().await {
let _stream = stream;
tokio::time::sleep(Duration::from_secs(2)).await;
}
});
(addr, task)
}
async fn cache_lazy_channel(addr: &str) {
let channel = TonicEndpoint::from_shared(addr.to_string()).unwrap().connect_lazy();
GLOBAL_CONN_MAP.write().await.insert(addr.to_string(), channel);
}
fn test_lock_request(timeout_duration: Duration) -> LockRequest {
LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner-a")
.with_acquire_timeout(timeout_duration)
.with_priority(LockPriority::Normal)
}
#[tokio::test]
async fn test_remote_client_acquire_lock_respects_request_timeout_and_evicts_connection() {
let (addr, accept_task) = spawn_hanging_listener().await;
cache_lazy_channel(&addr).await;
assert!(GLOBAL_CONN_MAP.read().await.contains_key(&addr));
let client = RemoteClient::new(addr.clone());
let request = test_lock_request(Duration::from_millis(50));
let started_at = tokio::time::Instant::now();
let response = client.acquire_lock(&request).await.unwrap();
assert!(
started_at.elapsed() < Duration::from_secs(1),
"remote lock RPC should honor request timeout"
);
assert!(!response.success, "timed out lock acquisition should fail");
assert_eq!(response.error.as_deref(), Some("Lock acquisition timeout"));
assert!(
!GLOBAL_CONN_MAP.read().await.contains_key(&addr),
"timeout should evict cached connection"
);
accept_task.abort();
}
#[tokio::test]
async fn test_remote_client_acquire_locks_batch_respects_request_timeout_and_evicts_connection() {
let (addr, accept_task) = spawn_hanging_listener().await;
cache_lazy_channel(&addr).await;
assert!(GLOBAL_CONN_MAP.read().await.contains_key(&addr));
let client = RemoteClient::new(addr.clone());
let requests = vec![test_lock_request(Duration::from_millis(50))];
let started_at = tokio::time::Instant::now();
let responses = client.acquire_locks_batch(&requests).await.unwrap();
assert!(
started_at.elapsed() < Duration::from_secs(1),
"remote batch lock RPC should honor request timeout"
);
assert_eq!(responses.len(), 1);
assert!(!responses[0].success, "timed out batch lock acquisition should fail");
assert_eq!(responses[0].error.as_deref(), Some("Lock acquisition timeout"));
assert!(
!GLOBAL_CONN_MAP.read().await.contains_key(&addr),
"batch timeout should evict cached connection"
);
accept_task.abort();
}
#[test]
fn test_remote_client_zero_timeout_is_clamped() {
assert_eq!(RemoteClient::rpc_timeout(Duration::ZERO), Duration::from_millis(1));
assert_eq!(RemoteClient::rpc_timeout(Duration::from_millis(25)), Duration::from_millis(25));
}
}