From 6c69f323bd71c11fb6eb3439b00a2521c7fc9869 Mon Sep 17 00:00:00 2001 From: mixeme Date: Wed, 24 Jun 2026 08:16:47 +0300 Subject: [PATCH] runner/seed, app/service: seed run-time stats from log files (T2.5) Add SeedStats, which reconstructs per-job execution-time aggregates from existing log files: suffix-matched by sanitized job name, bounded by MaxLogFiles, and tolerant of duration-less legacy logs (counted in run/fail totals but excluded from last/avg/max). NewService folds the seed into each JobRuntime at build time so stats survive a restart. Co-Authored-By: Claude Opus 4.8 --- src/app/service.go | 14 +++++ src/runner/seed.go | 140 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/runner/seed.go diff --git a/src/app/service.go b/src/app/service.go index 5ebf990..210ca66 100644 --- a/src/app/service.go +++ b/src/app/service.go @@ -93,6 +93,20 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service { s.parseScheduleLocked(job) s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now) } + // Seed execution-time statistics from existing log files so the details panel + // shows accumulated run history immediately after a restart, not just runs + // since this process started. + for id, seed := range runner.SeedStats(store.Paths.LogsDir, jobs, store.Config.MaxLogFiles) { + runtime := s.runtimes[id] + if runtime == nil { + continue + } + runtime.RunCount = seed.RunCount + runtime.FailCount = seed.FailCount + runtime.LastDurationMS = seed.LastDurationMS + runtime.AvgDurationMS = seed.AvgDurationMS + runtime.MaxDurationMS = seed.MaxDurationMS + } return s } diff --git a/src/runner/seed.go b/src/runner/seed.go new file mode 100644 index 0000000..8adef9c --- /dev/null +++ b/src/runner/seed.go @@ -0,0 +1,140 @@ +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 +} + +// 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 to a job by the sanitized job-name suffix of the file +// name (the same name writeRunLog uses), so no per-file header read is needed to +// associate a log with its job. 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 + } + + // Group log file names by the sanitized job-name portion of the file name. + // File names are "_.log"; the timestamp has no + // underscore, so everything after the first underscore is the name part. + byName := make(map[string][]string) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(strings.ToLower(name), ".log") { + continue + } + base := name[:len(name)-len(".log")] + idx := strings.Index(base, "_") + if idx < 0 { + continue + } + jobPart := base[idx+1:] + byName[jobPart] = append(byName[jobPart], name) + } + + for _, job := range jobs { + 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.AvgDurationMS = durationSum / int64(durationCount) + } + return stats +} + +// 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 +}