v0.37.1 match bytes to follow a file tail, preventing reset on edit in most cases
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-15 03:05:51 -04:00
1 parent 24c3eb1d6e
commit 1d90070ad1
4 files changed
+547 -172

No files matched your search

+30 -12
View File
@@ -59,18 +59,36 @@ Options, each as type and default:
written meanwhile is not read. `from = "start"` instead reads each file whole written meanwhile is not read. `from = "start"` instead reads each file whole
when its watcher is created, which a process writing beside LogWisp needs, at when its watcher is created, which a process writing beside LogWisp needs, at
the cost of replaying files already on disk at every restart and reload. the cost of replaying files already on disk at every restart and reload.
- Rotation is detected from size decrease, a modification time that went back - A file is followed by its content, not its inode or size. lw keeps the last
without growth, a position more than 1 KiB beyond end-of-file, or an inode 8 lines read (1 KiB at most), matches them only whole (never a line still
change. An inode change where the new file is non-empty and at least as large being written), and assumes lines are mostly distinct, as timestamped logs
as the recorded position is treated as an atomic save, not a rotation, and are; runs of identical lines can make it resume after a later copy.
the position is preserved. Each rotation also publishes an INFO entry - Appended to, it reads on.
`Log rotation detected (#N): REASON` with the file's name as `Source`; it - Rewritten, in place or by an editor's rename: reading resumes after the
reaches every sink, so exclude it with a filter (`Log rotation detected`) newest of those lines still in the file's last 64 KiB, wherever lines were
where unwanted. deleted or inserted before it; inserted lines are not read. Nothing read
- A rotation that renames in place — what a size-capped writer does — puts the is replayed.
same inode back under a name `pattern` also matches. Its watcher resumes at - None of those lines left (logrotate's `create` or `copytruncate`, a file
the position the original reached, so `from = "start"` reads the tail an emptied and written again, an edit deleting them all), and no other
unfinished read left behind rather than the whole archive a second time. watcher having read the file (below), is a rotation: read from its start,
after an INFO entry
`Log rotation detected (#N): the file starts anew` with the file's name as
`Source`, which reaches every sink (filter `Log rotation detected` out
where unwanted). The NULs `copytruncate` leaves when a writer that does
not append goes on at its old offset are skipped, unless the file stayed
empty for a whole poll.
- While the line read last is missing and the file still changes between
polls, as when a slow save is rewriting it in place, lw holds that file,
for 2 s at most, and decides once it settles.
- Limits: a save appending more than 64 KiB after those lines loses them,
so it is read from its start. A rewrite still being written after 2 s is
judged as it stands: read from its start if it has not reached those
lines yet, else what it writes after that is replayed.
- A rotation that renames in place (a size-capped writer, a numbered logrotate
chain shifting `app.log.1` to `app.log.2`) puts the same inode back under a
name `pattern` also matches. The watcher there, new or already following
that name, resumes where the file was read to: it reads only the tail an
unfinished read left behind, never the whole archive a second time.
- A line is parsed as JSON only when it is a non-empty object whose top-level - A line is parsed as JSON only when it is a non-empty object whose top-level
keys are all drawn from `time`, `level`, `msg` and `fields` (the four an keys are all drawn from `time`, `level`, `msg` and `fields` (the four an
entry can carry), with any `level` or `msg` a string. `time` is read as entry can carry), with any `level` or `msg` a string. `time` is read as
+27 -5
View File
@@ -10,7 +10,6 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall"
"time" "time"
"github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
@@ -276,6 +275,7 @@ func (fs *FileSource) ensureWatcher(path string) {
} }
w := newFileWatcher(path, fs.config.Raw, fs.config.From == "start", fs.publish, fs.logger) w := newFileWatcher(path, fs.config.Raw, fs.config.From == "start", fs.publish, fs.logger)
w.others = fs.othersOf(w)
// A rotation renames the file out from under its watcher, so the same inode // A rotation renames the file out from under its watcher, so the same inode
// reappears here under the archive name. Resume where it was left: from the // reappears here under the archive name. Resume where it was left: from the
// start would re-emit every record the file has already delivered. // start would re-emit every record the file has already delivered.
@@ -304,22 +304,44 @@ func (fs *FileSource) ensureWatcher(path string) {
// readPosition reports how far a running watcher has read the file now at path. // readPosition reports how far a running watcher has read the file now at path.
// Callers hold fs.mu. // Callers hold fs.mu.
func (fs *FileSource) readPosition(path string) (int64, bool) { func (fs *FileSource) readPosition(path string) (int64, bool) {
info, err := os.Stat(path) file, err := os.Open(path)
if err != nil { if err != nil {
return 0, false return 0, false
} }
stat, ok := info.Sys().(*syscall.Stat_t) defer file.Close()
if !ok { info, err := file.Stat()
if err != nil {
return 0, false return 0, false
} }
return fs.readFile(file, info, nil)
}
// readFile reports how far a running watcher other than but read the file, by
// its inode, if it still holds what that one read last before there: an inode
// can be reused, and more than one watcher can claim it. Callers hold fs.mu.
func (fs *FileSource) readFile(file *os.File, info os.FileInfo, but *fileWatcher) (int64, bool) {
for _, w := range fs.watchers { for _, w := range fs.watchers {
if position, ok := w.readTo(stat.Ino); ok { if w == but {
continue
}
if position, tail, ok := w.readTo(inodeOf(info)); ok && leads(file, position, info.Size(), tail) {
return position, true return position, true
} }
} }
return 0, false return 0, false
} }
// othersOf asks the source's other watchers, from w's goroutine, how far they
// read a file: w holds no lock of its own then, so the order is fs.mu, then a
// watcher's mu, as everywhere.
func (fs *FileSource) othersOf(w *fileWatcher) func(*os.File, os.FileInfo) (int64, bool) {
return func(file *os.File, info os.FileInfo) (int64, bool) {
fs.mu.RLock()
defer fs.mu.RUnlock()
return fs.readFile(file, info, w)
}
}
// removeWatcher removes only the watcher that finished. A deleted file can be // removeWatcher removes only the watcher that finished. A deleted file can be
// recreated before its old watcher observes stop; in that case ensureWatcher // recreated before its old watcher observes stop; in that case ensureWatcher
// has already installed a replacement under the same path, which must survive. // has already installed a replacement under the same path, which must survive.
+263
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -210,3 +211,265 @@ func TestRotatedFileResumesInsteadOfReplaying(t *testing.T) {
t.Error("a file no watcher has read was treated as rotated") t.Error("a file no watcher has read was treated as rotated")
} }
} }
// lines are log lines stamped with day, from line first up to but not
// including last
func lines(day, first, last int) string {
var b strings.Builder
for i := first; i < last; i++ {
fmt.Fprintf(&b, "Oct %d 00:00:%02d host app[1]: line %d\n", day, i%60, i)
}
return b.String()
}
// appendFile adds text to the file's end, as a writer would
func appendFile(t *testing.T, path, text string) {
t.Helper()
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
t.Fatal(err)
}
if _, err := f.WriteString(text); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
}
// tailFrom starts a watcher at the file's end; each call of the check it
// returns polls once and gives the messages read
func tailFrom(t *testing.T, path string) func() []string {
t.Helper()
var got []string
w := newFileWatcher(path, true, false, func(e core.LogEntry) { got = append(got, e.Message) }, log.NewLogger())
if err := w.initPosition(); err != nil {
t.Fatal(err)
}
return func() []string {
t.Helper()
got = nil
if err := w.checkFile(); err != nil {
t.Fatal(err)
}
return got
}
}
// An edited file is still the file being read, however it is saved and
// wherever lines are deleted or inserted: reading resumes after the newest
// line read that it still holds, and replays nothing
func TestEditedFileResumesAfterTheLastLineRead(t *testing.T) {
saves := map[string]func(t *testing.T, check func() []string, path, text string){
"by rename": func(t *testing.T, _ func() []string, path, text string) {
testutil.WriteFile(t, path+".tmp", text)
if err := os.Rename(path+".tmp", path); err != nil {
t.Fatal(err)
}
},
"in place": func(t *testing.T, _ func() []string, path, text string) { testutil.WriteFile(t, path, text) },
"in place, polled while empty": func(t *testing.T, check func() []string, path, text string) {
testutil.WriteFile(t, path, "")
if got := check(); len(got) != 0 {
t.Fatalf("read %q from the emptied file", got)
}
testutil.WriteFile(t, path, text)
},
"in place, polled partway": func(t *testing.T, check func() []string, path, text string) {
testutil.WriteFile(t, path, text[:len(text)/2])
if got := check(); len(got) != 0 {
t.Fatalf("read %q from the file half written", got)
}
testutil.WriteFile(t, path, text)
},
}
for name, save := range saves {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "messages")
base := lines(14, 0, 20)
testutil.WriteFile(t, path, base)
check := tailFrom(t, path)
steps := []struct {
text string
want []string
}{
{base + "ssh in\n", []string{"ssh in"}},
{base, nil}, // the line read last, deleted
{base + "after\n", []string{"after"}},
{lines(14, 0, 15) + lines(14, 16, 20) + "after\nmore\n", []string{"more"}}, // a line before it deleted
{lines(14, 0, 5) + "inserted\n" + lines(14, 5, 15) + lines(14, 16, 20) + "after\nmore\nagain\n", []string{"again"}},
{lines(14, 10, 15) + lines(14, 16, 20) + "after\nmore\nagain\nlast\n", []string{"last"}}, // the top trimmed
{lines(14, 10, 15) + lines(14, 16, 20) + "after\nmore\nagain\nfinal\n", []string{"final"}}, // the line read last replaced
{"final\nend\n", []string{"end"}}, // trimmed down to the line read last
}
for i, step := range steps {
save(t, check, path, step.text)
// a missing line read last holds the file for one check, until it settles
if got := append(check(), check()...); !slices.Equal(got, step.want) {
t.Fatalf("save %d read %q, want %q", i+1, got, step.want)
}
}
})
}
}
// A file that starts anew is a rotation: it is read from its start, after the
// entry saying so and once it settles, however it was replaced, whatever its
// size, and wherever in a line the last read ended
func TestRotatedFileIsReadFromItsStart(t *testing.T) {
fresh := lines(15, 0, 3)
rotations := map[string]func(t *testing.T, check func() []string, path string){
"truncated and written again": func(t *testing.T, _ func() []string, path string) { testutil.WriteFile(t, path, fresh) },
"renamed away and made anew": func(t *testing.T, _ func() []string, path string) {
if err := os.Rename(path, path+".1"); err != nil {
t.Fatal(err)
}
testutil.WriteFile(t, path, fresh)
},
"made anew past the position read": func(t *testing.T, _ func() []string, path string) {
testutil.WriteFile(t, path, fresh+lines(15, 3, 60))
},
"emptied, polled, then written": func(t *testing.T, check func() []string, path string) {
testutil.WriteFile(t, path, "")
if got := check(); len(got) != 0 {
t.Fatalf("read %q from the emptied file", got)
}
testutil.WriteFile(t, path, fresh)
},
// a block-buffered writer leaves a line half written: no mark, though
// the new lines start as it does
"after a line read half written": func(t *testing.T, check func() []string, path string) {
appendFile(t, path, "Oct 1")
check()
testutil.WriteFile(t, path, fresh)
},
// copytruncate under a writer that does not append: it goes on at its
// offset, past a hole of NULs, which is no entry
"truncated under a writer at its offset": func(t *testing.T, _ func() []string, path string) {
testutil.WriteFile(t, path, strings.Repeat("\x00", len(lines(14, 0, 20)))+fresh)
},
}
for name, rotate := range rotations {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "messages")
testutil.WriteFile(t, path, lines(14, 0, 20))
check := tailFrom(t, path)
rotate(t, check, path)
got := append(check(), check()...)
want := slices.Collect(strings.Lines(fresh))
if len(got) < len(want)+1 || !strings.HasPrefix(got[0], "Log rotation detected (#1)") {
t.Fatalf("read %q, want the rotation entry, then the new file from its start", got)
}
for i, line := range want {
if got[i+1] != strings.TrimSuffix(line, "\n") {
t.Fatalf("read %q, want the new file from its start", got)
}
}
})
}
}
// readTo answers only for files the watcher has read: never the inode an
// editor's save freed, and while a check holds a replaced file, the one renamed
// away, not its replacement
func TestReadToAnswersOnlyForFilesRead(t *testing.T) {
path := filepath.Join(t.TempDir(), "messages")
inode := func(name string) uint64 {
info, err := os.Stat(name)
if err != nil {
t.Fatal(err)
}
return inodeOf(info)
}
text := lines(14, 0, 20)
testutil.WriteFile(t, path, text)
w := newFileWatcher(path, true, false, func(core.LogEntry) {}, log.NewLogger())
if err := w.initPosition(); err != nil {
t.Fatal(err)
}
freed := inode(path)
text += "edited\n"
testutil.WriteFile(t, path+".tmp", text)
if err := errors.Join(os.Rename(path+".tmp", path), w.checkFile()); err != nil {
t.Fatal(err)
}
if _, _, ok := w.readTo(freed); ok {
t.Error("the inode an editor's save freed is taken for an archive")
}
archive := path + ".1"
if err := os.Rename(path, archive); err != nil {
t.Fatal(err)
}
testutil.WriteFile(t, path, "")
if err := w.checkFile(); err != nil {
t.Fatal(err)
}
if position, _, ok := w.readTo(inode(archive)); !ok || position != int64(len(text)) {
t.Errorf("held: the renamed file read to %d, %v; want %d, true", position, ok, len(text))
}
if _, _, ok := w.readTo(inode(path)); ok {
t.Error("held: the replacement is answered for before it is judged")
}
if err := w.checkFile(); err != nil {
t.Fatal(err)
}
if position, _, ok := w.readTo(inode(archive)); !ok || position != int64(len(text)) {
t.Errorf("judged: the renamed file read to %d, %v; want %d, true", position, ok, len(text))
}
}
// A rotation shifts archives along their names (app.log to app.log.1, that to
// app.log.2): each watcher finds the file the one before it read and goes on
// from where that one got, so nothing is replayed and what it left is read
// once. An inode reused, claimed by a watcher of a deleted file, is not it.
func TestShiftedArchiveResumesWhereItWasRead(t *testing.T) {
for name, order := range map[string][]int{"the archive's watcher first": {1, 0}, "the file's watcher first": {0, 1}} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
paths := []string{filepath.Join(dir, "app.log"), filepath.Join(dir, "app.log.1"), filepath.Join(dir, "app.log.2")}
testutil.WriteFile(t, paths[0], lines(15, 0, 20))
testutil.WriteFile(t, paths[1], lines(14, 0, 20))
var got []string
source := &FileSource{watchers: map[string]*fileWatcher{}}
watchers := make([]*fileWatcher, 2)
for i := range watchers {
w := newFileWatcher(paths[i], true, false, func(e core.LogEntry) { got = append(got, e.Message) }, log.NewLogger())
w.others = source.othersOf(w)
if err := w.initPosition(); err != nil {
t.Fatal(err)
}
source.watchers[paths[i]], watchers[i] = w, w
}
appendFile(t, paths[0], "unread\n")
for i := 1; i >= 0; i-- {
if err := os.Rename(paths[i], paths[i+1]); err != nil {
t.Fatal(err)
}
}
testutil.WriteFile(t, paths[0], "fresh\n")
info, err := os.Stat(paths[0])
if err != nil {
t.Fatal(err)
}
source.watchers["deleted"] = &fileWatcher{inode: inodeOf(info), position: 3, tail: []byte("ab\n")}
source.watchers["deleted too"] = &fileWatcher{prevInode: watchers[0].inode, prevPosition: 3, prevTail: []byte("ab\n")}
for range 2 {
for _, i := range order {
if err := watchers[i].checkFile(); err != nil {
t.Fatal(err)
}
}
}
want := []string{"Log rotation detected (#1): the file starts anew", "fresh", "unread"}
if slices.Sort(got); !slices.Equal(got, want) {
t.Errorf("read %q, want %q", got, want)
}
if position, ok := source.readPosition(paths[2]); !ok || position != int64(len(lines(14, 0, 20))) {
t.Errorf("app.log.2 read to %d, %v; want %d, true", position, ok, len(lines(14, 0, 20)))
}
})
}
}
+227 -155
View File
@@ -2,6 +2,7 @@ package file
import ( import (
"bufio" "bufio"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -30,6 +31,23 @@ type WatcherInfo struct {
Rotations int64 Rotations int64
} }
// The lines read last that are kept, to find the place again in a file an edit
// rewrote, newest first, are tailLines and tailBytes at most; findWithin is how
// much of its end is searched, what a save may append after them
const (
tailLines = 8
tailBytes = 1 << 10
findWithin = 64 << 10
)
// settleChecks is how many checks a file whose line read last is missing may go
// on changing, a slow rewrite still being written, before lw decides
const settleChecks = 20
// holeProbe is how much at each end of a rotated file's bytes before the
// position must be NUL to take them for a hole (see pastHole)
const holeProbe = 4 << 10
// fileWatcher tails a single file, handles rotations, and sends new lines to a callback // fileWatcher tails a single file, handles rotations, and sends new lines to a callback
type fileWatcher struct { type fileWatcher struct {
directory string directory string
@@ -37,17 +55,23 @@ type fileWatcher struct {
raw bool raw bool
position int64 position int64
size int64 size int64
inode uint64 inode uint64 // the file position is in, set once a check has judged it
modTime time.Time modTime time.Time
mu sync.Mutex mu sync.Mutex
stopped bool stopped bool
rotationSeq int64 rotationSeq int64
prevInode uint64 prevInode uint64
prevPosition int64 prevPosition int64
prevTail []byte
entriesRead atomic.Uint64 entriesRead atomic.Uint64
lastReadTime atomic.Value // time.Time lastReadTime atomic.Value // time.Time
logger *log.Logger logger *log.Logger
failed string // the last check's error, "" for none; watch's failed string // the last check's error, "" for none; watch's
tail []byte // the lines before position (lastLines), set with it under mu
seen os.FileInfo // the file as the last check found it; watch's
holds int // checks waited for the file to settle; watch's
// how far another watcher of the source read a file (FileSource.othersOf)
others func(*os.File, os.FileInfo) (int64, bool)
} }
// newFileWatcher creates a new watcher for a specific file path. // newFileWatcher creates a new watcher for a specific file path.
@@ -63,6 +87,7 @@ func newFileWatcher(directory string, raw, fromStart bool, callback func(core.Lo
raw: raw, raw: raw,
position: position, position: position,
logger: logger, logger: logger,
others: func(*os.File, os.FileInfo) (int64, bool) { return 0, false },
} }
w.lastReadTime.Store(time.Time{}) w.lastReadTime.Store(time.Time{})
return w return w
@@ -100,14 +125,12 @@ func (w *fileWatcher) note(err error) {
} }
} }
// stop signals the watcher to terminate its loop
func (w *fileWatcher) stop() { func (w *fileWatcher) stop() {
w.mu.Lock() w.mu.Lock()
w.stopped = true w.stopped = true
w.mu.Unlock() w.mu.Unlock()
} }
// getInfo returns a snapshot of the watcher's current statistics
func (w *fileWatcher) getInfo() WatcherInfo { func (w *fileWatcher) getInfo() WatcherInfo {
w.mu.Lock() w.mu.Lock()
info := WatcherInfo{ info := WatcherInfo{
@@ -127,12 +150,16 @@ func (w *fileWatcher) getInfo() WatcherInfo {
return info return info
} }
// checkFile examines the file for changes, rotations, or new content // checkFile reads what the file has gained, whatever its inode or size. Where
// an edit rewrote it, reading resumes after the newest line read that it still
// holds; with none the place is lost: a file another watcher read goes on from
// where that one got, any other is a rotation, read from the start. While the
// line read last is missing and the file still changes, it is held, up to
// settleChecks, so that a slow rewrite is judged once written.
func (w *fileWatcher) checkFile() error { func (w *fileWatcher) checkFile() error {
file, err := os.Open(w.directory) file, err := os.Open(w.directory)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
// File doesn't exist yet, keep watching
return nil return nil
} }
return err return err
@@ -143,168 +170,219 @@ func (w *fileWatcher) checkFile() error {
if err != nil { if err != nil {
return err return err
} }
size, same := info.Size(), w.seen != nil && os.SameFile(info, w.seen) && info.Size() == w.seen.Size() && info.ModTime().Equal(w.seen.ModTime())
w.seen = info
w.mu.Lock() w.mu.Lock()
oldPos := w.position pos := w.position
oldSize := w.size w.size, w.modTime = size, info.ModTime()
oldInode := w.inode
oldModTime := w.modTime
w.mu.Unlock() w.mu.Unlock()
if same && pos >= size && w.holds == 0 {
currentSize := info.Size() return nil
currentModTime := info.ModTime()
var currentInode uint64
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
currentInode = stat.Ino
} }
// Handle first time seeing a file that didn't exist before rotated, lost, tail := false, false, w.tail
if oldInode == 0 && currentInode != 0 { if !leads(file, pos, size, w.tail) {
// File just appeared, don't treat as rotation at, which, err := w.find(file, pos, size)
w.mu.Lock() if err != nil {
w.inode = currentInode
w.size = currentSize
w.modTime = currentModTime
// Position stays at 0 for new files
w.mu.Unlock()
// Don't return here - continue to read content
}
// Check for rotation
rotated := false
rotationReason := ""
startPos := oldPos
// Rotation detection
if currentSize < oldSize {
// File was truncated
rotated = true
rotationReason = "size decrease"
} else if currentModTime.Before(oldModTime) && currentSize <= oldSize {
// Modification time went backwards (logrotate behavior)
rotated = true
rotationReason = "modification time reset"
} else if oldPos > currentSize+1024 {
// Our position is way beyond file size
rotated = true
rotationReason = "position beyond file size"
} else if oldInode != 0 && currentInode != 0 && currentInode != oldInode {
// Inode changed - distinguish between rotation and atomic save
if currentSize == 0 {
// Empty file with new inode = likely rotation
rotated = true
rotationReason = "inode change with empty file"
} else if currentSize < oldPos {
// New file is smaller than our position = rotation
rotated = true
rotationReason = "inode change with size less than position"
} else {
// Inode changed but file has content and size >= position
// This is likely an atomic save by an editor
// Update inode but keep position
w.mu.Lock()
w.inode = currentInode
w.mu.Unlock()
w.logger.Debug("msg", "Atomic file update detected",
"component", "file_watcher",
"directory", w.directory,
"old_inode", oldInode,
"new_inode", currentInode,
"position", oldPos,
"size", currentSize)
}
}
if rotated {
startPos = 0
w.mu.Lock()
w.rotationSeq++
seq := w.rotationSeq
// Retained for the source: the renamed file is about to be discovered
// under its archive name, and only this says how much of it was read.
w.prevInode, w.prevPosition = oldInode, oldPos
w.inode = currentInode
w.position = 0 // Reset position on rotation
w.mu.Unlock()
w.callback(core.LogEntry{
Time: time.Now(),
Source: filepath.Base(w.directory),
Level: "INFO",
Message: fmt.Sprintf("Log rotation detected (#%d): %s", seq, rotationReason),
})
w.logger.Info("msg", "Log rotation detected",
"component", "file_watcher",
"directory", w.directory,
"sequence", seq,
"reason", rotationReason)
}
// Read if there's new content OR if we need to continue from position
if currentSize > startPos {
if _, err := file.Seek(startPos, io.SeekStart); err != nil {
return err return err
} }
switch {
case which != 0 && !same && w.holds < settleChecks:
w.holds++
return nil
case which < 0:
// as a rotation shifts archives along their names, each watcher
// finds the file the one before it read
if p, read := w.others(file, info); read {
at = p
w.logger.Info("msg", "File replaced by one another watcher read; resuming where it got",
"component", "file_watcher", "path", w.directory, "resumed_at", at, "size", size)
} else {
rotated, at = true, pastHole(file, pos, size)
}
lost = true
default:
w.logger.Info("msg", "File rewritten; resuming after the newest line read it still holds",
"component", "file_watcher", "path", w.directory, "position", pos, "resumed_at", at, "size", size)
}
pos = at
if tail, err = lastLines(file, pos); err != nil {
return err
}
}
w.mu.Lock()
if lost {
// Retained for the source: the renamed file is about to be found under
// its archive name, and only this says how much of it was read.
w.prevInode, w.prevPosition, w.prevTail = w.inode, w.position, w.tail
}
w.inode, w.position, w.tail = inodeOf(info), pos, tail
w.mu.Unlock()
w.holds = 0
if rotated {
w.rotated()
}
if _, err := file.Seek(pos, io.SeekStart); err != nil {
return err
}
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), core.MaxLogEntryBytes) scanner.Buffer(make([]byte, 0, 64*1024), core.MaxLogEntryBytes)
scanner.Split(scanLinesCapped) scanner.Split(scanLinesCapped)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
if line == "" { if line == "" {
continue continue
} }
rawSize := int64(len(line))
entry := w.parseLine(line) entry := w.parseLine(line)
entry.RawSize = rawSize entry.RawSize = int64(len(line))
w.callback(entry) w.callback(entry)
w.entriesRead.Add(1) w.entriesRead.Add(1)
w.lastReadTime.Store(time.Now()) w.lastReadTime.Store(time.Now())
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return err return err
} }
// Update position after successful read end, err := file.Seek(0, io.SeekCurrent)
currentPos, err := file.Seek(0, io.SeekCurrent)
if err != nil { if err != nil {
// Log error but don't fail - best effort position tracking
w.logger.Warn("msg", "Failed to get file position", "error", err) w.logger.Warn("msg", "Failed to get file position", "error", err)
// Use size as fallback position end = max(pos, size)
currentPos = currentSize }
if tail, err = lastLines(file, end); err != nil {
return err
} }
w.mu.Lock() w.mu.Lock()
w.position = currentPos w.position, w.tail = end, tail
w.size = currentSize
w.modTime = currentModTime
if !rotated && currentInode != 0 {
w.inode = currentInode
}
w.mu.Unlock() w.mu.Unlock()
return scanner.Err()
}
// Update metadata even if no new content
w.mu.Lock()
w.size = currentSize
w.modTime = currentModTime
if currentInode != 0 {
w.inode = currentInode
}
w.mu.Unlock()
return nil return nil
} }
// rotated counts a rotation and says so, in lw's log and to the pipeline
func (w *fileWatcher) rotated() {
w.mu.Lock()
w.rotationSeq++
seq := w.rotationSeq
w.mu.Unlock()
w.callback(core.LogEntry{
Time: time.Now(),
Source: filepath.Base(w.directory),
Level: "INFO",
Message: fmt.Sprintf("Log rotation detected (#%d): the file starts anew", seq),
})
w.logger.Info("msg", "Log rotation detected",
"component", "file_watcher",
"directory", w.directory,
"sequence", seq)
}
// leads reports whether the file holds tail just before pos
func leads(file *os.File, pos, size int64, tail []byte) bool {
if pos > size {
return false
}
got, err := readAt(file, pos-int64(len(tail)), int64(len(tail)))
return err == nil && bytes.Equal(got, tail)
}
// find is where reading resumes in a file an edit rewrote: after the newest of
// the lines read last that its last findWithin bytes still hold, each matched
// whole, the file's start counting as a newline. which counts back from the
// newest, -1 where none is.
func (w *fileWatcher) find(file *os.File, pos, size int64) (at int64, which int, err error) {
lo := max(0, size-findWithin)
buf, err := readAt(file, lo, size-lo)
if err != nil {
return 0, -1, err
}
if lo == 0 {
buf, lo = append([]byte{'\n'}, buf...), -1
}
end := len(w.tail)
if end > 0 && w.tail[end-1] != '\n' { // a line still being written is no mark
end, which = bytes.LastIndexByte(w.tail, '\n')+1, 1
}
for ; end > 0; which++ {
start := bytes.LastIndexByte(w.tail[:end-1], '\n')
line := w.tail[max(0, start):end]
if start < 0 && pos == int64(len(w.tail)) {
line = append([]byte{'\n'}, line...)
}
if len(bytes.TrimSpace(line)) > 0 {
if i := bytes.LastIndex(buf, line); i >= 0 {
return lo + int64(i+len(line)), which, nil
}
}
end = start + 1
}
return 0, -1, nil
}
// pastHole is where a rotated file is read from: its start, or its first data
// past the position when the bytes before it are NUL, the hole copytruncate
// leaves under a writer that does not append and goes on at its offset
func pastHole(file *os.File, pos, size int64) int64 {
at := min(pos, size)
for _, from := range []int64{0, max(0, at-holeProbe)} {
if b, err := readAt(file, from, min(at-from, holeProbe)); err != nil || len(b) == 0 || len(bytes.Trim(b, "\x00")) > 0 {
return 0
}
}
buf := make([]byte, holeProbe)
for at < size {
n, err := file.ReadAt(buf[:min(int64(len(buf)), size-at)], at)
if i := bytes.IndexFunc(buf[:n], func(r rune) bool { return r != 0 }); i >= 0 {
return at + int64(i)
}
if at += int64(n); err != nil {
break
}
}
return at
}
// lastLines are the lines that end at end, tailLines and tailBytes at most,
// each with the newline before it so that it is found only whole
func lastLines(file *os.File, end int64) ([]byte, error) {
n := min(end, tailBytes)
buf, err := readAt(file, end-n, n)
cut := 0
if end > n { // the first line is cut: from the newline after it
cut = max(0, bytes.IndexByte(buf[:max(0, len(buf)-1)], '\n'))
}
for i, lines := len(buf)-2, 0; i > cut; i-- {
if buf[i] == '\n' {
if lines++; lines == tailLines {
cut = i
break
}
}
}
return buf[cut:], err
}
// readAt reads n bytes at off, fewer where the file ends first
func readAt(file *os.File, off, n int64) ([]byte, error) {
buf := make([]byte, n)
got, err := file.ReadAt(buf, off)
if err == io.EOF {
err = nil
}
return buf[:got], err
}
// inodeOf is the file's inode, 0 where the platform gives none
func inodeOf(info os.FileInfo) uint64 {
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
return stat.Ino
}
return 0
}
// scanLinesCapped is bufio.ScanLines, except that a line longer than // scanLinesCapped is bufio.ScanLines, except that a line longer than
// core.MaxLogEntryBytes continues in the next one, as on the console source: // core.MaxLogEntryBytes continues in the next one, as on the console source:
// the scanner would otherwise stop at it and every poll re-read the lines before. // the scanner would otherwise stop at it and every poll re-read the lines before.
@@ -316,8 +394,8 @@ func scanLinesCapped(data []byte, atEOF bool) (int, []byte, error) {
return advance, token, err return advance, token, err
} }
// initPosition records the file's metadata and, unless the watcher was created // initPosition records the file's metadata and its marks and, unless the
// to read from the start, sets the initial read position to the end. // watcher was created to read from the start, sets the read position to the end.
func (w *fileWatcher) initPosition() error { func (w *fileWatcher) initPosition() error {
file, err := os.Open(w.directory) file, err := os.Open(w.directory)
if err != nil { if err != nil {
@@ -342,41 +420,35 @@ func (w *fileWatcher) initPosition() error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
if w.position == -1 {
pos, err := file.Seek(0, io.SeekEnd)
if err != nil {
return err
}
w.position = pos
}
w.size = info.Size() w.size = info.Size()
w.modTime = info.ModTime() w.modTime = info.ModTime()
if stat, ok := info.Sys().(*syscall.Stat_t); ok { w.inode = inodeOf(info)
w.inode = stat.Ino w.seen = info
if w.position == -1 || w.position > w.size { // a resumed position past the end is not this file's
w.position = w.size
}
w.tail, err = lastLines(file, w.position)
return err
} }
return nil // readTo reports how far this watcher read the given inode, and the lines it
} // read last before there: the file it tails now, as last judged (not one a held
// check has yet to), or the one a rotation renamed out from under it.
// readTo reports how far this watcher read the given inode: the file it tails func (w *fileWatcher) readTo(inode uint64) (int64, []byte, bool) {
// now, or the one a rotation renamed out from under it.
func (w *fileWatcher) readTo(inode uint64) (int64, bool) {
if inode == 0 { if inode == 0 {
return 0, false return 0, nil, false
} }
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
switch inode { switch inode {
case w.inode: case w.inode:
return w.position, true return w.position, w.tail, true
case w.prevInode: case w.prevInode:
return w.prevPosition, true return w.prevPosition, w.prevTail, true
} }
return 0, false return 0, nil, false
} }
// isStopped checks if the watcher has been instructed to stop
func (w *fileWatcher) isStopped() bool { func (w *fileWatcher) isStopped() bool {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()