MLServe.Cache (MLServe v0.1.3)

Copy Markdown View Source

Optional TTL cache for inference results.

Off by default, and deliberately so. Caching a prediction is only correct when the same input must produce the same output — which is false the moment a model reads a clock, a random seed, or mutable feature state. Silently caching by default would turn that into a subtle correctness bug, so MLServe.predict/3 caches only when you ask:

MLServe.predict(:fraud_detection, features, cache: true)

or per model:

config :ml_serve,
  models: [fraud_detection: [cache: [enabled: true, ttl: :timer.minutes(1)]]]

Design

A single :public ETS table with write_concurrency: true. Readers and writers work directly in the calling process — the GenServer exists only to own the table and run the sweeper, and is never in the request path.

Expiry is both lazy (checked on read) and swept (a periodic :ets.select_delete). Lazy alone leaks memory for keys never read again; sweeping alone lets a reader see a stale entry between sweeps.

Keys

The default key is :erlang.term_to_binary(input, [:deterministic]) hashed with SHA-256. The :deterministic flag matters: without it, large maps serialise in internal-hash order and two equal inputs can produce different binaries, silently halving the hit rate.

For large tensor inputs, hashing the whole term is more expensive than the inference you are trying to skip. Pass a cheap key instead:

MLServe.predict(:fraud, features, cache: true, cache_key: features.account_id)

What is not cached

Errors are never cached — a transient backend failure must not be pinned for the TTL.

Eviction

TTL plus a :max_size bound, enforced at sweep time. There is no LRU: tracking recency needs a write on every read, which would put a serialised write on the hot path to save memory that a TTL already bounds. If you need LRU, use Cachex and pass results through :postprocess.

Summary

Functions

Returns a specification to start this module under a supervisor.

Empties the cache.

Fetches a cached result, returning :miss when absent or expired.

Removes every cached entry for a model, at every version.

Removes every cached entry for one model version.

Builds the cache key for a prediction.

Stores a result under key for ttl milliseconds.

Number of entries currently held, including any not yet swept.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear()

@spec clear() :: :ok

Empties the cache.

fetch(key, metadata \\ %{})

@spec fetch(term(), map()) :: {:ok, term()} | :miss

Fetches a cached result, returning :miss when absent or expired.

Emits [:ml_serve, :cache, :hit] or [:ml_serve, :cache, :miss].

invalidate(name)

@spec invalidate(atom()) :: :ok

Removes every cached entry for a model, at every version.

invalidate(name, version)

@spec invalidate(atom(), String.t()) :: :ok

Removes every cached entry for one model version.

key(name, version, input)

@spec key(atom(), String.t(), term()) :: {atom(), String.t(), binary()}

Builds the cache key for a prediction.

Parameters

  • name: model name
  • version: the version serving the request
  • input: the input term, or an explicit :cache_key value

Examples

iex> key = MLServe.Cache.key(:fraud, "1.0.0", %{amount: 100})
iex> match?({:fraud, "1.0.0", _digest}, key)
true

iex> MLServe.Cache.key(:fraud, "1.0.0", %{b: 1, a: 2}) ==
...>   MLServe.Cache.key(:fraud, "1.0.0", %{a: 2, b: 1})
true

put(key, value, ttl)

@spec put(term(), term(), pos_integer()) :: :ok

Stores a result under key for ttl milliseconds.

size()

@spec size() :: non_neg_integer()

Number of entries currently held, including any not yet swept.