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
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue