Files
seaweedfs/weed/filer/meta_replay.go
T
Chris LuandDevin 72ac424198 filer: deliver meta-log events under their stream position, not their stamp (#12084)
* filer: deliver meta-log events under their stream position, not their stamp

A mutation's event timestamp is stamped before the filer store write so
the entry and its event share a commit time, while the log-buffer append
happens after the write. A write delayed behind later-stamped mutations
keeps its early stamp in the payload while AddDataToBuffer bumps
LogEntry.TsNs to keep the stream monotone - so subscribers could
receive events whose payload TsNs arrives out of order.

Consumers treat the delivered TsNs as a stream position (cursors,
watermarks, replay windows). The s3 lifecycle daily-run drains events in
order and pins its per-shard cursor behind the first not-yet-due match;
an event delivered with a stale stamp after the cursor already passed it
is filtered out forever and its pending match silently never fires -
the flaky test_lifecyclev2_expiration signature of exactly one object of
a same-rule pair surviving, with errors=0 on every pass.

Normalize event.TsNs to logEntry.TsNs at delivery (and for the
skeleton-filtered heartbeat) so delivered timestamps are monotone by
construction. The commit-time stamp remains available on the entries
themselves via CommitTsNs.

* pb,filer: stamp metadata events with a mutation timestamp

TsNs on the wire now carries the delivery stream position, so the
mutation's commit stamp needs its own field. Add mutation_ts_ns to
SubscribeMetadataResponse and set it when the event is created.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: carry the mutation stamp through event delivery

Delivery rewrites TsNs to the stream position; the payload stamp survives
in MutationTsNs, backfilled from TsNs for payloads written before the
field existed. The notification callback forwards it to the wire message.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* pb: normalize chunk-replay timestamps like eachLogEntryFn

ReadLogFileRefs bypasses the server decode path, so a delayed append
still arrived under its stale payload stamp and could hide behind a
subscriber cursor. Deliver the stream position and preserve the stamp in
MutationTsNs.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: order replay and tombstones by the mutation stamp

Replay treated the delivered TsNs as the commit time, so a peer write
delayed in its own log could overwrite a newer local entry. Compare and
stamp with MutationTsNs, falling back to TsNs for older peers.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* remote.sync: carry the mutation stamp through reshaped renames

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: test replay ordering on the mutation stamp

* filer: tombstone remote deletes by delivery position, order by commit stamp

* filer: keep the peer commit stamp when restamping aggregated events

* pb: keep the log cursor on the entry position past callback restamps

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 12:18:40 -07:00

408 lines
15 KiB
Go

package filer
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/protobuf/proto"
)
// Replay applies the delete and the insert as separate, non-transactional
// store calls, so retrying it after a partial failure is not atomic; both
// operations are idempotent and the timestamp check tolerates re-delivery.
//
// A replayed event can be older than local state: a peer resuming from a
// stale offset replays everything since the offset, and two writes through
// different filers race within the replication lag. Apply each path's change
// only when no newer local change is recorded, so a late event cannot revert
// or resurrect entries.
//
// Ordering uses the per-path commit timestamp recorded for every emitted
// mutation (meta_path_ts.go), which survives deletes as a tombstone and is
// not client-controllable the way entry mtime is — a mounted client can set
// mtime into the future, and a rename keeps the source file's mtime on the
// destination entry. Entries written before recording existed fall back to
// their mtime.
//
// The check and the mutation run under the same per-path entry lock the
// filer server's local writes hold, so they cannot be split by a concurrent
// local update or by another peer's replay.
func (f *Filer) Replay(resp *filer_pb.SubscribeMetadataResponse) error {
message := resp.EventNotification
dir := resp.Directory
if message.NewParentPath != "" {
dir = message.NewParentPath
}
var oldPath, newPath util.FullPath
if message.OldEntry != nil {
oldPath = util.NewFullPath(resp.Directory, message.OldEntry.Name)
}
if message.NewEntry != nil {
newPath = util.NewFullPath(dir, message.NewEntry.Name)
}
ctx := context.Background()
mutationTsNs := eventMutationTsNs(resp)
unlock := f.lockReplayPaths(oldPath, newPath)
defer unlock()
samePath := oldPath != "" && oldPath == newPath
if samePath {
newer, err := f.replayBlockedByLocal(ctx, oldPath, mutationTsNs)
if err != nil || newer {
return err
}
newEntry := FromPbEntry(dir, message.NewEntry)
newEntry.CommitTsNs = mutationTsNs
if message.OldEntry.IsDirectory {
glog.V(4).Infof("updating %v", newPath)
return f.updateExistingEntry(ctx, oldPath, newEntry)
}
glog.V(4).Infof("deleting %v", oldPath)
if err := f.Store.DeleteEntry(ctx, oldPath); err != nil {
return err
}
glog.V(4).Infof("creating %v", newPath)
if err := f.Store.InsertEntry(ctx, newEntry); err != nil {
return err
}
return nil
}
if message.OldEntry != nil {
newer, err := f.replayBlockedByLocal(ctx, oldPath, mutationTsNs)
if err != nil {
return err
}
if !newer {
// A directory delete on the origin removed the whole subtree, but
// only the directory's own key replicates. Sweep local children the
// origin never listed, or they orphan under a missing parent.
keepDir := false
if message.OldEntry.IsDirectory {
var err error
keepDir, err = f.deleteReplayedFolderChildren(ctx, oldPath, message, mutationTsNs, resp.TsNs)
if err != nil {
return err
}
}
if !keepDir {
glog.V(4).Infof("deleting %v", oldPath)
if err := f.Store.DeleteEntry(ctx, oldPath); err != nil {
return err
}
}
}
}
if message.NewEntry != nil {
newer, err := f.replayBlockedByLocal(ctx, newPath, mutationTsNs)
if err != nil {
return err
}
if !newer {
glog.V(4).Infof("creating %v", newPath)
newEntry := FromPbEntry(dir, message.NewEntry)
newEntry.CommitTsNs = mutationTsNs
if err := f.Store.InsertEntry(ctx, newEntry); err != nil {
return err
}
}
}
return nil
}
// eventMutationTsNs returns the mutation's commit stamp: TsNs carries the
// delivery stream position while MutationTsNs was stamped before the store
// write. Events from older peers lack the field and fall back to TsNs.
func eventMutationTsNs(event *filer_pb.SubscribeMetadataResponse) int64 {
if event.MutationTsNs != 0 {
return event.MutationTsNs
}
return event.TsNs
}
// updateExistingEntry applies a same-path update without deleting the row in
// between: the path never sits absent, so a failed retry cannot leave it
// unordered — a tombstone would instead read as a subtree delete that
// suppresses a directory's children. UpdateEntry goes on a present row and
// InsertEntry on an absent one (InsertEntry is not an upsert on stores
// configured without upsert support, and UpdateEntry touches no row that is
// not there).
//
// A peer filer sharing the store can still delete the row between the find
// and the update — the UPDATE then changes zero rows yet reports success,
// silently dropping the replay while its subscription offset advances. The
// write is verified by re-reading the entry, and each pass re-checks the
// recorded commit order so a newer peer change wins instead of being
// resurrected over.
func (f *Filer) updateExistingEntry(ctx context.Context, path util.FullPath, newEntry *Entry) error {
var lastErr error
for attempt := 0; attempt < 4; attempt++ {
if attempt > 0 {
newer, err := f.replayBlockedByLocal(ctx, path, newEntry.CommitTsNs)
if err != nil {
return err
}
if newer {
return nil
}
}
if _, findErr := f.Store.FindEntry(ctx, path); findErr == nil {
if err := f.Store.UpdateEntry(ctx, newEntry); err != nil {
return err
}
if _, err := f.Store.FindEntry(ctx, path); err == nil {
return nil
} else if !errors.Is(err, filer_pb.ErrNotFound) {
return err
} else {
lastErr = err
}
} else if errors.Is(findErr, filer_pb.ErrNotFound) {
// An insert colliding with a racing peer create retries as an
// update on the next pass.
if err := f.Store.InsertEntry(ctx, newEntry); err != nil {
lastErr = err
} else {
return nil
}
} else {
return findErr
}
}
return fmt.Errorf("update %s: %w", path, lastErr)
}
// deleteReplayedFolderChildren removes local entries under dir, and queues
// the chunks of removed files for deletion. Children this filer committed
// after the event stay — a newer local write wins over a delayed delete —
// along with the directories that hold them.
func (f *Filer) deleteReplayedFolderChildren(ctx context.Context, dir util.FullPath, message *filer_pb.EventNotification, commitTsNs, streamTsNs int64) (bool, error) {
sweep := &folderSweep{chunksByPath: make(map[util.FullPath][]*filer_pb.FileChunk)}
kept, err := f.sweepReplayedFolder(ctx, dir, message, commitTsNs, streamTsNs, sweep)
// Flush what was collected even on error: the entries already deleted
// cannot be listed again on retry, so their chunks must be queued now.
sweep.flush(ctx, f)
if err != nil {
return false, err
}
return kept, nil
}
// folderSweep accumulates the chunks of removed children for deletion. It
// flushes in batches so a large subtree cannot hold every chunk reference in
// memory at once, and so chunk queueing overlaps the listing work.
type folderSweep struct {
chunksByPath map[util.FullPath][]*filer_pb.FileChunk
count int
}
const folderSweepChunkBatch = 4096
func (s *folderSweep) add(path util.FullPath, chunks []*filer_pb.FileChunk) {
s.chunksByPath[path] = append(s.chunksByPath[path], chunks...)
s.count += len(chunks)
}
func (s *folderSweep) flush(ctx context.Context, f *Filer) {
for path, chunks := range s.chunksByPath {
f.DeleteChunks(ctx, path, chunks)
}
s.chunksByPath = make(map[util.FullPath][]*filer_pb.FileChunk)
s.count = 0
}
// sweepReplayedFolder deletes every child of dir whose local commit is not
// newer than the event, working subtree-first so nested entries go too.
// It reports whether a newer descendant keeps dir alive — the caller then
// keeps the dir entry itself.
func (f *Filer) sweepReplayedFolder(ctx context.Context, dir util.FullPath, message *filer_pb.EventNotification, commitTsNs, streamTsNs int64, sweep *folderSweep) (kept bool, err error) {
keepRemote := IsMetadataOnlyDelete(message.OldEntry)
deleteRemote := !keepRemote && !message.GetIsFromOtherCluster()
lastFileName := ""
for {
entries, _, lerr := f.ListDirectoryEntries(ctx, dir, lastFileName, false, PaginationSize, "", "", "")
if lerr != nil {
return false, fmt.Errorf("list folder %s: %w", dir, lerr)
}
for _, sub := range entries {
lastFileName = sub.Name()
childKept := false
if commitTsNs > 0 {
localTsNs, tsErr := f.localCommitTsNs(ctx, sub.FullPath)
if tsErr != nil {
return false, tsErr
}
childKept = localTsNs > commitTsNs
}
if !childKept && sub.IsDirectory() {
childKept, err = f.sweepReplayedFolder(ctx, sub.FullPath, message, commitTsNs, streamTsNs, sweep)
if err != nil {
return false, err
}
}
if childKept {
kept = true
continue
}
childKept, err = f.deleteSweptChild(ctx, sub, message, commitTsNs, streamTsNs, sweep, keepRemote, deleteRemote)
if err != nil {
return false, err
}
if childKept {
kept = true
}
}
if len(entries) < PaginationSize {
break
}
}
return kept, nil
}
// deleteSweptChild removes sub under its entry lock and reports whether the
// child was kept instead. The listing and the first commit-time check run
// without the lock, so a local write can land in between: the check runs
// again under the lock before anything is removed. A remote-only entry that
// has taken the path since — one the commit record may not cover, such as a
// lazy fetch materialized from the remote — reads the object at the key as
// its only copy: the write-back daemon's processDeleteEvent keeps such an
// object, and the sweep does too.
func (f *Filer) deleteSweptChild(ctx context.Context, sub *Entry, message *filer_pb.EventNotification, commitTsNs, streamTsNs int64, sweep *folderSweep, keepRemote, deleteRemote bool) (bool, error) {
var childLock *util.ActiveLock
if lt := f.EntryLockTable; lt != nil {
// The sweep already holds locks on the event's paths; waiting for a
// descendant here can deadlock against a local rename that holds the
// child and waits for one of them. ErrLockContended is transient, so
// the replication retry picks the event up once the write settles.
var acquired bool
childLock, acquired = lt.TryAcquireLock("MetaReplay", sub.FullPath, util.ExclusiveLock)
if !acquired {
return false, fmt.Errorf("sweep %s: %w", sub.FullPath, util.ErrLockContended)
}
defer lt.ReleaseLock(sub.FullPath, childLock)
}
if commitTsNs > 0 {
localTsNs, tsErr := f.localCommitTsNs(ctx, sub.FullPath)
if tsErr != nil {
return false, tsErr
}
if localTsNs > commitTsNs {
return true, nil
}
}
current, findErr := f.Store.FindEntry(ctx, sub.FullPath)
if findErr != nil && !errors.Is(findErr, filer_pb.ErrNotFound) {
return false, fmt.Errorf("find %s: %w", sub.FullPath, findErr)
}
if current != nil && len(current.Content) == 0 && current.IsInRemoteOnly() &&
!proto.Equal(current.ToProtoEntry(), sub.ToProtoEntry()) {
glog.V(0).InfofCtx(ctx, "keep %s: a remote-only entry has taken the path since the listing", sub.FullPath)
return true, nil
}
if !keepRemote && f.RemoteStorage != nil && f.remoteTombstones != nil {
if _, remoteLoc := f.RemoteStorage.FindMountDirectory(sub.FullPath); remoteLoc != nil {
f.remoteTombstones.addFromEvent(string(sub.FullPath), sub.IsDirectory(), commitTsNs, streamTsNs)
}
}
if deleteRemote && !sub.IsDirectory() {
if remoteErr := f.deleteSweptEntryRemote(ctx, sub); remoteErr != nil {
return false, fmt.Errorf("remote delete %s: %w", sub.FullPath, remoteErr)
}
}
if derr := f.Store.DeleteEntry(ctx, sub.FullPath); derr != nil {
return false, fmt.Errorf("delete %s: %w", sub.FullPath, derr)
}
// chunks of a hard-linked child belong to the shared inode record; free
// them only once that record is gone. A directory entry can still carry
// chunks of its own — an S3 child promotes an existing file to a
// directory without dropping them — so the entry's chunks are keyed to
// its own path whatever it is.
if message.DeleteChunks && len(sub.GetChunks()) > 0 && (len(sub.HardLinkId) == 0 || f.hardLinkIsOrphaned(ctx, sub.HardLinkId)) {
sweep.add(sub.FullPath, sub.GetChunks())
if sweep.count >= folderSweepChunkBatch {
sweep.flush(ctx, f)
}
}
return false, nil
}
// replayBlockedByLocal reports whether the local store already holds a
// version of path committed after the event's timestamp. Lookup failures are
// propagated: a replayed change must not be applied on the guess that the
// path was simply absent, or a transient read error could let a stale event
// overwrite a newer entry past the point of no return.
func (f *Filer) replayBlockedByLocal(ctx context.Context, path util.FullPath, tsNs int64) (bool, error) {
if tsNs <= 0 {
return false, nil
}
localTsNs, err := f.localCommitTsNs(ctx, path)
if err != nil {
return false, err
}
return localTsNs > tsNs, nil
}
// lockReplayPaths holds the shared entry locks for every path the event
// touches, in the same sorted order the rename handler takes them.
// lockReplayPaths takes the per-path entry locks for the event's paths and
// every ancestor of them, in lexicographic order — parents sort before
// children, the same ordering the local rename handler's two-path lock uses,
// so lock acquisition is deadlock-free across operations. Ancestors are
// included because the replay check consults ancestor delete records: a
// local recursive delete or directory rename holds only the ancestor's lock,
// and without contending on it a replay could reinsert a stale child after
// its check. Ancestors are locked shared — they only need to exclude an
// exclusive holder, not each other, or every replay under a busy parent
// would serialize — while a path the event actually changes stays exclusive
// even when it is also the other path's ancestor. The root is never locked:
// it cannot be deleted, and a shared root lock would still be excluded by
// every top-level delete.
func (f *Filer) lockReplayPaths(oldPath, newPath util.FullPath) func() {
lt := f.EntryLockTable
if lt == nil {
return func() {}
}
var paths []util.FullPath
addAncestors := func(p util.FullPath) {
for i := strings.LastIndexByte(string(p), '/'); i > 0; i = strings.LastIndexByte(string(p), '/') {
p = p[:i]
paths = append(paths, p)
}
}
addAncestors(oldPath)
addAncestors(newPath)
if oldPath != "" {
paths = append(paths, oldPath)
}
if newPath != "" {
paths = append(paths, newPath)
}
if len(paths) == 0 {
return func() {}
}
slices.Sort(paths)
paths = slices.Compact(paths)
locks := make([]*util.ActiveLock, len(paths))
for i, p := range paths {
lockType := util.SharedLock
if p == oldPath || p == newPath {
lockType = util.ExclusiveLock
}
locks[i] = lt.AcquireLock("MetaReplay", p, lockType)
}
return func() {
for i := len(paths) - 1; i >= 0; i-- {
lt.ReleaseLock(paths[i], locks[i])
}
}
}