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}