Files
seaweedfs/weed/mount/weedfs_file_sync_test.go
T
Chris Lu ef109fe9e1 mount: don't hang close() when a writer is killed during flush (#10090)
* operation: bound AssignVolume with a deadline

AssignVolume ran on context.Background(), so when the filer is overwhelmed
the RPC could block indefinitely and wedge every caller holding the
connection. Give it a 30s deadline so a stuck assign fails and the caller's
retry/error path runs instead of hanging forever.

* mount: abort flush when the FUSE request is interrupted

On close(), a killed process blocks in fuse_flush waiting for the mount to
answer. doFlush ran its metadata CreateEntry on context.Background() and
ignored the kernel interrupt channel, so against an overwhelmed filer the
flush never completed and the process stayed in uninterruptible sleep --
making the pod un-killable.

Derive a context from the FUSE cancel channel in Flush/Fsync and thread it
through doFlush -> flushMetadataToFiler -> streamCreateEntry; the retry loop
stops as soon as the context is cancelled. Release and the pre-rename flush
keep a non-cancellable context since they must finish regardless.

* operation: harden the AssignVolume timeout test

Make the test double's signal send non-blocking and bound the receive with a
timeout so a regression can't wedge the test instead of failing it.
2026-06-24 14:24:22 -07:00

491 lines
16 KiB
Go

package mount
import (
"context"
"fmt"
"math"
"math/rand/v2"
"testing"
"time"
"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)
}
}
}
}
// TestFuseInterruptContextCancelsOnInterrupt verifies the interrupt wiring:
// when the kernel interrupts a flush/fsync (the calling process was killed)
// go-fuse closes the cancel channel, and the derived context must be cancelled
// so the in-flight metadata RPC and its retries abort instead of leaving the
// killed process stuck in uninterruptible sleep inside close().
func TestFuseInterruptContextCancelsOnInterrupt(t *testing.T) {
cancel := make(chan struct{})
ctx, cancelFunc := fuseInterruptContext(cancel)
defer cancelFunc()
select {
case <-ctx.Done():
t.Fatal("context cancelled before the FUSE request was interrupted")
default:
}
close(cancel) // kernel sent FUSE_INTERRUPT (process killed)
select {
case <-ctx.Done():
case <-time.After(5 * time.Second):
t.Fatal("context not cancelled after FUSE interrupt")
}
}
// A nil cancel channel (no interrupt plumbing) must still yield a usable,
// cancellable context that does not fire on its own.
func TestFuseInterruptContextNilChannel(t *testing.T) {
ctx, cancelFunc := fuseInterruptContext(nil)
defer cancelFunc()
select {
case <-ctx.Done():
t.Fatal("context cancelled with no interrupt and no explicit cancel")
default:
}
cancelFunc()
select {
case <-ctx.Done():
case <-time.After(5 * time.Second):
t.Fatal("context not cancelled after explicit cancelFunc")
}
}