All notable changes to the MLServe library will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.1.3] - 2026-09-09
Supersedes 0.1.1 and 0.1.2, which were tagged but never published: each tag pointed at a commit whose
mix.exsversion did not match it, so the release guard inpublish.ymlrejected them before anything reached Hex. Everything intended for those versions is included here.
Fixed
MLServe.Security.digest/2raised on Elixir 1.14 through 1.16, which broke every:checksumverification on three of the five Elixir versions this library supports. The chunked read went throughFile.stream!/2, whose second argument only came to mean a byte count in Elixir 1.17 — before that it is stillmodes, so an integer raisedFunctionClauseErrorinFile.normalize_modes/2. There is no singleFile.stream!call that is correct on 1.14 and free of a contract violation on 1.18, so hashing now reads the file withFile.open/2and:file.read/2, which have been stable across every supported version. Streaming behaviour and peak memory are unchanged.MLServe.Security.validate_path/2reported:enoentinstead of:outside_rootfor a traversal whose target happened not to exist, making the result depend on the filesystem rather than on the path. Containment is now decided on the expanded path before the file is touched and re-checked after symlink resolution, so a path outside:model_rootis always rejected as:outside_root— and MLServe no longer stats a caller-supplied path outside the root.
Changed
Package description now leads with "Serve machine learning models in Elixir" rather than "BEAM", and names Phoenix — matching how people actually search for this.
README.mdgained a "Common questions" section and a plain-prose "Works with" line naming Nx, Bumblebee, Ortex and Phoenix, none of which previously appeared as indexable text.llms.txtandusage-rules.mdnow point at the runnable examples inexamples/, so coding agents cite verified working code rather than inventing snippets.
0.1.0 - 2026-08-24
Initial release.
Added
MLServe.Modelbehaviour. Two required callbacks —load/1andpredict/2— withbatch_predict/2,unload/1,metadata/1andcapabilities/0optional and detected viafunction_exported?/3. The smallest useful backend is two functions.Two execution strategies, declared by the backend.
concurrency: :sharedrunspredict/2in the calling process with state read from:persistent_term, starting no worker processes and copying no tensors between mailboxes — the right shape forNx.Serving, Bumblebee, pure functions and remote services.concurrency: :exclusiveuses a supervised worker pool, for ONNX sessions, ports and anything not thread-safe. Crossed withload: :once | :per_worker, so a NIF-resource model is loaded once and shared rather than once per worker.Per-model supervision subtrees. Each loaded
{name, version}is its own:rest_for_onesubtree under aDynamicSupervisor, so a backend that crash-loops exhausts its own restart budget and marks itself:failedwhile every other model keeps serving.Lock-free dispatch.
MLServe.ModelRegistryowns a:protected,read_concurrencyETS catalog but is never in the read path:predict/3resolves a model with a direct:ets.lookup/2in the caller. The hot path contains no MLServe process other than the worker running inference.Asynchronous model loading with backoff retry.
load_model/2returns once the model is registered; loading proceeds underhandle_continue/2so a thirty-second load never blocks application boot. Failures retry with exponential backoff before the model is marked:failed, with the reason preserved inmodel_status/2.ready?/1andawait_ready/2support readiness probes and tests.Dynamic batching. Optional per-model
batching: [max_size: 16, timeout: 10]coalesces independent concurrentpredict/3calls into one backend invocation. The batcher never blocks on inference — flushes are handed to a supervised task — and in-flight batches are capped at the worker count, which is the backpressure.[:ml_serve, :batch, :flush]reports:fullversus:timeoutso the window can be tuned.Explicit batching.
batch_predict/3calls the backend'sbatch_predict/2when exported, and falls back to a mappedpredict/2otherwise. Result count and order are validated.Deadlines and load shedding. Requests carry an absolute monotonic deadline that workers check before invoking the backend, so work whose caller has already timed out is dropped rather than run.
max_concurrencyadds process-free admission control via atomic counters, returning{:error, :overloaded}rather than queueing.Model versioning, canary rollout and graceful drain. Models are keyed
{name, version}and run side by side.canary/3routes a percentage of unpinned traffic to a candidate, rolled per-request in the caller with no coordination point;promote/2flips the default with a single ETS write;unload_model/2drains in-flight requests before terminating, reporting stragglers as thedrainedmeasurement. Telemetry carriesversionandcanary?, which is what makes the promote-or-roll-back decision measurable.preprocess/postprocesshooks. Per-model{module, function, args}hooks that run in the calling process, before dispatch — so a feature-store or pgvector query never occupies a GPU worker slot. One mechanism covers feature enrichment, tensor conversion, label decoding, and input validation (a hook returning{:error, {:invalid_input, reason}}rejects the request).Optional inference cache. Off by default, because caching is only correct when the same input must produce the same output. ETS-backed with TTL, lazy and swept expiry, and a
max_sizebound. Keys use:erlang.term_to_binary(input, [:deterministic])so equal maps hash equally; a custom:cache_keyavoids hashing large inputs. Errors are never cached, and entries are invalidated when a model version is unloaded.Telemetry.
[:ml_serve, :prediction, :start | :stop | :exception]as a standard span, plus[:ml_serve, :model, :load],[:ml_serve, :model, :unload],[:ml_serve, :cache, :hit | :miss]and[:ml_serve, :batch, :flush].queue_durationandinference_durationare reported separately so a rise in latency distinguishes "the pool is too small" from "the model got slower". A backend that returns an error produces a:stopwithresult: :error, not an:exception— an expected rejection is not a crash.MLServe.Telemetry.Loggerfor dependency-free visibility, andMLServe.Telemetry.MetricsreturningTelemetry.Metricsdefinitions for LiveDashboard when the optional:telemetry_metricsdependency is present, and[]when it is not.Structured errors.
MLServe.Errorwith a:type, andMLServe.BackendErrorcarrying the backend, callback, kind, reason and stacktrace when a backend raises, throws or exits. Failures are never swallowed — catching is used only to attach context before surfacing.MLServe.Error.retryable?/1separates transient conditions from permanent ones, which is what the Oban guide uses to choose between:snoozeand:discard.Artifact security.
MLServe.Securityvalidates model paths against a configured:model_root, rejecting..traversal and symlink escape (symlinks are resolved before the containment check), and enforcing existence, readability and a size limit. Optionalchecksum: {:sha256, hex}verification streams the file rather than reading it into memory, and compares digests in constant time. Backend modules are verified to implementMLServe.Modelat load time. The core never callsbinary_to_term/1,Code.eval_*, or loads a NIF from a model artifact — a model file is data.Built-in backends.
MLServe.Backend.Functionwraps any function or MFA (useful for rules engines and glue, and the workhorse of the test suite);MLServe.Backend.Staticreturns a fixed result, so applications can test against MLServe's real routing without a model file or an ML runtime.Eleven guides, including complete backend implementations for Nx,
Nx.Serving, Bumblebee, ONNX via Ortex, a Python model server over a port, and a remote HTTP service — shipped as documentation rather than dependencies so the core stays at one runtime dependency and backends version independently.
Robustness invariants
Found and fixed by the suite's own chaos tests, and asserted so they cannot regress:
- A worker that dies mid-request never kills its caller. Every exit from a worker call is caught and retried on another worker, so a crash during inference returns an error tuple instead of propagating into the Phoenix request process that made the call.
- Worker restarts are invisible to callers. A killed worker stays registered until its monitor fires, so selection checks liveness and dispatch retries a fresh worker rather than failing on a pid that is already gone.
- A dead batch task frees its slot and answers its callers. Flush tasks are monitored; without that, one killed task would leak an in-flight slot until the batcher wedged permanently, and its callers would block until their own timeouts.
ready?/1never reports ready beforemodel_status/2agrees. The status entry is written before the route, so a readiness probe and a status page can never disagree.[:ml_serve, :model, :load]is emitted before a model is marked ready, so anything observing readiness can rely on the load event having already fired.
Notes
- The only runtime dependency is
:telemetry. Phoenix, Oban, Ecto and Plug are documented integrations, not dependencies. Configuration validation is hand-rolled rather than pulling in a schema library. :timeoutis not enforced forconcurrency: :sharedbackends: they run in the calling process, so there is no other process to abandon. This is the same contract as calling any function directly, and is documented onMLServe.predict/3.- MLServe is node-local by design. Each node has its own catalog, counters and cache; there is no distributed coordination and none is required for stateless inference.