mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-08 13:06:00 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53b365606e | |||
| 3b7298eede | |||
| 7e0c67111b | |||
| 0008cbcb29 | |||
| 0db77be5c6 | |||
| 72e85210f1 | |||
| 0a9f0f59a7 | |||
| e65788f2e8 |
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
|
||||
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
|
||||
sha256-darwin=364f2329a7b72eb9f1608dbe1a3af37af4095354014f3cbe23ca448492d89961
|
||||
sha256-linux=60983f1ebe7068cf660d473c5f76c76a650410ccc99d71934ddca7fd67607987
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=0fe8408874ccec3620262a9812d67920ddd72dc9edf0e36e0d0aed3f8bad026e
|
||||
sha256=0e338d305260229e17ccfb2adc48a6212dbdfea36a9ebfb5a4e0d38658e6cc45
|
||||
|
||||
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
||||
|
||||
### Fixed
|
||||
- **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375.
|
||||
- **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`.
|
||||
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
||||
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
||||
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
||||
|
||||
@@ -609,6 +609,36 @@ pub const ENV_OBJECT_LOCK_RPC_TIMEOUT_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_TIMEOUT
|
||||
/// Default remote lock RPC transport timeout: 3000 milliseconds.
|
||||
pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000;
|
||||
|
||||
/// Environment variable for the minimum interval between evictions of the
|
||||
/// cached lock RPC channel to one peer, in milliseconds.
|
||||
///
|
||||
/// A lock RPC that fails on transport, or that times out while the peer has
|
||||
/// not completed any lock RPC for two deadlines, evicts the shared HTTP/2
|
||||
/// channel so the next request re-dials. Evictions are rate limited per peer
|
||||
/// so one slow lock endpoint cannot drive a reset/GOAWAY/reconnect loop
|
||||
/// (issue #7363). `0` disables the cooldown.
|
||||
///
|
||||
/// Default: 5000 milliseconds.
|
||||
pub const ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS";
|
||||
|
||||
/// Default minimum interval between lock RPC channel evictions per peer: 5000 milliseconds.
|
||||
pub const DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: u64 = 5000;
|
||||
|
||||
/// Environment variable for how many timed-out lock RPCs per peer may keep
|
||||
/// running in the background instead of being cancelled.
|
||||
///
|
||||
/// Cancelling a timed-out stream sends `RST_STREAM`; enough of them make the
|
||||
/// peer answer `GOAWAY too_many_resets` and drop every stream on the
|
||||
/// connection. A detached RPC ends on its own within the internode RPC
|
||||
/// timeout, and a lock it acquires after its caller gave up is released
|
||||
/// immediately. Beyond this budget timed-out RPCs are cancelled as before.
|
||||
///
|
||||
/// Default: 256.
|
||||
pub const ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT: &str = "RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT";
|
||||
|
||||
/// Default per-peer budget of detached (timed-out but still running) lock RPCs: 256.
|
||||
pub const DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT: usize = 256;
|
||||
|
||||
/// Environment variable to enable object namespace lock diagnostics.
|
||||
///
|
||||
/// When enabled, RustFS emits slow lock acquisition and long lock hold
|
||||
|
||||
@@ -923,7 +923,7 @@ mod tests {
|
||||
latest_cycle_end = latest_cycle_end.max(cycle_end);
|
||||
versions_observed |= versions_scanned > 0;
|
||||
observations.push(format!(
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, status={status}",
|
||||
metrics["current_cycle"],
|
||||
metrics["current_cycle_active"],
|
||||
metrics["leader_lock_state"],
|
||||
@@ -1047,6 +1047,8 @@ mod tests {
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
|
||||
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
|
||||
// Capture physical baselines after the PUT rename fanout has drained.
|
||||
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||
// Heal control uses the first lexicographically sorted grid host.
|
||||
// Keep that coordinator distinct from the remote target at index 1.
|
||||
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
|
||||
|
||||
@@ -2234,7 +2234,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
||||
let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple());
|
||||
let prefix = "transition/distributed-admission/";
|
||||
hot_client.create_bucket().bucket(&bucket).send().await?;
|
||||
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
|
||||
for index in 0u8..64 {
|
||||
let key = format!("{prefix}object-{index:02}.bin");
|
||||
hot_client
|
||||
@@ -2245,6 +2244,7 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
|
||||
|
||||
let (node0, node1) = tokio::join!(
|
||||
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
|
||||
|
||||
@@ -59,6 +59,36 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
||||
|
||||
assert_eq!(put_response.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||
|
||||
// A missing key in the healthy store is a client error, unlike a store outage.
|
||||
let missing_key_object = "test-missing-kms-key";
|
||||
let missing_key_error = s3_client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(missing_key_object)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"must not be published"))
|
||||
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||
.ssekms_key_id("rustfs-e2e-test-missing-key")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an unknown key in a healthy Local KMS store must reject the write");
|
||||
assert_eq!(missing_key_error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||
assert_eq!(
|
||||
missing_key_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("KMS.NotFoundException")
|
||||
);
|
||||
let missing_key_absence = s3_client
|
||||
.get_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(missing_key_object)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a write rejected by a missing KMS key must not publish an object");
|
||||
assert_eq!(missing_key_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(
|
||||
missing_key_absence.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("NoSuchKey")
|
||||
);
|
||||
|
||||
// Temporarily rename the key directory to simulate unavailability
|
||||
info!("🔧 Simulating key directory unavailability");
|
||||
let backup_dir = format!("{}.backup", kms_env.kms_keys_dir);
|
||||
|
||||
@@ -4036,6 +4036,10 @@ impl ManualTransitionRunReport {
|
||||
|| self.skipped_queue_timeout > 0
|
||||
}
|
||||
|
||||
fn has_enqueue_backpressure(&self) -> bool {
|
||||
self.skipped_queue_full > 0 || self.skipped_queue_closed > 0 || self.skipped_queue_timeout > 0
|
||||
}
|
||||
|
||||
pub fn was_truncated(&self) -> bool {
|
||||
self.truncated_by_limit || self.truncated_by_duration || self.cancelled
|
||||
}
|
||||
@@ -4251,7 +4255,7 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
|
||||
}
|
||||
report.scanned = report.scanned.saturating_add(1);
|
||||
enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await;
|
||||
if report.has_partial_enqueue() {
|
||||
if report.has_enqueue_backpressure() {
|
||||
report.next_marker.clone_from(&previous_marker);
|
||||
report.next_version_idmarker.clone_from(&previous_version_marker);
|
||||
report.continuation_token =
|
||||
@@ -9950,6 +9954,18 @@ mod tests {
|
||||
assert_eq!(report.skipped_queue_closed, 0);
|
||||
assert_eq!(report.skipped_queue_timeout, 0);
|
||||
assert!(report.has_partial_enqueue());
|
||||
assert!(report.has_enqueue_backpressure());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_transition_in_flight_skip_does_not_stop_the_scan() {
|
||||
let options = ManualTransitionRunOptions::default();
|
||||
let mut report = ManualTransitionRunReport::new("bucket", &options);
|
||||
|
||||
report.record_enqueue_outcome(TransitionEnqueueOutcome::AlreadyInFlight);
|
||||
|
||||
assert!(report.has_partial_enqueue());
|
||||
assert!(!report.has_enqueue_backpressure());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8229,7 +8229,6 @@ mod tests {
|
||||
#[derive(Debug)]
|
||||
struct Source {
|
||||
body: Bytes,
|
||||
stored: Option<Bytes>,
|
||||
info: ObjectInfo,
|
||||
ranges: StdMutex<Vec<(i64, i64)>>,
|
||||
full_reads: std::sync::atomic::AtomicUsize,
|
||||
@@ -8258,18 +8257,6 @@ mod tests {
|
||||
self.info.version_id.map(|id| id.to_string()),
|
||||
"every read retains the selected source version"
|
||||
);
|
||||
if let Some(stored) = &self.stored {
|
||||
if let Some(range) = &range {
|
||||
self.ranges.lock().expect("range journal lock").push((range.start, range.end));
|
||||
} else {
|
||||
self.full_reads.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let plan =
|
||||
crate::object_api::ReadPlan::build_for_request(range, &self.info, opts, &HeaderMap::new(), None).await?;
|
||||
let start = plan.storage_offset();
|
||||
let end = start + usize::try_from(plan.storage_length()).expect("nonnegative storage length");
|
||||
return plan.into_object_reader(Box::new(std::io::Cursor::new(stored.slice(start..end))), &self.info);
|
||||
}
|
||||
if range.is_none() {
|
||||
self.full_reads.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(GetObjectReader {
|
||||
@@ -8428,7 +8415,6 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
body: body.clone(),
|
||||
stored: Some(Bytes::from(stored)),
|
||||
ranges: StdMutex::new(Vec::new()),
|
||||
full_reads: std::sync::atomic::AtomicUsize::new(0),
|
||||
});
|
||||
@@ -8670,7 +8656,6 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
body: body.clone(),
|
||||
stored: None,
|
||||
ranges: StdMutex::new(Vec::new()),
|
||||
full_reads: std::sync::atomic::AtomicUsize::new(0),
|
||||
});
|
||||
|
||||
@@ -22,21 +22,152 @@ use rustfs_lock::{
|
||||
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
|
||||
types::{LockId, LockMetadata, LockPriority},
|
||||
};
|
||||
use rustfs_protos::proto_gen::node_service::{BatchGenerallyLockRequest, GenerallyLockRequest, PingRequest};
|
||||
use rustfs_protos::proto_gen::node_service::{
|
||||
BatchGenerallyLockRequest, BatchGenerallyLockResponse, GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult,
|
||||
PingRequest,
|
||||
};
|
||||
use rustfs_protos::{
|
||||
ConnectionEvictionLogLevel, evict_failed_connection_with_log_level, models::PingBodyBuilder,
|
||||
proto_gen::node_service::node_service_client::NodeServiceClient,
|
||||
};
|
||||
use std::{sync::OnceLock, time::Duration};
|
||||
use tokio::time::timeout;
|
||||
use tonic::Request;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, timeout};
|
||||
use tonic::service::interceptor::InterceptedService;
|
||||
use tonic::{Request, Response};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
|
||||
set_tonic_rolling_mutation_body_digest(request)
|
||||
}
|
||||
|
||||
/// Work to run if an RPC that already timed out for its caller completes later.
|
||||
type LateCompletion<T> = Option<Box<dyn FnOnce(T) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send>>;
|
||||
|
||||
/// The liveness window is this many RPC deadlines: a peer that completed a
|
||||
/// lock RPC within it is slow, not gone, and keeps its channel on a timeout.
|
||||
const LOCK_RPC_LIVENESS_WINDOW_DEADLINES: u32 = 2;
|
||||
|
||||
/// Recent history of the shared lock channel to one peer (issue #7363).
|
||||
///
|
||||
/// A single request deadline says nothing about the HTTP/2 connection it ran
|
||||
/// on: a peer whose lock service is merely slow keeps answering other streams.
|
||||
/// Evicting the cached channel on every timeout turned that slowness into a
|
||||
/// `RST_STREAM`/`GOAWAY too_many_resets`/re-dial loop across the cluster, so
|
||||
/// eviction now requires the peer to have gone quiet and is rate limited.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct LockPeerChannelHealth {
|
||||
last_success: Option<Instant>,
|
||||
last_eviction: Option<Instant>,
|
||||
consecutive_timeouts: u32,
|
||||
/// Timed-out RPCs still running in the background for this peer.
|
||||
detached_rpcs: usize,
|
||||
}
|
||||
|
||||
fn lock_peer_channel_health() -> &'static Mutex<HashMap<String, LockPeerChannelHealth>> {
|
||||
static HEALTH: OnceLock<Mutex<HashMap<String, LockPeerChannelHealth>>> = OnceLock::new();
|
||||
HEALTH.get_or_init(Mutex::default)
|
||||
}
|
||||
|
||||
fn with_lock_peer_health<R>(addr: &str, update: impl FnOnce(&mut LockPeerChannelHealth) -> R) -> R {
|
||||
let mut peers = lock_peer_channel_health()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
update(peers.entry(addr.to_string()).or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn lock_peer_health_for_test(addr: &str) -> LockPeerChannelHealth {
|
||||
lock_peer_channel_health()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(addr)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn reset_lock_peer_health_for_test(addr: &str) {
|
||||
lock_peer_channel_health()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.remove(addr);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EvictionTrigger {
|
||||
/// The caller's deadline expired while the stream was still open.
|
||||
Timeout,
|
||||
/// The transport itself reported the failure (refused, reset, GOAWAY, ...).
|
||||
Transport,
|
||||
}
|
||||
|
||||
impl EvictionTrigger {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Timeout => "timeout",
|
||||
Self::Transport => "transport",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EvictionVerdict {
|
||||
Evict,
|
||||
/// The peer completed a lock RPC within the liveness window: slow, not gone.
|
||||
PeerRecentlyServed,
|
||||
/// The channel was re-dialed within the cooldown; let it prove itself first.
|
||||
CoolingDown,
|
||||
}
|
||||
|
||||
impl EvictionVerdict {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Evict => "evict",
|
||||
Self::PeerRecentlyServed => "peer_recently_served",
|
||||
Self::CoolingDown => "cooling_down",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether a failed lock RPC may evict the shared channel to its peer.
|
||||
fn eviction_verdict(
|
||||
health: &LockPeerChannelHealth,
|
||||
now: Instant,
|
||||
trigger: EvictionTrigger,
|
||||
liveness_window: Duration,
|
||||
cooldown: Duration,
|
||||
) -> EvictionVerdict {
|
||||
if trigger == EvictionTrigger::Timeout
|
||||
&& health
|
||||
.last_success
|
||||
.is_some_and(|at| now.saturating_duration_since(at) < liveness_window)
|
||||
{
|
||||
return EvictionVerdict::PeerRecentlyServed;
|
||||
}
|
||||
if health
|
||||
.last_eviction
|
||||
.is_some_and(|at| now.saturating_duration_since(at) < cooldown)
|
||||
{
|
||||
return EvictionVerdict::CoolingDown;
|
||||
}
|
||||
EvictionVerdict::Evict
|
||||
}
|
||||
|
||||
/// Lock ids whose batch entry the server reports as granted.
|
||||
fn acquired_lock_ids(lock_ids: &[LockId], results: &[GenerallyLockResult]) -> Vec<LockId> {
|
||||
results
|
||||
.iter()
|
||||
.zip(lock_ids)
|
||||
.filter(|(result, _)| result.success)
|
||||
.map(|(_, lock_id)| lock_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remote lock client implementation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteClient {
|
||||
@@ -198,14 +329,202 @@ impl RemoteClient {
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute_rpc<T, F>(&self, op: &'static str, resource_summary: &str, future: F) -> std::result::Result<T, LockError>
|
||||
fn eviction_cooldown() -> Duration {
|
||||
Duration::from_millis(rustfs_utils::get_env_u64(
|
||||
rustfs_config::ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS,
|
||||
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS,
|
||||
))
|
||||
}
|
||||
|
||||
fn detached_rpc_limit() -> usize {
|
||||
rustfs_utils::get_env_usize(
|
||||
rustfs_config::ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT,
|
||||
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT,
|
||||
)
|
||||
}
|
||||
|
||||
fn liveness_window(deadline: Duration) -> Duration {
|
||||
deadline.saturating_mul(LOCK_RPC_LIVENESS_WINDOW_DEADLINES)
|
||||
}
|
||||
|
||||
fn record_rpc_success(&self) {
|
||||
with_lock_peer_health(&self.addr, |health| {
|
||||
health.last_success = Some(Instant::now());
|
||||
health.consecutive_timeouts = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply the per-peer eviction policy after a failed RPC.
|
||||
async fn maybe_evict_connection(
|
||||
&self,
|
||||
op: &'static str,
|
||||
reason: &str,
|
||||
resource_summary: &str,
|
||||
trigger: EvictionTrigger,
|
||||
deadline: Duration,
|
||||
) {
|
||||
let now = Instant::now();
|
||||
let cooldown = Self::eviction_cooldown();
|
||||
let liveness_window = Self::liveness_window(deadline);
|
||||
let (verdict, consecutive_timeouts) = with_lock_peer_health(&self.addr, |health| {
|
||||
if trigger == EvictionTrigger::Timeout {
|
||||
health.consecutive_timeouts = health.consecutive_timeouts.saturating_add(1);
|
||||
}
|
||||
let verdict = eviction_verdict(health, now, trigger, liveness_window, cooldown);
|
||||
if verdict == EvictionVerdict::Evict {
|
||||
health.last_eviction = Some(now);
|
||||
}
|
||||
(verdict, health.consecutive_timeouts)
|
||||
});
|
||||
if verdict == EvictionVerdict::Evict {
|
||||
rustfs_io_metrics::lock_metrics::record_remote_lock_channel_eviction(&self.addr, trigger.as_str());
|
||||
self.evict_connection(op, reason, resource_summary).await;
|
||||
return;
|
||||
}
|
||||
rustfs_io_metrics::lock_metrics::record_remote_lock_channel_eviction_suppressed(&self.addr, verdict.as_str());
|
||||
debug!(
|
||||
addr = %self.addr,
|
||||
op,
|
||||
resource_summary,
|
||||
trigger = trigger.as_str(),
|
||||
verdict = verdict.as_str(),
|
||||
consecutive_timeouts,
|
||||
"Keeping cached remote lock connection after RPC failure"
|
||||
);
|
||||
}
|
||||
|
||||
/// Keep a timed-out RPC running instead of cancelling its stream.
|
||||
///
|
||||
/// Dropping the future sends `RST_STREAM`; under load those resets pile up
|
||||
/// in the server's pending-accept queue until it answers `GOAWAY
|
||||
/// too_many_resets` and kills every stream on the connection. A detached
|
||||
/// stream ends on its own within the internode RPC timeout, the number per
|
||||
/// peer is bounded, and a lock granted after its caller gave up is released.
|
||||
fn detach_timed_out_rpc<T: Send + 'static>(
|
||||
&self,
|
||||
op: &'static str,
|
||||
resource_summary: &str,
|
||||
handle: JoinHandle<std::result::Result<T, tonic::Status>>,
|
||||
late: LateCompletion<T>,
|
||||
) {
|
||||
let limit = Self::detached_rpc_limit();
|
||||
let admitted = with_lock_peer_health(&self.addr, |health| {
|
||||
if health.detached_rpcs >= limit {
|
||||
false
|
||||
} else {
|
||||
health.detached_rpcs += 1;
|
||||
true
|
||||
}
|
||||
});
|
||||
if !admitted {
|
||||
handle.abort();
|
||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "aborted");
|
||||
debug!(
|
||||
addr = %self.addr,
|
||||
op,
|
||||
resource_summary,
|
||||
limit,
|
||||
"Cancelled timed-out remote lock RPC because the detached stream budget is exhausted"
|
||||
);
|
||||
return;
|
||||
}
|
||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "detached");
|
||||
let addr = self.addr.clone();
|
||||
tokio::spawn(async move {
|
||||
let outcome = handle.await;
|
||||
with_lock_peer_health(&addr, |health| health.detached_rpcs = health.detached_rpcs.saturating_sub(1));
|
||||
match outcome {
|
||||
Ok(Ok(response)) => {
|
||||
with_lock_peer_health(&addr, |health| {
|
||||
health.last_success = Some(Instant::now());
|
||||
health.consecutive_timeouts = 0;
|
||||
});
|
||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "success");
|
||||
if let Some(late) = late {
|
||||
late(response).await;
|
||||
}
|
||||
}
|
||||
Ok(Err(status)) => {
|
||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "error");
|
||||
debug!(
|
||||
addr = %addr,
|
||||
op,
|
||||
tonic_code = ?status.code(),
|
||||
tonic_message = status.message(),
|
||||
"Detached remote lock RPC failed after its caller timed out"
|
||||
);
|
||||
}
|
||||
Err(join_error) => {
|
||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "join_error");
|
||||
debug!(addr = %addr, op, error = %join_error, "Detached remote lock RPC task ended abnormally");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn late_release_hook(&self, lock_id: LockId) -> LateCompletion<Response<GenerallyLockResponse>> {
|
||||
let client = self.clone();
|
||||
Some(Box::new(move |response: Response<GenerallyLockResponse>| {
|
||||
Box::pin(async move {
|
||||
if response.get_ref().success {
|
||||
client.release_late_acquisitions(vec![lock_id]).await;
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn late_release_batch_hook(&self, lock_ids: Vec<LockId>) -> LateCompletion<Response<BatchGenerallyLockResponse>> {
|
||||
let client = self.clone();
|
||||
Some(Box::new(move |response: Response<BatchGenerallyLockResponse>| {
|
||||
Box::pin(async move {
|
||||
let acquired = acquired_lock_ids(&lock_ids, &response.get_ref().results);
|
||||
if !acquired.is_empty() {
|
||||
client.release_late_acquisitions(acquired).await;
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// A lock granted after its caller stopped waiting is an orphan until its
|
||||
/// lease expires; hand it back right away, best effort.
|
||||
async fn release_late_acquisitions(&self, lock_ids: Vec<LockId>) {
|
||||
let outcome = match self.release_locks_batch(&lock_ids).await {
|
||||
Ok(released) if released.iter().all(|released| *released) => "released",
|
||||
Ok(_) => "partial",
|
||||
Err(_) => "failed",
|
||||
};
|
||||
rustfs_io_metrics::lock_metrics::record_remote_lock_late_release(outcome);
|
||||
if outcome == "released" {
|
||||
debug!(addr = %self.addr, count = lock_ids.len(), "Released remote locks granted after their caller timed out");
|
||||
} else {
|
||||
warn!(
|
||||
addr = %self.addr,
|
||||
count = lock_ids.len(),
|
||||
outcome,
|
||||
"Could not release every remote lock granted after its caller timed out; the server lease will expire it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_rpc<T, Fut>(
|
||||
&self,
|
||||
op: &'static str,
|
||||
resource_summary: &str,
|
||||
deadline: Duration,
|
||||
future: Fut,
|
||||
late: LateCompletion<T>,
|
||||
) -> std::result::Result<T, LockError>
|
||||
where
|
||||
F: std::future::Future<Output = std::result::Result<T, tonic::Status>>,
|
||||
Fut: Future<Output = std::result::Result<T, tonic::Status>> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let lock_timeout = Self::rpc_timeout();
|
||||
match timeout(lock_timeout, future).await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(err)) => {
|
||||
let mut handle = tokio::spawn(future);
|
||||
match timeout(deadline, &mut handle).await {
|
||||
Ok(Ok(Ok(response))) => {
|
||||
self.record_rpc_success();
|
||||
Ok(response)
|
||||
}
|
||||
Ok(Ok(Err(err))) => {
|
||||
let reason = err.to_string();
|
||||
// Only evict (and re-dial) the cached channel when the failure is a genuine
|
||||
// transport problem. A server-produced application status (auth denied, peer
|
||||
@@ -217,7 +536,7 @@ impl RemoteClient {
|
||||
debug!(
|
||||
addr = %self.addr,
|
||||
op,
|
||||
timeout_ms = lock_timeout.as_millis(),
|
||||
timeout_ms = deadline.as_millis(),
|
||||
resource_summary,
|
||||
tonic_code = ?err.code(),
|
||||
tonic_message = err.message(),
|
||||
@@ -228,7 +547,7 @@ impl RemoteClient {
|
||||
warn!(
|
||||
addr = %self.addr,
|
||||
op,
|
||||
timeout_ms = lock_timeout.as_millis(),
|
||||
timeout_ms = deadline.as_millis(),
|
||||
resource_summary,
|
||||
tonic_code = ?err.code(),
|
||||
tonic_message = err.message(),
|
||||
@@ -237,17 +556,29 @@ impl RemoteClient {
|
||||
);
|
||||
}
|
||||
if transport_failure {
|
||||
self.evict_connection(op, &reason, resource_summary).await;
|
||||
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Transport, deadline)
|
||||
.await;
|
||||
}
|
||||
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
|
||||
}
|
||||
Ok(Err(join_error)) => {
|
||||
warn!(
|
||||
addr = %self.addr,
|
||||
op,
|
||||
resource_summary,
|
||||
error = %join_error,
|
||||
"Remote lock RPC task ended abnormally"
|
||||
);
|
||||
Err(LockError::internal(format!("{op} RPC task failed: {join_error}")))
|
||||
}
|
||||
Err(_) => {
|
||||
let reason = format!("RPC timed out after {:?}", lock_timeout);
|
||||
let reason = format!("RPC timed out after {deadline:?}");
|
||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
|
||||
if Self::is_scanner_leader_lock(resource_summary) {
|
||||
debug!(
|
||||
addr = %self.addr,
|
||||
op,
|
||||
timeout_ms = lock_timeout.as_millis(),
|
||||
timeout_ms = deadline.as_millis(),
|
||||
resource_summary,
|
||||
"Remote lock RPC timed out for scanner leader lock"
|
||||
);
|
||||
@@ -255,13 +586,15 @@ impl RemoteClient {
|
||||
warn!(
|
||||
addr = %self.addr,
|
||||
op,
|
||||
timeout_ms = lock_timeout.as_millis(),
|
||||
timeout_ms = deadline.as_millis(),
|
||||
resource_summary,
|
||||
"Remote lock RPC timed out"
|
||||
);
|
||||
}
|
||||
self.evict_connection(op, &reason, resource_summary).await;
|
||||
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), lock_timeout))
|
||||
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
|
||||
.await;
|
||||
self.detach_timed_out_rpc(op, resource_summary, handle, late);
|
||||
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,8 +687,18 @@ impl LockClient for RemoteClient {
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||
});
|
||||
attach_lock_mutation_body_digest(&mut req)?;
|
||||
let late = self.late_release_hook(request.lock_id.clone());
|
||||
|
||||
let resp = match self.execute_rpc("lock", &resource_summary, client.lock(req)).await {
|
||||
let resp = match self
|
||||
.execute_rpc(
|
||||
"lock",
|
||||
&resource_summary,
|
||||
Self::rpc_timeout(),
|
||||
async move { client.lock(req).await },
|
||||
late,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.into_inner(),
|
||||
Err(err @ LockError::Timeout { .. }) => return Ok(Self::rpc_timeout_failure_response(request, &err)),
|
||||
Err(err) => return Ok(Self::rpc_failure_response(request, &err)),
|
||||
@@ -393,9 +736,16 @@ impl LockClient for RemoteClient {
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
});
|
||||
attach_lock_mutation_body_digest(&mut req)?;
|
||||
let late = self.late_release_batch_hook(requests.iter().map(|request| request.lock_id.clone()).collect());
|
||||
|
||||
let resp = match self
|
||||
.execute_rpc("lock_batch", &resource_summary, client.lock_batch(req))
|
||||
.execute_rpc(
|
||||
"lock_batch",
|
||||
&resource_summary,
|
||||
Self::rpc_timeout(),
|
||||
async move { client.lock_batch(req).await },
|
||||
late,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.into_inner(),
|
||||
@@ -436,7 +786,13 @@ impl LockClient for RemoteClient {
|
||||
let mut req = Request::new(GenerallyLockRequest { args: request_string });
|
||||
attach_lock_mutation_body_digest(&mut req)?;
|
||||
let resp = self
|
||||
.execute_rpc("release", &resource_summary, client.un_lock(req))
|
||||
.execute_rpc(
|
||||
"release",
|
||||
&resource_summary,
|
||||
Self::rpc_timeout(),
|
||||
async move { client.un_lock(req).await },
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.into_inner();
|
||||
if let Some(error_info) = resp.error_info {
|
||||
@@ -464,7 +820,13 @@ impl LockClient for RemoteClient {
|
||||
attach_lock_mutation_body_digest(&mut req)?;
|
||||
|
||||
let resp = self
|
||||
.execute_rpc("release_batch", &resource_summary, client.un_lock_batch(req))
|
||||
.execute_rpc(
|
||||
"release_batch",
|
||||
&resource_summary,
|
||||
Self::rpc_timeout(),
|
||||
async move { client.un_lock_batch(req).await },
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.into_inner();
|
||||
|
||||
@@ -486,7 +848,13 @@ impl LockClient for RemoteClient {
|
||||
});
|
||||
attach_lock_mutation_body_digest(&mut req)?;
|
||||
let resp = self
|
||||
.execute_rpc("refresh", &resource_summary, client.refresh(req))
|
||||
.execute_rpc(
|
||||
"refresh",
|
||||
&resource_summary,
|
||||
Self::rpc_timeout(),
|
||||
async move { client.refresh(req).await },
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.into_inner();
|
||||
if let Some(error_info) = resp.error_info {
|
||||
@@ -506,7 +874,13 @@ impl LockClient for RemoteClient {
|
||||
});
|
||||
attach_lock_mutation_body_digest(&mut req)?;
|
||||
let resp = self
|
||||
.execute_rpc("force_release", &resource_summary, client.force_un_lock(req))
|
||||
.execute_rpc(
|
||||
"force_release",
|
||||
&resource_summary,
|
||||
Self::rpc_timeout(),
|
||||
async move { client.force_un_lock(req).await },
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.into_inner();
|
||||
if let Some(error_info) = resp.error_info {
|
||||
@@ -523,16 +897,26 @@ impl LockClient for RemoteClient {
|
||||
let status_request = Self::create_unlock_request(lock_id);
|
||||
let resource_summary = status_request.resource.to_string();
|
||||
let mut client = self.get_client().await?;
|
||||
let args = serde_json::to_string(&status_request)
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
|
||||
|
||||
// Try to acquire a very short-lived lock to test availability
|
||||
let mut req = Request::new(GenerallyLockRequest {
|
||||
args: serde_json::to_string(&status_request)
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||
});
|
||||
let mut req = Request::new(GenerallyLockRequest { args: args.clone() });
|
||||
attach_lock_mutation_body_digest(&mut req)?;
|
||||
// A probe lock granted after the deadline must not linger on the peer.
|
||||
let late = self.late_release_hook(lock_id.clone());
|
||||
|
||||
// Try exclusive lock first with very short timeout
|
||||
let resp = match self.execute_rpc("check_status", &resource_summary, client.lock(req)).await {
|
||||
let resp = match self
|
||||
.execute_rpc(
|
||||
"check_status",
|
||||
&resource_summary,
|
||||
Self::rpc_timeout(),
|
||||
async move { client.lock(req).await },
|
||||
late,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response.into_inner(),
|
||||
Err(_) => return Ok(Some(Self::unknown_lock_info(lock_id))),
|
||||
};
|
||||
@@ -540,14 +924,19 @@ impl LockClient for RemoteClient {
|
||||
if resp.success {
|
||||
// If we successfully acquired the lock, the resource was free.
|
||||
// Immediately release it on a best-effort basis.
|
||||
let mut release_req = Request::new(GenerallyLockRequest {
|
||||
args: serde_json::to_string(&status_request)
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||
});
|
||||
let mut release_req = Request::new(GenerallyLockRequest { args });
|
||||
attach_lock_mutation_body_digest(&mut release_req)?;
|
||||
let _ = self
|
||||
.execute_rpc("check_status_release", &resource_summary, client.un_lock(release_req))
|
||||
.await;
|
||||
if let Ok(mut client) = self.get_client().await {
|
||||
let _ = self
|
||||
.execute_rpc(
|
||||
"check_status_release",
|
||||
&resource_summary,
|
||||
Self::rpc_timeout(),
|
||||
async move { client.un_lock(release_req).await },
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
} else {
|
||||
@@ -582,19 +971,8 @@ impl LockClient for RemoteClient {
|
||||
|
||||
async fn is_online(&self) -> bool {
|
||||
let online_timeout = Self::online_check_timeout();
|
||||
match timeout(online_timeout, async {
|
||||
let mut client = self.get_client().await?;
|
||||
let ping_req = Request::new(Self::build_ping_request());
|
||||
self.execute_rpc("ping", Self::ONLINE_CHECK_RESOURCE, client.ping(ping_req))
|
||||
.await?;
|
||||
Ok::<(), LockError>(())
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {
|
||||
debug!(addr = %self.addr, timeout_ms = online_timeout.as_millis(), "remote lock client is online");
|
||||
true
|
||||
}
|
||||
let mut client = match timeout(online_timeout, self.get_client()).await {
|
||||
Ok(Ok(client)) => client,
|
||||
Ok(Err(err)) => {
|
||||
debug!(
|
||||
addr = %self.addr,
|
||||
@@ -602,16 +980,39 @@ impl LockClient for RemoteClient {
|
||||
error = %err,
|
||||
"remote lock client online check failed"
|
||||
);
|
||||
false
|
||||
return false;
|
||||
}
|
||||
Err(_) => {
|
||||
let reason = format!("online check timed out after {:?}", online_timeout);
|
||||
warn!(
|
||||
addr = %self.addr,
|
||||
timeout_ms = online_timeout.as_millis(),
|
||||
"remote lock client online check timed out"
|
||||
"remote lock client online check timed out while dialing"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let ping_req = Request::new(Self::build_ping_request());
|
||||
match self
|
||||
.execute_rpc(
|
||||
"ping",
|
||||
Self::ONLINE_CHECK_RESOURCE,
|
||||
online_timeout,
|
||||
async move { client.ping(ping_req).await },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
debug!(addr = %self.addr, timeout_ms = online_timeout.as_millis(), "remote lock client is online");
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
addr = %self.addr,
|
||||
timeout_ms = online_timeout.as_millis(),
|
||||
error = %err,
|
||||
"remote lock client online check failed"
|
||||
);
|
||||
self.evict_connection("ping", &reason, Self::ONLINE_CHECK_RESOURCE).await;
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -673,6 +1074,232 @@ mod tests {
|
||||
.with_priority(LockPriority::Normal)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_verdict_distinguishes_slow_peers_from_dead_channels() {
|
||||
let now = Instant::now() + Duration::from_secs(3600);
|
||||
let window = Duration::from_secs(6);
|
||||
let cooldown = Duration::from_secs(5);
|
||||
|
||||
let idle = LockPeerChannelHealth::default();
|
||||
assert_eq!(
|
||||
eviction_verdict(&idle, now, EvictionTrigger::Timeout, window, cooldown),
|
||||
EvictionVerdict::Evict
|
||||
);
|
||||
|
||||
let serving = LockPeerChannelHealth {
|
||||
last_success: Some(now - Duration::from_secs(1)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
eviction_verdict(&serving, now, EvictionTrigger::Timeout, window, cooldown),
|
||||
EvictionVerdict::PeerRecentlyServed,
|
||||
"a timeout on a peer that just answered is load, not a dead channel"
|
||||
);
|
||||
assert_eq!(
|
||||
eviction_verdict(&serving, now, EvictionTrigger::Transport, window, cooldown),
|
||||
EvictionVerdict::Evict,
|
||||
"a transport failure is reported by the channel itself and still evicts"
|
||||
);
|
||||
|
||||
let quiet = LockPeerChannelHealth {
|
||||
last_success: Some(now - Duration::from_secs(30)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
eviction_verdict(&quiet, now, EvictionTrigger::Timeout, window, cooldown),
|
||||
EvictionVerdict::Evict
|
||||
);
|
||||
|
||||
let just_evicted = LockPeerChannelHealth {
|
||||
last_eviction: Some(now - Duration::from_secs(1)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
eviction_verdict(&just_evicted, now, EvictionTrigger::Timeout, window, cooldown),
|
||||
EvictionVerdict::CoolingDown
|
||||
);
|
||||
assert_eq!(
|
||||
eviction_verdict(&just_evicted, now, EvictionTrigger::Transport, window, cooldown),
|
||||
EvictionVerdict::CoolingDown
|
||||
);
|
||||
|
||||
let cooled = LockPeerChannelHealth {
|
||||
last_eviction: Some(now - Duration::from_secs(10)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
eviction_verdict(&cooled, now, EvictionTrigger::Timeout, window, cooldown),
|
||||
EvictionVerdict::Evict
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquired_lock_ids_picks_only_granted_batch_entries() {
|
||||
let lock_ids = vec![
|
||||
LockId::new_unique(&ObjectKey::new("bucket", "a")),
|
||||
LockId::new_unique(&ObjectKey::new("bucket", "b")),
|
||||
LockId::new_unique(&ObjectKey::new("bucket", "c")),
|
||||
];
|
||||
let results = vec![
|
||||
GenerallyLockResult {
|
||||
success: true,
|
||||
..Default::default()
|
||||
},
|
||||
GenerallyLockResult {
|
||||
success: false,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let acquired = acquired_lock_ids(&lock_ids, &results);
|
||||
assert_eq!(
|
||||
acquired,
|
||||
vec![lock_ids[0].clone()],
|
||||
"only granted entries with a matching id are released"
|
||||
);
|
||||
assert!(acquired_lock_ids(&lock_ids, &[]).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_remote_client_timeout_keeps_channel_of_recently_serving_peer() {
|
||||
ensure_test_rpc_secret();
|
||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||
return;
|
||||
};
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
cache_lazy_channel(&addr).await;
|
||||
with_lock_peer_health(&addr, |health| health.last_success = Some(Instant::now()));
|
||||
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50"))], async {
|
||||
let client = RemoteClient::new(addr.clone());
|
||||
let response = client
|
||||
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!response.success, "timed out lock acquisition should fail");
|
||||
assert!(
|
||||
runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||
"a peer that served a lock RPC within the liveness window is slow, not gone"
|
||||
);
|
||||
assert_eq!(lock_peer_health_for_test(&addr).consecutive_timeouts, 1);
|
||||
})
|
||||
.await;
|
||||
|
||||
accept_task.abort();
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_remote_client_repeated_timeouts_evict_at_most_once_per_cooldown() {
|
||||
ensure_test_rpc_secret();
|
||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||
return;
|
||||
};
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
cache_lazy_channel(&addr).await;
|
||||
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50")),
|
||||
(rustfs_config::ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS, Some("60000")),
|
||||
],
|
||||
async {
|
||||
let client = RemoteClient::new(addr.clone());
|
||||
let request = test_lock_request(Duration::from_millis(5));
|
||||
|
||||
let _ = client.acquire_lock(&request).await.unwrap();
|
||||
assert!(
|
||||
!runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||
"the first timeout on a quiet peer evicts the cached channel"
|
||||
);
|
||||
|
||||
cache_lazy_channel(&addr).await;
|
||||
let _ = client.acquire_lock(&request).await.unwrap();
|
||||
assert!(
|
||||
runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||
"a second timeout inside the cooldown must not tear the fresh channel down again"
|
||||
);
|
||||
assert_eq!(lock_peer_health_for_test(&addr).consecutive_timeouts, 2);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
accept_task.abort();
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_remote_client_detaches_timed_out_rpc_and_reclaims_its_slot() {
|
||||
ensure_test_rpc_secret();
|
||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||
return;
|
||||
};
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
cache_lazy_channel(&addr).await;
|
||||
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50"))], async {
|
||||
let client = RemoteClient::new(addr.clone());
|
||||
let _ = client
|
||||
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
lock_peer_health_for_test(&addr).detached_rpcs,
|
||||
1,
|
||||
"the timed-out stream keeps running instead of being reset"
|
||||
);
|
||||
|
||||
// The hanging listener drops its socket after two seconds; the detached
|
||||
// task then observes the transport failure and frees its slot.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while lock_peer_health_for_test(&addr).detached_rpcs != 0 {
|
||||
assert!(Instant::now() < deadline, "detached RPC slot must be reclaimed once the stream ends");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
accept_task.abort();
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_remote_client_cancels_timed_out_rpc_when_detached_budget_is_exhausted() {
|
||||
ensure_test_rpc_secret();
|
||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||
return;
|
||||
};
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
cache_lazy_channel(&addr).await;
|
||||
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50")),
|
||||
(rustfs_config::ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT, Some("0")),
|
||||
],
|
||||
async {
|
||||
let client = RemoteClient::new(addr.clone());
|
||||
let response = client
|
||||
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!response.success);
|
||||
assert_eq!(
|
||||
lock_peer_health_for_test(&addr).detached_rpcs,
|
||||
0,
|
||||
"an exhausted detached budget falls back to cancelling the stream"
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
accept_task.abort();
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_mutation_helper_marks_single_and_batch_requests_for_rolling_auth() {
|
||||
let mut single = Request::new(GenerallyLockRequest {
|
||||
@@ -714,6 +1341,7 @@ mod tests {
|
||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||
return;
|
||||
};
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
cache_lazy_channel(&addr).await;
|
||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||
|
||||
@@ -759,6 +1387,7 @@ mod tests {
|
||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||
return;
|
||||
};
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
cache_lazy_channel(&addr).await;
|
||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||
|
||||
@@ -805,6 +1434,7 @@ mod tests {
|
||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||
return;
|
||||
};
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
cache_lazy_channel(&addr).await;
|
||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||
|
||||
@@ -842,6 +1472,7 @@ mod tests {
|
||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||
return;
|
||||
};
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
cache_lazy_channel(&addr).await;
|
||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||
|
||||
@@ -884,6 +1515,7 @@ mod tests {
|
||||
let Some(addr) = closed_listener_addr().await else {
|
||||
return;
|
||||
};
|
||||
reset_lock_peer_health_for_test(&addr);
|
||||
cache_lazy_channel(&addr).await;
|
||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||
|
||||
|
||||
@@ -4462,9 +4462,41 @@ pub(crate) struct PoolMetaWriteState {
|
||||
cluster_epoch: Option<u64>,
|
||||
pool_meta_absent: bool,
|
||||
bootstrap_authority: PoolMetaBootstrapAuthority,
|
||||
/// First-hand bootstrap authority this process holds for each pool
|
||||
/// (index = pool index): `Fresh` only for pools it formatted itself,
|
||||
/// `LegacyAdoption` only for pools whose migration it verified. Empty when
|
||||
/// the caller tracks deployment-wide authority only.
|
||||
pool_bootstrap_authorities: Vec<PoolMetaBootstrapAuthority>,
|
||||
/// Whether this process hosts the first endpoint of the first pool and is
|
||||
/// therefore the only writer allowed to publish the initial `pool.bin`.
|
||||
/// `None` when the caller did not say; unknown writers are treated as
|
||||
/// elected so every fail-closed rule still applies to them.
|
||||
elected_bootstrap_writer: Option<bool>,
|
||||
identity_initialized: Option<bool>,
|
||||
identity_fresh_bootstrap_nonce: Option<uuid::Uuid>,
|
||||
identity_needs_repair: bool,
|
||||
/// At least one pool has no identity replica at all.
|
||||
identity_replicas_missing: bool,
|
||||
/// At least one pool has a replica that is present but not a valid identity.
|
||||
identity_replicas_invalid: bool,
|
||||
}
|
||||
|
||||
/// Why an all-missing `pool.bin` set may not be initialized right now.
|
||||
enum MissingMetadataRejection {
|
||||
/// Another node still has to act (mint, attest, or publish); retrying the
|
||||
/// startup loop is the remedy, so the write gate stays open.
|
||||
BootstrapPending(Error),
|
||||
/// The durable state contradicts a fresh bootstrap; writes stay blocked
|
||||
/// until an operator recovers the metadata.
|
||||
RecoveryRequired(Error),
|
||||
}
|
||||
|
||||
impl MissingMetadataRejection {
|
||||
fn into_error(self) -> Error {
|
||||
match self {
|
||||
Self::BootstrapPending(err) | Self::RecoveryRequired(err) => err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -4496,6 +4528,7 @@ impl PoolMetaWriteState {
|
||||
Self::for_startup_with_bootstrap_authority(cluster_id, bootstrap_authority)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_startup_with_bootstrap_authority(
|
||||
cluster_id: uuid::Uuid,
|
||||
bootstrap_authority: PoolMetaBootstrapAuthority,
|
||||
@@ -4507,10 +4540,82 @@ impl PoolMetaWriteState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup state for a process that loaded every pool format itself and
|
||||
/// remembers, per pool, whether it created (or adopted) that pool
|
||||
/// first-hand. Deployment-wide authority is the conjunction across pools:
|
||||
/// any pool this process merely read yields `None`, exactly as before.
|
||||
pub(crate) fn for_startup_with_pool_bootstrap_authorities(
|
||||
cluster_id: uuid::Uuid,
|
||||
pool_bootstrap_authorities: Vec<PoolMetaBootstrapAuthority>,
|
||||
elected_bootstrap_writer: bool,
|
||||
) -> Self {
|
||||
let bootstrap_authority = pool_bootstrap_authorities
|
||||
.iter()
|
||||
.copied()
|
||||
.reduce(PoolMetaBootstrapAuthority::combine_across_pools)
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
expected_cluster_id: Some(cluster_id),
|
||||
bootstrap_authority,
|
||||
pool_bootstrap_authorities,
|
||||
elected_bootstrap_writer: Some(elected_bootstrap_writer),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bootstrap_identity_proven(&self) -> bool {
|
||||
self.bootstrap_authority.is_proven()
|
||||
}
|
||||
|
||||
fn pool_bootstrap_authority_proven(&self, pool_idx: usize) -> bool {
|
||||
self.pool_bootstrap_authorities
|
||||
.get(pool_idx)
|
||||
.is_some_and(|authority| authority.is_proven())
|
||||
}
|
||||
|
||||
/// Pools this process formatted or adopted first-hand during this startup.
|
||||
pub(crate) fn attested_pool_indices(&self) -> Vec<usize> {
|
||||
self.pool_bootstrap_authorities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, authority)| authority.is_proven())
|
||||
.map(|(pool_idx, _)| pool_idx)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Deployment-level proof assembled from per-pool creators: this process
|
||||
/// created the first pool itself, and every pool replica carries the same
|
||||
/// pending identity. A pending replica is only ever written by the process
|
||||
/// that formatted that pool with first-hand proof (see
|
||||
/// [`PoolMetaIdentityWriteScope::Pools`]), so a complete, agreeing pending
|
||||
/// set proves that every pool joined this bootstrap fresh. A missing,
|
||||
/// corrupt, or disagreeing replica keeps the writer fail-closed, and a
|
||||
/// restart without first-hand proof never reopens bootstrap on its own.
|
||||
fn pending_identity_attested_by_every_pool(&self) -> bool {
|
||||
self.elected_bootstrap_writer == Some(true)
|
||||
&& self.pool_bootstrap_authority_proven(0)
|
||||
&& self.identity_initialized == Some(false)
|
||||
&& !self.identity_needs_repair
|
||||
&& self.identity_fresh_bootstrap_nonce.is_some()
|
||||
}
|
||||
|
||||
/// The elected writer minted (or holds) the nonce and the only thing
|
||||
/// standing between it and a complete attestation is a pool whose creator
|
||||
/// has not written its replica yet. Corrupt replicas are never transient.
|
||||
fn awaiting_creator_attestation(&self) -> bool {
|
||||
self.elected_bootstrap_writer == Some(true)
|
||||
&& self.pool_bootstrap_authority_proven(0)
|
||||
&& self.identity_initialized == Some(false)
|
||||
&& self.identity_fresh_bootstrap_nonce.is_some()
|
||||
&& self.identity_needs_repair
|
||||
&& self.identity_replicas_missing
|
||||
&& !self.identity_replicas_invalid
|
||||
}
|
||||
|
||||
fn is_non_elected_bootstrap_observer(&self) -> bool {
|
||||
self.elected_bootstrap_writer == Some(false)
|
||||
}
|
||||
|
||||
pub(crate) fn identity_is_pending(&self) -> bool {
|
||||
self.identity_initialized == Some(false)
|
||||
}
|
||||
@@ -4630,9 +4735,19 @@ impl PoolMetaWriteState {
|
||||
self.identity_needs_repair = selection.needs_repair;
|
||||
self.identity_initialized = selection.identity.map(|identity| identity.initialized);
|
||||
self.identity_fresh_bootstrap_nonce = selection.identity.and_then(|identity| identity.fresh_bootstrap_nonce);
|
||||
self.identity_replicas_missing = selection
|
||||
.cas_tokens
|
||||
.iter()
|
||||
.any(|token| matches!(token, PoolMetaCasToken::Missing));
|
||||
self.identity_replicas_invalid = selection
|
||||
.valid_replicas
|
||||
.iter()
|
||||
.zip(&selection.cas_tokens)
|
||||
.any(|(valid, token)| !valid && !matches!(token, PoolMetaCasToken::Missing));
|
||||
if let Some(identity) = selection.identity {
|
||||
if identity.initialized {
|
||||
self.bootstrap_authority = PoolMetaBootstrapAuthority::None;
|
||||
self.pool_bootstrap_authorities.clear();
|
||||
}
|
||||
if let Some(metadata_epoch) = self.cluster_epoch
|
||||
&& metadata_epoch != identity.epoch
|
||||
@@ -4655,22 +4770,44 @@ impl PoolMetaWriteState {
|
||||
if !self.pool_meta_absent {
|
||||
return Ok(());
|
||||
}
|
||||
self.validate_missing_metadata_can_initialize()
|
||||
.map_err(|err| block_pool_meta_validation(self, err, "metadata_absence"))
|
||||
match self.validate_missing_metadata_can_initialize() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(MissingMetadataRejection::BootstrapPending(err)) => Err(err),
|
||||
Err(MissingMetadataRejection::RecoveryRequired(err)) => {
|
||||
Err(block_pool_meta_validation(self, err, "metadata_absence"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_missing_metadata_can_initialize(&self) -> Result<()> {
|
||||
fn validate_missing_metadata_can_initialize(&self) -> std::result::Result<(), MissingMetadataRejection> {
|
||||
use MissingMetadataRejection::{BootstrapPending, RecoveryRequired};
|
||||
match self.identity_initialized {
|
||||
Some(false) if self.bootstrap_identity_proven() && self.identity_fresh_bootstrap_nonce.is_some() => Ok(()),
|
||||
Some(false) => Err(Error::other(
|
||||
"pool metadata recovery required: pending cluster identity exists but this startup has no verified fresh-bootstrap proof or legacy-adoption proof",
|
||||
)),
|
||||
Some(true) => Err(Error::other(
|
||||
Some(false)
|
||||
if self.identity_fresh_bootstrap_nonce.is_some()
|
||||
&& (self.bootstrap_identity_proven() || self.pending_identity_attested_by_every_pool()) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Some(false) if self.awaiting_creator_attestation() => Err(BootstrapPending(Error::other(
|
||||
"pool metadata bootstrap pending: waiting for every pool creator to attest the pending cluster identity",
|
||||
))),
|
||||
Some(false) if self.is_non_elected_bootstrap_observer() && self.identity_fresh_bootstrap_nonce.is_some() => {
|
||||
Err(BootstrapPending(Error::other(
|
||||
"pool metadata bootstrap pending: waiting for the elected writer to publish the initial pool.bin",
|
||||
)))
|
||||
}
|
||||
Some(false) => Err(RecoveryRequired(Error::other(
|
||||
"pool metadata recovery required: pending cluster identity exists but this startup has no verified fresh-bootstrap proof, legacy-adoption proof, or complete per-pool creator attestation",
|
||||
))),
|
||||
Some(true) => Err(RecoveryRequired(Error::other(
|
||||
"pool metadata recovery required: initialized cluster identity exists but every pool.bin replica is missing",
|
||||
)),
|
||||
None => Err(Error::other(
|
||||
))),
|
||||
None if self.is_non_elected_bootstrap_observer() => Err(BootstrapPending(Error::other(
|
||||
"pool metadata bootstrap pending: waiting for the elected writer to establish the cluster identity",
|
||||
))),
|
||||
None => Err(RecoveryRequired(Error::other(
|
||||
"pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available",
|
||||
)),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5381,7 +5518,9 @@ where
|
||||
write_state.validate_selection(&selection)?;
|
||||
selection.replica_state.ensure_write_safe(operation)?;
|
||||
if selection.absent && (write_state.expected_cluster_id.is_some() || write_state.identity_initialized.is_some()) {
|
||||
write_state.validate_missing_metadata_can_initialize()?;
|
||||
write_state
|
||||
.validate_missing_metadata_can_initialize()
|
||||
.map_err(MissingMetadataRejection::into_error)?;
|
||||
}
|
||||
Ok(selection)
|
||||
}
|
||||
@@ -5483,6 +5622,8 @@ struct PoolMetaIdentitySelection {
|
||||
needs_repair: bool,
|
||||
repair_write_safe: bool,
|
||||
cas_tokens: Vec<PoolMetaCasToken>,
|
||||
/// Per pool: whether the replica decoded as a valid identity.
|
||||
valid_replicas: Vec<bool>,
|
||||
}
|
||||
|
||||
fn encode_pool_meta_identity(identity: PersistedPoolMetaIdentity) -> Result<Vec<u8>> {
|
||||
@@ -5531,6 +5672,17 @@ pub(crate) fn pool_meta_identity_initialized_for_test(data: &[u8]) -> Result<boo
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pending_pool_meta_identity_for_test(cluster_id: uuid::Uuid, epoch: u64, nonce: uuid::Uuid) -> Result<Vec<u8>> {
|
||||
encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
||||
version: POOL_META_IDENTITY_VERSION,
|
||||
cluster_id,
|
||||
epoch,
|
||||
initialized: false,
|
||||
fresh_bootstrap_nonce: Some(nonce),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn initialized_pool_meta_identity_for_test(cluster_id: uuid::Uuid, epoch: u64) -> Result<Vec<u8>> {
|
||||
encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
||||
@@ -5571,6 +5723,10 @@ fn select_pool_meta_identity(
|
||||
expected_cluster_id: uuid::Uuid,
|
||||
) -> Result<PoolMetaIdentitySelection> {
|
||||
let cas_tokens = reads.iter().map(|read| read.cas.clone()).collect();
|
||||
let valid_replicas = reads
|
||||
.iter()
|
||||
.map(|read| matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
|
||||
.collect();
|
||||
let mut selected: Option<PersistedPoolMetaIdentity> = None;
|
||||
let mut needs_repair = false;
|
||||
let mut repair_write_safe = true;
|
||||
@@ -5628,6 +5784,7 @@ fn select_pool_meta_identity(
|
||||
needs_repair,
|
||||
repair_write_safe,
|
||||
cas_tokens,
|
||||
valid_replicas,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5897,10 +6054,43 @@ where
|
||||
result
|
||||
}
|
||||
|
||||
/// Which pool replicas a cluster-identity write may touch.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum PoolMetaIdentityWriteScope<'a> {
|
||||
/// Every pool. Creating a pending identity here requires deployment-wide
|
||||
/// fresh-bootstrap or legacy-adoption proof.
|
||||
All,
|
||||
/// Only the listed pools, each of which this process formatted or adopted
|
||||
/// first-hand. Multi-pool bootstraps whose pools have distinct format
|
||||
/// creators use this scope: the first pool's creator mints the deployment
|
||||
/// nonce and every other creator copies it to its own pool, so the elected
|
||||
/// writer can verify a complete, agreeing pending set instead of trusting
|
||||
/// an in-process flag it cannot observe on another node.
|
||||
Pools(&'a [usize]),
|
||||
}
|
||||
|
||||
fn identity_write_satisfied(
|
||||
selection: &PoolMetaIdentitySelection,
|
||||
identity: PersistedPoolMetaIdentity,
|
||||
scope: PoolMetaIdentityWriteScope<'_>,
|
||||
targets: &[usize],
|
||||
) -> bool {
|
||||
if selection.identity != Some(identity) {
|
||||
return false;
|
||||
}
|
||||
match scope {
|
||||
PoolMetaIdentityWriteScope::All => !selection.needs_repair,
|
||||
PoolMetaIdentityWriteScope::Pools(_) => targets
|
||||
.iter()
|
||||
.all(|pool_idx| selection.valid_replicas.get(*pool_idx).copied().unwrap_or(false)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_pool_meta_identity<S>(
|
||||
pools: Vec<Arc<S>>,
|
||||
write_state: &mut PoolMetaWriteState,
|
||||
initialized: bool,
|
||||
scope: PoolMetaIdentityWriteScope<'_>,
|
||||
fence: &PoolMetaPersistenceFence<'_>,
|
||||
transaction_arm: &mut PoolMetaTransactionArm,
|
||||
) -> Result<()>
|
||||
@@ -5910,6 +6100,23 @@ where
|
||||
let Some(cluster_id) = write_state.expected_cluster_id else {
|
||||
return Ok(());
|
||||
};
|
||||
let targets: Vec<usize> = match scope {
|
||||
PoolMetaIdentityWriteScope::All => (0..pools.len()).collect(),
|
||||
PoolMetaIdentityWriteScope::Pools(indices) => {
|
||||
if initialized {
|
||||
return Err(Error::other("pool metadata identity commit must address every pool"));
|
||||
}
|
||||
if indices
|
||||
.iter()
|
||||
.any(|pool_idx| *pool_idx >= pools.len() || !write_state.pool_bootstrap_authority_proven(*pool_idx))
|
||||
{
|
||||
return Err(Error::other(
|
||||
"pool metadata recovery required: a pending cluster identity can only be attested for pools this startup formatted or adopted first-hand",
|
||||
));
|
||||
}
|
||||
indices.to_vec()
|
||||
}
|
||||
};
|
||||
for attempt in 0..POOL_META_CAS_MAX_ATTEMPTS {
|
||||
let selection = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
||||
if !selection.repair_write_safe {
|
||||
@@ -5918,20 +6125,39 @@ where
|
||||
"pool metadata recovery required: cluster identity has an unreadable replica",
|
||||
));
|
||||
}
|
||||
let identity = match selection.identity {
|
||||
Some(identity) if identity.initialized || initialized => PersistedPoolMetaIdentity {
|
||||
let identity = match (selection.identity, scope) {
|
||||
// An initialized deployment (for example a pool expansion) never
|
||||
// reopens bootstrap: first-hand proof for a new pool is not a
|
||||
// reason to publish a pending identity.
|
||||
(Some(identity), PoolMetaIdentityWriteScope::Pools(_)) if identity.initialized => return Ok(()),
|
||||
(Some(identity), _) if identity.initialized || initialized => PersistedPoolMetaIdentity {
|
||||
initialized: true,
|
||||
fresh_bootstrap_nonce: None,
|
||||
..identity
|
||||
},
|
||||
Some(identity) => identity,
|
||||
None if !initialized && !write_state.bootstrap_identity_proven() => {
|
||||
(Some(identity), _) => identity,
|
||||
// Only the first pool's creator mints the deployment nonce; every
|
||||
// other creator waits until it is durable and copies it, so two
|
||||
// concurrent creators can never publish disagreeing replicas.
|
||||
(None, PoolMetaIdentityWriteScope::Pools(indices)) => {
|
||||
if !indices.contains(&0) {
|
||||
return Ok(());
|
||||
}
|
||||
PersistedPoolMetaIdentity {
|
||||
version: POOL_META_IDENTITY_VERSION,
|
||||
cluster_id,
|
||||
epoch: write_state.cluster_epoch.unwrap_or(POOL_META_INITIAL_EPOCH),
|
||||
initialized: false,
|
||||
fresh_bootstrap_nonce: Some(uuid::Uuid::new_v4()),
|
||||
}
|
||||
}
|
||||
(None, PoolMetaIdentityWriteScope::All) if !initialized && !write_state.bootstrap_identity_proven() => {
|
||||
write_state.block_writes();
|
||||
return Err(Error::other(
|
||||
"pool metadata recovery required: cannot create a pending cluster identity without verified fresh-bootstrap proof or legacy-adoption proof",
|
||||
));
|
||||
}
|
||||
None => PersistedPoolMetaIdentity {
|
||||
(None, PoolMetaIdentityWriteScope::All) => PersistedPoolMetaIdentity {
|
||||
version: POOL_META_IDENTITY_VERSION,
|
||||
cluster_id,
|
||||
epoch: write_state.cluster_epoch.unwrap_or(POOL_META_INITIAL_EPOCH),
|
||||
@@ -5939,12 +6165,15 @@ where
|
||||
fresh_bootstrap_nonce: (!initialized).then(uuid::Uuid::new_v4),
|
||||
},
|
||||
};
|
||||
if selection.identity == Some(identity) && !selection.needs_repair {
|
||||
if identity_write_satisfied(&selection, identity, scope, &targets) {
|
||||
return Ok(());
|
||||
}
|
||||
let data = encode_pool_meta_identity(identity)?;
|
||||
let mut conflict = false;
|
||||
for (pool, token) in pools.iter().cloned().zip(&selection.cas_tokens) {
|
||||
for (pool_idx, (pool, token)) in pools.iter().cloned().zip(&selection.cas_tokens).enumerate() {
|
||||
if !targets.contains(&pool_idx) {
|
||||
continue;
|
||||
}
|
||||
match save_pool_meta_object_cas(
|
||||
pool,
|
||||
POOL_META_IDENTITY_NAME,
|
||||
@@ -5971,7 +6200,7 @@ where
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let confirmed = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
||||
if confirmed.identity == Some(identity) && !confirmed.needs_repair {
|
||||
if identity_write_satisfied(&confirmed, identity, scope, &targets) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -6003,6 +6232,34 @@ where
|
||||
pools,
|
||||
write_state,
|
||||
initialized,
|
||||
PoolMetaIdentityWriteScope::All,
|
||||
&PoolMetaPersistenceFence::Distributed(None),
|
||||
&mut transaction_arm,
|
||||
)
|
||||
.await?;
|
||||
transaction_arm.disarm();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attest, during startup, the pending cluster identity for the pools this
|
||||
/// process formatted or adopted first-hand. The first pool's creator mints the
|
||||
/// deployment nonce; every other creator copies it once it is durable. Nothing
|
||||
/// is written while the deployment is already initialized or while the nonce
|
||||
/// is not yet durable, so callers simply retry through the startup loop.
|
||||
pub(crate) async fn persist_pool_meta_identity_for_attested_pools<S>(
|
||||
pools: Vec<Arc<S>>,
|
||||
write_state: &mut PoolMetaWriteState,
|
||||
pool_indices: &[usize],
|
||||
) -> Result<()>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
let mut transaction_arm = write_state.arm_transaction();
|
||||
persist_pool_meta_identity(
|
||||
pools,
|
||||
write_state,
|
||||
false,
|
||||
PoolMetaIdentityWriteScope::Pools(pool_indices),
|
||||
&PoolMetaPersistenceFence::Distributed(None),
|
||||
&mut transaction_arm,
|
||||
)
|
||||
@@ -6618,7 +6875,15 @@ where
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
persist_pool_meta_identity(pools.clone(), write_state, true, fence, &mut transaction_arm).await?;
|
||||
persist_pool_meta_identity(
|
||||
pools.clone(),
|
||||
write_state,
|
||||
true,
|
||||
PoolMetaIdentityWriteScope::All,
|
||||
fence,
|
||||
&mut transaction_arm,
|
||||
)
|
||||
.await?;
|
||||
let confirmed = load_pool_meta_for_transaction_recovery(pools, write_state).await?;
|
||||
if confirmed.revision != expected_revision
|
||||
|| confirmed.canonical.as_ref() != Some(&expected_canonical)
|
||||
@@ -7235,7 +7500,15 @@ impl PoolMeta {
|
||||
}
|
||||
if !selection.absent && write_state.identity_requires_repair() {
|
||||
let initialized = write_state.identity_initialized != Some(false) || selection.revision.is_generation_protocol();
|
||||
persist_pool_meta_identity(pools.clone(), write_state, initialized, fence, transaction_arm).await?;
|
||||
persist_pool_meta_identity(
|
||||
pools.clone(),
|
||||
write_state,
|
||||
initialized,
|
||||
PoolMetaIdentityWriteScope::All,
|
||||
fence,
|
||||
transaction_arm,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
// Startup is the only path allowed to create an all-missing metadata
|
||||
@@ -7425,7 +7698,7 @@ impl PoolMeta {
|
||||
confirmed
|
||||
};
|
||||
if confirmed.revision == revision && confirmed.canonical.as_ref() == Some(&durable) {
|
||||
persist_pool_meta_identity(pools, write_state, true, fence, transaction_arm).await?;
|
||||
persist_pool_meta_identity(pools, write_state, true, PoolMetaIdentityWriteScope::All, fence, transaction_arm).await?;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
startup_cas_test_observe(serde_json::json!({
|
||||
"kind": "confirmed", "object": POOL_META_NAME,
|
||||
|
||||
@@ -431,7 +431,7 @@ impl<'a> MultiWriter<'a> {
|
||||
errs = ?self.errs,
|
||||
"Erasure encode write quorum unavailable: {summary_text}"
|
||||
);
|
||||
Err(std::io::Error::other(format!("Failed to write data: {summary_text}")))
|
||||
Err(write_err.into())
|
||||
}
|
||||
|
||||
async fn shutdown_writer(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>) {
|
||||
@@ -503,7 +503,7 @@ impl<'a> MultiWriter<'a> {
|
||||
errs = ?self.errs,
|
||||
"Erasure encode shutdown quorum unavailable: {summary_text}"
|
||||
);
|
||||
Err(std::io::Error::other(format!("Failed to shutdown writers: {summary_text}")))
|
||||
Err(write_err.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,6 +1002,7 @@ impl Erasure {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
|
||||
use crate::error::StorageError;
|
||||
use rustfs_rio::HardLimitReader;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::future::Future;
|
||||
@@ -1451,7 +1452,14 @@ mod tests {
|
||||
Ok(_) => panic!("writer quorum failure should fail the encode pipeline"),
|
||||
Err(err) => err,
|
||||
};
|
||||
assert!(err.to_string().contains("Failed to write data"));
|
||||
let err = StorageError::from(err);
|
||||
assert!(matches!(
|
||||
&err,
|
||||
StorageError::Io(source)
|
||||
if source.kind() == std::io::ErrorKind::Other
|
||||
&& source.to_string() == "injected write failure after producer blocks"
|
||||
));
|
||||
assert!(!err.is_quorum_error());
|
||||
tokio::time::timeout(Duration::from_secs(1), reader_dropped)
|
||||
.await
|
||||
.expect("writer failure should abort the blocked producer")
|
||||
@@ -1644,7 +1652,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_writer_short_write_fails_before_shutdown() {
|
||||
let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 16))];
|
||||
let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 32))];
|
||||
let err = {
|
||||
let mut writer = MultiWriter::new(&mut writers, 1);
|
||||
writer
|
||||
@@ -1653,63 +1661,93 @@ mod tests {
|
||||
.expect_err("short writes must fail the shard writer")
|
||||
};
|
||||
|
||||
assert!(err.to_string().contains("Failed to write data"));
|
||||
let err = StorageError::from(err);
|
||||
assert!(matches!(&err, StorageError::Io(source) if source.kind() == std::io::ErrorKind::WriteZero));
|
||||
assert!(!err.is_quorum_error());
|
||||
assert!(writers[0].is_none(), "short-write shard must be removed before commit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_writer_reports_fallback_summary_when_only_offline_writers_remain() {
|
||||
let mut writers = vec![None, None];
|
||||
let err = {
|
||||
let (err, summary) = {
|
||||
let mut writer = MultiWriter::new(&mut writers, 1);
|
||||
writer
|
||||
let err = writer
|
||||
.write(vec![Bytes::from_static(b"offline-a"), Bytes::from_static(b"offline-b")])
|
||||
.await
|
||||
.expect_err("offline writers cannot satisfy write quorum")
|
||||
.expect_err("offline writers cannot satisfy write quorum");
|
||||
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||
(err, format_write_quorum_failure(&summary))
|
||||
};
|
||||
|
||||
let err = err.to_string();
|
||||
assert!(err.contains("Failed to write data"));
|
||||
assert!(err.contains("offline-disks=2/2"));
|
||||
assert!(err.contains("required=1"));
|
||||
assert_eq!(
|
||||
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||
Some(&Error::ErasureWriteQuorum),
|
||||
);
|
||||
let err = StorageError::from(err);
|
||||
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||
assert!(err.is_quorum_error());
|
||||
assert!(summary.contains("offline-disks=2/2"));
|
||||
assert!(summary.contains("required=1"));
|
||||
|
||||
let shutdown_err = {
|
||||
let (shutdown_err, summary) = {
|
||||
let mut writer = MultiWriter::new(&mut writers, 1);
|
||||
writer
|
||||
let err = writer
|
||||
.shutdown()
|
||||
.await
|
||||
.expect_err("offline writers cannot satisfy shutdown quorum")
|
||||
.expect_err("offline writers cannot satisfy shutdown quorum");
|
||||
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||
(err, format_write_quorum_failure(&summary))
|
||||
};
|
||||
|
||||
let shutdown_err = shutdown_err.to_string();
|
||||
assert!(shutdown_err.contains("Failed to shutdown writers"));
|
||||
assert!(shutdown_err.contains("offline-disks=2/2"));
|
||||
assert!(shutdown_err.contains("required=1"));
|
||||
assert_eq!(
|
||||
shutdown_err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||
Some(&Error::ErasureWriteQuorum),
|
||||
);
|
||||
let shutdown_err = StorageError::from(shutdown_err);
|
||||
assert_eq!(shutdown_err, StorageError::ErasureWriteQuorum);
|
||||
assert!(shutdown_err.is_quorum_error());
|
||||
assert!(summary.contains("offline-disks=2/2"));
|
||||
assert!(summary.contains("required=1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_writer_reports_quorum_failure_when_quorum_exceeds_writer_count() {
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed), 16))];
|
||||
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed), 32))];
|
||||
let mut writer = MultiWriter::new(&mut writers, 2);
|
||||
|
||||
let err = writer
|
||||
.write(vec![Bytes::from_static(b"quorum impossible")])
|
||||
.await
|
||||
.expect_err("write quorum above writer count must fail");
|
||||
let err = err.to_string();
|
||||
assert!(err.contains("Failed to write data"));
|
||||
assert!(err.contains("required=2"));
|
||||
assert!(err.contains("erasure write quorum"));
|
||||
assert_eq!(
|
||||
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||
Some(&Error::ErasureWriteQuorum),
|
||||
);
|
||||
let err = StorageError::from(err);
|
||||
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||
assert!(err.is_quorum_error());
|
||||
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||
let summary = format_write_quorum_failure(&summary);
|
||||
assert!(summary.contains("required=2"));
|
||||
assert!(summary.contains("erasure write quorum"));
|
||||
|
||||
let shutdown_err = writer
|
||||
.shutdown()
|
||||
.await
|
||||
.expect_err("shutdown quorum above writer count must fail");
|
||||
let shutdown_err = shutdown_err.to_string();
|
||||
assert!(shutdown_err.contains("Failed to shutdown writers"));
|
||||
assert!(shutdown_err.contains("required=2"));
|
||||
assert!(shutdown_err.contains("erasure write quorum"));
|
||||
assert_eq!(
|
||||
shutdown_err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||
Some(&Error::ErasureWriteQuorum),
|
||||
);
|
||||
let shutdown_err = StorageError::from(shutdown_err);
|
||||
assert_eq!(shutdown_err, StorageError::ErasureWriteQuorum);
|
||||
assert!(shutdown_err.is_quorum_error());
|
||||
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||
let summary = format_write_quorum_failure(&summary);
|
||||
assert!(summary.contains("required=2"));
|
||||
assert!(summary.contains("erasure write quorum"));
|
||||
}
|
||||
|
||||
// The production wiring (`MultiWriter::new`) must arm a real deadline by
|
||||
@@ -1794,7 +1832,13 @@ mod tests {
|
||||
.write(four_shards())
|
||||
.await
|
||||
.expect_err("two stalled writers must fail the write quorum instead of hanging");
|
||||
assert!(err.to_string().contains("Failed to write data"));
|
||||
assert_eq!(
|
||||
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||
Some(&Error::ErasureWriteQuorum),
|
||||
);
|
||||
let err = StorageError::from(err);
|
||||
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||
assert!(err.is_quorum_error());
|
||||
}
|
||||
|
||||
// A small object whose bytes were fully buffered leaves `write` succeeding
|
||||
@@ -1839,7 +1883,13 @@ mod tests {
|
||||
.shutdown()
|
||||
.await
|
||||
.expect_err("two shutdown stalls must fail the shutdown quorum instead of hanging");
|
||||
assert!(err.to_string().contains("Failed to shutdown writers"));
|
||||
assert_eq!(
|
||||
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||
Some(&Error::ErasureWriteQuorum),
|
||||
);
|
||||
let err = StorageError::from(err);
|
||||
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||
assert!(err.is_quorum_error());
|
||||
}
|
||||
|
||||
// A slow-but-honest writer that keeps completing shards (delay < stall
|
||||
@@ -2121,7 +2171,13 @@ mod tests {
|
||||
.await
|
||||
.expect_err("streaming encode must fail when write quorum is unavailable");
|
||||
|
||||
assert!(err.to_string().contains("Failed to write data"));
|
||||
assert_eq!(
|
||||
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||
Some(&Error::ErasureWriteQuorum),
|
||||
);
|
||||
let err = StorageError::from(err);
|
||||
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||
assert!(err.is_quorum_error());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2145,7 +2201,13 @@ mod tests {
|
||||
.await
|
||||
.expect_err("write quorum failure must fail the inline encode");
|
||||
|
||||
assert!(err.to_string().contains("Failed to write data"));
|
||||
assert_eq!(
|
||||
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||
Some(&Error::ErasureWriteQuorum),
|
||||
);
|
||||
let err = StorageError::from(err);
|
||||
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||
assert!(err.is_quorum_error());
|
||||
assert!(
|
||||
committed.lock().expect("committed buffer should be lockable").is_empty(),
|
||||
"successful writer must not be committed when write quorum fails before shutdown"
|
||||
@@ -2173,7 +2235,13 @@ mod tests {
|
||||
.await
|
||||
.expect_err("shutdown quorum failure must fail the inline encode");
|
||||
|
||||
assert!(err.to_string().contains("Failed to shutdown writers"));
|
||||
let err = StorageError::from(err);
|
||||
assert!(matches!(
|
||||
&err,
|
||||
StorageError::Io(source)
|
||||
if source.kind() == std::io::ErrorKind::Other && source.to_string() == "injected shutdown failure"
|
||||
));
|
||||
assert!(!err.is_quorum_error());
|
||||
assert!(
|
||||
!committed.lock().expect("committed buffer should be lockable").is_empty(),
|
||||
"the successful writer should have committed before shutdown quorum failure was reported"
|
||||
@@ -2395,7 +2463,13 @@ mod tests {
|
||||
.await
|
||||
.expect_err("batched encode must fail when write quorum is unavailable");
|
||||
|
||||
assert!(err.to_string().contains("Failed to write data"));
|
||||
assert_eq!(
|
||||
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||
Some(&Error::ErasureWriteQuorum),
|
||||
);
|
||||
let err = StorageError::from(err);
|
||||
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||
assert!(err.is_quorum_error());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::core::pools::{
|
||||
PoolMetaBootstrapAuthority, PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix,
|
||||
PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix, persist_pool_meta_identity_for_attested_pools,
|
||||
persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
||||
};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
@@ -174,9 +174,20 @@ where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
if elected_writer && write_state.bootstrap_identity_proven() {
|
||||
persist_pool_meta_identity_for_startup(pools, write_state, false).await?;
|
||||
return persist_pool_meta_identity_for_startup(pools, write_state, false).await;
|
||||
}
|
||||
Ok(())
|
||||
if write_state.bootstrap_identity_proven() {
|
||||
return Ok(());
|
||||
}
|
||||
// Multi-pool bootstrap whose pools were formatted by different nodes: no
|
||||
// single process can prove the whole deployment fresh in memory, so each
|
||||
// creator attests the pools it formatted first-hand with the shared nonce
|
||||
// and the elected writer waits for a complete, agreeing pending set.
|
||||
let attested = write_state.attested_pool_indices();
|
||||
if attested.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
persist_pool_meta_identity_for_attested_pools(pools, write_state, &attested).await
|
||||
}
|
||||
|
||||
async fn save_validated_pool_meta_for_startup<S>(
|
||||
@@ -407,7 +418,7 @@ impl ECStore {
|
||||
preflight_startup_rpc_secret(&endpoint_pools)?;
|
||||
|
||||
let mut deployment_id = None;
|
||||
let mut pool_meta_bootstrap_authority = None;
|
||||
let mut pool_meta_bootstrap_authorities = Vec::new();
|
||||
|
||||
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
|
||||
|
||||
@@ -523,12 +534,10 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
}?;
|
||||
pool_meta_bootstrap_authority = Some(pool_meta_bootstrap_authority.map_or(
|
||||
loaded_format.pool_meta_bootstrap_authority,
|
||||
|authority: PoolMetaBootstrapAuthority| {
|
||||
authority.combine_across_pools(loaded_format.pool_meta_bootstrap_authority)
|
||||
},
|
||||
));
|
||||
// First-hand authority for this pool only: `Fresh` when this process
|
||||
// formatted it, `LegacyAdoption` when it verified the migration, and
|
||||
// `None` when it merely read a format another node created.
|
||||
pool_meta_bootstrap_authorities.push(loaded_format.pool_meta_bootstrap_authority);
|
||||
let fm = loaded_format.format;
|
||||
|
||||
// Format loading succeeded, enable health monitoring on all disks
|
||||
@@ -569,9 +578,13 @@ impl ECStore {
|
||||
let peer_sys = S3PeerSys::new_with_instance_ctx(&endpoint_pools, instance_ctx.clone());
|
||||
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
|
||||
pool_meta.dont_save = true;
|
||||
let pool_meta_write_state = PoolMetaWriteState::for_startup_with_bootstrap_authority(
|
||||
let elected_bootstrap_writer = pools
|
||||
.first()
|
||||
.is_some_and(|pool| pool_first_endpoint_is_local(&pool.endpoints));
|
||||
let pool_meta_write_state = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||
deployment_id,
|
||||
pool_meta_bootstrap_authority.unwrap_or_default(),
|
||||
pool_meta_bootstrap_authorities,
|
||||
elected_bootstrap_writer,
|
||||
);
|
||||
|
||||
let decommission_cancelers = RwLock::new(vec![None; pools.len()]);
|
||||
@@ -961,8 +974,9 @@ mod tests {
|
||||
bucket::replication::{ReplicationState, ReplicationStatusType, replication_statuses_map},
|
||||
core::pools::{
|
||||
DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_VERSION,
|
||||
PoolDecommissionInfo, PoolMeta, PoolStatus, pool_meta_identity_initialized_for_test,
|
||||
pool_meta_v3_commit_state_for_test, set_decommission_capacity_info_overrides_for_test,
|
||||
PoolDecommissionInfo, PoolMeta, PoolStatus, pending_pool_meta_identity_for_test,
|
||||
pool_meta_identity_initialized_for_test, pool_meta_v3_commit_state_for_test,
|
||||
set_decommission_capacity_info_overrides_for_test,
|
||||
},
|
||||
disk::endpoint::Endpoint,
|
||||
error::{Error, Result, StorageError},
|
||||
@@ -1465,6 +1479,331 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
fn startup_object(storage: &StartupPoolMetaStorage, object: &str) -> Option<Vec<u8>> {
|
||||
storage
|
||||
.objects
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(object)
|
||||
.map(|(payload, _)| payload.clone())
|
||||
}
|
||||
|
||||
/// Startup errors wrap their cause in context whose `Display` hides the
|
||||
/// source, so assertions walk the chain the same way
|
||||
/// `Error::pool_metadata_failure` does.
|
||||
fn error_chain_text(err: &Error) -> String {
|
||||
let mut parts = vec![err.to_string()];
|
||||
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
|
||||
while let Some(error) = current {
|
||||
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
||||
io.get_ref().map(|inner| inner as &(dyn std::error::Error + 'static))
|
||||
} else {
|
||||
error.source()
|
||||
};
|
||||
if let Some(next) = current {
|
||||
parts.push(next.to_string());
|
||||
}
|
||||
}
|
||||
parts.join(" <- ")
|
||||
}
|
||||
|
||||
fn inject_startup_object(storage: &StartupPoolMetaStorage, object: &str, payload: Vec<u8>) {
|
||||
storage
|
||||
.objects
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert(object.to_string(), (payload, format!("injected-{object}")));
|
||||
}
|
||||
|
||||
fn init_test_pool_meta_with_pools(pool_count: usize) -> PoolMeta {
|
||||
PoolMeta {
|
||||
version: POOL_META_VERSION,
|
||||
pools: (0..pool_count)
|
||||
.map(|id| PoolStatus {
|
||||
id,
|
||||
cmd_line: format!("pool-{id}"),
|
||||
last_update: OffsetDateTime::UNIX_EPOCH,
|
||||
decommission: None,
|
||||
})
|
||||
.collect(),
|
||||
dont_save: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two single-node pools whose formats were created by different nodes:
|
||||
/// node0 formatted pool0 and only read pool1's format, node1 the reverse.
|
||||
fn two_pool_creator_states(deployment_id: Uuid) -> (PoolMetaWriteState, PoolMetaWriteState) {
|
||||
let node0 = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||
deployment_id,
|
||||
vec![PoolMetaBootstrapAuthority::Fresh, PoolMetaBootstrapAuthority::None],
|
||||
true,
|
||||
);
|
||||
let node1 = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||
deployment_id,
|
||||
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::Fresh],
|
||||
false,
|
||||
);
|
||||
(node0, node1)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_two_pool_bootstrap_with_distinct_format_creators_converges_through_creator_attestation() {
|
||||
let deployment_id = Uuid::new_v4();
|
||||
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let pools = vec![pool0.clone(), pool1.clone()];
|
||||
let (mut node0, mut node1) = two_pool_creator_states(deployment_id);
|
||||
assert!(!node0.bootstrap_identity_proven(), "reading pool1's format is not deployment-wide proof");
|
||||
assert!(!node1.bootstrap_identity_proven());
|
||||
|
||||
// node1 (pool1 creator, non-elected) starts first: no durable nonce exists
|
||||
// yet, so it must neither mint one nor latch its write gate while waiting.
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||
.await
|
||||
.expect("a non-first creator without a durable nonce writes nothing");
|
||||
assert!(startup_object(&pool0, POOL_META_IDENTITY_NAME).is_none());
|
||||
assert!(startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none());
|
||||
let err = load_pool_meta_for_startup(pools.clone(), &mut node1)
|
||||
.await
|
||||
.expect_err("nothing durable authorizes a non-elected node");
|
||||
assert!(err.to_string().contains("bootstrap pending"), "{err}");
|
||||
node1
|
||||
.ensure_write_safe("waiting non-elected creator")
|
||||
.expect("waiting for the elected writer must not latch the write gate");
|
||||
|
||||
// node0 (pool0 creator, elected) mints the nonce on the pool it created;
|
||||
// pool1 is still unattested, so it cannot publish pool.bin and must not latch.
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||
.await
|
||||
.expect("the first pool's creator mints the pending identity");
|
||||
let minted = startup_object(&pool0, POOL_META_IDENTITY_NAME).expect("pool0 pending identity");
|
||||
assert!(!pool_meta_identity_initialized_for_test(&minted).expect("decode pending identity"));
|
||||
assert!(
|
||||
startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none(),
|
||||
"node0 holds no first-hand proof for pool1 and must not attest it"
|
||||
);
|
||||
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||
.await
|
||||
.expect_err("an unattested pool keeps the elected writer from publishing");
|
||||
assert!(err.to_string().contains("waiting for every pool creator"), "{err}");
|
||||
node0
|
||||
.ensure_write_safe("waiting elected writer")
|
||||
.expect("waiting for creators must not latch the write gate");
|
||||
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||
|
||||
// node1 retries: it copies pool0's pending identity (same nonce) onto the
|
||||
// pool it created, then keeps waiting for the elected writer's pool.bin.
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||
.await
|
||||
.expect("the pool1 creator attests with the durable nonce");
|
||||
assert_eq!(startup_object(&pool1, POOL_META_IDENTITY_NAME).as_deref(), Some(minted.as_slice()));
|
||||
let err = load_pool_meta_for_startup(pools.clone(), &mut node1)
|
||||
.await
|
||||
.expect_err("a complete pending set never unlocks a non-elected node");
|
||||
assert!(err.to_string().contains("waiting for the elected writer to publish"), "{err}");
|
||||
node1
|
||||
.ensure_write_safe("attested non-elected creator")
|
||||
.expect("waiting for pool.bin must not latch the write gate");
|
||||
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||
|
||||
// node0 retries: every pool is attested under one nonce, so it publishes
|
||||
// pool.bin and commits the identity on both pools.
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||
.await
|
||||
.expect("re-establishing an already minted identity is idempotent");
|
||||
let (_, replica_state) = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||
.await
|
||||
.expect("complete creator attestation authorizes the initial pool metadata write");
|
||||
persist_pool_meta_for_startup_if_safe(
|
||||
&init_test_pool_meta_with_pools(2),
|
||||
pools.clone(),
|
||||
replica_state,
|
||||
&mut node0,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("the elected writer publishes pool.bin and commits the identity");
|
||||
for pool in [&pool0, &pool1] {
|
||||
assert!(startup_object(pool, POOL_META_NAME).is_some());
|
||||
let identity = startup_object(pool, POOL_META_IDENTITY_NAME).expect("committed identity");
|
||||
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode committed identity"));
|
||||
}
|
||||
|
||||
// node1 retries once more: pool.bin exists and nothing is rewritten.
|
||||
let before = startup_object(&pool1, POOL_META_IDENTITY_NAME);
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||
.await
|
||||
.expect("an initialized deployment never reopens bootstrap");
|
||||
assert_eq!(startup_object(&pool1, POOL_META_IDENTITY_NAME), before);
|
||||
load_pool_meta_for_startup(pools, &mut node1)
|
||||
.await
|
||||
.expect("published pool metadata admits the non-elected node");
|
||||
node1
|
||||
.ensure_write_safe("converged non-elected creator")
|
||||
.expect("no latch remains after convergence");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_two_pool_bootstrap_rejects_pending_replicas_from_different_bootstraps() {
|
||||
let deployment_id = Uuid::new_v4();
|
||||
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let pools = vec![pool0.clone(), pool1.clone()];
|
||||
let (mut node0, _) = two_pool_creator_states(deployment_id);
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||
.await
|
||||
.expect("the first pool's creator mints the pending identity");
|
||||
inject_startup_object(
|
||||
&pool1,
|
||||
POOL_META_IDENTITY_NAME,
|
||||
pending_pool_meta_identity_for_test(deployment_id, 1, Uuid::new_v4()).expect("encode foreign pending identity"),
|
||||
);
|
||||
|
||||
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||
.await
|
||||
.expect_err("a pending replica bound to another bootstrap nonce must fail closed");
|
||||
let chain = error_chain_text(&err);
|
||||
assert!(chain.contains("disagree on fresh-bootstrap proof"), "{chain}");
|
||||
node0
|
||||
.ensure_write_safe("split bootstrap")
|
||||
.expect_err("a split bootstrap latches the write gate");
|
||||
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_two_pool_bootstrap_treats_corrupt_creator_replica_as_recovery_not_waiting() {
|
||||
let deployment_id = Uuid::new_v4();
|
||||
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let pools = vec![pool0.clone(), pool1.clone()];
|
||||
let (mut node0, _) = two_pool_creator_states(deployment_id);
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||
.await
|
||||
.expect("the first pool's creator mints the pending identity");
|
||||
// Keep the on-disk format/version header so the replica classifies as
|
||||
// corrupt (undecodable payload) rather than as an incompatible format.
|
||||
let mut corrupt = pending_pool_meta_identity_for_test(deployment_id, 1, Uuid::new_v4()).expect("encode identity");
|
||||
corrupt.truncate(4);
|
||||
corrupt.extend_from_slice(b"not a cluster identity");
|
||||
inject_startup_object(&pool1, POOL_META_IDENTITY_NAME, corrupt);
|
||||
|
||||
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||
.await
|
||||
.expect_err("a corrupt replica is not a creator that is still catching up");
|
||||
let chain = error_chain_text(&err);
|
||||
assert!(chain.contains("no verified fresh-bootstrap proof"), "{chain}");
|
||||
node0
|
||||
.ensure_write_safe("corrupt attestation")
|
||||
.expect_err("a corrupt attestation latches the write gate");
|
||||
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_elected_restart_without_first_hand_proof_cannot_reuse_a_complete_pending_set() {
|
||||
let deployment_id = Uuid::new_v4();
|
||||
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let pools = vec![pool0.clone(), pool1.clone()];
|
||||
let (mut node0, mut node1) = two_pool_creator_states(deployment_id);
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||
.await
|
||||
.expect("mint");
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||
.await
|
||||
.expect("attest");
|
||||
assert_eq!(
|
||||
startup_object(&pool0, POOL_META_IDENTITY_NAME),
|
||||
startup_object(&pool1, POOL_META_IDENTITY_NAME),
|
||||
"both creators attested the same pending identity"
|
||||
);
|
||||
|
||||
// The elected node restarts before publishing: it now merely reads both
|
||||
// formats, so the complete pending set alone must not reopen bootstrap.
|
||||
let mut restarted = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||
deployment_id,
|
||||
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::None],
|
||||
true,
|
||||
);
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut restarted, true)
|
||||
.await
|
||||
.expect("a restart without first-hand proof writes nothing");
|
||||
let err = load_pool_meta_for_startup(pools.clone(), &mut restarted)
|
||||
.await
|
||||
.expect_err("a pending set alone never authorizes a writer without first-hand proof");
|
||||
assert!(err.to_string().contains("no verified fresh-bootstrap proof"), "{err}");
|
||||
restarted
|
||||
.ensure_write_safe("unproven restart")
|
||||
.expect_err("the rejected restart latches the write gate");
|
||||
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fresh_pool_joining_an_initialized_deployment_never_reopens_bootstrap() {
|
||||
let deployment_id = Uuid::new_v4();
|
||||
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let mut founder = PoolMetaWriteState::for_startup(deployment_id, true);
|
||||
establish_pool_meta_bootstrap_identity_if_proven(vec![pool0.clone()], &mut founder, true)
|
||||
.await
|
||||
.expect("the founder mints");
|
||||
let (_, replica_state) = load_pool_meta_for_startup(vec![pool0.clone()], &mut founder)
|
||||
.await
|
||||
.expect("the founder may initialize");
|
||||
persist_pool_meta_for_startup_if_safe(
|
||||
&init_test_pool_meta(None),
|
||||
vec![pool0.clone()],
|
||||
replica_state,
|
||||
&mut founder,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("the founder commits");
|
||||
let founded = startup_object(&pool0, POOL_META_IDENTITY_NAME).expect("committed identity");
|
||||
assert!(pool_meta_identity_initialized_for_test(&founded).expect("decode committed identity"));
|
||||
|
||||
// Expansion: pool1 is fresh and was formatted first-hand by the node
|
||||
// hosting its first endpoint, whether or not that node is elected.
|
||||
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let pools = vec![pool0.clone(), pool1.clone()];
|
||||
for elected in [false, true] {
|
||||
let mut joiner = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||
deployment_id,
|
||||
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::Fresh],
|
||||
elected,
|
||||
);
|
||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut joiner, elected)
|
||||
.await
|
||||
.expect("an initialized deployment ignores first-hand proof for a new pool");
|
||||
assert!(
|
||||
startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none(),
|
||||
"no pending identity may be written to an expansion pool"
|
||||
);
|
||||
assert_eq!(startup_object(&pool0, POOL_META_IDENTITY_NAME).as_deref(), Some(founded.as_slice()));
|
||||
let (_, replica_state) = load_pool_meta_for_startup(pools.clone(), &mut joiner)
|
||||
.await
|
||||
.expect("published pool metadata admits the joiner");
|
||||
joiner
|
||||
.ensure_write_safe("expansion joiner")
|
||||
.expect("joining never latches the write gate");
|
||||
if elected {
|
||||
persist_pool_meta_for_startup_if_safe(
|
||||
&init_test_pool_meta_with_pools(2),
|
||||
pools.clone(),
|
||||
replica_state,
|
||||
&mut joiner,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("the topology update repairs the new pool's replicas");
|
||||
let identity = startup_object(&pool1, POOL_META_IDENTITY_NAME).expect("expansion pool identity");
|
||||
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode repaired identity"));
|
||||
assert!(startup_object(&pool1, POOL_META_NAME).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_init_distinguishes_fresh_deployment_from_wiped_lagging_node() {
|
||||
let deployment_id = Uuid::new_v4();
|
||||
|
||||
@@ -62,6 +62,51 @@ pub fn record_contention_event() {
|
||||
counter!("rustfs_lock_contentions").increment(1);
|
||||
}
|
||||
|
||||
/// Record a remote lock RPC that exceeded its caller's deadline.
|
||||
#[inline(always)]
|
||||
pub fn record_remote_lock_rpc_timeout(peer: &str, op: &'static str) {
|
||||
use metrics::counter;
|
||||
counter!("rustfs_remote_lock_rpc_timeouts_total", "peer" => peer.to_string(), "op" => op).increment(1);
|
||||
}
|
||||
|
||||
/// Record the cached lock channel to `peer` being evicted after an RPC failure.
|
||||
#[inline(always)]
|
||||
pub fn record_remote_lock_channel_eviction(peer: &str, trigger: &'static str) {
|
||||
use metrics::counter;
|
||||
counter!("rustfs_remote_lock_channel_evictions_total", "peer" => peer.to_string(), "trigger" => trigger).increment(1);
|
||||
}
|
||||
|
||||
/// Record an RPC failure that did not evict the cached lock channel to `peer`
|
||||
/// because the peer recently served a request or was re-dialed too recently.
|
||||
#[inline(always)]
|
||||
pub fn record_remote_lock_channel_eviction_suppressed(peer: &str, verdict: &'static str) {
|
||||
use metrics::counter;
|
||||
counter!("rustfs_remote_lock_channel_evictions_suppressed_total", "peer" => peer.to_string(), "verdict" => verdict)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
/// Record a timed-out lock RPC that was left running (`detached`) or cancelled
|
||||
/// because the per-peer detached budget was exhausted (`aborted`).
|
||||
#[inline(always)]
|
||||
pub fn record_remote_lock_rpc_detached(op: &'static str, outcome: &'static str) {
|
||||
use metrics::counter;
|
||||
counter!("rustfs_remote_lock_rpc_detached_total", "op" => op, "outcome" => outcome).increment(1);
|
||||
}
|
||||
|
||||
/// Record how a detached lock RPC eventually ended.
|
||||
#[inline(always)]
|
||||
pub fn record_remote_lock_rpc_late_completion(op: &'static str, outcome: &'static str) {
|
||||
use metrics::counter;
|
||||
counter!("rustfs_remote_lock_rpc_late_completions_total", "op" => op, "outcome" => outcome).increment(1);
|
||||
}
|
||||
|
||||
/// Record the release of a lock that was granted after its caller timed out.
|
||||
#[inline(always)]
|
||||
pub fn record_remote_lock_late_release(outcome: &'static str) {
|
||||
use metrics::counter;
|
||||
counter!("rustfs_remote_lock_late_releases_total", "outcome" => outcome).increment(1);
|
||||
}
|
||||
|
||||
/// Record object namespace lock diagnostics being enabled.
|
||||
#[inline(always)]
|
||||
pub fn record_object_lock_diag_enabled(enabled: bool) {
|
||||
@@ -183,6 +228,12 @@ mod tests {
|
||||
record_lock_hold_time(Duration::from_millis(100));
|
||||
record_early_release();
|
||||
record_contention_event();
|
||||
record_remote_lock_rpc_timeout("http://peer:9000", "lock");
|
||||
record_remote_lock_channel_eviction("http://peer:9000", "timeout");
|
||||
record_remote_lock_channel_eviction_suppressed("http://peer:9000", "peer_recently_served");
|
||||
record_remote_lock_rpc_detached("lock", "detached");
|
||||
record_remote_lock_rpc_late_completion("lock", "success");
|
||||
record_remote_lock_late_release("released");
|
||||
});
|
||||
|
||||
let emitted: std::collections::HashSet<String> = snapshotter
|
||||
@@ -199,6 +250,12 @@ mod tests {
|
||||
"rustfs_lock_hold_time_secs",
|
||||
"rustfs_lock_early_releases",
|
||||
"rustfs_lock_contentions",
|
||||
"rustfs_remote_lock_rpc_timeouts_total",
|
||||
"rustfs_remote_lock_channel_evictions_total",
|
||||
"rustfs_remote_lock_channel_evictions_suppressed_total",
|
||||
"rustfs_remote_lock_rpc_detached_total",
|
||||
"rustfs_remote_lock_rpc_late_completions_total",
|
||||
"rustfs_remote_lock_late_releases_total",
|
||||
] {
|
||||
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
||||
}
|
||||
|
||||
@@ -1233,6 +1233,9 @@ impl LocalKmsClient {
|
||||
async fn decode_stored_key(&self, key_id: &str) -> Result<(StoredMasterKey, Vec<u8>)> {
|
||||
let key_path = self.master_key_path(key_id)?;
|
||||
if !fs::try_exists(&key_path).await? {
|
||||
// Only an accessible key store can establish that a single key is
|
||||
// missing; a directory outage must retain its filesystem error.
|
||||
let _ = fs::read_dir(&self.config.key_dir).await?;
|
||||
return Err(KmsError::key_not_found(key_id));
|
||||
}
|
||||
|
||||
@@ -2387,6 +2390,76 @@ mod tests {
|
||||
(client, temp_dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_key_directory_outage_is_io_error_and_recovers_original_key() {
|
||||
let root = TempDir::new().expect("create isolated key store");
|
||||
let key_dir = root.path().join("keys");
|
||||
let unavailable_dir = root.path().join("keys-unavailable");
|
||||
let config = KmsConfig::local(key_dir.clone()).with_insecure_development_defaults();
|
||||
let backend = LocalKmsBackend::new(config).await.expect("start Local KMS");
|
||||
let key_id = "directory-outage-key";
|
||||
backend
|
||||
.create_key(CreateKeyRequest {
|
||||
key_name: Some(key_id.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("create the original key");
|
||||
let request = |key_id: &str| GenerateDataKeyRequest {
|
||||
key_id: key_id.to_string(),
|
||||
key_spec: KeySpec::Aes256,
|
||||
encryption_context: HashMap::new(),
|
||||
};
|
||||
let before = backend
|
||||
.generate_data_key(request(key_id))
|
||||
.await
|
||||
.expect("generate a data key before the outage");
|
||||
let missing_key = backend.generate_data_key(request("no-such-key")).await;
|
||||
let key_path = key_dir.join(format!("{key_id}.key"));
|
||||
let original_record = fs::read(&key_path).await.expect("read the original key record");
|
||||
|
||||
fs::rename(&key_dir, &unavailable_dir)
|
||||
.await
|
||||
.expect("make the key directory unavailable");
|
||||
let unavailable = backend.generate_data_key(request(key_id)).await;
|
||||
// Restore before checking the error so the failing regression leaves no
|
||||
// orphaned key store; both paths also belong to the same temporary root.
|
||||
fs::rename(&unavailable_dir, &key_dir)
|
||||
.await
|
||||
.expect("restore the original key directory");
|
||||
|
||||
let after = backend
|
||||
.generate_data_key(request(key_id))
|
||||
.await
|
||||
.expect("generate a data key after directory restoration");
|
||||
for data_key in [&before, &after] {
|
||||
let decrypted = backend
|
||||
.decrypt(DecryptRequest {
|
||||
ciphertext: data_key.ciphertext_blob.clone(),
|
||||
encryption_context: HashMap::new(),
|
||||
grant_tokens: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.expect("the original master key must decrypt both data keys");
|
||||
assert!(
|
||||
decrypted.plaintext == data_key.plaintext_key,
|
||||
"directory restoration must preserve the original key material"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
fs::read(&key_path).await.expect("read the restored key record") == original_record,
|
||||
"reads and recovery must not rewrite the key record"
|
||||
);
|
||||
assert!(
|
||||
matches!(missing_key, Err(KmsError::KeyNotFound { key_id }) if key_id == "no-such-key"),
|
||||
"a missing key in a readable directory must remain KeyNotFound"
|
||||
);
|
||||
assert!(
|
||||
matches!(unavailable, Err(KmsError::IoError { .. })),
|
||||
"an unavailable key directory must remain an I/O error, not KeyNotFound"
|
||||
);
|
||||
}
|
||||
|
||||
/// With the AAD write switch on, the Local backend seals the stored
|
||||
/// encryption context into the wrap exactly like KV2: the bound envelope
|
||||
/// round-trips, a rewritten stored context fails authentication even with
|
||||
|
||||
@@ -38,6 +38,16 @@ use uuid::Uuid;
|
||||
|
||||
const UNLOCK_RETRY_ATTEMPTS: usize = 3;
|
||||
const UNLOCK_RETRY_BACKOFF: Duration = Duration::from_millis(100);
|
||||
/// Slow retry schedule for unlocks that survive the fast retry loop. Lock RPC
|
||||
/// timeouts under load are transient (issue #7363); giving up after three
|
||||
/// quick attempts left orphaned entries for the server lease to expire.
|
||||
const DEFERRED_UNLOCK_BACKOFF: [Duration; 5] = [
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(2),
|
||||
Duration::from_secs(4),
|
||||
Duration::from_secs(8),
|
||||
Duration::from_secs(16),
|
||||
];
|
||||
const LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(250);
|
||||
const LOCK_ACQUIRE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const LOCK_ACQUIRE_SPARE_HEDGES: usize = 1;
|
||||
@@ -719,22 +729,7 @@ impl DistributedLock {
|
||||
let mut pending = entries;
|
||||
|
||||
for attempt in 1..=UNLOCK_RETRY_ATTEMPTS {
|
||||
let release_results = join_all(pending.into_iter().map(|(lock_id, client)| async move {
|
||||
match client.release(&lock_id).await {
|
||||
Ok(true) => None,
|
||||
Ok(false) => {
|
||||
warn!(%lock_id, attempt, context, "distributed unlock did not find lock on client");
|
||||
Some((lock_id, client))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(%lock_id, attempt, context, "distributed unlock failed on client: {}", err);
|
||||
Some((lock_id, client))
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
pending = release_results.into_iter().flatten().collect();
|
||||
pending = Self::release_pending_once(pending, attempt, context).await;
|
||||
if pending.is_empty() {
|
||||
debug!(attempt, context, "distributed unlock completed");
|
||||
return;
|
||||
@@ -749,7 +744,54 @@ impl DistributedLock {
|
||||
remaining = pending.len(),
|
||||
attempts = UNLOCK_RETRY_ATTEMPTS,
|
||||
context,
|
||||
"distributed unlock left unreleased entries after retry"
|
||||
"distributed unlock left unreleased entries after retry; continuing with deferred retries"
|
||||
);
|
||||
Self::release_entries_deferred(pending, context).await;
|
||||
}
|
||||
|
||||
async fn release_pending_once(
|
||||
pending: Vec<(LockId, Arc<dyn LockClient>)>,
|
||||
attempt: usize,
|
||||
context: &'static str,
|
||||
) -> Vec<(LockId, Arc<dyn LockClient>)> {
|
||||
let release_results = join_all(pending.into_iter().map(|(lock_id, client)| async move {
|
||||
match client.release(&lock_id).await {
|
||||
Ok(true) => None,
|
||||
Ok(false) => {
|
||||
warn!(%lock_id, attempt, context, "distributed unlock did not find lock on client");
|
||||
Some((lock_id, client))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(%lock_id, attempt, context, "distributed unlock failed on client: {}", err);
|
||||
Some((lock_id, client))
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
release_results.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// Bounded slow retries for entries the fast loop could not release. Every
|
||||
/// caller runs on a background task, so waiting here blocks nobody; after
|
||||
/// the schedule is exhausted the server lease reclaims the entry.
|
||||
async fn release_entries_deferred(mut pending: Vec<(LockId, Arc<dyn LockClient>)>, context: &'static str) {
|
||||
let mut attempt = UNLOCK_RETRY_ATTEMPTS;
|
||||
for delay in DEFERRED_UNLOCK_BACKOFF {
|
||||
tokio::time::sleep(delay).await;
|
||||
attempt += 1;
|
||||
pending = Self::release_pending_once(pending, attempt, context).await;
|
||||
if pending.is_empty() {
|
||||
debug!(attempt, context, "deferred distributed unlock converged");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
warn!(
|
||||
remaining = pending.len(),
|
||||
attempts = attempt,
|
||||
context,
|
||||
"distributed unlock abandoned entries after deferred retry; the server lease will expire them"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -795,7 +837,9 @@ impl DistributedLock {
|
||||
continue;
|
||||
};
|
||||
|
||||
Self::release_entries(vec![(lock_id, client.clone())], context).await;
|
||||
// Deferred retries may wait tens of seconds; never hold up the
|
||||
// next late completion behind them.
|
||||
drop(tokio::spawn(Self::release_entries(vec![(lock_id, client.clone())], context)));
|
||||
}
|
||||
Ok((idx, Ok(resp))) => {
|
||||
tracing::debug!(
|
||||
@@ -1198,8 +1242,8 @@ fn record_lock_held_release(lock_type: LockType) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
DistributedLock, LOCK_ACQUIRE_ATTEMPT_TIMEOUT, LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF, LockAcquireFailureKind,
|
||||
LockLostSignal, is_remote_lock_rpc_failure, should_warn_lock_failure,
|
||||
DEFERRED_UNLOCK_BACKOFF, DistributedLock, LOCK_ACQUIRE_ATTEMPT_TIMEOUT, LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF,
|
||||
LockAcquireFailureKind, LockLostSignal, UNLOCK_RETRY_ATTEMPTS, is_remote_lock_rpc_failure, should_warn_lock_failure,
|
||||
};
|
||||
use crate::{LockError, LockId, LockInfo, LockRequest, LockResponse, LockStats, LockType, ObjectKey, client::LockClient};
|
||||
use rand::{SeedableRng as _, TryRng, rngs::StdRng};
|
||||
@@ -1692,6 +1736,94 @@ mod tests {
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
/// Fails `release` a fixed number of times before succeeding, mimicking a
|
||||
/// peer whose lock RPCs time out under load and then recover.
|
||||
#[derive(Debug)]
|
||||
struct FlakyReleaseClient {
|
||||
failures_left: AtomicUsize,
|
||||
release_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LockClient for FlakyReleaseClient {
|
||||
async fn acquire_lock(&self, _request: &LockRequest) -> crate::Result<LockResponse> {
|
||||
Ok(LockResponse::failure("unused", Duration::ZERO))
|
||||
}
|
||||
|
||||
async fn release(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
||||
self.release_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.failures_left.load(Ordering::SeqCst) > 0 {
|
||||
self.failures_left.fetch_sub(1, Ordering::SeqCst);
|
||||
return Err(LockError::internal("remote lock rpc timed out: release"));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn refresh(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn force_release(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn check_status(&self, _lock_id: &LockId) -> crate::Result<Option<LockInfo>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_stats(&self) -> crate::Result<LockStats> {
|
||||
Ok(LockStats::default())
|
||||
}
|
||||
|
||||
async fn close(&self) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_online(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn is_local(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn release_entries_keeps_retrying_transient_failures_after_the_fast_loop() {
|
||||
let release_calls = Arc::new(AtomicUsize::new(0));
|
||||
let client: Arc<dyn LockClient> = Arc::new(FlakyReleaseClient {
|
||||
failures_left: AtomicUsize::new(UNLOCK_RETRY_ATTEMPTS + 2),
|
||||
release_calls: release_calls.clone(),
|
||||
});
|
||||
let lock_id = LockId::new_unique(&ObjectKey::new("bucket", "object"));
|
||||
|
||||
DistributedLock::release_entries(vec![(lock_id, client)], "test_deferred_unlock").await;
|
||||
|
||||
assert_eq!(
|
||||
release_calls.load(Ordering::SeqCst),
|
||||
UNLOCK_RETRY_ATTEMPTS + 3,
|
||||
"two deferred attempts fail, the third releases the entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn release_entries_gives_up_after_the_deferred_schedule() {
|
||||
let release_calls = Arc::new(AtomicUsize::new(0));
|
||||
let client: Arc<dyn LockClient> = Arc::new(FlakyReleaseClient {
|
||||
failures_left: AtomicUsize::new(usize::MAX),
|
||||
release_calls: release_calls.clone(),
|
||||
});
|
||||
let lock_id = LockId::new_unique(&ObjectKey::new("bucket", "object"));
|
||||
|
||||
DistributedLock::release_entries(vec![(lock_id, client)], "test_deferred_unlock_abandoned").await;
|
||||
|
||||
assert_eq!(
|
||||
release_calls.load(Ordering::SeqCst),
|
||||
UNLOCK_RETRY_ATTEMPTS + DEFERRED_UNLOCK_BACKOFF.len(),
|
||||
"the retry budget is bounded; the server lease reclaims what remains"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ResponseClient {
|
||||
response: LockResponse,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Lock RPC storm protection
|
||||
|
||||
**Use this when:** a slow lock endpoint turns into cluster-wide `Remote lock RPC timed out`, `Evicting cached remote lock connection`, and `GOAWAY too_many_resets` log floods, or when you tune how the remote lock client reacts to per-request deadlines (rustfs#7363).
|
||||
|
||||
## What the client does on a failed lock RPC
|
||||
|
||||
Every remote lock call (`lock`, `lock_batch`, `release`, `refresh`, `force_release`, `check_status`, and the readiness `ping`) runs under the deadline from `RUSTFS_OBJECT_LOCK_RPC_TIMEOUT_MS` (readiness uses `RUSTFS_HEALTH_LOCK_ONLINE_TIMEOUT_MS`). A deadline only says that one stream was slow; it says nothing about the shared HTTP/2 channel it ran on. The client therefore keeps a small per-peer history and decides per failure:
|
||||
|
||||
| Failure | Verdict | Effect |
|
||||
| --- | --- | --- |
|
||||
| Deadline expired, peer completed any lock RPC within two deadlines | `peer_recently_served` | Channel kept. The peer is slow, not gone. |
|
||||
| Deadline expired, peer quiet for longer than two deadlines | `evict` | Cached channel evicted once, then the next request re-dials. |
|
||||
| Any failure while the last eviction is younger than the cooldown | `cooling_down` | Channel kept so the fresh dial can prove itself; no re-dial burst. |
|
||||
| Transport failure (refused, reset, `GOAWAY`) outside the cooldown | `evict` | Cached channel evicted once. |
|
||||
|
||||
A timed-out request is no longer cancelled. Cancelling sends `RST_STREAM`, and enough resets against a server that is slow to accept streams make it answer `GOAWAY too_many_resets`, which kills every stream on the connection and restarts the loop. Instead the stream is detached: it keeps running in the background (bounded by the internode RPC timeout), the caller still gets its timeout error, and if the peer grants a lock after the caller gave up the client releases it immediately instead of leaving an orphan for the lease to expire.
|
||||
|
||||
Unlocks that fail three quick retries no longer stop there. The background task continues with a deferred schedule (1s, 2s, 4s, 8s, 16s) before it gives up and leaves the entry to the server-side lease.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment variable | Default | Behavior |
|
||||
| --- | ---: | --- |
|
||||
| `RUSTFS_OBJECT_LOCK_RPC_TIMEOUT_MS` | `3000` | Per-request deadline for remote lock RPCs. |
|
||||
| `RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS` | `5000` | Minimum interval between channel evictions per peer. `0` restores eviction on every qualifying failure. |
|
||||
| `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT` | `256` | How many timed-out lock RPCs per peer may keep running in the background. Beyond the budget a timed-out stream is cancelled as before. |
|
||||
|
||||
## Metrics
|
||||
|
||||
| Metric | Labels | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `rustfs_remote_lock_rpc_timeouts_total` | `peer`, `op` | Remote lock RPCs that exceeded their deadline. |
|
||||
| `rustfs_remote_lock_channel_evictions_total` | `peer`, `trigger` | Cached channel evictions; `trigger` is `timeout` or `transport`. |
|
||||
| `rustfs_remote_lock_channel_evictions_suppressed_total` | `peer`, `verdict` | Failures that kept the channel; `verdict` is `peer_recently_served` or `cooling_down`. |
|
||||
| `rustfs_remote_lock_rpc_detached_total` | `op`, `outcome` | Timed-out RPCs left running (`detached`) or cancelled for budget (`aborted`). |
|
||||
| `rustfs_remote_lock_rpc_late_completions_total` | `op`, `outcome` | How detached RPCs ended (`success`, `error`, `join_error`). |
|
||||
| `rustfs_remote_lock_late_releases_total` | `outcome` | Releases of locks granted after their caller timed out (`released`, `partial`, `failed`). |
|
||||
|
||||
## Reading an incident
|
||||
|
||||
A healthy-but-slow endpoint now shows a rising `rustfs_remote_lock_rpc_timeouts_total{peer}` with `evictions_suppressed_total{verdict="peer_recently_served"}` and at most one eviction per cooldown. A dead endpoint shows `evictions_total{trigger="transport"}` once per cooldown while the connection re-dials. Sustained `GOAWAY too_many_resets` in the server log means detached streams are being cancelled, which only happens once `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT` is exhausted; raise the limit or fix the slow lock service (`http_request_inflight_slow` on `NodeService/Lock` names the endpoint).
|
||||
|
||||
The client code lives in `crates/ecstore/src/cluster/rpc/remote_locker.rs`; the deferred unlock schedule lives in `crates/lock/src/distributed_lock.rs`.
|
||||
@@ -33,7 +33,7 @@
|
||||
1|crates/ecstore/src/disk/mod.rs
|
||||
5|crates/ecstore/src/erasure/codec/bridge.rs
|
||||
1|crates/ecstore/src/erasure/coding/decode_reader.rs
|
||||
10|crates/ecstore/src/erasure/coding/encode.rs
|
||||
8|crates/ecstore/src/erasure/coding/encode.rs
|
||||
25|crates/ecstore/src/erasure/coding/erasure.rs
|
||||
3|crates/ecstore/src/layout/disks_layout.rs
|
||||
2|crates/ecstore/src/layout/endpoint.rs
|
||||
|
||||
Reference in New Issue
Block a user