fix(daily-volume): 历史快照的 partial 自动修正 + 三个回归测试
=== 现象 === 仓库里 6 条快照 partial 全是 true(10-27 到 10-30),前端把昨天的柱子 也画成虚线空心,看着像"每天都在采样中"。 === 根因 === 早期实现把两件事都塞进 partial:「今天还没过完」和「起点超出 24h」。 10-27/10-28 的数据现在已经被 retention 删掉、永远不会再被抓一次, 所以这两条错误标记会永久留在库里。 === 修法 === prune 里加幂等修正:过去的天,只有快照是**在那天结束之后**才抓的 (说明抓到的是完整一天)才改成 partial=false;当天中途抓的继续保持 partial (那时确实只有半天,不能伪装成完整数据)。 === 测试(3/3 PASS)=== TestPruneDailyVolumeNormalizesPartialAndDropsExpired · 昨天结束之后抓的 → partial=false · 前天中途抓的 → 保持 partial=true · 今天 → 保持 partial=true · 超留存期 → 被清理 · 条数不被清理流程清空 TestUpsertVolumeSnapshotKeepsEntries 刷新体积不清空已抓到的条数 TestGetDailyVolumeHistoryReturnsAllDaysWithMissing 始终返回 days 个点,缺的标 Missing 不伪造 0 go vet ./services/ ./models/ 干净;go test ./services/ 全绿。
This commit is contained in:
1 parent
b7de337c9e
commit
63dd491c5b
2 files changed
+164
-3
No files matched your search
@@ -301,24 +301,49 @@ func CaptureRecentDailyVolumes() {
|
||||
pruneDailyVolumeSnapshots()
|
||||
}
|
||||
|
||||
// pruneDailyVolumeSnapshots 丢弃超过保留期的历史。
|
||||
// pruneDailyVolumeSnapshots 丢弃超过保留期的历史,并修正历史遗留的 partial 标记。
|
||||
func pruneDailyVolumeSnapshots() {
|
||||
db := repository.GetDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
cutoff := dateKey(time.Now().AddDate(0, 0, -dailyVolumeRetentionDays))
|
||||
today := dateKey(time.Now())
|
||||
db.Lock()
|
||||
defer db.Unlock()
|
||||
changed := false
|
||||
kept := make([]models.DailyVolumeSnapshot, 0, len(db.DailyVolumeSnapshots))
|
||||
for _, snap := range db.DailyVolumeSnapshots {
|
||||
if snap.Date >= cutoff {
|
||||
kept = append(kept, snap)
|
||||
if snap.Date < cutoff {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, snap)
|
||||
}
|
||||
if len(kept) != len(db.DailyVolumeSnapshots) {
|
||||
log.Printf("[daily-volume] 清理过期快照 %d 条", len(db.DailyVolumeSnapshots)-len(kept))
|
||||
db.DailyVolumeSnapshots = kept
|
||||
changed = true
|
||||
}
|
||||
|
||||
// 历史遗留修正:**过去的天不可能"还在进行中"**。
|
||||
// 早期版本把「起点超出 24h」也当成 partial,于是昨天/前天的柱子一直是虚线空心,
|
||||
// 看起来像"每天都在采样中"。只有当快照是在那天**结束之后**才抓的
|
||||
// (说明抓的是完整一天)才改成完整;当天中途抓到的继续保持 partial —— 那时确实只有半天。
|
||||
for i := range db.DailyVolumeSnapshots {
|
||||
snap := &db.DailyVolumeSnapshots[i]
|
||||
if !snap.Partial || snap.Date >= today {
|
||||
continue
|
||||
}
|
||||
day, err := time.ParseInLocation("2006-01-02", snap.Date, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, dayEnd := dayRange(day); snap.CapturedAt.UnixNano() >= dayEnd {
|
||||
snap.Partial = false
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
db.RequestSave()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
func setupDailyVolumeDB(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
t.Setenv("JWT_SECRET", "test-jwt-secret-with-at-least-32-characters")
|
||||
t.Setenv("ENCRYPTION_KEY", "test-encryption-key-with-at-least-32-characters")
|
||||
if err := repository.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repository.Shutdown() })
|
||||
}
|
||||
|
||||
func snapshotsByDate(t *testing.T) map[string]models.DailyVolumeSnapshot {
|
||||
t.Helper()
|
||||
got := map[string]models.DailyVolumeSnapshot{}
|
||||
db := repository.GetDB()
|
||||
db.RLock()
|
||||
defer db.RUnlock()
|
||||
for _, snap := range db.DailyVolumeSnapshots {
|
||||
got[snap.Date] = snap
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// 过去的天不可能"还在进行中":只有在那天结束之后才抓的快照才算完整一天,
|
||||
// 当天中途抓到的必须保持 partial —— 否则会把"只抓到半天"伪装成完整数据。
|
||||
func TestPruneDailyVolumeNormalizesPartialAndDropsExpired(t *testing.T) {
|
||||
setupDailyVolumeDB(t)
|
||||
now := time.Now()
|
||||
midday := func(d time.Time) time.Time {
|
||||
return time.Date(d.Year(), d.Month(), d.Day(), 12, 0, 0, 0, time.Local)
|
||||
}
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
dayBefore := now.AddDate(0, 0, -2)
|
||||
expired := now.AddDate(0, 0, -(dailyVolumeRetentionDays + 5))
|
||||
|
||||
db := repository.GetDB()
|
||||
db.Lock()
|
||||
db.DailyVolumeSnapshots = []models.DailyVolumeSnapshot{
|
||||
// 昨天结束之后(次日 12:00)抓的 → 完整一天
|
||||
{ID: 1, Date: dateKey(yesterday), SourceID: 1, Bytes: 100, Entries: 7,
|
||||
Partial: true, CapturedAt: midday(now)},
|
||||
// 前天中午抓的 → 只有半天,保持 partial
|
||||
{ID: 2, Date: dateKey(dayBefore), SourceID: 1, Bytes: 50,
|
||||
Partial: true, CapturedAt: midday(dayBefore)},
|
||||
// 今天 → 永远 partial
|
||||
{ID: 3, Date: dateKey(now), SourceID: 1, Bytes: 10, Partial: true},
|
||||
// 超过留存期 → 必须被清理
|
||||
{ID: 4, Date: dateKey(expired), SourceID: 1, Bytes: 999},
|
||||
}
|
||||
db.Unlock()
|
||||
|
||||
pruneDailyVolumeSnapshots()
|
||||
|
||||
got := snapshotsByDate(t)
|
||||
if _, ok := got[dateKey(expired)]; ok {
|
||||
t.Error("超过留存期的快照没有被清理")
|
||||
}
|
||||
if snap := got[dateKey(yesterday)]; snap.Partial {
|
||||
t.Error("昨天结束之后抓的快照应标记为完整(partial=false)")
|
||||
}
|
||||
if snap := got[dateKey(dayBefore)]; !snap.Partial {
|
||||
t.Error("当天中途抓的快照必须保持 partial=true")
|
||||
}
|
||||
if snap := got[dateKey(now)]; !snap.Partial {
|
||||
t.Error("今天必须保持 partial=true")
|
||||
}
|
||||
if snap := got[dateKey(yesterday)]; snap.Entries != 7 {
|
||||
t.Errorf("条数不应被清理流程清空,得到 %d", snap.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新体积不能把已经抓到的条数(贵、每天最多 3 次)冲掉。
|
||||
func TestUpsertVolumeSnapshotKeepsEntries(t *testing.T) {
|
||||
setupDailyVolumeDB(t)
|
||||
|
||||
db := repository.GetDB()
|
||||
db.Lock()
|
||||
db.DailyVolumeSnapshots = []models.DailyVolumeSnapshot{
|
||||
{ID: 1, Date: "2026-10-01", SourceID: 1, Bytes: 111, Entries: 42, Partial: true},
|
||||
}
|
||||
db.Unlock()
|
||||
|
||||
if err := upsertVolumeSnapshot(1, "2026-10-01", 1000, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := snapshotsByDate(t)["2026-10-01"]
|
||||
if got.Bytes != 1000 || got.Entries != 42 || got.Partial {
|
||||
t.Fatalf("更新后 bytes=%d entries=%d partial=%v;期望 1000/42/false", got.Bytes, got.Entries, got.Partial)
|
||||
}
|
||||
|
||||
if err := upsertVolumeSnapshot(1, "2026-10-02", 2000, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := snapshotsByDate(t); len(got) != 2 {
|
||||
t.Fatalf("新增一天后应有 2 条快照,得到 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 接口必须始终返回 days 个点,缺的标 Missing("不知道"),绝不伪造 0("确实没日志")。
|
||||
func TestGetDailyVolumeHistoryReturnsAllDaysWithMissing(t *testing.T) {
|
||||
setupDailyVolumeDB(t)
|
||||
now := time.Now()
|
||||
|
||||
db := repository.GetDB()
|
||||
db.Lock()
|
||||
db.DailyVolumeSnapshots = []models.DailyVolumeSnapshot{
|
||||
{ID: 1, Date: dateKey(now), SourceID: 1, Bytes: 123, Partial: true},
|
||||
{ID: 2, Date: dateKey(now), SourceID: 2, Bytes: 999}, // 别的数据源,不应混进来
|
||||
}
|
||||
db.Unlock()
|
||||
|
||||
points := GetDailyVolumeHistory(1, 7)
|
||||
if len(points) != 7 {
|
||||
t.Fatalf("期望 7 个点,得到 %d", len(points))
|
||||
}
|
||||
today := points[len(points)-1]
|
||||
if today.Missing || today.Bytes != 123 || !today.Partial {
|
||||
t.Errorf("今天应命中快照:missing=%v bytes=%d partial=%v", today.Missing, today.Bytes, today.Partial)
|
||||
}
|
||||
for i := 0; i < len(points)-1; i++ {
|
||||
if !points[i].Missing || points[i].Bytes != 0 {
|
||||
t.Errorf("%s 应标记 Missing 且 bytes=0,得到 missing=%v bytes=%d",
|
||||
points[i].Date, points[i].Missing, points[i].Bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user