Skip to content

Commit 4d33bef

Browse files
authored
feat(store): purge earnings/runtime_events beyond retention (default 400d) (#55)
The desktop DB grew unbounded — earnings and runtime_events were never pruned. Add store.PurgeOldData(retentionDays): deletes rows older than the cutoff but keeps the latest earnings row per platform so ListLatestEarnings/dashboard never lose a service. Add config.RetentionDays (default 400, disabled at <=0) and run the purge on each scheduler cycle (cheap, idempotent, never aborts the cycle).
1 parent c4c3a4d commit 4d33bef

5 files changed

Lines changed: 226 additions & 2 deletions

File tree

app.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,29 @@ func (a *App) collectAll(ctx context.Context) {
938938
if collected > 0 {
939939
a.emitEvent("earnings:changed", collected)
940940
}
941+
942+
// Bound local database growth: drop earnings/runtime_events rows older than the
943+
// retention window (keeping the latest earnings row per platform). a.store is
944+
// non-nil here (guarded at function entry). Runs every cycle — the DELETE is
945+
// cheap and idempotent — so no extra timer is needed. A purge failure must not
946+
// abort the cycle: log it and carry on, the next cycle simply retries.
947+
if _, err := a.store.PurgeOldData(a.retentionDays()); err != nil {
948+
a.emitError("store", err)
949+
}
950+
}
951+
952+
// retentionDays is the configured data-retention window in days, applying the
953+
// 400-day default for a non-positive setting. collectAll purges earnings and
954+
// runtime_events rows older than this each cycle so the local database does not
955+
// grow without bound.
956+
func (a *App) retentionDays() int {
957+
days := 400
958+
if a.cfg != nil {
959+
if d := a.cfg.Config().RetentionDays; d > 0 {
960+
days = d
961+
}
962+
}
963+
return days
941964
}
942965

943966
// collectInterval is the configured collection cadence as a duration, applying the

internal/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type AppConfig struct {
2626
AutoUpdate bool `json:"autoUpdate"`
2727
HostnamePrefix string `json:"hostnamePrefix"`
2828
CollectIntervalMinutes int `json:"collectIntervalMinutes"`
29+
RetentionDays int `json:"retentionDays"`
2930
Timezone string `json:"timezone"`
3031
FleetAPIKey string `json:"fleetApiKey"`
3132
FleetBindAddress string `json:"fleetBindAddress"`
@@ -56,6 +57,9 @@ func applyDefaults(cfg AppConfig) AppConfig {
5657
if cfg.CollectIntervalMinutes <= 0 {
5758
cfg.CollectIntervalMinutes = 60
5859
}
60+
if cfg.RetentionDays <= 0 {
61+
cfg.RetentionDays = 400
62+
}
5963
if cfg.Timezone == "" {
6064
cfg.Timezone = "UTC"
6165
}

internal/config/config_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,17 @@ func TestNewManagerAppliesDefaults(t *testing.T) {
3333
AutoUpdate: true,
3434
HostnamePrefix: "cashpilot",
3535
CollectIntervalMinutes: 60,
36+
RetentionDays: 400,
3637
Timezone: "UTC",
3738
FleetBindAddress: "127.0.0.1",
3839
FleetPort: 8085,
3940
}
4041
if cfg != want {
4142
t.Fatalf("unexpected default config:\n got %+v\nwant %+v", cfg, want)
4243
}
44+
if cfg.RetentionDays != 400 {
45+
t.Fatalf("expected RetentionDays to default to 400, got %d", cfg.RetentionDays)
46+
}
4347
if m.AppDir() == "" || m.DataDir() == "" {
4448
t.Fatal("expected AppDir and DataDir to be set")
4549
}
@@ -53,6 +57,7 @@ func TestSaveCoercesEmptyAndInvalidValues(t *testing.T) {
5357
RuntimeProvider: "existing-docker",
5458
HostnamePrefix: "cashpilot",
5559
CollectIntervalMinutes: 60,
60+
RetentionDays: 400,
5661
Timezone: "UTC",
5762
FleetBindAddress: "127.0.0.1",
5863
FleetPort: 8085,
@@ -69,8 +74,8 @@ func TestSaveCoercesEmptyAndInvalidValues(t *testing.T) {
6974
want: defaults,
7075
},
7176
{
72-
name: "non-positive interval and port coerce to defaults",
73-
in: AppConfig{CollectIntervalMinutes: -5, FleetPort: 0},
77+
name: "non-positive interval, retention and port coerce to defaults",
78+
in: AppConfig{CollectIntervalMinutes: -5, RetentionDays: -3, FleetPort: 0},
7479
want: defaults,
7580
},
7681
{
@@ -102,6 +107,7 @@ func TestSavePreservesValidValues(t *testing.T) {
102107
AutoUpdate: true,
103108
HostnamePrefix: "myrig",
104109
CollectIntervalMinutes: 15,
110+
RetentionDays: 180,
105111
Timezone: "Europe/Madrid",
106112
FleetAPIKey: "token-123",
107113
FleetBindAddress: "127.0.0.1",

internal/store/store.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,39 @@ func (s *Store) ListDailyBalances(daysBack int) []DailyBalance {
297297
return out
298298
}
299299

300+
// PurgeOldData deletes earnings and runtime_events rows older than the cutoff,
301+
// but NEVER the most-recent earnings row per platform (so ListLatestEarnings and
302+
// the dashboard breakdown keep working for a service that hasn't updated in a
303+
// long time). Returns the number of rows deleted. A retentionDays <= 0 is a no-op
304+
// (retention disabled).
305+
func (s *Store) PurgeOldData(retentionDays int) (int64, error) {
306+
if retentionDays <= 0 {
307+
return 0, nil
308+
}
309+
// created_at is stored as RFC3339Nano (see SaveEarnings); format the cutoff the
310+
// same way so the string comparison below is apples-to-apples.
311+
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays).Format(time.RFC3339Nano)
312+
313+
// Keep the most-recent row per platform regardless of age: a service that has
314+
// not reported in longer than the retention window must still contribute its
315+
// last-known balance to ListLatestEarnings and the dashboard breakdown.
316+
earningsRes, err := s.db.Exec(`
317+
DELETE FROM earnings
318+
WHERE created_at < ? AND id NOT IN (SELECT MAX(id) FROM earnings GROUP BY platform)
319+
`, cutoff)
320+
if err != nil {
321+
return 0, err
322+
}
323+
deleted, _ := earningsRes.RowsAffected()
324+
325+
eventsRes, err := s.db.Exec(`DELETE FROM runtime_events WHERE created_at < ?`, cutoff)
326+
if err != nil {
327+
return deleted, err
328+
}
329+
events, _ := eventsRes.RowsAffected()
330+
return deleted + events, nil
331+
}
332+
300333
func (s *Store) UpsertFleetDevice(device FleetDevice) (FleetDevice, error) {
301334
if device.Kind == "" {
302335
device.Kind = "worker"

internal/store/store_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,3 +549,161 @@ func TestListDailyBalancesTieBreakByID(t *testing.T) {
549549
t.Fatalf("intra-day tie-break did not pick the higher-id row: got balance=%v, want 9.0", got[0].Balance)
550550
}
551551
}
552+
553+
// TestPurgeOldData pins the retention purge: with a 400-day window it deletes
554+
// earnings and runtime_events rows older than the cutoff, KEEPS the most-recent
555+
// earnings row per platform even when that row is itself older than the cutoff
556+
// (so a long-stale service still shows its last balance), KEEPS rows newer than
557+
// the cutoff (even non-latest ones), returns the total number of rows deleted, is
558+
// idempotent, and does nothing when retention is disabled (retentionDays <= 0).
559+
func TestPurgeOldData(t *testing.T) {
560+
s := openTestStore(t)
561+
562+
// Timestamps relative to now so the test is stable regardless of the calendar
563+
// date it runs on; all seeds are days apart, well clear of any intra-second
564+
// RFC3339Nano ordering subtlety.
565+
ts := func(daysAgo int) string {
566+
return time.Now().UTC().AddDate(0, 0, -daysAgo).Format(time.RFC3339Nano)
567+
}
568+
569+
// "grows" is an active platform; its latest row (ts(5), highest id) is recent.
570+
// "stale" has not reported in a long time: its latest row (ts(500), highest id)
571+
// is OLDER than the 400-day cutoff and must survive via the keep-latest clause.
572+
// Insertion order fixes the AUTOINCREMENT ids, so the last insert per platform
573+
// is that platform's latest (MAX(id)).
574+
seed := []EarningsRecord{
575+
{Platform: "grows", Balance: 1.0, Currency: "USD", CreatedAt: ts(500)}, // old, not latest -> DELETE
576+
{Platform: "grows", Balance: 2.0, Currency: "USD", CreatedAt: ts(450)}, // old, not latest -> DELETE
577+
{Platform: "grows", Balance: 2.5, Currency: "USD", CreatedAt: ts(20)}, // newer than cutoff, not latest -> KEEP
578+
{Platform: "grows", Balance: 3.0, Currency: "USD", CreatedAt: ts(5)}, // newest -> latest -> KEEP
579+
{Platform: "stale", Balance: 10.0, Currency: "USD", CreatedAt: ts(600)}, // old, not latest -> DELETE
580+
{Platform: "stale", Balance: 20.0, Currency: "USD", CreatedAt: ts(500)}, // old, but latest -> KEEP
581+
}
582+
for _, r := range seed {
583+
if _, err := s.SaveEarnings(r); err != nil {
584+
t.Fatalf("SaveEarnings(%+v) error: %v", r, err)
585+
}
586+
}
587+
588+
// runtime_events: one clearly-old row inserted directly (RecordEvent always
589+
// stamps datetime('now'), so an old event can't be produced through it), plus a
590+
// fresh RecordEvent row that must survive.
591+
if _, err := s.db.Exec(
592+
`INSERT INTO runtime_events(slug, event, detail, created_at) VALUES(?, ?, ?, ?)`,
593+
"grows", "old-event", "", "2020-01-01 00:00:00",
594+
); err != nil {
595+
t.Fatalf("insert old runtime_event error: %v", err)
596+
}
597+
s.RecordEvent("grows", "recent-event", "detail")
598+
599+
countRows := func(table string) int {
600+
t.Helper()
601+
var n int
602+
// table is a test-local literal, never external input.
603+
if err := s.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil {
604+
t.Fatalf("count %s error: %v", table, err)
605+
}
606+
return n
607+
}
608+
609+
// Preconditions: everything seeded is present.
610+
if got := countRows("earnings"); got != 6 {
611+
t.Fatalf("expected 6 seeded earnings rows, got %d", got)
612+
}
613+
if got := countRows("runtime_events"); got != 2 {
614+
t.Fatalf("expected 2 seeded runtime_events rows, got %d", got)
615+
}
616+
617+
// Retention disabled: a non-positive window deletes nothing and returns 0.
618+
for _, disabled := range []int{0, -1} {
619+
n, err := s.PurgeOldData(disabled)
620+
if err != nil {
621+
t.Fatalf("PurgeOldData(%d) error: %v", disabled, err)
622+
}
623+
if n != 0 {
624+
t.Fatalf("PurgeOldData(%d) = %d, want 0 (retention disabled)", disabled, n)
625+
}
626+
}
627+
if countRows("earnings") != 6 || countRows("runtime_events") != 2 {
628+
t.Fatalf("a disabled purge must not delete anything: earnings=%d events=%d",
629+
countRows("earnings"), countRows("runtime_events"))
630+
}
631+
632+
// Real purge: 3 old earnings (grows ts500, grows ts450, stale ts600) + 1 old
633+
// event = 4 rows deleted.
634+
deleted, err := s.PurgeOldData(400)
635+
if err != nil {
636+
t.Fatalf("PurgeOldData(400) error: %v", err)
637+
}
638+
if deleted != 4 {
639+
t.Fatalf("PurgeOldData(400) = %d, want 4 (3 earnings + 1 event)", deleted)
640+
}
641+
642+
// 3 earnings survive: grows ts20 (2.5), grows ts5 (3.0), stale ts500 (20.0).
643+
if got := countRows("earnings"); got != 3 {
644+
t.Fatalf("expected 3 surviving earnings rows, got %d", got)
645+
}
646+
if got := countRows("runtime_events"); got != 1 {
647+
t.Fatalf("expected 1 surviving runtime_event, got %d", got)
648+
}
649+
650+
// The exact surviving balances prove: a row newer than the cutoff survives even
651+
// when it is NOT the latest (grows 2.5), and the latest row survives even though
652+
// it is older than the cutoff (stale 20.0).
653+
survived := map[float64]bool{}
654+
rows, err := s.db.Query(`SELECT balance FROM earnings`)
655+
if err != nil {
656+
t.Fatalf("query surviving balances error: %v", err)
657+
}
658+
for rows.Next() {
659+
var b float64
660+
if err := rows.Scan(&b); err != nil {
661+
rows.Close()
662+
t.Fatalf("scan balance error: %v", err)
663+
}
664+
survived[b] = true
665+
}
666+
rows.Close()
667+
for _, want := range []float64{2.5, 3.0, 20.0} {
668+
if !survived[want] {
669+
t.Fatalf("expected balance %v to survive the purge, survivors=%v", want, survived)
670+
}
671+
}
672+
for _, gone := range []float64{1.0, 2.0, 10.0} {
673+
if survived[gone] {
674+
t.Fatalf("expected balance %v to be purged, survivors=%v", gone, survived)
675+
}
676+
}
677+
678+
// The surviving runtime_event is the recent one, not the 2020 row.
679+
var lastEvent string
680+
if err := s.db.QueryRow(`SELECT event FROM runtime_events`).Scan(&lastEvent); err != nil {
681+
t.Fatalf("query surviving event error: %v", err)
682+
}
683+
if lastEvent != "recent-event" {
684+
t.Fatalf("expected the recent runtime_event to survive, got %q", lastEvent)
685+
}
686+
687+
// Both platforms still have a latest balance for the dashboard: grows -> 3.0
688+
// (recent), stale -> 20.0 (old but preserved).
689+
latest := map[string]float64{}
690+
for _, rec := range s.ListLatestEarnings() {
691+
latest[rec.Platform] = rec.Balance
692+
}
693+
if latest["grows"] != 3.0 {
694+
t.Fatalf("grows latest balance = %v, want 3.0", latest["grows"])
695+
}
696+
if latest["stale"] != 20.0 {
697+
t.Fatalf("stale latest balance = %v, want 20.0 (old latest row preserved)", latest["stale"])
698+
}
699+
700+
// Idempotent: a second purge finds nothing new to delete (the only rows still
701+
// older than the cutoff are the per-platform latest rows, which are protected).
702+
deleted, err = s.PurgeOldData(400)
703+
if err != nil {
704+
t.Fatalf("second PurgeOldData(400) error: %v", err)
705+
}
706+
if deleted != 0 {
707+
t.Fatalf("second PurgeOldData(400) = %d, want 0 (idempotent)", deleted)
708+
}
709+
}

0 commit comments

Comments
 (0)