filestream with copytruncate strategy loses data after rotation and restart
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 12.7k
- Forks
- 5k
- Avg merge
- 2d 1h
- Merged PRs (30d)
- 370
Description
- Version: main - probably 9+ and 8+ as well.
- Operating System: n/a
- Discuss Forum URL: n/a
The filestream input configured with the copytruncate external rotation strategy fails to ingest all log data after a file rotation event that occurs while Filebeat is not running. An integration test simulating two rotations consistently shows data loss, specifically from the first rotated file (.1 suffix). The issue appears to be in how Filebeat re-evaluates the files after a restart, leading it to miss data from the rotated archives.
A similar test using file_identity.native results in even greater data loss, suggesting the file identity and rotation logic may not be interacting as expected under these conditions. The logic for identifying a continued file versus a new one might be the source of the issue, potentially around this section of the code.
Not setting log rotation strategy and using fingerprint for file identity successfully ingest all 3 files, without data loss or data duplication. The fix might be just to use fingerptint for file identity instead of relying on log rotation strategy.
Steps to Reproduce:
The issue is reproduced via test, see code below. The test simulates a common log rotation scenario where Filebeat is stopped during the rotation process.
- Initial State: A log file (plain.log) is created and partially filled with content ('a' lines).
- First Run: Start Filebeat to ingest the initial content. Filebeat reads to the end of the file.
- First Rotation (while stopped): Stop Filebeat. The active log file has data appended to it, then it is copied to plain.log.1, and plain.log truncated and filled with new content ('b' lines).
- Second Run: Restart Filebeat. It is expected to ingest the rest of plain.log.1 and the new plain.log.
- Second Rotation (while stopped): Stop Filebeat. plain.log.1 is copied to plain.log.2, the data of plain.log is copied to plain.log.1, and plain.log truncated and filled with new content with 'c' lines.
- Final Run: Restart Filebeat. It is expected to process the newly rotated files and the new active file.
Expected Result:
All lines from all three log files ('a', 'b', and 'c') are ingested completely.
Actual Result:
The test fails, indicating that not all data was ingested. The amount of data loss varies depending on the configuration.
Example Configuration used in the test
filebeat.inputs:
- type: filestream
id: "test-filestream"
paths:
- %s
file_identity.native: ~
rotation.external.strategy.copytruncate.suffix_regex: \.\d$
output.file:
enabled: true
path: %s
filename: "%s"
logging.level: debug
Test Code
add the test to filebeat/tests/integration/filestream_copytruncate_test.go, the run with:
cd filebeat
mage buildSystemTestBinary && go test -tags integration -run TestFilestreamGZIPLogRotation_2_rotations$ ./tests/integration
click to see the code
//go:build integration
package integration
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/elastic/beats/v7/libbeat/tests/integration"
)
// TestFilestreamGZIPLogRotation_2_rotations test filebeat experiencing 2 file
// rotations. It simulates 3 log files, 2 of them will be rotated:
// - 1st: 'a' content
// - 2nd: 'b' content
// - 3rd: 'c' content
//
// Filebeat will "see the files" in 3 different moments, by "see" it means,
// filebeat will start with the files written to disk, read then until their end
// and then stop. While filebeat is stopped, the logs are rotated, then filebeat
// is started again.
// This test simulates the following moments:
// - 1st: only one active log file, 1/2 of the content
// - active file: 'a' content, only 1/2 of the logs
// - 2nd: 1st log rotation
// - active file: 'b' content
// - *.1.gz file: 'a' content. Full content
// - 3rd: 2nd log rotation
// - active file: 'c' content
// - *.1.gz file: 'b' content
// - *.2.gz file: 'a' content
func TestFilestreamGZIPLogRotation_2_rotations(t *testing.T) {
want1stRunLines := make([]string, 0, 50)
want2ndRunLines := make([]string, 0, 150)
want3rdRunLines := make([]string, 0, 100)
var dataPlainA1stHalf []byte
for i := range 50 {
l := fmt.Sprintf("%d: 1st 1/2 aaaaaaaaaaaaaaaaaaaaaaaaa", i)
want1stRunLines = append(want1stRunLines, l)
dataPlainA1stHalf = append(dataPlainA1stHalf, []byte(l+"\n")...)
}
var dataPlainA2ndHalf []byte
for i := range 50 {
l := fmt.Sprintf("%d: 2nd 1/2 aaaaaaaaaaaaaaaaaaaaaaaaa", i)
want2ndRunLines = append(want2ndRunLines, l)
dataPlainA2ndHalf = append(dataPlainA2ndHalf, []byte(l+"\n")...)
}
var dataPlainB []byte
for i := range 100 {
l := fmt.Sprintf("%d: bbbbbbbbbbbbbbbbbbbbbbbb", i)
want2ndRunLines = append(want2ndRunLines, l)
dataPlainB = append(dataPlainB, []byte(l+"\n")...)
}
var dataPlainC []byte
for i := range 100 {
l := fmt.Sprintf("%d: cccccccccccccccccccccccccccc", i)
want3rdRunLines = append(want3rdRunLines, l)
dataPlainC = append(dataPlainC, []byte(l+"\n")...)
}
dataGZA := append(dataPlainA1stHalf, dataPlainA2ndHalf...)
dataGZB := dataPlainB
filebeat := integration.NewBeat(
t,
"filebeat",
"../../filebeat.test",
)
tempDir := filebeat.TempDir()
t.Log("temp dir:", tempDir)
logFileBaseName := "plain.log"
logPathActive := filepath.Join(tempDir, logFileBaseName)
logPath1stRotation := filepath.Join(tempDir, logFileBaseName+".1")
logPath2ndRotation := filepath.Join(tempDir, logFileBaseName+".2")
outputFilePattern := "output-file"
cfg := fmt.Sprintf(`
filebeat.inputs:
- type: filestream
id: "test-filestream"
paths:
- %s
file_identity.native: ~
rotation.external.strategy.copytruncate.suffix_regex: \.\d$
output.file:
enabled: true
path: %s
filename: "%s"
logging.level: debug
`, logPathActive+"*", filebeat.TempDir(), outputFilePattern)
filebeat.WriteConfigFile(cfg)
// 1st: only one active log file, 1/2 of the content
err := os.WriteFile(logPathActive, dataPlainA1stHalf, 0644)
require.NoError(t, err, "could not write 'a' file to disk")
filebeat.Start()
eofLine := fmt.Sprintf("End of file reached: %s; Backoff now.", logPathActive)
filebeat.WaitForLogs(
eofLine,
30*time.Second,
"Filebeat did not reach EOF. Did not find log [%s]",
eofLine,
)
filebeat.Stop()
// 2nd: 1st log rotation
// finish writing the 'a' file
f, err := os.OpenFile(logPathActive, os.O_APPEND|os.O_WRONLY, 0644)
require.NoError(t, err, "could not open 'a' file to append")
_, err = f.Write(dataPlainA2ndHalf)
require.NoError(t, err, "could not append to 'a' file")
require.NoError(t, f.Close(), "could not close 'a' file after appending")
// "copy" and gzip the 'a' file
err = os.WriteFile(logPath1stRotation, dataGZA, 0644)
require.NoError(t, err, "could not write gzipped 'a' file")
// truncate active and write 'b' file
err = os.WriteFile(logPathActive, dataPlainB, 0644)
require.NoError(t, err, "could not write 'b' file")
// at this point there is:
// - an active file with 'b' content
// - a '.1.gz' file with the full 'a' content
filebeat.Start()
waitForLatestOutput(t, outputFilePattern, tempDir, len(want2ndRunLines))
// check the output
files := getOutputFilesSorted(t, outputFilePattern, tempDir)
require.Len(t, files, 2, "expected 2 output files")
got, err := os.ReadFile(files[1])
require.NoError(t, err, "could not open output file")
matchPublishedLines(t, got, want2ndRunLines)
filebeat.Stop()
// 3rd: 2nd log rotation
// move '.1.gz' to '.2.gz'
err = os.Rename(logPath1stRotation, logPath2ndRotation)
require.NoError(t, err, "could not move 'a' gzipped file")
// "copy" and gzip the 'b' file
err = os.WriteFile(logPath1stRotation, dataGZB, 0644)
require.NoError(t, err, "could not write gzipped 'b' file")
// truncate active and write 'c' file
err = os.WriteFile(logPathActive, dataPlainC, 0644)
require.NoError(t, err, "could not write 'c' file")
// at this point there is:
// - an active file with 'c' content
// - a '.1.gz' file with 'b' content
// - a '.2.gz' file with 'a' content
filebeat.Start()
waitForLatestOutput(t, outputFilePattern, tempDir, len(want3rdRunLines))
filebeat.Stop()
// So far so good. Now check all the output files
files = getOutputFilesSorted(t, outputFilePattern, tempDir)
got, err = os.ReadFile(files[0])
require.NoError(t, err, "could not open output file")
matchPublishedLines(t, got, want1stRunLines)
got, err = os.ReadFile(files[1])
require.NoError(t, err, "could not open output file")
matchPublishedLines(t, got, want2ndRunLines)
got, err = os.ReadFile(files[2])
require.NoError(t, err, "could not open output file")
matchPublishedLines(t, got, want3rdRunLines)
}
func waitForLatestOutput(t *testing.T, outputFilePattern string, tempDir string, want int) {
// wait for all lines in the output
msg := &strings.Builder{}
var files []string
condition := func() bool {
// writeMsg is used to avoid the message being reset, the function being
// executed, and before the function completes and writes the new
// message, the timeout elapses and the error message is written with an
// empty msg. Whereas it does not completely prevent this scenario, it
// minimises it as much as possible.
writeMsg := func(format string, a ...any) {
msg.Reset()
msg.WriteString(fmt.Sprintf(format, a...))
}
msg.Reset()
files = getOutputFilesSorted(t, outputFilePattern, tempDir)
got, _ := os.ReadFile(files[len(files)-1])
lines := strings.Split(strings.TrimSuffix(string(got), "\n"), "\n")
if len(lines) != want {
writeMsg("want %d lines, got %d",
want, len(lines))
return false
}
return true
}
if !assert.Eventuallyf(t, condition, 60*time.Second, 500*time.Millisecond,
"output file isn't what we expect: %s", msg) {
// call the condition one last time to ensure the msg isn't reset and not
// yet written when it's called as part of the eventually message
condition()
require.Failf(t, "condition never satisfied",
"output file isn't what we expect: %s", msg)
}
}
func getOutputFilesSorted(t *testing.T, outputFilePattern string, tempDir string) []string {
globPattern := outputFilePattern + "-*.ndjson"
files, err := filepath.Glob(filepath.Join(tempDir, globPattern))
require.NoError(t, err, "could not glob output file pattern")
slices.SortFunc(files, func(a, b string) int {
if len(a) < len(b) {
return -1
}
if len(a) > len(b) {
return 1
}
if len(a) == len(b) {
return strings.Compare(a, b)
}
panic("unreachable")
})
return files
}
func matchPublishedLines(t *testing.T, got []byte, want []string) {
gotLinesJSON := strings.Split(strings.TrimSpace(string(got)), "\n")
assert.Equal(t, len(want), len(gotLinesJSON), "unexpected number of events")
gotLines := make([]string, len(gotLinesJSON))
logLine := struct {
Message string `json:"message"`
}{}
for i, line := range gotLinesJSON {
err := json.Unmarshal([]byte(line), &logLine)
require.NoError(t, err, "could not Unmarshal log line")
gotLines[i] = logLine.Message
}
slices.Sort(gotLines)
slices.Sort(want)
assert.Equal(t, want, gotLines, "not all lines match")
}
Logs
- Failure log with rotation.external.strategy.copytruncate.suffix_regex:
Data loss is observed, with only 100 of 150 lines ingested.
=== RUN TestFilestreamGZIPLogRotation_2_rotations
Error: condition never satisfied
Test: TestFilestreamGZIPLogRotation_2_rotations
Messages: output file isn't what we expect: want 150 lines, got 100
- Failure log with file_identity.native:
Data loss is more significant, with only 35 of 150 lines ingested.
=== RUN TestFilestreamGZIPLogRotation_2_rotations
Error: condition never satisfied
Test: TestFilestreamGZIPLogRotation_2_rotations
Messages: output file isn't what we expect: want 150 lines, got 35
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Run the integration test in filebeat/tests/integration/filestream_copytruncate_test.go with the provided mage and go test command. Then inspect copytruncate_prospector.go around lines 341-353 and the file-identity and rotation behavior it exercises. Done means the test ingests every a, b, and c line after both restarts without loss or duplication.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- observability-sre
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100