Доработка ротацию лог файлов
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"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// minKeepFiles — сколько самых свежих файлов всегда сохраняем, даже если
|
||||||
|
// возраст или размер говорят удалить. Защита от полного стирания директории.
|
||||||
|
const minKeepFiles = 2
|
||||||
|
|
||||||
type Retention struct {
|
type Retention struct {
|
||||||
maxAgeHours int
|
maxAgeHours int
|
||||||
maxSizeBytes int64
|
maxSizeBytes int64
|
||||||
@@ -103,58 +107,21 @@ func (r *Retention) Cleanup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanByAge возвращает количество удаленных файлов и их общий размер в байтах
|
// fileInfo описывает один лог-файл: путь, время из имени (или ModTime как
|
||||||
func (r *Retention) cleanByAge(dataDir string) (int, int64) {
|
// запасной вариант), реальное время последней записи и размер.
|
||||||
cutoff := time.Now().Add(-time.Duration(r.maxAgeHours) * time.Hour)
|
type fileInfo struct {
|
||||||
deleted := 0
|
path string
|
||||||
var deletedBytes int64
|
time time.Time
|
||||||
|
modTime time.Time
|
||||||
// Получаем все .bin файлы
|
size int64
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanBySize возвращает количество удаленных файлов и их общий размер в байтах
|
// collectFiles возвращает все gpio-*.bin, отсортированные от старых к новым
|
||||||
func (r *Retention) cleanBySize(dataDir string) (int, int64) {
|
// (FIFO-порядок), и их суммарный размер в байтах.
|
||||||
// Получаем все файлы
|
func (r *Retention) collectFiles(dataDir string) ([]fileInfo, int64) {
|
||||||
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
|
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0
|
return nil, 0
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сортируем по времени (старыми первыми)
|
|
||||||
type fileInfo struct {
|
|
||||||
path string
|
|
||||||
time time.Time
|
|
||||||
size int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileList []fileInfo
|
var fileList []fileInfo
|
||||||
@@ -166,40 +133,82 @@ func (r *Retention) cleanBySize(dataDir string) (int, int64) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Время из имени файла; если распарсить не удалось — берём ModTime.
|
||||||
fileTime := r.parseFilenameTime(file)
|
fileTime := r.parseFilenameTime(file)
|
||||||
if fileTime.IsZero() {
|
if fileTime.IsZero() {
|
||||||
fileTime = info.ModTime()
|
fileTime = info.ModTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
fileList = append(fileList, fileInfo{
|
fileList = append(fileList, fileInfo{
|
||||||
path: file,
|
path: file,
|
||||||
time: fileTime,
|
time: fileTime,
|
||||||
size: info.Size(),
|
modTime: info.ModTime(),
|
||||||
|
size: info.Size(),
|
||||||
})
|
})
|
||||||
totalSize += info.Size()
|
totalSize += info.Size()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сортируем по времени (старые первые)
|
// Сортируем по времени (старые первые) — FIFO.
|
||||||
sort.Slice(fileList, func(i, j int) bool {
|
sort.Slice(fileList, func(i, j int) bool {
|
||||||
return fileList[i].time.Before(fileList[j].time)
|
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
|
deleted := 0
|
||||||
var deletedBytes int64
|
var deletedBytes int64
|
||||||
|
|
||||||
// Сохраняем как минимум 1 файл
|
for len(files) > minKeepFiles {
|
||||||
for totalSize > r.maxSizeBytes && len(fileList) > 1 {
|
f := files[0]
|
||||||
// Удаляем самый старый файл
|
|
||||||
oldest := fileList[0]
|
// Возраст — это цель, а не гильотина. Файл удаляем только если он старше
|
||||||
if err := os.Remove(oldest.path); err == nil {
|
// cutoff и по имени, и по реальному времени записи. Это защищает от
|
||||||
totalSize -= oldest.size
|
// скачка часов (после NTP на устройстве без RTC): свежий по ModTime
|
||||||
deletedBytes += oldest.size
|
// файл со «старым» именем не будет снесён.
|
||||||
deleted++
|
if !f.time.Before(cutoff) || !f.modTime.Before(cutoff) {
|
||||||
fileList = fileList[1:]
|
// Список отсортирован от старых к новым — дальше только моложе.
|
||||||
} else {
|
|
||||||
break
|
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
|
return deleted, deletedBytes
|
||||||
@@ -213,10 +222,22 @@ func (r *Retention) parseFilenameTime(filename string) time.Time {
|
|||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
year, _ := time.Parse("2006", parts[1])
|
year, err := time.Parse("2006", parts[1])
|
||||||
month, _ := time.Parse("01", parts[2])
|
if err != nil {
|
||||||
day, _ := time.Parse("02", parts[3])
|
return time.Time{}
|
||||||
hour, _ := time.Parse("15", parts[4])
|
}
|
||||||
|
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)
|
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