feat(advisor): snapshot builder + deterministic detectors
- internal/snapshot: compact cluster state via schedulerclient (nodes, PEND top-N most-starved with parsed pending_reason, cluster sums, queue garnish, recent exits). Field whitelists + stall-threshold filter keep the state a few KiB. Module-local PEND-reason table mirrors mcp-server/internal/pendreason.go (lift into schedulerclient is a P1 cleanup). - internal/detect: pend_stall / worker_health / util_skew / exit_burst triggers — threshold, then per-scenario cooldown, then dedup on a hash of the situation (job set + reasons / node set) so an unchanged cluster is never re-evaluated. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
1ab96521b8
commit
fbc81d61d1
7 changed files with 850 additions and 5 deletions
|
|
@ -4,12 +4,9 @@ go 1.25.5
|
||||||
|
|
||||||
replace schedulerclient => ../schedulerclient
|
replace schedulerclient => ../schedulerclient
|
||||||
|
|
||||||
require (
|
require schedulerclient v0.0.0-00010101000000-000000000000
|
||||||
schedulerclient v0.0.0-00010101000000-000000000000
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
resty.dev/v3 v3.0.0-beta.2 // indirect
|
|
||||||
golang.org/x/net v0.33.0 // indirect
|
golang.org/x/net v0.33.0 // indirect
|
||||||
|
resty.dev/v3 v3.0.0-beta.2 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
4
src/frontend/advisor/go.sum
Normal file
4
src/frontend/advisor/go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
resty.dev/v3 v3.0.0-beta.2 h1:xu4mGAdbCLuc3kbk7eddWfWm4JfhwDtdapwss5nCjnQ=
|
||||||
|
resty.dev/v3 v3.0.0-beta.2/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4=
|
||||||
212
src/frontend/advisor/internal/detect/detect.go
Normal file
212
src/frontend/advisor/internal/detect/detect.go
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
// Package detect holds the advisor's deterministic triggers — cheap, no
|
||||||
|
// LLM. A detector looks at one snapshot and decides whether a scenario is
|
||||||
|
// worth a provider round: threshold, then cooldown (don't re-evaluate the
|
||||||
|
// same scenario too often), then dedup (don't re-evaluate an identical
|
||||||
|
// situation at all).
|
||||||
|
package detect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"advisor/internal/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scenario names, shared by detector, provider, and log.
|
||||||
|
const (
|
||||||
|
ScenarioPendStall = "pend_stall"
|
||||||
|
ScenarioWorkerHealth = "worker_health"
|
||||||
|
ScenarioUtilSkew = "util_skew"
|
||||||
|
ScenarioExitBurst = "exit_burst"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trigger is one fired detection.
|
||||||
|
type Trigger struct {
|
||||||
|
Scenario string
|
||||||
|
// DedupKey hashes the situation (job set + reason set / node set) so an
|
||||||
|
// unchanged situation is not re-evaluated on every scan. Two rounds with
|
||||||
|
// the same key produce the second finding only after the cooldown.
|
||||||
|
DedupKey string
|
||||||
|
// Fingerprint is a human-readable summary of what matched (logged, and
|
||||||
|
// what the echo provider re-derives from the state).
|
||||||
|
Fingerprint string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limits configures the detectors.
|
||||||
|
type Limits struct {
|
||||||
|
// PendStallAfter: PEND wait counted as stalled (default 5m).
|
||||||
|
PendStallAfter time.Duration
|
||||||
|
// PendSpikeMin: absolute PEND count growth that fires without waits.
|
||||||
|
PendSpikeMin int
|
||||||
|
// ExitBurstMin: non-zero exits within the window that fire.
|
||||||
|
ExitBurstMin int
|
||||||
|
// Cooldowns per scenario; a scenario on cooldown is skipped entirely.
|
||||||
|
Cooldowns map[string]time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLimits returns the P0 defaults.
|
||||||
|
func DefaultLimits() Limits {
|
||||||
|
return Limits{
|
||||||
|
PendStallAfter: 5 * time.Minute,
|
||||||
|
PendSpikeMin: 5,
|
||||||
|
ExitBurstMin: 3,
|
||||||
|
Cooldowns: map[string]time.Duration{
|
||||||
|
ScenarioPendStall: 10 * time.Minute,
|
||||||
|
ScenarioWorkerHealth: 30 * time.Minute,
|
||||||
|
ScenarioUtilSkew: 30 * time.Minute,
|
||||||
|
ScenarioExitBurst: 30 * time.Minute,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detector evaluates snapshots against the limits. Stateful: tracks
|
||||||
|
// per-scenario cooldown and the last dedup key seen.
|
||||||
|
type Detector struct {
|
||||||
|
lim Limits
|
||||||
|
now func() time.Time // injectable clock for tests
|
||||||
|
|
||||||
|
lastFired map[string]time.Time
|
||||||
|
lastKey map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDetector returns a Detector with the given limits.
|
||||||
|
func NewDetector(lim Limits) *Detector {
|
||||||
|
return &Detector{
|
||||||
|
lim: lim,
|
||||||
|
now: time.Now,
|
||||||
|
lastFired: map[string]time.Time{},
|
||||||
|
lastKey: map[string]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect runs all detectors over the snapshot and returns the scenarios to
|
||||||
|
// evaluate this round, in stable order.
|
||||||
|
func (d *Detector) Detect(st *snapshot.State) []Trigger {
|
||||||
|
var out []Trigger
|
||||||
|
if t, ok := d.pendStall(st); ok {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
if t, ok := d.workerHealth(st); ok {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
if t, ok := d.utilSkew(st); ok {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
if t, ok := d.exitBurst(st); ok {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fire applies cooldown + dedup; ok=true means the scenario should be
|
||||||
|
// evaluated this round and the state recorded.
|
||||||
|
func (d *Detector) fire(scenario, key, fingerprint string) (Trigger, bool) {
|
||||||
|
if cd, ok := d.lim.Cooldowns[scenario]; ok {
|
||||||
|
if last, fired := d.lastFired[scenario]; fired && d.now().Sub(last) < cd {
|
||||||
|
return Trigger{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Identical situation as last fired round: skip regardless of cooldown.
|
||||||
|
if d.lastKey[scenario] == key {
|
||||||
|
return Trigger{}, false
|
||||||
|
}
|
||||||
|
d.lastFired[scenario] = d.now()
|
||||||
|
d.lastKey[scenario] = key
|
||||||
|
return Trigger{Scenario: scenario, DedupKey: key, Fingerprint: fingerprint}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Detector) pendStall(st *snapshot.State) (Trigger, bool) {
|
||||||
|
if len(st.PendTop) == 0 && st.Cluster.PendingJobs < d.lim.PendSpikeMin {
|
||||||
|
return Trigger{}, false
|
||||||
|
}
|
||||||
|
// Key: the stalled job set and their reasons.
|
||||||
|
key := hashOf(reasonKey(st.PendTop))
|
||||||
|
fp := "pending=" + itoa(st.Cluster.PendingJobs) + " stalled=" + itoa(len(st.PendTop))
|
||||||
|
return d.fire(ScenarioPendStall, key, fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Detector) workerHealth(st *snapshot.State) (Trigger, bool) {
|
||||||
|
var bad []string
|
||||||
|
for _, n := range st.Nodes {
|
||||||
|
switch n.Status {
|
||||||
|
case "down", "unknown", "unavail", "closed":
|
||||||
|
bad = append(bad, n.Name+":"+n.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(bad) == 0 {
|
||||||
|
return Trigger{}, false
|
||||||
|
}
|
||||||
|
key := hashOf(join(bad, ","))
|
||||||
|
return d.fire(ScenarioWorkerHealth, key, join(bad, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Detector) utilSkew(st *snapshot.State) (Trigger, bool) {
|
||||||
|
var hot, idle []string
|
||||||
|
for _, n := range st.Nodes {
|
||||||
|
if n.CoresTotal < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case n.Utilization >= 0.8:
|
||||||
|
hot = append(hot, n.Name)
|
||||||
|
case n.Utilization <= 0.2:
|
||||||
|
idle = append(idle, n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hot) == 0 || len(idle) == 0 {
|
||||||
|
return Trigger{}, false
|
||||||
|
}
|
||||||
|
key := hashOf("hot=" + join(hot, ",") + ";idle=" + join(idle, ","))
|
||||||
|
fp := "hot=[" + join(hot, ",") + "] idle=[" + join(idle, ",") + "]"
|
||||||
|
return d.fire(ScenarioUtilSkew, key, fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Detector) exitBurst(st *snapshot.State) (Trigger, bool) {
|
||||||
|
var ids []string
|
||||||
|
for _, x := range st.RecentExits {
|
||||||
|
if x.ExitCode != 0 {
|
||||||
|
ids = append(ids, x.ID+":"+itoa(x.ExitCode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) < d.lim.ExitBurstMin {
|
||||||
|
return Trigger{}, false
|
||||||
|
}
|
||||||
|
key := hashOf(join(ids, ","))
|
||||||
|
return d.fire(ScenarioExitBurst, key, join(ids, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
// reasonKey builds a stable string from the pending set: job id + reason.
|
||||||
|
func reasonKey(pend []snapshot.JobState) string {
|
||||||
|
parts := make([]string, 0, len(pend))
|
||||||
|
for _, j := range pend {
|
||||||
|
r := "-"
|
||||||
|
if j.Reason != nil {
|
||||||
|
r = j.Reason.MainReasonName
|
||||||
|
}
|
||||||
|
parts = append(parts, j.ID+"="+r)
|
||||||
|
}
|
||||||
|
return join(parts, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashOf(s string) string {
|
||||||
|
sum := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
func join(parts []string, sep string) string {
|
||||||
|
out := ""
|
||||||
|
for i, p := range parts {
|
||||||
|
if i > 0 {
|
||||||
|
out += sep
|
||||||
|
}
|
||||||
|
out += p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(i int) string {
|
||||||
|
b, _ := json.Marshal(i)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
143
src/frontend/advisor/internal/detect/detect_test.go
Normal file
143
src/frontend/advisor/internal/detect/detect_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package detect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"advisor/internal/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
func healthyState() *snapshot.State {
|
||||||
|
return &snapshot.State{
|
||||||
|
Cluster: snapshot.ClusterSum{PendingJobs: 0, RunningJobs: 2, Utilization: 0.5},
|
||||||
|
Nodes: []snapshot.NodeState{
|
||||||
|
{Name: "t1", Status: "busy", CoresTotal: 8, CoresUsed: 4, Utilization: 0.5},
|
||||||
|
{Name: "t2", Status: "busy", CoresTotal: 8, CoresUsed: 4, Utilization: 0.5},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stalledState() *snapshot.State {
|
||||||
|
st := healthyState()
|
||||||
|
st.Cluster.PendingJobs = 2
|
||||||
|
st.PendTop = []snapshot.JobState{
|
||||||
|
{ID: "1-0-1-0", Reason: &snapshot.JobReason{MainReasonName: "no_resource"}},
|
||||||
|
{ID: "1-0-2-0", Reason: &snapshot.JobReason{MainReasonName: "no_resource"}},
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixedClock lets tests drive cooldown timing.
|
||||||
|
type fixedClock struct{ t time.Time }
|
||||||
|
|
||||||
|
func (c *fixedClock) Now() time.Time { return c.t }
|
||||||
|
|
||||||
|
func TestDetect_HealthyClusterFiresNothing(t *testing.T) {
|
||||||
|
d := NewDetector(DefaultLimits())
|
||||||
|
if got := d.Detect(healthyState()); len(got) != 0 {
|
||||||
|
t.Fatalf("healthy state fired: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect_PendStall(t *testing.T) {
|
||||||
|
d := NewDetector(DefaultLimits())
|
||||||
|
trigs := d.Detect(stalledState())
|
||||||
|
if len(trigs) != 1 || trigs[0].Scenario != ScenarioPendStall {
|
||||||
|
t.Fatalf("want one pend_stall trigger: %+v", trigs)
|
||||||
|
}
|
||||||
|
if trigs[0].DedupKey == "" || trigs[0].Fingerprint == "" {
|
||||||
|
t.Errorf("trigger missing key/fingerprint: %+v", trigs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same situation again: deduped even before cooldown matters.
|
||||||
|
if again := d.Detect(stalledState()); len(again) != 0 {
|
||||||
|
t.Fatalf("identical situation re-fired: %+v", again)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed job set → new key → fires again (cooldown 10m must still block
|
||||||
|
// if the clock hasn't moved; use the clock to jump past it).
|
||||||
|
d2 := NewDetector(DefaultLimits())
|
||||||
|
fc := &fixedClock{t: time.Now()}
|
||||||
|
d2.now = fc.Now
|
||||||
|
if got := d2.Detect(stalledState()); len(got) != 1 {
|
||||||
|
t.Fatalf("first fire failed")
|
||||||
|
}
|
||||||
|
st2 := stalledState()
|
||||||
|
st2.PendTop = append(st2.PendTop, snapshot.JobState{ID: "1-0-3-0", Reason: &snapshot.JobReason{MainReasonName: "no_resource"}})
|
||||||
|
if got := d2.Detect(st2); len(got) != 0 {
|
||||||
|
t.Fatalf("cooldown must block within 10m: %+v", got)
|
||||||
|
}
|
||||||
|
fc.t = fc.t.Add(11 * time.Minute)
|
||||||
|
if got := d2.Detect(st2); len(got) != 1 {
|
||||||
|
t.Fatalf("after cooldown, changed set must fire: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect_WorkerHealth(t *testing.T) {
|
||||||
|
st := healthyState()
|
||||||
|
st.Nodes[1].Status = "down"
|
||||||
|
d := NewDetector(DefaultLimits())
|
||||||
|
trigs := d.Detect(st)
|
||||||
|
if len(trigs) != 1 || trigs[0].Scenario != ScenarioWorkerHealth {
|
||||||
|
t.Fatalf("want worker_health: %+v", trigs)
|
||||||
|
}
|
||||||
|
if trigs[0].Fingerprint != "t2:down" {
|
||||||
|
t.Errorf("fingerprint = %q", trigs[0].Fingerprint)
|
||||||
|
}
|
||||||
|
// Same node still down → deduped. Node recovers then breaks again → new key.
|
||||||
|
if again := d.Detect(st); len(again) != 0 {
|
||||||
|
t.Fatalf("same outage re-fired: %+v", again)
|
||||||
|
}
|
||||||
|
st.Nodes[1].Status = "unknown"
|
||||||
|
if again := d.Detect(st); len(again) != 0 {
|
||||||
|
t.Fatalf("cooldown must block second scenario change within window: %+v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect_UtilSkew(t *testing.T) {
|
||||||
|
st := healthyState()
|
||||||
|
st.Nodes[0].Utilization = 0.9
|
||||||
|
st.Nodes[0].CoresUsed = 8
|
||||||
|
st.Nodes[1].Utilization = 0.1
|
||||||
|
st.Nodes[1].CoresUsed = 0
|
||||||
|
d := NewDetector(DefaultLimits())
|
||||||
|
trigs := d.Detect(st)
|
||||||
|
if len(trigs) != 1 || trigs[0].Scenario != ScenarioUtilSkew {
|
||||||
|
t.Fatalf("want util_skew: %+v", trigs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect_ExitBurst(t *testing.T) {
|
||||||
|
st := healthyState()
|
||||||
|
st.RecentExits = []snapshot.ExitState{
|
||||||
|
{ID: "j1", ExitCode: 127}, {ID: "j2", ExitCode: 127}, {ID: "j3", ExitCode: 1},
|
||||||
|
}
|
||||||
|
d := NewDetector(DefaultLimits())
|
||||||
|
trigs := d.Detect(st)
|
||||||
|
if len(trigs) != 1 || trigs[0].Scenario != ScenarioExitBurst {
|
||||||
|
t.Fatalf("want exit_burst: %+v", trigs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two non-zero exits: below the burst threshold → nothing.
|
||||||
|
st.RecentExits = st.RecentExits[:2]
|
||||||
|
d2 := NewDetector(DefaultLimits())
|
||||||
|
if got := d2.Detect(st); len(got) != 0 {
|
||||||
|
t.Fatalf("2 exits must not fire: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect_MultipleScenariosOneRound(t *testing.T) {
|
||||||
|
st := stalledState()
|
||||||
|
st.Nodes[1].Status = "down"
|
||||||
|
st.Nodes[0].Utilization, st.Nodes[0].CoresUsed = 0.9, 8
|
||||||
|
st.Nodes[1].Utilization, st.Nodes[1].CoresUsed = 0.1, 0
|
||||||
|
d := NewDetector(DefaultLimits())
|
||||||
|
got := d.Detect(st)
|
||||||
|
scenarios := map[string]bool{}
|
||||||
|
for _, tr := range got {
|
||||||
|
scenarios[tr.Scenario] = true
|
||||||
|
}
|
||||||
|
if !scenarios[ScenarioPendStall] || !scenarios[ScenarioWorkerHealth] || !scenarios[ScenarioUtilSkew] {
|
||||||
|
t.Fatalf("want pend_stall+worker_health+util_skew, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/frontend/advisor/internal/snapshot/pendreason.go
Normal file
95
src/frontend/advisor/internal/snapshot/pendreason.go
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pendingReason mirrors the apiserver rawPendReason (convert_pure.go),
|
||||||
|
// surfaced by schedulerclient as the JSON-encoded string
|
||||||
|
// Extensions["pending_reason"].
|
||||||
|
type pendingReason struct {
|
||||||
|
MainReasonName string `json:"main_reason_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// reasonScenario maps a PREASON_* name to the human-readable scenario.
|
||||||
|
// This mirrors mcp-server/internal/pendreason.go (module-local copy: the
|
||||||
|
// advisor and mcp-server are separate modules; lifting the table into
|
||||||
|
// schedulerclient is a deliberate P1 cleanup, not forgotten).
|
||||||
|
func reasonScenario(name string) string {
|
||||||
|
switch name {
|
||||||
|
case "bad_resreq":
|
||||||
|
return "the resource request is invalid or cannot be satisfied"
|
||||||
|
case "no_resource":
|
||||||
|
return "insufficient resource capacity"
|
||||||
|
case "match_fail":
|
||||||
|
return "no host matches the job's host-selection condition"
|
||||||
|
case "limit_global":
|
||||||
|
return "a global quota is exhausted"
|
||||||
|
case "limit_host":
|
||||||
|
return "a host-level quota is exhausted"
|
||||||
|
case "queue_closed":
|
||||||
|
return "the queue is closed (not accepting new jobs)"
|
||||||
|
case "queue_draining":
|
||||||
|
return "the queue is draining (no new jobs until it empties)"
|
||||||
|
case "time_window":
|
||||||
|
return "outside the queue's dispatch window"
|
||||||
|
case "host_unavail":
|
||||||
|
return "the host is unavailable"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("unknown reason (%s)", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePendingReason extracts the main reason name + scenario from a job's
|
||||||
|
// Extensions. Returns nil when absent or unparseable — a missing reason is
|
||||||
|
// reported as such, never guessed.
|
||||||
|
func parsePendingReason(ext map[string]any) *JobReason {
|
||||||
|
raw, ok := ext["pending_reason"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pr pendingReason
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case string:
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(v), &pr); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
if err := json.Unmarshal(b, &pr); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if pr.MainReasonName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &JobReason{
|
||||||
|
MainReasonName: pr.MainReasonName,
|
||||||
|
Scenario: reasonScenario(pr.MainReasonName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// round2 keeps snapshot numbers at two decimals so the JSON stays small.
|
||||||
|
func round2(f float64) float64 {
|
||||||
|
return float64(int(f*100+0.5*sign(f))) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(f float64) float64 {
|
||||||
|
if f < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func frac(a, b int) float64 {
|
||||||
|
if b == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(a) / float64(b)
|
||||||
|
}
|
||||||
179
src/frontend/advisor/internal/snapshot/snapshot.go
Normal file
179
src/frontend/advisor/internal/snapshot/snapshot.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
// Package snapshot builds the compact JSON cluster snapshot the providers
|
||||||
|
// evaluate. It reads through schedulerclient (apiserver REST) and trims
|
||||||
|
// hard: field whitelists and top-N truncation, because a cheap LLM's
|
||||||
|
// attention is the scarce resource.
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sclient "schedulerclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Builder pulls cluster state via the schedulerclient.
|
||||||
|
type Builder struct {
|
||||||
|
client sclient.Client
|
||||||
|
// PendTop bounds the pending-job list sent to the model (most-starved
|
||||||
|
// first). 20 keeps the state under a few KiB.
|
||||||
|
PendTop int
|
||||||
|
// StallThreshold is the PEND wait beyond which a job counts as stalled
|
||||||
|
// (default 5m, matching the pend_stall detector).
|
||||||
|
StallThreshold time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBuilder returns a Builder with the defaults applied.
|
||||||
|
func NewBuilder(client sclient.Client) *Builder {
|
||||||
|
return &Builder{client: client, PendTop: 20, StallThreshold: 5 * time.Minute}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is the snapshot shape (also the echo provider's parse target — the
|
||||||
|
// two must evolve together; see provider/echo.go echoState).
|
||||||
|
type State struct {
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
Cluster ClusterSum `json:"cluster"`
|
||||||
|
Nodes []NodeState `json:"nodes"`
|
||||||
|
PendTop []JobState `json:"pend_top"`
|
||||||
|
// RecentExits carries jobs that exited since the previous scan
|
||||||
|
// (exit_burst detector input); empty when none.
|
||||||
|
RecentExits []ExitState `json:"recent_exits,omitempty"`
|
||||||
|
Queues []QueueSum `json:"queues,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClusterSum struct {
|
||||||
|
PendingJobs int `json:"pending_jobs"`
|
||||||
|
RunningJobs int `json:"running_jobs"`
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeState struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CoresTotal int `json:"cores_total"`
|
||||||
|
CoresUsed int `json:"cores_used"`
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobState struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Queue string `json:"queue"`
|
||||||
|
WaitSeconds float64 `json:"wait_seconds"`
|
||||||
|
Reason *JobReason `json:"reason,omitempty"`
|
||||||
|
Cores int `json:"cores,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobReason is the parsed pending_reason (main_reason_name + the
|
||||||
|
// human-readable scenario name from the translation table).
|
||||||
|
type JobReason struct {
|
||||||
|
MainReasonName string `json:"main_reason_name,omitempty"`
|
||||||
|
Scenario string `json:"scenario,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExitState struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueueSum struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PendingJobs int `json:"pending_jobs"`
|
||||||
|
RunningJobs int `json:"running_jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build fetches jobs/nodes/queues and assembles the trimmed snapshot. The
|
||||||
|
// previous-exits input lets exit_burst see what changed since the last
|
||||||
|
// round; pass nil on the first round.
|
||||||
|
func (b *Builder) Build(ctx context.Context, prevExits []ExitState) (*State, error) {
|
||||||
|
st := &State{GeneratedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||||
|
|
||||||
|
nodes, err := b.client.ListNodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list nodes: %w", err)
|
||||||
|
}
|
||||||
|
st.Nodes = make([]NodeState, 0, len(nodes))
|
||||||
|
var totalCores, usedCores int
|
||||||
|
for _, n := range nodes {
|
||||||
|
util := 0.0
|
||||||
|
if n.CoresTotal > 0 {
|
||||||
|
util = float64(n.CoresUsed) / float64(n.CoresTotal)
|
||||||
|
}
|
||||||
|
st.Nodes = append(st.Nodes, NodeState{
|
||||||
|
Name: n.Name, Status: n.Status,
|
||||||
|
CoresTotal: n.CoresTotal, CoresUsed: n.CoresUsed,
|
||||||
|
Utilization: round2(util),
|
||||||
|
})
|
||||||
|
totalCores += n.CoresTotal
|
||||||
|
usedCores += n.CoresUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// PEND jobs are the pend_stall input. One filtered call per status
|
||||||
|
// keeps the apiserver query simple.
|
||||||
|
pend, _, err := b.client.ListJobs(ctx, sclient.JobFilter{Statuses: []sclient.JobStatus{sclient.JobStatusPending}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list pending jobs: %w", err)
|
||||||
|
}
|
||||||
|
run, _, err := b.client.ListJobs(ctx, sclient.JobFilter{Statuses: []sclient.JobStatus{sclient.JobStatusRunning}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list running jobs: %w", err)
|
||||||
|
}
|
||||||
|
st.Cluster = ClusterSum{
|
||||||
|
PendingJobs: len(pend),
|
||||||
|
RunningJobs: len(run),
|
||||||
|
Utilization: round2(frac(usedCores, totalCores)),
|
||||||
|
}
|
||||||
|
|
||||||
|
st.PendTop = topPending(pend, b.PendTop, b.StallThreshold)
|
||||||
|
|
||||||
|
if prevExits != nil {
|
||||||
|
st.RecentExits = prevExits
|
||||||
|
}
|
||||||
|
|
||||||
|
queues, err := b.client.ListQueues(ctx)
|
||||||
|
if err == nil { // queues are garnish; never fail the snapshot for them
|
||||||
|
for _, q := range queues {
|
||||||
|
st.Queues = append(st.Queues, QueueSum{Name: q.Name, PendingJobs: q.PendingJobs, RunningJobs: q.RunningJobs})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// topPending picks the most-starved pending jobs, keeping only those past
|
||||||
|
// the stall threshold (the snapshot feeds the pend_stall scenario; fresh
|
||||||
|
// PEND jobs are noise for the model).
|
||||||
|
func topPending(pend []*sclient.URMJob, n int, stall time.Duration) []JobState {
|
||||||
|
type keyed struct {
|
||||||
|
js JobState
|
||||||
|
wait time.Duration
|
||||||
|
}
|
||||||
|
var out []keyed
|
||||||
|
for _, j := range pend {
|
||||||
|
wait := time.Since(j.SubmissionTime)
|
||||||
|
if wait < stall {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
js := JobState{
|
||||||
|
ID: j.ID, Name: j.Name, Owner: j.Owner, Queue: j.Queue,
|
||||||
|
WaitSeconds: round2(wait.Seconds()),
|
||||||
|
Cores: j.Cores,
|
||||||
|
}
|
||||||
|
if pr := parsePendingReason(j.Extensions); pr != nil {
|
||||||
|
js.Reason = pr
|
||||||
|
}
|
||||||
|
out = append(out, keyed{js, wait})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, k int) bool { return out[i].wait > out[k].wait })
|
||||||
|
if len(out) > n {
|
||||||
|
out = out[:n]
|
||||||
|
}
|
||||||
|
res := make([]JobState, 0, len(out))
|
||||||
|
for _, k := range out {
|
||||||
|
res = append(res, k.js)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
215
src/frontend/advisor/internal/snapshot/snapshot_test.go
Normal file
215
src/frontend/advisor/internal/snapshot/snapshot_test.go
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sclient "schedulerclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeClient implements the read side of sclient.Client for tests.
|
||||||
|
type fakeClient struct {
|
||||||
|
jobs map[sclient.JobStatus][]*sclient.URMJob
|
||||||
|
nodes []*sclient.URMNode
|
||||||
|
queues []*sclient.URMQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) ListJobs(_ context.Context, filter sclient.JobFilter) ([]*sclient.URMJob, *sclient.Page, error) {
|
||||||
|
var out []*sclient.URMJob
|
||||||
|
for _, s := range filter.Statuses {
|
||||||
|
out = append(out, f.jobs[s]...)
|
||||||
|
}
|
||||||
|
return out, &sclient.Page{Total: len(out)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) ListNodes(_ context.Context) ([]*sclient.URMNode, error) { return f.nodes, nil }
|
||||||
|
func (f *fakeClient) ListQueues(_ context.Context) ([]*sclient.URMQueue, error) {
|
||||||
|
return f.queues, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) GetJob(context.Context, string) (*sclient.URMJob, error) {
|
||||||
|
panic("not needed")
|
||||||
|
}
|
||||||
|
func (f *fakeClient) GetJobLogs(context.Context, string, sclient.LogOpts) ([]string, error) {
|
||||||
|
panic("not needed")
|
||||||
|
}
|
||||||
|
func (f *fakeClient) GetClusterMetrics(context.Context) (*sclient.Metrics, error) {
|
||||||
|
panic("not needed")
|
||||||
|
}
|
||||||
|
func (f *fakeClient) SubmitJob(context.Context, sclient.JobSpec) (*sclient.SubmitJobResult, error) {
|
||||||
|
panic("observe-only: writes must be unreachable")
|
||||||
|
}
|
||||||
|
func (f *fakeClient) CancelJob(context.Context, string) error { panic("observe-only") }
|
||||||
|
func (f *fakeClient) HoldJob(context.Context, string) error { panic("observe-only") }
|
||||||
|
func (f *fakeClient) ReleaseJob(context.Context, string) error {
|
||||||
|
panic("observe-only")
|
||||||
|
}
|
||||||
|
func (f *fakeClient) CheckpointJob(context.Context, string) error { panic("observe-only") }
|
||||||
|
func (f *fakeClient) RestartJob(context.Context, string) (*sclient.RestartJobResult, error) {
|
||||||
|
panic("observe-only")
|
||||||
|
}
|
||||||
|
func (f *fakeClient) WatchJobs(context.Context) (<-chan sclient.JobEvent, error) {
|
||||||
|
panic("not needed in snapshot tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClient() *fakeClient {
|
||||||
|
old := time.Now().Add(-2 * time.Hour)
|
||||||
|
recent := time.Now().Add(-30 * time.Second)
|
||||||
|
return &fakeClient{
|
||||||
|
jobs: map[sclient.JobStatus][]*sclient.URMJob{
|
||||||
|
sclient.JobStatusPending: {
|
||||||
|
{ID: "1-0-1-0", Name: "stalled", Owner: "u1", Queue: "normal", SubmissionTime: old,
|
||||||
|
Cores: 4, Extensions: map[string]any{
|
||||||
|
"pending_reason": `{"main_reason":1,"main_reason_name":"no_resource"}`,
|
||||||
|
}},
|
||||||
|
{ID: "1-0-2-0", Name: "fresh", Owner: "u1", Queue: "normal", SubmissionTime: recent},
|
||||||
|
{ID: "1-0-3-0", Name: "matchfail", Owner: "u2", Queue: "gpu", SubmissionTime: old,
|
||||||
|
Extensions: map[string]any{
|
||||||
|
"pending_reason": `{"main_reason":2,"main_reason_name":"match_fail"}`,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
sclient.JobStatusRunning: {
|
||||||
|
{ID: "1-0-9-0", Name: "runner", SubmissionTime: old},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nodes: []*sclient.URMNode{
|
||||||
|
{Name: "t1", Status: "busy", CoresTotal: 8, CoresUsed: 6},
|
||||||
|
{Name: "t2", Status: "idle", CoresTotal: 8, CoresUsed: 0},
|
||||||
|
},
|
||||||
|
queues: []*sclient.URMQueue{
|
||||||
|
{Name: "normal", PendingJobs: 2, RunningJobs: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_TrimsAndSorts(t *testing.T) {
|
||||||
|
b := NewBuilder(testClient())
|
||||||
|
st, err := b.Build(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.Cluster.PendingJobs != 3 || st.Cluster.RunningJobs != 1 {
|
||||||
|
t.Errorf("cluster counts = %+v", st.Cluster)
|
||||||
|
}
|
||||||
|
// 6/16 cores used.
|
||||||
|
if st.Cluster.Utilization != 0.38 {
|
||||||
|
t.Errorf("utilization = %v, want 0.38", st.Cluster.Utilization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh PEND job (30s) filtered by stall threshold; two stalled remain,
|
||||||
|
// most-starved first. Both stalled are 2h old so order is by (equal)
|
||||||
|
// wait — assert set membership instead.
|
||||||
|
if len(st.PendTop) != 2 {
|
||||||
|
t.Fatalf("pend_top = %+v, want 2 stalled jobs", st.PendTop)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, j := range st.PendTop {
|
||||||
|
got[j.ID] = true
|
||||||
|
if j.WaitSeconds < 300 {
|
||||||
|
t.Errorf("%s below stall threshold: %v", j.ID, j.WaitSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !got["1-0-1-0"] || !got["1-0-3-0"] {
|
||||||
|
t.Errorf("stalled jobs missing: %+v", st.PendTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reason parsing.
|
||||||
|
for _, j := range st.PendTop {
|
||||||
|
if j.ID == "1-0-1-0" && (j.Reason == nil || j.Reason.MainReasonName != "no_resource") {
|
||||||
|
t.Errorf("reason parse failed: %+v", j.Reason)
|
||||||
|
}
|
||||||
|
if j.ID == "1-0-3-0" && (j.Reason == nil || j.Reason.Scenario != "no host matches the job's host-selection condition") {
|
||||||
|
t.Errorf("scenario translation failed: %+v", j.Reason)
|
||||||
|
}
|
||||||
|
if j.ID == "1-0-2-0" {
|
||||||
|
t.Errorf("fresh job leaked into pend_top")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodes.
|
||||||
|
if len(st.Nodes) != 2 || st.Nodes[0].Utilization != 0.75 {
|
||||||
|
t.Errorf("nodes = %+v", st.Nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queues garnish.
|
||||||
|
if len(st.Queues) != 1 || st.Queues[0].Name != "normal" {
|
||||||
|
t.Errorf("queues = %+v", st.Queues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_PendTopCap(t *testing.T) {
|
||||||
|
fc := testClient()
|
||||||
|
var pend []*sclient.URMJob
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
pend = append(pend, &sclient.URMJob{
|
||||||
|
ID: "1-0-" + jsonInt(i) + "-0", SubmissionTime: time.Now().Add(-time.Duration(i+1) * time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fc.jobs[sclient.JobStatusPending] = pend
|
||||||
|
b := NewBuilder(fc)
|
||||||
|
b.PendTop = 5
|
||||||
|
st, err := b.Build(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build: %v", err)
|
||||||
|
}
|
||||||
|
if len(st.PendTop) != 5 {
|
||||||
|
t.Fatalf("cap failed: %d entries", len(st.PendTop))
|
||||||
|
}
|
||||||
|
// Most-starved first: the oldest job (i=29 → 30h) is top.
|
||||||
|
if st.PendTop[0].ID != "1-0-29-0" {
|
||||||
|
t.Errorf("top = %s, want oldest", st.PendTop[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_StateJSONMatchesEchoShape(t *testing.T) {
|
||||||
|
// The snapshot must decode cleanly into the echo provider's echoState —
|
||||||
|
// the contract between the two packages.
|
||||||
|
st, err := NewBuilder(testClient()).Build(context.Background(), []ExitState{{ID: "1-0-7-0", ExitCode: 127}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build: %v", err)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var probe struct {
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
Cluster struct {
|
||||||
|
PendingJobs int `json:"pending_jobs"`
|
||||||
|
} `json:"cluster"`
|
||||||
|
Nodes []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"nodes"`
|
||||||
|
PendTop []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Reason *struct {
|
||||||
|
MainReasonName string `json:"main_reason_name"`
|
||||||
|
Scenario string `json:"scenario"`
|
||||||
|
} `json:"reason"`
|
||||||
|
} `json:"pend_top"`
|
||||||
|
RecentExits []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
} `json:"recent_exits"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||||
|
t.Fatalf("state JSON: %v", err)
|
||||||
|
}
|
||||||
|
if probe.Cluster.PendingJobs != 3 || len(probe.Nodes) != 2 {
|
||||||
|
t.Errorf("roundtrip mismatch: %+v", probe)
|
||||||
|
}
|
||||||
|
if len(probe.RecentExits) != 1 || probe.RecentExits[0].ID != "1-0-7-0" {
|
||||||
|
t.Errorf("recent_exits: %+v", probe.RecentExits)
|
||||||
|
}
|
||||||
|
if len(probe.PendTop) == 0 || probe.PendTop[0].Reason == nil {
|
||||||
|
t.Errorf("reason missing in JSON: %+v", probe.PendTop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonInt(i int) string {
|
||||||
|
b, _ := json.Marshal(i)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue