1use crate::store::{BoxFuture, Cache, counter_value, prefixed, record};
13use rustlavel_core::{Json, Result};
14use std::collections::HashMap;
15use std::collections::hash_map::Entry as MapEntry;
16use std::hash::{Hash, Hasher};
17use std::sync::{Arc, RwLock, Weak};
18use std::time::{Duration, Instant};
19
20const SHARDS: usize = 16;
24
25const DEFAULT_SWEEP: Duration = Duration::from_secs(60);
27
28#[derive(Clone, Debug)]
29struct Entry {
30 value: Json,
31 expires_at: Option<Instant>,
33}
34
35impl Entry {
36 fn is_expired(&self, now: Instant) -> bool {
37 self.expires_at.is_some_and(|at| at <= now)
38 }
39}
40
41struct Inner {
42 shards: Vec<RwLock<HashMap<String, Entry>>>,
43 prefix: String,
44}
45
46impl Inner {
47 fn shard(&self, key: &str) -> &RwLock<HashMap<String, Entry>> {
48 let mut hasher = std::collections::hash_map::DefaultHasher::new();
49 key.hash(&mut hasher);
50 &self.shards[(hasher.finish() as usize) % SHARDS]
51 }
52
53 fn sweep(&self) {
55 let now = Instant::now();
56 for shard in &self.shards {
57 let mut map = shard.write().expect("cache shard poisoned");
58 map.retain(|_, entry| !entry.is_expired(now));
59 }
60 }
61
62 fn live_count(&self) -> usize {
63 let now = Instant::now();
64 self.shards
65 .iter()
66 .map(|shard| {
67 shard
68 .read()
69 .expect("cache shard poisoned")
70 .values()
71 .filter(|entry| !entry.is_expired(now))
72 .count()
73 })
74 .sum()
75 }
76}
77
78#[derive(Clone)]
81pub struct MemoryStore {
82 inner: Arc<Inner>,
83}
84
85impl Default for MemoryStore {
86 fn default() -> Self {
87 MemoryStore::new()
88 }
89}
90
91impl MemoryStore {
92 pub fn new() -> Self {
93 MemoryStore::with_options("", DEFAULT_SWEEP)
94 }
95
96 pub fn with_prefix(prefix: impl Into<String>) -> Self {
97 MemoryStore::with_options(prefix, DEFAULT_SWEEP)
98 }
99
100 pub fn with_options(prefix: impl Into<String>, sweep_every: Duration) -> Self {
106 let inner = Arc::new(Inner {
107 shards: (0..SHARDS).map(|_| RwLock::new(HashMap::new())).collect(),
108 prefix: prefix.into(),
109 });
110
111 if let Ok(handle) = tokio::runtime::Handle::try_current() {
112 let weak: Weak<Inner> = Arc::downgrade(&inner);
116 handle.spawn(async move {
117 loop {
118 tokio::time::sleep(sweep_every).await;
119 match weak.upgrade() {
120 Some(inner) => inner.sweep(),
121 None => break,
122 }
123 }
124 });
125 }
126
127 MemoryStore { inner }
128 }
129
130 pub fn sweep(&self) {
133 self.inner.sweep();
134 }
135
136 pub fn len(&self) -> usize {
138 self.inner.live_count()
139 }
140
141 pub fn is_empty(&self) -> bool {
142 self.len() == 0
143 }
144
145 fn read(&self, key: &str) -> Option<Json> {
147 let shard = self.inner.shard(key);
148 let now = Instant::now();
149
150 {
152 let map = shard.read().expect("cache shard poisoned");
153 match map.get(key) {
154 None => return None,
155 Some(entry) if !entry.is_expired(now) => return Some(entry.value.clone()),
156 Some(_) => {}
157 }
158 }
159
160 let mut map = shard.write().expect("cache shard poisoned");
163 match map.get(key) {
164 Some(entry) if entry.is_expired(now) => {
165 map.remove(key);
166 None
167 }
168 Some(entry) => Some(entry.value.clone()),
169 None => None,
170 }
171 }
172
173 fn write(&self, key: String, value: Json, expires_at: Option<Instant>) {
174 self.inner
175 .shard(&key)
176 .write()
177 .expect("cache shard poisoned")
178 .insert(key, Entry { value, expires_at });
179 }
180
181 fn remove(&self, key: &str) -> bool {
182 let now = Instant::now();
183 self.inner
184 .shard(key)
185 .write()
186 .expect("cache shard poisoned")
187 .remove(key)
188 .is_some_and(|entry| !entry.is_expired(now))
191 }
192
193 fn bump(&self, key: String, by: i64, ttl: Option<Duration>) -> i64 {
196 let now = Instant::now();
197 let mut map = self.inner.shard(&key).write().expect("cache shard poisoned");
198
199 match map.entry(key) {
200 MapEntry::Occupied(mut slot) if !slot.get().is_expired(now) => {
201 let next = counter_value(Some(&slot.get().value)) + by;
202 slot.get_mut().value = Json::from(next);
203 next
204 }
205 MapEntry::Occupied(mut slot) => {
206 slot.insert(Entry { value: Json::from(by), expires_at: ttl.map(|d| now + d) });
208 by
209 }
210 MapEntry::Vacant(slot) => {
211 slot.insert(Entry { value: Json::from(by), expires_at: ttl.map(|d| now + d) });
212 by
213 }
214 }
215 }
216}
217
218impl Cache for MemoryStore {
219 fn driver(&self) -> &'static str {
220 "memory"
221 }
222
223 fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Json>>> {
224 Box::pin(async move {
225 let full = prefixed(&self.inner.prefix, key);
226 let found = self.read(&full);
227 record(found.is_some(), "memory", key);
228 Ok(found)
229 })
230 }
231
232 fn put<'a>(&'a self, key: &'a str, value: Json, ttl: Duration) -> BoxFuture<'a, Result<()>> {
233 Box::pin(async move {
234 let full = prefixed(&self.inner.prefix, key);
235 if ttl.is_zero() {
236 self.remove(&full);
237 return Ok(());
238 }
239 self.write(full, value, Some(Instant::now() + ttl));
240 Ok(())
241 })
242 }
243
244 fn forever<'a>(&'a self, key: &'a str, value: Json) -> BoxFuture<'a, Result<()>> {
245 Box::pin(async move {
246 self.write(prefixed(&self.inner.prefix, key), value, None);
247 Ok(())
248 })
249 }
250
251 fn forget<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<bool>> {
252 Box::pin(async move { Ok(self.remove(&prefixed(&self.inner.prefix, key))) })
253 }
254
255 fn flush(&self) -> BoxFuture<'_, Result<()>> {
256 Box::pin(async move {
257 for shard in &self.inner.shards {
258 shard.write().expect("cache shard poisoned").clear();
259 }
260 Ok(())
261 })
262 }
263
264 fn increment<'a>(&'a self, key: &'a str, by: i64) -> BoxFuture<'a, Result<i64>> {
265 Box::pin(async move { Ok(self.bump(prefixed(&self.inner.prefix, key), by, None)) })
266 }
267
268 fn increment_within<'a>(
269 &'a self,
270 key: &'a str,
271 by: i64,
272 ttl: Duration,
273 ) -> BoxFuture<'a, Result<i64>> {
274 Box::pin(async move { Ok(self.bump(prefixed(&self.inner.prefix, key), by, Some(ttl))) })
275 }
276
277 fn ttl<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Duration>>> {
278 Box::pin(async move {
279 let full = prefixed(&self.inner.prefix, key);
280 let now = Instant::now();
281 let map = self.inner.shard(&full).read().expect("cache shard poisoned");
282 Ok(map
283 .get(&full)
284 .filter(|entry| !entry.is_expired(now))
285 .and_then(|entry| entry.expires_at)
286 .map(|at| at.saturating_duration_since(now)))
287 })
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::store::CacheExt;
295 use std::sync::atomic::{AtomicUsize, Ordering};
296
297 #[tokio::test]
298 async fn an_expired_entry_is_evicted_the_moment_it_is_read() {
299 let cache = MemoryStore::new();
300 cache.put("temporary", Json::from("here"), Duration::from_millis(30)).await.unwrap();
301 assert_eq!(cache.len(), 1);
302
303 tokio::time::sleep(Duration::from_millis(60)).await;
304
305 assert_eq!(cache.get("temporary").await.unwrap(), None);
306 assert_eq!(cache.len(), 0, "the read should have dropped the entry, not just hidden it");
307 }
308
309 #[tokio::test]
310 async fn a_sweep_removes_entries_nobody_ever_reads_again() {
311 let cache = MemoryStore::new();
312 for i in 0..50 {
313 cache
314 .put(&format!("key-{i}"), Json::from(i), Duration::from_millis(20))
315 .await
316 .unwrap();
317 }
318
319 tokio::time::sleep(Duration::from_millis(50)).await;
320 cache.sweep();
321
322 let raw: usize = cache.inner.shards.iter().map(|s| s.read().unwrap().len()).sum();
324 assert_eq!(raw, 0);
325 }
326
327 #[tokio::test]
328 async fn the_background_sweep_stops_when_the_last_clone_is_dropped() {
329 let cache = MemoryStore::with_options("", Duration::from_millis(5));
330 let weak = Arc::downgrade(&cache.inner);
331 drop(cache);
332
333 tokio::time::sleep(Duration::from_millis(30)).await;
334 assert_eq!(weak.strong_count(), 0, "the sweep task must not keep the cache alive");
335 }
336
337 #[tokio::test]
338 async fn a_prefix_keeps_two_stores_from_colliding() {
339 let alpha = MemoryStore::with_prefix("alpha:");
340 let beta = MemoryStore::with_prefix("beta:");
341
342 alpha.forever("shared", Json::from(1)).await.unwrap();
343 beta.forever("shared", Json::from(2)).await.unwrap();
344
345 assert_eq!(alpha.get("shared").await.unwrap(), Some(Json::from(1)));
346 assert_eq!(beta.get("shared").await.unwrap(), Some(Json::from(2)));
347 }
348
349 #[tokio::test]
350 async fn concurrent_increments_never_lose_a_count() {
351 let cache = MemoryStore::new();
352 let cache = Arc::new(cache);
353
354 let mut tasks = Vec::new();
355 for _ in 0..32 {
356 let cache = Arc::clone(&cache);
357 tasks.push(tokio::spawn(async move {
358 for _ in 0..100 {
359 cache.increment("hits", 1).await.unwrap();
360 }
361 }));
362 }
363 for task in tasks {
364 task.await.unwrap();
365 }
366
367 assert_eq!(cache.get("hits").await.unwrap(), Some(Json::from(3200)));
368 }
369
370 #[tokio::test]
371 async fn hammering_mixed_operations_from_many_tasks_stays_consistent() {
372 let cache = Arc::new(MemoryStore::new());
373 let computed = Arc::new(AtomicUsize::new(0));
374
375 let mut tasks = Vec::new();
376 for task_id in 0..24 {
377 let cache = Arc::clone(&cache);
378 let computed = Arc::clone(&computed);
379 tasks.push(tokio::spawn(async move {
380 for round in 0..80 {
381 let key = format!("k-{}", (task_id * round) % 40);
382 match round % 4 {
383 0 => {
384 cache.put(&key, Json::from(round), Duration::from_millis(5)).await.unwrap();
385 }
386 1 => {
387 let _ = cache.get(&key).await.unwrap();
388 }
389 2 => {
390 let _ = cache.forget(&key).await.unwrap();
391 }
392 _ => {
393 let counter = Arc::clone(&computed);
394 let value = cache
395 .remember(&key, Duration::from_millis(5), move || async move {
396 counter.fetch_add(1, Ordering::SeqCst);
397 Ok(Json::from("computed"))
398 })
399 .await
400 .unwrap();
401 assert!(!value.is_null());
402 }
403 }
404 }
405 }));
406 }
407
408 for task in tasks {
409 task.await.unwrap();
410 }
411
412 tokio::time::sleep(Duration::from_millis(30)).await;
415 cache.sweep();
416 assert_eq!(cache.len(), 0);
417 }
418
419 #[tokio::test]
420 async fn increment_within_starts_the_clock_only_on_the_first_call() {
421 let cache = MemoryStore::new();
422
423 assert_eq!(cache.increment_within("window", 1, Duration::from_millis(120)).await.unwrap(), 1);
424 tokio::time::sleep(Duration::from_millis(60)).await;
425 assert_eq!(cache.increment_within("window", 1, Duration::from_millis(120)).await.unwrap(), 2);
426
427 tokio::time::sleep(Duration::from_millis(90)).await;
429 assert_eq!(cache.get("window").await.unwrap(), None);
430 }
431
432 #[tokio::test]
433 async fn ttl_reports_the_remaining_life_of_a_key() {
434 let cache = MemoryStore::new();
435 cache.forever("immortal", Json::from(1)).await.unwrap();
436 cache.put("mortal", Json::from(1), Duration::from_secs(30)).await.unwrap();
437
438 assert_eq!(cache.ttl("immortal").await.unwrap(), None);
439 assert_eq!(cache.ttl("nothing").await.unwrap(), None);
440 let remaining = cache.ttl("mortal").await.unwrap().expect("a mortal key has a ttl");
441 assert!(remaining <= Duration::from_secs(30) && remaining > Duration::from_secs(25));
442 }
443
444 #[tokio::test]
445 async fn putting_with_a_zero_ttl_forgets_instead_of_storing() {
446 let cache = MemoryStore::new();
447 cache.forever("doomed", Json::from(1)).await.unwrap();
448 cache.put("doomed", Json::from(2), Duration::ZERO).await.unwrap();
449
450 assert!(!cache.has("doomed").await.unwrap());
451 }
452}