Skip to main content

oxihuman_core/
query_cache.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Query result cache with TTL and hit-rate tracking.
6
7use std::collections::HashMap;
8
9/// A cached query result.
10#[derive(Debug, Clone)]
11pub struct QueryEntry<V> {
12    pub value: V,
13    pub ttl: u32,
14    pub hits: u32,
15}
16
17/// Cache mapping query keys to results.
18pub struct QueryCache<V> {
19    entries: HashMap<String, QueryEntry<V>>,
20    total_hits: u64,
21    total_misses: u64,
22    default_ttl: u32,
23}
24
25#[allow(dead_code)]
26impl<V: Clone> QueryCache<V> {
27    pub fn new(default_ttl: u32) -> Self {
28        QueryCache {
29            entries: HashMap::new(),
30            total_hits: 0,
31            total_misses: 0,
32            default_ttl,
33        }
34    }
35
36    pub fn insert(&mut self, key: &str, value: V) {
37        let ttl = self.default_ttl;
38        self.entries.insert(
39            key.to_string(),
40            QueryEntry {
41                value,
42                ttl,
43                hits: 0,
44            },
45        );
46    }
47
48    pub fn insert_with_ttl(&mut self, key: &str, value: V, ttl: u32) {
49        self.entries.insert(
50            key.to_string(),
51            QueryEntry {
52                value,
53                ttl,
54                hits: 0,
55            },
56        );
57    }
58
59    pub fn get(&mut self, key: &str) -> Option<&V> {
60        if let Some(entry) = self.entries.get_mut(key) {
61            entry.hits += 1;
62            self.total_hits += 1;
63            Some(&entry.value)
64        } else {
65            self.total_misses += 1;
66            None
67        }
68    }
69
70    pub fn peek(&self, key: &str) -> Option<&V> {
71        self.entries.get(key).map(|e| &e.value)
72    }
73
74    pub fn contains(&self, key: &str) -> bool {
75        self.entries.contains_key(key)
76    }
77
78    pub fn remove(&mut self, key: &str) -> bool {
79        self.entries.remove(key).is_some()
80    }
81
82    pub fn tick(&mut self) {
83        self.entries.retain(|_, e| {
84            if e.ttl == 0 {
85                false
86            } else {
87                e.ttl -= 1;
88                true
89            }
90        });
91    }
92
93    pub fn len(&self) -> usize {
94        self.entries.len()
95    }
96
97    pub fn is_empty(&self) -> bool {
98        self.entries.is_empty()
99    }
100
101    pub fn hit_rate(&self) -> f64 {
102        let total = self.total_hits + self.total_misses;
103        if total == 0 {
104            0.0
105        } else {
106            self.total_hits as f64 / total as f64
107        }
108    }
109
110    pub fn total_hits(&self) -> u64 {
111        self.total_hits
112    }
113
114    pub fn total_misses(&self) -> u64 {
115        self.total_misses
116    }
117
118    pub fn clear(&mut self) {
119        self.entries.clear();
120    }
121
122    pub fn keys(&self) -> Vec<&str> {
123        self.entries.keys().map(|k| k.as_str()).collect()
124    }
125}
126
127pub fn new_query_cache<V: Clone>(default_ttl: u32) -> QueryCache<V> {
128    QueryCache::new(default_ttl)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn insert_and_get() {
137        let mut c: QueryCache<i32> = new_query_cache(10);
138        c.insert("k", 42);
139        assert_eq!(*c.get("k").expect("should succeed"), 42);
140    }
141
142    #[test]
143    fn miss_increments_counter() {
144        let mut c: QueryCache<i32> = new_query_cache(10);
145        assert!(c.get("none").is_none());
146        assert_eq!(c.total_misses(), 1);
147    }
148
149    #[test]
150    fn hit_rate_calculation() {
151        let mut c: QueryCache<i32> = new_query_cache(10);
152        c.insert("k", 1);
153        c.get("k");
154        c.get("k");
155        c.get("missing");
156        let hr = c.hit_rate();
157        assert!((hr - 2.0 / 3.0).abs() < 1e-9);
158    }
159
160    #[test]
161    fn ttl_expiry() {
162        let mut c: QueryCache<i32> = new_query_cache(1);
163        c.insert("k", 1);
164        c.tick();
165        assert!(c.contains("k"));
166        c.tick();
167        assert!(!c.contains("k"));
168    }
169
170    #[test]
171    fn contains_and_remove() {
172        let mut c: QueryCache<i32> = new_query_cache(5);
173        c.insert("x", 7);
174        assert!(c.contains("x"));
175        c.remove("x");
176        assert!(!c.contains("x"));
177    }
178
179    #[test]
180    fn clear() {
181        let mut c: QueryCache<i32> = new_query_cache(5);
182        c.insert("a", 1);
183        c.insert("b", 2);
184        c.clear();
185        assert!(c.is_empty());
186    }
187
188    #[test]
189    fn peek_no_hit_count() {
190        let mut c: QueryCache<i32> = new_query_cache(5);
191        c.insert("k", 1);
192        c.peek("k");
193        assert_eq!(c.total_hits(), 0);
194    }
195
196    #[test]
197    fn insert_with_custom_ttl() {
198        let mut c: QueryCache<i32> = new_query_cache(100);
199        c.insert_with_ttl("k", 5, 1);
200        c.tick();
201        c.tick();
202        assert!(!c.contains("k"));
203    }
204
205    #[test]
206    fn len_tracking() {
207        let mut c: QueryCache<i32> = new_query_cache(5);
208        assert_eq!(c.len(), 0);
209        c.insert("a", 1);
210        c.insert("b", 2);
211        assert_eq!(c.len(), 2);
212    }
213}