Files
gosentry/src/runner/seed.go
T
mixeme 29d2ffed8f fix: complete code review follow-ups for queue, stats, and docs
Replace overlap Pending flag with PendingRuns counter, match seed stats
by job_id, align average duration with TimedRunCount, and tidy docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 21:54:44 +03:00

173 lines
5.0 KiB
Go

package runner
import (
"bufio"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// SeededStats are the aggregate execution-time statistics reconstructed from a
// job's existing log files at startup. The fields mirror the run-time counters
// on domain.JobRuntime so the caller can fold them in directly.
type SeededStats struct {
RunCount int
FailCount int
LastDurationMS int64
AvgDurationMS int64
MaxDurationMS int64
TimedRunCount int
}
// SeedStats scans logsDir once and reconstructs per-job execution-time
// statistics from the log files written by previous runs, keyed by Job.ID.
//
// Log files are matched primarily by the job_id header line writeRunLog writes.
// When that header is absent (legacy logs), files fall back to the sanitized
// job-name suffix in the filename. For each job only the newest maxFiles
// matching logs are parsed, mirroring the retention policy that CleanupLogs
// enforces; a maxFiles of zero or less means "no bound". The duration and state
// are read from each log's header. Logs written before duration tracking existed
// carry no duration line: those are tolerated — they still count toward RunCount
// and FailCount but are left out of the duration aggregates (last/avg/max) so a
// missing duration cannot masquerade as a zero-millisecond run.
//
// A missing or unreadable logs directory yields an empty map rather than an
// error: seeding is best-effort and must never block startup.
func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededStats {
result := make(map[int]SeededStats, len(jobs))
entries, err := os.ReadDir(logsDir)
if err != nil {
return result
}
byID := make(map[int][]string)
byName := make(map[string][]string)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".log") {
continue
}
path := filepath.Join(logsDir, name)
if jobID, ok := readLogJobID(path); ok {
byID[jobID] = append(byID[jobID], name)
continue
}
base := name[:len(name)-len(".log")]
idx := strings.Index(base, "_")
if idx < 0 {
continue
}
byName[base[idx+1:]] = append(byName[base[idx+1:]], name)
}
for _, job := range jobs {
files := byID[job.ID]
if len(files) == 0 {
files = byName[sanitizeFileName(job.Name)]
}
if len(files) == 0 {
continue
}
// The timestamp prefix sorts chronologically, so a lexical sort puts the
// oldest first; keep the newest maxFiles to honor the retention bound.
sort.Strings(files)
if maxFiles > 0 && len(files) > maxFiles {
files = files[len(files)-maxFiles:]
}
result[job.ID] = aggregateLogStats(logsDir, files)
}
return result
}
// aggregateLogStats folds the header of each log file (oldest first) into one
// SeededStats. Files lacking a duration line contribute to the run/fail counts
// but not to the duration aggregates.
func aggregateLogStats(logsDir string, files []string) SeededStats {
var stats SeededStats
var durationSum int64
var durationCount int
for _, file := range files {
state, durationMS, hasDuration := readLogHeader(filepath.Join(logsDir, file))
stats.RunCount++
if state == "Failed" {
stats.FailCount++
}
if hasDuration {
// Files are oldest first, so the last assignment is the newest run.
stats.LastDurationMS = durationMS
if durationMS > stats.MaxDurationMS {
stats.MaxDurationMS = durationMS
}
durationSum += durationMS
durationCount++
}
}
if durationCount > 0 {
stats.TimedRunCount = durationCount
stats.AvgDurationMS = durationSum / int64(durationCount)
}
return stats
}
// readLogJobID reads the job_id field from a log file header.
func readLogJobID(path string) (int, bool) {
file, err := os.Open(path)
if err != nil {
return 0, false
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
break
}
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
id, err := strconv.Atoi(strings.TrimSpace(rest))
if err != nil {
return 0, false
}
return id, true
}
}
return 0, false
}
// readLogHeader reads the "state" and "duration" fields from a log file's
// header (the lines before the first blank line). hasDuration reports whether a
// well-formed duration line was present, distinguishing a legacy duration-less
// log from one that genuinely recorded a zero-millisecond run.
func readLogHeader(path string) (state string, durationMS int64, hasDuration bool) {
file, err := os.Open(path)
if err != nil {
return "", 0, false
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
break // end of header
}
if rest, ok := strings.CutPrefix(line, "state: "); ok {
state = strings.TrimSpace(rest)
} else if rest, ok := strings.CutPrefix(line, "duration: "); ok {
if value, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
durationMS = value
hasDuration = true
}
}
}
return state, durationMS, hasDuration
}