Files
seaweedfs/weed/mount/weedfs_file_sync_test.go
T
Chris Lu 0f1ec8983d mount: don't fail close() on a benign FUSE interrupt (#10102)
A FUSE interrupt is not a process kill. Go's async preemption (SIGURG)
makes a close() under load emit an interrupt on nearly every flush, so
deriving the metadata-flush context from the FUSE cancel channel turned
healthy concurrent close()s into EIO: the interrupt cancelled the
in-flight CreateEntry, which surfaced as "input/output error".

Bound the flush with a deadline instead. A healthy CreateEntry finishes
in well under a second, so the deadline only fires against a genuinely
stuck filer -- still keeping close() from hanging forever -- while
benign preemption no longer aborts a good flush.
2026-06-24 19:54:03 -07:00

445 lines
15 KiB
Go

package mount
import (
"context"
"fmt"
"math"
"math/rand/v2"
"testing"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
func TestShouldMergeChunks_NoChunks(t *testing.T) {
_, _, merge := shouldMergeChunks(nil, nil)
if merge {
t.Fatal("empty file should not trigger merge")
}
}
func TestShouldMergeChunks_SingleChunk(t *testing.T) {
chunks := []*filer_pb.FileChunk{
{Offset: 0, Size: 1000, FileId: "a", ModifiedTsNs: 1},
}
total, fileSize, merge := shouldMergeChunks(chunks, nil)
if merge {
t.Fatalf("single chunk should not merge (total=%d fileSize=%d)", total, fileSize)
}
}
func TestShouldMergeChunks_NonOverlapping(t *testing.T) {
chunks := []*filer_pb.FileChunk{
{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1},
{Offset: 100, Size: 100, FileId: "b", ModifiedTsNs: 2},
{Offset: 200, Size: 100, FileId: "c", ModifiedTsNs: 3},
}
total, fileSize, merge := shouldMergeChunks(chunks, nil)
if merge {
t.Fatalf("non-overlapping chunks should not merge (total=%d fileSize=%d)", total, fileSize)
}
if total != 300 || fileSize != 300 {
t.Fatalf("expected total=300 fileSize=300, got total=%d fileSize=%d", total, fileSize)
}
}
func TestShouldMergeChunks_ExactlyDouble(t *testing.T) {
// totalChunkSize == 2 * fileSize: should NOT merge (condition is >2x)
chunks := []*filer_pb.FileChunk{
{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1},
{Offset: 0, Size: 100, FileId: "b", ModifiedTsNs: 2},
}
_, _, merge := shouldMergeChunks(chunks, nil)
if merge {
t.Fatal("exactly 2x should not trigger merge")
}
}
func TestShouldMergeChunks_JustOverDouble(t *testing.T) {
// Three chunks overwriting the same range: 300 stored, 100 content = 3x
chunks := []*filer_pb.FileChunk{
{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1},
{Offset: 0, Size: 100, FileId: "b", ModifiedTsNs: 2},
{Offset: 0, Size: 100, FileId: "c", ModifiedTsNs: 3},
}
total, fileSize, merge := shouldMergeChunks(chunks, nil)
if !merge {
t.Fatalf("3x bloat should trigger merge (total=%d fileSize=%d)", total, fileSize)
}
}
func TestShouldMergeChunks_ManifestExtendFileSize(t *testing.T) {
// 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},
}
manifest := []*filer_pb.FileChunk{
{Offset: 100, Size: 900, FileId: "m", ModifiedTsNs: 2, IsChunkManifest: true},
}
_, _, merge := shouldMergeChunks(compacted, manifest)
if merge {
t.Fatal("single manifest extending file should not merge")
}
}
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},
}
manifest := []*filer_pb.FileChunk{
{Offset: 0, Size: 100, FileId: "m", ModifiedTsNs: 2, IsChunkManifest: true},
}
total, _, merge := shouldMergeChunks(compacted, manifest)
if total != 200 {
t.Fatalf("totalChunkSize should count compacted + manifests, got %d", total)
}
if 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)
}
}
func TestShouldMergeChunks_DoesNotMutateInput(t *testing.T) {
compacted := make([]*filer_pb.FileChunk, 2, 4) // extra capacity
compacted[0] = &filer_pb.FileChunk{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1}
compacted[1] = &filer_pb.FileChunk{Offset: 0, Size: 100, FileId: "b", ModifiedTsNs: 2}
manifest := []*filer_pb.FileChunk{
{Offset: 100, Size: 100, FileId: "m", ModifiedTsNs: 3, IsChunkManifest: true},
}
lenBefore := len(compacted)
shouldMergeChunks(compacted, manifest)
if len(compacted) != lenBefore {
t.Fatalf("shouldMergeChunks mutated compactedChunks: len changed from %d to %d",
lenBefore, len(compacted))
}
}
// TestCompactThenMergeCondition verifies that after CompactFileChunks
// removes fully-superseded chunks, the merge condition correctly detects
// remaining bloat from partially-overlapping chunks.
func TestCompactThenMergeCondition(t *testing.T) {
tests := []struct {
name string
chunks []*filer_pb.FileChunk
wantMerge bool
}{
{
name: "fully superseded chunks removed, no bloat",
chunks: []*filer_pb.FileChunk{
{Offset: 0, Size: 100, FileId: "old", ModifiedTsNs: 1, Fid: &filer_pb.FileId{FileKey: 1}},
{Offset: 0, Size: 100, FileId: "new", ModifiedTsNs: 2, Fid: &filer_pb.FileId{FileKey: 2}},
},
wantMerge: false,
},
{
name: "repeated full overwrites all compacted away",
chunks: []*filer_pb.FileChunk{
{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1, Fid: &filer_pb.FileId{FileKey: 1}},
{Offset: 0, Size: 100, FileId: "b", ModifiedTsNs: 2, Fid: &filer_pb.FileId{FileKey: 2}},
{Offset: 0, Size: 100, FileId: "c", ModifiedTsNs: 3, Fid: &filer_pb.FileId{FileKey: 3}},
{Offset: 0, Size: 100, FileId: "d", ModifiedTsNs: 4, Fid: &filer_pb.FileId{FileKey: 4}},
},
wantMerge: false,
},
{
name: "non-overlapping small chunks, no bloat",
chunks: func() []*filer_pb.FileChunk {
var c []*filer_pb.FileChunk
for i := 0; i < 10; i++ {
c = append(c, &filer_pb.FileChunk{
Offset: int64(i * 10),
Size: 10,
FileId: string(rune('a' + i)),
ModifiedTsNs: int64(i + 1),
Fid: &filer_pb.FileId{FileKey: uint64(i)},
})
}
return c
}(),
wantMerge: false,
},
{
name: "staggered partial overlaps create bloat",
// 100-byte base + three 40-byte writes at staggered offsets.
// All 4 survive compact (each partially visible).
// total = 100+40+40+40 = 220, file = 100, ratio = 2.2.
chunks: []*filer_pb.FileChunk{
{Offset: 0, Size: 100, FileId: "orig", ModifiedTsNs: 1, Fid: &filer_pb.FileId{FileKey: 1}},
{Offset: 10, Size: 40, FileId: "w1", ModifiedTsNs: 2, Fid: &filer_pb.FileId{FileKey: 2}},
{Offset: 30, Size: 40, FileId: "w2", ModifiedTsNs: 3, Fid: &filer_pb.FileId{FileKey: 3}},
{Offset: 50, Size: 40, FileId: "w3", ModifiedTsNs: 4, Fid: &filer_pb.FileId{FileKey: 4}},
},
wantMerge: true,
},
{
name: "overlapping writes with 75pct overlap",
// Writes of size 100 at step 25 -> each supersedes 75% of the
// previous write, all survive compact because the leading 25
// bytes are still visible.
// total = N*100, file ~ N*25+75. Ratio approaches 4x.
chunks: func() []*filer_pb.FileChunk {
var c []*filer_pb.FileChunk
for i := 0; i < 20; i++ {
c = append(c, &filer_pb.FileChunk{
Offset: int64(i * 25),
Size: 100,
FileId: string(rune(i)),
ModifiedTsNs: int64(i + 1),
Fid: &filer_pb.FileId{FileKey: uint64(i)},
})
}
return c
}(),
wantMerge: true,
},
{
name: "many 4K writes at 1K step over large range",
// 200 writes of 4096 bytes at 1024-byte step.
// After compact all survive (each partially visible).
// total = 200*4096 = 819200, file ~ 199*1024+4096 = 207872.
// ratio ~ 3.9x.
chunks: func() []*filer_pb.FileChunk {
var c []*filer_pb.FileChunk
for i := 0; i < 200; i++ {
c = append(c, &filer_pb.FileChunk{
Offset: int64(i * 1024),
Size: 4096,
FileId: string(rune(i)),
ModifiedTsNs: int64(i + 1),
Fid: &filer_pb.FileId{FileKey: uint64(i)},
})
}
return c
}(),
wantMerge: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compacted, _ := filer.CompactFileChunks(context.Background(), nil, tt.chunks)
_, _, merge := shouldMergeChunks(compacted, nil)
if merge != tt.wantMerge {
var total uint64
for _, c := range compacted {
total += c.Size
}
fileSize := filer.TotalSize(compacted)
t.Fatalf("got merge=%v want %v (compacted=%d total=%d fileSize=%d ratio=%.2f)",
merge, tt.wantMerge, len(compacted), total, fileSize,
float64(total)/float64(fileSize))
}
})
}
}
// TestRandomWritesBloatDetection simulates random overwrites and verifies
// that the condition agrees with the actual stored-vs-visible ratio.
func TestRandomWritesBloatDetection(t *testing.T) {
const (
fileSize = 1024 * 1024 // 1 MB logical file
chunkSize = 64 * 1024 // 64 KB write size
)
for iter := 0; iter < 50; iter++ {
chunks := []*filer_pb.FileChunk{
{Offset: 0, Size: uint64(fileSize), FileId: "base", ModifiedTsNs: 1,
Fid: &filer_pb.FileId{FileKey: 0}},
}
// Add random overwrites until stored > 3x file size.
for i := 1; ; i++ {
off := int64(rand.IntN(fileSize - chunkSize + 1))
chunks = append(chunks, &filer_pb.FileChunk{
Offset: off,
Size: chunkSize,
FileId: string(rune(i)),
ModifiedTsNs: int64(1 + i),
Fid: &filer_pb.FileId{FileKey: uint64(i)},
})
var total uint64
for _, c := range chunks {
total += c.Size
}
if total > uint64(fileSize)*3 {
break
}
}
compacted, _ := filer.CompactFileChunks(context.Background(), nil, chunks)
totalCompacted, reportedFileSize, merge := shouldMergeChunks(compacted, nil)
// Sanity: condition must be consistent with its own inputs.
expectMerge := reportedFileSize > 0 && totalCompacted > 2*reportedFileSize
if merge != expectMerge {
t.Fatalf("iter %d: merge=%v but totalCompacted=%d reportedFileSize=%d",
iter, merge, totalCompacted, reportedFileSize)
}
}
}
// 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
// content as the original.
func TestVisibleContentPreservedAfterCompact(t *testing.T) {
const dataSize = 4096
for iter := 0; iter < 100; iter++ {
var chunks []*filer_pb.FileChunk
numWrites := 5 + rand.IntN(20)
for i := 0; i < numWrites; i++ {
start := rand.IntN(dataSize)
maxSize := 256
if dataSize-start < maxSize {
maxSize = dataSize - start
}
size := 1 + rand.IntN(maxSize)
chunks = append(chunks, &filer_pb.FileChunk{
Offset: int64(start),
Size: uint64(size),
FileId: string(rune(i)),
ModifiedTsNs: int64(i + 1),
Fid: &filer_pb.FileId{FileKey: uint64(i)},
})
}
origViews := filer.ViewFromChunks(context.Background(), nil, chunks, 0, math.MaxInt64)
compacted, _ := filer.CompactFileChunks(context.Background(), nil, chunks)
compViews := filer.ViewFromChunks(context.Background(), nil, compacted, 0, math.MaxInt64)
// Collect all (offset, size) pairs from views.
type viewKey struct {
offset int64
size uint64
}
origSet := make(map[viewKey]bool)
for x := origViews.Front(); x != nil; x = x.Next {
v := x.Value
origSet[viewKey{v.ViewOffset, v.ViewSize}] = true
}
compSet := make(map[viewKey]bool)
for x := compViews.Front(); x != nil; x = x.Next {
v := x.Value
compSet[viewKey{v.ViewOffset, v.ViewSize}] = true
}
// Every view in the compacted set must exist in the original.
for k := range compSet {
if !origSet[k] {
t.Fatalf("iter %d: compacted has view {offset=%d size=%d} not in original",
iter, k.offset, k.size)
}
}
// Every view in the original must exist after compaction.
for k := range origSet {
if !compSet[k] {
t.Fatalf("iter %d: original view {offset=%d size=%d} lost after compaction",
iter, k.offset, k.size)
}
}
}
}