Доработка ротацию лог файлов

This commit is contained in:
Maxim
2026-07-13 09:11:34 +03:00
parent 4509753ac5
commit 1b305500a8
3 changed files with 285 additions and 67 deletions

5
.claudeignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.git/
dist/
build/
*.log

View File

@@ -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)
}

View 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)
}
}
}