Skip to content

Commit f5de462

Browse files
committed
test(tail): Add watch-method behavior matrix for the tailer engine
Add engine-level tests that exercise follow, rename/recreate and copytruncate rotation, delete-and-recreate, partial lines, symlinks and offset resume. Each scenario runs under both the native watcher and polling so coverage is independent of the watch implementation. Two combinations the current library does not satisfy are recorded as documented skips: same-length copytruncate under polling, and writes to a symlink target via the native watcher on macOS.
1 parent 0ab75b1 commit f5de462

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package tail
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/influxdata/telegraf/testutil"
13+
)
14+
15+
// These tests exercise the tailer engine itself (follow, rotation, truncation,
16+
// deletion, partial lines, symlinks, offset resume) rather than the parsing
17+
// glue. Each scenario runs under both the native watcher and polling so the
18+
// matrix is independent of the underlying watch implementation. They are meant
19+
// to stay green against the current library and act as an acceptance gate for
20+
// any replacement of it.
21+
22+
func TestTailFollowAppend(t *testing.T) {
23+
runWatchMethods(t, func(t *testing.T, watchMethod string) {
24+
f := filepath.Join(t.TempDir(), "test.log")
25+
require.NoError(t, os.WriteFile(f, []byte("cpu value=1\n"), 0600))
26+
27+
plugin := newBehaviorTail(t, watchMethod, "beginning", f)
28+
var acc testutil.Accumulator
29+
require.NoError(t, plugin.Start(&acc))
30+
defer plugin.Stop()
31+
32+
waitForValues(t, &acc, 1)
33+
34+
appendLine(t, f, "cpu value=2\n")
35+
waitForValues(t, &acc, 1, 2)
36+
})
37+
}
38+
39+
func TestTailRotateRenameRecreate(t *testing.T) {
40+
runWatchMethods(t, func(t *testing.T, watchMethod string) {
41+
f := filepath.Join(t.TempDir(), "test.log")
42+
require.NoError(t, os.WriteFile(f, []byte("cpu value=1\n"), 0600))
43+
44+
plugin := newBehaviorTail(t, watchMethod, "beginning", f)
45+
var acc testutil.Accumulator
46+
require.NoError(t, plugin.Start(&acc))
47+
defer plugin.Stop()
48+
49+
waitForValues(t, &acc, 1)
50+
51+
// Logrotate "create" style: move the active file aside and recreate it.
52+
require.NoError(t, os.Rename(f, f+".1"))
53+
require.NoError(t, os.WriteFile(f, []byte("cpu value=2\n"), 0600))
54+
55+
waitForValues(t, &acc, 1, 2)
56+
})
57+
}
58+
59+
func TestTailRotateCopyTruncate(t *testing.T) {
60+
runWatchMethods(t, func(t *testing.T, watchMethod string) {
61+
if watchMethod == "poll" {
62+
// Known gap in the current library: the polling watcher samples the
63+
// file size every POLL_DURATION (250ms) and only treats a shrink as
64+
// a truncation, so a truncate-and-rewrite back to the same length
65+
// within one interval is never detected and the new content is lost.
66+
// A replacement should detect truncation reliably under polling.
67+
t.Skip("current library misses same-length copytruncate under polling")
68+
}
69+
70+
f := filepath.Join(t.TempDir(), "test.log")
71+
require.NoError(t, os.WriteFile(f, []byte("cpu value=1\n"), 0600))
72+
73+
plugin := newBehaviorTail(t, watchMethod, "beginning", f)
74+
var acc testutil.Accumulator
75+
require.NoError(t, plugin.Start(&acc))
76+
defer plugin.Stop()
77+
78+
waitForValues(t, &acc, 1)
79+
80+
// Logrotate "copytruncate" style: truncate the file in place, then write
81+
// fresh content from offset zero.
82+
require.NoError(t, os.Truncate(f, 0))
83+
appendLine(t, f, "cpu value=2\n")
84+
85+
waitForValues(t, &acc, 1, 2)
86+
})
87+
}
88+
89+
func TestTailDeleteRecreate(t *testing.T) {
90+
runWatchMethods(t, func(t *testing.T, watchMethod string) {
91+
f := filepath.Join(t.TempDir(), "test.log")
92+
require.NoError(t, os.WriteFile(f, []byte("cpu value=1\n"), 0600))
93+
94+
plugin := newBehaviorTail(t, watchMethod, "beginning", f)
95+
var acc testutil.Accumulator
96+
require.NoError(t, plugin.Start(&acc))
97+
defer plugin.Stop()
98+
99+
waitForValues(t, &acc, 1)
100+
101+
// Remove the followed file entirely, then recreate it later.
102+
require.NoError(t, os.Remove(f))
103+
require.NoError(t, os.WriteFile(f, []byte("cpu value=2\n"), 0600))
104+
105+
waitForValues(t, &acc, 1, 2)
106+
})
107+
}
108+
109+
func TestTailPartialLine(t *testing.T) {
110+
runWatchMethods(t, func(t *testing.T, watchMethod string) {
111+
f := filepath.Join(t.TempDir(), "test.log")
112+
// Start with a line that has no trailing newline yet.
113+
require.NoError(t, os.WriteFile(f, []byte("cpu value="), 0600))
114+
115+
plugin := newBehaviorTail(t, watchMethod, "beginning", f)
116+
var acc testutil.Accumulator
117+
require.NoError(t, plugin.Start(&acc))
118+
defer plugin.Stop()
119+
120+
// The line must not be emitted until it is terminated.
121+
appendLine(t, f, "1\n")
122+
waitForValues(t, &acc, 1)
123+
})
124+
}
125+
126+
func TestTailSymlink(t *testing.T) {
127+
if runtime.GOOS == "windows" {
128+
t.Skip("symlink creation requires elevated privileges on Windows")
129+
}
130+
131+
runWatchMethods(t, func(t *testing.T, watchMethod string) {
132+
if watchMethod == "inotify" && runtime.GOOS == "darwin" {
133+
// Known gap in the current library on macOS: the native
134+
// (kqueue/FSEvents) watcher registered on the symlink path does not
135+
// receive events for appends to the symlink target. A replacement
136+
// should follow the resolved target on all platforms.
137+
t.Skip("current library misses target writes via the native watcher on macOS")
138+
}
139+
140+
dir := t.TempDir()
141+
target := filepath.Join(dir, "real.log")
142+
link := filepath.Join(dir, "link.log")
143+
require.NoError(t, os.WriteFile(target, []byte("cpu value=1\n"), 0600))
144+
require.NoError(t, os.Symlink(target, link))
145+
146+
plugin := newBehaviorTail(t, watchMethod, "beginning", link)
147+
var acc testutil.Accumulator
148+
require.NoError(t, plugin.Start(&acc))
149+
defer plugin.Stop()
150+
151+
waitForValues(t, &acc, 1)
152+
153+
appendLine(t, target, "cpu value=2\n")
154+
waitForValues(t, &acc, 1, 2)
155+
})
156+
}
157+
158+
func TestTailResumeOffset(t *testing.T) {
159+
runWatchMethods(t, func(t *testing.T, watchMethod string) {
160+
f := filepath.Join(t.TempDir(), "test.log")
161+
require.NoError(t, os.WriteFile(f, []byte("cpu value=1\ncpu value=2\n"), 0600))
162+
163+
// First run reads the file and persists its offset on stop.
164+
first := newBehaviorTail(t, watchMethod, "saved-or-beginning", f)
165+
var firstAcc testutil.Accumulator
166+
require.NoError(t, first.Start(&firstAcc))
167+
waitForValues(t, &firstAcc, 1, 2)
168+
state := first.GetState()
169+
first.Stop()
170+
171+
// New content arrives while no tailer is running.
172+
appendLine(t, f, "cpu value=3\n")
173+
174+
// Second run resumes from the persisted offset and must only see the
175+
// new line, never re-reading the already-delivered ones.
176+
second := newBehaviorTail(t, watchMethod, "saved-or-beginning", f)
177+
require.NoError(t, second.SetState(state))
178+
var secondAcc testutil.Accumulator
179+
require.NoError(t, second.Start(&secondAcc))
180+
defer second.Stop()
181+
182+
waitForValues(t, &secondAcc, 3)
183+
// Reading is sequential, so if 1 or 2 were going to reappear they would
184+
// arrive before 3; asserting their absence here needs no extra wait.
185+
require.ElementsMatch(t, []float64{3}, tailFieldValues(&secondAcc))
186+
})
187+
}
188+
189+
// runWatchMethods runs fn under each supported watch method so every behavior
190+
// is verified for both the native watcher and polling.
191+
func runWatchMethods(t *testing.T, fn func(t *testing.T, watchMethod string)) {
192+
t.Helper()
193+
methods := []string{"inotify", "poll"}
194+
if runtime.GOOS == "windows" {
195+
methods = []string{"poll"}
196+
}
197+
for _, method := range methods {
198+
t.Run(method, func(t *testing.T) {
199+
fn(t, method)
200+
})
201+
}
202+
}
203+
204+
func newBehaviorTail(t *testing.T, watchMethod, initialReadOffset string, files ...string) *Tail {
205+
t.Helper()
206+
plugin := newTestTail()
207+
plugin.Log = testutil.Logger{}
208+
plugin.WatchMethod = watchMethod
209+
plugin.InitialReadOffset = initialReadOffset
210+
plugin.Files = files
211+
plugin.SetParserFunc(newInfluxParser)
212+
require.NoError(t, plugin.Init())
213+
return plugin
214+
}
215+
216+
func appendLine(t *testing.T, path, line string) {
217+
t.Helper()
218+
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
219+
require.NoError(t, err)
220+
_, err = f.WriteString(line)
221+
require.NoError(t, err)
222+
require.NoError(t, f.Close())
223+
}
224+
225+
func tailFieldValues(acc *testutil.Accumulator) []float64 {
226+
var out []float64
227+
for _, m := range acc.GetTelegrafMetrics() {
228+
if v, ok := m.Fields()["value"]; ok {
229+
if f, ok := v.(float64); ok {
230+
out = append(out, f)
231+
}
232+
}
233+
}
234+
return out
235+
}
236+
237+
func waitForValues(t *testing.T, acc *testutil.Accumulator, want ...float64) {
238+
t.Helper()
239+
require.Eventuallyf(t, func() bool {
240+
got := make(map[float64]bool)
241+
for _, v := range tailFieldValues(acc) {
242+
got[v] = true
243+
}
244+
for _, w := range want {
245+
if !got[w] {
246+
return false
247+
}
248+
}
249+
return true
250+
}, 5*time.Second, 50*time.Millisecond, "did not observe values %v, got %v", want, tailFieldValues(acc))
251+
}

0 commit comments

Comments
 (0)