Ctrl K
ComunidadGuías…

internal/router/base_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package router

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"sync/atomic"
	"testing"
	"time"

	"github.com/quartermaster-labs/quartermaster/internal/config"
	"github.com/quartermaster-labs/quartermaster/internal/logmon"
	"github.com/quartermaster-labs/quartermaster/internal/process"
	"github.com/quartermaster-labs/quartermaster/internal/router/scheduler"
)

// These tests cover baseRouter's own machinery — the run loop, process
// lifecycle (doSwap), grant/ServeHTTP plumbing, Unload, and Shutdown. The
// scheduling decision logic (queueing, collation, eviction collisions) lives in
// the scheduler package and is tested directly there; see fifo_test.go.

// stubPlanner evicts nothing. baseRouter tests drive the run loop through the
// default FIFO scheduler without exercising any particular eviction policy.
type stubPlanner struct{}

func (s *stubPlanner) EvictionFor(string, []string) []string { return nil }
func (s *stubPlanner) OnSwapStart(string, []string)          {}

func newTestBase(t *testing.T, processes map[string]process.Process, planner scheduler.Swapper) *baseRouter {
	t.Helper()
	conf := config.Config{HealthCheckTimeout: 5}
	b, err := newBaseRouter("test", conf, processes, logmon.NewWriter(io.Discard), planner)
	if err != nil {
		t.Fatalf("newBaseRouter: %v", err)
	}
	b.testProcessed = make(chan struct{}, 64)
	go b.run()
	t.Cleanup(func() {
		if !b.shuttingDown.Load() {
			_ = b.Shutdown(time.Second)
		}
	})
	return b
}

// SetPreEvict must install a hook on every process bound to its own model ID,
// and that hook must fire when the process is stopped (e.g. Unload/eviction/TTL).
func TestBaseRouter_SetPreEvict(t *testing.T) {
	a := newFakeProcess("a")
	a.markReady()
	b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})

	var got atomic.Pointer[string]
	var gotReason atomic.Uint32
	b.SetPreEvict(func(id string, r process.StopReason) {
		got.Store(&id)
		gotReason.Store(uint32(r))
	})

	b.Unload(time.Second, "a")

	if p := got.Load(); p == nil || *p != "a" {
		t.Fatalf("preEvict hook not fired with model id; got %v", p)
	}
	// An operator-driven Unload is the reason that must NOT trigger a KV save.
	if r := process.StopReason(gotReason.Load()); r != process.StopManual {
		t.Errorf("Unload reported reason %d, want StopManual", r)
	}
}

// SetPostLoad must install a hook on every process bound to its own model ID,
// fired when the process reaches Ready (cold load), before requests are served.
func TestBaseRouter_SetPostLoad(t *testing.T) {
	a := newFakeProcess("a")
	b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})

	var got atomic.Pointer[string]
	b.SetPostLoad(func(id string) { got.Store(&id) })

	a.markReady() // process becomes Ready

	if p := got.Load(); p == nil || *p != "a" {
		t.Fatalf("postLoad hook not fired with model id; got %v", p)
	}
}

func TestBaseRouter_RunningModels(t *testing.T) {
	ready := newFakeProcess("ready")
	ready.markReady()
	starting := newFakeProcess("starting")
	starting.setState(process.StateStarting)
	stopped := newFakeProcess("stopped")

	b := newTestBase(t, map[string]process.Process{
		"ready": ready, "starting": starting, "stopped": stopped,
	}, &stubPlanner{})

	running := b.RunningModels()
	if len(running) != 2 {
		t.Fatalf("running=%v want 2 entries", running)
	}
	if running["ready"] != process.StateReady {
		t.Errorf("ready state=%q want ready", running["ready"])
	}
	if running["starting"] != process.StateStarting {
		t.Errorf("starting state=%q want starting", running["starting"])
	}
	if _, ok := running["stopped"]; ok {
		t.Errorf("stopped process should be excluded from RunningModels")
	}
}

