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>
This commit is contained in:
@@ -29,7 +29,7 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
|
||||
var detail string
|
||||
var durationMS int64
|
||||
if job.StartOnly {
|
||||
invocation := jobInvocation(context.Background(), *job)
|
||||
invocation := jobInvocation(ctx, *job)
|
||||
state, detail, output = startJobOnly(invocation, *job, started)
|
||||
// StartOnly jobs don't wait for process exit, so no meaningful duration.
|
||||
durationMS = 0
|
||||
|
||||
+46
-14
@@ -20,19 +20,20 @@ type SeededStats struct {
|
||||
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 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
|
||||
// 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
|
||||
@@ -44,9 +45,7 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
||||
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.
|
||||
byID := make(map[int][]string)
|
||||
byName := make(map[string][]string)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
@@ -56,17 +55,24 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
||||
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
|
||||
}
|
||||
jobPart := base[idx+1:]
|
||||
byName[jobPart] = append(byName[jobPart], name)
|
||||
byName[base[idx+1:]] = append(byName[base[idx+1:]], name)
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
files := byName[sanitizeFileName(job.Name)]
|
||||
files := byID[job.ID]
|
||||
if len(files) == 0 {
|
||||
files = byName[sanitizeFileName(job.Name)]
|
||||
}
|
||||
if len(files) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -105,11 +111,37 @@ func aggregateLogStats(logsDir string, files []string) SeededStats {
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
+47
-16
@@ -3,6 +3,8 @@ package runner
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
@@ -10,15 +12,21 @@ import (
|
||||
|
||||
// writeTestLog writes a minimal log file in the format writeRunLog produces.
|
||||
// Pass durationMS < 0 to omit the duration line (legacy log simulation).
|
||||
func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64) {
|
||||
// When jobID > 0 a job_id header line is included.
|
||||
func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64, jobID int) {
|
||||
t.Helper()
|
||||
var content string
|
||||
if durationMS >= 0 {
|
||||
content = "state: " + state + "\nduration: " + itoa(durationMS) + "\n\n"
|
||||
} else {
|
||||
content = "state: " + state + "\n\n"
|
||||
var content strings.Builder
|
||||
if jobID > 0 {
|
||||
content.WriteString("job_id: ")
|
||||
content.WriteString(strconv.Itoa(jobID))
|
||||
content.WriteString("\n")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0o644); err != nil {
|
||||
if durationMS >= 0 {
|
||||
content.WriteString("state: " + state + "\nduration: " + itoa(durationMS) + "\n\n")
|
||||
} else {
|
||||
content.WriteString("state: " + state + "\n\n")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content.String()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -47,9 +55,9 @@ func TestSeedStatsBasic(t *testing.T) {
|
||||
job := domain.Job{ID: 1, Name: "Build"}
|
||||
name := sanitizeFileName(job.Name)
|
||||
|
||||
writeTestLog(t, dir, "20260601-100000_"+name+".log", "OK", 200)
|
||||
writeTestLog(t, dir, "20260601-110000_"+name+".log", "Failed", 400)
|
||||
writeTestLog(t, dir, "20260601-120000_"+name+".log", "OK", 600)
|
||||
writeTestLog(t, dir, "20260601-100000_"+name+".log", "OK", 200, job.ID)
|
||||
writeTestLog(t, dir, "20260601-110000_"+name+".log", "Failed", 400, job.ID)
|
||||
writeTestLog(t, dir, "20260601-120000_"+name+".log", "OK", 600, job.ID)
|
||||
|
||||
result := SeedStats(dir, []domain.Job{job}, 0)
|
||||
s, ok := result[job.ID]
|
||||
@@ -83,9 +91,9 @@ func TestSeedStatsDurationLessLegacyLog(t *testing.T) {
|
||||
name := sanitizeFileName(job.Name)
|
||||
|
||||
// Legacy log (no duration line).
|
||||
writeTestLog(t, dir, "20260601-080000_"+name+".log", "OK", -1)
|
||||
writeTestLog(t, dir, "20260601-080000_"+name+".log", "OK", -1, job.ID)
|
||||
// Modern log with duration.
|
||||
writeTestLog(t, dir, "20260601-090000_"+name+".log", "OK", 300)
|
||||
writeTestLog(t, dir, "20260601-090000_"+name+".log", "OK", 300, job.ID)
|
||||
|
||||
result := SeedStats(dir, []domain.Job{job}, 0)
|
||||
s := result[job.ID]
|
||||
@@ -113,9 +121,9 @@ func TestSeedStatsMaxFilesHonoured(t *testing.T) {
|
||||
name := sanitizeFileName(job.Name)
|
||||
|
||||
// Write 3 logs; only the 2 newest should be counted (maxFiles=2).
|
||||
writeTestLog(t, dir, "20260601-060000_"+name+".log", "OK", 100)
|
||||
writeTestLog(t, dir, "20260601-070000_"+name+".log", "OK", 200)
|
||||
writeTestLog(t, dir, "20260601-080000_"+name+".log", "Failed", 300)
|
||||
writeTestLog(t, dir, "20260601-060000_"+name+".log", "OK", 100, job.ID)
|
||||
writeTestLog(t, dir, "20260601-070000_"+name+".log", "OK", 200, job.ID)
|
||||
writeTestLog(t, dir, "20260601-080000_"+name+".log", "Failed", 300, job.ID)
|
||||
|
||||
result := SeedStats(dir, []domain.Job{job}, 2)
|
||||
s := result[job.ID]
|
||||
@@ -140,10 +148,33 @@ func TestSeedStatsMissingDir(t *testing.T) {
|
||||
// any known job are silently ignored.
|
||||
func TestSeedStatsUnknownJobProducesNoEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestLog(t, dir, "20260601-100000_UnknownJob.log", "OK", 100)
|
||||
writeTestLog(t, dir, "20260601-100000_UnknownJob.log", "OK", 100, 0)
|
||||
|
||||
result := SeedStats(dir, []domain.Job{{ID: 1, Name: "KnownJob"}}, 0)
|
||||
if _, ok := result[1]; ok {
|
||||
t.Error("expected no entry for a job with no matching log files")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedStatsMatchesByJobID verifies that logs are associated by job_id even
|
||||
// when sanitized job names would collide.
|
||||
func TestSeedStatsMatchesByJobID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
jobA := domain.Job{ID: 1, Name: "foo@bar"}
|
||||
jobB := domain.Job{ID: 2, Name: "foo bar"}
|
||||
colliding := sanitizeFileName(jobA.Name)
|
||||
if colliding != sanitizeFileName(jobB.Name) {
|
||||
t.Fatalf("test setup: expected colliding sanitized names, got %q and %q", sanitizeFileName(jobA.Name), sanitizeFileName(jobB.Name))
|
||||
}
|
||||
|
||||
writeTestLog(t, dir, "20260601-100000_"+colliding+".log", "OK", 100, jobA.ID)
|
||||
writeTestLog(t, dir, "20260601-110000_"+colliding+".log", "Failed", 200, jobB.ID)
|
||||
|
||||
result := SeedStats(dir, []domain.Job{jobA, jobB}, 0)
|
||||
if result[jobA.ID].RunCount != 1 || result[jobA.ID].LastDurationMS != 100 {
|
||||
t.Errorf("job A stats = %+v, want one OK run at 100 ms", result[jobA.ID])
|
||||
}
|
||||
if result[jobB.ID].RunCount != 1 || result[jobB.ID].FailCount != 1 || result[jobB.ID].LastDurationMS != 200 {
|
||||
t.Errorf("job B stats = %+v, want one Failed run at 200 ms", result[jobB.ID])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user