v0.18.2 resume rotated files instead of replay, keep quiet SSE alive, refuse HEAD on strem path
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/config"
|
||||
@@ -270,6 +271,12 @@ func (fs *FileSource) ensureWatcher(path string) {
|
||||
}
|
||||
|
||||
w := newFileWatcher(path, fs.config.Raw, fs.config.From == "start", fs.publish, fs.logger)
|
||||
// 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
|
||||
// start would re-emit every record the file has already delivered.
|
||||
if position, ok := fs.readPosition(path); ok {
|
||||
w.position = position
|
||||
}
|
||||
fs.watchers[path] = w
|
||||
|
||||
fs.logger.Debug("msg", "Created file watcher",
|
||||
@@ -296,6 +303,25 @@ func (fs *FileSource) ensureWatcher(path string) {
|
||||
}()
|
||||
}
|
||||
|
||||
// readPosition reports how far a running watcher has read the file now at path.
|
||||
// Callers hold fs.mu.
|
||||
func (fs *FileSource) readPosition(path string) (int64, bool) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
for _, w := range fs.watchers {
|
||||
if position, ok := w.readTo(stat.Ino); ok {
|
||||
return position, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// removeWatcher removes only the watcher that finished. A deleted file can be
|
||||
// recreated before its old watcher observes stop; in that case ensureWatcher
|
||||
// has already installed a replacement under the same path, which must survive.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -44,3 +45,40 @@ func TestRemoveWatcherPreservesReplacement(t *testing.T) {
|
||||
t.Fatal("finished watcher was not removed")
|
||||
}
|
||||
}
|
||||
|
||||
// A rotated file reappears under its archive name with the same inode. Its
|
||||
// replacement watcher resumes where the original stopped, so a `from = "start"`
|
||||
// source does not re-emit every record the file already delivered.
|
||||
func TestRotatedFileResumesInsteadOfReplaying(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
active := filepath.Join(dir, "session.jsonl")
|
||||
if err := os.WriteFile(active, []byte("one\ntwo\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(active)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inode := info.Sys().(*syscall.Stat_t).Ino
|
||||
|
||||
archive := filepath.Join(dir, "session_260916_120000.jsonl")
|
||||
if err := os.Rename(active, archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, watcher := range map[string]*fileWatcher{
|
||||
"still tailing the renamed inode": {inode: inode, position: 8},
|
||||
"already moved on from it": {inode: 99, prevInode: inode, prevPosition: 8},
|
||||
} {
|
||||
source := &FileSource{watchers: map[string]*fileWatcher{active: watcher}}
|
||||
position, ok := source.readPosition(archive)
|
||||
if !ok || position != 8 {
|
||||
t.Errorf("%s: position = %d, ok = %v, want 8, true", name, position, ok)
|
||||
}
|
||||
}
|
||||
|
||||
unrelated := &FileSource{watchers: map[string]*fileWatcher{active: {inode: 99}}}
|
||||
if _, ok := unrelated.readPosition(archive); ok {
|
||||
t.Error("a file no watcher has read was treated as rotated")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ type fileWatcher struct {
|
||||
mu sync.Mutex
|
||||
stopped bool
|
||||
rotationSeq int64
|
||||
prevInode uint64
|
||||
prevPosition int64
|
||||
entriesRead atomic.Uint64
|
||||
lastReadTime atomic.Value // time.Time
|
||||
logger *log.Logger
|
||||
@@ -220,6 +222,9 @@ func (w *fileWatcher) checkFile() error {
|
||||
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()
|
||||
@@ -347,6 +352,23 @@ func (w *fileWatcher) initPosition() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// readTo reports how far this watcher read the given inode: the file it tails
|
||||
// now, or the one a rotation renamed out from under it.
|
||||
func (w *fileWatcher) readTo(inode uint64) (int64, bool) {
|
||||
if inode == 0 {
|
||||
return 0, false
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
switch inode {
|
||||
case w.inode:
|
||||
return w.position, true
|
||||
case w.prevInode:
|
||||
return w.prevPosition, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// isStopped checks if the watcher has been instructed to stop
|
||||
func (w *fileWatcher) isStopped() bool {
|
||||
w.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user