1use crate::error::{AgError, AgResult};
9use crate::request::RequestContext;
10use std::future::Future;
11use std::sync::{
12 atomic::{AtomicU64, Ordering},
13 Arc, OnceLock,
14};
15use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
16
17#[derive(Debug)]
18struct State {
19 limit: u64,
20 used: AtomicU64,
21 peak: AtomicU64,
22 requests: Arc<RwLock<()>>,
23}
24
25#[derive(Clone, Debug)]
32pub struct WorkBudget(Arc<State>);
33
34#[derive(Debug)]
36pub(crate) enum WorkAdmission {
37 Shared { _permit: OwnedRwLockReadGuard<()> },
38 Exclusive { _permit: OwnedRwLockWriteGuard<()> },
39}
40
41#[derive(Clone, Debug)]
43pub struct WorkReservation(Arc<Lease>);
44
45#[derive(Debug)]
46struct Lease {
47 state: Arc<State>,
48 bytes: u64,
49}
50
51impl WorkBudget {
52 pub fn new(limit_bytes: u64) -> Self {
55 Self(Arc::new(State {
56 limit: limit_bytes,
57 used: AtomicU64::new(0),
58 peak: AtomicU64::new(0),
59 requests: Arc::new(RwLock::new(())),
60 }))
61 }
62
63 pub(crate) async fn run_request<T, F, Fut>(
73 &self,
74 context: &RequestContext,
75 work: F,
76 ) -> AgResult<T>
77 where
78 F: Fn(RequestContext) -> Fut,
79 Fut: Future<Output = AgResult<T>>,
80 {
81 context.check()?;
82 let result = {
83 let permit = self.0.requests.clone().read_owned().await;
84 let attempt = context.with_work_admission(WorkAdmission::Shared { _permit: permit });
85 attempt.check()?;
86 attempt.scope(work(attempt.clone())).await
87 };
88 if !matches!(result, Err(AgError::ResourceLimit(_))) {
89 return result;
90 }
91 context.check()?;
94 tracing::debug!(
95 limit_bytes = self.0.limit,
96 "retrying request after working-memory pressure"
97 );
98 let permit = self.0.requests.clone().write_owned().await;
99 let attempt = context.with_work_admission(WorkAdmission::Exclusive { _permit: permit });
100 attempt.check()?;
101 attempt.scope(work(attempt.clone())).await
102 }
103
104 pub fn global() -> Self {
106 static GLOBAL: OnceLock<WorkBudget> = OnceLock::new();
107 GLOBAL.get_or_init(|| Self::new(512 * 1024 * 1024)).clone()
108 }
109
110 pub fn limit_bytes(&self) -> u64 {
112 self.0.limit
113 }
114 pub fn used_bytes(&self) -> u64 {
116 self.0.used.load(Ordering::Relaxed)
117 }
118 pub fn peak_bytes(&self) -> u64 {
120 self.0.peak.load(Ordering::Relaxed)
121 }
122
123 pub fn try_reserve(&self, bytes: u64) -> AgResult<WorkReservation> {
131 let previous = self
132 .0
133 .used
134 .fetch_update(Ordering::AcqRel, Ordering::Relaxed, |used| {
135 used.checked_add(bytes).filter(|&n| n <= self.0.limit)
136 })
137 .map_err(|_| {
138 AgError::ResourceLimit(format!(
139 "working memory request {bytes} exceeds available budget (limit {})",
140 self.0.limit
141 ))
142 })?;
143 self.0.peak.fetch_max(previous + bytes, Ordering::Relaxed);
144 Ok(WorkReservation(Arc::new(Lease {
145 state: self.0.clone(),
146 bytes,
147 })))
148 }
149}
150
151impl Drop for Lease {
152 fn drop(&mut self) {
153 self.state.used.fetch_sub(self.bytes, Ordering::AcqRel);
154 }
155}
156
157impl WorkReservation {
158 pub fn bytes(&self) -> u64 {
160 self.0.bytes
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
168 use crate::request::{RequestCancellation, RequestLimiter, RequestOptions};
169 use std::sync::atomic::AtomicUsize;
170 use std::time::Duration;
171
172 #[tokio::test]
173 async fn concurrent_multistage_requests_unwind_before_exclusive_retry() {
174 let budget = WorkBudget::new(100);
175 let limiter = RequestLimiter::new(2);
176 let barrier = Arc::new(tokio::sync::Barrier::new(2));
177 let total_attempts = Arc::new(AtomicUsize::new(0));
178 let mut tasks = Vec::new();
179 for _ in 0..2 {
180 let budget = budget.clone();
181 let limiter = limiter.clone();
182 let barrier = barrier.clone();
183 let total_attempts = total_attempts.clone();
184 tasks.push(tokio::spawn(async move {
185 let attempts = AtomicUsize::new(0);
186 limiter
187 .run(
188 Duration::from_secs(2),
189 &RequestOptions::default(),
190 |context| {
191 let budget = &budget;
192 let barrier = &barrier;
193 let total_attempts = &total_attempts;
194 let attempts = &attempts;
195 async move {
196 budget
197 .run_request(&context, |_| async {
198 total_attempts.fetch_add(1, Ordering::Relaxed);
199 let attempt = attempts.fetch_add(1, Ordering::Relaxed);
200 let _source = budget.try_reserve(40)?;
201 if attempt == 0 {
202 barrier.wait().await;
203 }
204 let _render = budget.try_reserve(40)?;
207 tokio::task::yield_now().await;
208 Ok(())
209 })
210 .await
211 }
212 },
213 )
214 .await
215 }));
216 }
217 for task in tasks {
218 task.await.unwrap().unwrap();
219 }
220 assert!((3..=4).contains(&total_attempts.load(Ordering::Relaxed)));
221 assert_eq!(budget.used_bytes(), 0);
222 assert!(budget.peak_bytes() <= 100);
223 assert_eq!(limiter.available_permits(), 2);
224 assert!(budget.0.requests.try_write().is_ok());
225 }
226
227 #[tokio::test]
228 async fn uncontended_requests_remain_parallel() {
229 let budget = WorkBudget::new(100);
230 let barrier = Arc::new(tokio::sync::Barrier::new(2));
231 let limiter = RequestLimiter::new(2);
232 let mut tasks = Vec::new();
233 for _ in 0..2 {
234 let (budget, barrier, limiter) = (budget.clone(), barrier.clone(), limiter.clone());
235 tasks.push(tokio::spawn(async move {
236 limiter
237 .run(
238 Duration::from_secs(2),
239 &RequestOptions::default(),
240 |context| {
241 let budget = &budget;
242 let barrier = &barrier;
243 async move {
244 budget
245 .run_request(&context, |_| async {
246 let _work = budget.try_reserve(40)?;
247 barrier.wait().await;
248 Ok(())
249 })
250 .await
251 }
252 },
253 )
254 .await
255 }));
256 }
257 for task in tasks {
258 task.await.unwrap().unwrap();
259 }
260 assert_eq!(budget.peak_bytes(), 80);
261 assert_eq!(budget.used_bytes(), 0);
262 }
263
264 #[tokio::test]
265 async fn permanent_limits_are_retried_at_most_once_and_other_errors_never_retry() {
266 let budget = WorkBudget::new(100);
267 let limiter = RequestLimiter::new(1);
268 let calls = AtomicUsize::new(0);
269 let result = limiter
270 .run(
271 Duration::from_secs(2),
272 &RequestOptions::default(),
273 |context| {
274 let budget = &budget;
275 let calls = &calls;
276 async move {
277 budget
278 .run_request(&context, |_| async {
279 calls.fetch_add(1, Ordering::Relaxed);
280 budget.try_reserve(101).map(|_| ())
281 })
282 .await
283 }
284 },
285 )
286 .await;
287 assert!(matches!(result, Err(AgError::ResourceLimit(_))));
288 assert_eq!(calls.load(Ordering::Relaxed), 2);
289 assert_eq!(budget.used_bytes(), 0);
290 calls.store(0, Ordering::Relaxed);
291 let result: AgResult<()> = limiter
292 .run(
293 Duration::from_secs(2),
294 &RequestOptions::default(),
295 |context| {
296 let budget = &budget;
297 let calls = &calls;
298 async move {
299 budget
300 .run_request(&context, |_| async {
301 calls.fetch_add(1, Ordering::Relaxed);
302 Err(AgError::Tiff("fixture error".into()))
303 })
304 .await
305 }
306 },
307 )
308 .await;
309 assert!(matches!(result, Err(AgError::Tiff(_))));
310 assert_eq!(calls.load(Ordering::Relaxed), 1);
311 assert_eq!(limiter.available_permits(), 1);
312 }
313
314 #[tokio::test(start_paused = true)]
315 async fn original_deadline_includes_exclusive_queue_wait() {
316 let budget = WorkBudget::new(100);
317 let limiter = RequestLimiter::new(1);
318 let held = budget.0.requests.clone().read_owned().await;
319 let calls = AtomicUsize::new(0);
320 let result: AgResult<()> = limiter
321 .run(
322 Duration::from_secs(1),
323 &RequestOptions::default(),
324 |context| {
325 let budget = &budget;
326 let calls = &calls;
327 async move {
328 budget
329 .run_request(&context, |_| async {
330 calls.fetch_add(1, Ordering::Relaxed);
331 Err(AgError::ResourceLimit("forced contention".into()))
332 })
333 .await
334 }
335 },
336 )
337 .await;
338 assert!(matches!(result, Err(AgError::DeadlineExceeded)));
339 assert_eq!(calls.load(Ordering::Relaxed), 1);
340 assert_eq!(limiter.available_permits(), 1);
341 assert!(budget.0.requests.try_read().is_ok());
343 drop(held);
344 assert!(budget.0.requests.try_write().is_ok());
345 assert_eq!(budget.used_bytes(), 0);
346 }
347
348 #[tokio::test]
349 async fn explicit_cancellation_removes_queued_retry_and_returns_admission() {
350 let budget = WorkBudget::new(100);
351 let limiter = RequestLimiter::new(1);
352 let held = budget.0.requests.clone().read_owned().await;
353 let cancellation = RequestCancellation::default();
354 let (entered, waiting) = tokio::sync::oneshot::channel();
355 let entered = std::sync::Mutex::new(Some(entered));
356 let (worker_budget, worker_limiter, worker_cancel) =
357 (budget.clone(), limiter.clone(), cancellation.clone());
358 let task = tokio::spawn(async move {
359 worker_limiter
360 .run(
361 Duration::from_secs(2),
362 &RequestOptions {
363 cancellation: worker_cancel,
364 timeout: None,
365 },
366 |context| {
367 let worker_budget = &worker_budget;
368 let entered = &entered;
369 async move {
370 worker_budget
371 .run_request(&context, |_| async {
372 entered.lock().unwrap().take().unwrap().send(()).unwrap();
373 Err::<(), _>(AgError::ResourceLimit("forced contention".into()))
374 })
375 .await
376 }
377 },
378 )
379 .await
380 });
381 waiting.await.unwrap();
382 cancellation.cancel();
383 assert!(matches!(task.await.unwrap(), Err(AgError::Cancelled)));
384 assert_eq!(limiter.available_permits(), 1);
385 assert!(budget.0.requests.try_read().is_ok());
386 drop(held);
387 assert!(budget.0.requests.try_write().is_ok());
388 }
389
390 #[tokio::test]
391 async fn cancelled_blocking_job_retains_memory_gate_until_actual_exit() {
392 let budget = WorkBudget::new(100);
393 let limiter = RequestLimiter::new(1);
394 let (entered, waiting) = tokio::sync::oneshot::channel();
395 let (release, released) = std::sync::mpsc::channel();
396 let signals = std::sync::Mutex::new(Some((entered, released)));
397 let (worker_budget, worker_limiter) = (budget.clone(), limiter.clone());
398 let task = tokio::spawn(async move {
399 worker_limiter
400 .run(
401 Duration::from_secs(5),
402 &RequestOptions::default(),
403 |context| {
404 let worker_budget = &worker_budget;
405 let signals = &signals;
406 async move {
407 worker_budget
408 .run_request(&context, |_| async {
409 let request = RequestContext::current()
410 .unwrap()
411 .with_work(worker_budget.try_reserve(70)?);
412 let (entered, released) =
413 signals.lock().unwrap().take().unwrap();
414 tokio::task::spawn_blocking(move || {
415 RequestContext::run_blocking(Some(request), || {
416 entered.send(()).unwrap();
417 released.recv_timeout(Duration::from_secs(3)).unwrap();
418 Ok(())
419 })
420 })
421 .await
422 .unwrap()
423 })
424 .await
425 }
426 },
427 )
428 .await
429 });
430 waiting.await.unwrap();
431 task.abort();
432 let _ = task.await;
433 assert_eq!(budget.used_bytes(), 70);
434 assert_eq!(limiter.available_permits(), 0);
435 assert!(budget.0.requests.try_write().is_err());
436 release.send(()).unwrap();
437 tokio::time::timeout(Duration::from_secs(2), async {
438 while budget.used_bytes() != 0
439 || limiter.available_permits() != 1
440 || budget.0.requests.try_write().is_err()
441 {
442 tokio::task::yield_now().await;
443 }
444 })
445 .await
446 .unwrap();
447 }
448
449 #[test]
450 fn reservations_are_shared_and_released_only_after_last_owner() {
451 let budget = WorkBudget::new(100);
452 let lease = budget.try_reserve(70).unwrap();
453 assert!(budget.clone().try_reserve(31).is_err());
454 let retained = lease.clone();
455 drop(lease);
456 assert_eq!(budget.used_bytes(), 70);
457 drop(retained);
458 assert_eq!(budget.used_bytes(), 0);
459 assert!(budget.try_reserve(100).is_ok());
460 assert_eq!(budget.peak_bytes(), 100);
461 }
462
463 #[test]
464 fn oversized_and_overflowing_requests_fail_without_charging() {
465 let budget = WorkBudget::new(100);
466 assert!(budget.try_reserve(u64::MAX).is_err());
467 assert_eq!(budget.used_bytes(), 0);
468 }
469}