sema-cache
0.1.0TTL and LRU caching, memoization, and optional persistent kv-backed caches
sema-cache
TTL and LRU caching, memoization, and optional persistent kv-backed caches.
Sema's LLM runtime already has built-in response caching — this package is for
everything else: HTTP calls, expensive computations, file parses. Modeled on
Clojure's core.cache and core.memoize.
Install
sema pkg add sema-cache
Quick start
Wrap an HTTP call so repeated lookups hit the cache instead of the network:
(import "sema-cache")
(define c (cache/new {:max-size 256 :ttl-ms 60000}))
(define (fetch-user id)
(cache/get-or c id
(fn () (json/decode (:body (http/get (format "https://api.example.com/users/~a" id)))))))
(fetch-user 1) ; miss — hits the network, caches the result
(fetch-user 1) ; hit — returned from the cache, no request
(cache/stats c) ; => {:evictions 0 :hits 1 :misses 1 :size 1}
Keys are compared with equal?, so strings, lists, and maps all work as keys.
Policies
cache/new holds at most :max-size entries (default 1024). When a put! of
a new key would overflow, one entry is evicted:
:lru(default) — evicts the least-recently-used entry.cache/getandcache/get-orhits refresh recency;cache/contains?deliberately does not.:fifo— evicts the oldest insertion. Gets never affect the order; re-putting a key counts as re-insertion.
:ttl-ms applies to both policies: an entry expires ttl-ms after it was
written (a hit at exactly ttl-ms is already expired). Expired entries are
evicted lazily on access, count as misses, and never count as hits.
nil values are cacheable — a stored nil is a hit, and cache/get-or will
not re-run its thunk for it. The cache distinguishes a stored nil from a
miss internally, not by value.
Memoize
(define slow-fib
(fn (n) (if (< n 2) n (+ (slow-fib (- n 1)) (slow-fib (- n 2))))))
(define fib (cache/memoize slow-fib))
(fib 25) ; computed
(fib 25) ; cached
The cache key is the full argument list, so multi-arg and variadic functions
work: (m 1 2) and (m 2 1) are distinct keys. cache/memoize accepts the
same options map as cache/new (:max-size, :ttl-ms, :policy,
:clock-fn), plus :cache — pass a handle you built yourself when you need
to inspect cache/stats or evict entries from outside:
(define c (cache/new {:max-size 512}))
(define m (cache/memoize expensive-fn {:cache c}))
Persistent kv-backed caches
cache/kv-cache opens a cache backed by the stdlib kv store — a JSON file on
disk — so cached entries survive across runs:
(define c (cache/kv-cache "geo" "geo-cache.json" {:ttl-ms 86400000}))
(cache/put! c "oslo" {:lat 59.91 :lng 10.75})
(cache/get c "oslo") ; => {:lat 59.91 :lng 10.75} — also after a restart
The handle works with every cache/* function, with these differences:
- Keys must be strings (a kv store requirement); anything else raises an error.
- Values must survive a JSON round-trip: keyword values come back as
strings (
:kw→"kw"), string map keys come back as keywords ({"a" 1}→{:a 1}), keyword-keyed maps, lists, strings, numbers, booleans, andnilround-trip cleanly, andNaN/Infinityfloats becomenil. Cache only JSON-representable data. - No
:max-sizeor:policy— the kv file has no recency index and every write rewrites the whole file, so capacity eviction belongs in an in-memorycache/new, not on disk. TTL is supported (enforced via a stored:writtentimestamp). :hits/:misses/:evictionsincache/statsare per-process and reset on each open;:sizereflects the file.
API
| Function | Description |
|---|---|
(cache/new opts?) |
Create an in-memory cache. Options: :max-size, :ttl-ms, :policy, :clock-fn |
(cache/get c k default?) |
Cached value, or default (nil) on miss. Hit refreshes LRU recency |
(cache/get-or c k thunk) |
Cached value, or run thunk once, cache, and return its result |
(cache/put! c k v) |
Store v under k, evicting per policy if full |
(cache/contains? c k) |
#t if k has a live entry. Non-touching, expiry-aware |
(cache/evict! c k) |
Remove k; #t if it was present |
(cache/clear! c) |
Remove every entry |
(cache/stats c) |
{:hits n :misses n :evictions n :size n} |
(cache/memoize f opts?) |
Memoized version of f, keyed by argument list |
(cache/kv-cache name path opts?) |
Persistent cache backed by a kv JSON file |
cache/new
(cache/new)
(cache/new {:max-size 1024 :ttl-ms nil :policy :lru :clock-fn time-ms})
Returns a cache handle. :max-size must be at least 1; :policy must be
:lru or :fifo. :clock-fn is a zero-arg function returning milliseconds —
inject a fake clock in tests to make TTL expiry deterministic.
cache/get
(cache/get c :key) ; => value or nil
(cache/get c :key :fallback) ; => value or :fallback
A hit counts toward :hits and refreshes LRU recency; a miss — including an
expired entry — counts toward :misses. Because nil values are cacheable, a
nil return is ambiguous; use cache/contains? or a sentinel default to
distinguish.
cache/get-or
(cache/get-or c "users/1" (fn () (http/get "https://api.example.com/users/1")))
The workhorse: on a hit the thunk never runs (including for a cached nil);
on a miss it runs exactly once and its result is stored.
cache/put!
(cache/put! c :key value)
Overwriting an existing key never evicts; it refreshes the entry's TTL clock,
LRU recency, and (for :fifo) its insertion order. Returns nil.
cache/contains?
(cache/contains? c :key) ; => #t / #f
Expiry-aware presence check that does not bump LRU recency and does not count
toward :hits/:misses — safe to poll without distorting eviction order.
cache/evict!
(cache/evict! c :key) ; => #t if present, #f otherwise
Manual eviction does not count toward the :evictions stat, which tracks
capacity- and TTL-driven evictions only.
cache/clear!
(cache/clear! c)
Empties the cache (:size drops to 0); the hit/miss/eviction counters are
preserved.
cache/stats
(cache/stats c) ; => {:evictions 0 :hits 2 :misses 1 :size 1}
cache/memoize
(cache/memoize f)
(cache/memoize f {:max-size 512 :ttl-ms 60000})
(cache/memoize f {:cache my-cache})
Returns a memoized function backed by an internal cache/new (or the handle
given as :cache). The key is the full argument list, compared with equal?.
cache/kv-cache
(cache/kv-cache "name" "path/to/cache.json")
(cache/kv-cache "name" "path/to/cache.json" {:ttl-ms 3600000 :clock-fn time-ms})
name is the process-local kv handle (opening the same name twice replaces
the handle); path is the backing JSON file, created on first write. See
Persistent kv-backed caches for key and value
constraints.
Testing
sema pkg add sema-test # once
sema tests.sema
The suite is fully deterministic and offline: TTL and eviction tests inject a
manually advanced :clock-fn.
License
MIT
| Version | Size | Published |
|---|---|---|
| 0.1.0 | 8 KB | 2026-07-07 21:27:54 |