Files
seaweedfs/weed/filer/filer_remote_tombstone.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

469 lines
16 KiB
Go

package filer
import (
"context"
"math"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
"google.golang.org/protobuf/proto"
)
const (
// remoteDeletionTombstoneTTL bounds a tombstone when no write-back sync
// offset ever confirms the remote delete. A remote object re-created
// outside the filer at a deleted path stays hidden for this long.
remoteDeletionTombstoneTTL = 24 * time.Hour
// remoteDeletionTombstoneLimit bounds tracked paths; past it new
// tombstones are dropped after an expired sweep still leaves no room.
remoteDeletionTombstoneLimit = 1 << 16
)
// remoteDeletionTombstones tracks paths deleted under a remote mount whose
// remote object may still exist because the write-back daemon has not
// consumed the delete event yet. A lazy remote fetch or listing must not
// resurrect them. A tombstone lifts when the path is written again, when the
// mount's persisted sync offset passes the delete event, or on TTL.
//
// Tombstones recorded before their metadata event lands (the synchronous
// delete path) are marked pending: the sync offset orders against event
// timestamps, which only the event itself knows, so a pending tombstone can
// only be lifted by the event confirming it or by TTL. Once the event stamps
// the real timestamp the tombstone is releasable by the offset.
// remoteDeletionTombstone carries the delete's commit stamp — the basis for
// ordering it against other mutations — and, once the event lands, the local
// stream position it was delivered under, which the write-back offset must
// pass before the remote delete counts as consumed.
type remoteDeletionTombstone struct {
commitTsNs int64
streamTsNs int64
}
type remoteDeletionTombstones struct {
mu sync.Mutex
files map[string]remoteDeletionTombstone // file path -> delete event
dirs map[string]remoteDeletionTombstone // deleted directory path -> event; covers its subtree
pending map[string]bool // tombstone path recorded ahead of its event
}
func newRemoteDeletionTombstones() *remoteDeletionTombstones {
return &remoteDeletionTombstones{
files: make(map[string]remoteDeletionTombstone),
dirs: make(map[string]remoteDeletionTombstone),
pending: make(map[string]bool),
}
}
// add records a tombstone ahead of its metadata event — the timestamp is the
// local delete time, a lower bound the sync offset cannot order against.
func (t *remoteDeletionTombstones) add(path string, isDir bool, tsNs int64) {
t.upsert(path, isDir, tsNs, 0, false)
}
// addFromEvent records a tombstone stamped by the delete event itself:
// commitTsNs orders it against other mutations, streamTsNs is the delivery
// position the mount's sync offset must pass to release it.
func (t *remoteDeletionTombstones) addFromEvent(path string, isDir bool, commitTsNs, streamTsNs int64) {
t.upsert(path, isDir, commitTsNs, streamTsNs, true)
}
func (t *remoteDeletionTombstones) upsert(path string, isDir bool, commitTsNs, streamTsNs int64, fromEvent bool) {
if t == nil {
return
}
if streamTsNs == 0 {
// No delivery position yet — the offset can never vouch for it.
streamTsNs = math.MaxInt64
}
t.mu.Lock()
defer t.mu.Unlock()
// An ancestor directory tombstone at least as new already covers the
// path; recording it again only spends capacity.
for p := path; ; {
i := strings.LastIndexByte(p, '/')
if i <= 0 {
break
}
p = p[:i]
if ancestor, ok := t.dirs[p]; ok && ancestor.commitTsNs >= commitTsNs {
return
}
}
m := t.files
if isDir {
m = t.dirs
}
if cur, ok := m[path]; ok {
if commitTsNs < cur.commitTsNs {
return
}
m[path] = remoteDeletionTombstone{commitTsNs: commitTsNs, streamTsNs: streamTsNs}
if fromEvent {
delete(t.pending, path)
} else {
// a newer local delete restamps the tombstone ahead of its
// event — the offset cannot vouch for it until the event lands
t.pending[path] = true
}
return
}
if len(t.files)+len(t.dirs) >= remoteDeletionTombstoneLimit {
t.evictExpiredLocked(time.Now().UnixNano())
if len(t.files)+len(t.dirs) >= remoteDeletionTombstoneLimit {
glog.V(0).Infof("remote deletion tombstones full (%d), skipping %s", remoteDeletionTombstoneLimit, path)
return
}
}
m[path] = remoteDeletionTombstone{commitTsNs: commitTsNs, streamTsNs: streamTsNs}
if !fromEvent {
t.pending[path] = true
}
if isDir {
t.dropCoveredLocked(path, commitTsNs)
}
}
// dropCoveredLocked removes descendant tombstones a new directory tombstone
// subsumes: their deletes predate it, so the ancestor already hides those
// remote objects. Descendants deleted later keep their own tombstone.
// Caller must hold t.mu.
func (t *remoteDeletionTombstones) dropCoveredLocked(dirPath string, tsNs int64) {
prefix := dirPath + "/"
for p, cur := range t.files {
if cur.commitTsNs <= tsNs && strings.HasPrefix(p, prefix) {
delete(t.files, p)
delete(t.pending, p)
}
}
for p, cur := range t.dirs {
if cur.commitTsNs <= tsNs && strings.HasPrefix(p, prefix) {
delete(t.dirs, p)
delete(t.pending, p)
}
}
}
// drop removes the exact tombstone recorded for path, e.g. when the delete
// that recorded it fails before touching anything.
func (t *remoteDeletionTombstones) drop(path string, tsNs int64) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if cur, ok := t.dirs[path]; ok && cur.commitTsNs <= tsNs {
delete(t.dirs, path)
delete(t.pending, path)
}
if cur, ok := t.files[path]; ok && cur.commitTsNs <= tsNs {
delete(t.files, path)
delete(t.pending, path)
}
}
// clear drops a file tombstone when a write at the path is at least as new as
// the delete; a replayed older create must not lift a newer delete.
func (t *remoteDeletionTombstones) clear(path string, tsNs int64) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if cur, ok := t.files[path]; ok && tsNs >= cur.commitTsNs {
delete(t.files, path)
delete(t.pending, path)
}
}
// blockedSince returns the tombstone governing path — its own file tombstone
// or the newest one from a deleted ancestor directory — and whether that
// tombstone is still waiting for its event. commitTsNs == 0 means clear.
func (t *remoteDeletionTombstones) blockedSince(path string) (streamTsNs, commitTsNs int64, pending bool) {
if t == nil {
return 0, 0, false
}
t.mu.Lock()
defer t.mu.Unlock()
if cur, ok := t.files[path]; ok {
streamTsNs, commitTsNs, pending = cur.streamTsNs, cur.commitTsNs, t.pending[path]
}
for p := path; ; {
if cur, ok := t.dirs[p]; ok && cur.commitTsNs > commitTsNs {
streamTsNs, commitTsNs, pending = cur.streamTsNs, cur.commitTsNs, t.pending[p]
}
i := strings.LastIndexByte(p, '/')
if i <= 0 {
break
}
p = p[:i]
}
return streamTsNs, commitTsNs, pending
}
// releaseThrough drops the tombstones governing path whose remote delete the
// write-back offset has passed, i.e. recorded at or below streamTsNs.
// Pending tombstones carry no delivery position yet and are never dropped.
func (t *remoteDeletionTombstones) releaseThrough(path string, tsNs int64) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if cur, ok := t.files[path]; ok && cur.streamTsNs <= tsNs {
delete(t.files, path)
delete(t.pending, path)
}
for p := path; ; {
if cur, ok := t.dirs[p]; ok && cur.streamTsNs <= tsNs {
delete(t.dirs, p)
delete(t.pending, p)
}
i := strings.LastIndexByte(p, '/')
if i <= 0 {
break
}
p = p[:i]
}
}
// releaseExpired drops the tombstones governing path that outlived the TTL —
// the only release a tombstone still waiting for its event can get.
func (t *remoteDeletionTombstones) releaseExpired(path string, nowNs int64) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if cur, ok := t.files[path]; ok && nowNs-cur.commitTsNs >= int64(remoteDeletionTombstoneTTL) {
delete(t.files, path)
delete(t.pending, path)
}
for p := path; ; {
if cur, ok := t.dirs[p]; ok && nowNs-cur.commitTsNs >= int64(remoteDeletionTombstoneTTL) {
delete(t.dirs, p)
delete(t.pending, p)
}
i := strings.LastIndexByte(p, '/')
if i <= 0 {
break
}
p = p[:i]
}
}
func (t *remoteDeletionTombstones) evictExpiredLocked(nowNs int64) {
for p, cur := range t.files {
if nowNs-cur.commitTsNs >= int64(remoteDeletionTombstoneTTL) {
delete(t.files, p)
delete(t.pending, p)
}
}
for p, cur := range t.dirs {
if nowNs-cur.commitTsNs >= int64(remoteDeletionTombstoneTTL) {
delete(t.dirs, p)
delete(t.pending, p)
}
}
}
// noteRemoteDeletion records a delete of path under a remote mount so lazy
// remote reads skip it until the remote delete is confirmed.
func (f *Filer) noteRemoteDeletion(p util.FullPath, isDir bool, tsNs int64) {
if f.RemoteStorage == nil || f.remoteTombstones == nil {
return
}
if _, remoteLoc := f.RemoteStorage.FindMountDirectory(p); remoteLoc == nil {
return
}
f.remoteTombstones.add(string(p), isDir, tsNs)
}
// unnoteRemoteDeletion retracts a tombstone when the delete that recorded it
// fails before touching anything under path.
func (f *Filer) unnoteRemoteDeletion(p util.FullPath, tsNs int64) {
if f.remoteTombstones == nil {
return
}
f.remoteTombstones.drop(string(p), tsNs)
}
// isRemoteDeletionPending reports whether a remote write-back delete for p is
// still owed: p was deleted under mountDir and neither a rewrite, the mount's
// sync offset, nor the TTL has lifted the tombstone.
func (f *Filer) isRemoteDeletionPending(ctx context.Context, p util.FullPath, mountDir util.FullPath) bool {
if f.remoteTombstones == nil {
return false
}
streamTsNs, commitTsNs, pending := f.remoteTombstones.blockedSince(string(p))
if commitTsNs == 0 {
return false
}
if pending {
// Recorded ahead of its delete event — the write-back offset cannot
// vouch for it yet; only the TTL lifts it.
if f.remoteDeletionExpired(commitTsNs) {
f.remoteTombstones.releaseExpired(string(p), time.Now().UnixNano())
return false
}
return true
}
if f.remoteDeletionConsumed(ctx, mountDir, streamTsNs) {
f.remoteTombstones.releaseThrough(string(p), streamTsNs)
return false
}
return true
}
func (f *Filer) remoteDeletionExpired(tsNs int64) bool {
return time.Now().UnixNano()-tsNs >= int64(remoteDeletionTombstoneTTL)
}
func (f *Filer) remoteDeletionConsumed(ctx context.Context, mountDir util.FullPath, tsNs int64) bool {
if f.remoteDeletionExpired(tsNs) {
return true
}
offset, err := f.readRemoteSyncOffset(ctx, mountDir)
return err == nil && offset >= tsNs
}
// readRemoteSyncOffset reads the write-back daemon's persisted watermark for
// mountDir straight from the local store: every event at or below it has been
// applied to the remote.
func (f *Filer) readRemoteSyncOffset(ctx context.Context, mountDir util.FullPath) (int64, error) {
value, err := f.Store.KvGet(ctx, remote_storage.SyncOffsetKey(string(mountDir)))
if err != nil {
return 0, err
}
if len(value) < 8 {
return 0, nil
}
return int64(util.BytesToUint64(value)), nil
}
// RebuildRemoteDeletionTombstones gates lazy remote reads and replays the
// persisted metadata log from the oldest write-back offset across mounts,
// restoring tombstones for deletes committed before a restart but not yet
// applied to the remote. Every filer writes its log files under the same
// directory, so pending peer deletes replay too; only events still inside
// the unflushed buffer tail are missed. The gate is set synchronously so no
// lazy read can slip in before replay starts, and it stays closed until a
// replay succeeds.
func (f *Filer) RebuildRemoteDeletionTombstones(ctx context.Context) {
if f.RemoteStorage == nil || f.remoteTombstones == nil {
return
}
mounts := f.RemoteStorage.MountedDirectories()
if len(mounts) == 0 {
return
}
done := make(chan struct{})
f.remoteTombstonesDone.Store(&done)
go f.rebuildRemoteDeletionTombstones(ctx, mounts, done)
}
func (f *Filer) rebuildRemoteDeletionTombstones(ctx context.Context, mounts []util.FullPath, done chan struct{}) {
// the replay itself lists directories; do not let it wait on its own gate
ctx = context.WithValue(ctx, lazyFetchContextKey{}, true)
startTsNs := f.remoteDeletionRebuildStartTsNs(ctx, mounts)
openGate := func() {
close(done)
f.remoteTombstonesDone.Store(nil)
}
// Deletes made after startup are recorded through the live delete and
// event paths, so replay can only be missing deletes committed before
// the restart — and those have all crossed the tombstone TTL once this
// deadline passes. Holding the gate longer protects nothing.
replayDeadline := time.Now().Add(remoteDeletionTombstoneTTL)
backoff := 2 * time.Second
for {
_, _, err := f.ReadPersistedLogBuffer(ctx, log_buffer.NewMessagePosition(startTsNs, 0), 0,
func(logEntry *filer_pb.LogEntry) (bool, error) {
event := &filer_pb.SubscribeMetadataResponse{}
if err := proto.Unmarshal(logEntry.Data, event); err != nil {
return false, nil
}
// Same normalization as delivery: the payload ts is the
// commit stamp, the wrapper ts the stream position.
if event.MutationTsNs == 0 {
event.MutationTsNs = event.TsNs
}
event.TsNs = logEntry.TsNs
f.onRemoteDeletionEvents(event)
return false, nil
})
if err == nil {
openGate()
return
}
glog.WarningfCtx(ctx, "rebuild remote deletion tombstones: %v", err)
if !time.Now().Before(replayDeadline) {
glog.ErrorfCtx(ctx, "rebuild remote deletion tombstones: giving up after %v", remoteDeletionTombstoneTTL)
openGate()
return
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < time.Minute {
backoff *= 2
}
}
}
// remoteDeletionRebuildStartTsNs returns the oldest write-back offset across
// mounts — the earliest event the daemon may not have applied. Mounts without
// a recorded offset replay from the TTL floor: events older than it would
// build tombstones that are already expired.
func (f *Filer) remoteDeletionRebuildStartTsNs(ctx context.Context, mounts []util.FullPath) int64 {
startTsNs := int64(math.MaxInt64)
for _, dir := range mounts {
offset, err := f.readRemoteSyncOffset(ctx, dir)
if err != nil {
glog.WarningfCtx(ctx, "read remote sync offset for %s: %v", dir, err)
offset = 0
}
if offset < startTsNs {
startTsNs = offset
}
}
ttlFloor := time.Now().Add(-remoteDeletionTombstoneTTL).UnixNano()
if startTsNs == int64(math.MaxInt64) || startTsNs < ttlFloor {
return ttlFloor
}
return startTsNs
}
// onRemoteDeletionEvents folds peer and local metadata events into the
// tombstone set: a delete or rename source is tombstoned at the event
// timestamp, a create/update/rename target lifts a file tombstone.
func (f *Filer) onRemoteDeletionEvents(event *filer_pb.SubscribeMetadataResponse) {
message := event.EventNotification
if message == nil {
return
}
// The commit stamp orders the delete against other mutations; event.TsNs
// is the delivery position the write-back offset must pass.
commitTsNs := eventMutationTsNs(event)
if message.OldEntry != nil && !IsMetadataOnlyDelete(message.OldEntry) {
sourcePath := filer_pb.MetadataEventSourceFullPath(event)
if message.NewEntry == nil || sourcePath != filer_pb.MetadataEventTargetFullPath(event) {
if f.RemoteStorage != nil && f.remoteTombstones != nil {
if _, remoteLoc := f.RemoteStorage.FindMountDirectory(util.FullPath(sourcePath)); remoteLoc != nil {
f.remoteTombstones.addFromEvent(sourcePath, message.OldEntry.IsDirectory, commitTsNs, event.TsNs)
}
}
}
}
if message.NewEntry != nil {
f.remoteTombstones.clear(filer_pb.MetadataEventTargetFullPath(event), commitTsNs)
}
}