Files
seaweedfs/weed/replication/source/filer_source_test.go
T
Chris LuandGitHub 23241cf0f1 Let filer.sync move past a chunk the source cluster no longer has (#11019)
* Name the failure when the source cluster cannot locate a chunk's volume

LookupFileId formatted a nil err into the message it returned, so the only
thing a caller could do with "no locations for this volume" was match on the
text. Return a typed error instead.

Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK

* Fail a source chunk read on a failure status instead of copying the error page

ReadPart never looked at the response status, so a volume server answering 404
for a needle vacuum had removed came back as a successful read whose body was
the error page. The caller counted those bytes as file content and reported a
size mismatch — a corruption claim about data the source had simply lost — and
a 404 from one replica ended the search instead of trying the next.

Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK

* Stop retrying a chunk the source cluster can no longer produce

A chunk whose volume vacuum has removed fails the same way on every attempt, but
the retry loop had no way to say so and kept going forever. The sync job holding
it never finished, so it pinned the offset watermark at the event ahead of it and
filer.sync never checkpointed again — alive, quiet, and permanently behind.

Wait the source out for a grace period long enough to cover a volume server
restart or a master failover, then give up and mark the failure permanent.

Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK

* Let replication continue past an entry whose source data is gone

An entry the source can no longer read holds the sync offset forever: the event
fails on every replay, so the checkpoint never moves past it and every later
event stays uncheckpointed, however long the sync keeps running. Nothing brings
those bytes back, so skip the entry with an error naming it and carry on.

Skip only while the source is demonstrably still serving other chunks. A volume
with no locations reads the same whether it was vacuumed away or every replica is
down, and during a cluster-wide outage that answer comes back for every chunk —
skipping then would drop live files wholesale.

Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK

* Propagate a missing source chunk instead of waiting when supersession is unverifiable

An incremental sink's dated target keys cannot be mapped back to a source path,
so nothing here can tell a chunk the source lost from one a later version already
replaced. Waiting out the grace period would stall every vacuumed needle for half
an hour; hand the failure to the caller, which has the event's real source key.

Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK

* Wait out a gone volume once, not once per file it held

A volume vacuum removed took every file it held with it, and each chunk was
timing its own grace period. With a bounded chunk executor those waits serialize,
so one gone volume holding many files stalls the sync for far longer than the
grace period — the wedge again, only slower.

Track the wait per source volume on the sink instead: the first chunk to find it
unlocatable starts the clock, every later chunk inherits it and gives up as soon
as it has run out, and a chunk the source does serve clears it.

Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK

* Probe the source with a read, not a lookup, before writing an entry off

A lookup only proves the source master still has the topology. If every volume
server is unreachable while the master still lists them, the probe passed and the
sink wrote off an entry whose data was merely out of reach. Read the probe chunk
instead, and say in the log that the entry stays unreplicated.

Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK
2026-08-28 14:09:56 -07:00

396 lines
12 KiB
Go

package source
import (
"bytes"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
func TestMain(m *testing.M) {
util_http.InitGlobalHttpClient()
os.Exit(m.Run())
}
func TestDownloadFile_NoOffset(t *testing.T) {
testData := []byte("0123456789abcdefghij")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") != "" {
t.Error("Range header should not be set when offset is 0")
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(testData)
}))
defer server.Close()
_, _, resp, err := util_http.DownloadFile(server.URL, "")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, testData) {
t.Fatalf("expected %q, got %q", testData, data)
}
}
func TestDownloadFile_WithOffset(t *testing.T) {
testData := []byte("0123456789abcdefghij")
var receivedRange string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedRange = r.Header.Get("Range")
var offset int
fmt.Sscanf(receivedRange, "bytes=%d-", &offset)
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, len(testData)-1, len(testData)))
w.WriteHeader(http.StatusPartialContent)
w.Write(testData[offset:])
}))
defer server.Close()
_, _, resp, err := util_http.DownloadFile(server.URL, "", 10)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if receivedRange != "bytes=10-" {
t.Fatalf("expected Range header %q, got %q", "bytes=10-", receivedRange)
}
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("expected status 206, got %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, testData[10:]) {
t.Fatalf("expected %q, got %q", testData[10:], data)
}
}
func TestDownloadFile_RejectsIgnoredRange(t *testing.T) {
// Server ignores Range header and returns 200 OK with full body
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("full body"))
}))
defer server.Close()
_, _, _, err := util_http.DownloadFile(server.URL, "", 100)
if err == nil {
t.Fatal("expected error when server ignores Range and returns 200")
}
}
func TestDownloadFile_ContentDisposition(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="test.txt"`)
w.Write([]byte("data"))
}))
defer server.Close()
filename, _, resp, err := util_http.DownloadFile(server.URL, "")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if filename != "test.txt" {
t.Fatalf("expected filename %q, got %q", "test.txt", filename)
}
}
// TestDownloadFile_PartialReadThenResume simulates a connection drop
// after partial data, then resumes from the offset. Verifies the combined
// data matches the original.
func TestDownloadFile_PartialReadThenResume(t *testing.T) {
testData := bytes.Repeat([]byte("abcdefghij"), 100) // 1000 bytes
dropAfter := 500
var requestNum atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := requestNum.Add(1)
rangeHeader := r.Header.Get("Range")
if n == 1 && rangeHeader == "" {
// First request: write partial data then kill the connection
hj, ok := w.(http.Hijacker)
if !ok {
t.Error("server doesn't support hijacking")
return
}
conn, buf, _ := hj.Hijack()
fmt.Fprintf(buf, "HTTP/1.1 200 OK\r\n")
fmt.Fprintf(buf, "Content-Length: %d\r\n", len(testData))
fmt.Fprintf(buf, "Content-Type: application/octet-stream\r\n")
fmt.Fprintf(buf, "\r\n")
buf.Write(testData[:dropAfter])
buf.Flush()
conn.Close()
return
}
// Resume request with Range
var offset int
fmt.Sscanf(rangeHeader, "bytes=%d-", &offset)
w.Header().Set("Content-Range",
fmt.Sprintf("bytes %d-%d/%d", offset, len(testData)-1, len(testData)))
w.WriteHeader(http.StatusPartialContent)
w.Write(testData[offset:])
}))
defer server.Close()
// First read — should get partial data + error
_, _, resp1, err := util_http.DownloadFile(server.URL, "")
if err != nil {
t.Fatal(err)
}
partialData, readErr := io.ReadAll(resp1.Body)
resp1.Body.Close()
if readErr == nil {
t.Fatal("expected error from truncated response")
}
if len(partialData) == 0 {
t.Fatal("expected some partial data")
}
if !bytes.Equal(partialData, testData[:len(partialData)]) {
t.Fatal("partial data doesn't match beginning of original")
}
// Resume from where we left off
_, _, resp2, err := util_http.DownloadFile(server.URL, "", int64(len(partialData)))
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
remainingData, readErr := io.ReadAll(resp2.Body)
if readErr != nil {
t.Fatalf("unexpected error on resume: %v", readErr)
}
// Combined data must match original
fullData := append(partialData, remainingData...)
if !bytes.Equal(fullData, testData) {
t.Fatalf("combined data mismatch: got %d bytes, want %d", len(fullData), len(testData))
}
}
// TestDownloadFile_GzipPartialReadThenResume verifies the tricky case
// where the first response is gzip-encoded (and Go's HTTP client auto-
// decompresses it), but the resume request gets uncompressed data (because
// Go doesn't add Accept-Encoding when Range is set). The combined
// decompressed bytes must still match the original.
func TestDownloadFile_GzipPartialReadThenResume(t *testing.T) {
testData := bytes.Repeat([]byte("hello world gzip test "), 100) // ~2200 bytes
// Pre-compress
var compressed bytes.Buffer
gz := gzip.NewWriter(&compressed)
gz.Write(testData)
gz.Close()
compressedData := compressed.Bytes()
dropAfter := len(compressedData) / 2
var requestNum atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := requestNum.Add(1)
rangeHeader := r.Header.Get("Range")
acceptsGzip := r.Header.Get("Accept-Encoding") != ""
if n == 1 && acceptsGzip && rangeHeader == "" {
// First request: serve gzip-encoded response, drop mid-stream.
// Go's HTTP client auto-added Accept-Encoding: gzip (no Range),
// so it will auto-decompress and strip the Content-Encoding header.
hj, ok := w.(http.Hijacker)
if !ok {
t.Error("server doesn't support hijacking")
return
}
conn, buf, _ := hj.Hijack()
fmt.Fprintf(buf, "HTTP/1.1 200 OK\r\n")
fmt.Fprintf(buf, "Content-Encoding: gzip\r\n")
fmt.Fprintf(buf, "Content-Length: %d\r\n", len(compressedData))
fmt.Fprintf(buf, "Content-Type: application/octet-stream\r\n")
fmt.Fprintf(buf, "\r\n")
buf.Write(compressedData[:dropAfter])
buf.Flush()
conn.Close()
return
}
// Resume request: Range is set, Go did NOT add Accept-Encoding,
// so we serve decompressed data from the requested offset —
// mimicking what the volume server does when the client doesn't
// advertise gzip support.
var offset int
fmt.Sscanf(rangeHeader, "bytes=%d-", &offset)
remaining := testData[offset:]
w.Header().Set("Content-Range",
fmt.Sprintf("bytes %d-%d/%d", offset, len(testData)-1, len(testData)))
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusPartialContent)
w.Write(remaining)
}))
defer server.Close()
// First read: Go auto-decompresses; truncated stream → error + partial data
_, _, resp1, err := util_http.DownloadFile(server.URL, "")
if err != nil {
t.Fatal(err)
}
partialData, readErr := io.ReadAll(resp1.Body)
resp1.Body.Close()
if readErr == nil {
t.Fatal("expected error from truncated gzip response")
}
if len(partialData) == 0 {
t.Fatal("expected some decompressed partial data")
}
// Partial decompressed data must match the beginning of the original
if !bytes.Equal(partialData, testData[:len(partialData)]) {
t.Fatalf("partial decompressed data doesn't match original (got %d bytes)", len(partialData))
}
// Resume: offset is in the *decompressed* domain.
// The server (like the volume server) decompresses and serves from that offset.
_, _, resp2, err := util_http.DownloadFile(server.URL, "", int64(len(partialData)))
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
remainingData, readErr := io.ReadAll(resp2.Body)
if readErr != nil {
t.Fatalf("unexpected error on resume: %v", readErr)
}
fullData := append(partialData, remainingData...)
if !bytes.Equal(fullData, testData) {
t.Fatalf("combined data mismatch: got %d bytes, want %d", len(fullData), len(testData))
}
}
// lookupFilerServer answers volume lookups with a fixed set of locations, so a
// test can hand ReadPart several replicas, or none at all.
type lookupFilerServer struct {
filer_pb.UnimplementedSeaweedFilerServer
locations []string
}
func (s *lookupFilerServer) LookupVolume(ctx context.Context, req *filer_pb.LookupVolumeRequest) (*filer_pb.LookupVolumeResponse, error) {
locationsMap := make(map[string]*filer_pb.Locations)
for _, vid := range req.VolumeIds {
if len(s.locations) == 0 {
continue
}
locations := &filer_pb.Locations{}
for _, url := range s.locations {
locations.Locations = append(locations.Locations, &filer_pb.Location{Url: url})
}
locationsMap[vid] = locations
}
return &filer_pb.LookupVolumeResponse{LocationsMap: locationsMap}, nil
}
func startLookupFiler(t *testing.T, locations ...string) *FilerSource {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := grpc.NewServer()
filer_pb.RegisterSeaweedFilerServer(server, &lookupFilerServer{locations: locations})
go server.Serve(listener)
t.Cleanup(server.Stop)
address := listener.Addr().String()
filerSource := &FilerSource{}
if err := filerSource.DoInitialize(address, address, "/", false); err != nil {
t.Fatalf("DoInitialize: %v", err)
}
filerSource.SetGrpcDialOption(grpc.WithTransportCredentials(insecure.NewCredentials()))
return filerSource
}
// A volume the source cluster no longer has must be reported as ErrVolumeNotFound,
// not as an untyped message the caller can only string-match.
func TestLookupFileIdVolumeNotFound(t *testing.T) {
filerSource := startLookupFiler(t)
_, err := filerSource.LookupFileId(context.Background(), "5617,01abc")
if !errors.Is(err, ErrVolumeNotFound) {
t.Fatalf("expected ErrVolumeNotFound, got %v", err)
}
}
// A replica answering 404 is a failed read, not an empty file: ReadPart must move
// on to the next replica instead of handing the error page back as content.
func TestReadPartSkipsNotFoundReplica(t *testing.T) {
const body = "chunk bytes"
gone := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not Found", http.StatusNotFound)
}))
defer gone.Close()
live := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(body))
}))
defer live.Close()
filerSource := startLookupFiler(t,
strings.TrimPrefix(gone.URL, "http://"), strings.TrimPrefix(live.URL, "http://"))
_, _, resp, err := filerSource.ReadPart("5617,01abc", 0)
if err != nil {
t.Fatalf("ReadPart: %v", err)
}
defer util_http.CloseResponse(resp)
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(data) != body {
t.Fatalf("got %q, want %q", data, body)
}
}
// Every replica gone: the caller must see a not-found error it can act on, and no
// response left open behind it.
func TestReadPartAllReplicasNotFound(t *testing.T) {
gone := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not Found", http.StatusNotFound)
}))
defer gone.Close()
filerSource := startLookupFiler(t, strings.TrimPrefix(gone.URL, "http://"))
_, _, resp, err := filerSource.ReadPart("5617,01abc", 0)
if !errors.Is(err, util_http.ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
if resp != nil {
t.Fatal("expected no response alongside the error")
}
}