mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-09 22:59:59 +00:00
ddf197ba57
fix(lock): renew distributed locks via heartbeat; wire server refresh (backlog#899) A held distributed write lock had a fixed 30s TTL and was never renewed, so any operation exceeding it got its per-node lease reclaimed and stolen by a contender, causing split-brain writes. This implements Phase 0 + Phase 1 of the #899 design (Phase 2 abort-on-loss is deferred and tracked in code comments). Phase 0 (server refresh wiring, P0): - node_service handle_refresh was a no-op stub that parsed args then always returned success=true. Extract a testable `refresh_lock` free function that actually delegates to the node's lock backend. Not-found maps to success=false with no error_info, so RemoteClient::refresh keeps its Ok(resp.success) semantics and yields a real not_found signal to the coordinator heartbeat. Without this, client heartbeats were silently no-ops. Phase 1 (client heartbeat + safe interval + observability): - DistributedLockGuard spawns a heartbeat that refreshes every per-client lease on a derived interval; interval is derived without Duration::clamp (entries<=1 or interval>=ttl => no spawn), fixing the sub-second-ttl panic. - Add LockLostSignal: declare the lock lost when not_found exceeds entries.len() - refresh_quorum; RPC errors are not counted (absorbed by the ttl > interval margin). Expose is_lock_lost()/lock_lost() for observers. - disarm(), release(), and Drop now abort the heartbeat before releasing so no refresh races the unlock (refresh only extends, never creates, a lease). - Reclaim path stays behaviorally unchanged but now warns with owner/resource/ lease age and records a metric (#698 scavenger preserved). - Add DEFAULT_LOCK_REFRESH_INTERVAL, LockRequest.refresh_interval (serde default for RPC back-compat) + builder, and lock lifecycle metrics. Open questions adopt the design's documented defaults (marked TODO in code): refresh not-found -> Ok(false)/error_info=None; lost-quorum base entries.len(); DEFAULT_LOCK_REFRESH_INTERVAL=10s. Tests: heartbeat keepalive/quorum-loss/jitter/boundary/disarm (lock crate), server refresh delegation (rustfs), and end-to-end survives-past-ttl plus crashed-owner-reclaim regressions (namespace).
403 lines
13 KiB
Rust
403 lines
13 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
#[cfg(target_os = "linux")]
|
|
use std::fs;
|
|
#[cfg(any(
|
|
target_os = "windows",
|
|
target_os = "macos",
|
|
target_os = "freebsd",
|
|
target_os = "openbsd",
|
|
target_os = "netbsd",
|
|
target_os = "dragonfly"
|
|
))]
|
|
use std::process::Command;
|
|
|
|
static READ_LOCKS_HELD: AtomicU64 = AtomicU64::new(0);
|
|
static WRITE_LOCKS_HELD: AtomicU64 = AtomicU64::new(0);
|
|
/// Monotonic count of expired lock guards forcibly reclaimed by a contender (#899 observability).
|
|
static LOCKS_RECLAIMED: AtomicU64 = AtomicU64::new(0);
|
|
/// Monotonic count of guard heartbeats that observed a refresh-quorum loss (#899 observability).
|
|
static LOCK_REFRESH_QUORUM_LOST: AtomicU64 = AtomicU64::new(0);
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub struct ProcessLockSnapshot {
|
|
pub read_locks_held: u64,
|
|
pub write_locks_held: u64,
|
|
}
|
|
|
|
/// Monotonic lock lifecycle counters (distinct from the held gauges above).
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub struct ProcessLockEventSnapshot {
|
|
pub locks_reclaimed: u64,
|
|
pub lock_refresh_quorum_lost: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub struct ProcessPlatformSnapshot {
|
|
pub io_rchar_bytes: Option<u64>,
|
|
pub io_read_bytes: Option<u64>,
|
|
pub io_wchar_bytes: Option<u64>,
|
|
pub io_write_bytes: Option<u64>,
|
|
pub syscall_read_total: Option<u64>,
|
|
pub syscall_write_total: Option<u64>,
|
|
pub virtual_memory_max_bytes: Option<u64>,
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn record_read_lock_held_acquire() {
|
|
READ_LOCKS_HELD.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn record_write_lock_held_acquire() {
|
|
WRITE_LOCKS_HELD.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn record_read_lock_held_release() {
|
|
let _ = READ_LOCKS_HELD.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| Some(value.saturating_sub(1)));
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn record_write_lock_held_release() {
|
|
let _ = WRITE_LOCKS_HELD.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| Some(value.saturating_sub(1)));
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn record_lock_reclaimed() {
|
|
LOCKS_RECLAIMED.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn record_lock_refresh_quorum_lost() {
|
|
LOCK_REFRESH_QUORUM_LOST.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn snapshot_process_lock_counts() -> ProcessLockSnapshot {
|
|
ProcessLockSnapshot {
|
|
read_locks_held: READ_LOCKS_HELD.load(Ordering::Relaxed),
|
|
write_locks_held: WRITE_LOCKS_HELD.load(Ordering::Relaxed),
|
|
}
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn snapshot_process_lock_events() -> ProcessLockEventSnapshot {
|
|
ProcessLockEventSnapshot {
|
|
locks_reclaimed: LOCKS_RECLAIMED.load(Ordering::Relaxed),
|
|
lock_refresh_quorum_lost: LOCK_REFRESH_QUORUM_LOST.load(Ordering::Relaxed),
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
pub fn snapshot_process_platform_stats() -> ProcessPlatformSnapshot {
|
|
platform::snapshot()
|
|
}
|
|
|
|
#[cfg(any(
|
|
target_os = "windows",
|
|
target_os = "macos",
|
|
target_os = "freebsd",
|
|
target_os = "openbsd",
|
|
target_os = "netbsd",
|
|
target_os = "dragonfly"
|
|
))]
|
|
fn run_command(command: &str, args: &[&str]) -> Option<String> {
|
|
let output = Command::new(command).args(args).output().ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if stdout.is_empty() { None } else { Some(stdout) }
|
|
}
|
|
|
|
#[cfg(any(target_os = "windows", test))]
|
|
fn parse_kv_u64(content: &str, key: &str) -> Option<u64> {
|
|
for line in content.lines() {
|
|
let Some((k, v)) = line.split_once(':') else {
|
|
continue;
|
|
};
|
|
if k.trim().eq_ignore_ascii_case(key) {
|
|
return v.trim().parse::<u64>().ok();
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod platform {
|
|
use super::*;
|
|
|
|
pub(super) fn snapshot() -> ProcessPlatformSnapshot {
|
|
let io = fs::read_to_string("/proc/self/io").ok();
|
|
let status = fs::read_to_string("/proc/self/status").ok();
|
|
let io_stats = io.as_deref().map(parse_proc_self_io).unwrap_or_default();
|
|
|
|
ProcessPlatformSnapshot {
|
|
io_rchar_bytes: io_stats.rchar_bytes,
|
|
io_read_bytes: io_stats.read_bytes,
|
|
io_wchar_bytes: io_stats.wchar_bytes,
|
|
io_write_bytes: io_stats.write_bytes,
|
|
syscall_read_total: io_stats.syscall_read_total,
|
|
syscall_write_total: io_stats.syscall_write_total,
|
|
virtual_memory_max_bytes: status.as_deref().and_then(parse_vm_peak_bytes),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
struct ProcSelfIoStats {
|
|
rchar_bytes: Option<u64>,
|
|
read_bytes: Option<u64>,
|
|
syscall_read_total: Option<u64>,
|
|
syscall_write_total: Option<u64>,
|
|
wchar_bytes: Option<u64>,
|
|
write_bytes: Option<u64>,
|
|
}
|
|
|
|
fn parse_proc_self_io(content: &str) -> ProcSelfIoStats {
|
|
let mut stats = ProcSelfIoStats::default();
|
|
|
|
for line in content.lines() {
|
|
let Some((key, value)) = line.split_once(':') else {
|
|
continue;
|
|
};
|
|
let Ok(value) = value.trim().parse::<u64>() else {
|
|
continue;
|
|
};
|
|
|
|
match key.trim() {
|
|
"rchar" => stats.rchar_bytes = Some(value),
|
|
"read_bytes" => stats.read_bytes = Some(value),
|
|
"wchar" => stats.wchar_bytes = Some(value),
|
|
"write_bytes" => stats.write_bytes = Some(value),
|
|
"syscr" => stats.syscall_read_total = Some(value),
|
|
"syscw" => stats.syscall_write_total = Some(value),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
stats
|
|
}
|
|
|
|
fn parse_vm_peak_bytes(content: &str) -> Option<u64> {
|
|
for line in content.lines() {
|
|
let Some(rest) = line.strip_prefix("VmPeak:") else {
|
|
continue;
|
|
};
|
|
let mut parts = rest.split_whitespace();
|
|
let value = parts.next()?.parse::<u64>().ok()?;
|
|
let unit = parts.next().unwrap_or_default();
|
|
return Some(match unit {
|
|
"kB" | "KB" | "kb" => value.saturating_mul(1024),
|
|
_ => value,
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_vm_peak() {
|
|
let status = "Name:\trustfs\nVmPeak:\t 2048 kB\nVmRSS:\t 1024 kB\n";
|
|
assert_eq!(parse_vm_peak_bytes(status), Some(2048 * 1024));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_proc_self_io_extracts_expected_fields() {
|
|
let stats = parse_proc_self_io(
|
|
"rchar: 11\nwchar: 22\nsyscr: 33\nsyscw: 44\nread_bytes: 55\nwrite_bytes: 66\ncancelled_write_bytes: 77\n",
|
|
);
|
|
|
|
assert_eq!(stats.rchar_bytes, Some(11));
|
|
assert_eq!(stats.wchar_bytes, Some(22));
|
|
assert_eq!(stats.syscall_read_total, Some(33));
|
|
assert_eq!(stats.syscall_write_total, Some(44));
|
|
assert_eq!(stats.read_bytes, Some(55));
|
|
assert_eq!(stats.write_bytes, Some(66));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
mod platform {
|
|
use super::*;
|
|
|
|
pub(super) fn snapshot() -> ProcessPlatformSnapshot {
|
|
let pid = std::process::id();
|
|
let script = format!(
|
|
"Get-CimInstance Win32_Process -Filter \"ProcessId = {pid}\" | \
|
|
Format-List -Property ReadOperationCount,WriteOperationCount,PeakVirtualSize"
|
|
);
|
|
|
|
let output = run_command("powershell", &["-NoProfile", "-Command", &script]);
|
|
ProcessPlatformSnapshot {
|
|
syscall_read_total: output.as_deref().and_then(|v| parse_kv_u64(v, "ReadOperationCount")),
|
|
syscall_write_total: output.as_deref().and_then(|v| parse_kv_u64(v, "WriteOperationCount")),
|
|
virtual_memory_max_bytes: output.as_deref().and_then(|v| parse_kv_u64(v, "PeakVirtualSize")),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod platform {
|
|
use super::*;
|
|
|
|
pub(super) fn snapshot() -> ProcessPlatformSnapshot {
|
|
let pid = std::process::id().to_string();
|
|
let output = run_command("ps", &["-o", "inblock=", "-o", "oublock=", "-o", "vsz=", "-p", &pid]);
|
|
parse_ps_stats(output.as_deref())
|
|
}
|
|
|
|
fn parse_ps_stats(output: Option<&str>) -> ProcessPlatformSnapshot {
|
|
let Some(output) = output else {
|
|
return ProcessPlatformSnapshot::default();
|
|
};
|
|
|
|
let Some(line) = output.lines().find(|line| !line.trim().is_empty()) else {
|
|
return ProcessPlatformSnapshot::default();
|
|
};
|
|
|
|
let mut parts = line.split_whitespace();
|
|
let inblock = parts.next().and_then(|v| v.parse::<u64>().ok());
|
|
let oublock = parts.next().and_then(|v| v.parse::<u64>().ok());
|
|
let vsz_kb = parts.next().and_then(|v| v.parse::<u64>().ok());
|
|
|
|
ProcessPlatformSnapshot {
|
|
syscall_read_total: inblock,
|
|
syscall_write_total: oublock,
|
|
virtual_memory_max_bytes: vsz_kb.map(|v| v.saturating_mul(1024)),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_ps_line() {
|
|
let snapshot = parse_ps_stats(Some("12 34 5678"));
|
|
assert_eq!(snapshot.syscall_read_total, Some(12));
|
|
assert_eq!(snapshot.syscall_write_total, Some(34));
|
|
assert_eq!(snapshot.virtual_memory_max_bytes, Some(5678 * 1024));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))]
|
|
mod platform {
|
|
use super::*;
|
|
|
|
pub(super) fn snapshot() -> ProcessPlatformSnapshot {
|
|
let pid = std::process::id().to_string();
|
|
let output = run_command("ps", &["-o", "inblk=", "-o", "oublk=", "-o", "vsz=", "-p", &pid]);
|
|
parse_ps_stats(output.as_deref())
|
|
}
|
|
|
|
fn parse_ps_stats(output: Option<&str>) -> ProcessPlatformSnapshot {
|
|
let Some(output) = output else {
|
|
return ProcessPlatformSnapshot::default();
|
|
};
|
|
|
|
let Some(line) = output.lines().find(|line| !line.trim().is_empty()) else {
|
|
return ProcessPlatformSnapshot::default();
|
|
};
|
|
|
|
let mut parts = line.split_whitespace();
|
|
let inblock = parts.next().and_then(|v| v.parse::<u64>().ok());
|
|
let oublock = parts.next().and_then(|v| v.parse::<u64>().ok());
|
|
let vsz_kb = parts.next().and_then(|v| v.parse::<u64>().ok());
|
|
|
|
ProcessPlatformSnapshot {
|
|
syscall_read_total: inblock,
|
|
syscall_write_total: oublock,
|
|
virtual_memory_max_bytes: vsz_kb.map(|v| v.saturating_mul(1024)),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(any(
|
|
target_os = "linux",
|
|
target_os = "windows",
|
|
target_os = "macos",
|
|
target_os = "freebsd",
|
|
target_os = "openbsd",
|
|
target_os = "netbsd",
|
|
target_os = "dragonfly"
|
|
)))]
|
|
mod platform {
|
|
use super::*;
|
|
|
|
pub(super) fn snapshot() -> ProcessPlatformSnapshot {
|
|
ProcessPlatformSnapshot::default()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn reset_process_lock_counts() {
|
|
READ_LOCKS_HELD.store(0, Ordering::Relaxed);
|
|
WRITE_LOCKS_HELD.store(0, Ordering::Relaxed);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn held_lock_counters_round_trip() {
|
|
reset_process_lock_counts();
|
|
|
|
record_read_lock_held_acquire();
|
|
record_read_lock_held_acquire();
|
|
record_write_lock_held_acquire();
|
|
|
|
assert_eq!(
|
|
snapshot_process_lock_counts(),
|
|
ProcessLockSnapshot {
|
|
read_locks_held: 2,
|
|
write_locks_held: 1,
|
|
}
|
|
);
|
|
|
|
record_read_lock_held_release();
|
|
record_write_lock_held_release();
|
|
record_write_lock_held_release();
|
|
|
|
assert_eq!(
|
|
snapshot_process_lock_counts(),
|
|
ProcessLockSnapshot {
|
|
read_locks_held: 1,
|
|
write_locks_held: 0,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_kv_u64_round_trip() {
|
|
let content = "syscr: 123\nsyscw: 456\n";
|
|
assert_eq!(parse_kv_u64(content, "syscr"), Some(123));
|
|
assert_eq!(parse_kv_u64(content, "syscw"), Some(456));
|
|
assert_eq!(parse_kv_u64(content, "missing"), None);
|
|
}
|
|
}
|