Files
2026-09-11 06:14:38 +02:00

180 lines
5.9 KiB
Go

package warm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/example/ollama-fair-gateway/internal/config"
"github.com/example/ollama-fair-gateway/internal/worker"
)
func TestHotPolicyPreloadsInstalledEligibleModel(t *testing.T) {
var mu sync.Mutex
var keep any
called := make(chan struct{}, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/ps":
_ = json.NewEncoder(w).Encode(map[string]any{"models": []any{}})
case "/api/tags":
_ = json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "qwen3:8b", "model": "qwen3:8b"}}})
case "/api/generate":
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
mu.Lock()
keep = in["keep_alive"]
mu.Unlock()
select {
case called <- struct{}{}:
default:
}
_ = json.NewEncoder(w).Encode(map[string]any{"done": true})
default:
http.NotFound(w, r)
}
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := worker.New([]config.WorkerConfig{{Name: "w", URL: ts.URL, MaxConcurrent: 1, HealthInterval: config.Duration(time.Hour)}}, "w")
p.Start(ctx)
m, err := New(config.WarmModelsConfig{Enabled: true, OperationTimeout: config.Duration(time.Second), Policies: map[string]config.WarmModelPolicy{"qwen3:*": {Class: "hot", Replicas: 1, IdleTimeout: config.Duration(time.Hour)}}}, p, t.TempDir()+"/warm.json")
if err != nil {
t.Fatal(err)
}
m.Reconcile(ctx)
select {
case <-called:
case <-time.After(2 * time.Second):
t.Fatal("preload was not issued")
}
waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
defer waitCancel()
if err := m.Wait(waitCtx); err != nil {
t.Fatal(err)
}
mu.Lock()
got := keep
mu.Unlock()
if n, ok := got.(float64); !ok || n != -1 {
t.Fatalf("keep_alive=%#v want -1", got)
}
}
func TestColdPolicyUnloadsAfterIdle(t *testing.T) {
called := make(chan struct{}, 2)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/ps":
_ = json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "qwen3:8b", "model": "qwen3:8b"}}})
case "/api/tags":
_ = json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "qwen3:8b", "model": "qwen3:8b"}}})
case "/api/generate":
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
if v, ok := in["keep_alive"].(float64); ok && v == 0 {
select {
case called <- struct{}{}:
default:
}
}
_ = json.NewEncoder(w).Encode(map[string]any{"done": true})
default:
http.NotFound(w, r)
}
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := worker.New([]config.WorkerConfig{{Name: "w", URL: ts.URL, MaxConcurrent: 1, HealthInterval: config.Duration(time.Hour)}}, "w")
p.Start(ctx)
m, err := New(config.WarmModelsConfig{Enabled: true, OperationTimeout: config.Duration(time.Second), Policies: map[string]config.WarmModelPolicy{"qwen3:8b": {Class: "cold", Replicas: 1, IdleTimeout: config.Duration(10 * time.Millisecond)}}}, p, t.TempDir()+"/warm.json")
if err != nil {
t.Fatal(err)
}
m.Reconcile(ctx) // establishes last-use grace
time.Sleep(20 * time.Millisecond)
m.Reconcile(ctx)
select {
case <-called:
case <-time.After(2 * time.Second):
t.Fatal("unload was not issued")
}
waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
defer waitCancel()
if err := m.Wait(waitCtx); err != nil {
t.Fatal(err)
}
}
func TestRuntimePoliciesPersist(t *testing.T) {
dir := t.TempDir()
path := dir + "/warm.json"
p := worker.New([]config.WorkerConfig{{Name: "w", URL: "http://127.0.0.1:1", MaxConcurrent: 1}}, "w")
base := config.WarmModelsConfig{Policies: map[string]config.WarmModelPolicy{"a:*": {Class: "warm", Replicas: 1, IdleTimeout: config.Duration(time.Minute)}}}
m, err := New(base, p, path)
if err != nil {
t.Fatal(err)
}
over := map[string]config.WarmModelPolicy{"b:*": {Class: "hot", Replicas: 1, Workers: []string{"w"}, IdleTimeout: config.Duration(time.Minute)}}
if err := m.SetPolicies(over); err != nil {
t.Fatal(err)
}
m2, err := New(base, p, path)
if err != nil {
t.Fatal(err)
}
st := m2.Status()
if !st.Override || st.Policies["b:*"].Class != "hot" {
t.Fatalf("status=%#v", st)
}
if err := m2.Reset(); err != nil {
t.Fatal(err)
}
if m2.Status().Override {
t.Fatal("override still active")
}
}
func TestColdPolicyDoesNotUnloadDrainingWorker(t *testing.T) {
called := make(chan struct{}, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/ps":
_ = json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "qwen3:8b", "model": "qwen3:8b"}}})
case "/api/tags":
_ = json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "qwen3:8b", "model": "qwen3:8b"}}})
case "/api/generate":
called <- struct{}{}
_ = json.NewEncoder(w).Encode(map[string]any{"done": true})
default:
http.NotFound(w, r)
}
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := worker.New([]config.WorkerConfig{{Name: "w", URL: ts.URL, MaxConcurrent: 1, HealthInterval: config.Duration(time.Hour)}}, "w")
p.Start(ctx)
if err := p.SetMaintenance("w", "draining"); err != nil {
t.Fatal(err)
}
m, err := New(config.WarmModelsConfig{Enabled: true, OperationTimeout: config.Duration(time.Second), Policies: map[string]config.WarmModelPolicy{"qwen3:8b": {Class: "cold", Replicas: 1, IdleTimeout: config.Duration(time.Millisecond)}}}, p, t.TempDir()+"/warm.json")
if err != nil {
t.Fatal(err)
}
m.Reconcile(ctx)
time.Sleep(5 * time.Millisecond)
m.Reconcile(ctx)
select {
case <-called:
t.Fatal("warm manager issued model action while worker was draining")
case <-time.After(100 * time.Millisecond):
}
}