Skip to main content

async_geotiff/
resource.rs

1//! Shared accounting for active library working buffers, separate from caches
2//! and caller-owned completed outputs. Individual reservations stay nonblocking:
3//! waiting while holding other allocations can deadlock a multi-stage pipeline.
4//! Public tile requests use a shared admission gate. On memory pressure the
5//! entire failed attempt unwinds before one exclusive retry, inside the original
6//! request deadline. This drains competing requests without raising the byte cap.
7
8use 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/// Byte allowance shared by clones for active buffers and retained reader metadata.
26///
27/// Reservations remain charged until their last handle is dropped, including
28/// after request cancellation while blocking work is still running. Cache
29/// residency and completed images owned by callers have separate lifetimes and
30/// are not covered by this allowance.
31#[derive(Clone, Debug)]
32pub struct WorkBudget(Arc<State>);
33
34/// Retained by request contexts, including jobs that outlive cancellation.
35#[derive(Debug)]
36pub(crate) enum WorkAdmission {
37    Shared { _permit: OwnedRwLockReadGuard<()> },
38    Exclusive { _permit: OwnedRwLockWriteGuard<()> },
39}
40
41/// Clones retain one reservation, for example across a cancelled blocking job.
42#[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    /// Create an independent allowance with an exact byte limit.
53    /// A zero limit permits only zero-byte reservations; it does not disable checks.
54    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    /// Run only at a public request boundary, never inside a read/warp stage.
64    /// A failed attempt must release all its buffers before waiting for the
65    /// exclusive gate. Blocking jobs retain the shared guard via RequestContext,
66    /// so the retry cannot overlap work left behind by a cancelled child future.
67    /// Tokio's writer-preferring queue prevents new readers starving a retry.
68    ///
69    /// ResourceLimit may also denote an intrinsically oversized plan. One retry
70    /// in isolation distinguishes that from contention without an unbounded loop;
71    /// a second failure is returned unchanged. Other errors are never retried.
72    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        // Do not upgrade a held read lock or keep a failed attempt's context.
92        // Its last blocking job, if any, is allowed to finish before this wait.
93        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    /// All default readers/tilers/pools share this 512 MiB working allowance.
105    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    /// Return the fixed byte limit shared by all clones of this budget.
111    pub fn limit_bytes(&self) -> u64 {
112        self.0.limit
113    }
114    /// Snapshot bytes held by live reservations, including retained reader metadata.
115    pub fn used_bytes(&self) -> u64 {
116        self.0.used.load(Ordering::Relaxed)
117    }
118    /// Snapshot the highest reserved byte total since this budget was created.
119    pub fn peak_bytes(&self) -> u64 {
120        self.0.peak.load(Ordering::Relaxed)
121    }
122
123    /// Reserve bytes immediately without waiting. Cloning the returned handle
124    /// retains the same charge; the final handle's drop releases it.
125    ///
126    /// # Errors
127    /// Returns [`AgError::ResourceLimit`] when the request exceeds the currently
128    /// available allowance or the total cannot be represented. No bytes are
129    /// reserved on failure.
130    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    /// Return this reservation's byte charge, shared rather than multiplied by clones.
159    pub fn bytes(&self) -> u64 {
160        self.0.bytes
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    //! Deterministic tests for request-level memory backpressure.
167    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                                        // Both initial attempts hold 40 bytes. Neither may wait
205                                        // for 40 more while retaining its original allocation.
206                                        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        // A cancelled writer must not prevent future readers proceeding.
342        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}