Files
summercms/modules/diary/daily.go
Jakub Zych 39677f1819 fix(diary): retire replaced daily writers and serialise ApplyGlobals
- install and Reset retire the channels they replace: a retired daily
  writer appends a late record and closes the file again, so no record is
  lost and no descriptor stays open; diary.Close keeps reopening the
  installed writers on the next record
- ApplyGlobals holds the setup lock for its whole run (lock-free
  installLocked and setVerbosityLocked), so concurrent calls rebuild the
  channels once (WR-06)
2026-10-22 02:27:39 +02:00

187 lines
4.6 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
// 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
}