From 1b305500a8fb10ccb9ed19d2a858f034a1eeeddd Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 13 Jul 2026 09:11:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=80=D0=BE=D1=82=D0=B0=D1=86=D0=B8=D1=8E=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claudeignore | 5 + internal/logger/retention.go | 155 +++++++++++++----------- internal/logger/retention_test.go | 192 ++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 67 deletions(-) create mode 100644 .claudeignore create mode 100644 internal/logger/retention_test.go diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..c1b90fa --- /dev/null +++ b/.claudeignore @@ -0,0 +1,5 @@ +node_modules/ +.git/ +dist/ +build/ +*.log \ No newline at end of file diff --git a/internal/logger/retention.go b/internal/logger/retention.go index e80d82e..97b0264 100644 --- a/internal/logger/retention.go +++ b/internal/logger/retention.go @@ -10,6 +10,10 @@ import ( "time" ) +// minKeepFiles — сколько самых свежих файлов всегда сохраняем, даже если +// возраст или размер говорят удалить. Защита от полного стирания директории. +const minKeepFiles = 2 + type Retention struct { maxAgeHours int maxSizeBytes int64 @@ -103,58 +107,21 @@ func (r *Retention) Cleanup() { } } -// cleanByAge возвращает количество удаленных файлов и их общий размер в байтах -func (r *Retention) cleanByAge(dataDir string) (int, int64) { - cutoff := time.Now().Add(-time.Duration(r.maxAgeHours) * time.Hour) - deleted := 0 - var deletedBytes int64 - - // Получаем все .bin файлы - files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin")) - if err != nil { - return 0, 0 - } - - for _, file := range files { - info, err := os.Stat(file) - if err != nil { - continue - } - - // Извлекаем время из имени файла - fileTime := r.parseFilenameTime(file) - if fileTime.IsZero() { - fileTime = info.ModTime() - } - - if fileTime.Before(cutoff) { - if err := os.Remove(file); err == nil { - deleted++ - deletedBytes += info.Size() - } - } - } - - return deleted, deletedBytes +// fileInfo описывает один лог-файл: путь, время из имени (или ModTime как +// запасной вариант), реальное время последней записи и размер. +type fileInfo struct { + path string + time time.Time + modTime time.Time + size int64 } -// cleanBySize возвращает количество удаленных файлов и их общий размер в байтах -func (r *Retention) cleanBySize(dataDir string) (int, int64) { - // Получаем все файлы +// collectFiles возвращает все gpio-*.bin, отсортированные от старых к новым +// (FIFO-порядок), и их суммарный размер в байтах. +func (r *Retention) collectFiles(dataDir string) ([]fileInfo, int64) { files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin")) if err != nil { - return 0, 0 - } - - if len(files) == 0 { - return 0, 0 - } - - // Сортируем по времени (старыми первыми) - type fileInfo struct { - path string - time time.Time - size int64 + return nil, 0 } var fileList []fileInfo @@ -166,40 +133,82 @@ func (r *Retention) cleanBySize(dataDir string) (int, int64) { continue } + // Время из имени файла; если распарсить не удалось — берём ModTime. fileTime := r.parseFilenameTime(file) if fileTime.IsZero() { fileTime = info.ModTime() } fileList = append(fileList, fileInfo{ - path: file, - time: fileTime, - size: info.Size(), + path: file, + time: fileTime, + modTime: info.ModTime(), + size: info.Size(), }) totalSize += info.Size() } - // Сортируем по времени (старые первые) + // Сортируем по времени (старые первые) — FIFO. sort.Slice(fileList, func(i, j int) bool { return fileList[i].time.Before(fileList[j].time) }) - // Удаляем самые старые пока не уложимся в лимит + return fileList, totalSize +} + +// cleanByAge удаляет старые файлы по FIFO: с самого старого края, пока файл +// старше cutoff И по имени, И по ModTime, и пока не упрёмся в minKeepFiles. +// Возвращает количество удалённых файлов и их общий размер в байтах. +func (r *Retention) cleanByAge(dataDir string) (int, int64) { + cutoff := time.Now().Add(-time.Duration(r.maxAgeHours) * time.Hour) + + files, _ := r.collectFiles(dataDir) + deleted := 0 var deletedBytes int64 - // Сохраняем как минимум 1 файл - for totalSize > r.maxSizeBytes && len(fileList) > 1 { - // Удаляем самый старый файл - oldest := fileList[0] - if err := os.Remove(oldest.path); err == nil { - totalSize -= oldest.size - deletedBytes += oldest.size - deleted++ - fileList = fileList[1:] - } else { + for len(files) > minKeepFiles { + f := files[0] + + // Возраст — это цель, а не гильотина. Файл удаляем только если он старше + // cutoff и по имени, и по реальному времени записи. Это защищает от + // скачка часов (после NTP на устройстве без RTC): свежий по ModTime + // файл со «старым» именем не будет снесён. + if !f.time.Before(cutoff) || !f.modTime.Before(cutoff) { + // Список отсортирован от старых к новым — дальше только моложе. break } + + if err := os.Remove(f.path); err != nil { + break + } + deleted++ + deletedBytes += f.size + files = files[1:] + } + + return deleted, deletedBytes +} + +// cleanBySize удаляет самые старые файлы, пока суммарный размер превышает лимит, +// сохраняя как минимум minKeepFiles файлов. +// Возвращает количество удалённых файлов и их общий размер в байтах. +func (r *Retention) cleanBySize(dataDir string) (int, int64) { + fileList, totalSize := r.collectFiles(dataDir) + + deleted := 0 + var deletedBytes int64 + + for totalSize > r.maxSizeBytes && len(fileList) > minKeepFiles { + // Удаляем самый старый файл. + oldest := fileList[0] + if err := os.Remove(oldest.path); err != nil { + break + } + totalSize -= oldest.size + deletedBytes += oldest.size + deleted++ + fileList = fileList[1:] } return deleted, deletedBytes @@ -213,10 +222,22 @@ func (r *Retention) parseFilenameTime(filename string) time.Time { return time.Time{} } - year, _ := time.Parse("2006", parts[1]) - month, _ := time.Parse("01", parts[2]) - day, _ := time.Parse("02", parts[3]) - hour, _ := time.Parse("15", parts[4]) + year, err := time.Parse("2006", parts[1]) + if err != nil { + return time.Time{} + } + month, err := time.Parse("01", parts[2]) + if err != nil { + return time.Time{} + } + day, err := time.Parse("02", parts[3]) + if err != nil { + return time.Time{} + } + hour, err := time.Parse("15", parts[4]) + if err != nil { + return time.Time{} + } return time.Date(year.Year(), month.Month(), day.Day(), hour.Hour(), 0, 0, 0, time.Local) } \ No newline at end of file diff --git a/internal/logger/retention_test.go b/internal/logger/retention_test.go new file mode 100644 index 0000000..f4b1b34 --- /dev/null +++ b/internal/logger/retention_test.go @@ -0,0 +1,192 @@ +package logger + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +// makeLogFile создаёт gpio-файл с заданным именем и выставляет ему ModTime. +func makeLogFile(t *testing.T, dir, name string, modTime time.Time) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("data"), 0644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + if err := os.Chtimes(path, modTime, modTime); err != nil { + t.Fatalf("chtimes %s: %v", name, err) + } + return path +} + +// nameForHour формирует имя файла в формате rotation.go для момента t. +func nameForHour(t time.Time) string { + return fmt.Sprintf("gpio-%04d-%02d-%02d-%02d.bin", + t.Year(), t.Month(), t.Day(), t.Hour()) +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// TestCleanByAge_RollingWindow: старые файлы удаляются, но свежие в окне 72ч и +// минимум minKeepFiles всегда сохраняются. +func TestCleanByAge_RollingWindow(t *testing.T) { + dir := t.TempDir() + now := time.Now() + + // Имена файлов хранят время с точностью до часа, поэтому держим обе группы + // с запасом от границы 72ч, чтобы округление до часа не решало исход теста. + var oldFiles, freshFiles []string + for hoursAgo := 200; hoursAgo >= 76; hoursAgo-- { // старше 72ч с запасом 4ч + ft := now.Add(-time.Duration(hoursAgo) * time.Hour) + oldFiles = append(oldFiles, makeLogFile(t, dir, nameForHour(ft), ft)) + } + for hoursAgo := 68; hoursAgo >= 1; hoursAgo-- { // свежее 72ч с запасом 4ч + ft := now.Add(-time.Duration(hoursAgo) * time.Hour) + freshFiles = append(freshFiles, makeLogFile(t, dir, nameForHour(ft), ft)) + } + + r := NewRetention(72, 0, nil) + deleted, _ := r.cleanByAge(dir) + + if deleted != len(oldFiles) { + t.Errorf("удалено %d, ожидалось %d старых файлов", deleted, len(oldFiles)) + } + for _, p := range oldFiles { + if exists(p) { + t.Errorf("старый файл не удалён: %s", filepath.Base(p)) + } + } + for _, p := range freshFiles { + if !exists(p) { + t.Errorf("свежий файл ошибочно удалён: %s", filepath.Base(p)) + } + } +} + +// TestCleanByAge_MinKeepFloor: даже когда ВСЕ файлы старше cutoff, minKeepFiles +// самых свежих остаются — директория не стирается целиком. +func TestCleanByAge_MinKeepFloor(t *testing.T) { + dir := t.TempDir() + now := time.Now() + + // 5 файлов, все старше 72ч. + var paths []string + for i := 0; i < 5; i++ { + ft := now.Add(-time.Duration(200-i) * time.Hour) + paths = append(paths, makeLogFile(t, dir, nameForHour(ft), ft)) + } + + r := NewRetention(72, 0, nil) + deleted, _ := r.cleanByAge(dir) + + remaining := 5 - deleted + if remaining != minKeepFiles { + t.Errorf("осталось %d файлов, ожидалось minKeepFiles=%d", remaining, minKeepFiles) + } + // Должны выжить именно самые свежие (последние два по индексу). + for _, p := range paths[len(paths)-minKeepFiles:] { + if !exists(p) { + t.Errorf("свежайший файл должен был сохраниться: %s", filepath.Base(p)) + } + } +} + +// TestCleanByAge_ClockJumpGuard: имя файла «старое», но ModTime свежий +// (сценарий скачка часов после NTP). Файл должен остаться. +func TestCleanByAge_ClockJumpGuard(t *testing.T) { + dir := t.TempDir() + now := time.Now() + + // Достаточно файлов, чтобы floor не мешал проверке guard. + // Настоящие старые файлы (и имя, и ModTime старые). + oldTime := now.Add(-200 * time.Hour) + for i := 0; i < 4; i++ { + ft := oldTime.Add(time.Duration(i) * time.Hour) + makeLogFile(t, dir, nameForHour(ft), ft) + } + // Файл со «старым» именем, но записанный только что. + staleName := nameForHour(now.Add(-300 * time.Hour)) + guarded := makeLogFile(t, dir, staleName, now) + + r := NewRetention(72, 0, nil) + r.cleanByAge(dir) + + if !exists(guarded) { + t.Errorf("файл со свежим ModTime не должен удаляться при скачке часов: %s", staleName) + } +} + +// TestCleanByAge_ParseFailureFallsBackToModTime: нераспознаваемое имя не должно +// приводить к удалению свежего файла (fallback на ModTime). +func TestCleanByAge_ParseFailureFallsBackToModTime(t *testing.T) { + dir := t.TempDir() + now := time.Now() + + // Контекст из нормальных старых файлов, чтобы был выбор для удаления. + for i := 0; i < 4; i++ { + ft := now.Add(-time.Duration(200-i) * time.Hour) + makeLogFile(t, dir, nameForHour(ft), ft) + } + // Битое имя, но свежий ModTime. + garbage := makeLogFile(t, dir, "gpio-garbage.bin", now) + + r := NewRetention(72, 0, nil) + r.cleanByAge(dir) + + if !exists(garbage) { + t.Error("файл с нераспознаваемым именем, но свежим ModTime, не должен удаляться") + } + + // А вот битое имя со СТАРЫМ ModTime — законно удаляется как старое. + dir2 := t.TempDir() + for i := 0; i < 4; i++ { + ft := now.Add(-time.Duration(20-i) * time.Hour) // свежие, чтобы не удалялись + makeLogFile(t, dir2, nameForHour(ft), ft) + } + oldGarbage := makeLogFile(t, dir2, "gpio-garbage.bin", now.Add(-200*time.Hour)) + r.cleanByAge(dir2) + if exists(oldGarbage) { + t.Error("битое имя со старым ModTime должно удаляться как старый файл") + } +} + +// TestCleanBySize_KeepsFloor: очистка по размеру не опускается ниже minKeepFiles. +func TestCleanBySize_KeepsFloor(t *testing.T) { + dir := t.TempDir() + now := time.Now() + for i := 0; i < 6; i++ { + ft := now.Add(-time.Duration(6-i) * time.Hour) + makeLogFile(t, dir, nameForHour(ft), ft) + } + + // Крошечный лимит: без floor удалились бы все. + r := &Retention{maxAgeHours: 0, maxSizeBytes: 1, eventLogger: nil, stopCh: make(chan struct{})} + r.cleanBySize(dir) + + files, _ := r.collectFiles(dir) + if len(files) != minKeepFiles { + t.Errorf("после cleanBySize осталось %d, ожидалось minKeepFiles=%d", len(files), minKeepFiles) + } +} + +// TestParseFilenameTime: корректное имя парсится, битое даёт нулевое время. +func TestParseFilenameTime(t *testing.T) { + r := NewRetention(72, 0, nil) + + got := r.parseFilenameTime("gpio-2026-06-09-11.bin") + want := time.Date(2026, 6, 9, 11, 0, 0, 0, time.Local) + if !got.Equal(want) { + t.Errorf("parseFilenameTime = %v, ожидалось %v", got, want) + } + + for _, bad := range []string{"gpio-garbage.bin", "gpio-2026-XX-09-11.bin", "gpio-2026-06-09.bin"} { + if !r.parseFilenameTime(bad).IsZero() { + t.Errorf("для %q ожидалось нулевое время", bad) + } + } +}