@@ -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+
15751587func 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.
16241639func 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.
19061928func 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+
20822221func BenchmarkToFileDescriptor (b * testing.B ) {
20832222 dir := b .TempDir ()
20842223 basename := "created.log"
0 commit comments