mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-12 01:07:35 +02:00
* filer: fail the request, not the process, when chunks cannot be resolved CompactFileChunks and ViewFromChunks discarded the error from NonOverlappingVisibleIntervals and iterated the nil interval list it returns on failure. A CreateEntry or UpdateEntry whose context was cancelled therefore panicked in SeparateGarbageChunks and took the whole filer down, losing the metadata log it had not yet flushed. Both functions now return the error. cleanupChunks fails the request with it, the mount flush paths return it, the replication sinks and the S3 SSE range reader return it, and a ChunkStreamReader that cannot build its view fails every read with it and reports it as its SourceError instead of reading as an empty file. Fixes #11682 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * filer: propagate chunk resolution failures in scan paths NonOverlappingVisibleIntervals can fail without a cancelled context; the query engine and log-store callers discarded the error and dereferenced nil intervals. * query: propagate chunk resolution errors instead of skipping files When a manifest cannot be resolved, ReadParquetStatistics, countLiveLogRowsExcludingParquetSources, and computeLiveLogMinMax logged a warning and skipped the failed file, so queries returned understated counts and wrong min/max values. Surface the errors instead: the fast-path aggregation collector now returns a DataSourceError so the engine falls back to a full scan, and the row-count helpers return the error rather than a partial total. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
463 lines
15 KiB
Go
463 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, _, err := filer.CompactFileChunks(context.Background(), nil, tt.chunks)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, 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, _, err := filer.CompactFileChunks(context.Background(), nil, chunks)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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, _, err := filer.CompactFileChunks(context.Background(), nil, nonManifestChunks)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, _, 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, err := filer.ViewFromChunks(context.Background(), nil, chunks, 0, math.MaxInt64)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
compacted, _, err := filer.CompactFileChunks(context.Background(), nil, chunks)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
compViews, err := filer.ViewFromChunks(context.Background(), nil, compacted, 0, math.MaxInt64)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|