func TestBaseRouter_UnloadAll(t *testing.T) {
	a := newFakeProcess("a")
	a.markReady()
	c := newFakeProcess("c")
	c.markReady()

	b := newTestBase(t, map[string]process.Process{"a": a, "c": c}, &stubPlanner{})
	b.Unload(time.Second)

	if a.State() != process.StateStopped || c.State() != process.StateStopped {
		t.Fatalf("Unload() should stop every process: a=%q c=%q", a.State(), c.State())
	}
}

// ApplyConfig must live-patch the process set without evicting kept models:
// a running model stays running and is retuned for its next spawn, an added
// model is created, a removed model is dropped from the map and stopped, and the
// config is swapped in — all without a teardown.
func TestBaseRouter_ApplyConfig(t *testing.T) {
	a := newFakeProcess("a")
	a.markReady()
	bproc := newFakeProcess("b")
	bproc.markReady()

	base := newTestBase(t, map[string]process.Process{"a": a, "b": bproc}, &stubPlanner{})

	// Desired process set = every model in cfg.Models; planner evicts nothing.
	base.plan = func(c config.Config) (scheduler.Swapper, map[string]config.ModelConfig, error) {
		want := make(map[string]config.ModelConfig, len(c.Models))
		for id, mc := range c.Models {
			want[id] = mc
		}
		return &stubPlanner{}, want, nil
	}
	created := map[string]*fakeProcess{}
	base.makeProcess = func(id string, _ config.ModelConfig) (process.Process, error) {
		p := newFakeProcess(id)
		created[id] = p
		return p, nil
	}

	// Keep a (changed args), remove b, add c. Bump HealthCheckTimeout to prove the
	// config swaps.
	newCfg := config.Config{
		HealthCheckTimeout: 9,
		Models: map[string]config.ModelConfig{
			"a": {Cmd: "new-args-for-a"},
			"c": {Cmd: "args-for-c"},
		},
	}
	if err := base.ApplyConfig(newCfg); err != nil {
		t.Fatalf("ApplyConfig: %v", err)
	}

	procs := base.procs()
	if procs["a"] != a {
		t.Fatalf("model a must be the same reused process object (not rebuilt)")
	}
	if a.stopCalls.Load() != 0 {
		t.Errorf("kept model a must not be stopped; stopCalls=%d", a.stopCalls.Load())
	}
	if lc := a.lastConfig.Load(); lc == nil || lc.Cmd != "new-args-for-a" {
		t.Errorf("kept model a must be retuned with the new config; got %v", lc)
	}
	if procs["c"] == nil || created["c"] == nil {
		t.Errorf("added model c must be newly created and present in the map")
	}
	if _, ok := procs["b"]; ok {
		t.Errorf("removed model b must be gone from the process map")
	}
	select {
	case <-bproc.stopStarted:
	case <-time.After(2 * time.Second):
		t.Fatalf("removed model b must be stopped")
	}
	if base.cfg().HealthCheckTimeout != 9 {
		t.Errorf("config not swapped live; HealthCheckTimeout=%d want 9", base.cfg().HealthCheckTimeout)
	}
}

// ApplyConfig must be a clean no-op when the new config is invalid: the router
// keeps its current processes and config untouched.
func TestBaseRouter_ApplyConfig_InvalidKeepsState(t *testing.T) {
	a := newFakeProcess("a")
	a.markReady()
	base := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})

	base.plan = func(config.Config) (scheduler.Swapper, map[string]config.ModelConfig, error) {
		return nil, nil, fmt.Errorf("bad config")
	}
	base.makeProcess = func(id string, _ config.ModelConfig) (process.Process, error) {
		return newFakeProcess(id), nil
	}

	if err := base.ApplyConfig(config.Config{HealthCheckTimeout: 9}); err == nil {
		t.Fatalf("ApplyConfig with invalid config should return an error")
	}
	if _, ok := base.procs()["a"]; !ok {
		t.Errorf("model a must survive a rejected ApplyConfig")
	}
	if a.stopCalls.Load() != 0 {
		t.Errorf("no process should be stopped on a rejected ApplyConfig")
	}
	if base.cfg().HealthCheckTimeout == 9 {
		t.Errorf("config must not change on a rejected ApplyConfig")
	}
}

