From 213b6c3107b7a46b034afd7c5219a0473327bacf Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 16 Apr 2026 03:33:08 -0700 Subject: [PATCH] fix(mount): count manifest sizes in merge condition to prevent accumulation (#9101) * fix(mount): count manifest sizes in merge condition to prevent manifest accumulation shouldMergeChunks only counted non-manifest chunk sizes toward the bloat threshold, so overlapping manifests accumulated undetected across flush cycles. During sustained random writes (e.g. fio), each metadata flush compacts non-manifest chunks and may group them into a new manifest via MaybeManifestize, while carrying forward all existing manifests. Since the merge condition only checked non-manifest totals against 2x file size, the growing pile of redundant manifests never triggered a merge. In a real workload: 4 GB file, 25 manifests each covering ~4 GB (107 GB manifest data on volume servers), but shouldMergeChunks saw only 4.2 GB of non-manifest chunks vs the 8.6 GB threshold -- no merge. Fix: include manifest coverage sizes in totalChunkSize. This correctly detects the 111 GB total vs 8.6 GB threshold and triggers the merge, which re-reads the file as clean chunks and lets the filer's MinusChunks (which resolves manifests) garbage-collect all redundant sub-chunks. * test(mount,filer): add manifest chunk correctness tests Filer-level tests (filechunk_manifest_test.go): - TestManifestRoundTripPreservesChunks: create -> serialize -> deserialize preserves fileId, offset, size, and timestamp for all sub-chunks - TestCompactResolvedOverlappingManifests: two manifests covering the same range, older sub-chunks correctly identified as garbage after compaction - TestDoMinusChunksWithResolvedManifests: old manifest sub-chunks detected as garbage when compared against new clean chunks (mirrors filer cleanup) - TestManifestizeSmallBatchWithRemainder: batch=3 with 7 chunks produces 2 manifests + 1 remainder, all data recoverable after resolve - TestCompactMultipleOverlappingManifestGenerations: 5 generations of full-file manifests, compaction keeps only the newest generation - TestManifestBloatDetection: with N overlapping manifests, merge triggers at N >= 2 (stored > 2x file size) Mount-level test (weedfs_file_sync_test.go): - TestFlushCycleManifestAccumulation: simulates the flushMetadataToFiler pipeline over multiple cycles with a small manifest batch (5 chunks). Verifies merge triggers at cycle 2 when accumulated manifests exceed the 2x threshold. Uses SeparateManifestChunks + CompactFileChunks + shouldMergeChunks to mirror the real code path. --- weed/filer/filechunk_manifest_test.go | 393 ++++++++++++++++++++++++++ weed/mount/weedfs_file_sync.go | 7 + weed/mount/weedfs_file_sync_test.go | 115 +++++++- 3 files changed, 508 insertions(+), 7 deletions(-) diff --git a/weed/filer/filechunk_manifest_test.go b/weed/filer/filechunk_manifest_test.go index a03f26a6d..0cf3ef298 100644 --- a/weed/filer/filechunk_manifest_test.go +++ b/weed/filer/filechunk_manifest_test.go @@ -2,10 +2,14 @@ package filer import ( "bytes" + "context" + "fmt" + "io" "math" "testing" "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" ) @@ -111,3 +115,392 @@ func mockMerge(saveFunc SaveDataAsChunkFunctionType, dataChunks []*filer_pb.File return } + +// --------------------------------------------------------------------------- +// In-memory manifest store for round-trip tests +// --------------------------------------------------------------------------- + +// testManifestStore provides in-memory save/resolve for manifest tests +// without requiring volume servers. +type testManifestStore struct { + stored map[string][]byte + nextID int +} + +func newTestManifestStore() *testManifestStore { + return &testManifestStore{stored: make(map[string][]byte)} +} + +func (s *testManifestStore) saveFunc() SaveDataAsChunkFunctionType { + return func(reader io.Reader, name string, offset int64, tsNs int64) (*filer_pb.FileChunk, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + s.nextID++ + chunk := &filer_pb.FileChunk{ + Fid: &filer_pb.FileId{VolumeId: 999, FileKey: uint64(s.nextID), Cookie: 0}, + } + s.stored[chunk.GetFileIdString()] = data + return chunk, nil + } +} + +// resolve expands a single manifest chunk by deserializing the stored proto. +func (s *testManifestStore) resolve(chunk *filer_pb.FileChunk) ([]*filer_pb.FileChunk, error) { + data, ok := s.stored[chunk.GetFileIdString()] + if !ok { + return nil, fmt.Errorf("manifest %q not in store", chunk.GetFileIdString()) + } + m := &filer_pb.FileChunkManifest{} + if err := proto.Unmarshal(data, m); err != nil { + return nil, err + } + filer_pb.AfterEntryDeserialization(m.Chunks) + return m.Chunks, nil +} + +// resolveAll expands all manifest chunks, returns (dataChunks, manifestChunks). +func (s *testManifestStore) resolveAll(chunks []*filer_pb.FileChunk) (data, manifests []*filer_pb.FileChunk, err error) { + for _, c := range chunks { + if !c.IsChunkManifest { + data = append(data, c) + continue + } + manifests = append(manifests, c) + sub, resolveErr := s.resolve(c) + if resolveErr != nil { + return nil, nil, resolveErr + } + data = append(data, sub...) + } + return +} + +func testChunk(volId uint32, key uint64, cookie uint32, offset int64, size uint64, tsNs int64) *filer_pb.FileChunk { + return &filer_pb.FileChunk{ + Fid: &filer_pb.FileId{VolumeId: volId, FileKey: key, Cookie: cookie}, + Offset: offset, + Size: size, + ModifiedTsNs: tsNs, + } +} + +// --------------------------------------------------------------------------- +// Manifest round-trip: create -> serialize -> deserialize -> verify +// --------------------------------------------------------------------------- + +func TestManifestRoundTripPreservesChunks(t *testing.T) { + store := newTestManifestStore() + + chunks := []*filer_pb.FileChunk{ + testChunk(1, 1, 100, 0, 1000, 10), + testChunk(1, 2, 200, 1000, 1000, 20), + testChunk(2, 3, 300, 2000, 1000, 30), + } + + origIds := make([]string, len(chunks)) + for i, c := range chunks { + origIds[i] = c.GetFileIdString() + } + + // Use real mergeIntoManifest with batch=3 so all go into one manifest + result, err := doMaybeManifestize(store.saveFunc(), chunks, 3, mergeIntoManifest) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 || !result[0].IsChunkManifest { + t.Fatalf("expected 1 manifest chunk, got %d chunks", len(result)) + } + if result[0].Offset != 0 || result[0].Size != 3000 { + t.Errorf("manifest coverage: offset=%d size=%d, want 0/3000", result[0].Offset, result[0].Size) + } + + resolved, err := store.resolve(result[0]) + if err != nil { + t.Fatal(err) + } + if len(resolved) != 3 { + t.Fatalf("expected 3 sub-chunks, got %d", len(resolved)) + } + for i, got := range resolved { + if got.GetFileIdString() != origIds[i] { + t.Errorf("chunk %d: fileId %q != %q", i, got.GetFileIdString(), origIds[i]) + } + if got.Offset != chunks[i].Offset { + t.Errorf("chunk %d: offset %d != %d", i, got.Offset, chunks[i].Offset) + } + if got.Size != chunks[i].Size { + t.Errorf("chunk %d: size %d != %d", i, got.Size, chunks[i].Size) + } + if got.ModifiedTsNs != chunks[i].ModifiedTsNs { + t.Errorf("chunk %d: ts %d != %d", i, got.ModifiedTsNs, chunks[i].ModifiedTsNs) + } + } +} + +// --------------------------------------------------------------------------- +// Compact resolved overlapping manifests: older sub-chunks become garbage +// --------------------------------------------------------------------------- + +func TestCompactResolvedOverlappingManifests(t *testing.T) { + store := newTestManifestStore() + + // Batch 1: older writes covering [0, 3000) + batch1 := []*filer_pb.FileChunk{ + testChunk(1, 1, 1, 0, 1000, 1), + testChunk(1, 2, 2, 1000, 1000, 2), + testChunk(1, 3, 3, 2000, 1000, 3), + } + origBatch1Ids := make(map[string]bool) + for _, c := range batch1 { + origBatch1Ids[c.GetFileIdString()] = true + } + + // Batch 2: newer writes covering [0, 3000) + batch2 := []*filer_pb.FileChunk{ + testChunk(2, 1, 1, 0, 1000, 10), + testChunk(2, 2, 2, 1000, 1000, 11), + testChunk(2, 3, 3, 2000, 1000, 12), + } + origBatch2Ids := make(map[string]bool) + for _, c := range batch2 { + origBatch2Ids[c.GetFileIdString()] = true + } + + // Manifestize each batch separately (simulates successive flush cycles) + save := store.saveFunc() + manifest1, err := doMaybeManifestize(save, batch1, 3, mergeIntoManifest) + if err != nil { + t.Fatal(err) + } + manifest2, err := doMaybeManifestize(save, batch2, 3, mergeIntoManifest) + if err != nil { + t.Fatal(err) + } + + // Resolve all manifests into sub-chunks + allChunks := append(manifest1, manifest2...) + dataChunks, _, err := store.resolveAll(allChunks) + if err != nil { + t.Fatal(err) + } + if len(dataChunks) != 6 { + t.Fatalf("expected 6 resolved data chunks, got %d", len(dataChunks)) + } + + // CompactFileChunks on resolved sub-chunks (nil lookupFn is fine for non-manifest chunks) + compacted, garbage := CompactFileChunks(context.Background(), nil, dataChunks) + + if len(compacted) != 3 { + t.Fatalf("expected 3 compacted chunks, got %d", len(compacted)) + } + if len(garbage) != 3 { + t.Fatalf("expected 3 garbage chunks, got %d", len(garbage)) + } + + for _, c := range compacted { + if !origBatch2Ids[c.GetFileIdString()] { + t.Errorf("compacted chunk %q should be from newer batch", c.GetFileIdString()) + } + } + for _, c := range garbage { + if !origBatch1Ids[c.GetFileIdString()] { + t.Errorf("garbage chunk %q should be from older batch", c.GetFileIdString()) + } + } +} + +// --------------------------------------------------------------------------- +// DoMinusChunks: old manifest sub-chunks identified as garbage vs new chunks +// --------------------------------------------------------------------------- + +func TestDoMinusChunksWithResolvedManifests(t *testing.T) { + store := newTestManifestStore() + + // Old entry: data packed into a manifest + oldData := []*filer_pb.FileChunk{ + testChunk(1, 1, 1, 0, 1000, 1), + testChunk(1, 2, 2, 1000, 1000, 2), + testChunk(1, 3, 3, 2000, 1000, 3), + } + oldManifest, err := doMaybeManifestize(store.saveFunc(), oldData, 3, mergeIntoManifest) + if err != nil { + t.Fatal(err) + } + + // Resolve old manifests into sub-chunks (what MinusChunks does internally) + oldResolved, oldMeta, err := store.resolveAll(oldManifest) + if err != nil { + t.Fatal(err) + } + + // New entry: clean re-uploaded chunks (from merge) with different file IDs + newData := []*filer_pb.FileChunk{ + testChunk(3, 10, 10, 0, 1500, 100), + testChunk(3, 11, 11, 1500, 1500, 101), + } + + // DoMinusChunks: data sub-chunks in old but not in new + deltaData := DoMinusChunks(oldResolved, newData) + if len(deltaData) != 3 { + t.Fatalf("expected 3 old data chunks as garbage, got %d", len(deltaData)) + } + + // DoMinusChunks: manifest chunks in old but not in new + deltaMeta := DoMinusChunks(oldMeta, nil) + if len(deltaMeta) != 1 { + t.Fatalf("expected 1 old manifest chunk as garbage, got %d", len(deltaMeta)) + } +} + +// --------------------------------------------------------------------------- +// Small-batch manifestize: verify sub-chunks and remainder handling +// --------------------------------------------------------------------------- + +func TestManifestizeSmallBatchWithRemainder(t *testing.T) { + store := newTestManifestStore() + + // 7 chunks with batch=3: should produce 2 manifests + 1 remainder + chunks := make([]*filer_pb.FileChunk, 7) + for i := range chunks { + chunks[i] = testChunk(1, uint64(i+1), uint32(i+1), int64(i*100), 100, int64(i+1)) + } + + result, err := doMaybeManifestize(store.saveFunc(), chunks, 3, mergeIntoManifest) + if err != nil { + t.Fatal(err) + } + + manifests := 0 + regular := 0 + for _, c := range result { + if c.IsChunkManifest { + manifests++ + } else { + regular++ + } + } + if manifests != 2 { + t.Errorf("expected 2 manifests, got %d", manifests) + } + if regular != 1 { + t.Errorf("expected 1 remainder chunk, got %d", regular) + } + + // Resolve all and verify we get back 7 data chunks + data, _, err := store.resolveAll(result) + if err != nil { + t.Fatal(err) + } + if len(data) != 7 { + t.Fatalf("expected 7 data chunks after resolve, got %d", len(data)) + } +} + +// --------------------------------------------------------------------------- +// Multiple overlapping manifests: compaction identifies all redundant sub-chunks +// --------------------------------------------------------------------------- + +func TestCompactMultipleOverlappingManifestGenerations(t *testing.T) { + store := newTestManifestStore() + save := store.saveFunc() + + const fileSize int64 = 3000 + const chunkSize uint64 = 1000 + + // Simulate 5 generations of full-file writes, each manifestized separately. + var allManifests []*filer_pb.FileChunk + for gen := 0; gen < 5; gen++ { + tsBase := int64((gen + 1) * 100) + batch := []*filer_pb.FileChunk{ + testChunk(uint32(gen+1), 1, uint32(gen), 0, chunkSize, tsBase), + testChunk(uint32(gen+1), 2, uint32(gen), int64(chunkSize), chunkSize, tsBase+1), + testChunk(uint32(gen+1), 3, uint32(gen), int64(chunkSize*2), chunkSize, tsBase+2), + } + manifest, err := doMaybeManifestize(save, batch, 3, mergeIntoManifest) + if err != nil { + t.Fatal(err) + } + allManifests = append(allManifests, manifest...) + } + + if len(allManifests) != 5 { + t.Fatalf("expected 5 manifest chunks, got %d", len(allManifests)) + } + + // Resolve all 5 manifests: 15 data chunks total + dataChunks, _, err := store.resolveAll(allManifests) + if err != nil { + t.Fatal(err) + } + if len(dataChunks) != 15 { + t.Fatalf("expected 15 resolved chunks, got %d", len(dataChunks)) + } + + // Compact: only generation 5 (the newest 3 chunks) should survive + compacted, garbage := CompactFileChunks(context.Background(), nil, dataChunks) + if len(compacted) != 3 { + t.Errorf("expected 3 compacted (gen 5), got %d", len(compacted)) + } + if len(garbage) != 12 { + t.Errorf("expected 12 garbage (gens 1-4), got %d", len(garbage)) + } + + // Verify the surviving chunks are the newest (highest timestamps) + for _, c := range compacted { + if c.ModifiedTsNs < 500 { + t.Errorf("compacted chunk ts=%d should be from gen 5 (ts >= 500)", c.ModifiedTsNs) + } + } +} + +// --------------------------------------------------------------------------- +// Bloat detection: manifest sizes must count toward total +// --------------------------------------------------------------------------- + +func TestManifestBloatDetection(t *testing.T) { + // Simulates what shouldMergeChunks sees after multiple flush cycles. + // With the fix, manifest sizes count toward totalChunkSize, so bloat + // from accumulated manifests is detected. + + const fileSize uint64 = 10000 + + // 1 regular chunk covering the file (from latest compaction) + regular := []*filer_pb.FileChunk{ + {Offset: 0, Size: fileSize, FileId: "latest", ModifiedTsNs: 100, + Fid: &filer_pb.FileId{VolumeId: 1, FileKey: 1}}, + } + + // N manifests each covering the full file (from prior flush cycles) + for n := 1; n <= 10; n++ { + var manifests []*filer_pb.FileChunk + for i := 0; i < n; i++ { + manifests = append(manifests, &filer_pb.FileChunk{ + Offset: 0, Size: fileSize, IsChunkManifest: true, + Fid: &filer_pb.FileId{VolumeId: 900, FileKey: uint64(i + 1)}, + }) + } + + allChunks := append(append([]*filer_pb.FileChunk{}, regular...), manifests...) + var totalStored uint64 + for _, c := range allChunks { + totalStored += c.Size + } + totalFile := TotalSize(allChunks) + + expectMerge := totalStored > 2*totalFile + t.Logf("n=%d manifests: totalStored=%d fileSize=%d ratio=%.1fx expectMerge=%v", + n, totalStored, totalFile, float64(totalStored)/float64(totalFile), expectMerge) + + // With 1 regular (10000) + N manifests (N*10000): + // total = (N+1)*10000, fileSize=10000, ratio = N+1 + // Merge when N+1 > 2, i.e., N >= 2 + if n >= 2 && !expectMerge { + t.Errorf("n=%d: should trigger merge at ratio %.1f", n, float64(totalStored)/float64(totalFile)) + } + if n < 2 && expectMerge { + t.Errorf("n=%d: should NOT trigger merge at ratio %.1f", n, float64(totalStored)/float64(totalFile)) + } + } +} diff --git a/weed/mount/weedfs_file_sync.go b/weed/mount/weedfs_file_sync.go index 0ca7bad57..9a4b205d6 100644 --- a/weed/mount/weedfs_file_sync.go +++ b/weed/mount/weedfs_file_sync.go @@ -268,6 +268,13 @@ func shouldMergeChunks(compactedChunks []*filer_pb.FileChunk, manifestChunks []* for _, chunk := range compactedChunks { totalChunkSize += chunk.Size } + // Count manifest coverage toward stored total. Each manifest holds + // sub-chunks on volume servers that cover approximately Size bytes. + // Without this, overlapping manifests accumulate undetected because + // the merge condition only saw the (small) non-manifest chunk total. + for _, chunk := range manifestChunks { + totalChunkSize += chunk.Size + } allChunks := make([]*filer_pb.FileChunk, 0, len(compactedChunks)+len(manifestChunks)) allChunks = append(allChunks, compactedChunks...) allChunks = append(allChunks, manifestChunks...) diff --git a/weed/mount/weedfs_file_sync_test.go b/weed/mount/weedfs_file_sync_test.go index e5781007a..d9c9b38a3 100644 --- a/weed/mount/weedfs_file_sync_test.go +++ b/weed/mount/weedfs_file_sync_test.go @@ -2,6 +2,7 @@ package mount import ( "context" + "fmt" "math" "math/rand/v2" "testing" @@ -68,7 +69,8 @@ func TestShouldMergeChunks_JustOverDouble(t *testing.T) { } func TestShouldMergeChunks_ManifestExtendFileSize(t *testing.T) { - // Compacted chunks are small relative to file extended by manifest. + // One manifest extends the file. Total stored = 100 (regular) + 900 + // (manifest) = 1000 vs 2*1000 = 2000 → no merge. compacted := []*filer_pb.FileChunk{ {Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1}, } @@ -77,12 +79,13 @@ func TestShouldMergeChunks_ManifestExtendFileSize(t *testing.T) { } _, _, merge := shouldMergeChunks(compacted, manifest) if merge { - t.Fatal("manifest-extended file should not merge when compacted chunks are small") + t.Fatal("single manifest extending file should not merge") } } -func TestShouldMergeChunks_ManifestChunksSizeIgnored(t *testing.T) { - // Only compacted chunk sizes count toward totalChunkSize. +func TestShouldMergeChunks_ManifestSizesCounted(t *testing.T) { + // Manifest sizes must count toward totalChunkSize so that overlapping + // manifests trigger merge. compacted := []*filer_pb.FileChunk{ {Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1}, } @@ -90,11 +93,35 @@ func TestShouldMergeChunks_ManifestChunksSizeIgnored(t *testing.T) { {Offset: 0, Size: 100, FileId: "m", ModifiedTsNs: 2, IsChunkManifest: true}, } total, _, merge := shouldMergeChunks(compacted, manifest) - if total != 100 { - t.Fatalf("totalChunkSize should only count compacted, got %d", total) + if total != 200 { + t.Fatalf("totalChunkSize should count compacted + manifests, got %d", total) } if merge { - t.Fatal("should not merge") + t.Fatal("2x total on 100-byte file should not merge (need >2x)") + } +} + +func TestShouldMergeChunks_AccumulatedManifests(t *testing.T) { + // Simulates the real bug: multiple manifests each covering the full file + // accumulate across flush cycles. Without counting manifest sizes, the + // merge condition never fires and storage bloats indefinitely. + compacted := []*filer_pb.FileChunk{ + {Offset: 0, Size: 1000, FileId: "a", ModifiedTsNs: 100}, + } + // 5 manifests each covering the full file — successive flush cycles + var manifests []*filer_pb.FileChunk + for i := 0; i < 5; i++ { + manifests = append(manifests, &filer_pb.FileChunk{ + Offset: 0, Size: 1000, FileId: string(rune('m' + i)), + IsChunkManifest: true, + }) + } + total, fileSize, merge := shouldMergeChunks(compacted, manifests) + // total = 1000 (regular) + 5*1000 (manifests) = 6000 + // fileSize = 1000 + // 6000 > 2*1000 → merge + if !merge { + t.Fatalf("accumulated overlapping manifests should trigger merge (total=%d fileSize=%d)", total, fileSize) } } @@ -279,6 +306,80 @@ func TestRandomWritesBloatDetection(t *testing.T) { } } +// TestFlushCycleManifestAccumulation simulates the flushMetadataToFiler +// pipeline over multiple cycles with a small manifest batch threshold. +// Each cycle adds a full pass of random writes, compacts, and manifestizes. +// Verifies that shouldMergeChunks detects manifest bloat within a few cycles. +func TestFlushCycleManifestAccumulation(t *testing.T) { + const ( + fileSize int64 = 10000 + chunkSize uint64 = 1000 + numSlots = int(fileSize / int64(chunkSize)) // 10 offsets + batchSize = 5 // small batch to trigger manifestize + ) + + var entryChunks []*filer_pb.FileChunk + nextTs := int64(1) + nextKey := uint64(1) + + for cycle := 0; cycle < 20; cycle++ { + // Each cycle: new writes at every offset (simulates a full random-write pass) + for slot := 0; slot < numSlots; slot++ { + entryChunks = append(entryChunks, &filer_pb.FileChunk{ + Offset: int64(slot) * int64(chunkSize), Size: chunkSize, + FileId: fmt.Sprintf("%d,%x00000000", cycle+1, nextKey), + ModifiedTsNs: nextTs, + Fid: &filer_pb.FileId{VolumeId: uint32(cycle + 1), FileKey: nextKey, Cookie: 0}, + }) + nextTs++ + nextKey++ + } + + // --- flush pipeline (mirrors flushMetadataToFiler) --- + manifestChunks, nonManifestChunks := filer.SeparateManifestChunks(entryChunks) + compacted, _ := filer.CompactFileChunks(context.Background(), nil, nonManifestChunks) + + _, _, merge := shouldMergeChunks(compacted, manifestChunks) + if merge { + t.Logf("cycle %d: merge correctly triggered (%d manifests, %d regular chunks)", + cycle, len(manifestChunks), len(compacted)) + return + } + + // Simulate MaybeManifestize with small batch: + // pack groups of batchSize regular chunks into manifest chunks. + numPacked := len(compacted) / batchSize + var newEntry []*filer_pb.FileChunk + newEntry = append(newEntry, manifestChunks...) // carry forward old manifests + + for i := 0; i < numPacked; i++ { + batch := compacted[i*batchSize : (i+1)*batchSize] + var minOff int64 = math.MaxInt64 + var maxEnd int64 = 0 + for _, c := range batch { + if c.Offset < minOff { + minOff = c.Offset + } + if end := c.Offset + int64(c.Size); end > maxEnd { + maxEnd = end + } + } + nextKey++ + newEntry = append(newEntry, &filer_pb.FileChunk{ + Offset: minOff, Size: uint64(maxEnd - minOff), + FileId: fmt.Sprintf("900,%x00000000", nextKey), + IsChunkManifest: true, + Fid: &filer_pb.FileId{VolumeId: 900 + uint32(cycle), FileKey: nextKey, Cookie: 0}, + }) + } + // Leftover regular chunks that didn't fill a batch + remainder := compacted[numPacked*batchSize:] + newEntry = append(newEntry, remainder...) + entryChunks = newEntry + } + t.Fatal("merge never triggered after 20 flush cycles") +} + // TestVisibleContentPreservedAfterCompact verifies that CompactFileChunks // never drops a chunk that contributes visible bytes. This is the invariant // that maybeMergeChunks relies on: the compacted set covers the same logical