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 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,20 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
|||||||
s.parseScheduleLocked(job)
|
s.parseScheduleLocked(job)
|
||||||
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 "<timestamp>_<sanitizedName>.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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user