// Package tailer watches a log file and delivers new bytes to a consumer.
//
// It uses fsnotify for event-driven notifications with a polling fallback
// ticker, and detects log rotation by comparing the file's inode against
// the inode stored in the state file. On rotation the offset resets to 0
// (subject to MaxRotationRecoverySize) and the state is updated.
package tailer
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"syscall"
"time"
"github.com/fsnotify/fsnotify"
"workshop.perforce.com/p4lf/internal/config"
)
// State is the persistent checkpoint written to StateFile.
type State struct {
Inode uint64 `json:"inode"`
Offset int64 `json:"offset"`
}
// Chunk is a slice of bytes read from the log file since the last flush.
type Chunk struct {
Data []byte
StartOffset int64
EndOffset int64
}
// Tailer watches a P4LOG file and delivers accumulated chunks on demand.
type Tailer struct {
cfg config.Config
log Logger
mu sync.Mutex
file *os.File
state State
buf []byte
startOff int64 // offset at the start of current accumulation window
done chan struct{}
watcher *fsnotify.Watcher
}
// Logger is the minimal interface Tailer needs for logging.
type Logger interface {
Infof(format string, args ...interface{})
Warnf(format string, args ...interface{})
Debugf(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
// New creates and starts a Tailer.
func New(cfg config.Config, log Logger) (*Tailer, error) {
t := &Tailer{
cfg: cfg,
log: log,
done: make(chan struct{}),
}
// Load or initialise state.
if err := t.loadState(); err != nil {
return nil, fmt.Errorf("loading state: %w", err)
}
// Open the log file.
if err := t.openFile(); err != nil {
return nil, err
}
// Start fsnotify watcher on the log file's directory (more reliable than
// watching the file directly, since rotation replaces the file).
w, err := fsnotify.NewWatcher()
if err != nil {
t.file.Close()
return nil, fmt.Errorf("creating fsnotify watcher: %w", err)
}
t.watcher = w
if err := w.Add(filepath.Dir(cfg.P4LogFile)); err != nil {
t.file.Close()
w.Close()
return nil, fmt.Errorf("watching directory %q: %w", filepath.Dir(cfg.P4LogFile), err)
}
// Background goroutine: drain fsnotify events so the channel doesn't fill.
// The actual reads happen in Flush(), called on the LogTailDelay ticker.
go t.drainEvents()
return t, nil
}
// loadState reads the state file, or initialises fresh state.
func (t *Tailer) loadState() error {
data, err := os.ReadFile(t.cfg.StateFile)
if os.IsNotExist(err) {
// No state file — fresh start.
t.state = State{}
return nil
}
if err != nil {
return fmt.Errorf("reading state file %q: %w", t.cfg.StateFile, err)
}
if err := json.Unmarshal(data, &t.state); err != nil {
t.log.Warnf("state file %q is corrupt (%v); starting fresh", t.cfg.StateFile, err)
t.state = State{}
}
return nil
}
// openFile opens the P4LOG and positions the read cursor, performing rotation
// detection and handling the ReadFromStart / MaxRotationRecoverySize logic.
func (t *Tailer) openFile() error {
f, err := os.Open(t.cfg.P4LogFile)
if err != nil {
return fmt.Errorf("opening P4LogFile %q: %w", t.cfg.P4LogFile, err)
}
inode, err := fileInode(f)
if err != nil {
f.Close()
return fmt.Errorf("stat P4LogFile: %w", err)
}
info, err := f.Stat()
if err != nil {
f.Close()
return fmt.Errorf("stat P4LogFile: %w", err)
}
switch {
case t.state.Inode == 0:
// First ever run (no state file).
t.handleFirstRun(f, inode, info.Size())
case inode == t.state.Inode:
// Same file — resume from saved offset.
if t.state.Offset > info.Size() {
// Truncation without inode change (copytruncate style).
t.log.Warnf("P4LOG appears truncated (saved offset %d > file size %d); treating as rotation",
t.state.Offset, info.Size())
t.handleRotation(f, inode, info.Size())
} else {
if _, err := f.Seek(t.state.Offset, io.SeekStart); err != nil {
f.Close()
return fmt.Errorf("seeking to saved offset %d: %w", t.state.Offset, err)
}
t.log.Infof("Resuming P4LOG from offset %d (inode %d)", t.state.Offset, inode)
}
default:
// Inode changed — log rotation detected.
t.log.Infof("Log rotation detected (inode changed %d → %d)", t.state.Inode, inode)
t.handleRotation(f, inode, info.Size())
}
t.file = f
t.startOff = t.state.Offset
return nil
}
func (t *Tailer) handleFirstRun(f *os.File, inode uint64, size int64) {
t.state.Inode = inode
if t.cfg.ReadFromStart {
t.log.Infof("First run: reading P4LOG from beginning (inode %d, size %d)", inode, size)
t.state.Offset = 0
} else {
t.log.Infof("First run: starting from end of P4LOG (inode %d, size %d)", inode, size)
f.Seek(0, io.SeekEnd)
t.state.Offset = size
}
}
func (t *Tailer) handleRotation(f *os.File, inode uint64, size int64) {
t.state.Inode = inode
if t.cfg.MaxRotationRecoverySize == 0 || size <= t.cfg.MaxRotationRecoverySize {
t.log.Infof("Reading new P4LOG from beginning (size %d ≤ MaxRotationRecoverySize %d)",
size, t.cfg.MaxRotationRecoverySize)
t.state.Offset = 0
} else {
t.log.Warnf("New P4LOG size %d exceeds MaxRotationRecoverySize %d; starting from EOF to avoid overload",
size, t.cfg.MaxRotationRecoverySize)
f.Seek(0, io.SeekEnd)
t.state.Offset = size
}
}
// drainEvents keeps the fsnotify channel clear so it never blocks.
// Actual data reads happen in Flush(), not here.
func (t *Tailer) drainEvents() {
for {
select {
case <-t.done:
return
case _, ok := <-t.watcher.Events:
if !ok {
return
}
// We don't act on individual events; Flush() reads everything
// available each tick. The watcher just prevents us from missing
// a rotation between ticks.
case err, ok := <-t.watcher.Errors:
if !ok {
return
}
t.log.Errorf("fsnotify error: %v", err)
}
}
}
// Flush reads all bytes available since the last call and returns a Chunk.
// It also checks for log rotation (inode change or truncation).
// Returns a nil Chunk if no new data was available.
func (t *Tailer) Flush() (*Chunk, error) {
t.mu.Lock()
defer t.mu.Unlock()
if err := t.checkRotation(); err != nil {
return nil, err
}
buf := make([]byte, 32*1024)
for {
n, err := t.file.Read(buf)
if n > 0 {
t.buf = append(t.buf, buf[:n]...)
t.state.Offset += int64(n)
}
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("reading P4LOG: %w", err)
}
}
if len(t.buf) == 0 {
return nil, nil
}
chunk := &Chunk{
Data: t.buf,
StartOffset: t.startOff,
EndOffset: t.state.Offset,
}
t.buf = nil
t.startOff = t.state.Offset
return chunk, nil
}
// checkRotation detects inode changes and truncation, reopening the file
// as needed. Must be called with t.mu held.
func (t *Tailer) checkRotation() error {
// Stat the path (not the open fd) to see if the file was replaced.
pathInfo, err := os.Stat(t.cfg.P4LogFile)
if os.IsNotExist(err) {
// File temporarily absent (mid-rotation); wait for next tick.
t.log.Warnf("P4LOG %q not found; waiting for rotation to complete", t.cfg.P4LogFile)
return nil
}
if err != nil {
return fmt.Errorf("stat %q: %w", t.cfg.P4LogFile, err)
}
newInode := inodeFromInfo(pathInfo)
truncated := t.state.Offset > pathInfo.Size()
if newInode == t.state.Inode && !truncated {
return nil // no rotation
}
// Rotation or truncation detected.
if truncated && newInode == t.state.Inode {
t.log.Warnf("P4LOG truncated (copytruncate rotation?); resetting to offset 0")
} else {
// Flush whatever remains in the current file before switching.
remaining, readErr := io.ReadAll(t.file)
if readErr == nil && len(remaining) > 0 {
t.buf = append(t.buf, remaining...)
t.state.Offset += int64(len(remaining))
t.log.Infof("Captured %d trailing bytes from rotated P4LOG before switching", len(remaining))
}
t.log.Infof("Log rotation detected during flush (inode %d → %d)", t.state.Inode, newInode)
}
t.file.Close()
newFile, err := os.Open(t.cfg.P4LogFile)
if err != nil {
return fmt.Errorf("reopening P4LOG after rotation: %w", err)
}
t.file = newFile
t.handleRotation(newFile, newInode, pathInfo.Size())
t.startOff = t.state.Offset
return nil
}
// SaveState atomically writes the current state to StateFile.
func (t *Tailer) SaveState() error {
t.mu.Lock()
state := t.state
t.mu.Unlock()
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
tmp := t.cfg.StateFile + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
return fmt.Errorf("writing state file: %w", err)
}
return os.Rename(tmp, t.cfg.StateFile)
}
// Close shuts down the tailer.
func (t *Tailer) Close() {
close(t.done)
t.watcher.Close()
t.mu.Lock()
defer t.mu.Unlock()
if t.file != nil {
t.file.Close()
}
}
// WaitForFile blocks until the P4LOG file appears, retrying every retryInterval.
// Useful at startup if the file doesn't exist yet.
func WaitForFile(path string, retryInterval time.Duration, log Logger, done <-chan struct{}) error {
for {
if _, err := os.Stat(path); err == nil {
return nil
}
log.Warnf("P4LOG %q not found; retrying in %s", path, retryInterval)
select {
case <-done:
return fmt.Errorf("shutdown before P4LOG appeared")
case <-time.After(retryInterval):
}
}
}
// fileInode returns the inode number of an open file.
func fileInode(f *os.File) (uint64, error) {
info, err := f.Stat()
if err != nil {
return 0, err
}
return inodeFromInfo(info), nil
}
// inodeFromInfo extracts the inode from an os.FileInfo.
// On Linux/macOS this is sys.Ino; falls back to 0 on unsupported platforms.
func inodeFromInfo(info os.FileInfo) uint64 {
if sys, ok := info.Sys().(*syscall.Stat_t); ok {
return sys.Ino
}
return 0
}
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #2 | 32824 | C. Thomas Tyler |
Rename Go module path from github.com/rcowham/p4lf to workshop.perforce.com/p4lf. The rcowham prefix was a carryover from early research into go-libtail (which is hosted at github.com/rcowham/go-libtail). p4lf is hosted on the Perforce Public Depot / Workshop, not GitHub, so workshop.perforce.com is the correct canonical module path. Updated: go.mod, all *.go files with internal imports, Makefile (MODULE and LDFLAGS). Session logs retain the old path as accurate historical record. |
||
| #1 | 32818 | C. Thomas Tyler |
Initial implementation of p4lf in Go. Adds: - Go module (github.com/rcowham/p4lf) with fsnotify dependency - internal/config: KEY=VALUE config parser with all settings - internal/tailer: file reader with inode-based rotation detection and state file checkpoint/resume (inode + byte offset, JSON, atomic write) - internal/chunker: gzip chunk writer with MaxLogChunks/MinLogSpace guards - internal/logger: rotating log writer with gzip of rotated files - cmd/p4lf/main.go: service main loop, SIGHUP/SIGTERM/SIGINT handling, config hot-reload on modtime change - Makefile: build/test/install/release targets for Linux amd64/arm64, macOS arm64/amd64; version injected via ldflags - p4lf.cfg.example: fully documented example config - p4lf.service: systemd unit file (User=perforce, Restart=on-failure) - ai/session_log_2026-06-25.md: design session log - ai/session_log_2026-06-25-2.md: implementation session log - .p4ignore: added bin/ and dist/ |