Bug 14: stream journal reads instead of buffering them; soft memory limit for the GC

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 17:51:09 -07:00
co-authored by Claude Fable 5.1
parent 03be65ee99
commit c1805f6ad5
2 changed files with 51 additions and 19 deletions
+5
View File
@@ -13,6 +13,7 @@ import (
"os/user" "os/user"
"path/filepath" "path/filepath"
"runtime" "runtime"
"runtime/debug"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -171,6 +172,10 @@ func serve(cfg *config.Config) error {
} }
} }
// Keep the heap tidy: the daemon's live data is tens of MB; without a limit the GC
// happily lets RSS coast at twice that. (Soft limit: exceeding it only means more GC.)
debug.SetMemoryLimit(160 << 20)
col := metrics.NewCollector() col := metrics.NewCollector()
go col.Run(context.Background()) go col.Run(context.Background())
+46 -19
View File
@@ -116,6 +116,35 @@ func run(ctx context.Context, stdin string, args ...string) (string, error) {
return out.String(), nil return out.String(), nil
} }
// runLines streams a command's stdout to fn line by line, so multi-megabyte journal
// dumps never sit in memory whole.
func runLines(ctx context.Context, fn func(line []byte), args ...string) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var errb bytes.Buffer
cmd.Stderr = &errb
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
for sc.Scan() {
fn(sc.Bytes())
}
if err := cmd.Wait(); err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New(msg)
}
return err
}
return nil
}
func (m *Manager) sudoArgs(args ...string) []string { func (m *Manager) sudoArgs(args ...string) []string {
if m.sudo { if m.sudo {
return append([]string{"sudo", "-n"}, args...) return append([]string{"sudo", "-n"}, args...)
@@ -414,18 +443,15 @@ func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
if m.journalSudo { if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...) args = append([]string{"sudo", "-n"}, args...)
} }
out, _ := run(ctx, "", args...)
seen := m.logging seen := m.logging
sc := bufio.NewScanner(strings.NewReader(out)) _ = runLines(ctx, func(line []byte) {
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
for sc.Scan() {
var raw struct { var raw struct {
Msg any `json:"MESSAGE"` Msg any `json:"MESSAGE"`
TS string `json:"__REALTIME_TIMESTAMP"` TS string `json:"__REALTIME_TIMESTAMP"`
Cursor string `json:"__CURSOR"` Cursor string `json:"__CURSOR"`
} }
if json.Unmarshal(sc.Bytes(), &raw) != nil { if json.Unmarshal(line, &raw) != nil {
continue return
} }
seen = true seen = true
if raw.Cursor != "" { if raw.Cursor != "" {
@@ -434,14 +460,14 @@ func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
msg, _ := raw.Msg.(string) msg, _ := raw.Msg.(string)
mm := cronLogRe.FindStringSubmatch(msg) mm := cronLogRe.FindStringSubmatch(msg)
if mm == nil { if mm == nil {
continue return
} }
us, _ := strconv.ParseInt(raw.TS, 10, 64) us, _ := strconv.ParseInt(raw.TS, 10, 64)
k := mm[1] + "\x00" + mm[2] k := mm[1] + "\x00" + mm[2]
if us/1000 > res[k] { if us/1000 > res[k] {
res[k] = us / 1000 res[k] = us / 1000
} }
} }, args...)
m.mu.Lock() m.mu.Lock()
m.lastRuns, m.lastAt, m.lastCursor, m.logging = res, time.Now(), cursor, seen m.lastRuns, m.lastAt, m.lastCursor, m.logging = res, time.Now(), cursor, seen
m.mu.Unlock() m.mu.Unlock()
@@ -454,28 +480,29 @@ func (m *Manager) JobLogs(ctx context.Context, j Job, n int) ([]map[string]any,
if m.journalSudo { if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...) args = append([]string{"sudo", "-n"}, args...)
} }
out, err := run(ctx, "", args...)
if err != nil {
return nil, err
}
var res []map[string]any var res []map[string]any
sc := bufio.NewScanner(strings.NewReader(out)) needle := "(" + j.User + ") CMD (" + j.Command + ")"
sc.Buffer(make([]byte, 0, 64<<10), 4<<20) err := runLines(ctx, func(line []byte) {
for sc.Scan() { if !bytes.Contains(line, []byte(j.Command[:min(len(j.Command), 40)])) { // cheap pre-filter before JSON
return
}
var raw struct { var raw struct {
Msg any `json:"MESSAGE"` Msg any `json:"MESSAGE"`
TS string `json:"__REALTIME_TIMESTAMP"` TS string `json:"__REALTIME_TIMESTAMP"`
PID string `json:"_PID"` PID string `json:"_PID"`
} }
if json.Unmarshal(sc.Bytes(), &raw) != nil { if json.Unmarshal(line, &raw) != nil {
continue return
} }
msg, _ := raw.Msg.(string) msg, _ := raw.Msg.(string)
if !strings.Contains(msg, "("+j.User+") CMD ("+j.Command+")") { if !strings.Contains(msg, needle) {
continue return
} }
us, _ := strconv.ParseInt(raw.TS, 10, 64) us, _ := strconv.ParseInt(raw.TS, 10, 64)
res = append(res, map[string]any{"t": us / 1000, "p": 6, "ident": "CRON", "pid": raw.PID, "msg": msg}) res = append(res, map[string]any{"t": us / 1000, "p": 6, "ident": "CRON", "pid": raw.PID, "msg": msg})
}, args...)
if err != nil {
return nil, err
} }
if len(res) > n { if len(res) > n {
res = res[len(res)-n:] res = res[len(res)-n:]