Skip to main content

reddb_server/storage/cache/
promotion_pool.rs

1//! Async promotion pool — turbo module for off-CPU L1 cache promotion.
2//!
3//! # Why this module exists
4//!
5//! `BlobCache::get` is on the read hot path. Today, an L2 hit synchronously
6//! promotes the blob to L1 (mutates `RwLock`s, runs admission policy, fires
7//! eviction loops). That promotion work — bookkeeping, not the actual byte
8//! transfer — adds tens of microseconds to every L2 hit.
9//!
10//! `AsyncPromotionPool` decouples the two: `get` decides "this should go to
11//! L1" and hands the request to a bounded queue, then returns the bytes to
12//! the caller immediately. A small worker pool drains the queue and performs
13//! the promotion off the read path.
14//!
15//! Inspired by Postgres's `bgwriter` and Linux's `kswapd`: the slow,
16//! housekeeping work belongs on a dedicated thread, not in the hot path.
17//!
18//! # Design
19//!
20//! - **Bounded queue** (`crossbeam::queue::ArrayQueue`) — back-pressure
21//!   without unbounded memory growth.
22//! - **Drop-oldest on saturation** — when the queue is full we evict the
23//!   oldest pending request and admit the new one. Rationale: under load
24//!   the freshest accesses are the most likely to be re-read soon, so a
25//!   FIFO drop loses the least value.
26//! - **Decoupled executor** — the closure that performs the actual
27//!   promotion is injected at construction (`new_with_executor`). The pool
28//!   knows nothing about `BlobCache` and is therefore unit-testable in
29//!   isolation.
30//! - **Atomic metrics** — counters are `Relaxed`-incremented by hot paths;
31//!   `metrics()` returns a consistent snapshot.
32//! - **Graceful shutdown** — `shutdown` flips a flag, lets workers drain a
33//!   bounded number of remaining requests, then they exit.
34//!
35//! # Wiring (deferred)
36//!
37//! This file is purely additive. Wiring into `BlobCache::get` and the
38//! `pub mod promotion_pool;` registration in `mod.rs` happen in a
39//! sequential follow-up, after all three turbo modules from issue #193
40//! have shipped.
41
42use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
43use std::sync::Arc;
44use std::time::Duration;
45
46use crossbeam_queue::ArrayQueue;
47
48use super::blob::BlobCachePolicy;
49
50// ---------------------------------------------------------------------------
51// Public surface
52// ---------------------------------------------------------------------------
53
54/// Configuration for the promotion pool.
55#[derive(Debug, Clone, Copy)]
56pub struct PoolOpts {
57    /// Maximum number of pending promotion requests. When full, the pool
58    /// drops the oldest entry to admit the newest (see `ScheduleOutcome`).
59    pub queue_capacity: usize,
60    /// Number of tokio worker tasks draining the queue.
61    pub worker_count: usize,
62}
63
64impl Default for PoolOpts {
65    fn default() -> Self {
66        Self {
67            queue_capacity: 1024,
68            worker_count: 2,
69        }
70    }
71}
72
73/// A single async promotion request handed to the pool by `BlobCache::get`
74/// (or, in tests, by the test harness).
75///
76/// `bytes` is `Arc<[u8]>` so that the same buffer the caller is returning
77/// to the user is shared zero-copy with the L1 promotion.
78#[derive(Debug, Clone)]
79pub struct PromotionRequest {
80    pub namespace: String,
81    pub key: String,
82    pub bytes: Arc<[u8]>,
83    pub policy: BlobCachePolicy,
84}
85
86/// Result of `AsyncPromotionPool::schedule`.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum ScheduleOutcome {
89    /// Request accepted into the queue.
90    Queued,
91    /// Queue was full. `evicted_oldest = true` means we dropped the oldest
92    /// pending request to admit this one. `evicted_oldest = false` means the
93    /// retry could not enqueue *this* request, or the pool was shut down.
94    DroppedQueueFull { evicted_oldest: bool },
95}
96
97/// Snapshot of the pool's atomic counters. Returned by `metrics()`.
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
99pub struct PromotionMetrics {
100    pub queued_total: u64,
101    pub dropped_total: u64,
102    pub completed_total: u64,
103    pub queue_depth: usize,
104}
105
106/// The closure that actually performs the L1 promotion. Injected at
107/// construction so this module has no compile-time dependency on
108/// `BlobCache`.
109///
110/// The wiring slice will pass a closure of the form:
111///
112/// ```ignore
113/// let cache = self.clone();
114/// Arc::new(move |req| cache.do_l1_promotion(req))
115/// ```
116pub type PromotionExecutor =
117    Arc<dyn Fn(PromotionRequest) -> Result<(), String> + Send + Sync + 'static>;
118
119// ---------------------------------------------------------------------------
120// AsyncPromotionPool
121// ---------------------------------------------------------------------------
122
123/// Bounded, drop-oldest async promotion pool.
124///
125/// See module docs for design rationale.
126pub struct AsyncPromotionPool {
127    queue: Arc<ArrayQueue<PromotionRequest>>,
128    executor: PromotionExecutor,
129    shutdown: Arc<AtomicBool>,
130
131    queued_total: AtomicU64,
132    dropped_total: AtomicU64,
133    completed_total: AtomicU64,
134
135    /// Soft cap on how many remaining requests workers will drain after
136    /// `shutdown()` is called before exiting. Prevents a pathological
137    /// flood of late requests from blocking shutdown indefinitely.
138    drain_budget: usize,
139}
140
141impl AsyncPromotionPool {
142    /// Construct a pool with a no-op executor. Useful only for tests / dry
143    /// runs where you want metrics but no actual promotion side-effects.
144    pub fn new(opts: PoolOpts) -> Arc<Self> {
145        Self::new_with_executor(opts, Arc::new(|_| Ok(())))
146    }
147
148    /// Construct a pool with a caller-provided executor closure.
149    ///
150    /// Spawns `opts.worker_count` tokio tasks that drain the queue. Each
151    /// task holds a `Weak<Self>` so the pool is dropped cleanly once the
152    /// caller releases its `Arc` and the workers exit.
153    pub fn new_with_executor(opts: PoolOpts, executor: PromotionExecutor) -> Arc<Self> {
154        let capacity = opts.queue_capacity.max(1);
155        let workers = opts.worker_count.max(1);
156
157        let pool = Arc::new(Self {
158            queue: Arc::new(ArrayQueue::new(capacity)),
159            executor,
160            shutdown: Arc::new(AtomicBool::new(false)),
161            queued_total: AtomicU64::new(0),
162            dropped_total: AtomicU64::new(0),
163            completed_total: AtomicU64::new(0),
164            // Drain at most one queue-worth of late requests per worker.
165            drain_budget: capacity,
166        });
167
168        for _ in 0..workers {
169            let pool_for_worker = Arc::clone(&pool);
170            tokio::spawn(async move {
171                worker_loop(pool_for_worker).await;
172            });
173        }
174
175        pool
176    }
177
178    /// Hand a promotion request to the pool.
179    ///
180    /// Never blocks. If the queue has capacity, the request is enqueued and
181    /// `Queued` is returned. If the queue is full, the oldest request is
182    /// popped (and dropped) to make room — the caller learns this via
183    /// `DroppedQueueFull { evicted_oldest: true }`. In the rare case where
184    /// the retry loses its slot to another producer, the *new* request is dropped:
185    /// `DroppedQueueFull { evicted_oldest: false }`. If a worker drains the
186    /// queue first and the retry needs no eviction, the result is `Queued`.
187    pub fn schedule(&self, request: PromotionRequest) -> ScheduleOutcome {
188        // After shutdown, refuse new work to keep the drain bounded.
189        if self.shutdown.load(Ordering::Acquire) {
190            self.dropped_total.fetch_add(1, Ordering::Relaxed);
191            return ScheduleOutcome::DroppedQueueFull {
192                evicted_oldest: false,
193            };
194        }
195
196        match self.queue.push(request) {
197            Ok(()) => {
198                self.queued_total.fetch_add(1, Ordering::Relaxed);
199                ScheduleOutcome::Queued
200            }
201            Err(rejected) => {
202                // Full. Try to evict oldest to admit the newest.
203                let evicted_oldest = self.queue.pop().is_some();
204                if evicted_oldest {
205                    self.dropped_total.fetch_add(1, Ordering::Relaxed);
206                }
207                match self.queue.push(rejected) {
208                    Ok(()) => {
209                        self.queued_total.fetch_add(1, Ordering::Relaxed);
210                        if evicted_oldest {
211                            ScheduleOutcome::DroppedQueueFull {
212                                evicted_oldest: true,
213                            }
214                        } else {
215                            // A worker drained the queue before our pop. The retry
216                            // succeeded without dropping either request.
217                            ScheduleOutcome::Queued
218                        }
219                    }
220                    Err(_) => {
221                        // Lost the race — another producer refilled the
222                        // slot before us. Drop the new request.
223                        self.dropped_total.fetch_add(1, Ordering::Relaxed);
224                        ScheduleOutcome::DroppedQueueFull {
225                            evicted_oldest: false,
226                        }
227                    }
228                }
229            }
230        }
231    }
232
233    /// Signal workers to drain remaining work and exit.
234    ///
235    /// Workers will process at most `drain_budget` more requests after the
236    /// shutdown flag is observed, then return. New `schedule` calls after
237    /// shutdown are rejected (counted in `dropped_total`).
238    pub fn shutdown(self: Arc<Self>) {
239        self.shutdown.store(true, Ordering::Release);
240    }
241
242    /// Snapshot of the pool's atomic counters.
243    ///
244    /// Each counter is read with `Relaxed` ordering; the snapshot is not
245    /// strictly atomic across counters (it can show, e.g., one more
246    /// `queued_total` than the queue depth implies if a worker is mid-pop).
247    /// This is acceptable for monitoring; consumers that need a strictly
248    /// consistent view should sample twice and take the difference.
249    pub fn metrics(&self) -> PromotionMetrics {
250        PromotionMetrics {
251            queued_total: self.queued_total.load(Ordering::Relaxed),
252            dropped_total: self.dropped_total.load(Ordering::Relaxed),
253            completed_total: self.completed_total.load(Ordering::Relaxed),
254            queue_depth: self.queue.len(),
255        }
256    }
257}
258
259// ---------------------------------------------------------------------------
260// Worker loop
261// ---------------------------------------------------------------------------
262
263/// Idle backoff between empty polls. Short enough that latency stays low,
264/// long enough that an idle pool doesn't spin a CPU.
265const WORKER_IDLE_BACKOFF: Duration = Duration::from_millis(1);
266
267async fn worker_loop(pool: Arc<AsyncPromotionPool>) {
268    loop {
269        match pool.queue.pop() {
270            Some(req) => {
271                // Run the executor. We swallow errors here because the
272                // promotion is best-effort by definition — the read path
273                // already handed bytes to the user. Errors are surfaced
274                // via tracing for observability.
275                if let Err(err) = (pool.executor)(req) {
276                    tracing::warn!(error = %err, "async promotion executor failed");
277                }
278                pool.completed_total.fetch_add(1, Ordering::Relaxed);
279            }
280            None => {
281                // Queue empty.
282                if pool.shutdown.load(Ordering::Acquire) {
283                    // Drain at most `drain_budget` more items (in case a
284                    // late `schedule` slipped in before the shutdown flag
285                    // was published) and exit.
286                    let mut drained = 0;
287                    while drained < pool.drain_budget {
288                        match pool.queue.pop() {
289                            Some(req) => {
290                                let _ = (pool.executor)(req);
291                                pool.completed_total.fetch_add(1, Ordering::Relaxed);
292                                drained += 1;
293                            }
294                            None => break,
295                        }
296                    }
297                    return;
298                }
299                tokio::time::sleep(WORKER_IDLE_BACKOFF).await;
300            }
301        }
302    }
303}
304
305// ---------------------------------------------------------------------------
306// Tests
307// ---------------------------------------------------------------------------
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use std::sync::atomic::AtomicUsize;
313    use std::sync::Mutex;
314    use std::time::Instant;
315
316    fn req(key: &str) -> PromotionRequest {
317        PromotionRequest {
318            namespace: "ns".to_string(),
319            key: key.to_string(),
320            bytes: Arc::from(vec![0u8; 8].into_boxed_slice()),
321            policy: BlobCachePolicy::default(),
322        }
323    }
324
325    /// Build a pool whose executor never runs (no workers spawned by us);
326    /// used for queue-semantics tests where worker drain would race.
327    ///
328    /// We do this by constructing the pool manually rather than going
329    /// through `new_with_executor`, which always spawns workers. For pure
330    /// queue-semantics tests we want zero concurrent draining.
331    fn pool_no_workers(capacity: usize) -> Arc<AsyncPromotionPool> {
332        Arc::new(AsyncPromotionPool {
333            queue: Arc::new(ArrayQueue::new(capacity)),
334            executor: Arc::new(|_| Ok(())),
335            shutdown: Arc::new(AtomicBool::new(false)),
336            queued_total: AtomicU64::new(0),
337            dropped_total: AtomicU64::new(0),
338            completed_total: AtomicU64::new(0),
339            drain_budget: capacity,
340        })
341    }
342
343    #[test]
344    fn schedule_returns_queued_when_capacity_available() {
345        let pool = pool_no_workers(4);
346        assert_eq!(pool.schedule(req("a")), ScheduleOutcome::Queued);
347        assert_eq!(pool.schedule(req("b")), ScheduleOutcome::Queued);
348        assert_eq!(pool.metrics().queued_total, 2);
349        assert_eq!(pool.metrics().queue_depth, 2);
350    }
351
352    #[test]
353    fn schedule_drops_oldest_when_saturated() {
354        let pool = pool_no_workers(2);
355        assert_eq!(pool.schedule(req("a")), ScheduleOutcome::Queued);
356        assert_eq!(pool.schedule(req("b")), ScheduleOutcome::Queued);
357
358        let outcome = pool.schedule(req("c"));
359        assert_eq!(
360            outcome,
361            ScheduleOutcome::DroppedQueueFull {
362                evicted_oldest: true
363            }
364        );
365        assert_eq!(pool.metrics().dropped_total, 1);
366        assert_eq!(pool.metrics().queue_depth, 2);
367    }
368
369    #[test]
370    fn drop_oldest_semantics_preserve_newest() {
371        // Insert N+1 items into a capacity-N queue. The oldest must be
372        // gone, the newest must survive.
373        let cap = 3;
374        let pool = pool_no_workers(cap);
375
376        for k in ["a", "b", "c"] {
377            assert_eq!(pool.schedule(req(k)), ScheduleOutcome::Queued);
378        }
379        // Saturating insert.
380        assert_eq!(
381            pool.schedule(req("d")),
382            ScheduleOutcome::DroppedQueueFull {
383                evicted_oldest: true
384            }
385        );
386
387        // Drain in FIFO order and check contents.
388        let mut seen = Vec::new();
389        while let Some(r) = pool.queue.pop() {
390            seen.push(r.key);
391        }
392        assert_eq!(
393            seen,
394            vec!["b".to_string(), "c".to_string(), "d".to_string()]
395        );
396    }
397
398    #[tokio::test]
399    async fn worker_executes_injected_closure() {
400        let counter = Arc::new(AtomicUsize::new(0));
401        let counter_for_exec = Arc::clone(&counter);
402        let executor: PromotionExecutor = Arc::new(move |_req| {
403            counter_for_exec.fetch_add(1, Ordering::Relaxed);
404            Ok(())
405        });
406
407        let pool = AsyncPromotionPool::new_with_executor(
408            PoolOpts {
409                queue_capacity: 16,
410                worker_count: 1,
411            },
412            executor,
413        );
414
415        for k in 0..5 {
416            pool.schedule(req(&format!("k{k}")));
417        }
418
419        // Wait for workers to drain.
420        let deadline = Instant::now() + Duration::from_secs(2);
421        while counter.load(Ordering::Relaxed) < 5 && Instant::now() < deadline {
422            tokio::time::sleep(Duration::from_millis(5)).await;
423        }
424
425        assert_eq!(counter.load(Ordering::Relaxed), 5);
426        assert_eq!(pool.metrics().completed_total, 5);
427
428        Arc::clone(&pool).shutdown();
429    }
430
431    #[tokio::test]
432    async fn shutdown_drains_queue_within_budget() {
433        let executed = Arc::new(AtomicUsize::new(0));
434        let executed_for_exec = Arc::clone(&executed);
435        let executor: PromotionExecutor = Arc::new(move |_req| {
436            executed_for_exec.fetch_add(1, Ordering::Relaxed);
437            Ok(())
438        });
439
440        let pool = AsyncPromotionPool::new_with_executor(
441            PoolOpts {
442                queue_capacity: 32,
443                worker_count: 2,
444            },
445            executor,
446        );
447
448        for k in 0..20 {
449            pool.schedule(req(&format!("k{k}")));
450        }
451
452        Arc::clone(&pool).shutdown();
453
454        // Workers should drain everything queued before shutdown was set.
455        let deadline = Instant::now() + Duration::from_secs(2);
456        while executed.load(Ordering::Relaxed) < 20 && Instant::now() < deadline {
457            tokio::time::sleep(Duration::from_millis(5)).await;
458        }
459
460        assert_eq!(executed.load(Ordering::Relaxed), 20);
461
462        // Post-shutdown schedule is rejected.
463        let outcome = pool.schedule(req("late"));
464        assert_eq!(
465            outcome,
466            ScheduleOutcome::DroppedQueueFull {
467                evicted_oldest: false
468            }
469        );
470        assert!(pool.metrics().dropped_total >= 1);
471    }
472
473    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
474    async fn concurrent_schedulers_no_deadlock_all_completions_counted() {
475        let executed = Arc::new(AtomicUsize::new(0));
476        let executed_for_exec = Arc::clone(&executed);
477        let executor: PromotionExecutor = Arc::new(move |_req| {
478            executed_for_exec.fetch_add(1, Ordering::Relaxed);
479            Ok(())
480        });
481
482        let pool = AsyncPromotionPool::new_with_executor(
483            PoolOpts {
484                queue_capacity: 64,
485                worker_count: 2,
486            },
487            executor,
488        );
489
490        let producers = 8;
491        let per_producer = 200;
492        // Producers track local outcomes so we can check accounting
493        // without relying on any single internal counter formula. The
494        // pool's `dropped_total` aggregates two distinct events
495        // (evicted-oldest + outright-rejected), so we use the
496        // ScheduleOutcome variants directly.
497        let outright_drops = Arc::new(AtomicUsize::new(0));
498        let mut handles = Vec::new();
499        for p in 0..producers {
500            let pool_p = Arc::clone(&pool);
501            let drops_p = Arc::clone(&outright_drops);
502            handles.push(tokio::spawn(async move {
503                for i in 0..per_producer {
504                    let r = PromotionRequest {
505                        namespace: format!("ns{p}"),
506                        key: format!("k{i}"),
507                        bytes: Arc::from(vec![0u8; 4].into_boxed_slice()),
508                        policy: BlobCachePolicy::default(),
509                    };
510                    if let ScheduleOutcome::DroppedQueueFull {
511                        evicted_oldest: false,
512                    } = pool_p.schedule(r)
513                    {
514                        drops_p.fetch_add(1, Ordering::Relaxed);
515                    }
516                    if i % 32 == 0 {
517                        tokio::task::yield_now().await;
518                    }
519                }
520            }));
521        }
522        for h in handles {
523            h.await.unwrap();
524        }
525
526        // Invariant we check after producers finish AND workers catch up:
527        //
528        //   queued_total + outright_drops == submitted
529        //
530        // (every submission was either admitted or rejected outright; an
531        // "evicted oldest" event still admits the new request)
532        //
533        // and once the queue drains:
534        //
535        //   completed_total == queued_total - dropped_via_eviction
536        //                   == queued_total - (dropped_total - outright_drops)
537        //
538        // Equivalently: completed + dropped_total == queued + outright_drops.
539        let submitted = (producers * per_producer) as u64;
540
541        let deadline = Instant::now() + Duration::from_secs(5);
542        loop {
543            let m = pool.metrics();
544            let outright = outright_drops.load(Ordering::Relaxed) as u64;
545            let admitted_invariant = m.queued_total + outright == submitted;
546            let drained_invariant =
547                m.completed_total + m.dropped_total == m.queued_total + outright;
548            if admitted_invariant && drained_invariant && m.queue_depth == 0 {
549                break;
550            }
551            if Instant::now() > deadline {
552                panic!(
553                    "did not converge: submitted={submitted} queued={} dropped={} completed={} depth={} outright={}",
554                    m.queued_total, m.dropped_total, m.completed_total, m.queue_depth, outright
555                );
556            }
557            tokio::time::sleep(Duration::from_millis(10)).await;
558        }
559
560        Arc::clone(&pool).shutdown();
561    }
562
563    #[test]
564    fn metrics_snapshot_is_consistent_under_simple_load() {
565        let pool = pool_no_workers(8);
566        for k in 0..5 {
567            pool.schedule(req(&format!("k{k}")));
568        }
569        let m = pool.metrics();
570        assert_eq!(m.queued_total, 5);
571        assert_eq!(m.dropped_total, 0);
572        assert_eq!(m.completed_total, 0);
573        assert_eq!(m.queue_depth, 5);
574    }
575
576    /// Sanity: the executor sees the same bytes/key/namespace the producer
577    /// scheduled. Catches accidental Arc/Box mix-ups in the queue plumbing.
578    #[tokio::test]
579    async fn executor_receives_unmodified_request() {
580        let captured: Arc<Mutex<Vec<(String, String, usize)>>> = Arc::new(Mutex::new(Vec::new()));
581        let captured_for_exec = Arc::clone(&captured);
582        let executor: PromotionExecutor = Arc::new(move |req| {
583            captured_for_exec
584                .lock()
585                .unwrap()
586                .push((req.namespace, req.key, req.bytes.len()));
587            Ok(())
588        });
589
590        let pool = AsyncPromotionPool::new_with_executor(
591            PoolOpts {
592                queue_capacity: 4,
593                worker_count: 1,
594            },
595            executor,
596        );
597
598        pool.schedule(PromotionRequest {
599            namespace: "users".to_string(),
600            key: "42".to_string(),
601            bytes: Arc::from(vec![1u8, 2, 3, 4, 5].into_boxed_slice()),
602            policy: BlobCachePolicy::default(),
603        });
604
605        let deadline = Instant::now() + Duration::from_secs(2);
606        while captured.lock().unwrap().is_empty() && Instant::now() < deadline {
607            tokio::time::sleep(Duration::from_millis(5)).await;
608        }
609
610        let seen = captured.lock().unwrap().clone();
611        assert_eq!(seen, vec![("users".to_string(), "42".to_string(), 5)]);
612
613        Arc::clone(&pool).shutdown();
614    }
615}