sema-cache

0.1.0

TTL and LRU caching, memoization, and optional persistent kv-backed caches

$ sema pkg install sema-cache
Readme Versions 1 Dependencies 0

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/get and cache/get-or hits 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, and nil round-trip cleanly, and NaN/Infinity floats become nil. Cache only JSON-representable data.
  • No :max-size or :policy — the kv file has no recency index and every write rewrites the whole file, so capacity eviction belongs in an in-memory cache/new, not on disk. TTL is supported (enforced via a stored :written timestamp).
  • :hits/:misses/:evictions in cache/stats are per-process and reset on each open; :size reflects 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

VersionSizePublished
0.1.0 8 KB 2026-07-07 21:27:54
No dependencies