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 // retired is set when a new setup replaced the writer's channels: a // late record is still appended, but the file is closed again after // every write, so the writer keeps no descriptor. retired bool } 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) if d.retired { _ = d.file.Close() d.file, d.date = nil, "" } 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)) } } } // retire closes the open file for good: later writes, from goroutines that // still hold the replaced channels, open the day's file, append and close // it again. func (d *dailyWriter) retire() error { d.mu.Lock() d.retired = true d.mu.Unlock() return d.Close() } // 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 }