Доработка ротацию лог файлов
This commit is contained in:
5
.claudeignore
Normal file
5
.claudeignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.git/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
@@ -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)
|
||||
}
|
||||
192
internal/logger/retention_test.go
Normal file
192
internal/logger/retention_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user