filer: repack conflicts on every input to its output

The commit-time check compared only the chunk list, so a concurrent
change that kept the chunks - clearing the TTL, moving the expiry
anchor, hard-linking, going remote - passed verification, and the swap
paired that fresh metadata with chunks uploaded under the old inputs: a
permanent entry pointing at chunks that still expire. Digest everything
the repack consumed - chunk fingerprint, file size, TTL, both time
anchors, the S3-expiry flag, hard-link and remote state - and answer
409 when any of it moved.
This commit is contained in:
Chris Lu
2026-08-10 19:02:58 -07:00
parent f07aabb39f
commit ed9d1eec64
2 changed files with 57 additions and 6 deletions
+21 -5
View File
@@ -58,6 +58,19 @@ func formatChunkIdentity(chunks []*filer_pb.FileChunk) []byte {
return digest.Sum(nil)
}
// repackSourceIdentity digests every entry field that influenced a repack's
// output: the chunk list it read, the size the layout was validated against,
// the TTL and expiry anchors its new chunks were assigned with, and the
// hard-link and remote state its guards evaluated.
func repackSourceIdentity(entry *filer.Entry) []byte {
digest := md5.New()
digest.Write(formatChunkIdentity(entry.GetChunks()))
fmt.Fprintf(digest, "%d:%d:%d:%d:%t:%x:%t",
entry.FileSize, entry.TtlSec, entry.Crtime.UnixNano(), entry.Mtime.UnixNano(),
entry.IsExpireS3Enabled(), []byte(entry.HardLinkId), entry.Remote != nil)
return digest.Sum(nil)
}
// roundUpToVolumeTTL returns the smallest volume-TTL-representable seconds
// value not below the argument. A volume TTL is at most 255 of one unit and
// SecondsToTTL truncates anything else downward, which would let chunks
@@ -307,7 +320,7 @@ func (fs *FilerServer) formatRepack(ctx context.Context, w http.ResponseWriter,
return
}
oldChunks := entry.GetChunks()
oldIdentity := formatChunkIdentity(oldChunks)
sourceIdentity := repackSourceIdentity(entry)
if len(oldChunks) == 0 {
writeJsonError(w, r, http.StatusBadRequest, errors.New("entry has no chunks to repack"))
return
@@ -408,9 +421,12 @@ func (fs *FilerServer) formatRepack(ctx context.Context, w http.ResponseWriter,
}
// The entry lock is filer-local, so a writer on another filer is not
// blocked by it. Re-read from the store and revalidate right before the
// swap: the unguarded window shrinks from the whole repack to this
// commit. Full enforcement needs owner routing.
// blocked by it. Re-read from the store and conflict on any change to
// state that influenced this repack - chunks, size, TTL and expiry
// anchors, hard-link and remote state - so the swap can never pair fresh
// metadata with chunks built from stale inputs. The unguarded window
// shrinks from the whole repack to this commit; within it repack races
// like any ordinary writer. Closing it needs owner routing.
current, err := fs.filer.FindEntry(ctx, fullPath)
if err != nil {
cleanup()
@@ -421,7 +437,7 @@ func (fs *FilerServer) formatRepack(ctx context.Context, w http.ResponseWriter,
}
return
}
if !bytes.Equal(formatChunkIdentity(current.GetChunks()), oldIdentity) {
if !bytes.Equal(repackSourceIdentity(current), sourceIdentity) {
cleanup()
writeJsonError(w, r, http.StatusConflict, errors.New("entry changed during repack"))
return
+36 -1
View File
@@ -2,8 +2,10 @@ package weed_server
import (
"bytes"
"github.com/seaweedfs/seaweedfs/weed/filer"
"math"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
@@ -22,7 +24,7 @@ func TestRoundUpToVolumeTTL(t *testing.T) {
{3600, 3600},
{3601, 3660},
{255 * 60, 255 * 60},
{255*60 + 1, 5 * 3600}, // minutes overflow 255, ceil to hours
{255*60 + 1, 5 * 3600}, // minutes overflow 255, ceil to hours
{20_000_000, 232 * 24 * 3600}, // ~231.5 days, ceil to days
{int64(math.MaxInt32), 0}, // beyond every unit's 255 cap: no TTL, never a shortened one
}
@@ -45,6 +47,39 @@ func TestRoundUpToVolumeTTL(t *testing.T) {
}
}
func TestRepackSourceIdentity(t *testing.T) {
base := func() *filer.Entry {
return &filer.Entry{
FullPath: "/videos/movie.ts",
Attr: filer.Attr{
FileSize: 30, TtlSec: 600,
Crtime: time.Unix(1000, 0), Mtime: time.Unix(2000, 0),
},
Chunks: []*filer_pb.FileChunk{{FileId: "1,ab", Offset: 0, Size: 30}},
}
}
identity := repackSourceIdentity(base())
if !bytes.Equal(identity, repackSourceIdentity(base())) {
t.Fatalf("identity is not deterministic")
}
mutations := map[string]func(*filer.Entry){
"ttl cleared": func(e *filer.Entry) { e.TtlSec = 0 },
"size changed": func(e *filer.Entry) { e.FileSize = 31 },
"mtime moved": func(e *filer.Entry) { e.Mtime = time.Unix(3000, 0) },
"crtime moved": func(e *filer.Entry) { e.Crtime = time.Unix(1001, 0) },
"hard linked": func(e *filer.Entry) { e.HardLinkId = []byte{1} },
"went remote": func(e *filer.Entry) { e.Remote = &filer_pb.RemoteEntry{} },
"chunk moved": func(e *filer.Entry) { e.Chunks[0].Offset = 1 },
}
for name, mutate := range mutations {
changed := base()
mutate(changed)
if bytes.Equal(identity, repackSourceIdentity(changed)) {
t.Fatalf("identity ignored: %s", name)
}
}
}
func TestFormatChunkIdentity(t *testing.T) {
chunks := []*filer_pb.FileChunk{
{FileId: "1,ab", Offset: 0, Size: 10},