func TestBaseRouter_UnloadSpecificModel(t *testing.T) {
	a := newFakeProcess("a")
	a.markReady()
	c := newFakeProcess("c")
	c.markReady()

	b := newTestBase(t, map[string]process.Process{"a": a, "c": c}, &stubPlanner{})
	b.Unload(time.Second, "a")

	if a.State() != process.StateStopped {
		t.Errorf("a should be stopped, got %q", a.State())
	}
	if c.State() != process.StateReady {
		t.Errorf("c should remain ready, got %q", c.State())
	}
}

// TestBaseRouter_Unload_StopsInParallel verifies that Unload fans out its
// Stop calls concurrently rather than stopping each process serially. Each
// fakeProcess.Stop is pinned via stopBlock; the test only releases them
// after observing every stopStarted, proving all three Stops were in
// flight simultaneously.
func TestBaseRouter_Unload_StopsInParallel(t *testing.T) {
	a := newFakeProcess("a")
	a.markReady()
	a.stopBlock = make(chan struct{})
	pb := newFakeProcess("b")
	pb.markReady()
	pb.stopBlock = make(chan struct{})
	pc := newFakeProcess("c")
	pc.markReady()
	pc.stopBlock = make(chan struct{})

	b := newTestBase(t, map[string]process.Process{"a": a, "b": pb, "c": pc}, &stubPlanner{})

	unloadDone := make(chan struct{})
	go func() {
		b.Unload(time.Second, "a", "b", "c")
		close(unloadDone)
	}()

	// All three Stop calls must start before any of them are allowed to
	// complete. If Unload was serial, only one stopStarted would fire
	// until we released its stopBlock, and this would deadlock.
	for _, p := range []*fakeProcess{a, pb, pc} {
		select {
		case <-p.stopStarted:
		case <-time.After(2 * time.Second):
			t.Fatalf("Stop on %s never started — Unload is not parallel", p.id)
		}
	}

	// Release them; Unload should now return.
	close(a.stopBlock)
	close(pb.stopBlock)
	close(pc.stopBlock)

	select {
	case <-unloadDone:
	case <-time.After(2 * time.Second):
		t.Fatal("Unload did not return after stops released")
	}

	for _, p := range []*fakeProcess{a, pb, pc} {
		if p.State() != process.StateStopped {
			t.Errorf("%s state=%q want stopped", p.id, p.State())
		}
		if got := p.stopCalls.Load(); got != 1 {
			t.Errorf("%s stopCalls=%d want 1", p.id, got)
		}
	}
}

func TestBaseRouter_OnDemandStart(t *testing.T) {
	a := newFakeProcess("a")
	a.autoReady = true

	b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})

	w := httptest.NewRecorder()
	b.ServeHTTP(w, newRequest("a"))

	if w.Code != http.StatusOK {
		t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
	}
	if got := a.runCalls.Load(); got != 1 {
		t.Errorf("runCalls=%d want 1", got)
	}
	if got := a.serveCalls.Load(); got != 1 {
		t.Errorf("serveCalls=%d want 1", got)
	}
}

func TestBaseRouter_ContextCancel(t *testing.T) {
	a := newFakeProcess("a")
	// autoReady=false so swap parks forever until we mark ready.

	b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})

	ctx, cancel := context.WithCancel(context.Background())
	w1 := httptest.NewRecorder()
	done1 := make(chan struct{})
	go func() {
		b.ServeHTTP(w1, newRequestCtx(ctx, "a"))
		close(done1)
	}()

	w2 := httptest.NewRecorder()
	done2 := make(chan struct{})
	go func() {
		b.ServeHTTP(w2, newRequest("a"))
		close(done2)
	}()

	waitProcessed(t, b.testProcessed, 2) // both requests joined the active swap
	<-a.runStarted

	cancel()
	select {
	case <-done1:
	case <-time.After(time.Second):
		t.Fatal("cancelled ServeHTTP did not return after ctx cancel")
	}

	a.markReady()
	select {
	case <-done2:
	case <-time.After(time.Second):
		t.Fatal("non-cancelled ServeHTTP did not complete after swap")
	}
	if w2.Code != http.StatusOK {
		t.Errorf("second request status=%d body=%q", w2.Code, w2.Body.String())
	}
}

