fix: restart wipes stats history — LoadAudit replayed only the last 3000 lines (92% of which are 3s access events) and fed access rows into aggregates; now replays every real request into totals/quota windows, skips event rows, tolerates >64KB lines; +TestLoadAuditFullReplay

This commit is contained in:
JianFeeeee
2026-08-11 08:42:13 +08:00
parent 6221502adc
commit e8ef73321c
3 changed files with 82 additions and 20 deletions

View File

@ -135,21 +135,30 @@ func incStatus(a *Stat, name string, r Req) {
func (s *Stats) LoadAudit(path string) {
f, err := os.Open(path)
if err == nil {
defer f.Close()
s.mu.Lock()
defer s.mu.Unlock()
var recs []Req
// tolerate long error summaries / oversized junk lines
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
for sc.Scan() {
var r Req
if json.Unmarshal(sc.Bytes(), &r) == nil {
recs = append(recs, r)
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
continue // access/event rows (obj) and malformed lines are no requests
}
// replay EVERY request row into the aggregates so totals and
// quota windows survive restarts; only the ring buffer view stays
// capped at maxRecs.
s.aggregateLocked(r)
recs = append(recs, r)
}
_ = f.Close()
if len(recs) > s.maxRecs {
recs = recs[len(recs)-s.maxRecs:]
}
for _, r := range recs {
s.Record(r)
if n := len(recs); n > s.maxRecs {
recs = append([]Req(nil), recs[n-s.maxRecs:]...)
}
s.recs = recs
s.auditPath = path
return
}
s.mu.Lock()
s.auditPath = path
@ -160,6 +169,20 @@ func (s *Stats) LoadAudit(path string) {
func (s *Stats) Record(r Req) {
s.mu.Lock()
defer s.mu.Unlock()
s.aggregateLocked(r)
s.recs = append(s.recs, r)
if len(s.recs) > s.maxRecs {
s.recs = s.recs[len(s.recs)-s.maxRecs:]
}
if s.auditPath != "" {
s.rotateAuditLocked()
appendAuditLine(s.auditPath, r)
}
}
// aggregateLocked folds r into every aggregate row and the quota window
// bucket. Caller must hold s.mu.
func (s *Stats) aggregateLocked(r Req) {
inc(s.byKey, r.Key, r)
if r.Model != "" {
inc(s.byModel, r.Model, r)
@ -180,13 +203,12 @@ func (s *Stats) Record(r Req) {
}
inc(ks, r.Source, r)
if r.Status > 0 {
name := strconv.Itoa(r.Status)
a := s.byStatus[r.Status]
if a == nil {
a = &Stat{}
s.byStatus[r.Status] = a
}
incStatus(a, name, r)
incStatus(a, strconv.Itoa(r.Status), r)
}
// window bucket for quota enforcement (per source-model pair, per unix hour)
tok := r.Prompt + r.Compl
@ -210,14 +232,6 @@ func (s *Stats) Record(r Req) {
}
}
}
s.recs = append(s.recs, r)
if len(s.recs) > s.maxRecs {
s.recs = s.recs[len(s.recs)-s.maxRecs:]
}
if s.auditPath != "" {
s.rotateAuditLocked()
appendAuditLine(s.auditPath, r)
}
}
// rotateAuditLocked renames the audit file to <path>.<unix>.old once it

View File

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
@ -85,4 +86,50 @@ func TestAuditRotationRecords(t *testing.T) {
if len(matches) != 1 {
t.Fatalf("Record must rotate too: got %d old files", len(matches))
}
}
func TestLoadAuditFullReplay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
lines := []string{
`{"obj":"access","time":1699999999000,"key":"k","method":"GET","path":"/api/stats","status":200}`,
`{"time":1700000000000,"key":"k","type":"chat","model":"m","source":"s","prompt_tokens":100,"completion_tokens":50,"latency_ms":10,"ok":true,"status":200}`,
`{this is not valid json`,
`{"time":1700003600000,"key":"k","type":"stream","model":"m","source":"s","prompt_tokens":200,"completion_tokens":20,"latency_ms":20,"ok":false,"status":503}`,
"garbage-not-json\n",
`{"time":1700007200000,"key":"k","type":"chat","model":"m2","source":"s","prompt_tokens":7,"completion_tokens":3,"latency_ms":5,"ok":true,"status":200}`,
}
loaded := strings.Join(lines, "\n") + "\n" + strings.Repeat("x", 1<<18) + "\n"
// oversized row at the END proves the scanner tolerates >64KB lines and
// still finishes the replay instead of truncating silently.
loaded += `{"time":1700010800000,"key":"k","type":"chat","model":"m","source":"s","prompt_tokens":1,"completion_tokens":1,"latency_ms":1,"ok":true,"status":200}` + "\n"
if err := os.WriteFile(path, []byte(loaded), 0644); err != nil {
t.Fatal(err)
}
s := NewStats(1000)
s.LoadAudit(path)
got := s.byModel["m"]
if got == nil || got.Tokens != 100+50+200+20+1+1 {
t.Fatalf("aggregates must replay EVERY request row, got %#v", got)
}
if s.byModel["m2"] == nil || s.byModel["m2"].Tokens != 10 {
t.Fatalf("m2 must be replayed too, got %#v", s.byModel["m2"])
}
// access rows are not requests: 4 real rows, junk skipped
if len(s.recs) != 4 {
t.Fatalf("ring must hold only real requests, got %d rows: %#v", len(s.recs), s.recs)
}
// quota window rebuilt from full history
if w := s.WindowTokens("m", "s", hourSec); w != 372 {
t.Fatalf("window tokens want 372, got %d", w)
}
// by_status only from requests (200 x3, 503 x1) — access line must not count
if st := s.byStatus[200]; st == nil || st.Reqs != 3 {
t.Fatalf("by_status 200 want 3 reqs, got %#v", st)
}
if s.byStatus[200].Err != 0 || s.byStatus[503] == nil || s.byStatus[503].Reqs != 1 {
t.Fatalf("by_status wrong: %#v", s.byStatus)
}
}