Skip to content

Commit baa8608

Browse files
committed
filestream: encode bridging raw header only for dedup winners
In growing-fingerprint mode, fileScanner.toFileDescriptor hex-encoded the raw fingerprint window for every complete file on every scan. The completedFingerprints suppression set is rebuilt by fileWatcher.watch from the post-dedup result only, so dedup losers can never enter it. Every duplicate therefore re-read and re-encoded its ~2*length-byte raw header on every scan forever, even though its descriptor is discarded immediately at the dedup check. Move the bridging-raw encode out of toFileDescriptor into a new attachBridgingRaw helper that GetFiles calls only after the dedup-winner decision. Duplicates never pay the encode, not even on their first scan. Behavior for the winner is byte-identical to before: dedup uses FileID() and every consumer of Fingerprint.Raw on a complete descriptor only ever sees post-dedup descriptors. The bridging header is still attached on the scan a file crosses the threshold, so rename/growth prefix matching is unchanged.
1 parent d884560 commit baa8608

2 files changed

Lines changed: 181 additions & 51 deletions

File tree

filebeat/input/filestream/fswatch.go

Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -610,14 +610,9 @@ type fileScanner struct {
610610
hasher hash.Hash
611611
readBuffer []byte
612612
compression string
613-
// completedFingerprints holds the paths whose fingerprint was already a
614-
// final SHA-256 on the previous watch-loop scan (growing mode only). The
615-
// bridging raw header is only useful on the scan a file crosses the
616-
// threshold, so for paths in this set toFileDescriptor skips recomputing it.
617-
// GetFiles itself is pure with respect to this set: only fileWatcher.watch
618-
// advances it (after each scan), so the enumeration-only scans the
619-
// prospector runs in Init/TakeOver cannot suppress the header a
620-
// still-growing entry needs to migrate across a restart.
613+
// completedFingerprints holds paths already complete on the previous watch scan
614+
// (growing mode), so attachBridgingRaw can skip re-encoding their bridging header.
615+
// Only fileWatcher.watch advances it, so prospector enumeration can't wrongly suppress it.
621616
completedFingerprints map[string]struct{}
622617

623618
// lastCount is the number of unique files the previous scan produced.
@@ -772,6 +767,7 @@ func (s *fileScanner) GetFiles(opts loginp.FileScanOptions) (map[string]loginp.F
772767
continue
773768
}
774769
uniqueIDs[fileID] = fd.Filename
770+
s.attachBridgingRaw(&fd)
775771
fdByName[filename] = fd
776772
if isFileIgnored(fd, opts) {
777773
scanMetrics.FilesIgnored++
@@ -863,10 +859,8 @@ func (s *fileScanner) getIngestTarget(filename string) (it ingestTarget, err err
863859
// - dataSize <= offset: file is too small to read anything from offset;
864860
// return errFileTooSmall.
865861
// - dataSize >= offset+length: read bytes[offset:offset+length] and hash
866-
// with SHA-256 (FingerprintID.Sum, so Complete() is true). In growing mode
867-
// the raw header bytes are also carried in FingerprintID.Raw so the one-time
868-
// crossing to the SHA-256 identity can be prefix-matched against a still
869-
// growing predecessor.
862+
// with SHA-256 (FingerprintID.Sum, so Complete() is true). The growing-mode
863+
// bridging raw header is added later by attachBridgingRaw, not here.
870864
// - dataSize in (offset, offset+length) under growing mode: read
871865
// bytes[offset:dataSize] and carry its hex as FingerprintID.Raw, leaving
872866
// Sum empty so Complete() is false.
@@ -1016,23 +1010,20 @@ func (s *fileScanner) toFileDescriptor(it *ingestTarget) (fd loginp.FileDescript
10161010
Sum: hex.EncodeToString(s.hasher.Sum(nil)),
10171011
}
10181012

1019-
// In growing mode the raw header is carried alongside the SHA-256 so the
1020-
// one-time transition to the final identity can be prefix-matched against a
1021-
// still-growing predecessor (in-place growth or rename+grow). It is only
1022-
// needed on the scan a file crosses the threshold: a path the watch loop
1023-
// already saw complete on its previous scan has no growing predecessor left
1024-
// to bridge, so recomputing the ~2*length-byte hex header every scan would
1025-
// be wasted work. New and just-crossed paths are absent from
1026-
// completedFingerprints and get the bridging header.
1027-
if s.cfg.Fingerprint.Growing {
1028-
if _, done := s.completedFingerprints[it.filename]; !done {
1029-
fd.Fingerprint.Raw = hex.EncodeToString(s.readBuffer[:length])
1030-
}
1031-
}
1032-
10331013
return fd, nil
10341014
}
10351015

1016+
// attachBridgingRaw sets a complete descriptor's raw header.
1017+
func (s *fileScanner) attachBridgingRaw(fd *loginp.FileDescriptor) {
1018+
if !s.cfg.Fingerprint.Growing || !fd.Fingerprint.Complete() {
1019+
return
1020+
}
1021+
if _, done := s.completedFingerprints[fd.Filename]; done {
1022+
return
1023+
}
1024+
fd.Fingerprint.Raw = hex.EncodeToString(s.readBuffer[:s.cfg.Fingerprint.Length])
1025+
}
1026+
10361027
func (s *fileScanner) isFileExcluded(file string) bool {
10371028
return len(s.cfg.ExcludedFiles) > 0 && s.matchAny(s.cfg.ExcludedFiles, file)
10381029
}

filebeat/input/filestream/fswatch_test.go

Lines changed: 164 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1572,6 +1572,18 @@ func writeBenchmarkFiles(tb testing.TB, dir string, n int) []string {
15721572
return []string{filepath.Join(dir, "*.log")}
15731573
}
15741574

1575+
// writeBenchmarkDuplicateFiles writes n identical-content files, so every file
1576+
// collides on one fingerprint and GetFiles dedups them to a single winner.
1577+
func writeBenchmarkDuplicateFiles(tb testing.TB, dir string, n int) []string {
1578+
tb.Helper()
1579+
content := []byte(strings.Repeat("duplicate-content\n", 1024))
1580+
for i := range n {
1581+
filename := filepath.Join(dir, fmt.Sprintf("file-%d.log", i))
1582+
require.NoError(tb, os.WriteFile(filename, content, 0o644))
1583+
}
1584+
return []string{filepath.Join(dir, "*.log")}
1585+
}
1586+
15751587
func BenchmarkGetFiles(b *testing.B) {
15761588
paths := writeBenchmarkFiles(b, b.TempDir(), benchmarkFileCount)
15771589
cfg := fileScannerConfig{
@@ -1621,17 +1633,30 @@ func BenchmarkGetFilesWithFingerprint(b *testing.B) {
16211633
// cost — exactly the work the watch loop elides on stable files by tracking
16221634
// completed paths. It therefore quantifies the per-scan cost that suppression
16231635
// removes after a file's first crossing scan.
1636+
//
1637+
// The "duplicates" case gives growing mode identical-content files: they dedup to
1638+
// one winner, so only that winner encodes the bridging raw header.
16241639
func BenchmarkGetFilesWithFingerprintGrowing(b *testing.B) {
16251640
cases := []struct {
1626-
name string
1627-
growing bool
1641+
name string
1642+
growing bool
1643+
duplicates bool
16281644
}{
1629-
{"static", false},
1630-
{"growing", true},
1645+
{"static", false, false},
1646+
{"growing", true, false},
1647+
{"duplicates", true, true},
16311648
}
16321649
for _, tc := range cases {
16331650
b.Run(tc.name, func(b *testing.B) {
1634-
paths := writeBenchmarkFiles(b, b.TempDir(), benchmarkFileCount)
1651+
dir := b.TempDir()
1652+
var paths []string
1653+
wantLen := benchmarkFileCount
1654+
if tc.duplicates {
1655+
paths = writeBenchmarkDuplicateFiles(b, dir, benchmarkFileCount)
1656+
wantLen = 1
1657+
} else {
1658+
paths = writeBenchmarkFiles(b, dir, benchmarkFileCount)
1659+
}
16351660
cfg := fileScannerConfig{
16361661
Fingerprint: fingerprintConfig{
16371662
Enabled: true,
@@ -1646,7 +1671,7 @@ func BenchmarkGetFilesWithFingerprintGrowing(b *testing.B) {
16461671
b.ResetTimer()
16471672
for i := 0; i < b.N; i++ {
16481673
files, _ := s.GetFiles(loginp.FileScanOptions{})
1649-
require.Len(b, files, benchmarkFileCount)
1674+
require.Len(b, files, wantLen)
16501675
}
16511676
})
16521677
}
@@ -1895,13 +1920,10 @@ func TestToFileDescriptor_TooSmallFile_NoFileOpen(t *testing.T) {
18951920
//
18961921
// 1. small file (below offset+length) under growing mode → raw-hex
18971922
// FingerprintID.Raw, Complete=false, no Sum.
1898-
// 2. file grows past threshold → SHA-256 Sum matching the static fingerprint
1899-
// output for the same bytes, Complete=true, and the raw header carried in
1900-
// Raw (so the crossing can be prefix-matched against the predecessor).
1901-
// 3. second scan still at threshold via toFileDescriptor → identical
1902-
// FingerprintID. The per-scan raw-header suppression lives in GetFiles
1903-
// (exercised by TestGetFiles_GrowingRawSuppression), so a direct
1904-
// toFileDescriptor call always recomputes Raw.
1923+
// 2. file grows past threshold → SHA-256 Sum, Complete=true, Raw empty;
1924+
// attachBridgingRaw (mirrored here) then adds the bridging header.
1925+
// 3. second scan at threshold → same Sum and empty Raw; attachBridgingRaw
1926+
// re-adds the bridging header identically.
19051927
// 4. file truncated back below threshold → raw-hex Raw, Complete=false.
19061928
func TestToFileDescriptor_GrowingLifecycle(t *testing.T) {
19071929
dir := t.TempDir()
@@ -1959,8 +1981,13 @@ func TestToFileDescriptor_GrowingLifecycle(t *testing.T) {
19591981
expectedSHA := sha256.Sum256([]byte(strings.Repeat("abcd", 256)[:length]))
19601982
assert.Equal(t, hex.EncodeToString(expectedSHA[:]), fd2.Fingerprint.Sum,
19611983
"step 2: Sum must be SHA-256 of bytes[0:length]")
1984+
assert.Empty(t, fd2.Fingerprint.Raw,
1985+
"step 2: toFileDescriptor alone leaves Raw empty (bridging header attached post-dedup)")
1986+
1987+
// Mirror GetFiles: attach the bridging header to the winner descriptor.
1988+
s.attachBridgingRaw(&fd2)
19621989
assert.Equal(t, expectedRawHex, fd2.Fingerprint.Raw,
1963-
"step 2: Raw must be raw hex of the same bytes for bridging the transition")
1990+
"step 2: attachBridgingRaw must carry raw hex of the same bytes for bridging the transition")
19641991

19651992
// Verify the threshold-transition prefix relationship: the previous raw-hex
19661993
// (200 bytes) is a prefix of the current completed Raw (1024 bytes). This is
@@ -1975,9 +2002,16 @@ func TestToFileDescriptor_GrowingLifecycle(t *testing.T) {
19752002
fd3, err := s.toFileDescriptor(&it)
19762003
require.NoError(t, err, "toFileDescriptor failed")
19772004

2005+
assert.True(t, fd3.Fingerprint.Complete(), "step 3: still Complete at threshold")
2006+
assert.Equal(t, fd2.Fingerprint.Sum, fd3.Fingerprint.Sum,
2007+
"step 3: Sum is stable across scans")
2008+
assert.Empty(t, fd3.Fingerprint.Raw,
2009+
"step 3: toFileDescriptor alone leaves Raw empty; the bridging header is attached post-dedup")
2010+
2011+
// After attaching the bridging header the descriptor matches the crossing scan.
2012+
s.attachBridgingRaw(&fd3)
19782013
assert.Equal(t, fd2.Fingerprint, fd3.Fingerprint,
1979-
"step 3: direct toFileDescriptor recomputes the same FingerprintID "+
1980-
"(GetFiles-level Raw suppression does not apply here)")
2014+
"step 3: attachBridgingRaw reproduces the same FingerprintID as the crossing scan")
19812015

19822016
// --- Step 4: file truncated back below threshold ---
19832017
writeFile(t, 100)
@@ -1995,6 +2029,18 @@ func TestToFileDescriptor_GrowingLifecycle(t *testing.T) {
19952029
"step 4: no SHA-256 Sum while below threshold")
19962030
}
19972031

2032+
// advanceCompletedFingerprints mirrors fileWatcher.watch: it seeds completedFingerprints
2033+
// from a scan result so the next scan suppresses stable files' bridging headers.
2034+
func advanceCompletedFingerprints(s *fileScanner, files map[string]loginp.FileDescriptor) {
2035+
completed := map[string]struct{}{}
2036+
for p, fd := range files {
2037+
if fd.Fingerprint.Complete() {
2038+
completed[p] = struct{}{}
2039+
}
2040+
}
2041+
s.completedFingerprints = completed
2042+
}
2043+
19982044
// TestGetFiles_GrowingRawSuppression verifies the optimization that avoids
19992045
// recomputing the bridging raw header for files that are already complete: the
20002046
// header is emitted on the scan a file crosses the threshold (so the transition
@@ -2032,15 +2078,7 @@ func TestGetFiles_GrowingRawSuppression(t *testing.T) {
20322078
files, _ := s.GetFiles(loginp.FileScanOptions{})
20332079
require.Contains(t, files, filename, "file must be scanned")
20342080
fp := files[filename].Fingerprint
2035-
// Mirror the watch loop: tell the scanner which paths are now complete
2036-
// so the next scan can skip recomputing their bridging raw header.
2037-
completed := map[string]struct{}{}
2038-
for p, fd := range files {
2039-
if fd.Fingerprint.Complete() {
2040-
completed[p] = struct{}{}
2041-
}
2042-
}
2043-
s.completedFingerprints = completed
2081+
advanceCompletedFingerprints(s, files)
20442082
return fp
20452083
}
20462084

@@ -2079,6 +2117,107 @@ func TestGetFiles_GrowingRawSuppression(t *testing.T) {
20792117
assert.NotEmpty(t, fp.Raw, "re-crossing must carry the bridging raw header again")
20802118
}
20812119

2120+
// TestGetFiles_DuplicateFingerprint checks that among files sharing a fingerprint, only
2121+
// the dedup winner carries the bridging raw header, including after the winner is deleted.
2122+
func TestGetFiles_DuplicateFingerprint(t *testing.T) {
2123+
dir := t.TempDir()
2124+
const length int64 = 1024
2125+
const n = 3
2126+
2127+
// Identical, above-threshold content: every file collides on the same Sum.
2128+
content := []byte(strings.Repeat("abcd", 512)) // 2048 bytes >= length
2129+
for i := range n {
2130+
fn := filepath.Join(dir, fmt.Sprintf("dup-%d.log", i))
2131+
require.NoError(t, os.WriteFile(fn, content, 0o644),
2132+
"could not write duplicate file")
2133+
}
2134+
2135+
cfg := fileScannerConfig{
2136+
Fingerprint: fingerprintConfig{
2137+
Enabled: true,
2138+
Offset: 0,
2139+
Length: length,
2140+
Growing: true,
2141+
},
2142+
}
2143+
s, err := newFileScanner(
2144+
logp.NewNopLogger(), []string{filepath.Join(dir, "*.log")}, cfg, CompressionNone)
2145+
require.NoError(t, err, "could not create file scanner")
2146+
2147+
expectedRawHex := hex.EncodeToString(content[:length])
2148+
2149+
scan := func() map[string]loginp.FileDescriptor {
2150+
files, _ := s.GetFiles(loginp.FileScanOptions{})
2151+
advanceCompletedFingerprints(s, files)
2152+
return files
2153+
}
2154+
2155+
// First scan: duplicates dedup to one winner, which carries the bridging header.
2156+
files := scan()
2157+
require.Len(t, files, 1, "duplicate-fingerprint files must dedup to a single winner")
2158+
var winner string
2159+
for p, fd := range files {
2160+
winner = p
2161+
require.True(t, fd.Fingerprint.Complete(), "winner must be Complete")
2162+
assert.Equal(t, expectedRawHex, fd.Fingerprint.Raw,
2163+
"first scan: winner carries the bridging raw header")
2164+
}
2165+
2166+
// Second scan: winner now in completedFingerprints, so its header is suppressed.
2167+
files = scan()
2168+
require.Len(t, files, 1, "still a single winner")
2169+
require.Contains(t, files, winner, "same winner across stable scans")
2170+
assert.Empty(t, files[winner].Fingerprint.Raw,
2171+
"second scan: winner's bridging raw header is suppressed once complete")
2172+
2173+
// Delete the winner: a promoted loser (never in the completed set) carries the header.
2174+
require.NoError(t, os.Remove(winner), "could not remove the winner file")
2175+
files = scan()
2176+
require.Len(t, files, 1, "still a single winner after deleting the previous one")
2177+
require.NotContains(t, files, winner, "deleted winner must be gone")
2178+
for _, fd := range files {
2179+
require.True(t, fd.Fingerprint.Complete(), "promoted winner must be Complete")
2180+
assert.Equal(t, expectedRawHex, fd.Fingerprint.Raw,
2181+
"promoted loser must carry the bridging raw header (never in completed set)")
2182+
}
2183+
}
2184+
2185+
// TestGetFiles_DuplicateFingerprintAllocBudget guards against the per-duplicate encode
2186+
// returning. The waste is invisible to a behavior test (the loser's Raw is discarded
2187+
// unread), so it bounds allocations: for above-threshold files the only growing-vs-static
2188+
// difference is that encode, so the delta is ~2 post-fix but ~2*benchmarkFileCount otherwise.
2189+
func TestGetFiles_DuplicateFingerprintAllocBudget(t *testing.T) {
2190+
dir := t.TempDir()
2191+
glob := writeBenchmarkDuplicateFiles(t, dir, benchmarkFileCount)
2192+
2193+
measure := func(growing bool) float64 {
2194+
cfg := fileScannerConfig{Fingerprint: fingerprintConfig{
2195+
Enabled: true, Offset: 0, Length: 1024, Growing: growing,
2196+
}}
2197+
s, err := newFileScanner(logp.NewNopLogger(), glob, cfg, CompressionNone)
2198+
require.NoError(t, err, "could not create file scanner")
2199+
2200+
files, _ := s.GetFiles(loginp.FileScanOptions{})
2201+
require.Len(t, files, 1, "duplicates must dedup to a single winner")
2202+
2203+
// completedFingerprints is never advanced, so the winner re-encodes on every run.
2204+
return testing.AllocsPerRun(10, func() {
2205+
_, _ = s.GetFiles(loginp.FileScanOptions{})
2206+
})
2207+
}
2208+
2209+
growingAllocs := measure(true)
2210+
staticAllocs := measure(false)
2211+
delta := growingAllocs - staticAllocs
2212+
2213+
// Budget one alloc per file: far below a regression's ~2*benchmarkFileCount, above noise.
2214+
const budget = float64(benchmarkFileCount)
2215+
assert.Less(t, delta, budget,
2216+
"growing mode must not re-encode the bridging raw header per dropped "+
2217+
"duplicate: growing=%.0f static=%.0f delta=%.0f allocs exceeds "+
2218+
"budget=%.0f", growingAllocs, staticAllocs, delta, budget)
2219+
}
2220+
20822221
func BenchmarkToFileDescriptor(b *testing.B) {
20832222
dir := b.TempDir()
20842223
basename := "created.log"

0 commit comments

Comments
 (0)