Files
seaweedfs/weed/server/filer_server_tus_complete_test.go
T
Chris LuandGitHub eb3bbfeb1f filer: apply the path's storage rule TTL on every write path (#10963)
* filer: cover the storage rule TTL on the object transaction write path

An object written through ObjectTransaction used to land with ttlSec 0
even under an fs.configure TTL rule, while the same object written
through CreateEntry got the rule's TTL. Guard the shared stamping so the
two paths cannot drift apart again.

* filer: apply the path's storage rule to an appended entry

AppendToEntry resolved the storage option from the path - so its chunks
land on a TTL volume under an fs.configure TTL rule - but never stamped
the rule's TTL on the entry it creates, leaving an entry that outlives
its data. Route it through applyStorageDefaultsToEntry, which now feeds
the entry's own TTL into the option so the placement an existing entry's
appended chunks get is unchanged.

* filer: apply the path's storage rule to a completed TUS upload

The PATCH path resolves the storage option from the target, so a TUS
upload into an fs.configure TTL prefix writes its chunks to a TTL volume,
but completion built the final entry with ttlSec 0 - the entry outlived
the data it pointed at. Stamp it through applyStorageDefaultsToEntry,
which also subsumes the hand-rolled read-only check and supplies the
rule's name-length limit.

* filer: apply the destination's storage option TTL to a copied entry

The copy handler re-uploads the source's chunks under the destination's
storage option, so a copy into an fs.configure TTL prefix already lands
its data on a TTL volume. The entry, though, carried the source's ttlSec
- 0 for a source outside the prefix, or the source's own TTL where the
two rules differ - so it never expired with the data it pointed at. Take
the TTL from the same option the chunks were placed with, after the
data-only copy has restored the destination's metadata.
2026-08-26 08:49:25 -07:00

158 lines
5.5 KiB
Go

