Skip to main content

platform_core/util/
managed_cache.rs

1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! Rust port of the Java `ManagedCache`
18//! (`org.platformlambda.core.util.ManagedCache`) — a **named, self-expiring
19//! (expire-after-write), size-bounded in-memory cache** with a process-wide
20//! registry. Design record: `draft-design-specs/managed-cache-port.md`
21//! (maintainer-approved 2026-07-27).
22//!
23//! Engine: [moka](https://docs.rs/moka) (the Caffeine-lineage Rust cache),
24//! kept an internal detail behind this wrapper so it can be swapped without
25//! touching any consumer. Deliberate, documented divergences from the Java
26//! original (design §5):
27//!
28//! - **Deterministic eviction** (maintainer ruling, 2026-07-27): the store is
29//!   built with `EvictionPolicy::lru()` — newcomers are always admitted and
30//!   the least-recently-used entry is the victim — where Java's Caffeine uses
31//!   approximate W-TinyLFU with frequency-based admission plus deliberate
32//!   HashDoS jitter (no policy switch exists there; a refactoring note is
33//!   filed with the Java team).
34//! - The housekeeper is **lifecycle-wired** ([`start_housekeeping`], called by
35//!   `AppStarter`'s essential-services phase) instead of lazily started on
36//!   first create: `create_cache` legitimately runs where no Tokio runtime
37//!   exists (static init, plain tests). Correctness never depends on the
38//!   sweep in either engine — the store itself enforces expiry on access.
39//! - Expiry is clamped to a ~100-year ceiling as well as the Java 1 s floor
40//!   (moka's builder panics past 1000 years; Java accepts any `long`).
41//! - `entries()` / [`ManagedCache::get_cache_collection`] return snapshots
42//!   where Java hands out live `ConcurrentMap` views.
43//!
44//! Values are type-erased as [`CacheValue`] (`Arc<dyn Any + Send + Sync>`) —
45//! the faithful Rust carrier of Java's `Object` reference semantics: the
46//! `Arc` clone returned by [`ManagedCache::get`] is the analog of Java
47//! handing back the same object reference. Convention: one named cache
48//! stores one value shape; [`ManagedCache::get_as`] returns `None` on a type
49//! mismatch, exactly where Java's cast would sit.
50//!
51//! Java's `SimpleCache` is deliberately NOT ported (maintainer ruling): any
52//! Java `SimpleCache` call site ported later maps onto a `ManagedCache`
53//! instance — bounded + self-expiring is a strict superset of `SimpleCache`'s
54//! unbounded lazy expiry. State the parity note once at each adopted site.
55
56use std::any::Any;
57use std::collections::{BTreeMap, HashMap};
58use std::sync::atomic::{AtomicI64, Ordering};
59use std::sync::{Arc, Mutex, OnceLock};
60use std::time::Duration;
61
62use moka::policy::EvictionPolicy;
63use moka::sync::Cache;
64
65use crate::util::elapsed_time;
66
67/// Type-erased cache value — the Rust carrier of Java's `Object`.
68pub type CacheValue = Arc<dyn Any + Send + Sync>;
69
70/// Default capacity of [`ManagedCache::create_cache`] (Java `DEFAULT_MAX_ITEMS`).
71const DEFAULT_MAX_ITEMS: u64 = 2000;
72/// Expiry floor in ms (Java `MIN_EXPIRY`) — clamped up, never rejected.
73const MIN_EXPIRY_MS: u64 = 1000;
74/// Expiry ceiling (~100 years): moka's builder panics past 1000 years, so the
75/// clamp keeps `create_cache` total where Java accepts any `long` (design §5).
76const MAX_EXPIRY_MS: u64 = 100 * 365 * 24 * 60 * 60 * 1000;
77/// Housekeeper cadence (Java `HOUSEKEEPING_INTERVAL` — 10 minutes).
78const HOUSEKEEPING_INTERVAL: Duration = Duration::from_secs(600);
79
80/// A named, self-expiring, size-bounded cache (see the module doc).
81pub struct ManagedCache {
82    name: String,
83    expiry_ms: u64,
84    max_items: u64,
85    store: Cache<String, CacheValue>,
86    // telemetry stamps — Java uses plain (racy) longs; atomics with relaxed
87    // ordering carry the same values soundly
88    last_read: AtomicI64,
89    last_write: AtomicI64,
90    last_reset: AtomicI64,
91}
92
93/// The process-wide registry (Java `COLLECTION` + `SAFETY` lock, collapsed
94/// into one mutex-guarded map).
95fn registry() -> &'static Mutex<HashMap<String, Arc<ManagedCache>>> {
96    static REGISTRY: OnceLock<Mutex<HashMap<String, Arc<ManagedCache>>>> = OnceLock::new();
97    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
98}
99
100fn now_ms() -> i64 {
101    std::time::SystemTime::now()
102        .duration_since(std::time::UNIX_EPOCH)
103        .map(|d| d.as_millis() as i64)
104        .unwrap_or_default()
105}
106
107impl ManagedCache {
108    /// Obtain (or create) the named cache with the default 2000-item bound
109    /// (Java `createCache(name, expiryMs)`). Idempotent by name: a later call
110    /// returns the existing instance unchanged — the first creation's
111    /// parameters win, later parameters are ignored (Java semantics).
112    pub fn create_cache(name: &str, expiry_ms: u64) -> Arc<ManagedCache> {
113        Self::create_cache_with_limit(name, expiry_ms, DEFAULT_MAX_ITEMS)
114    }
115
116    /// Obtain (or create) the named cache (Java `createCache(name, expiryMs,
117    /// maxItems)`). Expiry is clamped to \[1 s, ~100 years\].
118    pub fn create_cache_with_limit(
119        name: &str,
120        expiry_ms: u64,
121        max_items: u64,
122    ) -> Arc<ManagedCache> {
123        Self::create_clamped(
124            name,
125            expiry_ms.clamp(MIN_EXPIRY_MS, MAX_EXPIRY_MS),
126            max_items,
127        )
128    }
129
130    /// Test seam (design MC8, maintainer-approved): bypasses the 1 s floor so
131    /// TTL unit tests run in milliseconds. The public constructors above are
132    /// the only application path and always clamp.
133    #[cfg(test)]
134    fn create_cache_unclamped(name: &str, expiry_ms: u64, max_items: u64) -> Arc<ManagedCache> {
135        Self::create_clamped(name, expiry_ms.min(MAX_EXPIRY_MS), max_items)
136    }
137
138    fn create_clamped(name: &str, expiry_ms: u64, max_items: u64) -> Arc<ManagedCache> {
139        let mut collection = registry().lock().expect("managed cache registry");
140        if let Some(existing) = collection.get(name) {
141            return existing.clone();
142        }
143        let store = Cache::builder()
144            .max_capacity(max_items)
145            .time_to_live(Duration::from_millis(expiry_ms))
146            // deterministic eviction — maintainer ruling (module doc)
147            .eviction_policy(EvictionPolicy::lru())
148            .build();
149        let cache = Arc::new(ManagedCache {
150            name: name.to_string(),
151            expiry_ms,
152            max_items,
153            store,
154            last_read: AtomicI64::new(0),
155            last_write: AtomicI64::new(0),
156            last_reset: AtomicI64::new(now_ms()),
157        });
158        collection.insert(name.to_string(), cache.clone());
159        log::info!(
160            "Created cache ({}), expiry {}, maxItems={}",
161            name,
162            elapsed_time(Duration::from_millis(expiry_ms)),
163            max_items
164        );
165        cache
166    }
167
168    /// Resolve a cache by name from any module (Java `getInstance`).
169    pub fn get_instance(name: &str) -> Option<Arc<ManagedCache>> {
170        registry()
171            .lock()
172            .expect("managed cache registry")
173            .get(name)
174            .cloned()
175    }
176
177    /// Sorted snapshot of every registered cache for ops introspection
178    /// (Java `getCacheCollection` returns the live map — design §5).
179    pub fn get_cache_collection() -> BTreeMap<String, Arc<ManagedCache>> {
180        registry()
181            .lock()
182            .expect("managed cache registry")
183            .iter()
184            .map(|(k, v)| (k.clone(), v.clone()))
185            .collect()
186    }
187
188    /// Store a value (Java `put`) — stamps `last_write`; empty key = no-op.
189    /// NEVER pass a pre-wrapped `Arc`/[`CacheValue`] here — it would nest
190    /// (`Arc<Arc<T>>`) and `get_as::<T>` would miss; use [`Self::put_arc`]
191    /// for values that are already reference-counted.
192    pub fn put<V: Any + Send + Sync>(&self, key: &str, value: V) {
193        self.put_arc(key, Arc::new(value));
194    }
195
196    /// Store an already-wrapped value — stamps `last_write`; empty key = no-op.
197    pub fn put_arc(&self, key: &str, value: CacheValue) {
198        if !key.is_empty() {
199            self.last_write.store(now_ms(), Ordering::Relaxed);
200            self.store.insert(key.to_string(), value);
201        }
202    }
203
204    /// Fetch a value (Java `get`) — stamps `last_read` on any non-empty-key
205    /// call, hit or miss (Java stamps before the lookup).
206    pub fn get(&self, key: &str) -> Option<CacheValue> {
207        if key.is_empty() {
208            return None;
209        }
210        self.last_read.store(now_ms(), Ordering::Relaxed);
211        self.store.get(key)
212    }
213
214    /// Typed fetch: `None` on absence OR type mismatch — the Rust analog of
215    /// the cast at a Java call site.
216    pub fn get_as<T: Any + Send + Sync>(&self, key: &str) -> Option<Arc<T>> {
217        self.get(key).and_then(|value| value.downcast::<T>().ok())
218    }
219
220    /// Java `exists` — delegates to `get`, so it inherits the `last_read` stamp.
221    pub fn exists(&self, key: &str) -> bool {
222        self.get(key).is_some()
223    }
224
225    /// Java `remove` — stamps `last_write` even when the key is absent.
226    pub fn remove(&self, key: &str) {
227        if !key.is_empty() {
228            self.last_write.store(now_ms(), Ordering::Relaxed);
229            self.store.invalidate(key);
230        }
231    }
232
233    /// Java `clear` is three operations: stamp `lastReset`, `invalidateAll()`,
234    /// `cleanUp()` — the cleanup keeps `size()` honest immediately after a
235    /// clear. Emits no log (only `clean_up` logs).
236    pub fn clear(&self) {
237        self.last_reset.store(now_ms(), Ordering::Relaxed);
238        self.store.invalidate_all();
239        self.store.run_pending_tasks();
240    }
241
242    /// Java `cleanUp` — apply pending maintenance (expiry sweep, deferred
243    /// evictions) now.
244    pub fn clean_up(&self) {
245        log::debug!("Cleaning up {}", self.name);
246        self.store.run_pending_tasks();
247    }
248
249    /// Cache name (Java `getName`).
250    pub fn name(&self) -> &str {
251        &self.name
252    }
253
254    /// The clamped expiry in ms (Java `getExpiry` returns the clamped value).
255    pub fn expiry_ms(&self) -> u64 {
256        self.expiry_ms
257    }
258
259    /// Capacity bound (Java `getMaxItems`).
260    pub fn max_items(&self) -> u64 {
261        self.max_items
262    }
263
264    /// Estimated entry count (Java `size()` = Caffeine `estimatedSize`;
265    /// moka `entry_count` — call [`Self::clean_up`] first for freshness).
266    pub fn size(&self) -> u64 {
267        self.store.entry_count()
268    }
269
270    /// Snapshot of the unexpired entries (Java `getMap` returns a live
271    /// `ConcurrentMap` view; Rust hands out no live guard — design §5).
272    pub fn entries(&self) -> Vec<(String, CacheValue)> {
273        self.store.iter().map(|(k, v)| ((*k).clone(), v)).collect()
274    }
275
276    /// Epoch ms of the last `get`/`exists` (Java `getLastRead`; 0 = never).
277    pub fn last_read(&self) -> i64 {
278        self.last_read.load(Ordering::Relaxed)
279    }
280
281    /// Epoch ms of the last `put`/`remove` (Java `getLastWrite`; 0 = never).
282    pub fn last_write(&self) -> i64 {
283        self.last_write.load(Ordering::Relaxed)
284    }
285
286    /// Epoch ms of construction or the last `clear` (Java `getLastReset`).
287    pub fn last_reset(&self) -> i64 {
288        self.last_reset.load(Ordering::Relaxed)
289    }
290}
291
292/// Start the 10-minute housekeeper the lifecycle owns (idempotent). Java
293/// starts its sweeper lazily inside the first constructor; here
294/// `create_cache` may run where no Tokio runtime exists, so the lifecycle
295/// wires this instead — a documented behavioral no-op (design §5): the sweep
296/// only reclaims memory in caches with no subsequent activity; the store
297/// itself enforces expiry on access. Must be called within a Tokio runtime
298/// (`AppStarter::run` does — its "essential services" phase).
299pub fn start_housekeeping() {
300    static STARTED: OnceLock<()> = OnceLock::new();
301    STARTED.get_or_init(|| {
302        log::info!("Housekeeper started");
303        tokio::spawn(async {
304            loop {
305                tokio::time::sleep(HOUSEKEEPING_INTERVAL).await;
306                housekeeping();
307            }
308        });
309    });
310}
311
312/// One housekeeping sweep over every registered cache (the Java
313/// `removeExpiredCache` body; a single sequential task, so sweeps never
314/// overlap — the intent of Java's `NOT_RUNNING` guard). The interval task
315/// calls this; tests call it directly — never test the timer.
316fn housekeeping() {
317    for cache in ManagedCache::get_cache_collection().into_values() {
318        cache.clean_up();
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    // NOTE: the registry is process-wide and unit tests run in parallel —
327    // every test uses its own unique cache name.
328
329    #[test]
330    fn round_trip_remove_clear_and_entries() {
331        let cache = ManagedCache::create_cache("unit.round.trip", 60_000);
332        cache.put("s", "text".to_string());
333        cache.put("n", 7_i64);
334        assert_eq!(cache.get_as::<String>("s").unwrap().as_str(), "text");
335        assert_eq!(*cache.get_as::<i64>("n").unwrap(), 7);
336        // wrong type -> None (the Java cast site)
337        assert!(cache.get_as::<i64>("s").is_none());
338        let mut keys: Vec<String> = cache.entries().into_iter().map(|(k, _)| k).collect();
339        keys.sort();
340        assert_eq!(keys, ["n", "s"]);
341        cache.remove("s");
342        assert!(!cache.exists("s"));
343        cache.clear();
344        // clear runs pending tasks (Java invalidateAll + cleanUp), so the
345        // estimate is honest immediately
346        assert_eq!(cache.size(), 0);
347        assert!(cache.get("n").is_none());
348    }
349
350    #[test]
351    fn pre_wrapped_arc_is_the_documented_trap() {
352        let cache = ManagedCache::create_cache("unit.wrong.wrap", 60_000);
353        let wrapped: Arc<String> = Arc::new("hello".to_string());
354        cache.put("k", wrapped); // stores TypeId Arc<String>, NOT String
355        assert!(cache.get_as::<String>("k").is_none());
356        assert!(cache.get_as::<Arc<String>>("k").is_some());
357        // the right way for a pre-wrapped value
358        cache.put_arc("k2", Arc::new("hello".to_string()));
359        assert_eq!(cache.get_as::<String>("k2").unwrap().as_str(), "hello");
360    }
361
362    #[test]
363    fn empty_key_is_a_guarded_no_op() {
364        let cache = ManagedCache::create_cache("unit.empty.key", 60_000);
365        cache.put("", "x".to_string());
366        cache.clean_up();
367        assert_eq!(cache.size(), 0);
368        assert!(cache.get("").is_none());
369        assert!(!cache.exists(""));
370        cache.remove("");
371        // Java guards BEFORE stamping: none of the above touched the markers
372        assert_eq!(cache.last_read(), 0);
373        assert_eq!(cache.last_write(), 0);
374    }
375
376    #[test]
377    fn expiry_clamps_at_both_ends() {
378        let low = ManagedCache::create_cache("unit.clamp.low", 500);
379        assert_eq!(low.expiry_ms(), 1000);
380        // must not panic (moka's builder rejects > 1000 years — design §5)
381        let high = ManagedCache::create_cache("unit.clamp.high", u64::MAX);
382        assert_eq!(high.expiry_ms(), MAX_EXPIRY_MS);
383    }
384
385    #[test]
386    fn create_is_idempotent_first_params_win() {
387        let first = ManagedCache::create_cache_with_limit("unit.create.idempotent", 5_000, 10);
388        let second = ManagedCache::create_cache_with_limit("unit.create.idempotent", 9_000, 99);
389        assert!(Arc::ptr_eq(&first, &second));
390        assert_eq!(second.expiry_ms(), 5_000);
391        assert_eq!(second.max_items(), 10);
392    }
393
394    #[test]
395    fn registry_lookup_and_sorted_collection() {
396        ManagedCache::create_cache("unit.registry.zeta", 60_000);
397        ManagedCache::create_cache("unit.registry.alpha", 60_000);
398        assert!(ManagedCache::get_instance("unit.registry.alpha").is_some());
399        assert!(ManagedCache::get_instance("no.such.cache").is_none());
400        let all = ManagedCache::get_cache_collection();
401        let names: Vec<&str> = all
402            .keys()
403            .map(String::as_str)
404            .filter(|k| k.starts_with("unit.registry."))
405            .collect();
406        // BTreeMap keeps the snapshot deterministic (the /info/routes rule)
407        assert_eq!(names, ["unit.registry.alpha", "unit.registry.zeta"]);
408        assert_eq!(
409            all.get("unit.registry.alpha").unwrap().name(),
410            "unit.registry.alpha"
411        );
412    }
413
414    #[test]
415    fn ttl_expires_after_write_lazily() {
416        // 200 ms TTL: presence is asserted immediately after the put (only a
417        // >200 ms preemption between two adjacent statements could flake it);
418        // the absence side sleeps far past the boundary (sleeps never
419        // undershoot)
420        let cache = ManagedCache::create_cache_unclamped("unit.ttl.expiry", 200, 100);
421        cache.put("k", 42_i32);
422        assert!(cache.exists("k"));
423        std::thread::sleep(Duration::from_millis(600));
424        // no housekeeper involved — the store enforces expiry on access
425        assert!(cache.get_as::<i32>("k").is_none());
426        assert!(!cache.exists("k"));
427    }
428
429    #[test]
430    fn ttl_resets_on_update_expire_after_write() {
431        // a reset test inherently needs one mid-window presence assert; the
432        // 1200 ms TTL gives every boundary >= 400 ms of slow-CI headroom
433        let cache = ManagedCache::create_cache("unit.ttl.reset", 1200);
434        cache.put("k", "a".to_string());
435        std::thread::sleep(Duration::from_millis(800));
436        // a rewrite resets the clock (Caffeine expireAfterWrite parity) —
437        // the primitive behind the WS-dedup anchored window
438        cache.put("k", "b".to_string());
439        std::thread::sleep(Duration::from_millis(800));
440        // ~1.6 s after the first write (past its 1.2 s TTL — presence here
441        // PROVES the reset) but only ~0.8 s after the rewrite
442        assert_eq!(cache.get_as::<String>("k").unwrap().as_str(), "b");
443        std::thread::sleep(Duration::from_millis(700));
444        // ~1.5 s after the rewrite — expired
445        assert!(cache.get("k").is_none());
446    }
447
448    #[test]
449    fn lru_eviction_is_deterministic() {
450        // maintainer ruling: EvictionPolicy::lru — newcomers always admitted,
451        // the least-recently-used entry is the victim (design §5). Sequential
452        // access, so the recorded order is exact.
453        let cache = ManagedCache::create_cache_with_limit("unit.lru.eviction", 60_000, 3);
454        cache.put("a", 1_i32);
455        cache.put("b", 2_i32);
456        cache.put("c", 3_i32);
457        // flush the writes first: a maintenance pass applies the read log
458        // BEFORE the write log, so a read recorded ahead of its entry's
459        // write would be a recency no-op
460        cache.clean_up();
461        // touch a and b so c becomes the least recently used; flush the
462        // recorded reads before inserting the 4th entry
463        assert!(cache.exists("a"));
464        assert!(cache.exists("b"));
465        cache.clean_up();
466        cache.put("d", 4_i32);
467        cache.clean_up();
468        assert!(!cache.exists("c"), "the LRU entry is the victim");
469        assert!(cache.exists("a"));
470        assert!(cache.exists("b"));
471        assert!(cache.exists("d"), "the newcomer is always admitted");
472        assert_eq!(cache.size(), 3);
473    }
474
475    #[test]
476    fn telemetry_stamps_follow_the_java_map() {
477        let cache = ManagedCache::create_cache("unit.telemetry.stamps", 60_000);
478        assert_eq!(cache.last_read(), 0);
479        assert_eq!(cache.last_write(), 0);
480        assert!(cache.last_reset() > 0);
481        // get on a miss stamps last_read (Java stamps before the lookup)
482        assert!(cache.get("absent").is_none());
483        assert!(cache.last_read() > 0);
484        cache.put("k", "v".to_string());
485        let first_write = cache.last_write();
486        assert!(first_write > 0);
487        std::thread::sleep(Duration::from_millis(10));
488        // remove stamps last_write even when the key is absent (Java parity)
489        cache.remove("no-such-key");
490        assert!(cache.last_write() > first_write);
491        let first_reset = cache.last_reset();
492        std::thread::sleep(Duration::from_millis(10));
493        cache.clear();
494        assert!(cache.last_reset() > first_reset);
495    }
496
497    #[test]
498    fn housekeeping_sweeps_idle_caches() {
499        let cache = ManagedCache::create_cache_unclamped("unit.housekeeping", 50, 100);
500        cache.put("k", 1_i32);
501        std::thread::sleep(Duration::from_millis(250));
502        // the sweep body the interval task runs — never test the timer
503        housekeeping();
504        assert_eq!(cache.size(), 0);
505    }
506
507    #[test]
508    fn concurrent_put_get_smoke() {
509        let cache = ManagedCache::create_cache("unit.concurrent.smoke", 60_000);
510        let mut handles = Vec::new();
511        for t in 0..4 {
512            let cache = cache.clone();
513            handles.push(std::thread::spawn(move || {
514                for i in 0..250 {
515                    let key = format!("k{t}-{i}");
516                    cache.put(&key, i);
517                    assert_eq!(*cache.get_as::<i32>(&key).unwrap(), i);
518                }
519            }));
520        }
521        for handle in handles {
522            handle.join().unwrap();
523        }
524        cache.clean_up();
525        assert_eq!(cache.size(), 1000);
526    }
527}