store: history table helpers (upsert/list, COALESCE preserves prior values)
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RepoStatsHistoryPoint is one (day, host) point for the trend chart.
|
||||||
|
// Both metric pointers may be nil — a row exists as soon as either
|
||||||
|
// metric was reported on that day.
|
||||||
|
type RepoStatsHistoryPoint struct {
|
||||||
|
Day time.Time // 00:00:00 UTC
|
||||||
|
TotalSizeBytes *int64
|
||||||
|
SnapshotCount *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertHostRepoStatsHistory records the metrics carried by a
|
||||||
|
// repo.stats patch into the daily history table. Only the non-nil
|
||||||
|
// fields of patch.TotalSizeBytes / patch.SnapshotCount are written;
|
||||||
|
// existing values in the row are preserved via COALESCE so a
|
||||||
|
// prune-only or check-only patch does not null out a backup-time
|
||||||
|
// size we already captured earlier the same day.
|
||||||
|
func (s *Store) UpsertHostRepoStatsHistory(
|
||||||
|
ctx context.Context, hostID, day string, patch HostRepoStats, recordedAt time.Time,
|
||||||
|
) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO host_repo_stats_history
|
||||||
|
(host_id, day, total_size_bytes, snapshot_count, recorded_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(host_id, day) DO UPDATE SET
|
||||||
|
total_size_bytes = COALESCE(excluded.total_size_bytes, host_repo_stats_history.total_size_bytes),
|
||||||
|
snapshot_count = COALESCE(excluded.snapshot_count, host_repo_stats_history.snapshot_count),
|
||||||
|
recorded_at = excluded.recorded_at`,
|
||||||
|
hostID, day,
|
||||||
|
nullableInt64(patch.TotalSizeBytes),
|
||||||
|
nullableInt64(patch.SnapshotCount),
|
||||||
|
recordedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: upsert host_repo_stats_history: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListHostRepoStatsHistory returns all points for hostID with day
|
||||||
|
// >= since (UTC), ordered ascending. Pass time.Time{} to fetch the
|
||||||
|
// full history.
|
||||||
|
func (s *Store) ListHostRepoStatsHistory(
|
||||||
|
ctx context.Context, hostID string, since time.Time,
|
||||||
|
) ([]RepoStatsHistoryPoint, error) {
|
||||||
|
sinceStr := ""
|
||||||
|
if !since.IsZero() {
|
||||||
|
sinceStr = since.UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT day, total_size_bytes, snapshot_count
|
||||||
|
FROM host_repo_stats_history
|
||||||
|
WHERE host_id = ? AND day >= ?
|
||||||
|
ORDER BY day ASC`,
|
||||||
|
hostID, sinceStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list host_repo_stats_history: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []RepoStatsHistoryPoint
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
dayStr string
|
||||||
|
total sql.NullInt64
|
||||||
|
snapCnt sql.NullInt64
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&dayStr, &total, &snapCnt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan history row: %w", err)
|
||||||
|
}
|
||||||
|
d, perr := time.Parse("2006-01-02", dayStr)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("store: parse history day %q: %w", dayStr, perr)
|
||||||
|
}
|
||||||
|
p := RepoStatsHistoryPoint{Day: d}
|
||||||
|
if total.Valid {
|
||||||
|
v := total.Int64
|
||||||
|
p.TotalSizeBytes = &v
|
||||||
|
}
|
||||||
|
if snapCnt.Valid {
|
||||||
|
v := snapCnt.Int64
|
||||||
|
p.SnapshotCount = &v
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("store: iterate history rows: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHostRepoStatsHistory_PartialUpsertPreservesPriorValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const hostID = "h-history-1"
|
||||||
|
seedHost(t, s, hostID)
|
||||||
|
|
||||||
|
day := "2026-05-07"
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// 1. First write of the day: total_size only.
|
||||||
|
if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day,
|
||||||
|
HostRepoStats{TotalSizeBytes: int64ptr(100)}, now); err != nil {
|
||||||
|
t.Fatalf("upsert 1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Second write: snapshot_count only — total_size MUST survive.
|
||||||
|
if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day,
|
||||||
|
HostRepoStats{SnapshotCount: int64ptr(7)}, now.Add(time.Minute)); err != nil {
|
||||||
|
t.Fatalf("upsert 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Third write: a prune-only patch (no size, no count). Both
|
||||||
|
// prior values must survive.
|
||||||
|
if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day,
|
||||||
|
HostRepoStats{}, now.Add(2*time.Minute)); err != nil {
|
||||||
|
t.Fatalf("upsert 3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(pts) != 1 {
|
||||||
|
t.Fatalf("want 1 point, got %d", len(pts))
|
||||||
|
}
|
||||||
|
p := pts[0]
|
||||||
|
if p.TotalSizeBytes == nil || *p.TotalSizeBytes != 100 {
|
||||||
|
t.Errorf("TotalSizeBytes: want 100, got %v", p.TotalSizeBytes)
|
||||||
|
}
|
||||||
|
if p.SnapshotCount == nil || *p.SnapshotCount != 7 {
|
||||||
|
t.Errorf("SnapshotCount: want 7, got %v", p.SnapshotCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostRepoStatsHistory_OrderingAndSinceFilter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const hostID = "h-history-2"
|
||||||
|
seedHost(t, s, hostID)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for i, day := range []string{"2026-05-01", "2026-05-02", "2026-05-04", "2026-05-07"} {
|
||||||
|
v := int64(100 + i*10)
|
||||||
|
if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day,
|
||||||
|
HostRepoStats{TotalSizeBytes: &v}, now); err != nil {
|
||||||
|
t.Fatalf("upsert %s: %v", day, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list all: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 4 {
|
||||||
|
t.Fatalf("want 4 points, got %d", len(all))
|
||||||
|
}
|
||||||
|
wantDays := []string{"2026-05-01", "2026-05-02", "2026-05-04", "2026-05-07"}
|
||||||
|
for i, p := range all {
|
||||||
|
got := p.Day.Format("2006-01-02")
|
||||||
|
if got != wantDays[i] {
|
||||||
|
t.Errorf("point %d: want day %s, got %s", i, wantDays[i], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
since, _ := time.Parse("2006-01-02", "2026-05-03")
|
||||||
|
recent, err := s.ListHostRepoStatsHistory(ctx, hostID, since)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list since: %v", err)
|
||||||
|
}
|
||||||
|
if len(recent) != 2 {
|
||||||
|
t.Fatalf("since 2026-05-03: want 2 points, got %d", len(recent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostRepoStatsHistory_CascadeOnHostDelete(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const hostID = "h-history-3"
|
||||||
|
seedHost(t, s, hostID)
|
||||||
|
if err := s.UpsertHostRepoStatsHistory(ctx, hostID, "2026-05-07",
|
||||||
|
HostRepoStats{TotalSizeBytes: int64ptr(42)}, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("upsert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteHost(ctx, hostID); err != nil {
|
||||||
|
t.Fatalf("delete host: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list after delete: %v", err)
|
||||||
|
}
|
||||||
|
if len(pts) != 0 {
|
||||||
|
t.Fatalf("want 0 points after host delete, got %d", len(pts))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user