aboutsummaryrefslogtreecommitdiff
path: root/core/runs.go
diff options
context:
space:
mode:
Diffstat (limited to 'core/runs.go')
-rw-r--r--core/runs.go167
1 files changed, 167 insertions, 0 deletions
diff --git a/core/runs.go b/core/runs.go
new file mode 100644
index 0000000..38e6710
--- /dev/null
+++ b/core/runs.go
@@ -0,0 +1,167 @@
+// runs.jsonl read/write: the serve-side run log. One JSON RunRow appended
+// per non-empty poll batch; kept items double as the RSS render source, so
+// each carries everything a feed item needs (title/link/date/cleaned
+// content). Rotation is generous: past maxRunsBytes the file is rewritten
+// keeping the newest rows, via temp + rename so a crash mid-rotate can't
+// leave a torn log behind.
+package core
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// Kept-item kinds. Short strings so runs.jsonl stays human-greppable.
+const (
+ KindRanked = "ranked"
+ KindRunnerUp = "runnerup"
+ KindRandom = "random"
+)
+
+// Rotation knobs. 20MB of runs is years of batches at expected sizes, and
+// 500 retained rows is still far more than the count-based RSS tail (~21)
+// will ever render.
+const (
+ maxRunsBytes = 20 << 20
+ keepRunsOnRotate = 500
+)
+
+// KeptItem is one entry selected into a digest batch, with everything the
+// RSS renderer needs so rendering never touches Miniflux.
+type KeptItem struct {
+ EntryID int64 `json:"entry_id"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ FeedTitle string `json:"feed_title"`
+ PublishedAt time.Time `json:"published_at"`
+ Content string `json:"content"` // cleaned at poll time: HTML-stripped, <=5KB
+ Score float64 `json:"score"`
+ Kind string `json:"kind"` // KindRanked | KindRunnerUp | KindRandom
+}
+
+// RunRow is one poll batch: what was seen, what was kept, when, and how
+// old the model was. EntriesSeen counts every fetched entry including any
+// skipped before scoring (see RecFeedSkipped).
+type RunRow struct {
+ RanAt time.Time `json:"ran_at"`
+ EntriesSeen int `json:"entries_seen"`
+ RecFeedSkipped int `json:"rec_feed_skipped,omitempty"`
+ Kept []KeptItem `json:"kept"`
+ DurationMs int64 `json:"duration_ms"`
+ ModelCreatedAt time.Time `json:"model_created_at"`
+}
+
+// ReadRuns parses all of path strictly. A missing file is not an error —
+// it just means no runs logged yet (first serve boot).
+func ReadRuns(path string) ([]RunRow, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("opening runs: %w", err)
+ }
+ defer func() { _ = f.Close() }()
+
+ var rows []RunRow
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
+ line := 0
+ for scanner.Scan() {
+ line++
+ text := strings.TrimSpace(scanner.Text())
+ if text == "" {
+ continue
+ }
+ var row RunRow
+ if err := json.Unmarshal([]byte(text), &row); err != nil {
+ return nil, fmt.Errorf("%s:%d: invalid JSON: %w", path, line, err)
+ }
+ if row.RanAt.IsZero() {
+ return nil, fmt.Errorf("%s:%d: ran_at is required", path, line)
+ }
+ for i, k := range row.Kept {
+ if k.EntryID <= 0 {
+ return nil, fmt.Errorf("%s:%d: kept[%d]: entry_id must be positive", path, line, i)
+ }
+ if k.Kind != KindRanked && k.Kind != KindRunnerUp && k.Kind != KindRandom {
+ return nil, fmt.Errorf("%s:%d: kept[%d]: invalid kind %q", path, line, i, k.Kind)
+ }
+ }
+ rows = append(rows, row)
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("reading %s: %w", path, err)
+ }
+ return rows, nil
+}
+
+// ReadRunsTail returns the newest k runs in file order (oldest to newest).
+// k <= 0 reads everything.
+func ReadRunsTail(path string, k int) ([]RunRow, error) {
+ rows, err := ReadRuns(path)
+ if err != nil {
+ return nil, err
+ }
+ if k > 0 && len(rows) > k {
+ rows = rows[len(rows)-k:]
+ }
+ return rows, nil
+}
+
+// AppendRun appends one row, then rotates the file if it has grown past
+// maxRunsBytes. Appending is the only mutation; existing rows are never
+// edited, only dropped by rotation.
+func AppendRun(path string, row RunRow) error {
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("opening runs for append: %w", err)
+ }
+ if err := json.NewEncoder(f).Encode(row); err != nil {
+ _ = f.Close()
+ return fmt.Errorf("writing run row: %w", err)
+ }
+ if err := f.Close(); err != nil {
+ return fmt.Errorf("closing runs: %w", err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ return fmt.Errorf("stat runs: %w", err)
+ }
+ if info.Size() <= maxRunsBytes {
+ return nil
+ }
+ rows, err := ReadRuns(path)
+ if err != nil {
+ return err
+ }
+ if len(rows) > keepRunsOnRotate {
+ rows = rows[len(rows)-keepRunsOnRotate:]
+ }
+ tmp := filepath.Clean(path) + ".tmp"
+ w, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("rotating runs: %w", err)
+ }
+ enc := json.NewEncoder(w)
+ for _, r := range rows {
+ if err := enc.Encode(r); err != nil {
+ _ = w.Close()
+ return fmt.Errorf("rotating runs: %w", err)
+ }
+ }
+ if err := w.Close(); err != nil {
+ return fmt.Errorf("rotating runs: %w", err)
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ return fmt.Errorf("rotating runs: %w", err)
+ }
+ return nil
+}