moonlarder

    A generic in-memory cache with LRU/LFU/ARC/WindowTinyLfu eviction, TTL expiry, and a memoizing get-or-insert helper

    cache
    lru
    lfu
    arc
    tinylfu
    ttl
    memoize
    data-structures
    Download zip
    Author
    Version
    0.15.0
    License
    Apache-2.0
    Last updated
    8 hours ago
    Downloads
    1

    Dependencies

    #moonlarder

    CI License: Apache-2.0

    A generic in-memory cache for MoonBit with a choice of LRU, LFU, ARC, or windowed TinyLFU eviction (plus a standalone TinyLFU-style admission filter for LRU), TTL expiry, and a memoizing get_or_insert_with helper, combined in a single bounded structure.

    #Install

    moon add jhshuai/moonlarder

    #Try it

    moon run cmd/main --target wasm-gc

    cmd/main is a runnable tour of LRU eviction, TTL expiry, memoization, weighted capacity, the LFU, ARC, and WindowTinyLfu policies, the admission filter, and JSON snapshotting - each step prints what it did and why. It uses an explicit now_ms throughout rather than a real clock (the same as everything else in this library), so its output is identical on every target: swap wasm-gc for wasm, js, or native (the last needs a C compiler on PATH, same as moon test does) and nothing about the demo itself changes.

    #Why

    A cache that only evicts by size (plain LRU) can't drop stale entries on its own, and a cache that only expires by time (plain TTL) can grow without bound. Larder does both at once: it holds at most capacity entries, evicting the least-recently-used one to make room, and it treats any entry past its time-to-live as absent the next time it's looked up.

    The clock is never read internally. Every call that needs one takes a now_ms : Int64 argument instead, so a cache's behavior is deterministic and testable without depending on a wall clock or a particular target's time source.

    #Usage

    let cache : Larder[String, Int] = Larder::new(capacity=100, default_ttl_ms=60_000L)

    cache.set("answer", 42, now_ms=0L)
    cache.get("answer", now_ms=1_000L) // Some(42)

    // Memoize an expensive computation: recomputed only on a miss or expiry.
    let value = cache.get_or_insert_with("expensive", now_ms=0L, fn() {
    compute_expensive_thing()
    })

    Per-entry TTL overrides the cache-wide default:

    cache.set("short-lived", "value", now_ms=0L, ttl_ms=5_000L)

    get_many/set_many cover the batch case - looking up a page of IDs, or warming the cache from rows just read from a database - without writing the loop yourself at every call site:

    cache.set_many([("a", 1), ("b", 2), ("c", 3)], now_ms=0L)
    let found = cache.get_many(["a", "missing", "c"], now_ms=0L)
    // found == [("a", 1), ("c", 3)] - only the hits, in the order asked

    Each is exactly equivalent to calling get/set on every key or entry in turn - same recency updates, same stats() counting, and (for set_many) capacity is enforced as each entry goes in, so an entry earlier in the array can be evicted to make room for one later in the same call.

    By default, capacity counts entries. Pass weigher to weigh entries by something else instead - total byte size, say, so a handful of large values can't crowd out many small ones the way plain entry-counting would let them:

    let by_size : Larder[String, Bytes] = Larder::new(
    capacity=10 * 1024 * 1024, // 10 MiB total, not 10 MiB per entry
    weigher=fn(_key, value) { value.length() },
    )

    An entry whose own weight exceeds capacity is still admitted alone (evicting everything else) rather than rejected, since set never fails.

    By default a full cache evicts by least-recently-used (LRU). Pass policy=Lfu for least-frequently-used eviction instead - keeping entries that get reused often even if they haven't been touched recently, at the cost of a brand-new entry being evictable almost immediately if the cache is already full of entries used more than once (that's LFU's own definition at work, not a bug):

    let cache : Larder[String, Int] = Larder::new(capacity=100, policy=Lfu)

    For workloads with both a hot, frequently-reused set of keys and occasional one-time bulk scans that shouldn't be allowed to evict it, policy=Arc runs Adaptive Replacement Cache (Megiddo and Modha, FAST 2003 - the algorithm ZFS's and PostgreSQL's buffer caches are built on), which adapts between recency and frequency on its own rather than committing to one the way Lru/Lfu do, with no parameter to tune:

    let cache : Larder[String, Int] = Larder::new(capacity=100, policy=Arc)

    Arc isn't compatible with a custom weigher - new aborts if both are given - and its ghost-list bookkeeping (which real entry was recently evicted, and from where) is driven by set(), since this library's separate get/set calls don't give it the single "cache request" event the original algorithm is built around; see EvictionPolicy::Arc's doc comment for the exact adaptation. cmd/main includes a side-by-side demo against plain LRU under a hot-set-plus-scan workload - the textbook case ARC exists for.

    Larder doesn't hand-roll a linked list or a heap for any of the three policies; all are built on moonbitlang/core's own Map (LRU reorders on access the same way a real linked-list-backed LRU would; LFU keeps a Map[Int, Map[K, Unit]] of frequency buckets, the same structure the classic O(1) LFU algorithm describes; ARC keeps four such Maps - two for real entries, two ghost lists of evicted keys - standing in for the paper's own linked lists, just with Map for the hand-rolled hash-set-plus-doubly-linked-list most implementations use).

    #Frequency estimation

    FrequencySketch[K] is a Count-Min Sketch (Cormode and Muthukrishnan, 2005) - a fixed-space, approximate "how many times have I seen this key?" counter, the building block TinyLFU-style cache admission policies use to judge whether a newcomer is popular enough to deserve displacing an existing entry. It's public and useful on its own, independent of Larder:

    let sketch : FrequencySketch[String] = FrequencySketch::new(capacity=10_000)
    sketch.increment("popular-key")
    sketch.increment("popular-key")
    sketch.estimate("popular-key") // 2 - never lower than the true count,
    // possibly higher from hash collisions

    Space stays fixed no matter how many distinct keys are ever seen - the trade against an exact Map[K, Int] - and counts are capped and periodically halved so estimate tracks recent popularity rather than accumulating forever.

    Larder::new's admission_filter=true puts a FrequencySketch to work as a TinyLFU-style admission check (Einziger, Friedman, and Manes, ACM TOS 2017) in front of Lru eviction - a second, different defense against the same one-time-scan problem policy=Arc solves:

    let cache : Larder[String, Int] = Larder::new(capacity=100, admission_filter=true)

    Every get() (hit or miss) records the key it looked up in the filter's sketch. When a brand-new key would need to evict an existing entry to fit, that eviction happens only if the newcomer is estimated at least as popular as the entry it would displace - otherwise the newcomer is turned away outright and the cache is left exactly as it was (admission_rejections() counts how often this fires). This is what keeps a one-time bulk scan from wiping out a genuinely frequently-requested working set: the scan's keys have no request history, so they lose the comparison against anything that's actually been asked for more than once.

    admission_filter is only supported together with policy=Lru (the default) and without a custom weigher - new aborts if combined with Lfu/Arc or a weigher. cmd/main runs the same hot-set-plus-scan workload as the Arc demo through both a plain Lru cache and an admission-filtered one, side by side.

    admission_filter has one real weakness: it tests a brand-new key against the eviction victim on the newcomer's very first appearance, before it's had any chance to build up request history of its own - so a key that's genuinely about to become popular can still lose that first comparison and never get a second chance. policy=WindowTinyLfu fixes exactly that, with a different architecture rather than a tunable knob:

    let cache : Larder[String, Int] = Larder::new(capacity=100, policy=WindowTinyLfu)

    Instead of gating every insert directly, capacity is split into a small Lru window (10%, minimum 1 entry) and a larger main space every new key must earn its way into. Every new key lands in the window first; only once the window itself overflows does the evicted candidate contest admission into main - by which point it's had a chance to accumulate real hits (each promoting it within the window, buying more time) if it's actually being requested repeatedly. Main itself is a segmented Lru (SLRU): a probationary segment newly admitted keys enter, and a protected segment (80% of main) a probationary entry is promoted into on its first hit there, demoting protected's own least-recently-used entry back to probation if that promotion overflows it - a pure move between the two segments, never an eviction. The admission contest itself is the same frequency comparison admission_filter runs, just applied later, after the window has given the candidate a chance to prove itself.

    These are fixed fractions (Caffeine's own defaults), not the adaptive, hill-climbing-tuned window size the production system uses - a disclosed simplification, not an attempt at byte-for-byte parity. WindowTinyLfu isn't compatible with a custom weigher, and is redundant with (so new aborts if combined with) admission_filter, since this policy already runs its own version of the same idea.

    cmd/main demonstrates the concrete payoff against a full, already-established cache: a brand-new key requested five times right after it first appears (the "a page that's about to go viral" pattern) gets served as real cache hits almost immediately under WindowTinyLfu, because it's already sitting in the window, while a plain admission_filter cache - which can only judge it at set() time, with no separate "already provisionally cached" state - forces every one of those early requests to miss until the candidate finally accumulates enough sketch weight from its own miss traffic to win outright admission.

    Larder[K, V] implements ToJson/FromJson when K/V do, for persisting and rehydrating a cache across a restart:

    let snapshot : Json = ToJson::to_json(cache)
    let restored : Larder[String, Int] = @json.from_json(snapshot)

    The snapshot is {"capacity": .., "policy": "lru"|"lfu"|"arc"|"wtinylfu","entries": [{"key": .., "value": ..}, ..]}. It's a snapshot of contents, not a byte-for-byte save state: LRU recency order, LFU frequencies, ARC's T1/T2/ghost-list state, WindowTinyLfu's window/main membership, and TTLs don't survive the round trip (a reloaded entry never expires on its own, and FromJson always uses the default count-based weigher and leaves the admission filter off, since a weigher is a function and there's nothing in JSON to deserialize a filter's sketch state from either). Use to_array/from_array directly, supplying your own weigher/default_ttl_ms/admission_filter, if you need any of them preserved.

    Pass on_remove to be notified whenever an entry leaves the cache, and why - useful for cascading invalidation, releasing a resource tied to the value (closing a file handle, say), or metrics:

    let cache : Larder[String, Handle] = Larder::new(
    capacity=1000,
    on_remove=fn(_key, handle, _cause) { handle.close() },
    )

    _cause is Explicit (remove/retain/clear), Replaced (overwritten by set on a key already present - the old value is what's being discarded, not the new one), Expired, or Evicted. The listener runs synchronously, after the cache's own state already reflects the removal - so it's safe for a listener to call back into the same cache (check size(), even insert a replacement) without seeing a half-finished removal.

    #API

    • Larder::new(capacity~, default_ttl_ms?, weigher?, policy?, admission_filter?, on_remove?) — create a cache
    • Larder::from_array(entries, capacity~, default_ttl_ms?, weigher?, policy?, admission_filter?, on_remove?, now_ms~) — build a cache from an array in one call, as if set had been called for each entry in order
    • get(key, now_ms~) — look up a value, refreshing its recency on a hit
    • get_many(keys, now_ms~) — look up several keys in one call, returning only the hits, in order
    • peek(key, now_ms~) — read a value without affecting recency or stats
    • set(key, value, now_ms~, ttl_ms?) — insert or update, evicting least-recently-used entries if the cache is now over capacity
    • set_many(entries, now_ms~, ttl_ms?) — insert or update several entries under one ttl in one call
    • get_or_insert_with(key, now_ms~, ttl_ms?, compute) — memoize
    • try_get_or_insert_with(key, now_ms~, ttl_ms?, compute) — memoize a loader that can fail; the error propagates and nothing is stored
    • touch_ttl(key, now_ms~, ttl_ms?) — refresh an entry's expiry in place (sliding expiration) without needing its value
    • contains(key, now_ms~) — check presence without affecting recency
    • remove(key) / clear() / retain(predicate)
    • purge_expired(now_ms~) — proactively sweep expired entries
    • keys() / values() / to_array(now_ms~) — inspect current contents
    • iter() / iter2() — support for entry in larder { .. } and for key, value in larder { .. } directly
    • is_empty() / size() / capacity() / weight() / resize(capacity)
    • policy() — the eviction policy this cache was created with
    • admission_rejections() — how many newcomers admission_filter has turned away; always 0 when it isn't enabled
    • stats() — hit/miss/eviction/expiration counters
    • on_remove — notified synchronously whenever an entry leaves, with why (Explicit, Replaced, Expired, or Evicted); see below
    • ToJson/FromJson — snapshot to and rehydrate from JSON (see above)

    FrequencySketch[K], independent of Larder:

    • FrequencySketch::new(capacity~) — create an empty sketch
    • increment(key) — record one occurrence of key
    • estimate(key) — an approximate count, never below the true count while under the per-counter cap (15)
    • clear() — reset every counter to zero

    See pkg.generated.mbti for the full signature list.

    #Testing

    Beyond the hand-picked unit tests in moonlarder_test.mbt, moonlarder_qc_test.mbt uses moonbitlang/quickcheck to replay randomly generated sequences of operations and check invariants that must hold no matter what produced the current state - most notably, that the real Map-based LRU and LFU implementations agree with independent, deliberately naive reference models (plain arrays and linear scans, no shared code with the real implementation) on every hit/miss and on the final set of surviving keys, across 100 random sequences each run. Arc gets its own properties in the same file (capacity never exceeded, to_array/peek agreement, on_remove counts matching stats()) rather than a naive reference model, since a trustworthy independent model of an adaptive algorithm is itself nontrivial to write; hand-picked unit tests in moonlarder_test.mbt cover the specific cases (T1-to-T2 promotion, both ghost-list hits) a random sequence might take a while to stumble onto reliably.

    frequency_sketch_qc_test.mbt does the same differential comparison for FrequencySketch, against a naive exact Map[K, Int] of true counts: estimate(key) must never fall below key's true count (up to the sketch's own cap), the defining Count-Min Sketch guarantee. admission_filter gets its own capacity-invariant property (a rejected insert must leave the cache exactly as it was) plus hand-picked unit tests for the admission decision itself - both the rejection case and the "a tie favors the newcomer" rule that keeps a cold sketch from degenerating into rejecting every insert. WindowTinyLfu gets the same three properties (capacity, to_array/peek agreement, on_remove counts) Arc does, plus hand-picked unit tests walking through each mechanism by hand: window overflow admitting into probation, a probation hit promoting to protected, a protected overflow demoting back to probation without evicting anything, the admission contest itself picking a winner, and
    • the whole point of the window - a candidate that built up real frequency while still sitting there beating an untouched incumbent it would have lost to immediately under a plain admission_filter.

    #Benchmarks

    moon bench --target wasm-gc

    moonlarder_bench_test.mbt covers get (hit and miss), set at steady state (every insert evicting one entry), and get_or_insert_with on a hit, against a cache pre-warmed with 1,000 entries. native needs a C compiler on PATH the same as moon test does; wasm-gc doesn't.

    #Does the Map-based design actually pay for itself?

    moonlarder_scaling_bench_test.mbt answers that directly: it benchmarks get/set against a deliberately naive LRU cache - a plain array, linearly scanned and rebuilt on every operation, the way a cache gets written before reaching for a Map - at capacities 100, 1,000, and 10,000. Measured on one run of this repository's CI-shaped environment (absolute numbers will vary by machine; the trend is the point):

    capacityget, realget, naiveratioset, realset, naiveratio
    10086 ns849 ns~10x141 ns827 ns~5.9x
    1,00028.6 ns4.09 µs~143x72.3 ns6.92 µs~96x
    10,00026.1 ns87.7 µs~3,360x211 ns169.3 µs~800x

    The real implementation stays roughly flat (even improving slightly, likely from cache-friendlier access patterns at this scale) as capacity grows 100x, exactly as expected for O(1)-amortized operations; the naive one degrades close to linearly, since every one of its operations costs O(capacity). The gap is already an order of magnitude at capacity 100 and three orders of magnitude by 10,000 - the frequency-bucket/Map-reinsertion design isn't just asymptotically nicer on paper, it's the difference between a cache that's free to use liberally and one that becomes the bottleneck as it grows.

    #Real-world usage

    Benchmarks prove the design is fast in isolation; they don't prove the API actually holds up as a dependency in someone else's code. So jhshuai/moonlarder-shortlink exists to answer that directly: a small URL-shortener core built against moonlarder as a real, separately-versioned import (resolved via moon.work in development, the same way a monorepo or a pre-publish integration check would), not copy-pasted code sharing this repository's build.

    It's a genuine two-cache-shapes-in-one-system example - get_or_insert_with for memoizing code generation, and a capacity-and-TTL-bounded Larder for the reverse lookup a resolver can't hold forever - and it ships its own honest benchmark of when memoization actually pays off (spoiler: not always; see its README for the measured case where it doesn't).

    #Notes

    • Not thread-safe; use one Larder per thread or add your own synchronization if you need to share one.
    • Keys must implement Hash + Eq.
    • Expiry is lazy: an expired entry is only removed when it's looked up (get/contains) or via an explicit purge_expired, so a write-heavy, rarely-read workload can hold expired entries until one of those runs.
    • A negative ttl_ms/default_ttl_ms is honored, not rejected: the entry is already expired the moment it's looked up. A ttl_ms large enough to overflow Int64 saturates to "practically never expires" rather than wrapping around to a deadline in the past. A weigher returning zero or a negative weight is clamped to at least 1, so it can never silently defeat capacity enforcement.

    #License

    Apache-2.0, see LICENSE.

    EvictionPolicy

    pub(all) enum EvictionPolicy {
    Lru
    Lfu
    Arc
    WindowTinyLfu
    } derive(Eq,
    Debug
    )

    Which entry Larder evicts first once it's over capacity.

    EvictionPolicy::equal

    EvictionPolicy::not_equal

    fn EvictionPolicy::not_equal(x : EvictionPolicy, y : EvictionPolicy) -> Bool

    FrequencySketch

    pub struct FrequencySketch[K] {
    table : Array[Int]
    width : Int
    additions : Int
    sample_size : Int
    }

    An approximate, constant-space "how many times have I seen this key?" counter - a Count-Min Sketch (Cormode and Muthukrishnan, "An Improved Data Stream Summary: The Count-Min Sketch and its Applications", 2005), sized and aged the way Einziger, Friedman, and Manes' TinyLFU ("TinyLFU: A Highly Efficient Cache Admission Policy", ACM TOS 2017) uses one to decide whether a cache candidate deserves to displace an existing entry - see Larder::new's admission_filter? parameter for that use.

    Unlike an exact Map[K, Int], estimate can overestimate a key's count (two keys colliding in the same counter inflate each other) but never underestimates it, and space stays fixed regardless of how many distinct keys are ever seen - the trade this data structure is for. Counts are also deliberately short-lived: a 4-bit-equivalent cap per counter (0-15) plus periodic halving of the whole table keeps estimate reflecting recent frequency rather than accumulating forever, which is what a cache admission decision actually wants ("was this popular lately", not "was this ever popular once, years ago").

    K only needs Hash, not Eq - a sketch never stores or compares keys directly, only the positions their hashes land on. K itself doesn't appear in any field (the table is a plain Array[Int]), so it's a phantom type parameter here purely to keep a sketch tied to the one key type it was sized and hashed for, the same way it's tied to a single Larder[K, V] when used as that cache's admission filter.
    impl Show for FrequencySketch[K]

    FrequencySketch::clear

    fn[K] FrequencySketch::clear(self : FrequencySketch[K]) -> Unit

    Resets every counter to zero, as if the sketch were freshly created. Unlike the automatic periodic aging increment performs (which halves counts to keep them recent-weighted), this clears everything at once - the counterpart to Larder::clear().

    FrequencySketch::estimate

    fn[K : Hash] FrequencySketch::estimate(self : FrequencySketch[K], key : K) -> Int

    An approximate count of how many times increment(key) has been called since the sketch was created or last aged - never lower than the true count while counters are unsaturated (<= 15), possibly higher if key collides with other keys in a row's counters. The four rows' counters are combined by taking the minimum, which is what keeps a single unlucky collision from inflating the estimate as much as an ordinary hash table would.

    FrequencySketch::increment

    fn[K : Hash] FrequencySketch::increment(self : FrequencySketch[K], key : K) -> Unit

    Records one more occurrence of key. Ages the whole table (halving every counter) once total increment calls since the last aging reach roughly ten times the table's width - the standard TinyLFU heuristic for keeping estimates weighted toward recent activity instead of growing without bound.

    FrequencySketch::new

    fn[K] FrequencySketch::new(capacity~ : Int) -> FrequencySketch[K]

    Creates an empty sketch sized for roughly capacity distinct keys
    • typically the same capacity as the Larder it's protecting. capacity is a sizing hint, not a hard limit: a sketch never rejects a key for being "too many", it just gets proportionally less accurate (more collisions) the further usage grows past it.

    FrequencySketch::output

    fn[K] FrequencySketch::output(self : FrequencySketch[K], logger : &Logger) -> Unit

    FrequencySketch::to_repr

    FrequencySketch::to_string

    fn[K] FrequencySketch::to_string(self : FrequencySketch[K]) -> String

    Larder

    pub struct Larder[K, V] {
    table : Map[K, Slot[V]]
    policy : EvictionPolicy
    capacity : Int
    default_ttl_ms : Int64?
    weigher : (K, V) -> Int
    on_remove : (K, V, RemovalCause) -> Unit
    current_weight : Int
    freq_buckets : Map[Int, Map[K, Unit]]
    min_freq : Int
    arc_t1 : Map[K, Unit]
    arc_t2 : Map[K, Unit]
    arc_b1 : Map[K, Unit]
    arc_b2 : Map[K, Unit]
    arc_p : Int
    sketch : FrequencySketch[K]?
    rejected_count : Int
    wtlfu_window : Map[K, Unit]
    wtlfu_probation : Map[K, Unit]
    wtlfu_protected : Map[K, Unit]
    wtlfu_window_capacity : Int
    wtlfu_protected_capacity : Int
    hit_count : Int
    miss_count : Int
    eviction_count : Int
    expiration_count : Int
    }

    A bounded, expiring in-memory cache.

    Entries beyond capacity are evicted according to policy once there's no room for a new one, and an entry older than its time-to-live is treated as absent the next time it's looked up. The clock is supplied by the caller on every call that needs one (now_ms) instead of being read internally, so behavior stays deterministic and testable regardless of target or wall-clock source.

    By default capacity counts entries (every entry weighs 1). Pass weigher to new to have it count something else instead - total byte size, say, for a cache of blobs where a handful of large ones shouldn't crowd out many small ones the way plain entry-counting would let them.

    Pass on_remove to new to be notified whenever an entry leaves the cache, with the reason - useful for cascading invalidation, releasing a resource tied to the value (a file handle, say), or just metrics.
    impl Show for Larder[K, V]
    impl ToJson for Larder[K, V]
    impl Debug for Larder[K, V]
    impl FromJson for Larder[K, V]

    Larder::admission_rejections

    fn[K, V] Larder::admission_rejections(self : Larder[K, V]) -> Int

    How many set() calls on a new key have been turned away by the admission filter (see new's admission_filter? parameter) since this cache was created or last clear()ed. Always 0 when the filter isn't enabled.

    Larder::capacity

    fn[K, V] Larder::capacity(self : Larder[K, V]) -> Int

    The maximum weight this cache holds before evicting - an entry count, unless new was given a weigher.

    Larder::clear

    fn[K, V] Larder::clear(self : Larder[K, V]) -> Unit

    Removes every entry (firing on_remove with Explicit for each, once the cache is already empty - the same "state is consistent first" order new's on_remove doc comment promises for every other removal path) and resets the hit/miss/eviction/expiration counters to zero.

    Larder::contains

    fn[K : Hash + Eq, V] Larder::contains(self : Larder[K, V], key : K, now_ms~ : Int64) -> Bool

    Returns whether key is present and not expired, without affecting recency or hit/miss counters.

    Larder::from_array

    fn[K : Hash + Eq, V] Larder::from_array(entries : Array[(K, V)], capacity~ : Int, default_ttl_ms? : Int64, weigher? : (K, V) -> Int, policy? : EvictionPolicy, admission_filter? : Bool, on_remove? : (K, V, RemovalCause) -> Unit, now_ms~ : Int64) -> Larder[K, V]

    Builds a cache from entries in one call, as if set had been called for each in order - so later entries end up more recently-used than earlier ones, and capacity/weight limits are enforced the same way, possibly evicting some of entries itself if they don't all fit.

    The usual reason to reach for this instead of a loop of set calls is rehydrating a cache from persisted state (a snapshot written by to_array, say) in one expression rather than several statements.

    Larder::get

    fn[K : Hash + Eq, V] Larder::get(self : Larder[K, V], key : K, now_ms~ : Int64) -> V?

    Looks up key, returning None if it is absent or has expired.

    A hit refreshes the entry's recency; an expired entry is dropped from the cache as a side effect of the lookup (lazy expiry) and counted as both a miss and an expiration.

    When new was given admission_filter=true, every call here - hit or miss alike - also records key in the admission filter's frequency sketch, since a key's request history (not just its time as a cache member) is what the filter judges a future newcomer's admission against.

    Larder::get_many

    fn[K : Hash + Eq, V] Larder::get_many(self : Larder[K, V], keys : Array[K], now_ms~ : Int64) -> Array[(K, V)]

    Looks up every key in keys, in order, returning only the hits as (key, value) pairs - equivalent to calling get on each one and collecting whichever returned Some, right down to updating recency and hit/miss counters the same number of times (a key repeated in keys is looked up, and counted, that many times over, not deduplicated first).

    The usual reason to reach for this instead of a loop of get calls is a batch lookup by a list of IDs - filling in a page of search results from the cache, say - where the loop itself is boilerplate every call site would otherwise repeat.

    Larder::get_or_insert_with

    fn[K : Hash + Eq, V] Larder::get_or_insert_with(self : Larder[K, V], key : K, now_ms~ : Int64, ttl_ms? : Int64, compute : () -> V) -> V

    Returns the cached value for key, computing and storing it via compute on a miss or expiry.

    This is the usual way to memoize an expensive or repeated computation: concurrent callers aside (Larder is not synchronized), a given key is computed once per eviction/expiry cycle rather than on every call.

    Larder::is_empty

    fn[K, V] Larder::is_empty(self : Larder[K, V]) -> Bool

    Whether the cache holds no entries.

    Larder::iter

    fn[K, V] Larder::iter(self : Larder[K, V]) -> Iter[(K, V)]

    Supports for (key, value) in larder { .. }. Same order and the same inclusion of not-yet-purged expired entries as keys/values.

    Larder::iter2

    fn[K, V] Larder::iter2(self : Larder[K, V]) -> Iter2[K, V]

    Supports for key, value in larder { .. } - the two-variable form of the same iteration iter provides.

    Larder::keys

    fn[K, V] Larder::keys(self : Larder[K, V]) -> Iter[K]

    Keys in least- to most-recently-used order under Lru, or plain insertion order under Lfu/Arc (neither's own bookkeeping tracks a total order over entries the way LRU's position in Map already does for free). Includes entries that have expired but haven't been purged yet, either way.

    Larder::new

    fn[K : Hash + Eq, V] Larder::new(capacity~ : Int, default_ttl_ms? : Int64, weigher? : (K, V) -> Int, policy? : EvictionPolicy, admission_filter? : Bool, on_remove? : (K, V, RemovalCause) -> Unit) -> Larder[K, V]

    Creates an empty cache bounded by capacity.

    default_ttl_ms, when given, is the time-to-live applied to entries that don't specify their own via set's ttl_ms argument. Leaving both unset means an entry never expires on its own and is only removed by LRU eviction, remove, or clear. A negative default_ttl_ms (or a negative per-call ttl_ms) is honored rather than rejected: it resolves to a deadline before now_ms, so the entry is already expired the moment it's looked up - the same outcome ttl_ms=0 produces. A ttl_ms large enough that now_ms + ttl_ms would overflow Int64 saturates to "practically never expires" instead of wrapping around to a deadline in the past.

    weigher, when given, turns capacity into a weight budget rather than a plain entry count: each entry contributes weigher(key,value) instead of 1, clamped to at least 1 even if weigher returns zero or a negative number - otherwise such an entry could silently defeat capacity enforcement altogether. An entry whose own weight exceeds capacity is still admitted alone (evicting everything else) rather than being rejected, since set never fails.

    policy picks the eviction rule; it defaults to Lru.

    admission_filter, when true, runs a TinyLFU-style admission check (Einziger, Friedman, and Manes, ACM TOS 2017) in front of eviction: inserting a brand-new key that would require evicting an existing one first consults a FrequencySketch of every key get() has looked up (hit or miss) so far, and rejects the newcomer outright - leaving the cache unchanged - if it's estimated less popular than the entry it would have displaced. This is what protects a cache from a one-time bulk scan evicting a genuinely hot working set, the same failure mode policy=Arc addresses by a different mechanism. Only supported together with policy=Lru (or no policy at all, since that defaults to Lru) and without a custom weigher - new aborts if combined with Lfu/Arc or a weigher. See admission_rejections() to observe how often it fires.

    on_remove, when given, is called synchronously every time an entry leaves the cache for any reason (see RemovalCause), after the cache's own state has already been updated to reflect the removal - so a listener that calls back into the cache (size(), get(), even inserting a replacement) sees consistent state, not a half-finished removal.

    policy=WindowTinyLfu splits capacity into a window (10%, minimum 1) and a main space (the rest), itself split into a protected segment (80% of main) and a probationary one (the remainder) - Caffeine's own default ratios, simplified here to fixed fractions rather than the hill-climbing adaptive resizing the production system uses. Every new key lands in the window; only when the window overflows does the evicted candidate contest admission into probation against probation's own weakest entry (the same comparison admission_filter runs directly against every newcomer, but only after the candidate has had a chance to build up real request frequency while sitting in the window). A probation entry that gets a hit is promoted to protected; a protected entry that overflows the protected segment demotes back to probation. TTLs, on_remove, and everything else about Larder work the same as under any other policy. Not compatible with a custom weigher
    • new aborts if both are given - and redundant with admission_filter (which only makes sense layered on Lru), so new also aborts if both are given.

    Larder::output

    fn[K, V] Larder::output(self : Larder[K, V], logger : &Logger) -> Unit

    Larder::peek

    fn[K : Hash + Eq, V] Larder::peek(self : Larder[K, V], key : K, now_ms~ : Int64) -> V?

    Reads key without affecting recency or hit/miss counters.

    Use this for inspection and debugging where get's side effects (marking the entry most-recently-used, counting a hit or miss) would be misleading. An expired entry still reads as None, but unlike get it is left in place rather than removed.

    Larder::policy

    fn[K, V] Larder::policy(self : Larder[K, V]) -> EvictionPolicy

    This cache's eviction policy, as given to new.

    Larder::purge_expired

    fn[K : Hash + Eq, V] Larder::purge_expired(self : Larder[K, V], now_ms~ : Int64) -> Int

    Removes every currently-expired entry and returns how many were removed.

    Expiry is otherwise lazy (checked on get/contains), so a cache that is mostly written to and rarely read can accumulate expired entries that still count against capacity; call this periodically if that matters for your workload.

    Larder::remove

    fn[K : Hash + Eq, V] Larder::remove(self : Larder[K, V], key : K) -> V?

    Removes key and returns its value, regardless of whether it had already expired.

    Larder::resize

    fn[K : Hash + Eq, V] Larder::resize(self : Larder[K, V], new_capacity : Int) -> Unit

    Changes the cache's weight budget (see new's weigher parameter).

    Growing takes effect immediately with no other side effect. Shrinking below the current weight evicts the least-recently-used entries right away, the same as set would have, rather than waiting for the next insert to notice the cache is over budget.

    Larder::retain

    fn[K : Hash + Eq, V] Larder::retain(self : Larder[K, V], predicate : (K, V) -> Bool) -> Int

    Removes every entry for which predicate(key, value) is false, and returns how many were removed.

    Useful for bulk invalidation by pattern - e.g. dropping every cached entry under a prefix after the thing it was derived from changed - where doing it key by key via remove would mean collecting matching keys separately first.

    Larder::set

    fn[K : Hash + Eq, V] Larder::set(self : Larder[K, V], key : K, value : V, now_ms~ : Int64, ttl_ms? : Int64) -> Unit

    Larder::set_many

    fn[K : Hash + Eq, V] Larder::set_many(self : Larder[K, V], entries : Array[(K, V)], now_ms~ : Int64, ttl_ms? : Int64) -> Unit

    Inserts or updates every entry in entries, in order, under the same ttl_ms - equivalent to calling set on each pair in turn, right down to possibly evicting an earlier pair in entries itself to make room for a later one.

    The usual reason to reach for this instead of a loop of set calls is a bulk warm-up or refresh - reloading a page of rows just read from a database, say - where every entry shares one ttl_ms and the loop itself is boilerplate every call site would otherwise repeat.

    Larder::size

    fn[K, V] Larder::size(self : Larder[K, V]) -> Int

    The number of entries currently stored, including any that have expired but have not yet been purged or looked up.

    Larder::stats

    fn[K, V] Larder::stats(self : Larder[K, V]) -> Stats

    A snapshot of this cache's hit/miss/eviction/expiration counters.

    Larder::to_array

    fn[K, V] Larder::to_array(self : Larder[K, V], now_ms~ : Int64) -> Array[(K, V)]

    A snapshot of every non-expired entry, in the same order as keys. Unlike keys/values, this evaluates now_ms against every entry up front rather than leaving expired ones in the result.

    Larder::to_json

    fn[K : ToJson, V : ToJson] Larder::to_json(self : Larder[K, V]) -> Json

    Larder::to_repr

    fn[K, V] Larder::to_repr(self : Larder[K, V]) ->
    Repr

    Larder::to_string

    fn[K, V] Larder::to_string(self : Larder[K, V]) -> String

    Larder::touch_ttl

    fn[K : Hash + Eq, V] Larder::touch_ttl(self : Larder[K, V], key : K, now_ms~ : Int64, ttl_ms? : Int64) -> Bool

    Refreshes key's time-to-live without changing its value, moving it to the most-recently-used position. Returns false if key is absent or already expired - in which case an expired entry is dropped, the same as get would - and leaves the cache unchanged.

    ttl_ms, when given, overrides default_ttl_ms for this refresh, the same as set's ttl_ms argument does for a new value. This is the usual way to implement sliding expiration (e.g. a session that should stay alive as long as it's used) without having to read the value back just to write it again.

    Larder::try_get_or_insert_with

    fn[K : Hash + Eq, V, E : Error] Larder::try_get_or_insert_with(self : Larder[K, V], key : K, now_ms~ : Int64, ttl_ms? : Int64, compute : () -> V raise E) -> V raise E

    Like get_or_insert_with, but for a loader that can fail - a database lookup or a parse, say. On a miss or expiry, compute's error propagates to the caller instead of being caught, and nothing is stored: a failed load doesn't wedge a bad value into the cache for the next caller to reuse.

    Larder::values

    fn[K, V] Larder::values(self : Larder[K, V]) -> Iter[V]

    Same order as keys.

    Larder::weight

    fn[K, V] Larder::weight(self : Larder[K, V]) -> Int

    The current total weight of every stored entry (expired or not) - an entry count, unless new was given a weigher. Always <= capacity, except immediately after construction with a single entry whose own weight already exceeds it.

    RemovalCause

    pub(all) enum RemovalCause {
    Explicit
    Replaced
    Expired
    Evicted
    } derive(Eq,
    Debug
    )

    Why an entry left the cache, passed to new's on_remove listener.

    RemovalCause::equal

    RemovalCause::not_equal

    fn RemovalCause::not_equal(x : RemovalCause, y : RemovalCause) -> Bool

    Slot

    pub struct Slot[V] {
    value : V
    expire_at : Int64?
    weight : Int
    freq : Int
    }

    Stats

    pub(all) struct Stats {
    hits : Int
    misses : Int
    evictions : Int
    expirations : Int
    } derive(Eq,
    Debug
    )

    A snapshot of a cache's counters since it was created or last cleared.

    Stats::equal

    fn Stats::equal(Stats, Stats) -> Bool

    Stats::not_equal

    fn Stats::not_equal(x : Stats, y : Stats) -> Bool

    Stats::to_repr