feat(server): answer HTTP during model load; split /livez + /readyz probes #44

Merged
rcsheets merged 1 commit from feat/serve-during-model-load into main 2026-07-22 05:05:59 +00:00
Owner

Serve HTTP from the moment the port binds instead of letting connections hang in the accept backlog during the (minutes-long) model load, and split the probe endpoints k8s-style.

During the load (server.Loading, swapped out atomically once the engine is ready)

  • GET /v1/internal/status -> 200 {"state": "loading", "model_dir": ..., "elapsed_seconds": ...} -- an orchestrator can tell "starting" from "dead"
  • POST /v1/internal/drain -> aborts the load and exits (same secret gating); a drain racing load completion is honored after the swap
  • everything else -> 503 + Retry-After

The loading status deliberately omits the model key: glchat.Probe requires one, so loading instances stay out of the chat picker on old and new glchat builds alike. Pinned by tests on both sides and documented in AGENTS.md as a wire contract.

Probe split

  • GET /livez -- liveness: 200 whenever the process answers HTTP (loading, serving, draining)
  • GET /readyz -- readiness: 200 only while traffic should route here; 503 during load and once a drain triggers
  • GET /healthz -- temporary deprecated alias of /readyz; warns once per probing IP (capped at 100 IPs with a final suppression notice) so the log names every prober left to migrate

The serving status payload now reports "state": "serving" / "draining", giving pollers one lifecycle field from port-open to exit.

Verified

  • go build / vet / test ./... / gofmt clean
  • Live run with a FIFO-blocked load: status/probes answered correctly mid-load, wrong drain secret 401s, drain-during-load and SIGTERM-during-load both exit 0, repeated /healthz probes from one IP warn once

🤖 Generated with Claude Code

Serve HTTP from the moment the port binds instead of letting connections hang in the accept backlog during the (minutes-long) model load, and split the probe endpoints k8s-style. ## During the load (`server.Loading`, swapped out atomically once the engine is ready) - `GET /v1/internal/status` -> 200 `{"state": "loading", "model_dir": ..., "elapsed_seconds": ...}` -- an orchestrator can tell "starting" from "dead" - `POST /v1/internal/drain` -> aborts the load and exits (same secret gating); a drain racing load completion is honored after the swap - everything else -> 503 + `Retry-After` The loading status deliberately omits the `model` key: `glchat.Probe` requires one, so loading instances stay out of the chat picker on old and new glchat builds alike. Pinned by tests on both sides and documented in AGENTS.md as a wire contract. ## Probe split - `GET /livez` -- liveness: 200 whenever the process answers HTTP (loading, serving, draining) - `GET /readyz` -- readiness: 200 only while traffic should route here; 503 during load and once a drain triggers - `GET /healthz` -- temporary deprecated alias of `/readyz`; warns once per probing IP (capped at 100 IPs with a final suppression notice) so the log names every prober left to migrate The serving status payload now reports `"state": "serving"` / `"draining"`, giving pollers one lifecycle field from port-open to exit. ## Verified - `go build` / `vet` / `test ./...` / `gofmt` clean - Live run with a FIFO-blocked load: status/probes answered correctly mid-load, wrong drain secret 401s, drain-during-load and SIGTERM-during-load both exit 0, repeated `/healthz` probes from one IP warn once 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(server): answer HTTP during model load; split /livez + /readyz probes
All checks were successful
ci / test_and_build (pull_request) Successful in 22s
458e09c8cb
Serve on the already-bound listener immediately instead of letting
connections hang in the kernel accept backlog for the minutes a model
load takes (where a TCP probe looked "up" while every request stalled).
A loading-phase handler (server.Loading) answers until the engine is
ready, then the real handler is swapped in atomically:

- GET /v1/internal/status: 200 with {"state": "loading", "model_dir",
  "elapsed_seconds", ...} so an orchestrator can tell "starting" from
  "dead". The payload deliberately omits the "model" key: glchat's
  Probe requires one, which keeps loading instances out of its picker
  on old and new builds alike (pinned by tests on both sides).
- POST /v1/internal/drain: same secret gating as the serving handler,
  but aborts the load and exits, so an orchestrator can retire an
  instance without waiting out a load it intends to throw away. A
  drain that races load completion is honored after the swap.
- Everything else: 503 with a Retry-After hint.

The serving status payload gains "state": "serving" -> "draining", so
a poller sees one lifecycle field from port-open to exit.

