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}