fix(diary): retry a daily file that failed to open
- a failed open no longer disables the writer for good: it skips records and tries again one minute later by its clock, or at the first record of a new date, whichever comes first - the warning (still 'daily log file disabled') appears once per date - README and the logging page describe the retry (WR-04)
This commit is contained in:
@@ -30,7 +30,7 @@ Use cases:
|
||||
- Environment overrides: `SUMMER_LOG__` variables overlay the defaults key by key, and string values parse where YAML gives numbers or lists (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30`, `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily`).
|
||||
- Validation: an unknown driver, format or level, a stack naming a missing channel or containing itself, and a relative daily path that leaves the application root fail `diary.Setup` with an error naming the key.
|
||||
- Console lines in bonfire's palette: time, level, the component as a fixed-width prefix, the message and `key=value` attributes. Control characters other than tab are removed from messages and values, so logged data cannot inject terminal escape sequences. While a bonfire live view (spinner, progress bar, process view) runs, styled lines print above it through `bonfire.PrintAbove`.
|
||||
- Daily files: the path `storage/logs/summer.log` gives files `storage/logs/summer-2026-10-21.log`, created mode 0640 in directories created 0750, opened on the first record, rotated at local midnight, and deleted after the retention in days (only files of that name pattern). A relative path resolves against the application root. A directory that cannot be written logs one warning from component `diary` on the console and disables the channel; it never fails the start-up.
|
||||
- Daily files: the path `storage/logs/summer.log` gives files `storage/logs/summer-2026-10-21.log`, created mode 0640 in directories created 0750, opened on the first record, rotated at local midnight, and deleted after the retention in days (only files of that name pattern). A relative path resolves against the application root. A file that cannot be opened (a directory that cannot be written, a full disk) logs a warning from component `diary` on the console, once per day; the channel skips records and tries again a minute later or at the first record of the next day, and it never fails the start-up.
|
||||
- Redaction: every channel leaf is wrapped by `sunscreen.Wrap`.
|
||||
- Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn.
|
||||
- Console global options: `diary.FromGlobals` turns `-v`/`-vv`/`-vvv`, `-q` and `--ansi`/`--no-ansi` into `diary.Options`, and `diary.ApplyGlobals` applies them to the running setup (the generated main registers it with `bonfire.OnGlobals`).
|
||||
|
||||
@@ -12,23 +12,34 @@ import (
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
// dailyRetry is how long a daily writer skips records after a file failed
|
||||
// to open before it tries again on the same date.
|
||||
const dailyRetry = time.Minute
|
||||
|
||||
// dailyWriter writes to one file per local date: path storage/logs/summer.log
|
||||
// gives storage/logs/summer-2026-10-21.log. Files open lazily on the first
|
||||
// write (no empty files), are created 0o640 in directories created 0o750,
|
||||
// and on every open the files of the same base older than the retention are
|
||||
// deleted. If a file cannot be opened the writer reports once through warn
|
||||
// and is disabled: later writes succeed without writing anything.
|
||||
// deleted. If a file cannot be opened the writer skips records (writes
|
||||
// still succeed) and tries again one minute later by its clock, or at the
|
||||
// first record of a new date, whichever comes first; it reports through
|
||||
// warn once per date that fails.
|
||||
type dailyWriter struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
base string
|
||||
ext string
|
||||
days int
|
||||
now func() time.Time
|
||||
warn func(path string, err error)
|
||||
file *os.File
|
||||
date string
|
||||
disabled bool
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
base string
|
||||
ext string
|
||||
days int
|
||||
now func() time.Time
|
||||
warn func(path string, err error)
|
||||
file *os.File
|
||||
date string
|
||||
// failedDate is the date whose file failed to open last; no open is
|
||||
// tried for it before retryAt.
|
||||
failedDate string
|
||||
retryAt time.Time
|
||||
// warnedDate is the last date a failure was reported for.
|
||||
warnedDate string
|
||||
}
|
||||
|
||||
func newDailyWriter(path string, days int, now func() time.Time, warn func(string, error)) *dailyWriter {
|
||||
@@ -54,15 +65,19 @@ func (d *dailyWriter) fileName(date string) string {
|
||||
// Write appends p to the current day's file in one write call.
|
||||
func (d *dailyWriter) Write(p []byte) (int, error) {
|
||||
d.mu.Lock()
|
||||
if d.disabled {
|
||||
d.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
date := d.now().Local().Format(dateLayout)
|
||||
now := d.now()
|
||||
date := now.Local().Format(dateLayout)
|
||||
if d.file == nil || date != d.date {
|
||||
if date == d.failedDate && now.Before(d.retryAt) {
|
||||
d.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
if err := d.open(date); err != nil {
|
||||
d.disabled = true
|
||||
warn := d.warn
|
||||
d.failedDate, d.retryAt = date, now.Add(dailyRetry)
|
||||
var warn func(string, error)
|
||||
if d.warnedDate != date {
|
||||
d.warnedDate, warn = date, d.warn
|
||||
}
|
||||
name := d.fileName(date)
|
||||
d.mu.Unlock()
|
||||
if warn != nil {
|
||||
@@ -70,6 +85,7 @@ func (d *dailyWriter) Write(p []byte) (int, error) {
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
d.failedDate = ""
|
||||
}
|
||||
n, err := d.file.Write(p)
|
||||
d.mu.Unlock()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
)
|
||||
@@ -268,3 +269,79 @@ func TestPathConfinement(t *testing.T) {
|
||||
t.Errorf("relative path did not resolve against the root: %d lines", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDailyRetriesAfterFailure (WR-04): a daily file that failed to open is
|
||||
// retried one minute later by the writer's clock, or on the first record of
|
||||
// a new date, whichever comes first; the warning appears once per date.
|
||||
func TestDailyRetriesAfterFailure(t *testing.T) {
|
||||
start := func(t *testing.T, at time.Time) (root string, clk *clock, buf *lockedBuffer) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
blocker := filepath.Join(root, "blocker")
|
||||
if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk = newClock(at)
|
||||
buf = &lockedBuffer{}
|
||||
setup(t, diary.Options{Root: root, Stderr: buf, Now: clk.Now},
|
||||
append(slices.Clone(dailyOnly), "SUMMER_LOG__CHANNELS__DAILY__PATH=blocker/logs/summer.log")...)
|
||||
return root, clk, buf
|
||||
}
|
||||
warnings := func(buf *lockedBuffer) int { return strings.Count(buf.String(), "daily log file disabled") }
|
||||
blog := diary.For("blog")
|
||||
|
||||
t.Run("one minute later", func(t *testing.T) {
|
||||
root, clk, buf := start(t, day(21, 10, 0, 0))
|
||||
blog.Info("first")
|
||||
if n := warnings(buf); n != 1 {
|
||||
t.Fatalf("%d warnings after the failed open, want 1:\n%s", n, buf.String())
|
||||
}
|
||||
if err := os.Remove(filepath.Join(root, "blocker")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(root, "blocker", "logs", "summer-2026-10-21.log")
|
||||
clk.Set(day(21, 10, 0, 30))
|
||||
blog.Info("within the minute")
|
||||
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
||||
t.Fatalf("retried before the minute passed (stat err %v)", err)
|
||||
}
|
||||
if n := warnings(buf); n != 1 {
|
||||
t.Fatalf("%d warnings within the minute, want 1:\n%s", n, buf.String())
|
||||
}
|
||||
clk.Set(day(21, 10, 1, 1))
|
||||
blog.Info("after the minute")
|
||||
lines := readDaily(t, file)
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "after the minute") {
|
||||
t.Fatalf("daily file after the retry = %q, want the record after the minute", lines)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("next date", func(t *testing.T) {
|
||||
root, clk, buf := start(t, day(21, 23, 59, 59))
|
||||
blog.Info("before midnight")
|
||||
if err := os.Remove(filepath.Join(root, "blocker")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Set(day(22, 0, 0, 1))
|
||||
blog.Info("after midnight")
|
||||
lines := readDaily(t, filepath.Join(root, "blocker", "logs", "summer-2026-10-22.log"))
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "after midnight") {
|
||||
t.Fatalf("daily file of the new date = %q", lines)
|
||||
}
|
||||
if n := warnings(buf); n != 1 {
|
||||
t.Errorf("%d warnings, want 1:\n%s", n, buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("next date fails too", func(t *testing.T) {
|
||||
_, clk, buf := start(t, day(21, 23, 59, 59))
|
||||
blog.Info("before midnight")
|
||||
clk.Set(day(22, 0, 0, 1))
|
||||
blog.Info("after midnight")
|
||||
clk.Set(day(22, 0, 2, 0))
|
||||
blog.Info("later")
|
||||
if n := warnings(buf); n != 2 {
|
||||
t.Errorf("%d warnings over two failed dates, want 2:\n%s", n, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user