mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-12 23:09:33 +01:00
* filer.copy: compute chunk md5 so files get a real ETag Chunks uploaded by filer.copy had no ETag, so every multi-chunk file got the md5 of the empty string plus -N. An overwritten file kept its old ETag and a revalidating client got 304 for changed content. * filer.copy: store chunks in offset order Chunks are collected in upload completion order, and the entry ETag hashes the chunk ETags in stored order. Identical files copied with concurrent chunk uploads got different ETags, and a file with swapped equal-sized chunks could keep the old one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * filer.copy: test chunk order and chunk md5 on chunked uploads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
3.5 KiB
Go
126 lines
3.5 KiB
Go
package command
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
)
|
|
|
|
type copyTestFiler struct {
|
|
filer_pb.UnimplementedSeaweedFilerServer
|
|
volumeHost string
|
|
nextKey atomic.Int32
|
|
mu sync.Mutex
|
|
created *filer_pb.Entry
|
|
}
|
|
|
|
func (f *copyTestFiler) AssignVolume(ctx context.Context, req *filer_pb.AssignVolumeRequest) (*filer_pb.AssignVolumeResponse, error) {
|
|
return &filer_pb.AssignVolumeResponse{
|
|
FileId: fmt.Sprintf("1,%x637037d6", f.nextKey.Add(1)),
|
|
Location: &filer_pb.Location{Url: f.volumeHost, PublicUrl: f.volumeHost},
|
|
}, nil
|
|
}
|
|
|
|
func (f *copyTestFiler) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.created = req.Entry
|
|
return &filer_pb.CreateEntryResponse{}, nil
|
|
}
|
|
|
|
func TestUploadFileInChunksStoresChunksInOffsetOrder(t *testing.T) {
|
|
// The first chunk finishes last, and the server echoes Content-MD5 back
|
|
// the way a volume server does.
|
|
volume := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
data, _ := io.ReadAll(file)
|
|
if strings.HasSuffix(header.Filename, "-1") {
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
w.Header().Set("Content-MD5", header.Header.Get("Content-MD5"))
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]any{"name": header.Filename, "size": len(data)})
|
|
}))
|
|
defer volume.Close()
|
|
|
|
fake := ©TestFiler{volumeHost: strings.TrimPrefix(volume.URL, "http://")}
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(srv, fake)
|
|
go srv.Serve(lis)
|
|
defer srv.Stop()
|
|
|
|
const chunkSize, chunkCount = 64 * 1024, 3
|
|
data := make([]byte, chunkSize*chunkCount)
|
|
rand.Read(data)
|
|
path := filepath.Join(t.TempDir(), "f.bin")
|
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
empty, access, concurrent := "", "url", chunkCount
|
|
worker := &FileCopyWorker{
|
|
options: &CopyOptions{
|
|
replication: &empty,
|
|
collection: &empty,
|
|
diskType: &empty,
|
|
concurrentChunks: &concurrent,
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
volumeServerAccess: &access,
|
|
},
|
|
filerAddress: pb.ServerAddress(fmt.Sprintf("127.0.0.1:1.%d", lis.Addr().(*net.TCPAddr).Port)),
|
|
}
|
|
task := FileCopyTask{destinationUrlPath: "/dir/", fileSize: int64(len(data)), fileMode: 0o644}
|
|
if err := worker.uploadFileInChunks(task, f, chunkCount, chunkSize); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
fake.mu.Lock()
|
|
chunks := fake.created.GetChunks()
|
|
fake.mu.Unlock()
|
|
if len(chunks) != chunkCount {
|
|
t.Fatalf("stored %d chunks, want %d", len(chunks), chunkCount)
|
|
}
|
|
for i, c := range chunks {
|
|
if want := int64(i * chunkSize); c.Offset != want {
|
|
t.Fatalf("chunk %d is at offset %d, want %d", i, c.Offset, want)
|
|
}
|
|
sum := md5.Sum(data[i*chunkSize : (i+1)*chunkSize])
|
|
if want := base64.StdEncoding.EncodeToString(sum[:]); c.ETag != want {
|
|
t.Fatalf("chunk %d ETag = %q, want the md5 of its data %q", i, c.ETag, want)
|
|
}
|
|
}
|
|
}
|