[Bug]: Cascading priority deadlock in Safekeeper via `acquire_term()` returning long-lived RwLock ReadGuard
Description
There is a liveness vulnerability in the Safekeeper's WAL sender path, specifically in the WalResidentTimeline::acquire_term() method (safekeeper/src/timeline.rs).
The acquire_term() function acquires a tokio::sync::RwLockReadGuard on the timeline's shared state and returns it to the caller, who can hold it for an unbounded duration. The primary callers — WalSender::run() and WalReaderStreamState::read() — hold this guard across entire WAL sending operations, which involve network I/O to pageservers and other safekeepers.
Because Tokio's RwLock enforces a strict writer-priority (fair) queue, holding a read lock during slow WAL transmission creates a trap: when any operation needs write_shared_state() (e.g., WAL acceptance via process_msg(), config updates, state transitions), the writer will block waiting for the slow WAL sender to finish, and all subsequent read requests (metrics collection, other WAL senders, state queries) will be permanently queued behind the pending writer.
Code Snippet (acquire_term — returns ReadGuard to caller):
// safekeeper/src/timeline.rs:1058-1069
impl WalResidentTimeline {
/// Ensure that current term is t, erroring otherwise, and lock the state.
pub async fn acquire_term(&self, t: Term) -> Result<ReadGuardSharedState> {
let ss = self.read_shared_state().await; // Acquires RwLock::read()
if ss.sk.state().acceptor_state.term != t {
bail!(
"failed to acquire term {}, current term {}",
t,
ss.sk.state().acceptor_state.term
);
}
Ok(ss) // Returns the ReadGuard to the caller — lifetime uncontrolled!
}
}
// safekeeper/src/timeline.rs:746-748
pub async fn read_shared_state(&self) -> ReadGuardSharedState {
self.mutex.read().await // tokio::sync::RwLock<SharedState>
}Code Snippet (caller — WalSender holds guard across WAL I/O):
The returned ReadGuardSharedState is held by WalSender::run() (send_wal.rs:846) across the entire WAL sending loop, which includes:
- Network I/O to pageserver (potentially slow/unreliable)
- WAL data reads from disk
- Streaming multiple WAL segments
Code Snippet (write side — process_msg needs write lock):
// safekeeper/src/timeline.rs:1110-1136
impl WalResidentTimeline {
pub async fn process_msg(
&self,
msg: &ProposerAcceptorMessage,
) -> Result<Option<AcceptorProposerMessage>> {
let mut shared_state = self.write_shared_state().await; // Needs WRITE lock
// ... WAL acceptance processing
shared_state.sk.safekeeper().process_msg(msg).await?;
}
}
// safekeeper/src/timeline.rs:742-743
pub async fn write_shared_state(self: &Arc<Self>) -> WriteGuardSharedState<'_> {
WriteGuardSharedState::new(self.clone(), self.mutex.write().await)
}The core RwLock (the shared state mutex):
// safekeeper/src/timeline.rs:467
pub struct Timeline {
// ...
/// Safekeeper and other state, that should remain consistent and
/// synchronized with the disk. This is tokio mutex as we write WAL to disk
/// while holding it, ensuring that consensus checks are in order.
mutex: RwLock<SharedState>,
// ...
}Steps to reproduce
Minimal Reproducible Example (Rust):
This MRE demonstrates the exact pattern used in Safekeeper — acquire_term() returns a ReadGuard that the WalSender holds across slow network I/O, while process_msg() needs a write lock for WAL acceptance.
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::time::{sleep, Duration, Instant};
/// Simulates the Safekeeper's shared state locking pattern:
/// - Task A: WalSender::run() holding acquire_term() ReadGuard across WAL send I/O
/// - Task B: process_msg() requesting write lock for WAL acceptance
/// - Task C: get_safekeeper_info() / metrics collection requesting read lock
#[tokio::main]
async fn main() {
let shared_state = Arc::new(RwLock::new("term=5, flush_lsn=0/1234"));
// Task A: WalSender — holds ReadGuard across WAL sending (network I/O)
let state_a = shared_state.clone();
tokio::spawn(async move {
println!("[WalSender] acquire_term() — requesting read lock...");
let guard = state_a.read().await;
println!("[WalSender] Read lock acquired (term verified: {})", *guard);
println!("[WalSender] Sending WAL to pageserver (slow network, 5s)...");
// In production: WAL streaming over TCP to pageserver
// If pageserver is slow to acknowledge, this can take seconds to minutes
sleep(Duration::from_secs(5)).await;
println!("[WalSender] WAL sent. Releasing read lock.");
drop(guard);
});
// Ensure WalSender gets the lock first
sleep(Duration::from_millis(100)).await;
// Task B: WAL acceptance (process_msg) — needs write lock
let state_b = shared_state.clone();
tokio::spawn(async move {
println!("[process_msg] Compute proposing WAL! Requesting write lock...");
let _guard = state_b.write().await;
println!("[process_msg] Write lock acquired. WAL accepted.");
});
// Ensure write request is queued
sleep(Duration::from_millis(100)).await;
// Task C: Metrics / broker info — needs read lock
let state_c = shared_state.clone();
let start_c = Instant::now();
let handle_c = tokio::spawn(async move {
println!("[metrics] Collecting safekeeper info — requesting read lock...");
let _guard = state_c.read().await; // Blocked by pending writer!
println!(
"[metrics] Read lock acquired after {:?} — metrics were stalled!",
start_c.elapsed()
);
});
// Task D spawned CONCURRENTLY with Task C (not sequentially after it)
sleep(Duration::from_millis(50)).await;
// Task D: Another WalSender starting — also blocked
let state_d = shared_state.clone();
let start_d = Instant::now();
let handle_d = tokio::spawn(async move {
println!("[WalSender2] New connection! acquire_term() — requesting read lock...");
let _guard = state_d.read().await; // Also blocked by pending writer!
println!(
"[WalSender2] Read lock acquired after {:?} — new connection stalled!",
start_d.elapsed()
);
});
// Wait for both to complete
handle_c.await.unwrap();
handle_d.await.unwrap();
}Expected Result vs Actual Execution:
Naive Expectation: Since Task A (WalSender) holds a read lock, Task C (metrics) and Task D (new WalSender) should instantly acquire their read locks concurrently.
Actual Execution:
[WalSender] acquire_term() — requesting read lock...
[WalSender] Read lock acquired (term verified: term=5, flush_lsn=0/1234)
[WalSender] Sending WAL to pageserver (slow network, 5s)...
[process_msg] Compute proposing WAL! Requesting write lock...
[metrics] Collecting safekeeper info — requesting read lock...
[WalSender2] New connection! acquire_term() — requesting read lock...
[WalSender] WAL sent. Releasing read lock.
[process_msg] Write lock acquired. WAL accepted.
[WalSender2] Read lock acquired after 4.748686056s — new connection stalled!
[metrics] Read lock acquired after 4.799372098s — metrics were stalled!Explanation: Both metrics collection (Task C) and a new WalSender connection (Task D) are completely blocked by the pending write request from process_msg() (Task B). In the context of Safekeeper:
- A WalSender holds
acquire_term()ReadGuard across WAL sending to a slow/congested pageserver - A compute node proposes new WAL via
process_msg(), which needswrite_shared_state()— it queues behind the WalSender - ALL subsequent operations are blocked: metrics collection, broker info, new WalSender connections, other
process_msgcalls - If the pageserver is experiencing latency (network partition, S3 slowdown), this stall propagates to the entire safekeeper
This is confirmed by tokio 1.49.0 documentation:
"deadlock may occur if a read lock is held by the current task, a write lock attempt is made, and then a subsequent read lock attempt is made by the current task."
Expected result
WAL acceptance (process_msg) should not be blocked by WAL sending operations. Metrics collection and new connections should remain responsive regardless of WAL sender I/O latency.
Actual result
When a WalSender experiences network latency while holding the acquire_term() ReadGuard:
- WAL acceptance from compute is blocked (directly impacts write availability)
- Metrics collection is blocked (monitoring blackout)
- New WalSender connections are blocked (replication stall)
- Broker info updates are blocked (cluster coordination impacted)
Environment
- Neon Safekeeper with active WAL sender connections
tokio::sync::RwLockwrite-preferring (fair) policy (tokio >= 1.x)- Network latency or congestion to pageserver
- Concurrent WAL acceptance from compute nodes
Possible Fix
Consider one of:
- Don't return ReadGuard from
acquire_term(): Instead, read the term, validate it, drop the guard, and return only the term value. Callers that need ongoing term validation should use a different mechanism (e.g.,watchchannel on term changes). - Use a dedicated term validation mechanism: Replace the term-check-under-lock pattern with an
AtomicU64for the term, avoiding the need to hold the shared state lock during WAL sending. - Split the RwLock: Separate the term/consensus state (frequently read, rarely written) from the WAL storage state (frequently written during
process_msg).
Source: neondatabase/neon