mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-21 14:46:58 +00:00
fix(filer.backup): repair chunk-incomplete and stale destination entries (#10082)
* fix(filer.backup): repair chunk-incomplete and stale destination entries filer.backup left destinations diverged while metadata advanced — chunk-incomplete (missing/gapped ranges at full attr.file_size) or holding a chunk superseded by a missed overwrite. The skip/repair decision keyed on filer.FileSize (the attr), which a truncated entry keeps full, so it never repaired. Decide from actual chunk state instead: - coversReference: range-by-range containment (scalar byte totals and attr FileSize/Md5 cannot see chunk-level gaps). - hasStaleBackupChunk: a backup-written chunk (SourceFileId) the source no longer lists; ignores out-of-band (rsync/direct) chunks. - destinationMatchesReference: allocation-free positional fast path gating the above so they run only on divergence (the in-sync path stays cheap). - A strictly-newer destination is never repaired, so an older out-of-order replay cannot roll it back. The stale signal is deferred at equal mtime (same-second versions cannot be ordered; reliable S3 sub-second ordering is a separate fix). Tests in filer_sink_test.go. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * filer.backup: verify chunk range in destinationMatchesReference fast path The allocation-free fast path matched a destination chunk to its reference by SourceFileId alone. That is correct today only because replicateOneChunk copies the source chunk's Offset/Size verbatim, so SourceFileId identity implies an identical range — an invariant that lives in another file with no guard linking the two. If replication ever re-chunks (split/coalesce), a chunk with the right SourceFileId but a different range would fast-path as a full match and skip a needed repair (a false positive in the very class this change otherwise prevents). Compare Offset/Size alongside SourceFileId so the fast path is self-contained and can only be more conservative (a range mismatch falls through to the precise coversReference/hasStaleBackupChunk checks). Add tests for a shifted offset and a larger size at matching identity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e1f89f85f2
commit
a11d81b21f
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -244,18 +245,12 @@ func (fs *FilerSink) CreateEntry(key string, entry *filer_pb.Entry, signatures [
|
||||
}
|
||||
// glog.V(1).Infof("lookup: %v", lookupRequest)
|
||||
if resp, err := filer_pb.LookupEntry(context.Background(), client, lookupRequest); err == nil {
|
||||
if filer.ETag(resp.Entry) == filer.ETag(entry) {
|
||||
glog.V(3).Infof("already replicated %s", key)
|
||||
if destinationSatisfiesSource(resp.Entry, entry) {
|
||||
glog.V(3).Infof("skip overwriting %s", key)
|
||||
return nil
|
||||
}
|
||||
if getEntryMtimeNs(resp.Entry) >= getEntryMtimeNs(entry) {
|
||||
if filer.FileSize(resp.Entry) >= filer.FileSize(entry) {
|
||||
glog.V(3).Infof("skip overwriting %s", key)
|
||||
return nil
|
||||
}
|
||||
glog.Warningf("repair truncated %s: destination %d bytes < source %d bytes but has a newer mtime; overwriting from source",
|
||||
key, filer.FileSize(resp.Entry), filer.FileSize(entry))
|
||||
}
|
||||
glog.Warningf("re-replicating %s: destination not current (dst %d chunks, src %d chunks)",
|
||||
key, len(resp.Entry.GetChunks()), len(entry.GetChunks()))
|
||||
}
|
||||
|
||||
replicatedChunks, err := fs.replicateChunks(context.Background(), entry.GetChunks(), key, getEntryMtimeNs(entry))
|
||||
@@ -331,14 +326,14 @@ func (fs *FilerSink) UpdateEntry(key string, oldEntry *filer_pb.Entry, newParent
|
||||
// now, which for a rename is newParentPath/newEntry.Name, not the old key.
|
||||
targetKey := updatedEntryKey(key, newParentPath, newEntry)
|
||||
|
||||
switch chooseUpdateAction(existingEntry, newEntry) {
|
||||
switch chooseUpdateAction(existingEntry, oldEntry, newEntry) {
|
||||
case updateSkip:
|
||||
// a newer, complete version already landed (out-of-order); leave it
|
||||
glog.V(2).Infof("late updates %s", key)
|
||||
return true, nil
|
||||
case updateRepair:
|
||||
glog.Warningf("repair truncated %s: destination %d bytes < source %d bytes but has a newer mtime; re-replicating full source content",
|
||||
key, filer.FileSize(existingEntry), filer.FileSize(newEntry))
|
||||
glog.Warningf("repair %s: destination diverged from source (dst %d chunks, src %d chunks); re-replicating full source content",
|
||||
key, len(existingEntry.GetChunks()), len(newEntry.GetChunks()))
|
||||
replicatedChunks, err := fs.replicateChunks(context.Background(), newEntry.GetChunks(), targetKey, getEntryMtimeNs(newEntry))
|
||||
if err != nil {
|
||||
if errors.Is(err, errChunkSizeMismatch) {
|
||||
@@ -470,12 +465,174 @@ func updatedEntryKey(key, newParentPath string, newEntry *filer_pb.Entry) string
|
||||
return string(util.NewFullPath(targetDir, newEntry.Name))
|
||||
}
|
||||
|
||||
func chooseUpdateAction(existing, incoming *filer_pb.Entry) updateAction {
|
||||
if getEntryMtimeNs(existing) <= getEntryMtimeNs(incoming) {
|
||||
return updateNormal
|
||||
func chooseUpdateAction(existing, oldEntry, incoming *filer_pb.Entry) updateAction {
|
||||
exNs, inNs := getEntryMtimeNs(existing), getEntryMtimeNs(incoming)
|
||||
if exNs > inNs {
|
||||
// Destination is strictly newer: incoming is a stale/out-of-order replay of
|
||||
// an older source version, so never roll the destination back to it — even
|
||||
// when the newer destination is smaller (a legitimate truncate). A newer copy
|
||||
// that is itself incomplete is reconciled by -initialSnapshot, not by an
|
||||
// older event.
|
||||
return updateSkip
|
||||
}
|
||||
if filer.FileSize(existing) < filer.FileSize(incoming) {
|
||||
return updateRepair
|
||||
// Destination at or behind incoming. updateNormal applies the incremental
|
||||
// oldEntry->incoming diff, valid only when the destination already holds
|
||||
// oldEntry's chunks. A cheap, allocation-free positional identity match proves
|
||||
// that for the common (in-sync) path; only when it fails — divergence, chunk
|
||||
// reordering, or an out-of-band destination — do we run the precise O(n log n)
|
||||
// checks.
|
||||
if existing != nil && oldEntry != nil && !destinationMatchesReference(existing, oldEntry) {
|
||||
// Truncated/gapped: the destination does not cover every byte range oldEntry
|
||||
// holds. Range containment (not a scalar byte count, attr FileSize, or ETag)
|
||||
// catches it for any destination, and is safe at equal mtime since a complete
|
||||
// destination always covers oldEntry.
|
||||
if !coversReference(existing, oldEntry) {
|
||||
return updateRepair
|
||||
}
|
||||
// Stale superseded chunk at full coverage: a chunk this backup wrote whose
|
||||
// source oldEntry no longer references. Restricted to backup-written chunks
|
||||
// (out-of-band rsync/direct chunks are ignored) AND to a strictly-older
|
||||
// destination — at equal mtime same-second versions cannot be ordered, so
|
||||
// defer rather than risk a rollback (reliable ordering is a separate fix).
|
||||
if exNs < inNs && hasStaleBackupChunk(existing, oldEntry) {
|
||||
return updateRepair
|
||||
}
|
||||
}
|
||||
return updateSkip
|
||||
return updateNormal
|
||||
}
|
||||
|
||||
// destinationSatisfiesSource reports whether an existing destination entry already
|
||||
// satisfies the incoming source entry, so CreateEntry can skip the write. Coverage
|
||||
// is checked first: a chunk-truncated destination (covering fewer bytes than the
|
||||
// source) must be re-replicated even when its attr.Md5-backed ETag still matches
|
||||
// the source, so ETag equality must never bypass the coverage check. A destination
|
||||
// that is at least as complete is skippable when it has identical content (ETag) or
|
||||
// is a newer-or-equal version.
|
||||
func destinationSatisfiesSource(existing, incoming *filer_pb.Entry) bool {
|
||||
if existing == nil {
|
||||
return false
|
||||
}
|
||||
exNs, inNs := getEntryMtimeNs(existing), getEntryMtimeNs(incoming)
|
||||
if exNs > inNs {
|
||||
return true // destination strictly newer: incoming is older → keep destination
|
||||
}
|
||||
if !destinationMatchesReference(existing, incoming) {
|
||||
if !coversReference(existing, incoming) {
|
||||
return false // missing a byte range the source covers (truncated/gapped)
|
||||
}
|
||||
if exNs < inNs && hasStaleBackupChunk(existing, incoming) {
|
||||
return false // a provably-older destination holds a superseded backup chunk
|
||||
}
|
||||
}
|
||||
if filer.ETag(existing) == filer.ETag(incoming) {
|
||||
return true // identical content
|
||||
}
|
||||
return exNs >= inNs // equal mtime → keep (deferred); strictly older → re-replicate
|
||||
}
|
||||
|
||||
// destinationMatchesReference is a cheap, allocation-free sufficient check that the
|
||||
// destination already holds exactly the reference's chunks: equal count and, in
|
||||
// order, each destination chunk was replicated from the corresponding reference
|
||||
// chunk (SourceFileId == reference FileId) at the same byte range (Offset/Size).
|
||||
// True ⇒ full coverage and no stale chunk, so updateNormal/skip is safe and the
|
||||
// O(n log n) range and identity checks can be skipped — the common in-sync path.
|
||||
// False is never a false positive: a reordered, diverged, or out-of-band
|
||||
// (rsync/direct) destination just falls back to the precise checks.
|
||||
func destinationMatchesReference(existing, reference *filer_pb.Entry) bool {
|
||||
ex, ref := existing.GetChunks(), reference.GetChunks()
|
||||
if len(ex) != len(ref) {
|
||||
return false
|
||||
}
|
||||
for i := range ref {
|
||||
if ex[i].SourceFileId == "" || ex[i].SourceFileId != ref[i].GetFileIdString() {
|
||||
return false
|
||||
}
|
||||
// Require the same byte range too. replicateOneChunk copies the source
|
||||
// chunk's Offset/Size verbatim, so SourceFileId identity already implies an
|
||||
// identical range today; verifying it here keeps this fast path sound even
|
||||
// if replication ever re-chunks (split/coalesce), instead of silently
|
||||
// depending on that invariant from another file.
|
||||
if ex[i].Offset != ref[i].Offset || ex[i].Size != ref[i].Size {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hasStaleBackupChunk reports whether existing holds a chunk that this backup
|
||||
// wrote (SourceFileId set) whose source chunk `reference` no longer references.
|
||||
// That means an overwrite/deletion event was missed: the destination still carries
|
||||
// the superseded source chunk's bytes even though total coverage looks full, so the
|
||||
// incremental diff cannot fix it. Chunks without SourceFileId (seeded out-of-band,
|
||||
// e.g. rsync or a direct write) are ignored, so such destinations keep their normal
|
||||
// path and are never re-copied on this signal.
|
||||
func hasStaleBackupChunk(existing, reference *filer_pb.Entry) bool {
|
||||
if existing == nil || reference == nil {
|
||||
return false
|
||||
}
|
||||
refFids := make(map[string]bool, len(reference.GetChunks()))
|
||||
for _, c := range reference.GetChunks() {
|
||||
refFids[c.GetFileIdString()] = true
|
||||
}
|
||||
for _, c := range existing.GetChunks() {
|
||||
if c.SourceFileId == "" {
|
||||
continue // not backup-written → do not judge staleness
|
||||
}
|
||||
if !refFids[c.SourceFileId] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mergedIntervals returns the entry's chunk byte ranges, sorted and merged
|
||||
// (overlapping or touching ranges combined). Chunk manifests are counted by their
|
||||
// own offset/size span, which already covers the range they wrap, so no manifest
|
||||
// resolution is needed.
|
||||
func mergedIntervals(entry *filer_pb.Entry) [][2]int64 {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
chunks := entry.GetChunks()
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
ivs := make([][2]int64, 0, len(chunks))
|
||||
for _, c := range chunks {
|
||||
ivs = append(ivs, [2]int64{int64(c.Offset), int64(c.Offset) + int64(c.Size)})
|
||||
}
|
||||
sort.Slice(ivs, func(i, j int) bool { return ivs[i][0] < ivs[j][0] })
|
||||
merged := make([][2]int64, 0, len(ivs))
|
||||
for _, iv := range ivs {
|
||||
if n := len(merged); n > 0 && iv[0] <= merged[n-1][1] {
|
||||
if iv[1] > merged[n-1][1] {
|
||||
merged[n-1][1] = iv[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
merged = append(merged, iv)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// coversReference reports whether existing's chunks cover every byte range that
|
||||
// reference's chunks cover. Equal total coverage is not sufficient proof — extra
|
||||
// chunks at other offsets can hide a missing range — so containment is verified
|
||||
// range by range against the merged interval sets.
|
||||
func coversReference(existing, reference *filer_pb.Entry) bool {
|
||||
ex := mergedIntervals(existing)
|
||||
i := 0
|
||||
for _, r := range mergedIntervals(reference) {
|
||||
lo, hi := r[0], r[1]
|
||||
for lo < hi {
|
||||
for i < len(ex) && ex[i][1] <= lo {
|
||||
i++
|
||||
}
|
||||
if i >= len(ex) || ex[i][0] > lo {
|
||||
return false // a byte in reference is not covered by existing
|
||||
}
|
||||
lo = ex[i][1]
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -18,6 +18,59 @@ func entryNs(mtime int64, ns int32, size uint64) *filer_pb.Entry {
|
||||
return &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{Mtime: mtime, MtimeNs: ns, FileSize: size}}
|
||||
}
|
||||
|
||||
// entryChunks builds an entry with nChunks contiguous 1-byte chunks at a given
|
||||
// mtime, so coveredBytes == nChunks. Different counts give different coverage,
|
||||
// which is what the truncation-repair routing keys on.
|
||||
func entryChunks(mtime int64, nChunks int) *filer_pb.Entry {
|
||||
e := &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{Mtime: mtime, FileSize: uint64(nChunks)}}
|
||||
for i := range nChunks {
|
||||
e.Chunks = append(e.Chunks, &filer_pb.FileChunk{
|
||||
FileId: "3," + string(rune('a'+i)), Offset: int64(i), Size: 1,
|
||||
})
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// entryMd5 is like entryChunks but also sets attr.Md5, so filer.ETag returns the
|
||||
// stored Md5 (the S3 PutObject case) instead of a chunk-derived ETag. Used to show
|
||||
// that coverage — not the Md5-backed ETag — drives the truncation decision.
|
||||
func entryMd5(mtime int64, nChunks int, md5 []byte) *filer_pb.Entry {
|
||||
e := entryChunks(mtime, nChunks)
|
||||
e.Attributes.Md5 = md5
|
||||
return e
|
||||
}
|
||||
|
||||
// srcEntry builds a source-side entry at mtime whose chunks carry the given
|
||||
// FileIds — the identities a backup destination's SourceFileId is matched against.
|
||||
func srcEntry(mtime int64, fids ...string) *filer_pb.Entry {
|
||||
e := &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{Mtime: mtime}}
|
||||
for i, f := range fids {
|
||||
e.Chunks = append(e.Chunks, &filer_pb.FileChunk{FileId: f, Offset: int64(i), Size: 1})
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// dstEntry builds a destination entry at mtime. An empty src marks a chunk
|
||||
// written out-of-band (no SourceFileId, e.g. rsync/direct); otherwise it is a
|
||||
// backup chunk copied from that source FileId.
|
||||
func dstEntry(mtime int64, srcFids ...string) *filer_pb.Entry {
|
||||
e := &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{Mtime: mtime}}
|
||||
for i, s := range srcFids {
|
||||
e.Chunks = append(e.Chunks, &filer_pb.FileChunk{FileId: "dst", SourceFileId: s, Offset: int64(i), Size: 1})
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// ivEntry builds an entry at mtime with chunks at the given [offset,end) byte
|
||||
// ranges — for coverage/containment tests where exact ranges matter.
|
||||
func ivEntry(mtime int64, ivs ...[2]int64) *filer_pb.Entry {
|
||||
e := &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{Mtime: mtime}}
|
||||
for _, iv := range ivs {
|
||||
e.Chunks = append(e.Chunks, &filer_pb.FileChunk{Offset: iv[0], Size: uint64(iv[1] - iv[0])})
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// getEntryMtimeNs must order versions written within the same second, which
|
||||
// plain second-grained mtime cannot. It also must be nil-safe.
|
||||
func TestGetEntryMtimeNs(t *testing.T) {
|
||||
@@ -65,28 +118,181 @@ func TestChooseUpdateAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
existing *filer_pb.Entry
|
||||
oldEntry *filer_pb.Entry
|
||||
incoming *filer_pb.Entry
|
||||
want updateAction
|
||||
}{
|
||||
{"nil existing entry", nil, entry(200, 100), updateNormal},
|
||||
{"destination older, catching up", entry(100, 50), entry(200, 100), updateNormal},
|
||||
{"same mtime", entry(200, 50), entry(200, 100), updateNormal},
|
||||
{"destination newer and complete", entry(300, 100), entry(200, 100), updateSkip},
|
||||
{"destination newer and larger", entry(300, 200), entry(200, 100), updateSkip},
|
||||
{"destination newer but truncated", entry(300, 90), entry(200, 100), updateRepair},
|
||||
{"nil existing entry", nil, entry(100, 100), entry(200, 100), updateNormal},
|
||||
{"destination older, catching up", entry(100, 50), entry(100, 50), entry(200, 100), updateNormal},
|
||||
{"same mtime", entry(200, 50), entry(200, 50), entry(200, 100), updateNormal},
|
||||
{"destination newer and complete", entry(300, 100), entry(100, 100), entry(200, 100), updateSkip},
|
||||
{"destination newer and larger", entry(300, 200), entry(100, 100), entry(200, 100), updateSkip},
|
||||
// out-of-order: a newer (smaller) destination must NOT be rolled back by an
|
||||
// older, larger replayed event — even though it does not cover oldEntry.
|
||||
{"newer smaller dest, older larger event -> skip", ivEntry(300, [2]int64{0, 5}), ivEntry(100, [2]int64{0, 10}), ivEntry(100, [2]int64{0, 10}), updateSkip},
|
||||
// same second: sub-second ordering must still pick the winner
|
||||
{"same second, destination newer and complete", entryNs(5, 200, 100), entryNs(5, 100, 100), updateSkip},
|
||||
{"same second, destination older", entryNs(5, 100, 100), entryNs(5, 200, 100), updateNormal},
|
||||
{"same second, destination newer and complete", entryNs(5, 200, 100), entryNs(5, 100, 100), entryNs(5, 100, 100), updateSkip},
|
||||
{"same second, destination older", entryNs(5, 100, 100), entryNs(5, 100, 100), entryNs(5, 200, 100), updateNormal},
|
||||
// chunk-incompleteness (the truncation bug): destination is at/behind the
|
||||
// incoming mtime but covers fewer bytes than oldEntry, so the incremental
|
||||
// diff cannot heal it — must repair in full, not append. entryChunks(n) has
|
||||
// n contiguous 1-byte chunks → coverage n.
|
||||
{"behind, covers less than oldEntry -> repair", entryChunks(200, 2), entryChunks(200, 4), entryChunks(200, 4), updateRepair},
|
||||
{"behind, matches oldEntry coverage -> normal", entryChunks(200, 3), entryChunks(200, 3), entryChunks(200, 5), updateNormal},
|
||||
{"behind, exceeds oldEntry coverage -> normal", entryChunks(200, 5), entryChunks(200, 3), entryChunks(200, 5), updateNormal},
|
||||
// range containment, not scalar coverage: equal total bytes but a missing
|
||||
// range (the destination has an extra range at another offset) → repair.
|
||||
{"behind, equal total but range gap -> repair", ivEntry(200, [2]int64{0, 10}, [2]int64{30, 40}), ivEntry(200, [2]int64{0, 20}), ivEntry(200, [2]int64{0, 20}), updateRepair},
|
||||
// same-range stale: full coverage but a backup-written chunk whose source
|
||||
// oldEntry no longer references → repair only when the destination is
|
||||
// provably older. Equal-mtime is deferred (cannot order same-second
|
||||
// versions). Out-of-band (rsync) chunks are never stale-checked.
|
||||
{"older, stale backup chunk -> repair", dstEntry(100, "A", "X"), srcEntry(100, "A", "B"), srcEntry(200, "A", "B"), updateRepair},
|
||||
{"equal-mtime stale -> deferred (normal)", dstEntry(200, "A", "X"), srcEntry(200, "A", "B"), srcEntry(200, "A", "B"), updateNormal},
|
||||
{"older, rsync chunks -> normal", dstEntry(100, "", ""), srcEntry(100, "A", "B"), srcEntry(200, "A", "B"), updateNormal},
|
||||
// in-sync fast path: destination holds exactly oldEntry's chunks → normal
|
||||
{"in-sync (fast path) -> normal", dstEntry(100, "A", "B"), srcEntry(100, "A", "B"), srcEntry(200, "A", "B"), updateNormal},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := chooseUpdateAction(tc.existing, tc.incoming); got != tc.want {
|
||||
if got := chooseUpdateAction(tc.existing, tc.oldEntry, tc.incoming); got != tc.want {
|
||||
t.Fatalf("chooseUpdateAction = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// coversReference must verify range containment, not just equal total bytes: a
|
||||
// destination with the same byte count but a missing range (extra chunk elsewhere)
|
||||
// does not cover the reference.
|
||||
func TestCoversReference(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
existing, reference *filer_pb.Entry
|
||||
want bool
|
||||
}{
|
||||
{"empty reference", ivEntry(0), ivEntry(0), true},
|
||||
{"reference no chunks", ivEntry(0, [2]int64{0, 10}), ivEntry(0), true},
|
||||
{"exact", ivEntry(0, [2]int64{0, 10}), ivEntry(0, [2]int64{0, 10}), true},
|
||||
{"superset", ivEntry(0, [2]int64{0, 20}), ivEntry(0, [2]int64{0, 10}), true},
|
||||
{"truncated tail", ivEntry(0, [2]int64{0, 5}), ivEntry(0, [2]int64{0, 10}), false},
|
||||
{"equal total, range gap", ivEntry(0, [2]int64{0, 10}, [2]int64{30, 40}), ivEntry(0, [2]int64{0, 20}), false},
|
||||
{"contiguous parts cover", ivEntry(0, [2]int64{0, 10}, [2]int64{10, 20}), ivEntry(0, [2]int64{0, 20}), true},
|
||||
{"overlapping parts cover", ivEntry(0, [2]int64{0, 12}, [2]int64{8, 20}), ivEntry(0, [2]int64{0, 20}), true},
|
||||
{"existing empty, reference has bytes", ivEntry(0), ivEntry(0, [2]int64{0, 1}), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := coversReference(tc.existing, tc.reference); got != tc.want {
|
||||
t.Fatalf("coversReference = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// destinationSatisfiesSource gates CreateEntry's skip. Coverage/identity must be
|
||||
// checked before ETag: a truncated or stale destination whose attr.Md5 still
|
||||
// matches the source (so filer.ETag is equal) must NOT be skipped, or
|
||||
// -initialSnapshot would leave it permanently diverged.
|
||||
func TestDestinationSatisfiesSource(t *testing.T) {
|
||||
md5A := []byte("0123456789abcdef")
|
||||
md5B := []byte("fedcba9876543210")
|
||||
cases := []struct {
|
||||
name string
|
||||
existing, incoming *filer_pb.Entry
|
||||
want bool
|
||||
}{
|
||||
{"nil existing", nil, entryChunks(200, 4), false},
|
||||
{"identical complete -> skip", entryChunks(200, 4), entryChunks(200, 4), true},
|
||||
{"truncated (no md5) -> replicate", entryChunks(200, 2), entryChunks(200, 4), false},
|
||||
// regression: same attr.Md5 (equal filer.ETag) but fewer chunks must repair
|
||||
{"md5-backed truncated -> replicate", entryMd5(200, 2, md5A), entryMd5(200, 4, md5A), false},
|
||||
{"md5 equal and complete -> skip", entryMd5(200, 4, md5A), entryMd5(200, 4, md5A), true},
|
||||
// complete but different content: keep newer, replace older
|
||||
{"complete, dest newer -> skip", entryMd5(300, 4, md5A), entryMd5(200, 4, md5B), true},
|
||||
{"complete, dest older -> replicate", entryMd5(200, 4, md5A), entryMd5(300, 4, md5B), false},
|
||||
// same-range stale backup chunk: replicate only when provably older; an
|
||||
// equal-mtime stale dest is deferred (skip), and rsync chunks are not
|
||||
// stale-checked.
|
||||
{"older, stale backup chunk -> replicate", dstEntry(100, "A", "X"), srcEntry(200, "A", "B"), false},
|
||||
{"equal-mtime stale -> skip (deferred)", dstEntry(200, "A", "X"), srcEntry(200, "A", "B"), true},
|
||||
{"rsync complete -> skip", dstEntry(200, "", ""), srcEntry(200, "A", "B"), true},
|
||||
{"in-sync (fast path) -> skip", dstEntry(200, "A", "B"), srcEntry(200, "A", "B"), true},
|
||||
// out-of-order: newer (smaller) destination must not be rolled back by an
|
||||
// older, larger source replay.
|
||||
{"newer smaller dest, older larger source -> skip", ivEntry(300, [2]int64{0, 5}), ivEntry(100, [2]int64{0, 10}), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := destinationSatisfiesSource(tc.existing, tc.incoming); got != tc.want {
|
||||
t.Fatalf("destinationSatisfiesSource = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// destinationMatchesReference is the allocation-free fast path: true only when the
|
||||
// destination holds exactly the reference's chunks in order (by SourceFileId). It
|
||||
// must be conservative — never a false positive — so reorders, count mismatches,
|
||||
// and out-of-band (no SourceFileId) chunks all return false.
|
||||
func TestDestinationMatchesReference(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
existing, reference *filer_pb.Entry
|
||||
want bool
|
||||
}{
|
||||
{"both empty", dstEntry(0), srcEntry(0), true},
|
||||
{"exact in order", dstEntry(0, "A", "B"), srcEntry(0, "A", "B"), true},
|
||||
{"count mismatch", dstEntry(0, "A"), srcEntry(0, "A", "B"), false},
|
||||
{"reordered", dstEntry(0, "B", "A"), srcEntry(0, "A", "B"), false},
|
||||
{"stale identity", dstEntry(0, "A", "X"), srcEntry(0, "A", "B"), false},
|
||||
{"out-of-band chunk", dstEntry(0, "A", ""), srcEntry(0, "A", "B"), false},
|
||||
// Same identity but a different byte range must not fast-path as a match,
|
||||
// so the precise checks still run if replication ever re-chunks.
|
||||
{"shifted offset, same identity",
|
||||
&filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{Mtime: 0}, Chunks: []*filer_pb.FileChunk{
|
||||
{FileId: "dst", SourceFileId: "A", Offset: 0, Size: 1},
|
||||
{FileId: "dst", SourceFileId: "B", Offset: 5, Size: 1},
|
||||
}}, srcEntry(0, "A", "B"), false},
|
||||
{"larger size, same identity",
|
||||
&filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{Mtime: 0}, Chunks: []*filer_pb.FileChunk{
|
||||
{FileId: "dst", SourceFileId: "A", Offset: 0, Size: 2},
|
||||
}}, srcEntry(0, "A"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := destinationMatchesReference(tc.existing, tc.reference); got != tc.want {
|
||||
t.Fatalf("destinationMatchesReference = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// hasStaleBackupChunk flags a destination that still holds a backup-written chunk
|
||||
// (SourceFileId set) the source reference no longer lists — a missed
|
||||
// overwrite/deletion. Out-of-band chunks (no SourceFileId, e.g. rsync) are ignored.
|
||||
func TestHasStaleBackupChunk(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
existing, reference *filer_pb.Entry
|
||||
want bool
|
||||
}{
|
||||
{"nil existing", nil, srcEntry(200, "A"), false},
|
||||
{"in sync", dstEntry(200, "A", "B"), srcEntry(200, "A", "B"), false},
|
||||
{"stale backup chunk", dstEntry(200, "A", "X"), srcEntry(200, "A", "B"), true},
|
||||
{"rsync chunks ignored", dstEntry(200, "", ""), srcEntry(200, "A", "B"), false},
|
||||
{"synced backup + rsync", dstEntry(200, "A", ""), srcEntry(200, "A", "B"), false},
|
||||
{"stale backup among rsync", dstEntry(200, "X", ""), srcEntry(200, "A", "B"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := hasStaleBackupChunk(tc.existing, tc.reference); got != tc.want {
|
||||
t.Fatalf("hasStaleBackupChunk = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// updatedEntryKey must resolve the incoming entry's new path. For a rename the
|
||||
// supersession check has to target newParentPath/newEntry.Name, not the old key,
|
||||
// or the renamed-away old path looks deleted on the source and skips a live event.
|
||||
|
||||
Reference in New Issue
Block a user