blob: 8ccbc203f37a0acd2bfa7cc04f60fdda9665a8c1 [file] [view]
# Design: Fleet State — Ownership, Lifecycle, and Recovery
Companion to [`vllm-fleet-design.md`](vllm-fleet-design.md), which defines the
control-plane contract: what a host is, how a box fetches its assignment, and
what the JSON looks like. This document covers **where fleet state lives, how
it changes, and what happens when it is lost**.
---
## 1. LiteLLM is the source of truth
An earlier draft of this document proposed a runtime-owned YAML file for
membership, on the grounds that a GPU box rebooting during a database outage
could still fetch its assignment.
**That argument does not hold.** During a Postgres outage LiteLLM cannot
authenticate any request virtual keys live there and with
`STORE_MODEL_IN_DB` it cannot route either. The gateway is down regardless, so
a box that fetches its config during that window comes up serving a model
nothing can reach. The resilience buys nothing.
State lives in LiteLLM's database. There is no second store.
### 1.1 What a route already carries
| field | fleet meaning |
|---|---|
| `model_name` | the catalog model, and the routing key |
| `litellm_params.api_base` | **which host, which port** |
| `litellm_params.api_key` | the bearer token for that vLLM |
| `model_info` | arbitrary dict — carries the recipe and provenance |
`GET /vllm/config` becomes: select routes whose `api_base` host matches the
caller's IP, return their `model_info.vllm` blocks and ports.
### 1.2 Enabling it
`STORE_MODEL_IN_DB` is an **environment variable**, not a config key:
```
STORE_MODEL_IN_DB=True
```
Without it, `/model/new` returns HTTP 500 with
`Set 'STORE_MODEL_IN_DB='True'' in your env to enable this feature`. The YAML
`model_list` then becomes a bootstrap seed rather than the source of truth.
---
## 2. Registration is health-gated
A provisioned instance can take fifteen minutes to load weights. A route whose
backend is not yet serving will fail every request routed to it.
**Rule: a route exists in LiteLLM if and only if its vLLM is serving.**
```
add host -> record assignment, generate api_key, no route yet
box boots -> GET /vllm/config
health OK -> POST /model/new
health DOWN -> POST /model/delete
retire -> delete route, drop assignment
```
Uniform, with no special cases. An earlier draft proposed registering at
add-time when the `model_name` already had healthy peers and deferring
otherwise that makes the same operation behave differently depending on the
state of unrelated servers, which is fine when written and baffling later.
### 2.1 Why not register early and let cooldown absorb it
Verified against litellm 1.99.0 source. Two findings:
**There is no per-deployment enable/disable.** No `enabled` or `is_disabled`
field, and nothing in the model-management endpoints. A route is registered or
deleted; there is no third state.
**Cooldown does not protect a booting backend.** `DEFAULT_COOLDOWN_TIME_SECONDS`
is **5**, so a cooled deployment re-enters rotation almost immediately. And
`router_utils/cooldown_handlers.py` deliberately exempts single-deployment
model groups `SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD` is **1000**, with
the comment *"by default we should avoid cooldowns on single deployment model
groups."*
So the first server for a new model would fail every request for the full boot
window, uncooled. Health-gating is the only mechanism available.
`cooldown_time` and `allowed_fails` are settable per-deployment via
`model_info` if different behaviour is wanted later.
### 2.2 The pending assignment
Health-gating means an assignment must exist before its route does. That state
is small IP, port, model name, generated key, per pending host but it is
state.
Options, preferred first:
- **A table in the same Postgres.** Not LiteLLM's schema, but the same
database, so no new backup story and no new failure mode. "State-free" meant
no *second* datastore; this respects that.
- **In memory, accepting loss on restart.** A box that already fetched keeps
running; one that has not gets 404 and retries. Zero persistence, but a
restart mid-provisioning strands a box being paid for.
- **Register immediately and tolerate the failures.** Simplest, and a bad
default given §2.1.
---
## 3. Three config lifetimes
Config here has three distinct change costs, and conflating them causes most of
the confusion:
| lifetime | what | to change it |
|---|---|---|
| **baked** | `ASFQUART_URL`, `FLEET_KEY` in the instance template | re-provision |
| **boot** | the vLLM assignment — model, port, launch args | box must re-fetch and restart vLLM |
| **live** | routes, keys, budgets in LiteLLM | immediate |
Changing `max_model_len` does nothing until that box restarts vLLM. The catalog
says one thing and the server does another, silently.
**This is a second kind of skew.** `check_config_skew` compares llmao against
LiteLLM. Nothing compares llmao's *intended* launch args against what a box is
actually running.
### 3.1 Config revision
`/vllm/config` responses carry a revision a hash of the assignment payload.
The box records what it applied and reports it back, so the UX can show
`applied rev 3, current rev 5` rather than letting a stale server pretend to be
current.
If boxes re-fetch periodically rather than only at boot, a changed revision
also becomes the trigger for a restart, making config changes eventually
consistent.
Whether that restart should be automatic is open: an unattended restart drops
in-flight requests. Probably detect automatically, apply on a button.
---
## 4. Modelling variants
### 4.1 Contract versus recipe
**`model_name` is a contract with callers.** Everything behind it must be
interchangeable from their point of view.
**`model_info.vllm` is a recipe for the box.** Recipes may differ freely as
long as the contract holds.
Same `model_name`, different recipes fine:
- FP8 on one box, BF16 on another
- weights from HF on one, an internal mirror on another
- different cards, `gpu_memory_utilization`, `--kv-cache-memory`
Different `model_name` required:
- different served context window a 32k route cannot take a 100k prompt
- different reasoning parser, or thinking on by default versus off
- anything that changes the shape of a response
**If two routes share a `model_name`, their caller-visible parameters must
match.** Otherwise identical requests behave differently depending on which
backend they land on. Where a pool is uneven, advertise the **minimum** a
pool with a 40k and a 128k server advertises 40k, or it is not a pool.
### 4.2 Recipes carry provenance
Weights come from HF, from internal mirrors, from image registries, and in
several quantizations. The recipe should say so explicitly rather than
overloading one string:
```yaml
model_info:
vllm:
model: Qwen/Qwen3-8B-FP8 # what vLLM is told to load
source:
kind: hf # hf | url | registry | local
vram_gb: 8 # checkable before pulling 50GB
disk_gb: 18
args: ["--reasoning-parser", "qwen3"]
```
Three reasons to separate `source` from `model`: the box can check fit before
downloading; credentials differ by source kind; and a box that already holds
the weights should not re-download because the catalog names an HF repo.
### 4.3 Host capacity is discovered, not declared
VRAM and disk are discoverable on the box. A hand-typed `vram_gb: 80` is wrong
the first time a provider supplies a different card than was ordered which,
with rented instances, is a matter of when.
The model's requirement goes in the recipe; the box decides fit against
hardware it can see. `--kv-cache-memory` makes this exact: vLLM prints the byte
count it wants, so the install step can start conservative, read the figure,
and relaunch.
---
## 5. Lifecycle
### 5.1 Retirement
Deleting a route deletes the record, so "did this host ever exist" loses its
answer. Either mark `model_info.retired` and remove the route from routing, or
accept LiteLLM's own audit trail as the record.
Worth deciding rather than losing by default.
### 5.2 Staleness
`last_config_fetch` is the liveness signal surface it as a coloured age,
green under an hour, red over a week.
**Never auto-retire.** A box down for maintenance is indistinguishable from one
that is dead, and silently dropping it during an outage is the wrong default.
Retiring promptly is also hygiene: providers recycle IPs, so a stale entry may
eventually match an instance rented by someone else. `GET /vllm/config`
requires the fleet key with a constant-time compare, so the blast radius is
bounded but that key is a single shared secret baked into the box template,
which makes "nobody else could hold it" an assumption.
### 5.3 At a dozen hosts
- **Group by provider in the UI.** The useful question is "how many rented
boxes are still alive", not an alphabetical list of IPs.
- **`model_name` pools need a minimum-advertising rule** 4.1), which cannot
be a static catalog value once a pool is uneven.
- **A `notes` field is worth considering.** "Rented for the superset scan, kill
after" is obvious for a week and unrecoverable after a month.
---
## 6. Loss and recovery
| event | LiteLLM DB | in-memory health |
|---|---|---|
| Puppet run | untouched | untouched |
| app restart | untouched | rebuilt in ~45s from probes |
| Postgres outage | unavailable; auth **and routing** fail | untouched |
| host rebuild | **lost** | lost |
Health state is correctly ephemeral a live measurement, not a record.
With routes in Postgres, a lost database means the proxy no longer knows what
to proxy, not merely who may call it. Virtual keys are stored hashed and shown
once at mint, so recovery also means re-minting every key and reconfiguring
every consumer.
**Partial mitigation for free:** the GPU boxes keep running. They hold their
assignment locally and continue serving; they simply cannot re-fetch.
### 6.1 Backup
```bash
sudo -u postgres pg_dump litellm | gzip > "$dest/litellm-$stamp.sql.gz"
```
One artifact now, rather than a database dump plus a state file.
**`$dest` is unresolved and belongs to the p6 work.** Three constraints:
- it must be captured **off-host**. `bpc_client_asf` pulls via rsync from a
central BackupPC server, so the client has no local daemon and coverage
cannot be confirmed from the box. The share list lives on the server ask
Infra which paths are pulled. A default share of `/etc` and `/home` would
miss `/var/backups`.
- confirm the host is not in `bpc_client_asf::excludelist`.
- a file-level copy of a live Postgres data directory is **not** a valid
backup. The `pg_dump` is what makes it restorable.
---
## 7. Implementation
1. `STORE_MODEL_IN_DB=True` in the service environment
2. Derive `GET /vllm/config` from routes matching the caller's IP
3. Push `/model/new` on the healthy transition, `/model/delete` on down
4. Somewhere for pending assignments (§2.2)
5. Config revision on `/vllm/config`, reported back by `install_sets.py`
6. UI: add, retire, edit
---
## 8. Open
- Pending-assignment storage (§2.2).
- Retirement record after route deletion (§5.1).
- Automatic restart on revision change, or detect-and-prompt (§3.1).
- Advertising the minimum across an uneven pool (§4.1).
- Backup destination (§6.1).
- **Does `model_info` survive a `/model/new` round-trip intact?** If LiteLLM
normalises or drops unknown nested keys, recipes cannot live there. Testable
against a local proxy with `STORE_MODEL_IN_DB=True`.
**Resolved:** LiteLLM is the source of truth; no second datastore; registration
is health-gated; there is no per-deployment enable/disable and cooldown does
not cover the boot window; host capacity is discovered, not declared;
`model_name` is a caller contract and `model_info.vllm` a box recipe.
---
*End of design document.*