saluki_core/pooling/
elastic.rs

1use std::{
2    collections::VecDeque,
3    future::Future,
4    pin::Pin,
5    sync::{
6        atomic::{
7            AtomicUsize,
8            Ordering::{AcqRel, Acquire, Relaxed},
9        },
10        Arc, Mutex,
11    },
12    task::{Context, Poll},
13    time::Duration,
14};
15
16use pin_project::pin_project;
17use saluki_common::resource_tracking::ResourceGroupToken;
18use tokio::{
19    sync::{futures::OwnedNotified, Notify, OwnedSemaphorePermit, Semaphore, SemaphorePermit},
20    time::sleep,
21};
22use tokio_util::sync::PollSemaphore;
23use tracing::{debug, trace};
24
25use super::{Clearable, ObjectPool, PoolMetrics, Poolable, ReclaimStrategy};
26
27const SHRINKER_SLEEP_DURATION: Duration = Duration::from_secs(1);
28
29/// An elastic object pool.
30///
31/// Pools are configured with a minimum and maximum size, and allocate the minimum number of items up front. When an
32/// item is requested and the pool is empty, but hasn't yet reached its maximum size, it will allocate the item on
33/// demand.
34///
35/// Periodically, a background task will evaluate the utilization of the pool and shrink the pool size in order to
36/// attempt to size it more closely to the recent demand. The frequency of this shrinking, as well as how usage demand
37/// is captured and rolled off, is configurable.
38///
39/// # Missing
40///
41/// - Actual configurability around the shrinking frequency and usage demand roll-off.
42pub struct ElasticObjectPool<T: Poolable> {
43    strategy: Arc<ElasticStrategy<T>>,
44}
45
46impl<T> ElasticObjectPool<T>
47where
48    T: Poolable + 'static,
49    T::Data: Default,
50{
51    /// Creates a new `ElasticObjectPool` with the given minimum and maximum capacity.
52    pub fn with_capacity<S>(pool_name: S, min_capacity: usize, max_capacity: usize) -> (Self, impl Future<Output = ()>)
53    where
54        S: AsRef<str>,
55    {
56        Self::with_builder(pool_name, min_capacity, max_capacity, T::Data::default)
57    }
58}
59
60impl<T> ElasticObjectPool<T>
61where
62    T: Poolable + 'static,
63{
64    /// Creates a new `ElasticObjectPool` with the given minimum and maximum capacity and item builder.
65    ///
66    /// `builder` is called to construct each item.
67    pub fn with_builder<S, B>(
68        pool_name: S, min_capacity: usize, max_capacity: usize, builder: B,
69    ) -> (Self, impl Future<Output = ()>)
70    where
71        S: AsRef<str>,
72        B: Fn() -> T::Data + Send + Sync + 'static,
73    {
74        let strategy = Arc::new(ElasticStrategy::with_builder(
75            pool_name,
76            min_capacity,
77            max_capacity,
78            builder,
79        ));
80        let shrinker = run_background_shrinker(Arc::clone(&strategy));
81
82        (Self { strategy }, shrinker)
83    }
84}
85
86impl<T: Poolable> Clone for ElasticObjectPool<T> {
87    fn clone(&self) -> Self {
88        Self {
89            strategy: self.strategy.clone(),
90        }
91    }
92}
93
94impl<T> ObjectPool for ElasticObjectPool<T>
95where
96    T: Poolable + Send + Unpin + 'static,
97{
98    type Item = T;
99    type AcquireFuture = ElasticAcquireFuture<T>;
100
101    fn acquire(&self) -> Self::AcquireFuture {
102        ElasticStrategy::acquire(&self.strategy)
103    }
104}
105
106struct ElasticStrategy<T: Poolable> {
107    items: Mutex<VecDeque<T::Data>>,
108    builder: Box<dyn Fn() -> T::Data + Send + Sync>,
109    available: Arc<Semaphore>,
110    active_decreased: Arc<Notify>,
111    active: AtomicUsize,
112    on_demand_allocs: AtomicUsize,
113    min_capacity: usize,
114    max_capacity: usize,
115    resource_group: ResourceGroupToken,
116    metrics: PoolMetrics,
117}
118
119impl<T: Poolable> ElasticStrategy<T> {
120    fn with_builder<S, B>(pool_name: S, min_capacity: usize, max_capacity: usize, builder: B) -> Self
121    where
122        S: AsRef<str>,
123        B: Fn() -> T::Data + Send + Sync + 'static,
124    {
125        let builder = Box::new(builder);
126
127        // Allocate enough storage to hold the maximum number of items, but only _build_ the minimum number of items.
128        let mut items = VecDeque::with_capacity(max_capacity);
129        items.extend((0..min_capacity).map(|_| builder()));
130        let available = Arc::new(Semaphore::new(min_capacity));
131
132        let metrics = PoolMetrics::new(pool_name.as_ref());
133        metrics.capacity().set(min_capacity as f64);
134        metrics.created().increment(min_capacity as u64);
135
136        Self {
137            items: Mutex::new(items),
138            builder,
139            available,
140            active_decreased: Arc::new(Notify::new()),
141            active: AtomicUsize::new(min_capacity),
142            on_demand_allocs: AtomicUsize::new(0),
143            min_capacity,
144            max_capacity,
145            resource_group: ResourceGroupToken::current(),
146            metrics,
147        }
148    }
149
150    fn acquire_item(&self, permit: OwnedSemaphorePermit) -> T::Data {
151        permit.forget();
152
153        let data = { self.items.lock().unwrap().pop_back().unwrap() };
154
155        self.metrics.acquired().increment(1);
156        self.metrics.in_use().increment(1.0);
157
158        data
159    }
160}
161
162impl<T> ElasticStrategy<T>
163where
164    T: Poolable,
165    T::Data: Send + 'static,
166{
167    fn acquire(strategy: &Arc<Self>) -> ElasticAcquireFuture<T> {
168        ElasticAcquireFuture::new(Arc::clone(strategy))
169    }
170}
171
172impl<T: Poolable> ReclaimStrategy<T> for ElasticStrategy<T> {
173    fn reclaim(&self, mut data: T::Data) {
174        data.clear();
175
176        self.items.lock().unwrap().push_back(data);
177        self.available.add_permits(1);
178        self.metrics.released().increment(1);
179        self.metrics.in_use().decrement(1.0);
180    }
181}
182
183/// A [`Future`] that acquires an item from an [`ElasticObjectPool`].
184#[pin_project]
185pub struct ElasticAcquireFuture<T: Poolable> {
186    strategy: Option<Arc<ElasticStrategy<T>>>,
187    waiting_slow: bool,
188    semaphore: PollSemaphore,
189    #[pin]
190    active_decreased: OwnedNotified,
191}
192
193impl<T: Poolable> ElasticAcquireFuture<T> {
194    fn new(strategy: Arc<ElasticStrategy<T>>) -> Self {
195        let semaphore = PollSemaphore::new(Arc::clone(&strategy.available));
196        let active_decreased = strategy.active_decreased.clone().notified_owned();
197        Self {
198            strategy: Some(strategy),
199            waiting_slow: false,
200            semaphore,
201            active_decreased,
202        }
203    }
204}
205
206impl<T> Future for ElasticAcquireFuture<T>
207where
208    T: Poolable + 'static,
209{
210    type Output = T;
211
212    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
213        let mut this = self.project();
214        let strategy = this.strategy.take().unwrap();
215
216        loop {
217            // If we're not waiting from previously going down the slow path, we'll try to acquire a permit in a
218            // non-polling fashion, which avoids scheduling a wakeup for this task if we end up trying to allocate an
219            // item on demand.
220            //
221            // If we are waiting from previously going down the slow path, we'll always poll the semaphore to ensure
222            // we don't throw away a permit that was given to us while we were waiting.
223            while !*this.waiting_slow {
224                // Fast path: try to acquire a permit immediately.
225                //
226                // If we get one, we can acquire an item from the pool. Otherwise, we'll attempt to do a burst
227                // allocation if the pool hasn't yet reached its maximum capacity.
228                if let Ok(permit) = strategy.available.clone().try_acquire_owned() {
229                    trace!("Acquired permit on fast path. Acquiring item from pool.");
230
231                    let data = strategy.acquire_item(permit);
232
233                    return Poll::Ready(T::from_data(strategy, data));
234                }
235
236                trace!("No available permits. Attempting to allocate on demand.");
237
238                // If we're at capacity, we can't allocate any more items.
239                let active = strategy.active.load(Acquire);
240                if active == strategy.max_capacity {
241                    trace!("Pool at capacity. Falling back to waiting for next available permit.");
242
243                    *this.waiting_slow = true;
244                    break;
245                }
246
247                // Try to atomically increment `active` which signals that we still have capacity to allocate another
248                // item, and more importantly, that _we_ are authorized to do so.
249                if strategy
250                    .active
251                    .compare_exchange_weak(active, active + 1, AcqRel, Relaxed)
252                    .is_err()
253                {
254                    continue;
255                }
256
257                trace!("Updated active count. Allocating on demand.");
258
259                let new_item = {
260                    let _entered = strategy.resource_group.enter();
261                    (strategy.builder)()
262                };
263
264                strategy.on_demand_allocs.fetch_add(1, Relaxed);
265                strategy.metrics.created().increment(1);
266                strategy.metrics.capacity().increment(1.0);
267                strategy.metrics.in_use().increment(1.0);
268
269                return Poll::Ready(T::from_data(strategy, new_item));
270            }
271
272            trace!("Waiting for next available permit.");
273            match this.semaphore.poll_acquire(cx) {
274                Poll::Ready(Some(permit)) => {
275                    trace!("Acquired permit. Acquiring item from pool.");
276
277                    let data = strategy.acquire_item(permit);
278
279                    return Poll::Ready(T::from_data(strategy, data));
280                }
281                Poll::Ready(None) => {
282                    saluki_antithesis::unreachable!("elastic object pool semaphore closed");
283                    unreachable!("semaphore should never be closed")
284                }
285                Poll::Pending => {
286                    trace!("Permit not yet available. Waiting for next available permit.");
287                }
288            }
289
290            match this.active_decreased.as_mut().poll(cx) {
291                Poll::Ready(()) => {
292                    trace!("Active count decreased. Retrying acquisition.");
293
294                    // Shrinking lowered `active`, so retry the fast/on-demand path where this waiter may claim the
295                    // newly available capacity and allocate a replacement item.
296                    this.active_decreased
297                        .set(strategy.active_decreased.clone().notified_owned());
298                    *this.semaphore = PollSemaphore::new(Arc::clone(&strategy.available));
299                    *this.waiting_slow = false;
300                }
301                Poll::Pending => {
302                    this.strategy.replace(strategy);
303                    return Poll::Pending;
304                }
305            }
306        }
307    }
308}
309
310async fn run_background_shrinker<T: Poolable>(strategy: Arc<ElasticStrategy<T>>) {
311    // The shrinker continuously tracks the "demand" for items in the pool, and attempts to shrink the pool size such
312    // that it stays at the smallest possible size while minimizing the amount of on-demand allocations seen.
313    //
314    // Every time the shrinker runs, it consumes the current value of `fallback_count`, which gives us a delta of the
315    // number of on-demand allocations that have occurred since the last time the shrinker ran, or simply "demand". We
316    // track a rolling average of this demand, and if the average demand is less than N, where N is configurable, then
317    // we consider the pool eligible to shrink.
318    //
319    // We only shrink the pool down to its minimum capacity, even if the average demand is less than N. When the pool is
320    // eligible to shrink and not yet at the minimum capacity, we remove a single item from the pool per iteration.
321
322    // We use an alpha of 0.1, which provides a fairly strong smoothing effect.
323    let mut average_demand = Ewma::new(0.1);
324    let min_capacity = strategy.min_capacity;
325
326    loop {
327        debug!("Shrinker sleeping.");
328        sleep(SHRINKER_SLEEP_DURATION).await;
329
330        // Track the number of available permits, and the number of active items.
331        //
332        // When we have available permits, and our active count is greater than the minimum capacity, we'll take an item
333        // from the pool.
334        let active = strategy.active.load(Relaxed);
335        if active <= min_capacity {
336            debug!("Object pool already at minimum capacity. Nothing to do.");
337            continue;
338        }
339
340        let delta_demand = strategy.on_demand_allocs.swap(0, Relaxed);
341        average_demand.update(delta_demand as f64);
342        if average_demand.value() < 1.0 {
343            debug!(
344                avg_demand = average_demand.value(),
345                active, min_capacity, "Pool qualifies for shrinking. Attempting to remove single item..."
346            );
347
348            try_shrink_one_available_item(&strategy);
349        } else {
350            debug!(
351                avg_demand = average_demand.value(),
352                active, min_capacity, "Pool does not qualify for shrinking."
353            );
354        }
355    }
356}
357
358fn try_shrink_one_available_item<T: Poolable>(strategy: &ElasticStrategy<T>) -> bool {
359    // Only shrink idle pool capacity. Waiting here can let the shrinker consume the next returned item ahead of
360    // application waiters that are already blocked on the same semaphore.
361    let Ok(permit) = strategy.available.try_acquire() else {
362        debug!("Pool qualifies for shrinking, but no idle item is available.");
363        return false;
364    };
365
366    // Keep shrink completion separate so tests can reproduce interleavings after the shrinker takes a permit.
367    shrink_available_item(strategy, permit);
368    true
369}
370
371fn shrink_available_item<T: Poolable>(strategy: &ElasticStrategy<T>, permit: SemaphorePermit<'_>) {
372    // Lock the pool and remove an item, taking care to update the active count while holding the lock.
373    let item = {
374        let item = strategy.items.lock().unwrap().pop_back().unwrap();
375        strategy.active.fetch_sub(1, AcqRel);
376        item
377    };
378
379    // Drop the item itself, and update our metrics.
380    drop(item);
381    strategy.metrics.deleted().increment(1);
382    strategy.metrics.capacity().decrement(1.0);
383
384    // Forget the permit so that we shrink the overall number of permits attached to the semaphore.
385    permit.forget();
386
387    strategy.active_decreased.notify_waiters();
388
389    debug!("Shrinker successfully removed an item from the pool.");
390}
391
392struct Ewma {
393    value: f64,
394    alpha: f64,
395}
396
397impl Ewma {
398    fn new(alpha: f64) -> Self {
399        Self { value: 0.0, alpha }
400    }
401
402    fn update(&mut self, new_value: f64) {
403        self.value = (1.0 - self.alpha) * self.value + self.alpha * new_value;
404    }
405
406    fn value(&self) -> f64 {
407        self.value
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use std::sync::atomic::Ordering::Acquire;
414
415    use tokio_test::{assert_pending, assert_ready, task::spawn};
416
417    use super::{shrink_available_item, try_shrink_one_available_item, ElasticObjectPool};
418    use crate::{pooled, pooling::ObjectPool as _};
419
420    pooled! {
421        struct TestObject {
422            value: u32,
423        }
424
425        clear => |this| this.value = 0
426    }
427
428    impl std::fmt::Debug for TestObject {
429        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430            f.debug_struct("TestObject").finish_non_exhaustive()
431        }
432    }
433
434    #[test]
435    fn basic() {
436        let (pool, _) = ElasticObjectPool::<TestObject>::with_capacity("test", 1, 2);
437        assert_eq!(pool.strategy.available.available_permits(), 1);
438
439        let mut acquire = spawn(pool.acquire());
440        let item = assert_ready!(acquire.poll());
441        assert_eq!(pool.strategy.available.available_permits(), 0);
442
443        drop(item);
444        assert_eq!(pool.strategy.available.available_permits(), 1);
445    }
446
447    #[test]
448    fn burst_allocation() {
449        let (pool, _) = ElasticObjectPool::<TestObject>::with_capacity("test", 1, 2);
450        assert_eq!(pool.strategy.available.available_permits(), 1);
451
452        // Acquire the first item, which should already exist.
453        let mut first_acquire = spawn(pool.acquire());
454        let first_item = assert_ready!(first_acquire.poll());
455        assert_eq!(pool.strategy.available.available_permits(), 0);
456
457        // Acquire a second item, which should be allocated on demand since we haven't reached our maximum capacity yet.
458        let mut second_acquire = spawn(pool.acquire());
459        let second_item = assert_ready!(second_acquire.poll());
460        assert_eq!(pool.strategy.available.available_permits(), 0);
461
462        // Try to acquire a third item, which should block because our pool has reached its maximum capacity.
463        let mut third_acquire = spawn(pool.acquire());
464        assert_pending!(third_acquire.poll());
465        assert!(!third_acquire.is_woken());
466
467        // Drop the items to return them to the pool and observe that at least one makes it back, but the semaphore will
468        // divert the returned permit for the first item to the pending third acquire.
469        drop(first_item);
470        assert_eq!(pool.strategy.available.available_permits(), 0);
471        drop(second_item);
472        assert_eq!(pool.strategy.available.available_permits(), 1);
473
474        // Now our third acquire should have been notified and we should be able to acquire an item.
475        assert!(third_acquire.is_woken());
476        let third_item = assert_ready!(third_acquire.poll());
477        assert_eq!(pool.strategy.available.available_permits(), 1);
478
479        drop(third_item);
480        assert_eq!(pool.strategy.available.available_permits(), 2);
481    }
482
483    #[test]
484    fn shrinker_does_not_wait_for_returned_items() {
485        let (pool, _) = ElasticObjectPool::<TestObject>::with_capacity("test", 1, 2);
486
487        let mut first_acquire = spawn(pool.acquire());
488        let first_item = assert_ready!(first_acquire.poll());
489
490        let mut second_acquire = spawn(pool.acquire());
491        let second_item = assert_ready!(second_acquire.poll());
492
493        let mut third_acquire = spawn(pool.acquire());
494        assert_pending!(third_acquire.poll());
495        assert!(!third_acquire.is_woken());
496
497        assert!(!try_shrink_one_available_item(&pool.strategy));
498
499        drop(first_item);
500        assert!(third_acquire.is_woken());
501
502        let third_item = assert_ready!(third_acquire.poll());
503        assert_eq!(pool.strategy.active.load(Acquire), 2);
504
505        drop(second_item);
506        drop(third_item);
507    }
508
509    #[test]
510    fn slow_waiter_retries_allocation_when_shrink_reduces_active() {
511        let (pool, _) = ElasticObjectPool::<TestObject>::with_capacity("test", 1, 2);
512
513        // Fill the pool to its maximum size: active = 2, permits = 0.
514        let mut first_acquire = spawn(pool.acquire());
515        let first_item = assert_ready!(first_acquire.poll());
516
517        let mut second_acquire = spawn(pool.acquire());
518        let second_item = assert_ready!(second_acquire.poll());
519
520        // Return one item while the pool is still at max capacity: active = 2, permits = 1.
521        drop(second_item);
522        assert_eq!(pool.strategy.active.load(Acquire), 2);
523        assert_eq!(pool.strategy.available.available_permits(), 1);
524
525        // Simulate the shrinker winning the race to the idle permit before a new acquire starts waiting:
526        // active = 2, permits = 0, shrinker holds the permit.
527        let permit = pool
528            .strategy
529            .available
530            .try_acquire()
531            .expect("returned item should leave one idle permit for the shrinker");
532        assert_eq!(pool.strategy.available.available_permits(), 0);
533
534        // The new acquire sees active == max_capacity and permits = 0, so it waits on the semaphore.
535        let mut third_acquire = spawn(pool.acquire());
536        assert_pending!(third_acquire.poll());
537        assert!(!third_acquire.is_woken());
538
539        // Shrinking removes the idle item and lowers active: active = 1, permits = 0.
540        shrink_available_item(&pool.strategy, permit);
541        assert_eq!(pool.strategy.active.load(Acquire), 1);
542        assert_eq!(pool.strategy.available.available_permits(), 0);
543
544        // The waiter must be woken by the active count change, otherwise it remains asleep even though
545        // active < max_capacity means it could allocate a replacement item.
546        assert!(
547            third_acquire.is_woken(),
548            "slow-path waiters must wake when shrinking creates on-demand allocation capacity"
549        );
550
551        let third_item = assert_ready!(third_acquire.poll());
552        assert_eq!(pool.strategy.active.load(Acquire), 2);
553
554        drop(first_item);
555        drop(third_item);
556    }
557
558    #[tokio::test(start_paused = true)]
559    async fn background_shrinker_shrinks_idle_pool_to_min_capacity() {
560        use super::SHRINKER_SLEEP_DURATION;
561
562        // min=1, max=3: one item is preallocated, and up to two more can be burst-allocated on demand.
563        let (pool, shrinker) = ElasticObjectPool::<TestObject>::with_capacity("test", 1, 3);
564
565        // Burst all the way to max capacity, then return every item so the pool is fully idle. The two
566        // burst allocations register as recent "demand" that the shrinker's EWMA has to decay past
567        // before it will start reclaiming items.
568        let mut items = Vec::new();
569        for _ in 0..3 {
570            let mut acquire = spawn(pool.acquire());
571            items.push(assert_ready!(acquire.poll()));
572        }
573        assert_eq!(pool.strategy.active.load(Acquire), 3);
574        drop(items);
575        assert_eq!(pool.strategy.available.available_permits(), 3);
576
577        // Drive the real background shrinker one loop iteration per poll under paused time.
578        let mut shrinker = spawn(shrinker);
579        assert_pending!(shrinker.poll()); // first iteration parks on the initial sleep
580
581        // With no further on-demand demand, the smoothed demand stays below the shrink threshold, so the
582        // shrinker removes exactly one idle item per iteration.
583        tokio::time::advance(SHRINKER_SLEEP_DURATION).await;
584        assert_pending!(shrinker.poll());
585        assert_eq!(pool.strategy.active.load(Acquire), 2);
586        assert_eq!(pool.strategy.available.available_permits(), 2);
587
588        tokio::time::advance(SHRINKER_SLEEP_DURATION).await;
589        assert_pending!(shrinker.poll());
590        assert_eq!(pool.strategy.active.load(Acquire), 1);
591        assert_eq!(pool.strategy.available.available_permits(), 1);
592
593        // Once the pool is back at its minimum capacity, further shrinker iterations leave it untouched:
594        // the pool is documented to only ever shrink down to (never below) its minimum capacity.
595        tokio::time::advance(SHRINKER_SLEEP_DURATION).await;
596        assert_pending!(shrinker.poll());
597        assert_eq!(
598            pool.strategy.active.load(Acquire),
599            1,
600            "the shrinker must never shrink a pool below its minimum capacity"
601        );
602        assert_eq!(pool.strategy.available.available_permits(), 1);
603    }
604}