Skip to main content

lean_ctx/server/
bounded_lock.rs

1use std::sync::Arc;
2use std::time::Duration;
3use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
4
5const BASE_READ_TIMEOUT: Duration = Duration::from_secs(10);
6const BASE_WRITE_TIMEOUT: Duration = Duration::from_secs(10);
7
8/// Spin interval between `try_*` attempts. Kept short enough for responsiveness
9/// but long enough to avoid busy-spinning on Windows where thread scheduling
10/// quanta are ~15ms.
11const SPIN_INTERVAL: Duration = Duration::from_millis(20);
12
13/// Determines how an operation handles a bounded-lock timeout.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub enum LockFailBehavior {
16    /// Return a [`LockTimeoutError`] when the operation cannot safely continue.
17    ReturnError,
18    /// Return the wrapper's default value (`Ok(None)`) when the operation is optional.
19    #[default]
20    ReturnDefault,
21    /// Wait through one additional timeout window before applying the default value.
22    RetryOnce,
23}
24
25/// Details of a lock acquisition timeout returned by [`LockFailBehavior::ReturnError`].
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct LockTimeoutError {
28    /// Whether the timed-out acquisition was for a read or write guard.
29    pub operation: &'static str,
30    /// Caller-provided description of the operation that needed the lock.
31    pub context: String,
32    /// Timeout applied to the final acquisition attempt.
33    pub timeout: Duration,
34}
35
36impl std::fmt::Display for LockTimeoutError {
37    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        write!(
39            formatter,
40            "bounded_lock: {} timeout ({}ms) for {}",
41            self.operation,
42            self.timeout.as_millis(),
43            self.context
44        )
45    }
46}
47
48impl std::error::Error for LockTimeoutError {}
49
50/// Acquire a read lock via a non-blocking spin loop with an adaptive timeout.
51///
52/// Unlike the previous `Handle::block_on` approach, this never parks a blocking
53/// thread waiting for the async runtime to make progress — eliminating the
54/// stall-under-load anti-pattern on Windows (#1018). The loop yields the thread
55/// between attempts so it does not starve other work on the blocking pool.
56///
57/// Returns `None` on timeout, preserving the historical default behavior.
58pub fn read<T: Send + Sync + 'static>(
59    lock: &Arc<RwLock<T>>,
60    context: &str,
61) -> Option<OwnedRwLockReadGuard<T>> {
62    read_with_behavior(lock, context, LockFailBehavior::default())
63        .ok()
64        .flatten()
65}
66
67/// Acquire a read lock with explicit timeout behavior for this operation.
68pub fn read_with_behavior<T: Send + Sync + 'static>(
69    lock: &Arc<RwLock<T>>,
70    context: &str,
71    behavior: LockFailBehavior,
72) -> Result<Option<OwnedRwLockReadGuard<T>>, LockTimeoutError> {
73    let timeout = crate::core::io_health::adaptive_timeout(BASE_READ_TIMEOUT);
74    acquire_with_behavior(lock, context, "read", timeout, behavior, |lock| {
75        lock.clone().try_read_owned().ok()
76    })
77}
78
79/// Acquire a write lock with explicit timeout behavior for this operation.
80pub fn write_with_behavior<T: Send + Sync + 'static>(
81    lock: &Arc<RwLock<T>>,
82    context: &str,
83    behavior: LockFailBehavior,
84) -> Result<Option<OwnedRwLockWriteGuard<T>>, LockTimeoutError> {
85    let timeout = crate::core::io_health::adaptive_timeout(BASE_WRITE_TIMEOUT);
86    acquire_with_behavior(lock, context, "write", timeout, behavior, |lock| {
87        lock.clone().try_write_owned().ok()
88    })
89}
90
91fn acquire_with_behavior<T, Guard, Acquire>(
92    lock: &Arc<RwLock<T>>,
93    context: &str,
94    operation: &'static str,
95    timeout: Duration,
96    behavior: LockFailBehavior,
97    acquire: Acquire,
98) -> Result<Option<Guard>, LockTimeoutError>
99where
100    Acquire: Fn(&Arc<RwLock<T>>) -> Option<Guard>,
101{
102    let attempts = if behavior == LockFailBehavior::RetryOnce {
103        2
104    } else {
105        1
106    };
107
108    for attempt in 1..=attempts {
109        let deadline = std::time::Instant::now() + timeout;
110
111        loop {
112            if let Some(guard) = acquire(lock) {
113                return Ok(Some(guard));
114            }
115            if std::time::Instant::now() < deadline {
116                std::thread::sleep(SPIN_INTERVAL);
117                continue;
118            }
119
120            crate::core::io_health::record_freeze();
121            if attempt < attempts {
122                tracing::warn!(
123                    "bounded_lock: {operation} timeout ({}ms) for {context}; retrying once",
124                    timeout.as_millis()
125                );
126                break;
127            }
128
129            let error = LockTimeoutError {
130                operation,
131                context: context.to_owned(),
132                timeout,
133            };
134            tracing::warn!("{error}; degrading gracefully");
135            return match behavior {
136                LockFailBehavior::ReturnError => Err(error),
137                LockFailBehavior::ReturnDefault | LockFailBehavior::RetryOnce => Ok(None),
138            };
139        }
140    }
141
142    unreachable!("RetryOnce returns after its second acquisition attempt")
143}
144
145/// Acquire a write lock via a non-blocking spin loop with an adaptive timeout.
146/// See `read()` for design rationale (#1018).
147///
148/// Returns `None` on timeout, preserving the historical default behavior.
149pub fn write<T: Send + Sync + 'static>(
150    lock: &Arc<RwLock<T>>,
151    context: &str,
152) -> Option<OwnedRwLockWriteGuard<T>> {
153    write_with_behavior(lock, context, LockFailBehavior::default())
154        .ok()
155        .flatten()
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use std::sync::atomic::{AtomicUsize, Ordering};
162
163    #[test]
164    fn read_succeeds_on_uncontested_lock() {
165        let lock = Arc::new(RwLock::new(42u32));
166        let guard = read(&lock, "test").expect("uncontested read must succeed");
167        assert_eq!(*guard, 42);
168    }
169
170    #[test]
171    fn write_succeeds_on_uncontested_lock() {
172        let lock = Arc::new(RwLock::new(0u32));
173        let mut guard = write(&lock, "test").expect("uncontested write must succeed");
174        *guard = 7;
175        assert_eq!(*guard, 7);
176    }
177
178    #[test]
179    fn multiple_readers_concurrent() {
180        let lock = Arc::new(RwLock::new(99u32));
181        let g1 = read(&lock, "r1").expect("first reader");
182        let g2 = read(&lock, "r2").expect("second reader");
183        assert_eq!(*g1, 99);
184        assert_eq!(*g2, 99);
185    }
186
187    #[test]
188    fn write_excludes_readers() {
189        let lock = Arc::new(RwLock::new(0u32));
190        let _hold = lock.clone().try_write_owned().unwrap();
191        assert!(lock.clone().try_read_owned().is_err());
192    }
193
194    #[test]
195    fn return_error_reports_lock_timeout() {
196        let lock = Arc::new(RwLock::new(()));
197        let result: Result<Option<()>, _> = acquire_with_behavior(
198            &lock,
199            "required operation",
200            "read",
201            Duration::ZERO,
202            LockFailBehavior::ReturnError,
203            |_| None,
204        );
205
206        let error = result.expect_err("ReturnError must expose the timeout");
207        assert_eq!(error.operation, "read");
208        assert_eq!(error.context, "required operation");
209    }
210
211    #[test]
212    fn return_default_suppresses_lock_timeout() {
213        let lock = Arc::new(RwLock::new(()));
214        let result: Result<Option<()>, _> = acquire_with_behavior(
215            &lock,
216            "optional operation",
217            "read",
218            Duration::ZERO,
219            LockFailBehavior::ReturnDefault,
220            |_| None,
221        );
222
223        assert_eq!(result, Ok(None));
224    }
225
226    #[test]
227    fn retry_once_makes_two_acquisition_attempts() {
228        let lock = Arc::new(RwLock::new(()));
229        let attempts = AtomicUsize::new(0);
230        let result: Result<Option<()>, _> = acquire_with_behavior(
231            &lock,
232            "retryable operation",
233            "read",
234            Duration::ZERO,
235            LockFailBehavior::RetryOnce,
236            |_| {
237                attempts.fetch_add(1, Ordering::Relaxed);
238                None
239            },
240        );
241
242        assert_eq!(result, Ok(None));
243        assert_eq!(attempts.load(Ordering::Relaxed), 2);
244    }
245}