Skip to main content

dualcache_ff/
daemon.rs

1#[cfg(not(feature = "std"))]
2use alloc::{boxed::Box, vec::Vec};
3
4use crate::sync::{Arc, ArcSlice, new_arc_slice};
5use crate::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
6use crate::sync::index_types::AtomicTick;
7use core::hash::{Hash, BuildHasher};
8
9use crate::arena::Arena;
10use crate::storage::{Cache, Node};
11use crate::filters::{T1, T2};
12use crate::lossy_queue::{LossyQueue, OneshotAck};
13use crate::cache::{WorkerState, GLOBAL_EPOCH};
14
15/// Maximum rank (Revolution Shield value).
16/// A newly inserted or hit item gets rank = MAX_RANK, granting it
17/// MAX_RANK Pendulum sweeps of guaranteed survival.
18const MAX_RANK: u8 = 3;
19
20// ── Command ───────────────────────────────────────────────────────────────
21
22pub enum Command<K, V> {
23    /// Single insert from Worker (goes through probation gate).
24    Insert(K, V, u64),
25    /// Insert directly as a top priority item bypassing probation, giving it max survival rank.
26    InsertT1(K, V, u64),
27    /// Batch of (K, V, hash) from sharded worker buffers.
28    BatchInsert(Vec<(K, V, u64)>),
29    /// Remove by key+hash.
30    Remove(K, u64),
31    /// Blocking clear — caller spins on `OneshotAck::wait()`.
32    Clear(Arc<OneshotAck>),
33    /// Blocking maintenance flush — caller spins on `OneshotAck::wait()`.
34    Sync(Arc<OneshotAck>),
35    /// Signal Daemon to exit its run loop.
36    Shutdown,
37}
38
39// ── Daemon ────────────────────────────────────────────────────────────────
40
41pub struct Daemon<K, V, S> {
42    pub hasher: S,
43    pub arena: Arena,
44    pub t1: Arc<T1<K, V>>,
45    pub t2: Arc<T2<K, V>>,
46    pub cache: Arc<Cache<K, V>>,
47    pub cmd_rx: Arc<LossyQueue<Command<K, V>>>,
48    pub hit_rx: Arc<LossyQueue<[usize; 64]>>,
49    pub epoch: Arc<AtomicU32>,
50    /// Configurable poll interval in microseconds (1 000–10 000 µs).
51    /// Controls the trade-off between CPU idle cost and hit-signal latency.
52    pub poll_us: u64,
53    pub admission: Arc<AdmissionFilter>,
54    /// Pre-allocated accumulator for deferred-sort hit processing.
55    pub hit_accumulator: Vec<usize>,
56    pub last_decay_epoch: u32,
57    pub garbage_queue: Vec<(*mut Node<K, V>, usize)>,
58    pub worker_states: ArcSlice<WorkerState>,
59    /// Monotonically increasing tick counter — incremented on every poll loop.
60    /// Workers read this (Relaxed) to decide whether to time-flush their TLS
61    /// buffers without needing a hardware clock in no_std mode.
62    pub daemon_tick: Arc<AtomicTick>,
63    /// Cold-start flag shared with DualCacheFF
64    pub is_cold_start: Arc<AtomicBool>,
65}
66
67unsafe impl<K: Send, V: Send, S: Send> Send for Daemon<K, V, S> {}
68
69impl<K, V, S> Daemon<K, V, S>
70where
71    K: Hash + Eq + Send + Sync + Clone + 'static,
72    V: Send + Sync + Clone + 'static,
73    S: BuildHasher + Clone + Send + 'static,
74{
75    #[allow(clippy::too_many_arguments)]
76    pub fn new(
77        hasher: S,
78        capacity: usize,
79        t1: Arc<T1<K, V>>,
80        t2: Arc<T2<K, V>>,
81        cache: Arc<Cache<K, V>>,
82        cmd_rx: Arc<LossyQueue<Command<K, V>>>,
83        hit_rx: Arc<LossyQueue<[usize; 64]>>,
84        epoch: Arc<AtomicU32>,
85        duration: u32,
86        poll_us: u64,
87        worker_states: ArcSlice<WorkerState>,
88        daemon_tick: Arc<AtomicTick>,
89        is_cold_start: Arc<AtomicBool>,
90    ) -> Self {
91        let _ = duration; // duration is stored in the epoch tick rate; kept for API compat
92        Self {
93            hasher,
94            arena: Arena::new(capacity),
95            t1,
96            t2,
97            cache,
98            cmd_rx,
99            hit_rx,
100            epoch,
101            poll_us,
102            admission: Arc::new(AdmissionFilter::new(capacity)),
103            hit_accumulator: Vec::with_capacity(8192),
104            last_decay_epoch: 0,
105            garbage_queue: Vec::new(),
106            worker_states,
107            daemon_tick,
108            is_cold_start,
109        }
110    }
111
112    /// Main Daemon event loop.
113    ///
114    /// # std mode
115    /// Called from a dedicated `std::thread::spawn` inside `DualCacheFF::new`.
116    /// Sleeps `poll_us` microseconds when the command queue is empty.
117    ///
118    /// # no_std mode
119    /// The caller (e.g. RTOS task) must invoke `daemon.run()` on a dedicated
120    /// task. The loop uses `core::hint::spin_loop()` between iterations;
121    /// the RTOS scheduler handles preemption and CPU sharing.
122    pub fn run(mut self) {
123        #[cfg(feature = "std")]
124        let mut last_epoch_tick = std::time::Instant::now();
125
126        loop {
127            // ── Drain command queue (up to 8192 commands per poll) ────────
128            let mut processed = 0u32;
129            loop {
130                match self.cmd_rx.try_recv() {
131                    Some(Command::Shutdown) => return,
132                    Some(cmd) => {
133                        self.process_cmd(cmd);
134                        processed += 1;
135                        if processed >= 8192 {
136                            break;
137                        }
138                    }
139                    None => break,
140                }
141            }
142
143            // ── Epoch tick ────────────────────────────────────────────────
144            // In std mode: wall-clock driven, every ~100 ms.
145            // In no_std mode: daemon_tick driven, every 100 poll iterations.
146            #[cfg(feature = "std")]
147            {
148                let now = std::time::Instant::now();
149                if now.duration_since(last_epoch_tick)
150                    >= std::time::Duration::from_millis(100)
151                {
152                    self.epoch.fetch_add(1, Ordering::Relaxed);
153                    last_epoch_tick = now;
154                }
155            }
156            #[cfg(not(feature = "std"))]
157            {
158                let tick = self.daemon_tick.load(Ordering::Relaxed);
159                if tick % 100 == 0 {
160                    self.epoch.fetch_add(1, Ordering::Relaxed);
161                }
162            }
163
164            // ── Maintenance (GC + hit processing + eviction) ──────────────
165            self.maintenance();
166
167            // ── Advance daemon_tick ───────────────────────────────────────
168            #[cfg(any(feature = "loom", loom))]
169            {
170                if processed > 0 {
171                    self.daemon_tick.fetch_add(1, Ordering::Relaxed);
172                }
173            }
174            #[cfg(not(any(feature = "loom", loom)))]
175            {
176                self.daemon_tick.fetch_add(1, Ordering::Relaxed);
177            }
178
179            // ── Idle sleep / spin ─────────────────────────────────────────
180            if processed == 0 {
181                #[cfg(any(feature = "loom", loom))]
182                loom::thread::yield_now();
183                #[cfg(all(feature = "std", not(any(feature = "loom", loom))))]
184                std::thread::sleep(std::time::Duration::from_micros(self.poll_us));
185                #[cfg(not(feature = "std"))]
186                core::hint::spin_loop();
187            }
188        }
189    }
190
191    #[inline(always)]
192    fn process_cmd(&mut self, cmd: Command<K, V>) {
193        match cmd {
194            Command::Insert(k, v, hash) => self.handle_admission_insert(k, v, hash),
195            Command::InsertT1(k, v, hash) => self.handle_insert_t1(k, v, hash),
196            Command::BatchInsert(batch) => {
197                for (k, v, hash) in batch {
198                    self.handle_admission_insert(k, v, hash);
199                }
200            }
201            Command::Remove(k, hash) => self.handle_remove(k, hash),
202            Command::Clear(ack) => {
203                self.handle_clear();
204                ack.signal();
205            }
206            Command::Sync(ack) => {
207                self.maintenance();
208                ack.signal();
209            }
210            Command::Shutdown => unreachable!("handled in run()"),
211        }
212    }
213
214    /// Binary Valve Admission:
215    /// 1. Cold Start Mode (free slots > 5%): accept all.
216    /// 2. Steady State Mode: only accept if Ghost Set recognises the item.
217    fn handle_admission_insert(&mut self, k: K, v: V, hash: u64) {
218        let cold_start = self.arena.free_list_len() > self.arena.capacity / 20;
219        self.is_cold_start.store(cold_start, Ordering::Relaxed);
220        if cold_start || self.admission.check_ghost(hash) {
221            self.handle_insert_with_hash(k, v, hash);
222        }
223    }
224
225    fn handle_insert_with_hash(&mut self, k: K, v: V, hash: u64) {
226        let tag = (hash >> 48) as u16;
227
228        // 1. Check if it's an update of an existing entry
229        let global_idx = if let Some(existing_idx) = self.cache.index_probe(hash, tag) {
230            existing_idx
231        } else {
232            // 2. New insert: need a free slot
233            if self.arena.free_list_empty() {
234                self.evict_batch();
235            }
236            if let Some(new_idx) = self.arena.pop_free_slot() {
237                new_idx
238            } else {
239                return; // Still no slots after eviction — drop
240            }
241        };
242
243        let entry = (tag as u64) << 48 | (global_idx as u64 & 0x0000_FFFF_FFFF_FFFF);
244
245        let node_ptr = Box::into_raw(Box::new(Node {
246            key: k,
247            value: v,
248            expire_at: self.epoch.load(Ordering::Relaxed) + self.get_duration(),
249            g_idx: global_idx as u32,
250        }));
251
252        let old_ptr = self.cache.nodes[global_idx].swap(node_ptr, Ordering::Release);
253        if !old_ptr.is_null() {
254            let epoch = GLOBAL_EPOCH.load(Ordering::Relaxed);
255            self.garbage_queue.push((old_ptr, epoch));
256        }
257
258        self.cache.index_store(hash, tag, entry);
259        self.arena.set_hash(global_idx, hash);
260        // Revolution Shield: new items start with MAX_RANK protection
261        self.arena.set_rank(global_idx, MAX_RANK);
262    }
263
264    fn handle_insert_t1(&mut self, k: K, v: V, hash: u64) {
265        let tag = (hash >> 48) as u16;
266
267        let global_idx = if let Some(existing_idx) = self.cache.index_probe(hash, tag) {
268            existing_idx
269        } else {
270            if self.arena.free_list_empty() {
271                self.evict_batch();
272            }
273            if let Some(new_idx) = self.arena.pop_free_slot() {
274                new_idx
275            } else {
276                return;
277            }
278        };
279
280        let entry = (tag as u64) << 48 | (global_idx as u64 & 0x0000_FFFF_FFFF_FFFF);
281
282        let node_ptr = Box::into_raw(Box::new(Node {
283            key: k,
284            value: v,
285            expire_at: self.epoch.load(Ordering::Relaxed) + self.get_duration(),
286            g_idx: global_idx as u32,
287        }));
288
289        let old_ptr = self.cache.nodes[global_idx].swap(node_ptr, Ordering::Release);
290        if !old_ptr.is_null() {
291            let epoch = GLOBAL_EPOCH.load(Ordering::Relaxed);
292            self.garbage_queue.push((old_ptr, epoch));
293        }
294
295        self.cache.index_store(hash, tag, entry);
296        self.arena.set_hash(global_idx, hash);
297        self.arena.set_rank(global_idx, 255); // high initial rank (God mode)
298
299        // Promotion: hot items migrate to T1 immediately
300        self.t1.store_slot(hash, node_ptr);
301    }
302
303    fn get_duration(&self) -> u32 {
304        // Default: 10 epoch ticks ≈ 1 second (epoch ticks every 100 ms)
305        // This preserves the original API's `duration` field semantics.
306        10
307    }
308
309    fn handle_remove(&mut self, _k: K, hash: u64) {
310        let tag = (hash >> 48) as u16;
311        if let Some(g_idx) = self.cache.index_probe(hash, tag) {
312            let old_ptr =
313                self.cache.nodes[g_idx].swap(core::ptr::null_mut(), Ordering::Release);
314            if !old_ptr.is_null() {
315                let epoch = GLOBAL_EPOCH.load(Ordering::Relaxed);
316                self.garbage_queue.push((old_ptr, epoch));
317                self.t1.clear_if_matches(hash, old_ptr);
318                self.t2.clear_if_matches(hash, old_ptr);
319            }
320            self.cache.index_remove(hash, tag, g_idx);
321            self.arena.set_rank(g_idx, 0); // Fast eviction next cycle
322        }
323    }
324
325    fn handle_clear(&mut self) {
326        self.cache.clear();
327        for i in 0..self.t1.len() {
328            self.t1.clear_at(i);
329        }
330        for i in 0..self.t2.len() {
331            self.t2.clear_at(i);
332        }
333        self.admission.clear();
334        self.arena.clear();
335        self.is_cold_start.store(true, Ordering::Relaxed);
336    }
337
338    fn maintenance(&mut self) {
339        // ── Phase 0: QSBR Garbage Collection ─────────────────────────────
340        if !self.garbage_queue.is_empty() {
341            let current_global = GLOBAL_EPOCH.load(Ordering::Relaxed);
342            GLOBAL_EPOCH.store(current_global + 1, Ordering::Release);
343
344            let mut min_active_epoch = current_global + 1;
345            for state in self.worker_states.iter() {
346                let local = state.local_epoch.load(Ordering::Acquire);
347                if local != 0 && local < min_active_epoch {
348                    min_active_epoch = local;
349                }
350            }
351
352            self.garbage_queue.retain(|&(ptr, epoch)| {
353                if epoch < min_active_epoch {
354                    unsafe { drop(Box::from_raw(ptr)) };
355                    false
356                } else {
357                    true
358                }
359            });
360        }
361
362        // ── Phase 1: Collect hit indices into accumulator ─────────────────
363        while let Some(batch) = self.hit_rx.try_recv() {
364            for &g_idx in batch.iter() {
365                if g_idx < self.arena.capacity {
366                    self.hit_accumulator.push(g_idx);
367                }
368            }
369            if self.hit_accumulator.len() >= 8192 {
370                break;
371            }
372        }
373
374        // ── Phase 2: Sort + Revolution Shield hit processing ──────────────
375        if !self.hit_accumulator.is_empty() {
376            self.hit_accumulator.sort_unstable();
377
378            for &g_idx in &self.hit_accumulator {
379                // Revolution Shield: refill to MAX_RANK on every hit
380                self.arena.set_rank(g_idx, MAX_RANK);
381
382                let hash = self.arena.get_hash(g_idx);
383
384                // Promotion: hot items migrate to T1
385                let ptr = self.cache.nodes[g_idx].load(Ordering::Acquire);
386                if !ptr.is_null() && self.t1.load_slot(hash) != ptr {
387                    self.t1.store_slot(hash, ptr);
388                }
389            }
390
391            self.hit_accumulator.clear();
392        }
393
394        if self.arena.free_list_len() < self.arena.capacity / 10 {
395            self.evict_batch();
396        }
397
398        let cold_start = self.arena.free_list_len() > self.arena.capacity / 20;
399        if self.is_cold_start.load(Ordering::Relaxed) != cold_start {
400            self.is_cold_start.store(cold_start, Ordering::Relaxed);
401        }
402    }
403
404    /// Avg-rank eviction: scan the Pendulum cursor, compare each slot's rank
405    /// with the running average. Guaranteed O(1) amortised candidate search.
406    fn evict_batch(&mut self) {
407        let count = 128;
408        let avg = (self.arena.count_sum() / self.arena.capacity as u64) as u8;
409        let threshold = avg.max(1);
410
411        for _ in 0..count {
412            if self.arena.free_list_len() > self.arena.capacity / 10 {
413                break;
414            }
415
416            let idx = self.arena.cursor();
417            let r = self.arena.get_rank(idx);
418
419            if r <= threshold {
420                // Evict
421                let hash = self.arena.get_hash(idx);
422                let tag = (hash >> 48) as u16;
423
424                let old_ptr =
425                    self.cache.nodes[idx].swap(core::ptr::null_mut(), Ordering::Release);
426                if !old_ptr.is_null() {
427                    let epoch = GLOBAL_EPOCH.load(Ordering::Relaxed);
428                    self.garbage_queue.push((old_ptr, epoch));
429                    self.t1.clear_if_matches(hash, old_ptr);
430                    self.t2.clear_if_matches(hash, old_ptr);
431                }
432
433                self.cache.index_remove(hash, tag, idx);
434
435                // Task 5 — Ghost Set dynamically scaled to capacity:
436                // record_death writes to ghost_set[hash & ghost_mask],
437                // where ghost_mask = capacity - 1 (already aligned).
438                self.admission.record_death(hash);
439                self.arena.push_free_slot(idx);
440                self.arena.set_rank(idx, 0);
441            } else {
442                // Decay — decrement rank by 1
443                self.arena.decrement_rank(idx);
444            }
445            self.arena.advance_cursor();
446        }
447    }
448}
449
450impl<K, V, S> Drop for Daemon<K, V, S> {
451    fn drop(&mut self) {
452        for &(ptr, _) in self.garbage_queue.iter() {
453            if !ptr.is_null() {
454                unsafe {
455                    let _ = Box::from_raw(ptr);
456                }
457            }
458        }
459    }
460}
461
462// ── AdmissionFilter (Ghost Set) ───────────────────────────────────────────
463
464/// Ghost Set — direct-mapped fingerprint array.
465///
466/// Records the 16-bit fingerprint of evicted items so that previously-hot
467/// items bypass TLS probation on re-insertion.
468///
469/// # Task 5 — Capacity Align
470/// Ghost Set size is always equal to the Arena's `capacity` (already a power
471/// of two). When the user sets a small capacity (e.g. 2000 items rounded to
472/// 2048), the Ghost Set is also 2048 × 2 bytes = 4 KB — not the previous
473/// bloated `capacity.next_power_of_two()` of a larger default.
474pub struct AdmissionFilter {
475    pub ghost_mask: usize,
476    pub ghost_set: ArcSlice<AtomicU16>,
477}
478
479impl AdmissionFilter {
480    /// `capacity` must be a power of two (enforced by `Config`).
481    /// Ghost Set is exactly `capacity` entries (2 bytes each).
482    pub fn new(capacity: usize) -> Self {
483        // Capacity is already power-of-two — no extra `.next_power_of_two()`.
484        // Minimum 256 entries to keep the false-positive rate reasonable.
485        let ghost_size = capacity.max(256);
486
487        let mut ghost_vec = Vec::with_capacity(ghost_size);
488        for _ in 0..ghost_size {
489            ghost_vec.push(AtomicU16::new(0));
490        }
491
492        Self {
493            ghost_mask: ghost_size - 1,
494            ghost_set: new_arc_slice(ghost_vec),
495        }
496    }
497
498    /// Called by Daemon on eviction: record this item's 16-bit fingerprint.
499    #[inline(always)]
500    pub fn record_death(&self, hash: u64) {
501        let fp = (hash >> 48) as u16;
502        let idx = (hash as usize) & self.ghost_mask;
503        self.ghost_set[idx].store(fp, Ordering::Relaxed);
504    }
505
506    /// Called by Worker on insert: `true` if the fingerprint matches a
507    /// previously evicted item → bypass TLS probation.
508    #[inline(always)]
509    pub fn check_ghost(&self, hash: u64) -> bool {
510        let fp = (hash >> 48) as u16;
511        let ghost_idx = (hash as usize) & self.ghost_mask;
512        self.ghost_set[ghost_idx].load(Ordering::Relaxed) == fp
513    }
514
515    pub fn clear(&self) {
516        for val in self.ghost_set.iter() {
517            val.store(0, Ordering::Relaxed);
518        }
519    }
520}