155 lines
4.4 KiB
Go
155 lines
4.4 KiB
Go
package health_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.wntrmute.dev/kyle/crossbar/internal/health"
|
|
)
|
|
|
|
// fake is a llama-server stand-in whose /health can be flipped and whose model list is fixed.
|
|
type fake struct {
|
|
srv *httptest.Server
|
|
down atomic.Bool
|
|
models []string
|
|
hits atomic.Int32
|
|
}
|
|
|
|
func newFake(t *testing.T, models ...string) *fake {
|
|
f := &fake{models: models}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
f.hits.Add(1)
|
|
if f.down.Load() {
|
|
http.Error(w, "loading", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
|
|
type m struct {
|
|
ID string `json:"id"`
|
|
}
|
|
var data []m
|
|
for _, id := range f.models {
|
|
data = append(data, m{ID: id})
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
|
|
})
|
|
f.srv = httptest.NewServer(mux)
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func TestFirstPollMakesHealthy(t *testing.T) {
|
|
a := newFake(t, "zeta", "alpha", "alpha")
|
|
tbl := health.New(map[string]string{"a": a.srv.URL}, time.Hour, nil)
|
|
if s, ok := tbl.Get("a"); !ok || s.Healthy || len(s.Loaded) != 0 {
|
|
t.Fatalf("before any poll: %+v %v", s, ok)
|
|
}
|
|
tbl.PollOnce(context.Background())
|
|
s, _ := tbl.Get("a")
|
|
if !s.Healthy || s.Consecutive != 1 || s.LastErr != "" || s.LastOK.IsZero() {
|
|
t.Errorf("after one good poll: %+v", s)
|
|
}
|
|
if len(s.Loaded) != 2 || s.Loaded[0] != "alpha" || s.Loaded[1] != "zeta" {
|
|
t.Errorf("Loaded = %v, want sorted, unique [alpha zeta]", s.Loaded)
|
|
}
|
|
}
|
|
|
|
func TestFailureThenRecoveryNeedsTwoPolls(t *testing.T) {
|
|
a := newFake(t, "m")
|
|
tbl := health.New(map[string]string{"a": a.srv.URL}, time.Hour, nil)
|
|
ctx := context.Background()
|
|
tbl.PollOnce(ctx)
|
|
a.down.Store(true)
|
|
tbl.PollOnce(ctx)
|
|
s, _ := tbl.Get("a")
|
|
if s.Healthy || s.Consecutive != 0 || s.LastErr == "" {
|
|
t.Fatalf("after failure: %+v", s)
|
|
}
|
|
if len(s.Loaded) != 1 {
|
|
t.Errorf("Loaded is left as last seen; got %v", s.Loaded)
|
|
}
|
|
a.down.Store(false)
|
|
tbl.PollOnce(ctx)
|
|
if s, _ := tbl.Get("a"); s.Healthy || s.Consecutive != 1 {
|
|
t.Errorf("one good poll after a failure must not be healthy yet: %+v", s)
|
|
}
|
|
tbl.PollOnce(ctx)
|
|
if s, _ := tbl.Get("a"); !s.Healthy || s.Consecutive != 2 || s.LastErr != "" {
|
|
t.Errorf("two good polls: %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestMarkDown(t *testing.T) {
|
|
a := newFake(t, "m")
|
|
tbl := health.New(map[string]string{"a": a.srv.URL}, time.Hour, nil)
|
|
tbl.PollOnce(context.Background())
|
|
tbl.MarkDown("a", "connection refused")
|
|
s, _ := tbl.Get("a")
|
|
if s.Healthy || s.Consecutive != 0 || s.LastErr != "marked down: connection refused" {
|
|
t.Errorf("after MarkDown: %+v", s)
|
|
}
|
|
tbl.MarkDown("nobody", "x") // unknown hosts are ignored, not a panic
|
|
tbl.PollOnce(context.Background())
|
|
if s, _ := tbl.Get("a"); s.Healthy {
|
|
t.Errorf("one poll after MarkDown must not be healthy: %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestUnreachableAndUnknown(t *testing.T) {
|
|
tbl := health.New(map[string]string{"a": "http://127.0.0.1:1"}, time.Hour, &http.Client{Timeout: time.Second})
|
|
tbl.PollOnce(context.Background())
|
|
s, ok := tbl.Get("a")
|
|
if !ok || s.Healthy || s.LastErr == "" {
|
|
t.Errorf("unreachable host: %+v %v", s, ok)
|
|
}
|
|
if _, ok := tbl.Get("zzz"); ok {
|
|
t.Errorf("unknown host must report ok=false")
|
|
}
|
|
}
|
|
|
|
func TestAllIsACopy(t *testing.T) {
|
|
a := newFake(t, "m")
|
|
tbl := health.New(map[string]string{"a": a.srv.URL}, time.Hour, nil)
|
|
tbl.PollOnce(context.Background())
|
|
all := tbl.All()
|
|
all["a"].Loaded[0] = "changed"
|
|
if s, _ := tbl.Get("a"); s.Loaded[0] != "m" {
|
|
t.Errorf("All must return copies")
|
|
}
|
|
if len(all) != 1 {
|
|
t.Errorf("All = %v", all)
|
|
}
|
|
}
|
|
|
|
func TestRunPollsOnStart(t *testing.T) {
|
|
a := newFake(t, "m")
|
|
tbl := health.New(map[string]string{"a": a.srv.URL}, 20*time.Millisecond, nil)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { tbl.Run(ctx); close(done) }()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for a.hits.Load() < 3 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Run did not return after cancel")
|
|
}
|
|
if a.hits.Load() < 3 {
|
|
t.Errorf("Run polled %d times in 2s at 20ms interval", a.hits.Load())
|
|
}
|
|
if s, _ := tbl.Get("a"); !s.Healthy {
|
|
t.Errorf("not healthy after Run: %+v", s)
|
|
}
|
|
}
|