Files
log/storage.go
T
Claude f9b8770303
Test / test (push) Canceled after 0s
Test / fuzz (./compat, FuzzParseFormat) (push) Canceled after 0s
Test / fuzz (./formatter, FuzzFormatJSON) (push) Canceled after 0s
Test / fuzz (./formatter, FuzzFormatTextPolicy) (push) Canceled after 0s
Test / fuzz (./sanitizer, FuzzSanitize) (push) Canceled after 0s
Test / fuzz (./sanitizer, FuzzSerializerJSON) (push) Canceled after 0s
v0.1.13 follow a replaced, removed, moved log file properly
2026-10-15 16:54:39 -04:00

552 lines
15 KiB
Go

package log
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// performSync syncs the current log file
func (l *Logger) performSync() error {
if !l.getConfig().EnableFile {
return nil
}
if file, _ := l.state.CurrentFile.Load().(*os.File); file != nil {
if err := file.Sync(); err != nil {
return fmtErrorf("failed to sync log file '%s': %w", file.Name(), err)
}
return nil
}
return fmtErrorf("file output enabled without an open log file")
}
// performDiskCheck checks disk space, triggers cleanup if needed, and updates status
// Returns true if disk is OK, false otherwise
func (l *Logger) performDiskCheck(forceCleanup bool) bool {
c := l.getConfig()
// Skip all disk checks if file output is disabled
enableFile := c.EnableFile
if !enableFile {
// Always return OK status when file output is disabled
if !l.state.DiskStatusOK.Load() {
l.state.DiskStatusOK.Store(true)
l.state.DiskFullLogged.Store(false)
}
return true
}
dir := c.Directory
ext := c.Extension
maxTotalKB := c.MaxTotalSizeKB
minDiskFreeKB := c.MinDiskFreeKB
maxTotal := maxTotalKB * sizeMultiplier
minFreeRequired := minDiskFreeKB * sizeMultiplier
// If no limits are set, the disk is considered OK
if maxTotal <= 0 && minFreeRequired <= 0 {
if !l.state.DiskStatusOK.Load() {
l.state.DiskStatusOK.Store(true)
l.state.DiskFullLogged.Store(false)
}
return true
}
// Check available disk space
freeSpace, err := l.getDiskFreeSpace(dir)
if err != nil {
l.internalLog("warning - failed to check free disk space for '%s': %v\n", dir, err)
l.state.DiskStatusOK.Store(false)
return false
}
// Determine if cleanup is needed based on disk space and total log size
needsCleanupCheck := false
spaceToFree := int64(0)
if minFreeRequired > 0 && freeSpace < minFreeRequired {
needsCleanupCheck = true
spaceToFree = minFreeRequired - freeSpace
}
if maxTotal > 0 {
dirSize, err := l.getLogDirSize(dir, ext)
if err != nil {
l.internalLog("warning - failed to check log directory size for '%s': %v\n", dir, err)
if l.state.DiskStatusOK.Load() {
l.state.DiskStatusOK.Store(false)
}
return false
}
if dirSize > maxTotal {
needsCleanupCheck = true
amountOver := dirSize - maxTotal
if amountOver > spaceToFree {
spaceToFree = amountOver
}
}
}
// Trigger cleanup if needed and allowed by the 'forceCleanup' flag
if needsCleanupCheck && forceCleanup {
if err := l.cleanOldLogs(spaceToFree); err != nil {
if !l.state.DiskFullLogged.Swap(true) {
diskFullRecord := logRecord{
Flags: FlagDefault | FlagKV, TimeStamp: time.Now(), Level: LevelError,
Args: []any{
"msg", "log directory full or disk space low, cleanup failed",
"error", err.Error(),
},
}
l.sendLogRecord(diskFullRecord)
}
l.state.DiskStatusOK.Store(false)
return false
}
// Cleanup succeeded, reset flags
l.state.DiskFullLogged.Store(false)
l.state.DiskStatusOK.Store(true)
l.updateEarliestFileTime()
return true
} else if needsCleanupCheck {
// Limits exceeded, but not forcing cleanup now
if l.state.DiskStatusOK.Load() {
l.state.DiskStatusOK.Store(false)
}
return false
} else {
// Limits OK, reset flags
if !l.state.DiskStatusOK.Load() {
l.state.DiskStatusOK.Store(true)
l.state.DiskFullLogged.Store(false)
}
return true
}
}
// getLogDirSize calculates total size of log files matching the current extension
func (l *Logger) getLogDirSize(dir, ext string) (int64, error) {
var size int64
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmtErrorf("failed to read log directory '%s': %w", dir, err)
}
for _, entry := range entries {
if !matchesLogEntry(entry, ext) {
continue
}
info, errInfo := entry.Info()
if errInfo != nil {
continue
}
size += info.Size()
}
return size, nil
}
// cleanOldLogs removes oldest log files until required space is freed
func (l *Logger) cleanOldLogs(required int64) error {
c := l.getConfig()
if !c.EnableFile || required <= 0 {
return nil
}
dir := c.Directory
ext := c.Extension
name := c.Name
entries, err := os.ReadDir(dir)
if err != nil {
return fmtErrorf("failed to read log directory '%s' for cleanup: %w", dir, err)
}
// Build a list of log files eligible for deletion, excluding the active log file
staticLogName := name
if ext != "" {
staticLogName = name + "." + ext
}
type logFileMeta struct {
name string
modTime time.Time
size int64
}
active := l.openFileInfo()
var logs []logFileMeta
for _, entry := range entries {
if !matchesLogEntry(entry, ext) || entry.Name() == staticLogName {
continue
}
info, errInfo := entry.Info()
if errInfo != nil || os.SameFile(info, active) {
continue
}
logs = append(logs, logFileMeta{name: entry.Name(), modTime: info.ModTime(), size: info.Size()})
}
if len(logs) == 0 {
if required > 0 {
return fmtErrorf("no old logs available to delete in '%s', needed %d bytes", dir, required)
}
return nil
}
// Sort logs by modification time to delete the oldest ones first
sort.Slice(logs, func(i, j int) bool {
if logs[i].modTime.Equal(logs[j].modTime) {
return logs[i].name < logs[j].name
}
return logs[i].modTime.Before(logs[j].modTime)
})
// Iterate and remove files until enough space has been freed
var freedSpace int64
for _, log := range logs {
if required > 0 && freedSpace >= required {
break
}
filePath := filepath.Join(dir, log.name)
if err := os.Remove(filePath); err != nil {
l.internalLog("failed to remove old log file '%s': %v\n", filePath, err)
continue
}
freedSpace += log.size
l.state.TotalDeletions.Add(1)
}
if required > 0 && freedSpace < required {
return fmtErrorf("could not free enough space in '%s': freed %d bytes, needed %d bytes", dir, freedSpace, required)
}
return nil
}
// updateEarliestFileTime scans the log directory for the oldest log file.
// Matches by extension only: a name-prefix filter would hide files written by
// earlier runs when the active name carries a per-run timestamp.
func (l *Logger) updateEarliestFileTime() {
c := l.getConfig()
dir := c.Directory
ext := c.Extension
name := c.Name
entries, err := os.ReadDir(dir)
if err != nil {
l.state.EarliestFileTime.Store(time.Time{})
return
}
var earliest time.Time
// Get the active log filename to exclude from timestamp tracking
staticLogName := name
if ext != "" {
staticLogName = name + "." + ext
}
for _, entry := range entries {
if !matchesLogEntry(entry, ext) {
continue
}
fname := entry.Name()
if fname == staticLogName {
continue // Skip the active log file
}
info, errInfo := entry.Info()
if errInfo != nil {
continue
}
if earliest.IsZero() || info.ModTime().Before(earliest) {
earliest = info.ModTime()
}
}
l.state.EarliestFileTime.Store(earliest)
}
// cleanExpiredLogs removes log files older than the retention period
func (l *Logger) cleanExpiredLogs(oldest time.Time) error {
c := l.getConfig()
dir := c.Directory
ext := c.Extension
name := c.Name
retentionPeriodHrs := c.RetentionPeriodHrs
rpDuration := time.Duration(retentionPeriodHrs * float64(time.Hour))
if !c.EnableFile || rpDuration <= 0 {
return nil
}
cutoffTime := time.Now().Add(-rpDuration)
if oldest.IsZero() || !oldest.Before(cutoffTime) {
return nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmtErrorf("failed to read log directory '%s' for retention cleanup: %w", dir, err)
}
// Get the active log filename to exclude from deletion
staticLogName := name
if ext != "" {
staticLogName = name + "." + ext
}
active := l.openFileInfo()
for _, entry := range entries {
if !matchesLogEntry(entry, ext) || entry.Name() == staticLogName {
continue
}
info, errInfo := entry.Info()
if errInfo != nil || os.SameFile(info, active) {
continue
}
if info.ModTime().Before(cutoffTime) {
filePath := filepath.Join(dir, entry.Name())
if err := os.Remove(filePath); err != nil {
l.internalLog("failed to remove expired log file '%s': %v\n", filePath, err)
} else {
l.state.TotalDeletions.Add(1)
}
}
}
return nil
}
// openFileInfo identifies the file being written; cleanup spares it under any
// name, as it may have been renamed since the last path check
func (l *Logger) openFileInfo() os.FileInfo {
if file, _ := l.state.CurrentFile.Load().(*os.File); file != nil {
if info, err := file.Stat(); err == nil {
return info
}
}
return nil
}
// getStaticLogFilePath returns the full path to the active log file
func (l *Logger) getStaticLogFilePath() string {
c := l.getConfig()
dir := c.Directory
ext := c.Extension
name := c.Name
// Extension was validated without a leading dot
filename := name
if ext != "" {
filename = name + "." + ext
}
return filepath.Join(dir, filename)
}
// fileExists includes directories and symlinks, which also occupy archive names
func fileExists(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
// generateArchiveLogFileName creates a second-resolution name for a rotated
// log, disambiguating with a counter when that name is already taken
func (l *Logger) generateArchiveLogFileName(timestamp time.Time) string {
c := l.getConfig()
suffix := ""
if c.Extension != "" {
suffix = "." + c.Extension
}
base := fmt.Sprintf("%s_%s", c.Name, timestamp.Format("060102_150405"))
name := base + suffix
for i := 1; fileExists(filepath.Join(c.Directory, name)); i++ {
name = fmt.Sprintf("%s_%d%s", base, i, suffix)
}
return name
}
func openLogFile(c *Config) (*os.File, error) {
name := c.Name
if c.Extension != "" {
name += "." + c.Extension
}
fullPath := filepath.Join(c.Directory, name)
file, err := os.OpenFile(fullPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, fmtErrorf("failed to open/create log file '%s': %w", fullPath, err)
}
return file, nil
}
// openLogPath opens the log path again after a rotation or an external change.
// Unlike the configured first open it never follows a symlink, never blocks on
// a FIFO, and keeps only a regular file: the path may have been replaced.
func openLogPath(path string) (*os.File, os.FileInfo, error) {
file, err := os.OpenFile(path, reopenFlags, 0644)
if err != nil {
return nil, nil, fmtErrorf("failed to open/create log file '%s': %w", path, err)
}
info, err := file.Stat()
if err == nil && !info.Mode().IsRegular() {
err = errors.New("not a regular file")
}
if err != nil {
_ = file.Close()
return nil, nil, fmtErrorf("refused log file '%s': %w", path, err)
}
return file, info, nil
}
// checkLogPath keeps the processor on its log path. At most once per
// fileCheckInterval of record time, or when forced, it compares the path with
// the open file: a path that is gone or names another file (rename-on-save
// editors, sed -i, rm, mv) is opened afresh. CurrentSize is resynced from the
// file either way. Reports whether the check ran. Processor goroutine only.
func (l *Logger) checkLogPath(now time.Time, force bool) bool {
w := &l.watch
if !force && now.Sub(w.lastCheck) < fileCheckInterval {
return false
}
w.lastCheck = now
file, _ := l.state.CurrentFile.Load().(*os.File)
if file == nil {
return true
}
if w.file != file {
info, err := file.Stat()
if err != nil {
w.file = nil
return true // unusable handle: the write reports it
}
w.file, w.info, w.path = file, info, l.getStaticLogFilePath()
}
pathInfo, err := os.Stat(w.path)
if err == nil && os.SameFile(pathInfo, w.info) {
l.state.CurrentSize.Store(pathInfo.Size())
w.warned = false
return true
}
if errors.Is(err, fs.ErrPermission) {
return true // cannot tell: keep the open file
}
newFile, info, err := openLogPath(w.path)
if err != nil {
if !w.warned {
w.warned = true
l.internalLog("log path no longer names the open file, not reopened: %v\n", err)
}
return true
}
l.state.CurrentFile.Store(newFile)
if err := file.Close(); err != nil {
l.internalLog("failed to close replaced log file: %v\n", err)
}
w.file, w.info, w.warned = newFile, info, false
l.state.CurrentSize.Store(info.Size())
l.state.TotalReopens.Add(1)
return true
}
// rotateLogFile implements the rename-on-rotate strategy
// Closes current file, renames it with timestamp, creates new static file
func (l *Logger) rotateLogFile() error {
c := l.getConfig()
currentPath := l.getStaticLogFilePath()
currentFile, _ := l.state.CurrentFile.Load().(*os.File)
if currentFile == nil {
// This can happen if file logging was disabled and re-enabled
// No current file, just create a new one
newFile, info, err := openLogPath(currentPath)
if err != nil {
return fmtErrorf("failed to create log file during rotation: %w", err)
}
l.state.CurrentFile.Store(newFile)
l.state.CurrentSize.Store(info.Size())
l.state.TotalRotations.Add(1)
return nil
}
// Close current file before renaming
if err := currentFile.Close(); err != nil {
l.internalLog("failed to close log file before rotation: %v\n", err)
// Continue with rotation anyway
}
l.state.CurrentFile.Store((*os.File)(nil))
// Generate a new unique name with current timestamp for the old log file
archivePath := filepath.Join(c.Directory, l.generateArchiveLogFileName(time.Now()))
// Rename current file to archive name
if err := os.Rename(currentPath, archivePath); errors.Is(err, fs.ErrNotExist) {
// Removed since the last path check: nothing to archive
l.internalLog("log file '%s' was removed before rotation, nothing to archive\n", currentPath)
} else if err != nil {
// Critical failure: the original file is closed and couldn't be renamed
// This is a terminal state for file logging
l.internalLog("failed to rename log file from '%s' to '%s': %v. file logging disabled.",
currentPath, archivePath, err)
l.state.LoggerDisabled.Store(true)
l.refreshLevelGate()
return fmtErrorf("failed to rotate log file, logging is disabled: %w", err)
}
// Create new log file at static path
newFile, info, err := openLogPath(currentPath)
if err != nil {
l.state.LoggerDisabled.Store(true)
l.refreshLevelGate()
return fmtErrorf("failed to create new log file after rotation: %w", err)
}
// Update state
l.state.CurrentFile.Store(newFile)
l.state.CurrentSize.Store(info.Size())
l.state.TotalRotations.Add(1)
// Update earliest file time after successful rotation
l.updateEarliestFileTime()
return nil
}
// getLogFileCount calculates the number of log files matching the current extension
func (l *Logger) getLogFileCount(dir, ext string) (int, error) {
count := 0
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return -1, fmtErrorf("failed to read log directory '%s': %w", dir, err)
}
for _, entry := range entries {
if !matchesLogEntry(entry, ext) {
continue
}
// Count all files matching the extension, including the current one if present
count++
}
return count, nil
}
// Cleanup is scoped to regular files with this extension, including files from
// earlier runs with different base names. Extensionless logs match only files
// without an extension. Each logger must own its directory exclusively.
func matchesLogEntry(entry os.DirEntry, ext string) bool {
if !entry.Type().IsRegular() {
return false
}
if ext == "" {
return filepath.Ext(entry.Name()) == ""
}
return strings.HasSuffix(entry.Name(), "."+ext)
}