mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-07-27 00:37:14 +02:00
cc-lib changed JobData, ScopedJobStats and Job.Statistics from flat maps to structs (a .Metrics map plus array-valued Groups) to represent filesystem (and future interconnect) metric groups. Migrate all construction, indexing and iteration to the new API across the archive backends, metricstore query path, metric dispatcher, archiver, importer, tagger, taskmanager, repository and API layers. Semantics: DecodeJobStats now projects JobData.Groups into ScopedJobStats.Groups, and the archiver derives per-filesystem node-scope statistics into Job.Statistics.Groups. deepCopy, resampling and the metric/scope filter are group-aware. The metricstore internal storage (buffers, selector tree, checkpoint/parquet) is unchanged; all conversion stays at the LoadData/archive-codec seam. Note: requires the corresponding cc-lib release; bump the cc-lib dependency version once tagged (a local go.mod replace was used during development and is intentionally not committed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
450 lines
14 KiB
Go
450 lines
14 KiB
Go
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
|
// All rights reserved. This file is part of cc-backend.
|
|
// Use of this source code is governed by a MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package tagger
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
|
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
|
"github.com/ClusterCockpit/cc-lib/v2/util"
|
|
"github.com/expr-lang/expr"
|
|
"github.com/expr-lang/expr/vm"
|
|
)
|
|
|
|
const (
|
|
// defaultJobClassConfigPath is the default path for job classification configuration
|
|
defaultJobClassConfigPath = "./var/tagger/jobclasses"
|
|
// tagTypeJobClass is the tag type identifier for job classification tags
|
|
tagTypeJobClass = "jobClass"
|
|
// jobClassConfigDirMatch is the directory name used for matching filesystem events
|
|
jobClassConfigDirMatch = "jobclasses"
|
|
// parametersFileName is the name of the parameters configuration file
|
|
parametersFileName = "parameters.json"
|
|
)
|
|
|
|
// Variable defines a named expression that can be computed and reused in rules.
|
|
// Variables are evaluated before the main rule and their results are added to the environment.
|
|
type Variable struct {
|
|
// Name is the variable identifier used in rule expressions
|
|
Name string `json:"name"`
|
|
// Expr is the expression to evaluate (must return a numeric value)
|
|
Expr string `json:"expr"`
|
|
}
|
|
|
|
type ruleVariable struct {
|
|
name string
|
|
expr *vm.Program
|
|
}
|
|
|
|
// RuleFormat defines the JSON structure for job classification rules.
|
|
// Each rule specifies requirements, metrics to analyze, variables to compute,
|
|
// and the final rule expression that determines if the job matches the classification.
|
|
type RuleFormat struct {
|
|
// Name is a human-readable description of the rule
|
|
Name string `json:"name"`
|
|
// Tag is the classification tag to apply if the rule matches
|
|
Tag string `json:"tag"`
|
|
// Parameters are shared values referenced in the rule (e.g., thresholds)
|
|
Parameters []string `json:"parameters"`
|
|
// Metrics are the job metrics required for this rule (e.g., "cpu_load", "mem_used")
|
|
Metrics []string `json:"metrics"`
|
|
// Requirements are boolean expressions that must be true for the rule to apply
|
|
Requirements []string `json:"requirements"`
|
|
// Variables are computed values used in the rule expression
|
|
Variables []Variable `json:"variables"`
|
|
// Rule is the boolean expression that determines if the job matches
|
|
Rule string `json:"rule"`
|
|
// Hint is a template string that generates a message when the rule matches
|
|
Hint string `json:"hint"`
|
|
}
|
|
|
|
type ruleInfo struct {
|
|
env map[string]any
|
|
metrics []string
|
|
requirements []*vm.Program
|
|
variables []ruleVariable
|
|
rule *vm.Program
|
|
hint *template.Template
|
|
}
|
|
|
|
// JobRepository defines the interface for job database operations needed by the tagger.
|
|
// This interface allows for easier testing and decoupling from the concrete repository implementation.
|
|
type JobRepository interface {
|
|
// HasTag checks if a job already has a specific tag
|
|
HasTag(jobID int64, tagType string, tagName string) bool
|
|
// AddTagOrCreateDirect adds a tag to a job or creates it if it doesn't exist
|
|
AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error)
|
|
// UpdateMetadata updates job metadata with a key-value pair
|
|
UpdateMetadata(job *schema.Job, key, val string) (err error)
|
|
}
|
|
|
|
// JobClassTagger classifies jobs based on configurable rules that evaluate job metrics and properties.
|
|
// Rules are loaded from an external configuration directory and can be dynamically reloaded when files change.
|
|
// When a job matches a rule, it is tagged with the corresponding classification and an optional hint message.
|
|
type JobClassTagger struct {
|
|
// rules maps classification tags to their compiled rule information
|
|
rules map[string]ruleInfo
|
|
// parameters are shared values (e.g., thresholds) used across multiple rules
|
|
parameters map[string]any
|
|
// tagType is the type of tag ("jobClass")
|
|
tagType string
|
|
// cfgPath is the path to watch for configuration changes
|
|
cfgPath string
|
|
// repo provides access to job database operations
|
|
repo JobRepository
|
|
// getStatistics retrieves job statistics for analysis
|
|
getStatistics func(job *schema.Job) (schema.JobStatisticsSet, error)
|
|
// getMetricConfig retrieves metric configuration (limits) for a cluster
|
|
getMetricConfig func(cluster, subCluster string) map[string]*schema.Metric
|
|
}
|
|
|
|
// roundEnv returns a copy of env with all float64 values rounded to 2 decimal places.
|
|
// Nested map[string]any and map[string]float64 values are recursed into.
|
|
func roundEnv(env map[string]any) map[string]any {
|
|
rounded := make(map[string]any, len(env))
|
|
for k, v := range env {
|
|
switch val := v.(type) {
|
|
case float64:
|
|
rounded[k] = math.Round(val*100) / 100
|
|
case map[string]any:
|
|
rounded[k] = roundEnv(val)
|
|
case map[string]float64:
|
|
rm := make(map[string]float64, len(val))
|
|
for mk, mv := range val {
|
|
rm[mk] = math.Round(mv*100) / 100
|
|
}
|
|
rounded[k] = rm
|
|
default:
|
|
rounded[k] = v
|
|
}
|
|
}
|
|
return rounded
|
|
}
|
|
|
|
func (t *JobClassTagger) prepareRule(b []byte, fns string) {
|
|
var rule RuleFormat
|
|
if err := json.NewDecoder(bytes.NewReader(b)).Decode(&rule); err != nil {
|
|
cclog.Warn("Error while decoding raw job meta json")
|
|
return
|
|
}
|
|
|
|
ri := ruleInfo{}
|
|
ri.env = make(map[string]any)
|
|
ri.metrics = make([]string, 0)
|
|
ri.requirements = make([]*vm.Program, 0)
|
|
ri.variables = make([]ruleVariable, 0)
|
|
|
|
// check if all required parameters are available
|
|
for _, p := range rule.Parameters {
|
|
param, ok := t.parameters[p]
|
|
if !ok {
|
|
cclog.Warnf("prepareRule() > missing parameter %s in rule %s", p, fns)
|
|
return
|
|
}
|
|
ri.env[p] = param
|
|
}
|
|
|
|
// set all required metrics
|
|
ri.metrics = append(ri.metrics, rule.Metrics...)
|
|
|
|
// compile requirements
|
|
for _, r := range rule.Requirements {
|
|
req, err := expr.Compile(r, expr.AsBool())
|
|
if err != nil {
|
|
cclog.Errorf("error compiling requirement %s: %#v", r, err)
|
|
return
|
|
}
|
|
ri.requirements = append(ri.requirements, req)
|
|
}
|
|
|
|
// compile variables
|
|
for _, v := range rule.Variables {
|
|
req, err := expr.Compile(v.Expr, expr.AsFloat64())
|
|
if err != nil {
|
|
cclog.Errorf("error compiling requirement %s: %#v", v.Name, err)
|
|
return
|
|
}
|
|
ri.variables = append(ri.variables, ruleVariable{name: v.Name, expr: req})
|
|
}
|
|
|
|
// compile rule
|
|
exp, err := expr.Compile(rule.Rule, expr.AsBool())
|
|
if err != nil {
|
|
cclog.Errorf("error compiling rule %s: %#v", fns, err)
|
|
return
|
|
}
|
|
ri.rule = exp
|
|
|
|
// prepare hint template
|
|
ri.hint, err = template.New(fns).Parse(rule.Hint)
|
|
if err != nil {
|
|
cclog.Errorf("error processing template %s: %#v", fns, err)
|
|
}
|
|
cclog.Infof("prepareRule() > processing %s with %d requirements and %d variables", fns, len(ri.requirements), len(ri.variables))
|
|
|
|
t.rules[rule.Tag] = ri
|
|
}
|
|
|
|
// EventMatch checks if a filesystem event should trigger configuration reload.
|
|
// It returns true if the event path contains "jobclasses".
|
|
func (t *JobClassTagger) EventMatch(s string) bool {
|
|
return strings.Contains(s, jobClassConfigDirMatch)
|
|
}
|
|
|
|
// EventCallback is triggered when the configuration directory changes.
|
|
// It reloads parameters and all rule files from the watched directory.
|
|
// FIXME: Only process the file that caused the event
|
|
func (t *JobClassTagger) EventCallback() {
|
|
files, err := os.ReadDir(t.cfgPath)
|
|
if err != nil {
|
|
cclog.Fatal(err)
|
|
}
|
|
|
|
t.rules = make(map[string]ruleInfo)
|
|
|
|
parametersFile := filepath.Join(t.cfgPath, parametersFileName)
|
|
if util.CheckFileExists(parametersFile) {
|
|
cclog.Info("Merge parameters")
|
|
b, err := os.ReadFile(parametersFile)
|
|
if err != nil {
|
|
cclog.Warnf("prepareRule() > open file error: %v", err)
|
|
}
|
|
|
|
var paramTmp map[string]any
|
|
if err := json.NewDecoder(bytes.NewReader(b)).Decode(¶mTmp); err != nil {
|
|
cclog.Warn("Error while decoding parameters.json")
|
|
}
|
|
|
|
maps.Copy(t.parameters, paramTmp)
|
|
}
|
|
|
|
for _, fn := range files {
|
|
fns := fn.Name()
|
|
if fns != parametersFileName {
|
|
cclog.Debugf("Process: %s", fns)
|
|
filename := filepath.Join(t.cfgPath, fns)
|
|
b, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
cclog.Warnf("prepareRule() > open file error: %v", err)
|
|
continue
|
|
}
|
|
t.prepareRule(b, fns)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *JobClassTagger) initParameters() error {
|
|
cclog.Info("Initialize parameters")
|
|
parametersFile := filepath.Join(t.cfgPath, parametersFileName)
|
|
b, err := os.ReadFile(parametersFile)
|
|
if err != nil {
|
|
cclog.Warnf("prepareRule() > open file error: %v", err)
|
|
return err
|
|
}
|
|
|
|
if err := json.NewDecoder(bytes.NewReader(b)).Decode(&t.parameters); err != nil {
|
|
cclog.Warn("Error while decoding parameters.json")
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Register initializes the JobClassTagger by loading parameters and classification rules from external folder.
|
|
// It sets up a file watch on ./var/tagger/jobclasses if it exists, allowing for
|
|
// dynamic configuration updates without restarting the application.
|
|
// Returns an error if the configuration path does not exist or cannot be read.
|
|
func (t *JobClassTagger) Register() error {
|
|
if t.cfgPath == "" {
|
|
t.cfgPath = defaultJobClassConfigPath
|
|
}
|
|
t.tagType = tagTypeJobClass
|
|
t.rules = make(map[string]ruleInfo)
|
|
|
|
if !util.CheckFileExists(t.cfgPath) {
|
|
return fmt.Errorf("configuration path does not exist: %s", t.cfgPath)
|
|
}
|
|
|
|
err := t.initParameters()
|
|
if err != nil {
|
|
cclog.Warnf("error reading parameters.json: %v", err)
|
|
return err
|
|
}
|
|
|
|
files, err := os.ReadDir(t.cfgPath)
|
|
if err != nil {
|
|
return fmt.Errorf("error reading jobclasses folder: %#v", err)
|
|
}
|
|
|
|
for _, fn := range files {
|
|
fns := fn.Name()
|
|
if fns != parametersFileName {
|
|
cclog.Infof("Process: %s", fns)
|
|
filename := filepath.Join(t.cfgPath, fns)
|
|
|
|
b, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
cclog.Warnf("prepareRule() > open file error: %v", err)
|
|
continue
|
|
}
|
|
t.prepareRule(b, fns)
|
|
}
|
|
}
|
|
|
|
cclog.Infof("Setup file watch for %s", t.cfgPath)
|
|
util.AddListener(t.cfgPath, t)
|
|
|
|
t.repo = repository.GetJobRepository()
|
|
t.getStatistics = archive.GetStatistics
|
|
t.getMetricConfig = archive.GetMetricConfigSubCluster
|
|
|
|
return nil
|
|
}
|
|
|
|
// Match evaluates all classification rules against a job and applies matching tags.
|
|
// It retrieves job statistics and metric configurations, then tests each rule's requirements
|
|
// and main expression. For each matching rule, it:
|
|
// - Applies the classification tag to the job
|
|
// - Generates and stores a hint message based on the rule's template
|
|
//
|
|
// The function constructs an evaluation environment containing:
|
|
// - Job properties (duration, cores, nodes, state, etc.)
|
|
// - Metric statistics (min, max, avg) and their configured limits
|
|
// - Shared parameters defined in parameters.json
|
|
// - Computed variables from the rule definition
|
|
//
|
|
// Rules are evaluated in arbitrary order. Multiple rules can match and apply
|
|
// their tags to the same job. Hint messages from all matching rules are collected
|
|
// and stored as a combined message in the job metadata.
|
|
func (t *JobClassTagger) Match(job *schema.Job) {
|
|
jobStats, err := t.getStatistics(job)
|
|
metricsList := t.getMetricConfig(job.Cluster, job.SubCluster)
|
|
cclog.Debugf("Enter match rule with %d rules for job %d", len(t.rules), job.JobID)
|
|
if err != nil {
|
|
cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err)
|
|
return
|
|
}
|
|
|
|
id := *job.ID
|
|
var messages []string
|
|
|
|
for tag, ri := range t.rules {
|
|
env := make(map[string]any)
|
|
maps.Copy(env, ri.env)
|
|
cclog.Debugf("Try to match rule %s for job %d", tag, job.JobID)
|
|
|
|
// Initialize environment
|
|
env["job"] = map[string]any{
|
|
"shared": job.Shared,
|
|
"duration": job.Duration,
|
|
"numCores": job.NumHWThreads,
|
|
"numNodes": job.NumNodes,
|
|
"jobState": job.State,
|
|
"numAcc": job.NumAcc,
|
|
"smt": job.SMT,
|
|
}
|
|
|
|
// add metrics to env
|
|
skipRule := false
|
|
for _, m := range ri.metrics {
|
|
stats, ok := jobStats.Metrics[m]
|
|
if !ok {
|
|
cclog.Debugf("job classification: missing metric '%s' for rule %s on job %d", m, tag, job.JobID)
|
|
skipRule = true
|
|
break
|
|
}
|
|
env[m] = map[string]any{
|
|
"min": stats.Min,
|
|
"max": stats.Max,
|
|
"avg": stats.Avg,
|
|
"limits": map[string]float64{
|
|
"peak": metricsList[m].Peak,
|
|
"normal": metricsList[m].Normal,
|
|
"caution": metricsList[m].Caution,
|
|
"alert": metricsList[m].Alert,
|
|
},
|
|
}
|
|
}
|
|
if skipRule {
|
|
continue
|
|
}
|
|
|
|
// check rule requirements apply
|
|
requirementsMet := true
|
|
for _, r := range ri.requirements {
|
|
ok, err := expr.Run(r, env)
|
|
if err != nil {
|
|
cclog.Debugf("error running requirement for rule %s: %#v", tag, err)
|
|
requirementsMet = false
|
|
break
|
|
}
|
|
if !ok.(bool) {
|
|
cclog.Debugf("requirement for rule %s not met", tag)
|
|
requirementsMet = false
|
|
break
|
|
}
|
|
}
|
|
if !requirementsMet {
|
|
continue
|
|
}
|
|
|
|
// evaluate rule variables
|
|
varError := false
|
|
for _, v := range ri.variables {
|
|
value, err := expr.Run(v.expr, env)
|
|
if err != nil {
|
|
cclog.Debugf("error evaluating variable %s for rule %s: %#v", v.name, tag, err)
|
|
varError = true
|
|
break
|
|
}
|
|
env[v.name] = value
|
|
}
|
|
if varError {
|
|
continue
|
|
}
|
|
|
|
match, err := expr.Run(ri.rule, env)
|
|
if err != nil {
|
|
cclog.Errorf("error running rule %s: %#v", tag, err)
|
|
continue
|
|
}
|
|
if match.(bool) {
|
|
if !t.repo.HasTag(id, t.tagType, tag) {
|
|
if _, err := t.repo.AddTagOrCreateDirect(id, t.tagType, tag); err != nil {
|
|
cclog.Errorf("failed to add tag '%s' to job %d: %v", tag, id, err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// process hint template
|
|
var msg bytes.Buffer
|
|
if err := ri.hint.Execute(&msg, roundEnv(env)); err != nil {
|
|
cclog.Errorf("Template error: %s", err.Error())
|
|
continue
|
|
}
|
|
messages = append(messages, msg.String())
|
|
}
|
|
}
|
|
|
|
if len(messages) > 0 {
|
|
combined := strings.Join(messages, "\n")
|
|
if err := t.repo.UpdateMetadata(job, "message", combined); err != nil {
|
|
cclog.Errorf("failed to update metadata for job %d: %v", *job.ID, err)
|
|
}
|
|
}
|
|
}
|