- 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)
169 lines
4.0 KiB
Go
169 lines
4.0 KiB
Go
package diary
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
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 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
|
|
// 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 {
|
|
dir, name := filepath.Split(path)
|
|
ext := filepath.Ext(name)
|
|
if dir == "" {
|
|
dir = "."
|
|
}
|
|
return &dailyWriter{
|
|
dir: filepath.Clean(dir),
|
|
base: strings.TrimSuffix(name, ext),
|
|
ext: ext,
|
|
days: days,
|
|
now: now,
|
|
warn: warn,
|
|
}
|
|
}
|
|
|
|
func (d *dailyWriter) fileName(date string) string {
|
|
return filepath.Join(d.dir, d.base+"-"+date+d.ext)
|
|
}
|
|
|
|
// Write appends p to the current day's file in one write call.
|
|
func (d *dailyWriter) Write(p []byte) (int, error) {
|
|
d.mu.Lock()
|
|
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.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 {
|
|
warn(name, err)
|
|
}
|
|
return len(p), nil
|
|
}
|
|
d.failedDate = ""
|
|
}
|
|
n, err := d.file.Write(p)
|
|
d.mu.Unlock()
|
|
return n, err
|
|
}
|
|
|
|
func (d *dailyWriter) open(date string) error {
|
|
if d.file != nil {
|
|
_ = d.file.Close()
|
|
d.file = nil
|
|
}
|
|
if err := os.MkdirAll(d.dir, 0o750); err != nil {
|
|
return err
|
|
}
|
|
name := d.fileName(date)
|
|
_, statErr := os.Stat(name)
|
|
f, err := os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if errors.Is(statErr, fs.ErrNotExist) {
|
|
// The process umask may strip bits; new log files are 0o640 exactly.
|
|
_ = f.Chmod(0o640)
|
|
}
|
|
d.file, d.date = f, date
|
|
d.prune(date)
|
|
return nil
|
|
}
|
|
|
|
// prune deletes files named base-YYYY-MM-DD+ext whose date falls outside the
|
|
// retention window: with days N the files of today and the N-1 days before
|
|
// it are kept. Other files in the directory are never touched.
|
|
func (d *dailyWriter) prune(today string) {
|
|
if d.days <= 0 {
|
|
return
|
|
}
|
|
now, err := time.ParseInLocation(dateLayout, today, time.Local)
|
|
if err != nil {
|
|
return
|
|
}
|
|
cutoff := now.AddDate(0, 0, -d.days)
|
|
entries, err := os.ReadDir(d.dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
prefix := d.base + "-"
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
rest, ok := strings.CutPrefix(name, prefix)
|
|
if !ok {
|
|
continue
|
|
}
|
|
stamp, ok := strings.CutSuffix(rest, d.ext)
|
|
if !ok || len(stamp) != len(dateLayout) {
|
|
continue
|
|
}
|
|
day, err := time.ParseInLocation(dateLayout, stamp, time.Local)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !day.After(cutoff) {
|
|
_ = os.Remove(filepath.Join(d.dir, name))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close closes the open file. A later write opens it again.
|
|
func (d *dailyWriter) Close() error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.file == nil {
|
|
return nil
|
|
}
|
|
err := d.file.Close()
|
|
d.file, d.date = nil, ""
|
|
return err
|
|
}
|