Health: learn n_ctx and total_slots from /props
Implemented /props learning in internal/health/health.go (added Status.NCtx/Status.Slots, PerSlotCtx, and a best-effort GET <base>/props appended to the poll after /v1/models; 0/unknown on any failure without failing the poll) and exposed them in internal/admin/admin.go HostView. Copied internal/health/props_test.go and the replacement internal/proxy/helpers_test.go byte-identical to docs/plans/v2/_files/. Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -38,6 +38,8 @@ type HostView struct {
|
||||
InFlight int `json:"in_flight"` // sum over the host's configured models
|
||||
Queued int `json:"queued"` // same
|
||||
Draining bool `json:"draining"`
|
||||
NCtx int `json:"n_ctx"` // from /props; 0 = unknown
|
||||
Slots int `json:"slots"` // from /props; 0 = unknown
|
||||
}
|
||||
|
||||
// LeaseView is one lease's row in a route's leases.
|
||||
@@ -125,6 +127,8 @@ func (hx *handler) hostView(name string, s health.Status) HostView {
|
||||
InFlight: inflight,
|
||||
Queued: queued,
|
||||
Draining: hx.d.Draining(name),
|
||||
NCtx: s.NCtx,
|
||||
Slots: s.Slots,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,17 @@ type Status struct {
|
||||
LastOK time.Time `json:"last_ok"` // zero if never
|
||||
LastErr string `json:"last_err"` // "" after a good poll
|
||||
Consecutive int `json:"consecutive"` // good polls in a row
|
||||
NCtx int `json:"n_ctx"` // total context from /props; 0 = unknown
|
||||
Slots int `json:"slots"` // total_slots from /props; 0 = unknown
|
||||
}
|
||||
|
||||
// PerSlotCtx is the context one request may use: NCtx divided by Slots, or the
|
||||
// whole NCtx when Slots is unknown (0). It is 0 when NCtx is unknown.
|
||||
func (s Status) PerSlotCtx() int {
|
||||
if s.NCtx == 0 || s.Slots == 0 {
|
||||
return s.NCtx
|
||||
}
|
||||
return s.NCtx / s.Slots
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
@@ -40,6 +51,8 @@ type pollResult struct {
|
||||
cancelled bool
|
||||
reason string
|
||||
loaded []string
|
||||
nctx int
|
||||
slots int
|
||||
}
|
||||
|
||||
// Table maps a host name to its health status. All methods are safe for concurrent use.
|
||||
@@ -156,6 +169,8 @@ func (t *Table) pollHost(ctx context.Context, name string) {
|
||||
e.status.LastOK = time.Now()
|
||||
e.status.LastErr = ""
|
||||
e.status.Loaded = r.loaded
|
||||
e.status.NCtx = r.nctx
|
||||
e.status.Slots = r.slots
|
||||
e.status.Healthy = !e.everFailed || e.status.Consecutive >= RecoveryPolls
|
||||
} else {
|
||||
e.everFailed = true
|
||||
@@ -174,13 +189,64 @@ func (t *Table) poll(ctx context.Context, base string) pollResult {
|
||||
return r
|
||||
}
|
||||
loaded, r = t.check(ctx, base+"/v1/models", "models")
|
||||
if r.cancelled {
|
||||
if r.cancelled || r.reason != "" {
|
||||
return r
|
||||
}
|
||||
if r.reason != "" {
|
||||
nctx, slots, r := t.props(ctx, base)
|
||||
if r.cancelled || r.reason != "" {
|
||||
return r
|
||||
}
|
||||
return pollResult{ok: true, loaded: loaded}
|
||||
return pollResult{ok: true, loaded: loaded, nctx: nctx, slots: slots}
|
||||
}
|
||||
|
||||
// props reads <base>/props best-effort. A request that fails because ctx is
|
||||
// done yields a cancelled result so the caller records nothing; any other
|
||||
// outcome (status, body, or missing fields) leaves context unknown without
|
||||
// failing the poll.
|
||||
func (t *Table) props(ctx context.Context, base string) (int, int, pollResult) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/props", nil)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return 0, 0, pollResult{cancelled: true}
|
||||
}
|
||||
return 0, 0, pollResult{}
|
||||
}
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return 0, 0, pollResult{cancelled: true}
|
||||
}
|
||||
return 0, 0, pollResult{}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, 0, pollResult{}
|
||||
}
|
||||
var p struct {
|
||||
Generation struct {
|
||||
NCtx *int `json:"n_ctx"`
|
||||
} `json:"default_generation_settings"`
|
||||
TotalSlots *int `json:"total_slots"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, MaxModelsBody)).Decode(&p); err != nil {
|
||||
return 0, 0, pollResult{}
|
||||
}
|
||||
|
||||
nctx, slots := 0, 0
|
||||
if p.Generation.NCtx != nil {
|
||||
nctx = *p.Generation.NCtx
|
||||
}
|
||||
if p.TotalSlots != nil {
|
||||
slots = *p.TotalSlots
|
||||
}
|
||||
if nctx < 0 {
|
||||
nctx = 0
|
||||
}
|
||||
if slots < 0 {
|
||||
slots = 0
|
||||
}
|
||||
return nctx, slots, pollResult{}
|
||||
}
|
||||
|
||||
// check performs one GET and, on success, returns the decoded model ids. Health checks use the
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package health_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/crossbar/internal/health"
|
||||
)
|
||||
|
||||
// propsFake answers /health, /v1/models and a configurable /props.
|
||||
func propsFake(t *testing.T, props string, status int) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"status":"ok"}`) })
|
||||
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"data":[{"id":"m"}]}`) })
|
||||
mux.HandleFunc("/props", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprint(w, props)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestPropsLearned(t *testing.T) {
|
||||
srv := propsFake(t, `{"default_generation_settings":{"n_ctx":131072,"params":{}},"total_slots":4,"model_path":"/x/m.gguf","chat_template":"..."}`, 200)
|
||||
tbl := health.New(map[string]string{"a": srv.URL}, time.Hour, nil)
|
||||
tbl.PollOnce(context.Background())
|
||||
s, _ := tbl.Get("a")
|
||||
if !s.Healthy || s.NCtx != 131072 || s.Slots != 4 {
|
||||
t.Fatalf("status = %+v, want healthy with NCtx 131072 and Slots 4", s)
|
||||
}
|
||||
if got := s.PerSlotCtx(); got != 32768 {
|
||||
t.Errorf("PerSlotCtx = %d, want 131072/4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropsAbsentOrBrokenIsNotAFailure(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
props string
|
||||
status int
|
||||
}{
|
||||
"404": {`not found`, 404},
|
||||
"not json": {`<html>`, 200},
|
||||
"no fields": {`{"model_path":"/x"}`, 200},
|
||||
"zero ctx": {`{"default_generation_settings":{"n_ctx":0},"total_slots":0}`, 200},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
srv := propsFake(t, tc.props, tc.status)
|
||||
tbl := health.New(map[string]string{"a": srv.URL}, time.Hour, nil)
|
||||
tbl.PollOnce(context.Background())
|
||||
s, _ := tbl.Get("a")
|
||||
if !s.Healthy {
|
||||
t.Fatalf("a bad /props must not make the host unhealthy: %+v", s)
|
||||
}
|
||||
if s.NCtx != 0 || s.Slots != 0 || s.PerSlotCtx() != 0 {
|
||||
t.Errorf("unknown context must read as 0: %+v", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerSlotCtxWithUnknownSlots(t *testing.T) {
|
||||
s := health.Status{NCtx: 8192, Slots: 0}
|
||||
if s.PerSlotCtx() != 8192 {
|
||||
t.Errorf("with Slots unknown the whole context is the per-slot value; got %d", s.PerSlotCtx())
|
||||
}
|
||||
s = health.Status{NCtx: 8192, Slots: 3}
|
||||
if s.PerSlotCtx() != 2730 {
|
||||
t.Errorf("integer division: got %d, want 2730", s.PerSlotCtx())
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ func newUpstream(t *testing.T, name string) *upstream {
|
||||
u.mu.Unlock()
|
||||
fmt.Fprint(w, `{"object":"list","data":[{"id":"shared"},{"id":"`+name+`-only"}]}`)
|
||||
})
|
||||
// The v2 poller also asks /props; it is a health request, not a hit, so it is not counted.
|
||||
// No n_ctx here: "unknown context" is what the v1 tests and TestUnknownContextNeverBlocks want.
|
||||
mux.HandleFunc("/props", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"model_path":"`+name+`"}`)
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
u.hits.Add(1)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
|
||||
Reference in New Issue
Block a user