mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-12 17:27:50 +02:00
* filer: skip replayed peer events older than the local entry Replay deleted and re-inserted every received event unconditionally, so a restarted filer that rewound its subscription offset re-applied stale peer writes over newer local state (values reverted, deleted entries came back and pointed at already-vacuumed chunks). Compare the event's TsNs against the local entry's Mtime and skip per-path, so a move event can still remove its source while keeping a newer destination. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: add replay last-writer-wins regression tests Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: stop peer subscriptions and flush offsets on shutdown Filer.Shutdown closed the store while subscription goroutines were still mid-stream; their deferred offset save was never guaranteed to run, so a restart rewound each peer's stream back to the last periodic flush and replayed stale events. MetaAggregator.Shutdown now closes every peer's stop channel and waits for the loops so the saved offset covers what was replayed, and the reconnect sleep is interruptible by the stop channel. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: test that shutdown stops and waits for peer loops Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: order replay by persisted per-path commit timestamps Review feedback found the mtime comparison still lets stale replay win: a local delete removes the entry it would have been compared against, an entry mtime is client-controllable, and the check ran unlocked so a local write could land between it and the store mutation. - Record the event timestamp per mutated path in the store KV on every emitted mutation, plus a delete record for sources of deletes and renames. The record survives the entry, so a replayed create older than a local delete no longer resurrects it; ancestor delete records cover children swept without per-child events. - Replay compares the event timestamp against that record under the same per-path entry locks local RPC mutations hold, falling back to entry mtime only for entries written before records existed. Lookup errors now abort the event instead of being treated as an absent entry. - MetaAggregator.Shutdown marks the aggregator stopped under peerChansLock so a master update cannot start a subscription that outlives the wait, and waits for the loops without the five-second cap now that every blocking stage — signature RPC, bootstrap traversal, retry sleeps, and per-event processing — cancels on the stop channel. * filer: pin peer offset at failed replay, fix commit-record context and key size A replayed event that exhausted its retries (or was interrupted by shutdown) was still followed by advancing the peer watermark, so the persisted offset skipped the unapplied event forever. replicateMetadataChange now reports the outcome: errPeerSubscriptionStopped aborts the subscription so the deferred save keeps the last completed event, and an exhausted retry records a barrier the persisted offset and reconnect resume point cannot pass while the stream keeps flowing. Commit records also failed on two fronts: NotifyUpdateEvent runs after CommitTransaction with the same context, so SQL stores wrote MpTs/MpDl through an already-committed *sql.Tx; and metaPathKey embedded the whole path, overflowing the 766-char name column SQL KV stores use, which the KvPut fallback update then silently dropped. The writes now use a fresh context and a sha256-hashed fixed-size key. * filer: lock HTTP delete handler and replay against ancestor paths The HTTP DeleteHandler called DeleteEntryMetaAndData without the shared entry lock, so a peer replay could still interleave with a local delete and its commit record. It now holds the same lock the gRPC DeleteEntry handler does. Replay also locked only the exact event paths, but its ordering check consults ancestor delete records: a local recursive delete holds only the ancestor's lock, so a stale child create could check before the tombstone landed and then reinsert. lockReplayPaths now locks every ancestor of both event paths in one lexicographic order — parents sort before children, as the rename handler's two-path order already does — while never locking the root, which cannot be deleted and would serialize all replays. * filer: record replay commit before the replacement insert If the same-path update DeleteEntry succeeded but InsertEntry failed, the path sat deleted with no commit record until the retry arrived, and a stale peer create could resurrect it in the gap. The commit record now lands immediately after the delete — without a delete record, since an update is not a delete — and again after the successful insert. * filer: lock replay ancestors shared, not exclusive Exclusive locks on every ancestor serialized all replays under a shared parent — unrelated objects in the same bucket could no longer apply in parallel. Ancestors only need to exclude an exclusive holder (a local delete or rename of the ancestor), so they now take shared locks; a path the event changes stays exclusive even when it is also the other path's ancestor. * filer: order peer bootstrap on the sender's commit timestamp traversePeerMetadata compared localCommitTsNs — a commit-clock value — against the entry's mtime, which a client can set freely: a peer change committed recently with an old mtime was skipped, and a stale entry mtime-stamped in the future overwrote a newer local commit. TraverseBfsMetadataResponse now carries the sender's per-path commit timestamp (recorded MpTs record, entry mtime as the unrecorded fallback). The bootstrap applies an entry only when its commit is newer than the local one and records the applied timestamp, so replayed events order against it — closing the gap between bootstrap content and the event stream. A peer without the field sends 0 and keeps the mtime fallback, preserving rolling-upgrade behavior. * filer: keep failed commit records pending until they land notePathChange ran after the mutation committed and only logged a KvPut failure: a delete whose MpTs/MpDl record never landed left no tombstone, so a stale replayed create could resurrect the path while the store was unreachable. Failed records now stay in metaTsPending and a repair pass retries them until they land. localCommitTsNs, deletedAfterTsNs and CommitTsNs consult the pending record, so ordering holds in the window between the committed mutation and the durable record. A per-path flag bounds the repair to one goroutine, and respawns cover merges landing between its last pass and the flag release. * filer: keep only unwritten work in the pending commit record The pending record merged every obligation into one timestamp and flag, and a later successful write left it untouched: a repair pass could then write an older timestamp over a newer one, and a merged delete inherited the newer change's time, which the ancestor check reads as a newer delete. The record now holds a change and a delete timestamp of its own, both discharged under the record's lock alongside the store writes that cover them, so a repair writes only what is still owed. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: fail traversal on a commit-record read error CommitTsNs answered a store read failure with the entry mtime, a value clients can set arbitrarily far ahead; a peer bootstrapping on it would record the minted time and reject real updates below it. The lookup now returns the error so the traversal restarts instead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
112 lines
3.3 KiB
Go
112 lines
3.3 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/viant/ptrie"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
func TestPtrie(t *testing.T) {
|
|
b := []byte("/topics/abc/dev")
|
|
excludedTrie := ptrie.New[bool]()
|
|
excludedTrie.Put([]byte("/topics/abc/d"), true)
|
|
excludedTrie.Put([]byte("/topics/abc"), true)
|
|
|
|
assert.True(t, excludedTrie.MatchPrefix(b, func(key []byte, value bool) bool {
|
|
println("matched1", string(key))
|
|
return true
|
|
}))
|
|
|
|
assert.True(t, excludedTrie.MatchAll(b, func(key []byte, value bool) bool {
|
|
println("matched2", string(key))
|
|
return true
|
|
}))
|
|
|
|
assert.False(t, excludedTrie.MatchAll([]byte("/topics/ab"), func(key []byte, value bool) bool {
|
|
println("matched3", string(key))
|
|
return true
|
|
}))
|
|
|
|
assert.False(t, excludedTrie.Has(b))
|
|
}
|
|
|
|
type captureTraverseStream struct {
|
|
grpc.ServerStream
|
|
ctx context.Context
|
|
visited []string
|
|
entryTsNs map[string]int64
|
|
}
|
|
|
|
func (s *captureTraverseStream) Context() context.Context { return s.ctx }
|
|
|
|
func (s *captureTraverseStream) Send(resp *filer_pb.TraverseBfsMetadataResponse) error {
|
|
dir := resp.Directory
|
|
if dir != "/" {
|
|
dir += "/"
|
|
}
|
|
path := dir + resp.Entry.Name
|
|
s.visited = append(s.visited, path)
|
|
if s.entryTsNs == nil {
|
|
s.entryTsNs = make(map[string]int64)
|
|
}
|
|
s.entryTsNs[path] = resp.EntryTsNs
|
|
return nil
|
|
}
|
|
|
|
func TestTraverseBfsMetadata(t *testing.T) {
|
|
store := newRenameTestStore()
|
|
// "/.system-data" shares the "/.system" byte prefix but is a different
|
|
// path component, so it must not be excluded.
|
|
for _, dir := range []string{"/", "/a", "/a/sub", "/b", "/.system", "/.system-data"} {
|
|
store.entries[dir] = newDirectoryEntry(dir, 1)
|
|
}
|
|
for _, file := range []string{"/a/f1", "/a/sub/f2", "/b/f3", "/.system/secret", "/.system-data/keep"} {
|
|
store.entries[file] = newFileEntry(file, 2)
|
|
}
|
|
|
|
server := &FilerServer{filer: newRenameTestFiler(t, store)}
|
|
stream := &captureTraverseStream{ctx: context.Background()}
|
|
|
|
err := server.TraverseBfsMetadata(&filer_pb.TraverseBfsMetadataRequest{
|
|
Directory: "/",
|
|
ExcludedPrefixes: []string{"/.system"},
|
|
}, stream)
|
|
assert.NoError(t, err)
|
|
|
|
// The excluded subtree is skipped; everything else (including the
|
|
// /.system-data sibling) is visited exactly once.
|
|
assert.ElementsMatch(t, []string{
|
|
"/", "/a", "/a/f1", "/a/sub", "/a/sub/f2", "/b", "/b/f3",
|
|
"/.system-data", "/.system-data/keep",
|
|
}, stream.visited)
|
|
|
|
// Each entry's parent must be streamed before the entry itself, so a
|
|
// consumer can apply the tree top-down.
|
|
seen := make(map[string]bool)
|
|
for _, path := range stream.visited {
|
|
if path != "/" {
|
|
parent, _ := util.FullPath(path).DirAndName()
|
|
assert.Truef(t, seen[parent], "parent %s of %s not streamed first", parent, path)
|
|
}
|
|
seen[path] = true
|
|
}
|
|
|
|
// Every response carries a commit timestamp for ordering; with no
|
|
// recorded commit it falls back to the entry's mtime.
|
|
for path, tsNs := range stream.entryTsNs {
|
|
if path == "/" {
|
|
assert.Equal(t, filer.Root.Attr.Mtime.UnixNano(), tsNs)
|
|
continue
|
|
}
|
|
assert.Equalf(t, store.entries[path].Attr.Mtime.UnixNano(), tsNs, "entry_ts_ns for %s", path)
|
|
}
|
|
}
|
|
|
|
var _ filer_pb.SeaweedFiler_TraverseBfsMetadataServer = (*captureTraverseStream)(nil)
|