v0.1.13 follow a replaced, removed, moved log file properly
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

This commit is contained in:
Claude authored and Lixen Wraith committed 2026-10-15 16:54:39 -04:00
1 parent fe711ed2a5
commit f9b8770303
14 files changed
+420 -46

No files matched your search

+38 -3
View File
@@ -2,11 +2,12 @@ package log
import ( import (
"testing" "testing"
"time"
) )
// These benchmarks measure the producer path: format selection, channel send, // The benchmarks up to BenchmarkProcessLogRecord measure the producer path:
// and drop accounting. File writes complete asynchronously in the processor and // format selection, channel send, and drop accounting. File writes complete
// are not attributed to the measured iterations. // asynchronously in the processor and are not attributed to their iterations.
// BenchmarkLoggerInfo measures the default raw-format path. // BenchmarkLoggerInfo measures the default raw-format path.
func BenchmarkLoggerInfo(b *testing.B) { func BenchmarkLoggerInfo(b *testing.B) {
@@ -95,3 +96,37 @@ func BenchmarkConcurrentLogging(b *testing.B) {
} }
}) })
} }
// BenchmarkProcessLogRecord measures the processor's write path, which the
// producer benchmarks above do not see. Record stamps advance by step to stand
// for a logging rate: at 100/s every record is due a log path check.
func BenchmarkProcessLogRecord(b *testing.B) {
for _, bc := range []struct {
name string
step time.Duration // 0: time.Now per record, as emit stamps it
}{{"ts=now", 0}, {"rate=1k/s", time.Millisecond}, {"rate=100/s", 10 * time.Millisecond}} {
b.Run(bc.name, func(b *testing.B) {
l := NewLogger()
c := DefaultConfig()
c.EnableConsole = false
c.EnableFile = true
c.Directory = b.TempDir()
c.Format = "txt"
c.MaxSizeKB = 0
mustNoErr(b, l.ApplyConfig(c), "ApplyConfig") // not started: the benchmark is the processor
b.Cleanup(func() { _ = l.Shutdown() })
rec := logRecord{Flags: l.getFlags(), Level: LevelInfo, Args: []any{"benchmark message", 42}}
ts := time.Now()
b.ReportAllocs()
for b.Loop() {
if ts = ts.Add(bc.step); bc.step == 0 {
ts = time.Now()
}
rec.TimeStamp = ts
if l.processLogRecord(rec) == 0 {
b.Fatal("record not written")
}
}
})
}
}
+3
View File
@@ -55,6 +55,9 @@ const (
const ( const (
// Minimum wait time used throughout the package // Minimum wait time used throughout the package
minWaitTime = 10 * time.Millisecond minWaitTime = 10 * time.Millisecond
// Record time between checks that the log path still names the open file;
// bounds the writes lost to a file replaced or removed under the logger
fileCheckInterval = 10 * time.Millisecond
// Factors to adjust check interval // Factors to adjust check interval
adaptiveIntervalFactor float64 = 1.5 // Slow down adaptiveIntervalFactor float64 = 1.5 // Slow down
adaptiveSpeedUpFactor float64 = 0.8 // Speed up adaptiveSpeedUpFactor float64 = 0.8 // Speed up
+2 -1
View File
@@ -61,12 +61,13 @@ logger.ApplyConfigString(
**Additional Output:** **Additional Output:**
``` ```
2024-01-15T10:30:00Z DISK type=disk sequence=1 rotated_files=12 deleted_files=5 total_log_size_mb="487.32" log_file_count=8 current_file_size_mb="23.45" disk_status_ok=true disk_free_mb="5234.67" 2024-01-15T10:30:00Z DISK type=disk sequence=1 rotated_files=12 deleted_files=5 reopened_files=1 total_log_size_mb="487.32" log_file_count=8 current_file_size_mb="23.45" disk_status_ok=true disk_free_mb="5234.67"
``` ```
**Additional Fields:** **Additional Fields:**
- `rotated_files`: Total file rotations - `rotated_files`: Total file rotations
- `deleted_files`: Files removed by cleanup - `deleted_files`: Files removed by cleanup
- `reopened_files`: Log paths opened again after an external rename or removal
- `total_log_size_mb`: Size of all log files - `total_log_size_mb`: Size of all log files
- `log_file_count`: Number of log files - `log_file_count`: Number of log files
- `current_file_size_mb`: Active file size - `current_file_size_mb`: Active file size
+17 -3
View File
@@ -17,12 +17,26 @@ logger.ApplyConfigString(
### Rotation Behavior ### Rotation Behavior
1. Before each write, the logger checks whether the active file would exceed `max_size_kb`. 1. Before each write, the logger checks whether the active file would exceed `max_size_kb`.
2. It closes the active `{name}.{extension}` file and renames it to `{name}_{YYMMDD}_{HHMMSS}[_N].{extension}`. 2. It closes the active `{name}.{extension}` file and renames it to `{name}_{YYMMDD}_{HHMMSS}[_N].{extension}`; a file already removed leaves nothing to archive.
3. It opens a new active file. A counter suffix avoids existing names when rotations share a second. 3. It opens a new active file. A counter suffix avoids existing names when rotations share a second.
For example: `myapp.log` becomes `myapp_240115_143022.log`, then `myapp_240115_143022_1.log` on the next collision. Rotation does not split a record, so a single oversized record can exceed the size limit. File errors can drop records and are counted. Periodic sync, explicit `Flush`, and shutdown provide sync points; closing a file alone does not guarantee crash durability. For example: `myapp.log` becomes `myapp_240115_143022.log`, then `myapp_240115_143022_1.log` on the next collision. Rotation does not split a record, so a single oversized record can exceed the size limit. File errors can drop records and are counted. Periodic sync, explicit `Flush`, and shutdown provide sync points; closing a file alone does not guarantee crash durability.
Use a directory owned exclusively by this logger. Cleanup and retention select regular files by extension, including files from earlier runs with different base names. They exclude the active filename, directories, and symlinks. With an empty extension, only extensionless files match. Console-only configurations never perform retention deletion. Total-size and free-space limits are checked periodically and can be exceeded between checks. Use a directory owned exclusively by this logger. Cleanup and retention select regular files by extension, including files from earlier runs with different base names. They exclude the active filename, the file being written under any name, directories, and symlinks. With an empty extension, only extensionless files match. Console-only configurations never perform retention deletion. Total-size and free-space limits are checked periodically and can be exceeded between checks.
### External Edits and Rotation
The logger writes with `O_APPEND` to whatever file its path names. It checks the path at most once per 10 ms of record time, and at every `Flush`, at `Stop` and before a size rotation.
- Edited in place: `truncate -s 0`, `: > app.log`, nano, `echo note >> app.log`, logrotate `copytruncate`.
- The next record lands at the new end of the file; the tracked size is re-read at each check.
- Replaced, removed or moved: vim (it saves by renaming), `sed -i`, `perl -i`, `rm`, `mv`, logrotate `create`.
- The path is opened again, and created if missing; no signal or reopen call is needed.
- Records written between the change and that check go to the old file: lost with a removed file, kept in a moved one.
- On unix the reopen never follows a symlink and keeps only a regular file. A refusal is reported once as an internal error (`SetErrorHandler`, or stderr with `internal_errors_to_stderr`), and the old handle stays in use until the path is usable. The first open in `ApplyConfig` still follows a symlinked path.
- On Windows the open file cannot be renamed or removed while the logger holds it.
- Disk heartbeats count reopens as `reopened_files`.
- Not preventable by a writer: an editor saves the snapshot it read, overwriting records logged while it had the file open (vim and nano warn first). A note without a final newline joins the next record's line.
## Disk Space Management ## Disk Space Management
@@ -149,7 +163,7 @@ logger.ApplyConfigString(
Output: Output:
``` ```
2024-01-15T10:30:00Z DISK type="disk" sequence=1 rotated_files=5 deleted_files=2 total_log_size_mb="487.32" log_file_count=8 current_file_size_mb="23.45" disk_status_ok=true disk_free_mb="5234.67" 2024-01-15T10:30:00Z DISK type="disk" sequence=1 rotated_files=5 deleted_files=2 reopened_files=0 total_log_size_mb="487.32" log_file_count=8 current_file_size_mb="23.45" disk_status_ok=true disk_free_mb="5234.67"
``` ```
## Manual Recovery ## Manual Recovery
+1
View File
@@ -92,6 +92,7 @@ func (l *Logger) logDiskHeartbeat() {
"sequence", sequence, "sequence", sequence,
"rotated_files", rotations, "rotated_files", rotations,
"deleted_files", deletions, "deleted_files", deletions,
"reopened_files", l.state.TotalReopens.Load(),
"total_log_size_mb", fmt.Sprintf("%.2f", totalSizeMB), "total_log_size_mb", fmt.Sprintf("%.2f", totalSizeMB),
"log_file_count", fileCount, "log_file_count", fileCount,
"current_file_size_mb", fmt.Sprintf("%.2f", currentSizeMB), "current_file_size_mb", fmt.Sprintf("%.2f", currentSizeMB),
+1
View File
@@ -27,6 +27,7 @@ type Logger struct {
initMu sync.Mutex // serializes lifecycle and configuration changes initMu sync.Mutex // serializes lifecycle and configuration changes
sendMu sync.RWMutex // joins enqueues before detaching a processor sendMu sync.RWMutex // joins enqueues before detaching a processor
processorErr error // written by processor, read only after ProcDone closes processorErr error // written by processor, read only after ProcDone closes
watch fileWatch // processor goroutine only
} }
// levelOff closes the emit gate without touching configuration // levelOff closes the emit gate without touching configuration
+20 -6
View File
@@ -51,6 +51,7 @@ func (l *Logger) processLogs(ch <-chan logRecord, stop <-chan struct{}, done cha
for { for {
select { select {
case <-stop: case <-stop:
l.checkLogPath(time.Now(), true)
l.drain(ch) l.drain(ch)
l.processorErr = l.performSync() l.processorErr = l.performSync()
return return
@@ -107,7 +108,10 @@ func (l *Logger) drain(ch <-chan logRecord) {
} }
} }
// handleFlushRequest also reattaches a replaced log path before draining, so a
// Flush is a deterministic point to follow an external rotation
func (l *Logger) handleFlushRequest(ch <-chan logRecord, request flushRequest) { func (l *Logger) handleFlushRequest(ch <-chan logRecord, request flushRequest) {
l.checkLogPath(time.Now(), true)
l.drain(ch) l.drain(ch)
request.done <- l.performSync() request.done <- l.performSync()
} }
@@ -116,6 +120,14 @@ func (l *Logger) handleFlushRequest(ch <-chan logRecord, request flushRequest) {
func (l *Logger) processLogRecord(record logRecord) int64 { func (l *Logger) processLogRecord(record logRecord) int64 {
c := l.getConfig() c := l.getConfig()
enableFile := c.EnableFile enableFile := c.EnableFile
// Follow the log path when the file was replaced, removed or resized. Before
// the disk gate: removing the file is how an operator frees the space.
now := record.TimeStamp
if now.IsZero() {
now = time.Now()
}
checked := enableFile && l.checkLogPath(now, false)
if enableFile && !l.state.DiskStatusOK.Load() { if enableFile && !l.state.DiskStatusOK.Load() {
// Simple increment of both counters // Simple increment of both counters
l.handleFailedSend() l.handleFailedSend()
@@ -171,12 +183,13 @@ func (l *Logger) processLogRecord(record logRecord) int64 {
return formattedDataLen // Return data length for adaptive interval calculations return formattedDataLen // Return data length for adaptive interval calculations
} }
// File rotation check // File rotation check, on the size read from the file: an external edit
currentFileSize := l.state.CurrentSize.Load() // since the last check leaves CurrentSize stale
estimatedSize := currentFileSize + formattedDataLen if maxSize := c.MaxSizeKB * sizeMultiplier; maxSize > 0 && l.state.CurrentSize.Load()+formattedDataLen > maxSize {
if !checked {
maxSizeKB := c.MaxSizeKB l.checkLogPath(now, true)
if maxSizeKB > 0 && estimatedSize > maxSizeKB*sizeMultiplier { }
if l.state.CurrentSize.Load()+formattedDataLen > maxSize {
if err := l.rotateLogFile(); err != nil { if err := l.rotateLogFile(); err != nil {
l.internalLog("failed to rotate log file: %v\n", err) l.internalLog("failed to rotate log file: %v\n", err)
// Account for the dropped log that triggered the failed rotation // Account for the dropped log that triggered the failed rotation
@@ -184,6 +197,7 @@ func (l *Logger) processLogRecord(record logRecord) int64 {
return 0 return 0
} }
} }
}
// Write to file // Write to file
cfPtr := l.state.CurrentFile.Load() cfPtr := l.state.CurrentFile.Load()
+1
View File
@@ -45,4 +45,5 @@ type State struct {
TotalLogsProcessed atomic.Uint64 // Counter for non-heartbeat logs successfully processed TotalLogsProcessed atomic.Uint64 // Counter for non-heartbeat logs successfully processed
TotalRotations atomic.Uint64 // Counter for successful log rotations TotalRotations atomic.Uint64 // Counter for successful log rotations
TotalDeletions atomic.Uint64 // Counter for successful log deletions (cleanup/retention) TotalDeletions atomic.Uint64 // Counter for successful log deletions (cleanup/retention)
TotalReopens atomic.Uint64 // Counter for log paths reopened after an external rename or removal
} }
+100 -32
View File
@@ -1,7 +1,9 @@
package log package log
import ( import (
"errors"
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -175,13 +177,14 @@ func (l *Logger) cleanOldLogs(required int64) error {
modTime time.Time modTime time.Time
size int64 size int64
} }
active := l.openFileInfo()
var logs []logFileMeta var logs []logFileMeta
for _, entry := range entries { for _, entry := range entries {
if !matchesLogEntry(entry, ext) || entry.Name() == staticLogName { if !matchesLogEntry(entry, ext) || entry.Name() == staticLogName {
continue continue
} }
info, errInfo := entry.Info() info, errInfo := entry.Info()
if errInfo != nil { if errInfo != nil || os.SameFile(info, active) {
continue continue
} }
logs = append(logs, logFileMeta{name: entry.Name(), modTime: info.ModTime(), size: info.Size()}) logs = append(logs, logFileMeta{name: entry.Name(), modTime: info.ModTime(), size: info.Size()})
@@ -292,12 +295,13 @@ func (l *Logger) cleanExpiredLogs(oldest time.Time) error {
staticLogName = name + "." + ext staticLogName = name + "." + ext
} }
active := l.openFileInfo()
for _, entry := range entries { for _, entry := range entries {
if !matchesLogEntry(entry, ext) || entry.Name() == staticLogName { if !matchesLogEntry(entry, ext) || entry.Name() == staticLogName {
continue continue
} }
info, errInfo := entry.Info() info, errInfo := entry.Info()
if errInfo != nil { if errInfo != nil || os.SameFile(info, active) {
continue continue
} }
if info.ModTime().Before(cutoffTime) { if info.ModTime().Before(cutoffTime) {
@@ -313,6 +317,17 @@ func (l *Logger) cleanExpiredLogs(oldest time.Time) error {
return nil 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 // getStaticLogFilePath returns the full path to the active log file
func (l *Logger) getStaticLogFilePath() string { func (l *Logger) getStaticLogFilePath() string {
c := l.getConfig() c := l.getConfig()
@@ -353,11 +368,6 @@ func (l *Logger) generateArchiveLogFileName(timestamp time.Time) string {
return name return name
} }
// createNewLogFile opens the active log file in append mode
func (l *Logger) createNewLogFile() (*os.File, error) {
return openLogFile(l.getConfig())
}
func openLogFile(c *Config) (*os.File, error) { func openLogFile(c *Config) (*os.File, error) {
name := c.Name name := c.Name
if c.Extension != "" { if c.Extension != "" {
@@ -371,35 +381,93 @@ func openLogFile(c *Config) (*os.File, error) {
return file, nil 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 // rotateLogFile implements the rename-on-rotate strategy
// Closes current file, renames it with timestamp, creates new static file // Closes current file, renames it with timestamp, creates new static file
func (l *Logger) rotateLogFile() error { func (l *Logger) rotateLogFile() error {
c := l.getConfig() c := l.getConfig()
currentPath := l.getStaticLogFilePath()
// Get current file handle currentFile, _ := l.state.CurrentFile.Load().(*os.File)
cfPtr := l.state.CurrentFile.Load() if currentFile == nil {
if cfPtr == nil {
// This can happen if file logging was disabled and re-enabled // This can happen if file logging was disabled and re-enabled
// No current file, just create a new one // No current file, just create a new one
newFile, err := l.createNewLogFile() newFile, info, err := openLogPath(currentPath)
if err != nil { if err != nil {
return fmtErrorf("failed to create log file during rotation: %w", err) return fmtErrorf("failed to create log file during rotation: %w", err)
} }
l.state.CurrentFile.Store(newFile) l.state.CurrentFile.Store(newFile)
l.state.CurrentSize.Store(0) l.state.CurrentSize.Store(info.Size())
l.state.TotalRotations.Add(1)
return nil
}
currentFile, ok := cfPtr.(*os.File)
if !ok || currentFile == nil {
// Invalid file handle in state, treat as if there's no file
newFile, err := l.createNewLogFile()
if err != nil {
return fmtErrorf("failed to create log file during rotation: %w", err)
}
l.state.CurrentFile.Store(newFile)
l.state.CurrentSize.Store(0)
l.state.TotalRotations.Add(1) l.state.TotalRotations.Add(1)
return nil return nil
} }
@@ -413,13 +481,13 @@ func (l *Logger) rotateLogFile() error {
l.state.CurrentFile.Store((*os.File)(nil)) l.state.CurrentFile.Store((*os.File)(nil))
// Generate a new unique name with current timestamp for the old log file // Generate a new unique name with current timestamp for the old log file
dir := c.Directory archivePath := filepath.Join(c.Directory, l.generateArchiveLogFileName(time.Now()))
archiveName := l.generateArchiveLogFileName(time.Now())
archivePath := filepath.Join(dir, archiveName)
// Rename current file to archive name // Rename current file to archive name
currentPath := l.getStaticLogFilePath() if err := os.Rename(currentPath, archivePath); errors.Is(err, fs.ErrNotExist) {
if err := os.Rename(currentPath, archivePath); err != nil { // 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 // Critical failure: the original file is closed and couldn't be renamed
// This is a terminal state for file logging // This is a terminal state for file logging
l.internalLog("failed to rename log file from '%s' to '%s': %v. file logging disabled.", l.internalLog("failed to rename log file from '%s' to '%s': %v. file logging disabled.",
@@ -430,7 +498,7 @@ func (l *Logger) rotateLogFile() error {
} }
// Create new log file at static path // Create new log file at static path
newFile, err := l.createNewLogFile() newFile, info, err := openLogPath(currentPath)
if err != nil { if err != nil {
l.state.LoggerDisabled.Store(true) l.state.LoggerDisabled.Store(true)
l.refreshLevelGate() l.refreshLevelGate()
@@ -439,7 +507,7 @@ func (l *Logger) rotateLogFile() error {
// Update state // Update state
l.state.CurrentFile.Store(newFile) l.state.CurrentFile.Store(newFile)
l.state.CurrentSize.Store(0) l.state.CurrentSize.Store(info.Size())
l.state.TotalRotations.Add(1) l.state.TotalRotations.Add(1)
// Update earliest file time after successful rotation // Update earliest file time after successful rotation
+7 -1
View File
@@ -2,7 +2,13 @@
package log package log
import "math" import (
"math"
"os"
)
// reopenFlags open the log path again; see openLogPath
const reopenFlags = os.O_APPEND | os.O_CREATE | os.O_WRONLY
// getDiskFreeSpace reports unlimited space where statfs is unavailable. // getDiskFreeSpace reports unlimited space where statfs is unavailable.
// Total-size limits still apply; only the free-space check is inert. // Total-size limits still apply; only the free-space check is inert.
+165
View File
@@ -4,7 +4,9 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -218,3 +220,166 @@ func TestArchiveNaming(t *testing.T) {
isTrue(t, strings.HasPrefix(second, "log_"), "archive prefix") isTrue(t, strings.HasPrefix(second, "log_"), "archive prefix")
isTrue(t, strings.HasSuffix(second, ".log"), "archive extension") isTrue(t, strings.HasSuffix(second, ".log"), "archive extension")
} }
// followLogger returns a started txt logger writing dir/app.log, and the
// diagnostics it reported
func followLogger(t *testing.T, maxKB int64) (*Logger, string, func() []string) {
t.Helper()
l := NewLogger()
var mu sync.Mutex
var diags []string
l.SetErrorHandler(func(s string) { mu.Lock(); diags = append(diags, s); mu.Unlock() })
c := DefaultConfig()
c.EnableConsole = false
c.EnableFile = true
c.Directory = t.TempDir()
c.Name = "app"
c.Format = "txt"
c.MaxSizeKB = maxKB
c.MaxTotalSizeKB = 0
c.MinDiskFreeKB = 0
mustNoErr(t, l.ApplyConfig(c), "ApplyConfig")
mustNoErr(t, l.Start(), "Start")
t.Cleanup(func() { _ = l.Shutdown() })
return l, filepath.Join(c.Directory, "app.log"), func() []string {
mu.Lock()
defer mu.Unlock()
return slices.Clone(diags)
}
}
func logFlush(t *testing.T, l *Logger, msgs ...string) {
t.Helper()
for _, m := range msgs {
l.Info(m)
}
mustNoErr(t, l.Flush(time.Second), "Flush")
}
// logProcessed logs without Flush, whose forced path check would mask the one under test
func logProcessed(t *testing.T, l *Logger, msgs ...string) {
t.Helper()
want := l.state.TotalLogsProcessed.Load() + uint64(len(msgs))
for _, m := range msgs {
l.Info(m)
}
mustEventually(t, time.Second, "records processed", func() bool { return l.state.TotalLogsProcessed.Load() >= want })
}
// replaceFile saves content the way vim, sed -i and perl -i do: a new file renamed over path
func replaceFile(t *testing.T, path, content string) {
t.Helper()
mustNoErr(t, os.WriteFile(path+".tmp", []byte(content), 0644), "write replacement")
mustNoErr(t, os.Rename(path+".tmp", path), "replace")
}
func TestFollowsReplacedLogFile(t *testing.T) {
l, path, _ := followLogger(t, 0)
logFlush(t, l, "before")
data, err := os.ReadFile(path)
mustNoErr(t, err, "read")
replaceFile(t, path, string(data)+"NOTE by user\n")
time.Sleep(fileCheckInterval + 5*time.Millisecond) // the next record is due a check
logProcessed(t, l, "after")
data, err = os.ReadFile(path)
mustNoErr(t, err, "read")
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
mustEqual(t, len(lines), 3, "lines: "+string(data))
contains(t, lines[0], "before", "kept by the editor")
equal(t, lines[1], "NOTE by user", "note")
contains(t, lines[2], "after", "appended after the note")
equal(t, l.state.TotalReopens.Load(), uint64(1), "reopens")
}
// The rotation decision follows a replaced file instead of archiving it
func TestRotationFollowsReplacedLogFile(t *testing.T) {
l, path, _ := followLogger(t, 1) // 1000 bytes
for l.state.CurrentSize.Load() < 900 {
logFlush(t, l, "fill-"+strings.Repeat("y", 10))
}
replaceFile(t, path, "") // all content deleted
pad := strings.Repeat("z", 120) // the first record after the edit crosses the stale size limit
logProcessed(t, l, "a1"+pad, "a2"+pad, "a3"+pad)
data, err := os.ReadFile(path)
mustNoErr(t, err, "read")
equal(t, strings.Count(string(data), pad), 3, "records after the edit: "+string(data))
equal(t, l.state.TotalRotations.Load(), uint64(0), "rotations")
}
// Truncation leaves the tracked size stale; rotation must use the real size
func TestRotationAfterTruncateUsesFileSize(t *testing.T) {
l, path, _ := followLogger(t, 2) // 2000 bytes
for l.state.CurrentSize.Load() < 1800 {
logFlush(t, l, "fill-"+strings.Repeat("y", 10))
}
mustNoErr(t, os.Truncate(path, 0), "truncate")
logProcessed(t, l, "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "a10")
equal(t, l.state.TotalRotations.Load(), uint64(0), "rotations")
fi, err := os.Stat(path)
mustNoErr(t, err, "stat")
equal(t, l.state.CurrentSize.Load(), fi.Size(), "tracked size")
}
// A file removed under the logger leaves nothing to archive, not a disabled logger
func TestRotateRemovedLogFile(t *testing.T) {
l, path, _ := followLogger(t, 0)
mustNoErr(t, l.Stop(5*time.Second), "Stop") // own the file state from the test goroutine
mustNoErr(t, os.Remove(path), "rm")
mustNoErr(t, l.rotateLogFile(), "rotate")
isFalse(t, l.state.LoggerDisabled.Load(), "logger disabled")
_, err := os.Stat(path)
mustNoErr(t, err, "new active file")
}
// Flush and Stop reattach a moved log path at once, as after logrotate's create
// mode; no record is processed in between, so only their check can reopen
func TestFlushAndStopReattachMovedLogFile(t *testing.T) {
for name, barrier := range map[string]func(l *Logger) error{
"flush": func(l *Logger) error { return l.Flush(time.Second) },
"stop": func(l *Logger) error { return l.Stop(5 * time.Second) },
} {
t.Run(name, func(t *testing.T) {
l, path, _ := followLogger(t, 0)
logFlush(t, l, "before")
mustNoErr(t, os.Rename(path, path+".1"), "mv")
mustNoErr(t, barrier(l), name)
_, err := os.Stat(path)
mustNoErr(t, err, "path recreated")
equal(t, l.state.TotalReopens.Load(), uint64(1), "reopens")
})
}
}
// Cleanup never deletes the file being written, whatever it is now named
func TestCleanupSparesOpenFile(t *testing.T) {
l, path, _ := followLogger(t, 0)
logFlush(t, l, "before")
mustNoErr(t, l.Stop(5*time.Second), "Stop") // own the file state from the test goroutine
moved := filepath.Join(filepath.Dir(path), "app-keep.log")
mustNoErr(t, os.Rename(path, moved), "mv")
old := time.Now().Add(-48 * time.Hour)
mustNoErr(t, os.Chtimes(moved, old, old), "age")
c := l.GetConfig()
c.RetentionPeriodHrs = 1
mustNoErr(t, l.ApplyConfig(c), "ApplyConfig")
errContains(t, l.cleanOldLogs(1), "no old logs", "size cleanup")
mustNoErr(t, l.cleanExpiredLogs(old), "retention cleanup")
_, err := os.Stat(moved)
mustNoErr(t, err, "open file kept")
equal(t, l.state.TotalDeletions.Load(), uint64(0), "deletions")
}
// A file removed to free disk space is followed while records are dropped for
// that low space, so the unlinked inode is released
func TestFollowsRemovedLogFileWhileDiskLow(t *testing.T) {
l, path, _ := followLogger(t, 0)
logFlush(t, l, "before")
l.state.DiskStatusOK.Store(false) // as when min_disk_free_kb is not met
mustNoErr(t, os.Remove(path), "rm")
time.Sleep(fileCheckInterval + 5*time.Millisecond)
l.Info("dropped")
mustEventually(t, time.Second, "path recreated", func() bool { return l.state.TotalReopens.Load() == 1 })
_, err := os.Stat(path)
mustNoErr(t, err, "path")
}
+3
View File
@@ -8,6 +8,9 @@ import (
"syscall" "syscall"
) )
// reopenFlags open the log path again; see openLogPath
const reopenFlags = os.O_APPEND | os.O_CREATE | os.O_WRONLY | syscall.O_NOFOLLOW | syscall.O_NONBLOCK
// getDiskFreeSpace retrieves available disk space for the given path // getDiskFreeSpace retrieves available disk space for the given path
func (l *Logger) getDiskFreeSpace(path string) (int64, error) { func (l *Logger) getDiskFreeSpace(path string) (int64, error) {
var stat syscall.Statfs_t var stat syscall.Statfs_t
+51
View File
@@ -0,0 +1,51 @@
//go:build linux || freebsd || darwin
package log
import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
// A symlink or FIFO planted at the log path is neither followed nor allowed to
// block the processor; a FIFO that opens is still not a regular file. The
// refusal is reported once.
func TestReopenRefusesNonRegularPath(t *testing.T) {
plant := map[string]func(t *testing.T, path, target string){
"symlink": func(t *testing.T, path, target string) {
mustNoErr(t, os.WriteFile(target, nil, 0644), "target")
mustNoErr(t, os.Symlink(target, path), "symlink")
},
"fifo": func(t *testing.T, path, _ string) {
mustNoErr(t, syscall.Mkfifo(path, 0644), "mkfifo")
},
"fifo with a reader": func(t *testing.T, path, _ string) {
mustNoErr(t, syscall.Mkfifo(path, 0644), "mkfifo")
r, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
mustNoErr(t, err, "reader")
t.Cleanup(func() { _ = r.Close() })
},
}
for name, fn := range plant {
t.Run(name, func(t *testing.T) {
l, path, diags := followLogger(t, 0)
logFlush(t, l, "before")
target := filepath.Join(t.TempDir(), "target")
mustNoErr(t, os.Remove(path), "rm")
fn(t, path, target)
time.Sleep(fileCheckInterval + 5*time.Millisecond)
logFlush(t, l, "after") // a blocked processor fails the Flush
if data, err := os.ReadFile(target); err == nil {
equal(t, len(data), 0, "bytes written through the symlink")
}
equal(t, l.state.TotalReopens.Load(), uint64(0), "reopens")
if d := diags(); len(d) != 1 || !strings.Contains(d[0], "not reopened") {
t.Errorf("diagnostics %q", d)
}
})
}
}
+11
View File
@@ -2,6 +2,7 @@ package log
import ( import (
"io" "io"
"os"
"time" "time"
"github.com/lixenwraith/log/formatter" "github.com/lixenwraith/log/formatter"
@@ -35,6 +36,16 @@ type TimerSet struct {
diskInterval time.Duration diskInterval time.Duration
} }
// fileWatch records which file the processor's handle refers to. Holding file
// keeps its address from being reused by a later handle. Processor only.
type fileWatch struct {
file *os.File // handle info was taken from
info os.FileInfo // identity of file
path string // log path for file; it changes only with a new file
lastCheck time.Time // record time of the last path check
warned bool // a refused or failed reopen was reported
}
// sink holds the console writers in one atomic.Value; warn takes split's // sink holds the console writers in one atomic.Value; warn takes split's
// warn-and-above records // warn-and-above records
type sink struct { type sink struct {