func TestBaseRouter_ModelNotFound(t *testing.T) {
	a := newFakeProcess("a")
	b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})

	w := httptest.NewRecorder()
	b.ServeHTTP(w, newRequest("unknown"))

	if w.Code != http.StatusNotFound {
		t.Errorf("status=%d want %d body=%q", w.Code, http.StatusNotFound, w.Body.String())
	}
}

func TestBaseRouter_Shutdown_StopsAllProcesses(t *testing.T) {
	a := newFakeProcess("a")
	a.markReady()
	go a.Run(0)
	pb := newFakeProcess("b")
	pb.markReady()
	go pb.Run(0)

	b := newTestBase(t, map[string]process.Process{"a": a, "b": pb}, &stubPlanner{})

	if err := b.Shutdown(time.Second); err != nil {
		t.Fatalf("Shutdown: %v", err)
	}
	if got := a.stopCalls.Load(); got != 1 {
		t.Errorf("a.stopCalls=%d want 1", got)
	}
	if got := pb.stopCalls.Load(); got != 1 {
		t.Errorf("b.stopCalls=%d want 1", got)
	}

	// Subsequent ServeHTTP should report 5xx.
	w := httptest.NewRecorder()
	b.ServeHTTP(w, newRequest("a"))
	if w.Code != http.StatusInternalServerError && w.Code != http.StatusServiceUnavailable {
		t.Errorf("post-shutdown status=%d want 5xx body=%q", w.Code, w.Body.String())
	}

	// Second Shutdown should report already in progress.
	if err := b.Shutdown(0); err == nil {
		t.Errorf("second Shutdown returned nil, want error")
	}
}

func TestBaseRouter_ProgressHeaders(t *testing.T) {
	a := newFakeProcess("a")
	a.autoReady = true

	b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})

	// Cold: the request pays for the load, so it is reported as such.
	w := httptest.NewRecorder()
	b.ServeHTTP(w, newRequest("a"))
	if got := w.Header().Get("X-QM-Model"); got != "a" {
		t.Errorf("X-QM-Model=%q want %q", got, "a")
	}
	if got := w.Header().Get("X-QM-Model-Loaded"); got != "1" {
		t.Errorf("cold X-QM-Model-Loaded=%q want 1", got)
	}
	if _, err := strconv.Atoi(w.Header().Get("X-QM-Wait-Ms")); err != nil {
		t.Errorf("X-QM-Wait-Ms=%q not an integer: %v", w.Header().Get("X-QM-Wait-Ms"), err)
	}

	// Warm: the model is already resident, nothing was loaded.
	w2 := httptest.NewRecorder()
	b.ServeHTTP(w2, newRequest("a"))
	if got := w2.Header().Get("X-QM-Model-Loaded"); got != "0" {
		t.Errorf("warm X-QM-Model-Loaded=%q want 0", got)
	}
}

// A lease must be visible to Inflight(), not just to the scheduler. The VRAM
// watchdog decides whether a model may be shed by asking Inflight() from a
// sampling goroutine that cannot reach the run loop, so a render that is
// invisible here gets killed mid-job: the process is unloaded, restarted, and
// the poll for the job it was running answers 404.
func TestBaseRouter_InflightCountsLeases(t *testing.T) {
	p := newFakeProcess("vid")
	p.markReady()
	b := newTestBase(t, map[string]process.Process{"vid": p}, &stubPlanner{})

	// fakeProcess.Inflight() is always 0, so whatever we read is the lease.
	if n, ok := b.Inflight("vid"); !ok || n != 0 {
		t.Fatalf("Inflight before lease = (%d, %v), want (0, true)", n, ok)
	}

	release, ok := b.Lease("vid")
	if !ok {
		t.Fatal("Lease(vid) not granted")
	}
	if n, _ := b.Inflight("vid"); n != 1 {
		t.Errorf("Inflight while leased = %d, want 1: a rendering model reads as idle", n)
	}

	release()
	if n, _ := b.Inflight("vid"); n != 0 {
		t.Errorf("Inflight after release = %d, want 0", n)
	}
	// Release is documented as idempotent, so a caller that both defers it and
	// calls it on the terminal poll must not drive the count negative.
	release()
	if n, _ := b.Inflight("vid"); n != 0 {
		t.Errorf("Inflight after double release = %d, want 0", n)
	}
}