Skip to main content

async_geotiff/
request.rs

1//! Request admission, deadline, and cooperative cancellation.
2//!
3//! Admission rejects excess requests immediately instead of building an unbounded
4//! queue. Clones share one limit. Dropping a request cancels its later stages;
5//! an already-running blocking job retains its admission until it actually ends.
6//!
7//! Public tile and warp calls default to a 30-second deadline. Configure defaults
8//! through `TilerConfig` / `MosaicConfig`, or pass `RequestOptions` to a
9//! `*_with_options` call. Cancelling its handle, timing out, or dropping that
10//! future cancels later stages. Synchronous kernels are cooperative at stage
11//! boundaries, so cancellation does not preempt a running codec or warp kernel.
12//! CPU, admission and working-byte permits remain held until that kernel ends.
13//! Returned images/bytes belong to the caller and are outside active-work limits.
14
15use crate::error::{AgError, AgResult};
16use std::future::Future;
17use std::sync::{Arc, OnceLock};
18use std::time::Duration;
19use tokio::sync::{watch, OwnedSemaphorePermit, Semaphore};
20use tokio::time::Instant;
21
22/// Default total tiler request deadline, including I/O and queued CPU work: 30 seconds.
23pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
24/// Capacity of the process-wide admission pool used by default tilers and mosaics.
25pub const DEFAULT_MAX_IN_FLIGHT_REQUESTS: usize = 64;
26
27tokio::task_local! {
28    // Synchronous jobs explicitly clone this before leaving the request task.
29    // Direct reader calls outside a tile request have no request admission.
30    static CURRENT_REQUEST: RequestContext;
31}
32
33/// Shared admission limit. Defaults share a process-wide pool of 64 requests.
34///
35/// Clones share the same pool. Admission rejects excess requests immediately
36/// with [`AgError::Overloaded`]. A cancelled request's running blocking work
37/// retains its permit until that work exits.
38#[derive(Clone, Debug)]
39pub struct RequestLimiter(Arc<Semaphore>);
40
41impl RequestLimiter {
42    /// Create an independent pool, clamping capacity to `1..=Semaphore::MAX_PERMITS`.
43    /// Use [`Default::default`] to join the process-wide pool instead.
44    pub fn new(max_in_flight: usize) -> Self {
45        Self(Arc::new(Semaphore::new(
46            max_in_flight.clamp(1, Semaphore::MAX_PERMITS),
47        )))
48    }
49
50    /// Snapshot the currently unoccupied capacity, shared by all clones.
51    pub fn available_permits(&self) -> usize {
52        self.0.available_permits()
53    }
54
55    pub(crate) async fn run<T, F, Fut>(
56        &self,
57        timeout: Duration,
58        options: &RequestOptions,
59        work: F,
60    ) -> AgResult<T>
61    where
62        F: FnOnce(RequestContext) -> Fut,
63        Fut: Future<Output = AgResult<T>>,
64    {
65        if options.cancellation.is_cancelled() {
66            return Err(AgError::Cancelled);
67        }
68        let permit = self
69            .0
70            .clone()
71            .try_acquire_owned()
72            .map_err(|_| AgError::Overloaded)?;
73        let deadline = Instant::now()
74            .checked_add(options.timeout.unwrap_or(timeout))
75            .ok_or_else(|| AgError::query("timeout", "duration exceeds clock range"))?;
76        let context = RequestContext {
77            _admission: Arc::new(permit),
78            cancellation: RequestCancellation::default(),
79            deadline,
80            work: Vec::new(),
81            work_admission: None,
82        };
83        let _guard = CancelOnDrop(context.cancellation.clone());
84        context.check()?;
85        tokio::select! {
86            biased;
87            _ = options.cancellation.cancelled() => Err(AgError::Cancelled),
88            result = tokio::time::timeout_at(
89                deadline,
90                CURRENT_REQUEST.scope(context.clone(), work(context.clone())),
91            ) => {
92                result.map_err(|_| AgError::DeadlineExceeded)?
93            }
94        }
95    }
96}
97
98impl Default for RequestLimiter {
99    fn default() -> Self {
100        static SHARED: OnceLock<RequestLimiter> = OnceLock::new();
101        SHARED
102            .get_or_init(|| Self::new(DEFAULT_MAX_IN_FLIGHT_REQUESTS))
103            .clone()
104    }
105}
106
107/// Caller-controlled, one-shot cancellation handle shared by its clones.
108///
109/// The default is a fresh, uncancelled handle. Cancellation stops waiting for
110/// the request and prevents queued work from starting, but cannot interrupt a
111/// synchronous kernel already running; its resources remain held until it exits.
112#[derive(Clone, Debug)]
113pub struct RequestCancellation(watch::Sender<bool>);
114
115impl Default for RequestCancellation {
116    fn default() -> Self {
117        Self(watch::channel(false).0)
118    }
119}
120
121impl RequestCancellation {
122    /// Permanently cancel this handle and every clone. Repeated calls are harmless.
123    pub fn cancel(&self) {
124        self.0.send_replace(true);
125    }
126    /// Return whether this handle or any clone has been cancelled.
127    pub fn is_cancelled(&self) -> bool {
128        *self.0.borrow()
129    }
130    async fn cancelled(&self) {
131        let mut receiver = self.0.subscribe();
132        let _ = receiver.wait_for(|cancelled| *cancelled).await;
133    }
134}
135
136/// Per-call overrides. `timeout: None` uses the tiler configuration.
137#[derive(Clone, Debug, Default)]
138pub struct RequestOptions {
139    /// Override the total request deadline, including I/O, CPU waits and retries.
140    /// `None` uses the tiler's timeout (30 seconds by default); zero expires
141    /// immediately. Durations outside the clock's range return a query error.
142    pub timeout: Option<Duration>,
143    /// Shared cancellation for this call. The default is a fresh uncancelled
144    /// handle; cloning these options links cancellation across those calls.
145    pub cancellation: RequestCancellation,
146}
147
148#[derive(Clone)]
149pub(crate) struct RequestContext {
150    _admission: Arc<OwnedSemaphorePermit>,
151    cancellation: RequestCancellation,
152    deadline: Instant,
153    work: Vec<crate::resource::WorkReservation>,
154    work_admission: Option<Arc<crate::resource::WorkAdmission>>,
155}
156
157impl RequestContext {
158    pub(crate) fn current() -> Option<Self> {
159        CURRENT_REQUEST.try_with(Clone::clone).ok()
160    }
161
162    /// Execute synchronous work while retaining its originating request.
163    /// Capture `current()` before spawning: task locals do not follow spawned
164    /// tasks or blocking threads. A cancelled queued job must not start work.
165    pub(crate) fn run_blocking<T>(
166        request: Option<Self>,
167        work: impl FnOnce() -> AgResult<T>,
168    ) -> AgResult<T> {
169        if let Some(request) = &request {
170            request.check()?;
171        }
172        let result = work();
173        if let Some(request) = &request {
174            request.check()?;
175        }
176        result
177    }
178
179    pub(crate) fn with_work_admission(&self, admission: crate::resource::WorkAdmission) -> Self {
180        let mut next = self.clone();
181        next.work_admission = Some(Arc::new(admission));
182        next
183    }
184
185    /// Readers capture the scoped context before spawning decode/stitch jobs.
186    pub(crate) async fn scope<F: Future>(&self, work: F) -> F::Output {
187        CURRENT_REQUEST.scope(self.clone(), work).await
188    }
189
190    pub(crate) fn with_work(&self, reservation: crate::resource::WorkReservation) -> Self {
191        let mut next = self.clone();
192        next.work.push(reservation);
193        next
194    }
195
196    pub(crate) fn check(&self) -> AgResult<()> {
197        if self.cancellation.is_cancelled() {
198            return Err(AgError::Cancelled);
199        }
200        if Instant::now() >= self.deadline {
201            return Err(AgError::DeadlineExceeded);
202        }
203        Ok(())
204    }
205}
206
207struct CancelOnDrop(RequestCancellation);
208impl Drop for CancelOnDrop {
209    fn drop(&mut self) {
210        self.0.cancel();
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use std::time::Duration;
218
219    #[tokio::test]
220    async fn cancelled_queued_job_does_not_run_its_work() {
221        let limiter = RequestLimiter::new(1);
222        let worker_limiter = limiter.clone();
223        let (captured_tx, captured_rx) = tokio::sync::oneshot::channel();
224        let task = tokio::spawn(async move {
225            worker_limiter
226                .run(
227                    Duration::from_secs(30),
228                    &RequestOptions::default(),
229                    |_| async {
230                        captured_tx.send(RequestContext::current()).ok().unwrap();
231                        std::future::pending::<AgResult<()>>().await
232                    },
233                )
234                .await
235        });
236        let request = captured_rx.await.unwrap();
237        assert!(request.is_some());
238        task.abort();
239        let _ = task.await;
240        assert_eq!(limiter.available_permits(), 0);
241        let result: AgResult<()> = RequestContext::run_blocking(request, || {
242            panic!("work queued by a cancelled request must not start")
243        });
244        assert!(matches!(result, Err(AgError::Cancelled)));
245        assert_eq!(limiter.available_permits(), 1);
246    }
247
248    #[tokio::test(start_paused = true)]
249    async fn expired_decode_owner_does_not_fail_a_healthy_waiter() {
250        use crate::{
251            io::{CogReader, ReaderConfig},
252            CpuLimiter,
253        };
254        use object_store::{memory::InMemory, path::Path, ObjectStoreExt};
255
256        let store = Arc::new(InMemory::new());
257        store
258            .put(
259                &Path::from("fixture"),
260                bytes::Bytes::from_static(include_bytes!("../tests/fixtures/rgb_nodata.tif"))
261                    .into(),
262            )
263            .await
264            .unwrap();
265        let cpu = CpuLimiter::new(1);
266        let config = ReaderConfig {
267            cpu_limiter: Some(cpu.clone()),
268            ..ReaderConfig::default()
269        };
270        let cache = config.block_cache.clone();
271        let reader = Arc::new(
272            CogReader::builder()
273                .reader_config(config)
274                .open_object_store(store, "fixture")
275                .await
276                .unwrap(),
277        );
278        let limiter = RequestLimiter::new(1);
279        let request = RequestContext {
280            _admission: Arc::new(limiter.0.clone().try_acquire_owned().unwrap()),
281            cancellation: RequestCancellation::default(),
282            deadline: Instant::now() + Duration::from_secs(1),
283            work: Vec::new(),
284            work_admission: None,
285        };
286        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
287        let (release_tx, release_rx) = std::sync::mpsc::channel();
288        // Occupy the CPU permit, so the owner claims the block before its
289        // deadline but cannot decode until after it. Keep the owner future
290        // alive to exercise the race before its outer timeout gets polled.
291        let permit = cpu.acquire().await.unwrap();
292        let blocker = tokio::task::spawn_blocking(move || {
293            let _permit = permit;
294            started_tx.send(()).unwrap();
295            let _ = release_rx.recv_timeout(Duration::from_secs(5));
296        });
297        started_rx.await.unwrap();
298        let owner_reader = reader.clone();
299        let owner = tokio::spawn(CURRENT_REQUEST.scope(request, async move {
300            owner_reader.read_window(0, 0, 0, 1, 1).await
301        }));
302        while cache.stats().fetched == 0 {
303            tokio::task::yield_now().await;
304        }
305        let waiter = tokio::spawn(async move { reader.read_window(0, 0, 0, 1, 1).await });
306        while cache.stats().misses < 2 {
307            tokio::task::yield_now().await;
308        }
309        tokio::time::advance(Duration::from_secs(2)).await;
310        release_tx.send(()).unwrap();
311        blocker.await.unwrap();
312        assert!(matches!(
313            owner.await.unwrap(),
314            Err(AgError::DeadlineExceeded)
315        ));
316        let image = waiter
317            .await
318            .unwrap()
319            .expect("healthy waiter must retry the expired owner's claim");
320        assert_eq!(image.pixels.to_f64()[[0, 0, 0]], 1.0);
321        assert_eq!(limiter.available_permits(), 1);
322    }
323
324    #[tokio::test]
325    async fn cancelled_http_initialization_retains_request_admission() {
326        let limiter = RequestLimiter::new(1);
327        let worker_limiter = limiter.clone();
328        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
329        let (release_tx, release_rx) = std::sync::mpsc::channel();
330        let task = tokio::spawn(async move {
331            worker_limiter
332                .run(Duration::from_secs(30), &RequestOptions::default(), |_| {
333                    crate::io::cog::build_http_transport(move || {
334                        started_tx.send(()).unwrap();
335                        let _ = release_rx.recv_timeout(Duration::from_secs(5));
336                        Ok(())
337                    })
338                })
339                .await
340        });
341        started_rx.await.unwrap();
342        task.abort();
343        let _ = task.await;
344        let available_while_running = limiter.available_permits();
345        release_tx.send(()).unwrap();
346        tokio::time::timeout(Duration::from_secs(2), async {
347            while limiter.available_permits() != 1 {
348                tokio::task::yield_now().await;
349            }
350        })
351        .await
352        .unwrap();
353        assert_eq!(available_while_running, 0);
354    }
355
356    #[tokio::test(start_paused = true)]
357    async fn stalled_read_is_bounded_by_request_deadline() {
358        let limiter = RequestLimiter::new(1);
359        let result: AgResult<()> = limiter
360            .run(Duration::from_secs(30), &RequestOptions::default(), |_| {
361                std::future::pending()
362            })
363            .await;
364        assert!(matches!(result, Err(AgError::DeadlineExceeded)));
365        assert_eq!(limiter.available_permits(), 1);
366    }
367
368    #[tokio::test]
369    async fn admission_rejects_excess_without_starting_work() {
370        let limiter = RequestLimiter::new(1);
371        let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
372        let first_limiter = limiter.clone();
373        let first = tokio::spawn(async move {
374            first_limiter
375                .run(
376                    Duration::from_secs(30),
377                    &RequestOptions::default(),
378                    |_| async {
379                        entered_tx.send(()).unwrap();
380                        std::future::pending::<AgResult<()>>().await
381                    },
382                )
383                .await
384        });
385        entered_rx.await.unwrap();
386        let result: AgResult<()> = limiter
387            .run(
388                Duration::from_secs(30),
389                &RequestOptions::default(),
390                |_| async { panic!("overloaded request must not start") },
391            )
392            .await;
393        assert!(matches!(result, Err(AgError::Overloaded)));
394        first.abort();
395        let _ = first.await;
396        assert_eq!(limiter.available_permits(), 1);
397    }
398
399    #[tokio::test]
400    async fn cancellation_keeps_admission_until_blocking_job_stops() {
401        let limiter = RequestLimiter::new(1);
402        let first_limiter = limiter.clone();
403        let budget = crate::resource::WorkBudget::new(100);
404        let task_budget = budget.clone();
405        let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
406        let (release_tx, release_rx) = std::sync::mpsc::channel();
407        let first = tokio::spawn(async move {
408            first_limiter
409                .run(
410                    Duration::from_secs(30),
411                    &RequestOptions::default(),
412                    |context| async move {
413                        let context = context.with_work(task_budget.try_reserve(70).unwrap());
414                        tokio::task::spawn_blocking(move || {
415                            entered_tx.send(()).unwrap();
416                            release_rx.recv().unwrap();
417                            assert!(matches!(context.check(), Err(AgError::Cancelled)));
418                        })
419                        .await
420                        .unwrap();
421                        Ok(())
422                    },
423                )
424                .await
425        });
426        entered_rx.await.unwrap();
427        first.abort();
428        let _ = first.await;
429        assert_eq!(limiter.available_permits(), 0);
430        assert_eq!(budget.used_bytes(), 70);
431        release_tx.send(()).unwrap();
432        tokio::time::timeout(Duration::from_secs(2), async {
433            while limiter.available_permits() != 1 || budget.used_bytes() != 0 {
434                tokio::task::yield_now().await;
435            }
436        })
437        .await
438        .expect("blocking admission and bytes must be released");
439    }
440
441    #[tokio::test]
442    async fn explicit_cancellation_stops_stalled_request() {
443        let limiter = RequestLimiter::new(1);
444        let options = RequestOptions::default();
445        options.cancellation.cancel();
446        let result: AgResult<()> = limiter
447            .run(Duration::from_secs(30), &options, |_| {
448                std::future::pending()
449            })
450            .await;
451        assert!(matches!(result, Err(AgError::Cancelled)));
452    }
453}