mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 17:07:59 +00:00
fix(gateway): aggregate the full audit history, repair record paging
Two problems reported after the on-demand log work landed.
1. Dashboard totals were wrong. LoadAudit only replayed the last 4 MB of the
audit file, so requests/tokens/per-key rows reflected a window instead of all
time — a regression in reported numbers, not just in presentation.
The aggregates are now built by streaming EVERY audit file (oldest first, so
the hourly quota buckets keep their intended trailing window) and keeping
nothing per record: aggregate maps are keyed by key/model/source, so their
size is bounded by cardinality. Measured on the production host: 29 MB /
221k lines / 37k requests in ~260 ms at startup.
What stays bounded is the RAW-record ring: a fixed-size reqRing keeps only the
newest maxRecs records, so the ~25 MB that used to be spent appending every
record into a slice is still saved. auditReplayBytes is gone, and
replayPartial now means "an audit file could not be read", which is the only
remaining way for the totals to be incomplete.
2. Scrolling to the bottom stopped loading more records. Two independent causes:
* paintRecords rebuilt the entire table on every 5s poll whenever the row
count did not exceed the first screen — the "is a paged view live?" test
compared row counts and matched exactly on the first refresh — wiping loaded
pages and resetting scroll position.
* IntersectionObserver only fires on TRANSITIONS. With a short list, or after a
page whose rows all duplicated the first screen, the sentinel stayed visible
and never fired again.
paintRecords now builds once (recsState.built) and later polls PREPEND only
genuinely new rows; attachRecsObserver adds a scroll-position fallback;
fillRecordsViewport loads until the list actually overflows; and
loadMoreRecords chains (bounded) when a page yields no new rows, since the
first fetch necessarily overlaps the first screen.
TestUIRecordsPagingWiring pins all four mechanisms structurally, since none of
them is reachable from Go. Test names/comments referring to bounded replay are
updated to describe the bounded RING instead, and both READMEs now state that
totals come from the full history while records are paged.
This commit is contained in:
@ -179,50 +179,110 @@ func writeAuditFile(t *testing.T, path string, n int, key string, startMs int64)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAuditBoundedTail is the startup-memory fix: only the tail of a large
|
||||
// audit file is replayed, and the ring stays capped.
|
||||
func TestLoadAuditBoundedTail(t *testing.T) {
|
||||
// TestLoadAuditAggregatesFullHistory is the correctness half of the log work:
|
||||
// the dashboard's totals must cover EVERY audit record, however large the file,
|
||||
// while the in-memory record ring stays bounded.
|
||||
func TestLoadAuditAggregatesFullHistory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
writeAuditFile(t, path, 20000, "k1", 1_700_000_000_000)
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orig := auditReplayBytes
|
||||
auditReplayBytes = 64 << 10 // 64 KB tail out of a much larger file
|
||||
defer func() { auditReplayBytes = orig }()
|
||||
if fi.Size() <= auditReplayBytes {
|
||||
t.Fatalf("fixture too small (%d bytes) to exercise bounded replay", fi.Size())
|
||||
}
|
||||
const n = 20000
|
||||
writeAuditFile(t, path, n, "k1", 1_700_000_000_000)
|
||||
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
if !s.ReplayPartial() {
|
||||
t.Fatal("a truncated replay must be reported as partial")
|
||||
if s.ReplayPartial() {
|
||||
t.Fatal("a readable history must not be reported as partial")
|
||||
}
|
||||
|
||||
snap := s.Snapshot(0, "")
|
||||
total, _ := snap["total"].(Stat)
|
||||
if total.Reqs != n {
|
||||
t.Fatalf("total reqs = %d, want %d (aggregates must cover the whole file)", total.Reqs, n)
|
||||
}
|
||||
if total.Tokens != int64(n)*2 { // 1 prompt + 1 completion per row
|
||||
t.Fatalf("total tokens = %d, want %d", total.Tokens, n*2)
|
||||
}
|
||||
|
||||
// ...while the resident ring is capped and the snapshot ships one screen
|
||||
recs, _ := snap["records"].([]Req)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("bounded replay must still load the recent window")
|
||||
t.Fatal("the newest records must stay resident")
|
||||
}
|
||||
if len(recs) > firstScreenRecords {
|
||||
t.Fatalf("snapshot shipped %d records, must be capped at the first screen (%d)",
|
||||
len(recs), firstScreenRecords)
|
||||
}
|
||||
// the aggregates must come from the tail only, not the whole file
|
||||
total, _ := snap["total"].(Stat)
|
||||
if total.Reqs == 0 {
|
||||
t.Fatal("tail replay must feed the aggregates")
|
||||
s.mu.Lock()
|
||||
ring := len(s.recs)
|
||||
s.mu.Unlock()
|
||||
if ring > defaultRingSize {
|
||||
t.Fatalf("resident ring holds %d records, must be capped at %d", ring, defaultRingSize)
|
||||
}
|
||||
if total.Reqs >= 20000 {
|
||||
t.Fatalf("aggregates replayed the whole file (%d reqs); replay must be bounded", total.Reqs)
|
||||
|
||||
// the ring must hold the NEWEST records, not the first ones scanned
|
||||
newest := recs[len(recs)-1]
|
||||
wantNewest := int64(1_700_000_000_000) + int64(n-1)*1000
|
||||
if newest.Time != wantNewest {
|
||||
t.Fatalf("newest resident record time = %d, want %d", newest.Time, wantNewest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAuditFullFileWhenSmall: a file inside the replay window is loaded
|
||||
// completely and is not flagged partial.
|
||||
// TestLoadAuditAggregatesAcrossRotatedFiles: history split over rotated .old
|
||||
// files must still add up, and must be replayed oldest-first so the hourly
|
||||
// quota buckets keep the intended trailing window.
|
||||
func TestLoadAuditAggregatesAcrossRotatedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
writeAuditFile(t, path, 40, "k1", 1_700_000_500_000) // newest
|
||||
writeAuditFile(t, path+".100.old", 40, "k1", 1_700_000_300_000) // older
|
||||
writeAuditFile(t, path+".050.old", 40, "k1", 1_700_000_100_000) // oldest
|
||||
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
snap := s.Snapshot(0, "")
|
||||
total, _ := snap["total"].(Stat)
|
||||
if total.Reqs != 120 {
|
||||
t.Fatalf("total reqs = %d, want 120 across three files", total.Reqs)
|
||||
}
|
||||
recs, _ := snap["records"].([]Req)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("expected resident records")
|
||||
}
|
||||
// oldest-first replay + newest-last ring => the last resident record is the
|
||||
// newest one in the live file
|
||||
if got := recs[len(recs)-1].Time; got != 1_700_000_500_000+39*1000 {
|
||||
t.Fatalf("newest resident record time = %d, want the live file's last row", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReqRingKeepsNewest pins the ring semantics used by the full-history scan:
|
||||
// bounded size, newest retained, oldest-first output.
|
||||
func TestReqRingKeepsNewest(t *testing.T) {
|
||||
r := newReqRing(3)
|
||||
for i := 1; i <= 7; i++ {
|
||||
r.push(Req{Time: int64(i)})
|
||||
}
|
||||
got := r.slice()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("ring len = %d, want 3", len(got))
|
||||
}
|
||||
for i, want := range []int64{5, 6, 7} {
|
||||
if got[i].Time != want {
|
||||
t.Fatalf("ring[%d] = %d, want %d (oldest-first, newest retained)", i, got[i].Time, want)
|
||||
}
|
||||
}
|
||||
|
||||
// a partially filled ring returns exactly what was pushed
|
||||
r2 := newReqRing(5)
|
||||
r2.push(Req{Time: 1})
|
||||
r2.push(Req{Time: 2})
|
||||
if got := r2.slice(); len(got) != 2 || got[0].Time != 1 || got[1].Time != 2 {
|
||||
t.Fatalf("partial ring = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAuditFullFileWhenSmall: a small file is loaded completely and is not
|
||||
// flagged partial.
|
||||
func TestLoadAuditFullFileWhenSmall(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.jsonl")
|
||||
writeAuditFile(t, path, 50, "k1", 1_700_000_000_000)
|
||||
@ -238,10 +298,10 @@ func TestLoadAuditFullFileWhenSmall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaWindowsSurviveBoundedReplay is the regression guard the plan calls
|
||||
// out: shrinking the ring must not break token-quota accounting, which reads the
|
||||
// hour buckets rather than the ring.
|
||||
func TestQuotaWindowsSurviveBoundedReplay(t *testing.T) {
|
||||
// TestQuotaWindowsSurviveBoundedRing is the regression guard the plan calls out:
|
||||
// capping the resident record ring must not break token-quota accounting, which
|
||||
// reads the hour buckets rather than the ring.
|
||||
func TestQuotaWindowsSurviveBoundedRing(t *testing.T) {
|
||||
s := NewStats(10) // deliberately tiny ring
|
||||
now := time.Now().UnixMilli()
|
||||
for i := 0; i < 100; i++ {
|
||||
|
||||
Reference in New Issue
Block a user