package weed_server
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// TestFilerServer_completeTusUpload_OverlappingRecords verifies a session whose
// chunk records overlap - a PATCH retried while its predecessor was still
// storing a sub-chunk - still completes: coverage is validated the way HEAD
// computes the offset, and only records extending coverage join the entry.
func TestFilerServer_completeTusUpload_OverlappingRecords(t *testing.T) {
fidA, fidB, fidDup := "3,01637037d6", "4,02637037d6", "5,03637037d6"
tests := []struct {
name string
chunks [][3]int64 // offset, size, fid index into fids
wantChunks [][2]int64 // offset, size expected on the entry
}{
{
name: "exact duplicate dropped",
chunks: [][3]int64{{0, 8, 0}, {0, 8, 2}, {8, 4, 1}},
wantChunks: [][2]int64{{0, 8}, {8, 4}},
},
{
name: "partial overlap kept",
chunks: [][3]int64{{0, 8, 0}, {6, 6, 2}},
wantChunks: [][2]int64{{0, 8}, {6, 6}},
},
{
// a covered record naming a file id the entry keeps must not free it
name: "duplicate sharing a kept file id",
chunks: [][3]int64{{0, 8, 0}, {0, 4, 0}, {8, 4, 1}},
wantChunks: [][2]int64{{0, 8}, {8, 4}},
},
}
fids := []string{fidA, fidB, fidDup}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs, store := newTusTestServer(t, nil)
targetPath := "/buckets/data/raced.bin"
size := int64(12)
seedTusSession(t, fs, store, TusSession{ID: tusTestUploadID, TargetPath: targetPath, Size: size})
for _, c := range tt.chunks {
seedTusChunk(t, fs, store, tusTestUploadID, c[0], c[1], fids[c[2]])
}
// a zero-length PATCH at the reported offset triggers completion
req := tusRequest(http.MethodPatch, "/.tus/.uploads/"+tusTestUploadID, map[string]string{
"Authorization": "Bearer " + signFilerToken(t, tusTestWriteKey, nil, nil),
"Content-Type": "application/offset+octet-stream",
"Upload-Offset": "12",
}, "")
rec := httptest.NewRecorder()
fs.tusHandler(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("PATCH = %d, want %d; body=%q", rec.Code, http.StatusNoContent, rec.Body.String())
}
entry, err := store.FindEntry(context.Background(), util.FullPath(targetPath))
if err != nil {
t.Fatalf("final entry not created: %v", err)
}
chunks := entry.GetChunks()
if len(chunks) != len(tt.wantChunks) {
t.Fatalf("entry chunks = %d, want %d", len(chunks), len(tt.wantChunks))
}
for i, want := range tt.wantChunks {
if chunks[i].Offset != want[0] || int64(chunks[i].Size) != want[1] {
t.Errorf("chunk[%d] = @%d+%d, want @%d+%d", i, chunks[i].Offset, chunks[i].Size, want[0], want[1])
}
}
var freed []string
fs.filer.FileIdDeletionQueue.Consume(func(fileIds []string) {
freed = append(freed, fileIds...)
})
for _, chunk := range chunks {
for _, fileId := range freed {
if fileId == chunk.FileId {
t.Errorf("file id %s is referenced by the entry but was freed", fileId)
}
}
}
if _, err := store.FindEntry(context.Background(), util.FullPath(fs.tusSessionInfoPath(tusTestUploadID))); err == nil {
t.Errorf("session still present after completion")
}
})
}
}
// TestFilerServer_completeTusUpload_GapRejected verifies records that do not
// cover the full size still fail completion.
func TestFilerServer_completeTusUpload_GapRejected(t *testing.T) {
fs, store := newTusTestServer(t, nil)
targetPath := "/buckets/data/gap.bin"
seedTusSession(t, fs, store, TusSession{ID: tusTestUploadID, TargetPath: targetPath, Size: 12})
session := &TusSession{
ID: tusTestUploadID,
TargetPath: targetPath,
Size: 12,
Offset: 12,
Chunks: []*TusChunkInfo{
{Offset: 0, Size: 8, FileId: "3,01637037d6"},
{Offset: 9, Size: 3, FileId: "4,02637037d6"},
},
}
err := fs.completeTusUpload(context.Background(), session)
if err == nil || !strings.Contains(err.Error(), "chunk gap") {
t.Fatalf("completeTusUpload err = %v, want a chunk gap error", err)
}
if _, findErr := store.FindEntry(context.Background(), util.FullPath(targetPath)); findErr == nil {
t.Fatalf("entry created despite gap")
}
}
// TestFilerServer_completeTusUpload_ReadOnlyTarget verifies a read-only prefix
// still refuses the completing upload, before the session's chunks are claimed.
func TestFilerServer_completeTusUpload_ReadOnlyTarget(t *testing.T) {
fs, store := newTusTestServer(t, nil)
if err := fs.filer.FilerConf.AddLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/frozen/",
ReadOnly: true,
}); err != nil {
t.Fatalf("AddLocationConf: %v", err)
}
targetPath := "/buckets/frozen/upload.bin"
session := &TusSession{
ID: tusTestUploadID,
TargetPath: targetPath,
Size: 8,
Offset: 8,
Chunks: []*TusChunkInfo{{Offset: 0, Size: 8, FileId: "3,01637037d6"}},
}
seedTusSession(t, fs, store, *session)
err := fs.completeTusUpload(context.Background(), session)
if !errors.Is(err, ErrReadOnly) {
t.Fatalf("completeTusUpload err = %v, want %v", err, ErrReadOnly)
}
if _, findErr := store.FindEntry(context.Background(), util.FullPath(targetPath)); findErr == nil {
t.Fatalf("entry created under a read-only prefix")
}
if consumed, checkErr := fs.isTusSessionConsumed(context.Background(), tusTestUploadID); checkErr != nil || consumed {
t.Fatalf("session consumed = %v (err %v), want false", consumed, checkErr)
}
}