Lesson 69 of 73
Loading, Unloading, and Scheduling Models
A language model has to sit in memory before it can answer anything. A 7-billion-parameter model takes several gigabytes, and the memory belongs to a running engine process for as long as that process lives. A gateway that offers several models faces a planning problem. It cannot keep every model loaded, because memory is limited. It also cannot start a fresh engine for every request, because loading takes seconds to minutes. Something has to decide which engines run, when to start one, when to stop one, and how many requests each may serve at the same time.
This lesson builds that decision maker for the Spring Boot gateway. It is a bean (a Spring-managed object) called ModelManager. It starts a model on the first request for it, stops it after an idle period called keep_alive, limits the concurrent requests per model, evicts (removes) a model when memory or the model count runs out, and stops every engine on shutdown. The controllers of the other gateway lessons talk to the manager through four methods: acquire, preload, unload, and snapshot.
After the lesson, you will have written a manager with that small API and tested each behavior. You will also be able to explain where it differs from Ollama's scheduler, which solves the same problem inside Ollama.
The table shows which example covers which part.
| Part | Example |
|---|---|
| Backends as Spring beans, fake engine | 1 |
gateway.* properties and the model catalog |
2 |
ModelManager, Lease, load on first request |
3 |
| Concurrent first requests, one engine start | 4 |
| A failed load | 5 |
keep_alive values |
6 |
| Idle unload | 7 |
| Per-model concurrency limit and queue | 8 |
| Maximum loaded models and eviction | 9 |
Memory check with loop fit |
10 |
| HTTP wiring and error mapping | 11 |
Load and unload requests, /api/ps |
12 |
/api/pull with streaming progress |
13 |
| Graceful shutdown | 14 |
| Comparison with Ollama's scheduler | 15 |
The lesson uses the setup of Spring Boot Essentials for an LLM Gateway: Spring Boot 4.1.1 with spring-boot-starter-web (Spring MVC) and spring-boot-starter-validation, JDK 25, virtual threads enabled with spring.threads.virtual.enabled=true, and RestClient for outgoing HTTP calls. A virtual thread is a cheap JDK thread that may block freely. Because the code runs on virtual threads, a thread that waits for an engine to load or for a free slot costs little, and the manager can wait with plain blocking calls instead of callbacks.
Spring Boot 4.1.1 pulls in Jackson 3 (tools.jackson.databind, version 3.1.5 in the local Maven repository). Annotations such as @JsonProperty still come from com.fasterxml.jackson.annotation.
The backend classes come from the serve and chat workshop. The request path comes from An OpenAI-Compatible API in Front of llama-server, and the stream handling from Streaming Responses Through the Gateway.
The engines in this lesson are fake OpenAI-compatible processes: a small Java program that answers /health and /v1/chat/completions. This makes every run fast and repeatable, and no example uses a real model. All code was compiled and run in a Maven project. Spring code blocks are marked java,ignore because the single-file checker of the course cannot run Spring. The output blocks are the real output of the tests, the jar, and curl. The two framework-free helpers (the keep_alive parser and the eviction comparison) are runnable java blocks.
Example 1: backends as Spring beans, and a fake engine
The manager needs one thing it can start and stop, whatever kind of engine sits behind it. The workshop's Backend interface is that unit. It has a base URL, a way to wait until the engine is ready, and a way to stop it. The gateway keeps the interface and adds one method, alive(), so the manager can notice an engine that crashed while it sat idle.
/** Lesson 38's Backend, plus alive() so the manager can notice a crashed engine. */
public interface Backend extends AutoCloseable {
String name();
String baseUrl(); // ends with /v1
void ensureReady() throws Exception; // start if needed, then wait until healthy
boolean alive();
@Override void close(); // stop only what this backend started
}ProcessBackend is the workshop's supervisor reduced to what the manager needs. It starts the child process (the engine program). Then it polls GET /health with a growing pause between tries (25 ms, doubling up to 500 ms) until the answer is 200. This is the same signal llama-server gives: it answers 503 while the model loads and 200 when it is ready. If the process exits during the wait, or 30 seconds pass, the method throws. On close() it stops the child and any processes the child started: first a polite destroy(), and after stopTimeout a forced destroyForcibly().
@Override public void ensureReady() throws Exception {
ProcessBuilder pb = new ProcessBuilder(command).redirectErrorStream(true)
.redirectOutput(ProcessBuilder.Redirect.DISCARD);
pb.environment().remove("JAVA_TOOL_OPTIONS");
process = pb.start();
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
long sleep = 25;
while (System.nanoTime() < deadline) {
if (!process.isAlive()) throw new IllegalStateException("engine exited with code " + process.exitValue());
if (probe() == 200) return;
Thread.sleep(sleep);
sleep = Math.min(sleep * 2, 500);
}
throw new IllegalStateException("engine not ready after 30s");
}
@Override public void close() {
if (process == null) return;
process.descendants().forEach(ProcessHandle::destroy);
process.destroy();
try {
if (!process.waitFor(stopTimeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)) {
process.descendants().forEach(ProcessHandle::destroyForcibly);
process.destroyForcibly();
process.waitFor();
}
} catch (InterruptedException e) {
process.destroyForcibly();
Thread.currentThread().interrupt();
}
}A factory builds one backend per model. In this project the command line comes from a template in the configuration, with {port} and {model} replaced for each engine. A free port comes from ServerSocket(0), which asks the operating system for any unused port, as in the workshop. A production factory would build the llama-server command from the registry entry and loop fit, as the workshop's LlamaServerBackend does.
import demo.GatewayProperties.ModelSpec;
import java.io.IOException;
import java.net.ServerSocket;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
class ProcessBackendFactory implements BackendFactory {
private final GatewayProperties props;
ProcessBackendFactory(GatewayProperties props) { this.props = props; }
@Override public Backend create(String model, ModelSpec spec) {
int port = freePort();
List<String> command = new ArrayList<>();
for (String part : props.engineCommand()) {
command.add(part.replace("{port}", Integer.toString(port)).replace("{model}", model));
}
return new ProcessBackend(model, command, port, props.stopTimeout());
}
private static int freePort() {
try (ServerSocket s = new ServerSocket(0)) {
return s.getLocalPort();
} catch (IOException e) {
throw new IllegalStateException("no free port", e);
}
}
}The fake engine is a small Java program. Its /health answers 503 until a load delay has passed and 200 afterwards, which imitates a model that is still loading. Its /v1/chat/completions answers after a generation delay. It also reports how many requests were in flight when this one started, which later examples use to show concurrency.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
/** A fake OpenAI-compatible engine: args are port, model name, load milliseconds, generation milliseconds. */
public final class FakeEngine {
public static void main(String[] args) throws Exception {
int port = Integer.parseInt(args[0]);
String model = args[1];
long loadMs = Long.parseLong(args[2]), genMs = Long.parseLong(args[3]);
long readyAt = System.currentTimeMillis() + loadMs;
AtomicInteger inFlight = new AtomicInteger();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/health", ex -> {
boolean ready = System.currentTimeMillis() >= readyAt;
respond(ex, ready ? 200 : 503, ready ? "{\"status\":\"ok\"}" : "{\"error\":{\"message\":\"Loading model\"}}");
});
server.createContext("/v1/chat/completions", ex -> {
ex.getRequestBody().readAllBytes();
int concurrent = inFlight.incrementAndGet();
try {
Thread.sleep(genMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
inFlight.decrementAndGet();
}
respond(ex, 200, "{\"model\":\"" + model + "\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\","
+ "\"content\":\"reply from " + model + "\"},\"finish_reason\":\"stop\"}],\"in_flight_at_start\":" + concurrent + "}");
});
server.start();
}
private static void respond(com.sun.net.httpserver.HttpExchange ex, int status, String body) throws java.io.IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(status, bytes.length);
ex.getResponseBody().write(bytes);
ex.close();
}
}A test starts one engine through the factory, reads the state at three points, closes the backend, and then counts engine processes on the machine to prove none is left.
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class BackendFactoryTest {
@Test
void factoryBuildsAndStopsAnEngine() throws Exception {
GatewayProperties props = Lab.props(2, 1);
BackendFactory factory = new ProcessBackendFactory(props);
try (Backend backend = factory.create("coder:7b", props.models().get("coder:7b"))) {
System.out.println("alive before start: " + backend.alive());
backend.ensureReady();
System.out.println("alive after ensureReady: " + backend.alive());
System.out.println("base url shape: " + backend.baseUrl().replaceAll(":\\d+/", ":<port>/"));
assertTrue(backend.alive());
}
long left = ProcessHandle.allProcesses().filter(p -> p.info().command().map(c -> c.endsWith("/java")).orElse(false))
.filter(p -> p.info().commandLine().map(c -> c.contains("demo.dev.FakeEngine")).orElse(false)).count();
System.out.println("engine processes after close: " + left);
assertFalse(left > 0);
}
}alive before start: false
alive after ensureReady: true
base url shape: http://127.0.0.1:<port>/v1
engine processes after close: 0Before ensureReady() the backend has no process, so alive() is false. After it returns, the process answers /health with 200 and alive() is true. The base URL has the shape http://127.0.0.1:<port>/v1. The port differs on every run, so the test replaces it before printing, which keeps the output stable.
After close() no engine process is left. The test counts processes whose command is java and whose command line contains the fake engine's class name, and the count is 0.
Example 2: gateway properties and the model catalog
The manager needs two kinds of configuration: a catalog of the models it may start, and the limits it enforces. GatewayProperties holds both. It is a @ConfigurationProperties record (taught in the Spring Boot essentials lesson), so Spring fills it from application.yaml or command-line arguments. It has defaults for optional values and validation for required ones.
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.util.unit.DataSize;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties("gateway")
public record GatewayProperties(
@DefaultValue("5m") Duration keepAlive,
@DefaultValue("3") @Min(1) int maxLoadedModels,
@DefaultValue("1") @Min(1) int parallelPerModel,
@DefaultValue("8") @Min(0) int maxQueue,
@DefaultValue("30s") Duration queueTimeout,
@DefaultValue("5s") Duration stopTimeout,
@DefaultValue("5s") Duration reapInterval,
@NotNull DataSize memoryBudget,
@NotEmpty List<String> engineCommand,
@NotEmpty Map<String, @Valid ModelSpec> models,
@NotNull String hubUrl,
@NotNull Path modelDir) {
public record ModelSpec(
@NotNull DataSize weights,
@Min(1) long kvBytesPerToken,
@DefaultValue("4096") @Min(1) int contextTokens) { }
}Each ModelSpec carries the three numbers the memory check needs: the size of the weight file, the KV cache bytes per token, and the context length the engine is started with. The KV cache (key-value cache) is the memory an engine uses to remember the tokens of the current conversation, and it grows with every token. Its size per token is worked out in Memory Math.
For a Qwen2.5-Coder-7B shape, the values are 28 layers, 4 KV heads, a head size of 128, and 2 bytes per value. The formula is 2 * 28 * 4 * 128 * 2 = 57,344 bytes per token. The leading 2 stands for the two things stored per token, a key and a value.
The configuration file lists the models, the limits, and the engine command.
spring:
threads:
virtual:
enabled: true
lifecycle:
timeout-per-shutdown-phase: 20s
server:
shutdown: graceful
gateway:
keep-alive: 5m
max-loaded-models: 2
parallel-per-model: 2
max-queue: 4
queue-timeout: 10s
memory-budget: 16GB
engine-command: [java, -cp, target/classes, demo.dev.FakeEngine, "{port}", "{model}", "300", "400"]
hub-url: http://127.0.0.1:18680
model-dir: ${java.io.tmpdir}/gw68-models
models:
"[coder:7b]":
weights: 4466MB
kv-bytes-per-token: 57344
context-tokens: 8192
"[chat:8b]":
weights: 4900MB
kv-bytes-per-token: 131072
context-tokens: 8192
"[big:70b]":
weights: 40GB
kv-bytes-per-token: 327680
context-tokens: 8192Two details of the binding cause failures that are hard to trace.
- A map key with a colon, such as
coder:7b, must be written as"[coder:7b]". With a plain quoted key, the colon is dropped and the model is not found. The firstcurlin this project returned 404 for that reason. @Validgoes on the map's type argument (Map<String, @Valid ModelSpec>), because Hibernate Validator reports@Validon the container as deprecated.
The application also needs @ConfigurationPropertiesScan and @EnableScheduling (used in Example 7) on the main class, and a Clock bean, so that tests can replace the clock.
Starting the jar with invalid values shows the validation. The run passed --gateway.max-loaded-models=0 and an empty --gateway.memory-budget=.
APPLICATION FAILED TO START
***************************
Description:
Binding to target demo.GatewayProperties failed:
Property: gateway.maxLoadedModels
Value: "0"
Origin: "gateway.max-loaded-models" from property source "commandLineArgs"
Reason: must be greater than or equal to 1
Property: gateway.memoryBudget
Value: "null"
Reason: must not be nullThe application refuses to start and names both properties, their origin, and the rule they broke. A wrong limit becomes a startup failure instead of a surprise at runtime. max-loaded-models has @Min(1) because a limit of zero would reject every model.
Example 3: the model manager and load on first request
Here is the plan of the manager before the code. For every model that is loading, ready, or stopping, the manager keeps one Slot. A slot is the manager's record for one model. A request that wants to use a model takes a Lease. A lease is a handle that says "this request is using this model right now". Closing the lease frees the request's place, so the model can become idle again.
request --acquire--> ModelManager --reserve--> Slot (LOADING -> READY)
--awaitPermit--> permit for one concurrent request
request <--Lease (model, baseUrl)--
request uses baseUrl, then lease.close() --> finish(slot) --> idle timer startsThe Lease class is small. Its close() runs the release action once, even if called twice, because of the AtomicBoolean guard. It implements AutoCloseable, so a request can hold it in try-with-resources and never forget to give it back.
import java.util.concurrent.atomic.AtomicBoolean;
/** One request's hold on a loaded model. Closing it frees the slot and starts the keep_alive timer when it was the last. */
public final class Lease implements AutoCloseable {
private final String model;
private final String baseUrl;
private final Runnable release;
private final AtomicBoolean closed = new AtomicBoolean();
Lease(String model, String baseUrl, Runnable release) {
this.model = model; this.baseUrl = baseUrl; this.release = release;
}
public String model() { return model; }
public String baseUrl() { return baseUrl; }
@Override public void close() {
if (closed.compareAndSet(false, true)) release.run();
}
}A Slot holds the backend, a semaphore, a count of requests that use the model, and the data for keep_alive. A semaphore is a counter of permits: a thread takes a permit before it proceeds and gives it back afterwards, and when no permit is left, threads wait. Here there is one permit per concurrent request the model allows. The reserved field counts requests that hold a permit or wait for one. The state field is one of LOADING, READY, and STOPPING.
private static final class Slot {
final String name;
final long bytes;
final Semaphore permits;
State state = State.LOADING;
Backend backend;
int reserved; // requests that hold a permit or wait for one
int waiting; // requests waiting for a permit
Duration keepAlive;
Instant lastUsed;
Instant expiresAt; // null while busy or when keep_alive is forever
Slot(String name, long bytes, int permits, Duration keepAlive, Instant now) {
this.name = name; this.bytes = bytes; this.permits = new Semaphore(permits, true);
this.keepAlive = keepAlive; this.lastUsed = now;
}
}All decisions are made under one ReentrantLock. A lock lets one thread at a time run a block of code. The lock has a Condition named changed. A thread that cannot continue calls changed.await() and sleeps. Whenever anything changes, another thread calls changed.signalAll() to wake every sleeping thread, and each one re-checks whether it can go on. Slow work, such as starting and stopping processes, happens outside the lock, so one slow engine does not freeze requests for other models.
reserve() is the center of the manager. It loops until it finds a way forward. In each pass the branches mean:
- If the manager is shutting down, fail.
- If the slot is
READYbut the engine has died, stop it and start over. - If the slot is
READYand healthy, count one more request on it and return it. - If the slot is
LOADINGorSTOPPING, wait for the change. This is how concurrent first requests share one load (Example 4). - If the model has no slot and there is no room (too many models or too little memory), evict a victim or wait (Examples 9 and 10).
- Otherwise create a new slot in state
LOADING, then leave the lock and start the engine.
In this example only the last branch matters: the model has no slot and there is room. After the engine is ready, the code takes the lock again, marks the slot READY, wakes everyone, and publishes a Loaded event.
private Slot reserve(String model, long bytes, Duration keepAlive) throws InterruptedException {
Slot fresh;
lock.lock();
try {
while (true) {
if (closing) throw new ShuttingDownException();
Slot s = slots.get(model);
if (s != null && s.state == State.READY && !s.backend.alive()) {
stop(s, "engine exited");
continue;
}
if (s != null && s.state == State.READY) {
s.reserved++;
s.keepAlive = keepAlive;
s.expiresAt = null;
return s;
}
if (s != null) { changed.await(); continue; } // LOADING or STOPPING: wait for the change
if (slots.size() >= props.maxLoadedModels() || used() + bytes > props.memoryBudget().toBytes()) {
Slot victim = victim();
if (victim == null) { changed.await(); continue; } // every loaded model is busy or stopping
stop(victim, "evicted to make room for " + model);
continue;
}
fresh = new Slot(model, bytes, props.parallelPerModel(), keepAlive, clock.instant());
slots.put(model, fresh); // memory is reserved before the engine starts
break;
}
} finally {
lock.unlock();
}
Backend backend = factory.create(model, props.models().get(model));
try {
backend.ensureReady();
} catch (Exception e) {
backend.close();
lock.lock();
try { slots.remove(model); changed.signalAll(); } finally { lock.unlock(); }
if (e instanceof InterruptedException ie) throw ie;
throw new LoadFailedException(model, e);
}
lock.lock();
try {
fresh.backend = backend;
fresh.state = State.READY;
fresh.reserved++;
changed.signalAll();
} finally {
lock.unlock();
}
events.publishEvent(new ModelEvent.Loaded(model, bytes));
return fresh;
}acquire() is the public entry point. It checks that the model name is in the catalog and that the model can ever fit in the memory budget. Then it calls reserve() and finally waits for a permit (Example 8). If anything fails after reserve(), the catch block calls finish(slot) so the reservation is given back.
/** Returns a lease on a ready engine for the model, loading it and evicting others as needed. */
public Lease acquire(String model, Duration keepAlive) throws InterruptedException {
ModelSpec spec = props.models().get(model);
if (spec == null) throw new UnknownModelException(model);
long bytes = MemoryPlan.estimate(spec, props.parallelPerModel());
if (bytes > props.memoryBudget().toBytes()) throw new DoesNotFitException(model, bytes, props.memoryBudget().toBytes());
Slot slot = reserve(model, bytes, keepAlive == null ? props.keepAlive() : keepAlive);
try {
awaitPermit(slot);
} catch (RuntimeException | InterruptedException e) {
finish(slot);
throw e;
}
return new Lease(model, slot.backend.baseUrl(), () -> { slot.permits.release(); finish(slot); });
}The test helper Lab builds a manager with real fake-engine processes, a clock the test moves by hand, and an event log. The manager publishes ModelEvent.Loaded and ModelEvent.Unloaded through Spring's ApplicationEventPublisher. The metrics lesson (Securing and Observing the Gateway) listens to the same events.
/** A manager over real fake-engine processes; every event is appended to log. */
static ModelManager manager(GatewayProperties props, Clock clock, List<String> log) {
return new ModelManager(props, new ProcessBackendFactory(props), clock, event -> {
switch ((ModelEvent) event) {
case ModelEvent.Loaded l -> log.add("loaded " + l.model());
case ModelEvent.Unloaded u -> log.add("unloaded " + u.model() + " (" + u.reason() + ")");
}
});
}
static String view(ModelManager m) {
List<String> parts = new ArrayList<>();
for (var s : m.snapshot()) parts.add(s.model() + "=" + s.state().name().toLowerCase() + " in_flight=" + s.inFlight() + " queued=" + s.queued());
return parts.isEmpty() ? "(nothing loaded)" : String.join(", ", parts);
}The first test asks for a model, looks at the state before, during, and after, asks again, and prints the event log.
import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.Test;
class FirstRequestTest {
@Test
void firstRequestLoadsTheModel() throws Exception {
List<String> log = Lab.log();
ModelManager manager = Lab.manager(Lab.props(2, 1), new Lab.MutableClock(), log);
System.out.println("before: " + Lab.view(manager));
try (Lease lease = manager.acquire("coder:7b", null)) {
System.out.println("during: " + Lab.view(manager));
System.out.println("lease model: " + lease.model());
}
System.out.println("after: " + Lab.view(manager));
try (Lease again = manager.acquire("coder:7b", null)) {
System.out.println("second request reuses the engine");
}
log.forEach(System.out::println);
manager.shutdown();
log.stream().skip(1).forEach(System.out::println);
}
}before: (nothing loaded)
during: coder:7b=ready in_flight=1 queued=0
lease model: coder:7b
after: coder:7b=ready in_flight=0 queued=0
second request reuses the engine
loaded coder:7b
unloaded coder:7b (gateway shutdown)Before the first request nothing is loaded and no engine process exists. The acquire call returns only after the engine answers /health with 200, so it takes the engine's load time. During the request the model is ready with one request in flight.
When the lease closes, the model stays ready with zero requests in flight. The engine keeps running for the keep_alive period, so the next request does not pay the load time again. The second request finds the ready slot and returns at once, with no second load event. The event log has one loaded line, and shutdown() (Example 14) adds the unloaded line.
Example 4: concurrent first requests start one engine
Several requests for an unloaded model can arrive together. A coding agent that sends parallel tool-result turns does this, for example. The manager must start one engine, not one per request, because each engine would load its own copy of the model into memory.
The LOADING slot is the guard. The first thread creates it. Later threads find it in reserve() and wait on the condition. When the load ends, signalAll() wakes them, and they find a READY slot and share it.
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.Test;
class ConcurrentFirstRequestsTest {
@Test
void eightFirstRequestsStartOneEngine() throws Exception {
List<String> log = Lab.log();
ModelManager manager = Lab.manager(Lab.props(2, 8), new Lab.MutableClock(), log);
Set<String> urls = ConcurrentHashMap.newKeySet();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 8; i++) {
threads.add(Thread.ofVirtual().start(() -> {
try (Lease lease = manager.acquire("coder:7b", null)) {
urls.add(lease.baseUrl());
Lab.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
}
for (Thread t : threads) t.join();
System.out.println("requests served: 8");
System.out.println("distinct engine URLs: " + urls.size());
System.out.println("engine starts: " + log.stream().filter(l -> l.startsWith("loaded")).count());
manager.shutdown();
}
}requests served: 8
distinct engine URLs: 1
engine starts: 1Eight virtual threads ask for coder:7b at once, and the model allows eight parallel requests in this test (Lab.props(2, 8)). All eight are served and all of them use the same engine URL, and the event log has exactly one loaded event.
The memory for the new engine is reserved when the LOADING slot is created, before the engine starts. A second model that arrives during the load therefore already sees that memory as taken.
Example 5: a failed load leaves no trace
An engine can fail to start: a missing file, a port conflict, or an out-of-memory error. If the manager kept the LOADING slot after a failure, every later request would wait for a load that never ends. So the error path in reserve() cleans up completely. It closes the backend, removes the slot, wakes the waiting threads, and throws LoadFailedException.
The test points the engine command at a class that does not exist, so the child exits with code 1 right away. It tries twice to show that the second attempt starts fresh.
import demo.GatewayExceptions.LoadFailedException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.util.unit.DataSize;
class LoadFailureTest {
@Test
void failedLoadLeavesNoTrace() throws Exception {
GatewayProperties ok = Lab.props(2, 1);
GatewayProperties broken = new GatewayProperties(ok.keepAlive(), 2, 1, 8, Duration.ofSeconds(10), Duration.ofSeconds(5),
Duration.ofSeconds(5), DataSize.ofGigabytes(64),
List.of("java", "-cp", "target/classes", "demo.dev.NoSuchEngine", "{port}"), ok.models(), ok.hubUrl(), Path.of("target/models"));
List<String> log = Lab.log();
ModelManager manager = Lab.manager(broken, new Lab.MutableClock(), log);
for (int attempt = 1; attempt <= 2; attempt++) {
try {
manager.acquire("coder:7b", null);
} catch (LoadFailedException e) {
System.out.println("attempt " + attempt + ": " + e.getMessage());
}
System.out.println("state: " + Lab.view(manager));
}
System.out.println("events: " + log);
}
}attempt 1: engine for model 'coder:7b' failed to start: engine exited with code 1
state: (nothing loaded)
attempt 2: engine for model 'coder:7b' failed to start: engine exited with code 1
state: (nothing loaded)
events: []Each attempt reports the exit code of the engine. After each failure the manager shows nothing loaded, and no Loaded event was published. The second attempt starts a new engine, which fails the same way, so a failure does not poison the model name.
Threads that were waiting on the failed slot retry the load themselves. One broken model therefore produces one failure per waiting request and never a hang. The controller maps LoadFailedException to status 502 (Example 11).
Example 6: keep_alive values
keep_alive says how long an idle model stays loaded. Keeping it loaded makes the next request fast, because no load is needed. Unloading frees memory for other models. The right period depends on how often requests come, so the client may choose it per request.
The gateway accepts the forms that Ollama's API accepts:
- A number means seconds.
- A string such as
"10m"is a duration with units. 0unloads the model as soon as it is idle.- A negative number keeps the model loaded forever.
Ollama's api.Duration.UnmarshalJSON (in api/types.go) reads a number as seconds (negative means forever). It reads a string with Go's time.ParseDuration, where a negative result also means forever. The Java parser below follows that behavior. It is framework-free, so it runs as a normal Java program. A negative Duration represents forever, and a missing value falls back to the configured default. The main method feeds it twelve inputs, including bad ones.
import java.time.Duration;
public class KeepAliveStage {
/** Parses the keep_alive request field: a number of seconds, or a string such as "10m". Negative means forever. */
static final class KeepAlive {
public static final Duration FOREVER = Duration.ofSeconds(-1);
private KeepAlive() { }
public static Duration parse(Object value, Duration fallback) {
if (value == null) return fallback;
if (value instanceof Number n) {
return n.doubleValue() < 0 ? FOREVER : Duration.ofMillis(Math.round(n.doubleValue() * 1000));
}
if (value instanceof String s) {
Duration d = parseUnits(s.trim());
return d.isNegative() ? FOREVER : d;
}
throw new IllegalArgumentException("keep_alive must be a number or a string, not " + value.getClass().getSimpleName());
}
private static Duration parseUnits(String s) {
if (s.isEmpty()) throw new IllegalArgumentException("keep_alive is empty");
if (s.equals("0")) return Duration.ZERO; // Go accepts a bare 0 with no unit
boolean negative = s.startsWith("-");
String body = negative ? s.substring(1) : s;
long millis = 0;
int i = 0;
while (i < body.length()) {
int start = i;
while (i < body.length() && (Character.isDigit(body.charAt(i)) || body.charAt(i) == '.')) i++;
if (start == i) throw new IllegalArgumentException("invalid keep_alive: " + s);
double amount = Double.parseDouble(body.substring(start, i));
int unitStart = i;
while (i < body.length() && Character.isLetter(body.charAt(i))) i++;
double unit = switch (body.substring(unitStart, i)) {
case "ms" -> 1;
case "s" -> 1000;
case "m" -> 60_000;
case "h" -> 3_600_000;
default -> throw new IllegalArgumentException("invalid keep_alive unit in: " + s);
};
millis += Math.round(amount * unit);
}
return Duration.ofMillis(negative ? -millis : millis);
}
}
public static void main(String[] args) {
Duration fallback = Duration.ofMinutes(5);
Object[] inputs = {null, 90, 0, -1, 0.5, "10m", "1h30m", "250ms", "0", "-1", "-1m", "5"};
for (Object in : inputs) {
String shown = in instanceof String ? "\"" + in + "\"" : String.valueOf(in);
try {
System.out.println(shown + " -> " + KeepAlive.parse(in, fallback));
} catch (IllegalArgumentException e) {
System.out.println(shown + " -> error: " + e.getMessage());
}
}
}
}null -> PT5M
90 -> PT1M30S
0 -> PT0S
-1 -> PT-1S
0.5 -> PT0.5S
"10m" -> PT10M
"1h30m" -> PT1H30M
"250ms" -> PT0.25S
"0" -> PT0S
"-1" -> error: invalid keep_alive unit in: -1
"-1m" -> PT-1S
"5" -> error: invalid keep_alive unit in: 5Read the output line by line:
- The first line shows the fallback. With no
keep_alivein the request, the default of 5 minutes applies (PT5Mis the ISO-8601 way to print 5 minutes). - A number is seconds, so
90is one minute and thirty seconds,0is zero,-1is the negative duration that means forever, and0.5is half a second. - A string needs units.
"10m","1h30m"(two units add up), and"250ms"work. - A bare
"0"is accepted, because Go'sParseDurationaccepts"0"without a unit. "-1m"is negative, so it means forever, the same as-1.- The strings
"-1"and"5"fail because they have no unit, which is also what Go's parser does.
A bad value becomes a 400 response (Example 12) before any engine starts.
Example 7: idle unload with keep_alive
Now the manager has to act on keep_alive. When the last request on a model ends, the manager records the time and computes expiresAt = now + keep_alive. A scheduled method wakes up regularly. It unloads every ready model that has no request in flight and whose expiresAt has passed. The request's keep_alive overrides the default and stays with the slot, so the latest request's value decides, as in Ollama.
/** Called when a request ends: the last one out starts the keep_alive timer, or unloads at once for keep_alive 0. */
private void finish(Slot s) {
lock.lock();
try {
s.reserved--;
if (s.reserved > 0) return;
Instant now = clock.instant();
s.lastUsed = now;
if (s.keepAlive.isZero()) stop(s, "keep_alive 0");
else s.expiresAt = s.keepAlive.isNegative() ? null : now.plus(s.keepAlive);
changed.signalAll();
} finally {
lock.unlock();
}
}
/** Unloads every idle model whose keep_alive has run out. */
@Scheduled(fixedDelayString = "${gateway.reap-interval:5s}")
public void reapIdle() {
lock.lock();
try {
Instant now = clock.instant();
for (Slot s : new ArrayList<>(slots.values())) {
if (s.state == State.READY && s.reserved == 0 && s.expiresAt != null && !now.isBefore(s.expiresAt)) {
stop(s, "keep_alive expired");
}
}
} finally {
lock.unlock();
}
}finish() runs when a lease closes. It subtracts one request. If that was not the last request, it stops there. If it was the last one, it stamps lastUsed. Then it unloads at once for keep_alive 0, and otherwise sets the expiry (none for forever).
reapIdle() is @Scheduled with a pause taken from gateway.reap-interval, so the timer lives in one place instead of one timer per model. The method reads time from the injected Clock, which lets a test move time without waiting.
The test loads three models with three different values, then advances the fake clock in steps and calls reapIdle() after each step.
import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.Test;
class KeepAliveExpiryTest {
@Test
void idleModelsUnloadWhenKeepAliveEnds() throws Exception {
List<String> log = Lab.log();
Lab.MutableClock clock = new Lab.MutableClock();
ModelManager manager = Lab.manager(Lab.props(3, 1), clock, log);
manager.acquire("coder:7b", null).close(); // default keep_alive: 5m
manager.acquire("chat:8b", Duration.ofSeconds(30)).close(); // keep_alive 30s
manager.acquire("tiny:1b", KeepAlive.FOREVER).close(); // keep_alive -1
long[] stepsSeconds = {0, 29, 1, 270, 3_600};
for (long step : stepsSeconds) {
clock.advance(Duration.ofSeconds(step));
manager.reapIdle();
System.out.println(clock.time() + " loaded: " + manager.snapshot().stream().map(ModelManager.LoadedModel::model).toList());
}
log.stream().filter(l -> l.startsWith("unloaded")).forEach(System.out::println);
manager.acquire("coder:7b", Duration.ZERO).close(); // keep_alive 0
System.out.println("after keep_alive 0: " + manager.snapshot().stream().map(ModelManager.LoadedModel::model).toList());
manager.shutdown();
}
}09:00:00 loaded: [coder:7b, chat:8b, tiny:1b]
09:00:29 loaded: [coder:7b, chat:8b, tiny:1b]
09:00:30 loaded: [coder:7b, tiny:1b]
09:05:00 loaded: [tiny:1b]
10:05:00 loaded: [tiny:1b]
unloaded chat:8b (keep_alive expired)
unloaded coder:7b (keep_alive expired)
after keep_alive 0: [tiny:1b]Three models load at 09:00:00 with three different values: coder:7b with the default 5 minutes, chat:8b with 30 seconds, and tiny:1b with forever. The steps then move the clock to 09:00:00, 09:00:29, 09:00:30, 09:05:00, and 10:05:00.
- At 09:00:29 nothing has expired, so all three are still loaded.
- At 09:00:30 the 30-second timer of
chat:8bhas run out, and the reaper unloads it. - At 09:05:00 the default timer of
coder:7bhas run out. - An hour later
tiny:1bis still loaded, because forever has no expiry.
The event lines give the reason for each unload. The last line loads coder:7b with keep_alive 0. The request ends, finish() unloads the model at once, and only tiny:1b remains.
The scheduled method also works in the running application. A run with --gateway.reap-interval=1s loaded coder:7b with "keep_alive":"2s" through /api/generate (Example 12). /api/ps listed it right away and listed nothing four seconds later.
Example 8: per-model concurrency limit and queue
An engine has a fixed number of slots for parallel requests (-np for llama-server, as in Example 2 of the serve workshop). If more requests arrive than there are slots, they pile up inside the engine, with no limit and no visibility from outside. The manager takes control of this itself, with three rules:
- A fair
Semaphoreper model, sized toparallel-per-model, gives out the slots. Fair means waiting threads get permits in the order they arrived. - A request that finds no free permit waits in a bounded queue. The bound is
max-queue. - A request that finds the queue full is rejected at once, and a waiting request gives up after
queue-timeout.
private void awaitPermit(Slot s) throws InterruptedException {
if (s.permits.tryAcquire()) return;
lock.lock();
try {
if (s.waiting >= props.maxQueue()) throw new QueueFullException(s.name, props.maxQueue());
s.waiting++;
} finally {
lock.unlock();
}
boolean got = false;
try {
got = s.permits.tryAcquire(props.queueTimeout().toMillis(), TimeUnit.MILLISECONDS);
} finally {
lock.lock();
try { s.waiting--; } finally { lock.unlock(); }
}
if (!got) throw new QueueTimeoutException(s.name);
}The reserved count (requests that hold or wait for a permit) is raised before the wait. This protects a model that has waiting requests from eviction (Example 9).
The first test uses one slot and a queue of two. Request A takes the only permit. B and C arrive 100 ms apart and wait. D arrives to a full queue. The second test shows the time limit.
import demo.GatewayExceptions.QueueFullException;
import demo.GatewayExceptions.QueueTimeoutException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.junit.jupiter.api.Test;
import org.springframework.util.unit.DataSize;
class QueueTest {
@Test
void oneSlotTwoWaitersThenReject() throws Exception {
ModelManager manager = Lab.manager(Lab.props(2, 1, 2, Duration.ofSeconds(10), DataSize.ofGigabytes(64)),
new Lab.MutableClock(), Lab.log());
List<String> order = new CopyOnWriteArrayList<>();
Lease first = manager.acquire("coder:7b", null);
System.out.println("A running: " + Lab.view(manager));
List<Thread> waiters = new ArrayList<>();
for (String name : new String[] {"B", "C"}) {
waiters.add(Thread.ofVirtual().start(() -> {
try (Lease l = manager.acquire("coder:7b", null)) {
order.add(name);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
Lab.sleep(100); // B queues before C
}
System.out.println("B, C wait: " + Lab.view(manager));
try {
manager.acquire("coder:7b", null);
} catch (QueueFullException e) {
System.out.println("D rejected: " + e.getMessage());
}
System.out.println("after D: " + Lab.view(manager));
first.close();
for (Thread t : waiters) t.join();
System.out.println("service order after A: " + order);
System.out.println("at the end: " + Lab.view(manager));
manager.shutdown();
}
@Test
void waitingHasATimeLimit() throws Exception {
ModelManager manager = Lab.manager(Lab.props(2, 1, 4, Duration.ofMillis(300), DataSize.ofGigabytes(64)),
new Lab.MutableClock(), Lab.log());
try (Lease holder = manager.acquire("tiny:1b", null)) {
try {
manager.acquire("tiny:1b", null);
} catch (QueueTimeoutException e) {
System.out.println(e.getMessage());
}
System.out.println(Lab.view(manager));
}
manager.shutdown();
}
}A running: coder:7b=ready in_flight=1 queued=0
B, C wait: coder:7b=ready in_flight=1 queued=2
D rejected: model 'coder:7b' has 2 requests waiting (the queue limit)
after D: coder:7b=ready in_flight=1 queued=2
service order after A: [B, C]
at the end: coder:7b=ready in_flight=0 queued=0
timed out waiting for a free slot on model 'tiny:1b'
tiny:1b=ready in_flight=1 queued=0In the first test, A takes the only permit, and the snapshot shows one request in flight. B and C wait, so the snapshot shows one in flight and two queued. D finds the queue full and gets QueueFullException immediately. The snapshot is unchanged by D's rejection, because the finally path in acquire gives its reservation back. When A closes its lease, the permits go to B and then C, in the order they arrived, and at the end nothing is in flight.
The second test sets a 300 ms queue timeout and keeps the only permit. The waiting request gives up with QueueTimeoutException after 300 ms. The holder is unaffected. Both exceptions map to HTTP 503 with a Retry-After header (Example 11).
Example 9: maximum loaded models and eviction
max-loaded-models limits how many engines run at the same time. When a request needs a new model and the limit is reached, the manager has to remove one. Eviction means choosing a model to unload so that another can load. The rule has three parts:
- Only an idle model can be evicted. A model that serves requests is never touched.
- Among idle models, the least recently used (LRU) goes first. It is the one whose last use was longest ago, on the guess that a model unused for a long time is less likely to be needed soon.
- A model loaded with
keep_alive-1 goes last, because the client asked to keep it.
If every loaded model is busy, the new request waits until one becomes idle.
/** The idle model used longest ago; models loaded with keep_alive -1 go last. */
private Slot victim() {
return slots.values().stream()
.filter(s -> s.state == State.READY && s.reserved == 0)
.min(Comparator.comparing((Slot s) -> s.keepAlive.isNegative()).thenComparing(s -> s.lastUsed).thenComparing(s -> s.name))
.orElse(null);
}
/** Stops the engine. The lock is released while the process exits; the slot keeps its memory reserved until then. */
private void stop(Slot s, String reason) {
s.state = State.STOPPING;
lock.unlock();
try {
if (s.backend != null) s.backend.close();
} finally {
lock.lock();
slots.remove(s.name, s);
changed.signalAll();
}
events.publishEvent(new ModelEvent.Unloaded(s.name, reason));
}stop() marks the slot STOPPING, releases the lock while the process exits, then removes the slot and wakes the waiting threads. The slot still counts for memory until the process has exited, because the operating system has not freed the memory before that. Releasing the lock keeps requests for other models from waiting behind a slow shutdown.
Two tests follow. The first checks which model leaves. The second checks that busy models are never evicted.
import java.util.List;
import org.junit.jupiter.api.Test;
class EvictionTest {
@Test
void leastRecentlyUsedIdleModelIsEvicted() throws Exception {
List<String> log = Lab.log();
Lab.MutableClock clock = new Lab.MutableClock();
ModelManager manager = Lab.manager(Lab.props(2, 1), clock, log);
manager.acquire("coder:7b", null).close();
clock.advance(java.time.Duration.ofSeconds(10));
manager.acquire("chat:8b", null).close();
clock.advance(java.time.Duration.ofSeconds(10));
manager.acquire("coder:7b", null).close(); // coder is now newer than chat
clock.advance(java.time.Duration.ofSeconds(10));
System.out.println("loaded: " + Lab.view(manager));
manager.acquire("tiny:1b", null).close(); // limit is 2: one must go
log.forEach(System.out::println);
System.out.println("loaded: " + Lab.view(manager));
manager.shutdown();
}
@Test
void busyModelsAreNeverEvicted() throws Exception {
List<String> log = Lab.log();
ModelManager manager = Lab.manager(Lab.props(2, 1), new Lab.MutableClock(), log);
Lease coder = manager.acquire("coder:7b", null);
Lease chat = manager.acquire("chat:8b", null);
Thread third = Thread.ofVirtual().start(() -> {
try (Lease l = manager.acquire("tiny:1b", null)) {
log.add("tiny:1b served");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Lab.sleep(300);
System.out.println("both busy, tiny waits: " + Lab.view(manager));
coder.close();
third.join();
log.forEach(System.out::println);
chat.close();
manager.shutdown();
}
}loaded: coder:7b=ready in_flight=0 queued=0, chat:8b=ready in_flight=0 queued=0
loaded coder:7b
loaded chat:8b
unloaded chat:8b (evicted to make room for tiny:1b)
loaded tiny:1b
loaded: coder:7b=ready in_flight=0 queued=0, tiny:1b=ready in_flight=0 queued=0
both busy, tiny waits: coder:7b=ready in_flight=1 queued=0, chat:8b=ready in_flight=1 queued=0
loaded coder:7b
loaded chat:8b
unloaded coder:7b (evicted to make room for tiny:1b)
loaded tiny:1b
tiny:1b servedThe first test sets a limit of two models and moves the clock between requests: coder:7b at 09:00:00, chat:8b at 09:00:10, and coder:7b again at 09:00:20. The third request makes coder:7b more recent than chat:8b. When tiny:1b arrives at 09:00:30, the limit is reached, and the least recently used idle model, chat:8b, is evicted. The event reason names the model that caused it.
The second test holds leases on coder:7b and chat:8b, so both are busy. The request for tiny:1b waits: the snapshot shows both models with one request in flight and no tiny:1b. When the coder:7b lease closes, that model becomes idle, the waiting thread wakes, evicts coder:7b, and loads tiny:1b. The busy chat:8b was never touched.
Example 10: memory check with loop fit
The model count is a coarse limit. Two small models and one large model take very different amounts of memory. A gateway also needs to know whether the next engine fits. The model-pull workshop built loop fit, which estimates weights, KV cache, and a reserve. The serve workshop used it to choose -ngl (the number of layers to place on the GPU).
The manager uses the same estimate as an admission rule: the sum of the estimates of all loaded models plus the new one must stay within gateway.memory-budget.
import demo.GatewayProperties.ModelSpec;
/** The memory estimate behind `loop fit`: weights, KV cache for every slot, and a fixed reserve. */
public final class MemoryPlan {
public static final long COMPUTE_RESERVE = 512L << 20;
private MemoryPlan() { }
public static long estimate(ModelSpec spec, int parallel) {
return spec.weights().toBytes() + spec.kvBytesPerToken() * spec.contextTokens() * parallel + COMPUTE_RESERVE;
}
}The estimate is weights + kvBytesPerToken * contextTokens * parallel + 512 MiB. Each term has a reason:
- Weights are the model file loaded into memory.
- The KV cache is multiplied by
parallelbecausellama-serversplits-cbetween its slots, as in the serve workshop's command builder. Two slots each get their own cache. - The 512 MiB is the compute reserve of the fit calculation in the serve workshop, a margin for temporary buffers.
The budget is a configured number, because a JVM cannot read free GPU memory portably. Ollama discovers devices and free memory itself (discover.GPUDevices in server/sched.go).
Two checks use the estimate. acquire() throws DoesNotFitException when one model alone is larger than the budget, because no eviction could help. reserve() evicts idle models until the sum fits, with the same victim rule as before.
The test prints the estimate of each model, then loads models in an order that forces memory-based eviction, then asks for the 70B model.
import demo.GatewayExceptions.DoesNotFitException;
import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.util.unit.DataSize;
class MemoryCheckTest {
@Test
void memoryDecidesWhatStaysLoaded() throws Exception {
List<String> log = Lab.log();
GatewayProperties props = Lab.props(3, 2, 8, Duration.ofSeconds(10), DataSize.ofMegabytes(12_288));
for (var e : props.models().entrySet()) {
System.out.printf("%-9s needs %5d MiB%n", e.getKey(), MemoryPlan.estimate(e.getValue(), props.parallelPerModel()) >> 20);
}
System.out.println("budget " + props.memoryBudget().toMegabytes() + " MiB");
Lab.MutableClock clock = new Lab.MutableClock();
ModelManager manager = Lab.manager(props, clock, log);
manager.acquire("tiny:1b", null).close();
clock.advance(Duration.ofSeconds(10));
manager.acquire("coder:7b", null).close();
clock.advance(Duration.ofSeconds(10));
System.out.println("loaded: " + Lab.view(manager));
manager.acquire("chat:8b", null).close();
log.forEach(System.out::println);
System.out.println("loaded: " + Lab.view(manager));
try {
manager.acquire("big:70b", null);
} catch (DoesNotFitException e) {
System.out.println(e.getMessage());
}
manager.shutdown();
}
}coder:7b needs 5874 MiB
chat:8b needs 7460 MiB
tiny:1b needs 1468 MiB
big:70b needs 46592 MiB
budget 12288 MiB
loaded: tiny:1b=ready in_flight=0 queued=0, coder:7b=ready in_flight=0 queued=0
loaded tiny:1b
loaded coder:7b
unloaded tiny:1b (evicted to make room for chat:8b)
unloaded coder:7b (evicted to make room for chat:8b)
loaded chat:8b
loaded: chat:8b=ready in_flight=0 queued=0
model 'big:70b' needs 48855252992 bytes, the memory budget is 12884901888 bytesThe table comes from the formula. For coder:7b with two slots and 8,192 tokens, the pieces are 4,466 MiB of weights, 57,344 * 8,192 * 2 bytes (896 MiB) of KV cache, and 512 MiB of reserve. Together they give 5,874 MiB. chat:8b has a larger KV cache per token (131,072 bytes), so it needs 4,900 + 2,048 + 512 = 7,460 MiB. The budget is 12,288 MiB, and the model limit is three, so the limit does not decide anything in this test.
tiny:1b and coder:7b fit together (7,342 MiB). Loading chat:8b needs 14,802 MiB in total, which is over the budget. The manager applies the victim rule repeatedly. It evicts tiny:1b, the oldest, and finds that coder:7b plus chat:8b (13,334 MiB) is still too large, so it evicts coder:7b too. Only then does chat:8b load.
The last line is the other check. big:70b needs 48,855,252,992 bytes and the budget is 12,884,901,888, so the request fails immediately without evicting anything.
Example 11: HTTP wiring and error mapping
The controller shows how a request uses the manager. It reads the model field, acquires a lease, forwards the body to the engine with RestClient, and closes the lease when the call returns. An OpenAI-Compatible API in Front of llama-server builds the full proxy. This version keeps only the lifecycle calls.
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
@RestController
class ChatController {
private final ModelManager manager;
private final RestClient http = RestClient.create();
ChatController(ModelManager manager) { this.manager = manager; }
@PostMapping("/v1/chat/completions")
ResponseEntity<String> chat(@RequestBody Map<String, Object> body) throws InterruptedException {
if (!(body.get("model") instanceof String model)) throw new IllegalArgumentException("model is required");
try (Lease lease = manager.acquire(model, null)) {
String upstream = http.post().uri(lease.baseUrl() + "/chat/completions")
.contentType(MediaType.APPLICATION_JSON).body(body).retrieve().body(String.class);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(upstream);
}
}
}The try-with-resources is the contract: every code path that acquires a lease closes it, including an upstream error. For a streaming response the lease must stay open until the last chunk has been written, so the streaming lesson closes it in the completion callback of the stream instead of at the end of the controller method. A lease that is never closed keeps the model busy forever and blocks eviction.
The manager throws its own exceptions for the things that can go wrong. ApiErrors maps each one to an HTTP status with a ProblemDetail body (taught in the essentials lesson). A ProblemDetail is Spring's standard JSON error format, with title, status, and detail. The choice of status follows one criterion: whether the client can fix the request or can only retry it.
import demo.GatewayExceptions.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
class ApiErrors {
@ExceptionHandler(UnknownModelException.class)
ProblemDetail unknown(UnknownModelException e) { return problem(HttpStatus.NOT_FOUND, "Unknown model", e); }
@ExceptionHandler(DoesNotFitException.class)
ProblemDetail tooBig(DoesNotFitException e) { return problem(HttpStatus.UNPROCESSABLE_CONTENT, "Model does not fit", e); }
@ExceptionHandler({QueueFullException.class, QueueTimeoutException.class, ShuttingDownException.class})
ResponseEntity<ProblemDetail> busy(RuntimeException e) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).header("Retry-After", "1")
.body(problem(HttpStatus.SERVICE_UNAVAILABLE, "Server busy", e));
}
@ExceptionHandler(LoadFailedException.class)
ProblemDetail load(LoadFailedException e) { return problem(HttpStatus.BAD_GATEWAY, "Engine failed to start", e); }
@ExceptionHandler(IllegalArgumentException.class)
ProblemDetail bad(IllegalArgumentException e) { return problem(HttpStatus.BAD_REQUEST, "Bad request", e); }
private static ProblemDetail problem(HttpStatus status, String title, Exception e) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(status, e.getMessage());
p.setTitle(title);
return p;
}
}| Exception | Status | Reason |
|---|---|---|
UnknownModelException |
404 | The name is not in the catalog |
DoesNotFitException |
422 | The request is well formed, but no retry can succeed |
QueueFullException, QueueTimeoutException, ShuttingDownException |
503 with Retry-After |
A later retry can succeed |
LoadFailedException |
502 | The upstream engine failed to start |
IllegalArgumentException |
400 | Missing model, bad keep_alive |
The run below starts the packaged jar and sends requests with curl. It uses the configuration of Example 2: at most two loaded models, two slots per model, and a queue of four. The first request loads coder:7b:
curl -s -w "\nstatus %{http_code}\n" -H Content-Type:application/json \
-d '{"model":"coder:7b","messages":[{"role":"user","content":"hi"}]}' localhost:18681/v1/chat/completions{"model":"coder:7b","choices":[{"index":0,"message":{"role":"assistant","content":"reply from coder:7b"},"finish_reason":"stop"}],"in_flight_at_start":1}
status 200The body is the engine's answer, passed through unchanged. in_flight_at_start is 1 because this was the only request. The load took the fake engine's 300 ms. A real engine takes seconds. GET /api/ps now lists the model (Example 12 explains the fields):
{"models":[{"name":"coder:7b","model":"coder:7b","size":6159335424,"state":"ready","in_flight":0,"queued":0,"expires_at":"2026-10-03T16:09:31.121943351Z"}]}The size is 6,159,335,424 bytes, which is the 5,874 MiB estimate of Example 10 (5,874 times 1,048,576). The value expires_at is five minutes after the request ended, so it differs on every run. The next two requests use a model that is not configured and the 70B model that cannot fit the 16 GB budget:
{"detail":"model 'nope' is not configured","instance":"/v1/chat/completions","status":404,"title":"Unknown model"}
status 404
{"detail":"model 'big:70b' needs 48855252992 bytes, the memory budget is 17179869184 bytes","instance":"/v1/chat/completions","status":422,"title":"Model does not fit"}
status 422The 404 names the model, and the 422 names both sizes. The last run sends eight requests at once to chat:8b, which has two slots and a queue of four:
for i in 1 2 3 4 5 6 7 8; do
curl -s -o /dev/null -w "%{http_code}\n" -H Content-Type:application/json \
-d '{"model":"chat:8b","messages":[]}' localhost:18681/v1/chat/completions &
done | sort | uniq -c 6 200
2 503The count is what to read here: six answers with status 200 and two with status 503. One request starts the engine and the other seven wait for the load (Example 4). Two requests then take the two permits, four wait in the queue, and two find the queue full. That makes six successes and two rejections. The split does not depend on timing, because all eight requests reach the manager while the first engine is still loading.
Example 12: load and unload requests and /api/ps
A client can control the lifecycle without generating any text. This is useful for warming up a model before a user arrives, or for freeing memory on purpose. Ollama does it with /api/generate:
- An empty prompt loads the model (
done_reasonload). - The same request with
keep_alive0 unloads it (done_reasonunload).
The branches are in GenerateHandler in server/routes.go, and ChatHandler has the same pair for an empty messages array. A separate endpoint, /api/ps, lists the loaded models. The gateway implements both. The manager's snapshot() supplies the data for /api/ps: name, state, estimated size, requests in flight, requests queued, and expiry.
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
class LifecycleController {
record PsEntry(String name, String model, long size, String state,
@JsonProperty("in_flight") int inFlight, int queued,
@JsonProperty("expires_at") Instant expiresAt) { }
record PsResponse(List<PsEntry> models) { }
private final ModelManager manager;
private final GatewayProperties props;
LifecycleController(ModelManager manager, GatewayProperties props) { this.manager = manager; this.props = props; }
@GetMapping("/api/ps")
PsResponse ps() {
return new PsResponse(manager.snapshot().stream()
.map(m -> new PsEntry(m.model(), m.model(), m.estimatedBytes(), m.state().name().toLowerCase(),
m.inFlight(), m.queued(), m.expiresAt())).toList());
}
/** Only the load and unload forms of Ollama's /api/generate: an empty prompt. */
@PostMapping("/api/generate")
Map<String, Object> generate(@RequestBody Map<String, Object> body) throws InterruptedException {
if (!(body.get("model") instanceof String model)) throw new IllegalArgumentException("model is required");
Object prompt = body.get("prompt");
if (prompt != null && !prompt.toString().isEmpty()) {
throw new IllegalArgumentException("only an empty prompt is handled here; generation goes through /v1/chat/completions");
}
Duration keepAlive = KeepAlive.parse(body.get("keep_alive"), props.keepAlive());
Map<String, Object> out = new LinkedHashMap<>();
out.put("model", model);
out.put("response", "");
out.put("done", true);
if (keepAlive.isZero()) {
manager.unload(model);
out.put("done_reason", "unload");
} else {
manager.preload(model, keepAlive);
out.put("done_reason", "load");
}
return out;
}
}unload() marks the slot with keep_alive 0. If no request is in flight, the model stops at once. Otherwise it stops when the last request ends, through the same path as finish() in Example 7. Ollama's expireRunner does the same: it sets the session duration to 0, and it expires the runner at once when no request uses it. preload() is acquire followed by an immediate close, so the keep_alive of the request is applied without holding a slot.
The curl calls below run in this order: unload chat:8b, load it with "10m", load coder:7b with -1, and send an invalid value. After each load or unload the run reads /api/ps. The sed filter replaced the changing timestamps with ....
{"model":"chat:8b","response":"","done":true,"done_reason":"unload"}
{"models":[]}
{"model":"chat:8b","response":"","done":true,"done_reason":"load"}
{"models":[{"name":"chat:8b","model":"chat:8b","size":7822376960,"state":"ready","in_flight":0,"queued":0,"expires_at":"..."}]}
{"model":"coder:7b","response":"","done":true,"done_reason":"load"}
{"models":[{"name":"chat:8b","model":"chat:8b","size":7822376960,"state":"ready","in_flight":0,"queued":0,"expires_at":"..."},{"name":"coder:7b","model":"coder:7b","size":6159335424,"state":"ready","in_flight":0,"queued":0,"expires_at":null}]}
{"detail":"invalid keep_alive: soon","instance":"/api/generate","status":400,"title":"Bad request"}
status 400The first answer has done_reason unload, and /api/ps is empty. The second has load, and the model is listed with an expiry. After the third call two models are listed. coder:7b has "expires_at":null, which is how the gateway reports a model that never expires. The last call shows the validation: soon has no unit, so it is a 400 with the ProblemDetail body, and no engine starts.
Example 13: /api/pull with streaming progress
Before a model can load, its file has to be on disk. Downloading a model takes minutes, so /api/pull reports progress as it goes, and a client can draw a progress bar. Ollama's PullHandler streams one JSON object per line (application/x-ndjson, as in the streaming lesson), shaped like api.ProgressResponse: status, and for downloads digest, total, and completed. A digest is a hash of the file contents, and it identifies the file and proves it arrived intact.
The statuses in server/images.go and server/download.go are pulling manifest, pulling <first 12 hex digits of the digest>, verifying sha256 digest, writing manifest, and success. With "stream": false the handler waits and returns only the last object. Errors arrive as {"error": "..."}.
The gateway reproduces that protocol over a registry it can reach. The dev registry in this project (demo.dev.FakeHub) serves one 1 MiB blob and a manifest with its digest and size. A manifest is a small JSON file that names the blob and says how big it is. PullService works in these steps:
- Read the manifest.
- Stream the blob in 64 KiB chunks into a
.partialfile, updating a SHA-256 digest as it goes. - Report progress every quarter of the total size.
- Verify the digest, and delete the file if it does not match.
- Move the file into place and write the manifest.
The HTTP downloads lesson covers resume and range requests. This service omits them.
public void pull(String model, Consumer<Progress> out) throws IOException, InterruptedException {
out.accept(Progress.of("pulling manifest"));
HttpResponse<String> m = http.send(HttpRequest.newBuilder(URI.create(props.hubUrl() + "/manifests/" + model)).build(),
HttpResponse.BodyHandlers.ofString());
if (m.statusCode() != 200) throw new IOException("registry answered " + m.statusCode() + " for " + model);
Manifest manifest = json.readValue(m.body(), Manifest.class);
Files.createDirectories(props.modelDir().resolve("blobs"));
Path partial = props.modelDir().resolve("blobs").resolve(manifest.digest().replace(':', '-') + ".partial");
String label = "pulling " + manifest.digest().substring(7, 19);
out.accept(new Progress(label, manifest.digest(), manifest.size(), 0L));
MessageDigest sha = sha256();
long done = 0;
HttpResponse<InputStream> blob = http.send(
HttpRequest.newBuilder(URI.create(props.hubUrl() + "/blobs/" + manifest.digest())).build(),
HttpResponse.BodyHandlers.ofInputStream());
try (InputStream in = blob.body(); OutputStream file = Files.newOutputStream(partial)) {
long nextReport = manifest.size() / 4;
byte[] buf;
while ((buf = in.readNBytes(CHUNK)).length > 0) {
file.write(buf);
sha.update(buf);
done += buf.length;
if (done >= nextReport) {
out.accept(new Progress(label, manifest.digest(), manifest.size(), done));
nextReport += manifest.size() / 4;
}
}
}
out.accept(Progress.of("verifying sha256 digest"));
String actual = "sha256:" + HexFormat.of().formatHex(sha.digest());
if (!actual.equals(manifest.digest())) {
Files.deleteIfExists(partial);
throw new IOException("digest mismatch: expected " + manifest.digest() + " but received " + actual);
}
Files.move(partial, props.modelDir().resolve("blobs").resolve(manifest.digest().replace(':', '-')),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
out.accept(Progress.of("writing manifest"));
Files.createDirectories(props.modelDir().resolve("manifests"));
Files.writeString(props.modelDir().resolve("manifests").resolve(model.replace(':', '_') + ".json"), m.body());
out.accept(Progress.of("success"));
}The controller returns ResponseEntity<StreamingResponseBody>, and Spring MVC runs the body on its async executor (a virtual thread here), so the servlet thread is not held for the whole download. Each progress object is written as one line and flushed, so the client sees it at once.
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import tools.jackson.databind.json.JsonMapper;
@RestController
class PullController {
private final PullService pulls;
private final JsonMapper json;
PullController(PullService pulls, JsonMapper json) { this.pulls = pulls; this.json = json; }
@PostMapping("/api/pull")
ResponseEntity<StreamingResponseBody> pull(@RequestBody Map<String, Object> body) throws Exception {
if (!(body.get("model") instanceof String model)) throw new IllegalArgumentException("model is required");
if (Boolean.FALSE.equals(body.get("stream"))) {
AtomicReference<PullService.Progress> last = new AtomicReference<>();
pulls.pull(model, last::set); // runs to the end before any byte is sent
StreamingResponseBody single = out -> write(out, json.writeValueAsString(last.get()));
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(single);
}
StreamingResponseBody stream = (OutputStream out) -> {
try {
pulls.pull(model, p -> write(out, json.writeValueAsString(p)));
} catch (IOException e) {
write(out, json.writeValueAsString(Map.of("error", e.getMessage())));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
return ResponseEntity.ok().contentType(MediaType.parseMediaType("application/x-ndjson")).body(stream);
}
private static void write(OutputStream out, String line) {
try {
out.write((line + "\n").getBytes(StandardCharsets.UTF_8));
out.flush();
} catch (IOException e) {
throw new java.io.UncheckedIOException(e); // the client left: stop downloading
}
}
}The declared return type matters. A first version returned ResponseEntity<?>, and every request failed with No converter for [class demo.PullController$$Lambda...] with preset Content-Type 'application/x-ndjson'. Spring chooses the return-value handler from the declared type, and a lambda is not a known body type. With ResponseEntity<StreamingResponseBody> the request succeeds. If the client disconnects, write throws UncheckedIOException, which stops the download loop.
curl -sN -H Content-Type:application/json -d '{"model":"qwen2.5-coder:7b"}' localhost:18681/api/pull{"status":"pulling manifest"}
{"status":"pulling 06b7bbfb7824","digest":"sha256:06b7bbfb7824aa03382051691630eb26de85102d1b08a81e907ec0744cd8a286","total":1048576,"completed":0}
{"status":"pulling 06b7bbfb7824","digest":"sha256:06b7bbfb7824aa03382051691630eb26de85102d1b08a81e907ec0744cd8a286","total":1048576,"completed":262144}
{"status":"pulling 06b7bbfb7824","digest":"sha256:06b7bbfb7824aa03382051691630eb26de85102d1b08a81e907ec0744cd8a286","total":1048576,"completed":524288}
{"status":"pulling 06b7bbfb7824","digest":"sha256:06b7bbfb7824aa03382051691630eb26de85102d1b08a81e907ec0744cd8a286","total":1048576,"completed":786432}
{"status":"pulling 06b7bbfb7824","digest":"sha256:06b7bbfb7824aa03382051691630eb26de85102d1b08a81e907ec0744cd8a286","total":1048576,"completed":1048576}
{"status":"verifying sha256 digest"}
{"status":"writing manifest"}
{"status":"success"}The first line announces the manifest. The next five lines are progress for one blob. The value completed is 0 at the start and then 262,144, 524,288, 786,432, and 1,048,576 bytes, which are the quarters of the 1,048,576-byte total. The sizes are the same on every run because readNBytes(65536) fills each chunk, so the quarter boundaries fall on chunk boundaries (262,144 is exactly four chunks of 65,536).
After the last byte the service verifies the SHA-256 digest, moves the .partial file to its final name, writes the manifest, and sends success. A real pull of a multi-gigabyte file produces many more lines. The client uses completed / total for a progress bar, as loop pull does in the model-pull workshop.
The two other forms are "stream": false and a failing pull. The model name missing makes the fake registry answer 404.
{"status":"success"}
HTTP/1.1 200
Content-Type: application/x-ndjson
{"status":"pulling manifest"}
{"error":"registry answered 404 for missing"}The first output line is the answer to "stream": false: one object with the last status. The next lines are the failing pull. The HTTP status is 200 and the Content-Type is application/x-ndjson, because the response headers were sent before the registry answered. The error therefore travels as a line {"error": "..."} in the stream. A client must read the stream to its end and treat a line with an error key as a failure.
Example 14: graceful shutdown stops the engines
Engines are child processes. If the gateway exits without stopping them, they keep running and hold memory. Two settings and one method handle a normal stop:
server.shutdown=gracefulmakes Tomcat stop accepting new connections and wait for active requests. The wait lasts at mostspring.lifecycle.timeout-per-shutdown-phase, which is 20 seconds in the configuration of Example 2.- The manager's
@PreDestroymethod runs when the application context closes. This happens after the web server has finished its graceful phase, so engines are stopped after the last in-flight request is answered.
@PreDestroy
void shutdown() {
lock.lock();
try {
closing = true;
changed.signalAll();
for (Slot s : new ArrayList<>(slots.values())) {
if (s.state == State.READY) stop(s, "gateway shutdown");
}
} finally {
lock.unlock();
}
}shutdown() sets closing, so any request that reaches the manager from now on gets ShuttingDownException (503). Then it stops every ready engine. ProcessBackend.close() sends destroy() to the child and its descendants, waits up to stop-timeout, and calls destroyForcibly() if the child is still alive.
The run below loads two engines, starts a request that takes 400 ms, and sends SIGTERM (the normal "please stop" signal) to the gateway 150 ms later. It counts engine processes before and after.
engines running: 2
in-flight request finished with status 200
engines running after the gateway exited: 0Both engines run before the signal. The request that was in flight when the signal arrived completes with status 200. Tomcat logged Commencing graceful shutdown. Waiting for active requests to complete and then Graceful shutdown complete. After the gateway process exits, no engine process is left.
@PreDestroy does not run when the JVM is killed with SIGKILL (a signal the process cannot catch) or when it crashes. A run that loaded coder:7b and then killed the gateway with kill -9 left the engine running:
orphan engine, parent pid 1The orphan had been adopted by process 1, which is what the operating system does with a child whose parent died. The workshop's shutdown hook has the same limit. The protection against it is outside the JVM: a supervisor such as systemd that stops the whole control group (the unit file in the production lesson), or an engine that exits when its parent disappears. A startup check for engines left by an earlier crash needs a record of their process ids, such as a pid file per engine.
Example 15: comparison with Ollama's scheduler
The Engine Landscape and How Ollama Works simulated Ollama's scheduler, the part of Ollama that decides which models are loaded. This example compares it with the gateway's manager, using server/sched.go, llm/llama_server.go, and envconfig/config.go of Ollama v0.35.1. In the table, a runnerRef is Ollama's record for one loaded model, like the gateway's Slot.
| Topic | Ollama v0.35.1 | Gateway manager |
|---|---|---|
| Unit | A runnerRef per model, with a reference count and a session duration |
A Slot per model, with reserved and keepAlive |
Default keep_alive |
5 minutes (envconfig.KeepAlive); negative means forever |
gateway.keep-alive, 5 minutes; negative means forever |
| Max loaded models | OLLAMA_MAX_LOADED_MODELS; 0 means automatic, which is 3 per GPU (defaultModelsPerGPU) |
gateway.max-loaded-models, set by the operator |
| Parallel requests per model | OLLAMA_NUM_PARALLEL, default 1; a weighted semaphore of that size in each llamaServer (sem) |
gateway.parallel-per-model; a fair semaphore |
| Queue | OLLAMA_MAX_QUEUE, default 512: the channel of requests waiting for the scheduler; a full channel returns ErrMaxQueue, which the API maps to 503 |
gateway.max-queue per model; a full queue returns 503 with Retry-After |
| Loads | One load at a time (activeLoading); requests for loaded models proceed in parallel |
Loads of different models can overlap; memory is reserved before the engine starts |
| Victim choice | Sort by session duration, then by model path; the first idle runner; if none is idle, the one with the shortest duration, and the scheduler waits for it (findRunnerToUnload) |
Least recently used idle model, forever last; if none is idle, wait |
| Memory | Discovers GPUs and free memory, plans layers, and waits for VRAM to recover after an unload on discrete GPUs (waitForVRAMRecovery) |
A configured budget and the loop fit estimate; no device query |
| Failed load | If the load crashes after the process started, evicts the other models and retries once (oomRetryAttempted) |
Fails the request with 502 and keeps the other models |
| Unload request | keep_alive 0 with an empty prompt calls expireRunner |
unload() |
| Shutdown | unloadAllRunners closes the loading runner and every loaded runner |
@PreDestroy shutdown() |
The victim rule differs in a way that changes behavior, and the program below shows the effect. It restates both rules and applies them to four situations. In the program, a runner is a loaded model with a keep_alive in seconds (Long.MAX_VALUE means forever), a last-used time, and a count of requests using it.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class VictimPolicies {
// keepAliveSeconds: Long.MAX_VALUE stands for "forever", as in Ollama (negative keep_alive becomes MaxInt64)
record Runner(String name, long keepAliveSeconds, long lastUsed, int refCount) { }
/** Ollama v0.35.1 findRunnerToUnload: sort by keep_alive, then name; first idle runner, else the first one. */
static Runner ollama(List<Runner> loaded) {
List<Runner> sorted = new ArrayList<>(loaded);
sorted.sort(Comparator.comparingLong(Runner::keepAliveSeconds).thenComparing(Runner::name));
for (Runner r : sorted) if (r.refCount() == 0) return r;
return sorted.get(0); // the caller then waits until it goes idle
}
/** The gateway: the idle runner used longest ago, keep_alive forever last; none when every runner is busy. */
static Runner lru(List<Runner> loaded) {
return loaded.stream().filter(r -> r.refCount() == 0)
.min(Comparator.comparing((Runner r) -> r.keepAliveSeconds() == Long.MAX_VALUE)
.thenComparingLong(Runner::lastUsed).thenComparing(Runner::name)).orElse(null);
}
static String name(Runner r) { return r == null ? "(wait for one to go idle)" : r.name(); }
public static void main(String[] args) {
List<Runner> mixed = List.of(
new Runner("coder", 300, 100, 0),
new Runner("chat", 1800, 10, 0),
new Runner("embed", 60, 150, 1));
System.out.println("mixed ollama: " + name(ollama(mixed)) + ", gateway: " + name(lru(mixed)));
List<Runner> allBusy = List.of(
new Runner("coder", 300, 100, 1),
new Runner("chat", 1800, 10, 2));
System.out.println("busy ollama: " + name(ollama(allBusy)) + ", gateway: " + name(lru(allBusy)));
List<Runner> sameKeepAlive = List.of(
new Runner("b-model", 300, 5, 0),
new Runner("a-model", 300, 90, 0));
System.out.println("equal ollama: " + name(ollama(sameKeepAlive)) + ", gateway: " + name(lru(sameKeepAlive)));
List<Runner> pinned = List.of(
new Runner("pinned", Long.MAX_VALUE, 1, 0),
new Runner("recent", 300, 500, 0));
System.out.println("pinned ollama: " + name(ollama(pinned)) + ", gateway: " + name(lru(pinned)));
}
}mixed ollama: coder, gateway: chat
busy ollama: coder, gateway: (wait for one to go idle)
equal ollama: a-model, gateway: b-model
pinned ollama: recent, gateway: recentEach output line is one situation.
- In the
mixedcase, Ollama's rule sorts bykeep_alive(embed 60 s, coder 300 s, chat 1,800 s) and takes the first idle one,coder, althoughchatwas used longest ago (time 10). The gateway rule takeschat. Theembedrunner is skipped by both because it is busy. - In the
busycase, every runner is in use. Ollama picks the shortest duration,coder, and waits until its requests finish. The gateway picks none and waits for any model to become idle, so whichever finishes first is evicted. - In the
equalcase, thekeep_alivevalues tie. Ollama breaks the tie by name (a-model), while the gateway uses the last use time (b-model). - In the
pinnedcase, the model with forever as itskeep_alivesorts last in both rules, so both evictrecent.
Ollama's rule treats keep_alive as the eviction priority: a model that the user asked to keep for less time goes first. The gateway's rule treats recency as the priority and uses keep_alive -1 as the only protection. Which one fits depends on the workload. A long list of models with different keep_alive values favors Ollama's rule. A workload where recent use predicts the next request favors least recently used.
The queue differs in meaning too. Ollama's bounded queue holds requests that wait for the scheduler, and each loaded runner then lets requests wait on its semaphore for as long as the request context lives. The gateway bounds the wait per model and also applies a timeout, which gives clients a clear 503 instead of an open connection.
Interview points
- A gateway manages engine processes because an engine holds its model in memory for as long as it runs. The manager decides when to start one, when to stop one, and how many requests it may serve.
- A
LOADINGslot makes concurrent first requests share one engine start. Memory is reserved when the slot is created, not when the engine is ready. - A lease (a handle closed in
try-with-resources) ties a request to a model. A model with leases is busy and cannot be evicted. A leaked lease blocks eviction forever. keep_aliveis stored per slot, the latest request wins,0unloads when the last request ends, and a negative value never expires. A scheduled reaper with an injectedClockmakes the behavior testable without sleeping.- A per-model limit with a bounded queue and a wait timeout turns overload into a fast 503 with
Retry-After. An unbounded wait hides overload until clients time out. - Eviction needs a rule for the case where every model is busy. Waiting for an idle model is safe, and evicting a busy model fails requests that are in progress.
- A stopped engine still holds memory until its process exits, so its estimate stays in the accounting until
close()returns. - A failed load must remove its slot and wake the waiting threads. Otherwise every later request for that model waits forever.
- Status codes follow retryability: 404 and 422 mean the client must change the request, and 503 and 502 mean a retry may succeed.
- A streamed
/api/pullhas already sent status 200 when an error happens, so errors travel as lines in the stream. @PreDestroywith graceful web shutdown stops engines after in-flight requests finish. ASIGKILLskips both and leaves orphan engines, so a process supervisor must cover that case.- Ollama's scheduler loads one model at a time, evicts by
keep_aliveand name, and discovers memory itself. The gateway's manager evicts by recency and takes its memory budget from configuration.
Summary
- The
ModelManagerbean keeps one slot per model in the statesLOADING,READY, andSTOPPING, under one lock with one condition. acquire(model, keepAlive)returns aLeaseafter the engine is ready and a permit is free. Closing the lease releases the permit and starts the idle timer when it was the last request.- The first request for a model creates the slot and starts the engine. Concurrent requests wait on the slot, so one engine starts.
- A failed load removes the slot and raises
LoadFailedException, which maps to 502. keep_aliveaccepts seconds (a number) or duration strings with units.0unloads when idle, and a negative value means forever.- A scheduled
reapIdle()unloads ready models without requests whose expiry has passed. The default period is set bygateway.reap-interval. - Each model has
parallel-per-modelpermits in a fair semaphore andmax-queuewaiting places. A full queue gives 503, and a wait beyondqueue-timeoutgives 503. max-loaded-modelsand the memory budget both trigger eviction. The victim is the least recently used idle model, withkeep_alive-1 models last. If every model is busy, the request waits.- The memory estimate is
weights + kvBytesPerToken * contextTokens * parallel + 512 MiB. A model larger than the whole budget fails with 422. - Map keys with a colon need the form
"[coder:7b]"in YAML configuration, and@Validbelongs on the map's type argument. /api/generatewith an empty prompt loads a model, and withkeep_alive0 unloads it./api/pslists the loaded models with state, size, in-flight and queued counts, and expiry./api/pullstreams NDJSON progress objects (status,digest,total,completed) and returns only the last one for"stream": false. A streaming method needs the declared return typeResponseEntity<StreamingResponseBody>.- Graceful shutdown,
@PreDestroy, and a process-tree stop remove engines onSIGTERM.SIGKILLleaves them running. - Ollama v0.35.1 differs in loading one model at a time, choosing victims by
keep_aliveand path, discovering GPU memory, and retrying a crashed load once after evicting the other models.