Probes now follow the k8s liveness/readiness split: GET /livez is 200
whenever the process answers HTTP (loading, serving, and draining --
restarting an instance for loading or draining would defeat both);
GET /readyz is 200 only while traffic should route here (503 during
load and once a drain triggers). GET /healthz stays temporarily as a
deprecated alias of /readyz; each IP still probing it is warned about
once in the log (capped, with a final suppression notice) so the log
names every prober left to migrate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collaborator

Automated review by pr-reviewer v0.36.0 | Safety Check | Claude Sonnet 5 | tracking id r-604f45-b5f40a
This is an AI-generated review and may contain mistakes.

Status: Completed


⚠️ The primary review (Mistral Small) failed. The review below is from Claude Sonnet 5, which reviewed the same diff.

Verdict: LGTM — The flagged "secret" is just standard drain-secret header-comparison code (already existing pattern), and the rest of the diff is a well-tested feature addition with no obvious bugs or breaking changes.

Checked the flagged file (internal/server/loading.go) for the "possible secret" hit — it's just the drain-secret gating logic (X-Drain-Secret header comparison via subtle.ConstantTimeCompare), same pattern already used in server.go. Test fixtures use an obvious placeholder "s3cr3t". No real credential leaked.

Logic review:

  • The atomic handler-pointer swap in main.go (loading handler -> real handler once engine ready) looks race-safe; uses atomic.Pointer[http.Handler].
  • Shutdown paths (ctx.Done, loading.Drained(), serveDone failing early) all call srv.Shutdown/srv.Close appropriately, avoiding goroutine leaks/hangs.
  • BuildVersion export rename is a trivial, backward-compatible change (unexported -> exported), no external callers broken since it's a new export.
  • Probe/status wire-contract change (omitting model key while loading) is deliberately tested on both sides (glchat/discover_test.go and server/loading_test.go), consistent with the AGENTS.md documentation update.
  • healthzWarner cap/suppression logic looks correct and is tested for both the per-IP-once behavior and the cap/suppression case.
  • Drain gating logic extracted into shared handleDrainRequest used by both loading and serving paths — reduces duplication without changing behavior.

No exposed secrets, no obvious logic bugs, no breaking API removal (old /healthz kept as deprecated alias rather than removed). This is a substantial but well-tested feature addition.

*Automated review by [pr-reviewer](https://git.brooktrails.org/brooktrails/pr-reviewer) v0.36.0 | Safety Check | Claude Sonnet 5 | tracking id `r-604f45-b5f40a`* *This is an AI-generated review and may contain mistakes.* **Status:** ✅ Completed --- > ⚠️ The primary review (**Mistral Small**) failed. The review below is from **Claude Sonnet 5**, which reviewed the same diff. **✅ Verdict: LGTM** — The flagged "secret" is just standard drain-secret header-comparison code (already existing pattern), and the rest of the diff is a well-tested feature addition with no obvious bugs or breaking changes. Checked the flagged file (`internal/server/loading.go`) for the "possible secret" hit — it's just the drain-secret gating logic (`X-Drain-Secret` header comparison via `subtle.ConstantTimeCompare`), same pattern already used in `server.go`. Test fixtures use an obvious placeholder `"s3cr3t"`. No real credential leaked. Logic review: - The atomic handler-pointer swap in `main.go` (loading handler -> real handler once engine ready) looks race-safe; uses `atomic.Pointer[http.Handler]`. - Shutdown paths (ctx.Done, loading.Drained(), serveDone failing early) all call `srv.Shutdown`/`srv.Close` appropriately, avoiding goroutine leaks/hangs. - `BuildVersion` export rename is a trivial, backward-compatible change (unexported -> exported), no external callers broken since it's a new export. - Probe/status wire-contract change (omitting `model` key while loading) is deliberately tested on both sides (`glchat/discover_test.go` and `server/loading_test.go`), consistent with the AGENTS.md documentation update. - healthzWarner cap/suppression logic looks correct and is tested for both the per-IP-once behavior and the cap/suppression case. - Drain gating logic extracted into shared `handleDrainRequest` used by both loading and serving paths — reduces duplication without changing behavior. No exposed secrets, no obvious logic bugs, no breaking API removal (old `/healthz` kept as deprecated alias rather than removed). This is a substantial but well-tested feature addition.
rcsheets deleted branch feat/serve-during-model-load 2026-07-22 05:06:00 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
brooktrails/gllm!44
No description provided.