Artifact 9971f436e5d5ad5ce9847e8f70c74ad1c3a8f61bc078b3a32881dedf14b4933b:
- File
internal/obsduck/prune_test.go
— part of check-in
[e2b74749b4]
at
2026-08-25 22:19:15
on branch fleet-census
— Analytics: say whether history is being kept, and stop keeping it for ever
The live panels read in-memory rings, so a forge whose store failed to open looked exactly like a healthy one. Sink.Err() existed and nothing asked it. The page now distinguishes three states: off, broken, and recording to a named path.
Retention was 'append for ever', which is a default nobody was asked about. --analytics-keep sets the window, 14d by default, 0 keeps everything. Pruned at startup and daily. (user: dmestas, size: 3045) [annotate] [blame] [check-ins using] [more...]
//go:build !js && !wasip1 package obsduck import ( "path/filepath" "testing" "time" "craftdesign.group/quarry/internal/obs" ) func openSink(t *testing.T) *Sink { t.Helper() s, err := Open(filepath.Join(t.TempDir(), "ops.duckdb"), 0) if err != nil { t.Skipf("duckdb unavailable: %v", err) } t.Cleanup(func() { s.Close() }) return s } func rows(t *testing.T, s *Sink, table string) int { t.Helper() var n int if err := s.db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil { t.Fatal(err) } return n } // Nothing pruned the store, so it grew without bound — on a busy forge // the thing watching for trouble becomes the trouble. The window is the // operator's to set, including setting it to for ever. func TestPruneDropsOnlyWhatIsOlderThanTheWindow(t *testing.T) { s := openSink(t) now := time.Now() s.Render(obs.Render{At: now.Add(-30 * 24 * time.Hour), Path: "GET /old"}) s.Render(obs.Render{At: now.Add(-time.Hour), Path: "GET /new"}) s.Subprocess(obs.Subprocess{At: now.Add(-30 * 24 * time.Hour), Verb: "ls"}) s.Subprocess(obs.Subprocess{At: now.Add(-time.Hour), Verb: "ls"}) s.Flush() if got := rows(t, s, "render"); got != 2 { t.Fatalf("before prune: %d renders, want 2", got) } n, err := s.Prune(14 * 24 * time.Hour) if err != nil { t.Fatal(err) } if n != 2 { t.Errorf("pruned %d rows, want 2 (one render, one subprocess)", n) } if got := rows(t, s, "render"); got != 1 { t.Errorf("after prune: %d renders, want 1", got) } if got := rows(t, s, "subprocess"); got != 1 { t.Errorf("after prune: %d subprocesses, want 1", got) } var path string if err := s.db.QueryRow(`SELECT path FROM render`).Scan(&path); err != nil { t.Fatal(err) } if path != "GET /new" { t.Errorf("the surviving render is %q — prune took the wrong end", path) } } // Keeping everything has to stay available, and has to be free: an // operator who switched retention off should not be paying for a DELETE // that can never match. func TestPruneKeepsEverythingWhenTheWindowIsZero(t *testing.T) { s := openSink(t) s.Render(obs.Render{At: time.Now().Add(-365 * 24 * time.Hour), Path: "GET /ancient"}) s.Flush() n, err := s.Prune(0) if err != nil { t.Fatal(err) } if n != 0 { t.Errorf("pruned %d rows with retention off", n) } if got := rows(t, s, "render"); got != 1 { t.Errorf("%d renders survived retention-off, want 1", got) } } // Prune flushes first: rows still buffered are the newest and would // survive anyway, but the count handed to an operator must not be a // guess about what was on disk at the time. func TestPruneFlushesBeforeCounting(t *testing.T) { s := openSink(t) s.Render(obs.Render{At: time.Now(), Path: "GET /buffered"}) if got := rows(t, s, "render"); got != 0 { t.Fatalf("precondition: %d rows already written, wanted them still buffered", got) } if _, err := s.Prune(time.Hour); err != nil { t.Fatal(err) } if got := rows(t, s, "render"); got != 1 { t.Errorf("%d renders on disk after prune, want the buffered one flushed", got) } }