mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-24 23:10:41 +02:00
* filer.backup: process metadata events through the concurrent processor Each event was handled inline on the subscription loop, so one fetch+write at a time capped the stream at a few entries per second and a busy source kept building lag. Route events through the same MetadataProcessor filer.sync uses: it fans jobs out behind -concurrency while serializing same-path and subtree-barrier conflicts, and the checkpoint now advances on the processor's contiguous watermark via GetResumeTsNs, with Resubscribe replaying anything a failure pins. -disableErrorRetry keeps its log-and-skip meaning by returning nil after logging, since the processor would otherwise pin the checkpoint forever. * filer.backup: drain in-flight jobs before the stream retry loop reconnects FollowMetadata returns as soon as the stream ends while admitted jobs keep writing; the next doFilerBackup builds a fresh processor that cannot order its replayed writes against them, so an older upload could finish last and clobber a newer file. * filer.backup: checkpoint the completed watermark on a ticker and at stream end The offset callback only fires while events arrive, so work draining on a quiet stream stayed uncheckpointed until the next event and a restart recopied it. Persist the processed watermark every 3s when it advances, and once more after the post-stream drain. * filer.backup: bound the checkpoint wait before reconnecting setOffset has no context, so a stalled KvPut would hold persistDone and block the retry loop forever. The persist goroutine now performs the final write itself on stopPersist, and the caller waits at most 30s. * filer.backup: carry the processed position across retry attempts A follow attempt that ended with a pinned failure recomputed startFrom on retry, so a -timeAgo window could move past the failed event and skip it. doFilerBackup now returns the failure pin (or processed watermark) and the retry loop clamps startFrom to it. * filer.backup: resume retries at the previous attempt's pinned position The retry loop threaded resumeTsNs through but never consulted it, so a -timeAgo run recomputed startFrom as now-minus-the-window on every attempt — a failed event older than the window was skipped on reconnect. Use the pinned position when one exists; -timeAgo and the persisted checkpoint only choose the first starting point. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.backup: resume below the failure pin so the failed event replays The metadata reader treats the resume position as exclusive — events at or below it are skipped — so resuming at the failed event's own timestamp would skip it. Back one tick off the pin. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.backup: label the progress line as the received-event rate The counter increments when a job is admitted, not when its copy finishes, so the old wording showed backup throughput during a burst of slow copies while the processed watermark had not moved. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: bound the offset checkpoint RPC setOffset called KvPut on the background context, so a stalled write could pin a caller's drain or persistence loop indefinitely. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.backup: keep the resume pin when filer-source setup fails A retried attempt carries resumeTsNs so the failed event replays from the same position, but filer-source and SSE initialization failures returned 0, so the next attempt recomputed -timeAgo and could move the window past the pinned event. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: serialize checkpoint writes per key A checkpoint write can outlive its caller's wait and land beside the next subscription attempt's writes, moving saved progress backward. Serialize writes per key inside setOffset and drop any value older than one already written from this process. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.backup: gofmt * replication: serialize checkpoint writes per filer and key A single process-wide lock made a stalled checkpoint RPC block every stream's checkpoint, including the opposite direction of a bidirectional filer.sync. Keeping the last-written watermark per persisted key also let one stream's offset shadow another stream checkpointing the same key to a different target filer. Lock and track progress per (filer, key). * replication: keep the resume position behind a cleared failure pin A redelivery that clears failedTs lets the processed watermark advance past the earliest failure, but events dropped while the processor was stopped — possibly sharing the failure's timestamp — have to replay. Latched earliestFailedTsNs floors the resume position one tick below that failure so reconnects still cover them. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: persist floored checkpoints through the write ordering setOffset drops a value older than the last written, which also swallows a deliberate regression: the resume floor, an operator-seeded offset, or a snapshot high-water mark. allowRegress marks those callers so the checkpoint can move backward when a failure forces replay. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: order checkpoint writes by issuance, not value The value-based stale guard could never fire — every caller passed allowRegress because floors and seeds are deliberate regressions — and ordering by value cannot tell a stale high watermark apart from real progress: a write that outlives its attempt could still land after a floored resume and skip events owed a replay. Order by issuance instead: a write issued before the last one to land under the key is dropped, so late writes from a superseded attempt can no longer move the checkpoint in either direction while deliberate regressions still apply. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: keep the resume position one tick below the frontier * remote.sync: resume and persist through the floored watermark * test: concurrent metadata jobs fan out up to the limit Distinct-path events must all be in flight before any finishes — a serial loop could only ever have one running — while the limit still bounds the pool. Pins the property behind the backup-throughput fix. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
598 lines
26 KiB
Go
598 lines
26 KiB
Go
package command
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
nethttp "net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/replication/repl_util"
|
|
"github.com/seaweedfs/seaweedfs/weed/replication/sink"
|
|
"github.com/seaweedfs/seaweedfs/weed/replication/sink/filersink"
|
|
"github.com/seaweedfs/seaweedfs/weed/replication/source"
|
|
"github.com/seaweedfs/seaweedfs/weed/security"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/http"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
type FilerBackupOptions struct {
|
|
isActivePassive *bool
|
|
filer *string
|
|
path *string
|
|
excludePaths *string
|
|
excludeFileName *string // deprecated: use excludeFileNames
|
|
excludeFileNames *string
|
|
excludePathPatterns *string
|
|
debug *bool
|
|
proxyByFiler *bool
|
|
doDeleteFiles *bool
|
|
disableErrorRetry *bool
|
|
ignore404Error *bool
|
|
timeAgo *time.Duration
|
|
retentionDays *int
|
|
initialSnapshot *bool
|
|
concurrency *int
|
|
}
|
|
|
|
var (
|
|
filerBackupOptions FilerBackupOptions
|
|
ignorable404ErrString = fmt.Sprintf("%d %s: %s", nethttp.StatusNotFound, nethttp.StatusText(nethttp.StatusNotFound), http.ErrNotFound.Error())
|
|
)
|
|
|
|
func init() {
|
|
cmdFilerBackup.Run = runFilerBackup // break init cycle
|
|
filerBackupOptions.filer = cmdFilerBackup.Flag.String("filer", "localhost:8888", "filer of one SeaweedFS cluster")
|
|
filerBackupOptions.path = cmdFilerBackup.Flag.String("filerPath", "/", "directory to sync on filer")
|
|
filerBackupOptions.excludePaths = cmdFilerBackup.Flag.String("filerExcludePaths", "", "exclude directories to sync on filer")
|
|
filerBackupOptions.excludeFileName = cmdFilerBackup.Flag.String("filerExcludeFileName", "", "[DEPRECATED: use -filerExcludeFileNames] exclude file names that match the regexp")
|
|
filerBackupOptions.excludeFileNames = cmdFilerBackup.Flag.String("filerExcludeFileNames", "", "comma-separated wildcard patterns to exclude file names, e.g., \"*.tmp,._*\"")
|
|
filerBackupOptions.excludePathPatterns = cmdFilerBackup.Flag.String("filerExcludePathPatterns", "", "comma-separated wildcard patterns to exclude paths where any component matches, e.g., \".snapshot,temp*\"")
|
|
filerBackupOptions.proxyByFiler = cmdFilerBackup.Flag.Bool("filerProxy", false, "read and write file chunks by filer instead of volume servers")
|
|
filerBackupOptions.doDeleteFiles = cmdFilerBackup.Flag.Bool("doDeleteFiles", false, "delete files on the destination")
|
|
filerBackupOptions.debug = cmdFilerBackup.Flag.Bool("debug", false, "debug mode to print out received files")
|
|
filerBackupOptions.timeAgo = cmdFilerBackup.Flag.Duration("timeAgo", 0, "start time before now. \"300ms\", \"1.5h\" or \"2h45m\". Valid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\"")
|
|
filerBackupOptions.retentionDays = cmdFilerBackup.Flag.Int("retentionDays", 0, "incremental backup retention days")
|
|
filerBackupOptions.disableErrorRetry = cmdFilerBackup.Flag.Bool("disableErrorRetry", false, "disables errors retry, only logs will print")
|
|
filerBackupOptions.ignore404Error = cmdFilerBackup.Flag.Bool("ignore404Error", true, "ignore 404 errors from filer")
|
|
filerBackupOptions.initialSnapshot = cmdFilerBackup.Flag.Bool("initialSnapshot", false, "before subscribing to metadata updates, walk the live filer tree under -filerPath and seed the destination, then subscribe from the walk-start timestamp so concurrent changes are still captured. Runs on every start when -timeAgo is 0 and overwrites the saved checkpoint, so it also re-seeds a reinitialized destination. Remove it once the backup is caught up, otherwise it re-walks the whole tree on each restart.")
|
|
filerBackupOptions.concurrency = cmdFilerBackup.Flag.Int("concurrency", DefaultConcurrencyLimit, "the maximum number of files that will be backed up concurrently.")
|
|
}
|
|
|
|
var cmdFilerBackup = &Command{
|
|
UsageLine: "filer.backup -filer=<filerHost>:<filerPort> ",
|
|
Short: "resume-able continuously replicate files from a SeaweedFS cluster to another location defined in replication.toml",
|
|
Long: `resume-able continuously replicate files from a SeaweedFS cluster to another location defined in replication.toml
|
|
|
|
filer.backup listens on filer notifications. If any file is updated, it will fetch the updated content,
|
|
and write to the destination. This is to replace filer.replicate command since additional message queue is not needed.
|
|
|
|
If restarted and "-timeAgo" is not set, the synchronization will resume from the previous checkpoints, persisted every minute.
|
|
A fresh sync will start from the earliest metadata logs.
|
|
|
|
On a fresh sync the metadata event log only re-materializes files that still exist on the source; entries that were
|
|
created and later deleted are replayed as a create-then-delete pair and therefore never appear on the destination.
|
|
Pass "-initialSnapshot" to walk the live filer tree first and seed the destination with the current tree, then
|
|
subscribe from the walk-start timestamp. This also re-seeds a reinitialized destination: it overwrites the saved
|
|
checkpoint and re-walks on every start, so remove it once the backup is caught up.
|
|
|
|
`,
|
|
}
|
|
|
|
func runFilerBackup(cmd *Command, args []string) bool {
|
|
|
|
util.LoadSecurityConfiguration()
|
|
util.LoadConfiguration("replication", true)
|
|
|
|
// Compile exclude patterns once before the retry loop — these are
|
|
// configuration errors and must not be retried.
|
|
reExcludeFileName, err := compileExcludePattern(*filerBackupOptions.excludeFileName, "exclude file name")
|
|
if err != nil {
|
|
glog.Fatalf("invalid -filerExcludeFileName: %v", err)
|
|
}
|
|
excludeFileNames := wildcard.CompileWildcardMatchers(*filerBackupOptions.excludeFileNames)
|
|
excludePathPatterns := wildcard.CompileWildcardMatchers(*filerBackupOptions.excludePathPatterns)
|
|
|
|
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client")
|
|
|
|
clientId := util.RandomInt32()
|
|
var clientEpoch int32
|
|
var resumeTsNs int64
|
|
|
|
for {
|
|
clientEpoch++
|
|
var err error
|
|
resumeTsNs, err = doFilerBackup(grpcDialOption, &filerBackupOptions, reExcludeFileName, excludeFileNames, excludePathPatterns, clientId, clientEpoch, resumeTsNs)
|
|
if err != nil {
|
|
glog.Errorf("backup from %s: %v", *filerBackupOptions.filer, err)
|
|
time.Sleep(1747 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
|
|
const (
|
|
BackupKeyPrefix = "backup."
|
|
)
|
|
|
|
func doFilerBackup(grpcDialOption grpc.DialOption, backupOption *FilerBackupOptions, reExcludeFileName *regexp.Regexp, excludeFileNames []*wildcard.WildcardMatcher, excludePathPatterns []*wildcard.WildcardMatcher, clientId int32, clientEpoch int32, resumeTsNs int64) (int64, error) {
|
|
|
|
// find data sink
|
|
dataSink := findSink(util.GetViper())
|
|
if dataSink == nil {
|
|
return 0, fmt.Errorf("no data sink configured in replication.toml")
|
|
}
|
|
|
|
sourceFiler := pb.ServerAddress(*backupOption.filer)
|
|
sourcePath := *backupOption.path
|
|
excludePaths := util.StringSplit(*backupOption.excludePaths, ",")
|
|
timeAgo := *backupOption.timeAgo
|
|
targetPath := dataSink.GetSinkToDirectory()
|
|
debug := *backupOption.debug
|
|
|
|
// get start time for the data sink
|
|
startFrom := time.Unix(0, 0)
|
|
sinkId, legacySinkId := backupCheckpointIds(sourcePath, dataSink)
|
|
runSnapshot := *backupOption.initialSnapshot && timeAgo == 0
|
|
if resumeTsNs > 0 {
|
|
// The previous attempt pinned where it stopped — a failed event's
|
|
// timestamp or the processed watermark — which can sit behind both the
|
|
// persisted checkpoint and a recomputed -timeAgo window.
|
|
startFrom = time.Unix(0, resumeTsNs)
|
|
glog.V(0).Infof("resuming previous attempt at %v", startFrom)
|
|
} else if timeAgo == 0 {
|
|
if runSnapshot {
|
|
// snapshot below sets the start point; no checkpoint read needed
|
|
glog.V(0).Infof("initialSnapshot requested — walking live tree before subscribing")
|
|
} else {
|
|
lastOffsetTsNs, err := getOffsetWithFallback(grpcDialOption, sourceFiler, BackupKeyPrefix, sinkId, BackupKeyPrefix, legacySinkId)
|
|
if err != nil {
|
|
glog.V(0).Infof("starting from %v (offset read failed: %v)", startFrom, err)
|
|
} else if lastOffsetTsNs > 0 {
|
|
startFrom = time.Unix(0, lastOffsetTsNs)
|
|
glog.V(0).Infof("resuming from %v", startFrom)
|
|
} else {
|
|
glog.V(0).Infof("starting from %v (no prior checkpoint)", startFrom)
|
|
}
|
|
}
|
|
} else {
|
|
startFrom = time.Now().Add(-timeAgo)
|
|
glog.V(0).Infof("start time is set to %v", startFrom)
|
|
}
|
|
|
|
// create filer sink
|
|
filerSource := &source.FilerSource{}
|
|
if err := filerSource.DoInitialize(
|
|
sourceFiler.ToHttpAddress(),
|
|
sourceFiler.ToGrpcAddress(),
|
|
sourcePath,
|
|
*backupOption.proxyByFiler); err != nil {
|
|
return resumeTsNs, fmt.Errorf("filersource initialization failed: %v", err)
|
|
}
|
|
|
|
if err := repl_util.InitializeSSEForReplication(filerSource); err != nil {
|
|
return resumeTsNs, fmt.Errorf("SSE initialization failed: %v", err)
|
|
}
|
|
dataSink.SetSourceFiler(filerSource)
|
|
|
|
// Walk and seed the live tree, then subscribe from the walk-start watermark:
|
|
// replaying the event log alone misses entries created-then-deleted before
|
|
// the walk. The watermark overwrites any stale checkpoint, re-seeding a wiped
|
|
// destination.
|
|
if runSnapshot {
|
|
snapshotTsNs, err := runInitialSnapshot(sourceFiler.ToGrpcAddress(), filerSource, sourcePath, targetPath, excludePaths, reExcludeFileName, excludeFileNames, excludePathPatterns, dataSink, *backupOption.ignore404Error)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("initial snapshot: %w", err)
|
|
}
|
|
// The walk can take hours on large trees; retry the tiny KV write a
|
|
// handful of times before giving up so a flaky filer KV doesn't force
|
|
// the whole walk to repeat on the next retry loop iteration.
|
|
if err := persistSnapshotOffset(grpcDialOption, sourceFiler, sinkId, snapshotTsNs); err != nil {
|
|
glog.Errorf("initialSnapshot: FAILED to persist offset %d for sinkId %d after retries: %v — the next retry will redo the full walk", snapshotTsNs, sinkId, err)
|
|
return 0, fmt.Errorf("persist initial snapshot offset: %w", err)
|
|
}
|
|
startFrom = time.Unix(0, snapshotTsNs)
|
|
// walk once per process; retries resume from the persisted checkpoint
|
|
*backupOption.initialSnapshot = false
|
|
glog.V(0).Infof("initialSnapshot done; subscribing from %v", startFrom)
|
|
}
|
|
|
|
var processEventFn func(*filer_pb.SubscribeMetadataResponse) error
|
|
if *backupOption.ignore404Error {
|
|
processEventFnGenerated := genProcessFunction(sourcePath, targetPath, excludePaths, reExcludeFileName, excludeFileNames, excludePathPatterns, dataSink, *backupOption.doDeleteFiles, debug)
|
|
processEventFn = func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
err := processEventFnGenerated(resp)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if isIgnorable404(err) {
|
|
glog.V(0).Infof("got 404 error for %s, ignore it: %s", getSourceKey(resp), err.Error())
|
|
return nil
|
|
}
|
|
if isSourceLookupError(err) && eventSourceSuperseded(filerSource, resp) {
|
|
glog.V(0).Infof("source superseded %s during lookup failure, skip it: %s", getSourceKey(resp), err.Error())
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
} else {
|
|
processEventFn = genProcessFunction(sourcePath, targetPath, excludePaths, reExcludeFileName, excludeFileNames, excludePathPatterns, dataSink, *backupOption.doDeleteFiles, debug)
|
|
}
|
|
|
|
if *backupOption.disableErrorRetry {
|
|
// TrivialOnError semantics: log the failure and advance. Left to the
|
|
// processor a permanent failure would pin the checkpoint and replay
|
|
// forever, which is the opposite of what this flag asks for.
|
|
inner := processEventFn
|
|
processEventFn = func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
if err := inner(resp); err != nil {
|
|
glog.Errorf("process %v: %v", resp, err)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Serial event handling caps throughput at one fetch+write at a time, so a
|
|
// busy source keeps outrunning the backup. The metadata processor fans
|
|
// events out while serializing same-path and subtree-barrier conflicts,
|
|
// and the checkpoint only advances on the contiguous processed watermark.
|
|
concurrency := *backupOption.concurrency
|
|
if concurrency < 1 || concurrency > 1024 {
|
|
glog.Warningf("invalid concurrency value %d, using default: %d", concurrency, DefaultConcurrencyLimit)
|
|
concurrency = DefaultConcurrencyLimit
|
|
}
|
|
processor := NewMetadataProcessor(processEventFn, concurrency, startFrom.UnixNano())
|
|
|
|
// AddOffsetFunc only checkpoints while events arrive; persist the
|
|
// completed watermark on a ticker too so work finishing on a quiet
|
|
// stream does not sit uncheckpointed until the next event.
|
|
var lastPersistedTsNs atomic.Int64
|
|
lastPersistedTsNs.Store(-1)
|
|
stopPersist := make(chan struct{})
|
|
persistDone := make(chan struct{})
|
|
go func() {
|
|
defer close(persistDone)
|
|
ticker := time.NewTicker(3 * time.Second)
|
|
defer ticker.Stop()
|
|
persist := func() {
|
|
if ts := resumeTsNsFor(processor); ts != lastPersistedTsNs.Load() {
|
|
if err := setOffset(grpcDialOption, sourceFiler, BackupKeyPrefix, sinkId, ts); err == nil {
|
|
lastPersistedTsNs.Store(ts)
|
|
} else {
|
|
glog.Warningf("persist backup offset: %v", err)
|
|
}
|
|
}
|
|
}
|
|
for {
|
|
select {
|
|
case <-stopPersist:
|
|
persist()
|
|
return
|
|
case <-ticker.C:
|
|
persist()
|
|
}
|
|
}
|
|
}()
|
|
|
|
var lastLogTsNs = time.Now().UnixNano()
|
|
processEventFnWithOffset := pb.AddOffsetFunc(func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
processor.AddSyncJob(resp)
|
|
return nil
|
|
}, 3*time.Second, func(counter int64, lastTsNs int64) error {
|
|
offsetTsNs := processor.processedTsWatermark.Load()
|
|
if offsetTsNs == 0 {
|
|
return nil
|
|
}
|
|
now := time.Now().UnixNano()
|
|
glog.V(0).Infof("backup %s received %0.2f events/sec, processed through %v", sourceFiler, float64(counter)/(float64(now-lastLogTsNs)/1e9), time.Unix(0, offsetTsNs))
|
|
lastLogTsNs = now
|
|
return nil
|
|
})
|
|
|
|
if dataSink.IsIncremental() && *filerBackupOptions.retentionDays > 0 {
|
|
go func() {
|
|
for {
|
|
now := time.Now()
|
|
time.Sleep(time.Hour * 24)
|
|
key := util.Join(targetPath, now.Add(-1*time.Hour*24*time.Duration(*filerBackupOptions.retentionDays)).Format("2006-01-02"))
|
|
_ = dataSink.DeleteEntry(util.Join(targetPath, key), true, true, nil)
|
|
glog.V(0).Infof("incremental backup delete directory:%s", key)
|
|
}
|
|
}()
|
|
}
|
|
|
|
prefix := sourcePath
|
|
if !strings.HasSuffix(prefix, "/") {
|
|
prefix = prefix + "/"
|
|
}
|
|
|
|
eventErrorType := pb.RetryForeverOnError
|
|
if *backupOption.disableErrorRetry {
|
|
eventErrorType = pb.TrivialOnError
|
|
}
|
|
|
|
metadataFollowOption := &pb.MetadataFollowOption{
|
|
ClientName: "backup_" + dataSink.GetName(),
|
|
ClientId: clientId,
|
|
ClientEpoch: clientEpoch,
|
|
SelfSignature: 0,
|
|
PathPrefix: prefix,
|
|
AdditionalPathPrefixes: nil,
|
|
DirectoriesToWatch: nil,
|
|
StartTsNs: startFrom.UnixNano(),
|
|
StopTsNs: 0,
|
|
EventErrorType: eventErrorType,
|
|
GetResumeTsNs: func() int64 {
|
|
return resumeTsNsFor(processor)
|
|
},
|
|
Resubscribe: processor.ResubscribeCh(),
|
|
}
|
|
|
|
err := pb.FollowMetadata(sourceFiler, grpcDialOption, metadataFollowOption, processEventFnWithOffset)
|
|
// Jobs admitted before the stream ended keep running; drain them so the
|
|
// retry loop's replay cannot race an older write past a newer one.
|
|
processor.Drain()
|
|
close(stopPersist)
|
|
// setOffset has no context, so a stalled checkpoint RPC must not hold the
|
|
// retry loop: bound the wait and let a late write land in the background.
|
|
select {
|
|
case <-persistDone:
|
|
case <-time.After(30 * time.Second):
|
|
glog.Warningf("backup %s offset checkpoint did not finish in 30s; reconnecting anyway", sourceFiler)
|
|
}
|
|
// Resume the next attempt at the floored watermark — never recompute a
|
|
// position that skips events. The floor sits one tick below the earliest
|
|
// failure and stays there even when a redelivery clears the pin, so
|
|
// events dropped while the processor was stopped still replay.
|
|
return resumeTsNsFor(processor), err
|
|
|
|
}
|
|
|
|
// backupCheckpointIds derives the checkpoint key for this backup and the
|
|
// historical key kept for fallback reads. The checkpoint covers everything
|
|
// that defines one backup stream: what is backed up (the source path) and
|
|
// exactly where it lands (the sink's destination identity). The historical
|
|
// key hashed only sink name + directory, so two backups to different buckets
|
|
// or endpoints sharing a directory layout advanced one checkpoint and could
|
|
// silently skip each other's changes. Writes go only to the new key; the
|
|
// historical key is read once when the new key has no value yet, so an
|
|
// existing backup resumes where it left off after an upgrade.
|
|
//
|
|
// NUL joins the fields because it cannot occur in a path or a configuration
|
|
// value, so distinct field tuples cannot concatenate to the same hash input.
|
|
func backupCheckpointIds(sourcePath string, dataSink sink.ReplicationSink) (sinkId, legacySinkId int32) {
|
|
sinkId = int32(util.HashStringToLong(sourcePath + "\x00" + dataSink.GetName() + "\x00" + dataSink.GetDestinationIdentity()))
|
|
legacySinkId = int32(util.HashStringToLong(dataSink.GetName() + dataSink.GetSinkToDirectory()))
|
|
return
|
|
}
|
|
|
|
func getSourceKey(resp *filer_pb.SubscribeMetadataResponse) string {
|
|
if resp == nil || resp.EventNotification == nil {
|
|
return ""
|
|
}
|
|
message := resp.EventNotification
|
|
if message.NewEntry != nil {
|
|
return string(util.FullPath(message.NewParentPath).Child(message.NewEntry.Name))
|
|
}
|
|
if message.OldEntry != nil {
|
|
return string(util.FullPath(resp.Directory).Child(message.OldEntry.Name))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isIgnorable404 returns true only for a genuine source-side 404, where skipping
|
|
// the event is lossless: errors wrapping http.ErrNotFound, or carrying the S3
|
|
// "404 Not Found: not found" status string (the AWS SDK breaks the unwrap chain).
|
|
// It deliberately does not match "LookupFileId" / "volume id ... not found" —
|
|
// usually transient lookup races whose skip drops live files; those are resolved
|
|
// against the live source instead (isSourceLookupError + eventSourceSuperseded).
|
|
func isIgnorable404(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, http.ErrNotFound) {
|
|
return true
|
|
}
|
|
return strings.Contains(err.Error(), ignorable404ErrString)
|
|
}
|
|
|
|
// isSourceLookupError reports whether err is a source volume-lookup failure.
|
|
// The same strings cover a transient race and a permanently gone volume, so
|
|
// the caller must consult the live source to pick skip or retry.
|
|
func isSourceLookupError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
errStr := err.Error()
|
|
return strings.Contains(errStr, "LookupFileId") ||
|
|
(strings.Contains(errStr, "volume id") && strings.Contains(errStr, "not found"))
|
|
}
|
|
|
|
// eventSourceSuperseded reports whether the live source has moved past the
|
|
// version carried by this event (entry gone or strictly newer), making a skip
|
|
// lossless. Without a NewEntry it reports false so the error propagates.
|
|
func eventSourceSuperseded(filerSource *source.FilerSource, resp *filer_pb.SubscribeMetadataResponse) bool {
|
|
sourcePath, mtimeNs, ok := eventSupersessionProbe(resp)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return filersink.SourceSupersedes(context.Background(), filerSource, sourcePath, mtimeNs)
|
|
}
|
|
|
|
// eventSupersessionProbe derives the source path and replayed mtime of the
|
|
// event's landing entry. Legacy events can carry an empty NewParentPath, so the
|
|
// path comes from MetadataEventTargetFullPath (falls back to resp.Directory) —
|
|
// a wrong path here would read as "gone" and skip a live file.
|
|
func eventSupersessionProbe(resp *filer_pb.SubscribeMetadataResponse) (util.FullPath, int64, bool) {
|
|
if resp == nil || resp.EventNotification == nil || resp.EventNotification.NewEntry == nil {
|
|
return "", 0, false
|
|
}
|
|
return util.FullPath(filer_pb.MetadataEventTargetFullPath(resp)),
|
|
filersink.EntryMtimeNs(resp.EventNotification.NewEntry), true
|
|
}
|
|
|
|
// persistSnapshotOffset writes the snapshot high-water mark to the source
|
|
// filer's KV, retrying a few times with exponential backoff on transient
|
|
// errors. Losing this write forces a full re-walk on the next retry loop
|
|
// iteration, so a small retry budget here is far cheaper than paying the
|
|
// walk cost again on a large tree.
|
|
func persistSnapshotOffset(grpcDialOption grpc.DialOption, sourceFiler pb.ServerAddress, sinkId int32, tsNs int64) error {
|
|
const attempts = 4
|
|
backoff := 500 * time.Millisecond
|
|
var lastErr error
|
|
for i := 1; i <= attempts; i++ {
|
|
if err := setOffset(grpcDialOption, sourceFiler, BackupKeyPrefix, sinkId, tsNs); err == nil {
|
|
return nil
|
|
} else {
|
|
lastErr = err
|
|
if i == attempts {
|
|
break
|
|
}
|
|
glog.V(0).Infof("initialSnapshot: setOffset attempt %d/%d failed: %v (retrying in %v)", i, attempts, err, backoff)
|
|
time.Sleep(backoff)
|
|
backoff *= 2
|
|
}
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
// runInitialSnapshot walks the live filer tree under sourcePath and seeds the
|
|
// destination via the sink. The snapshot timestamp is captured before the walk
|
|
// starts so any create/update/delete that races with the walk is still caught
|
|
// by the subscription that runs afterward (sink CreateEntry is idempotent for
|
|
// all builtin sinks, so replaying a concurrent create from the subscription
|
|
// over an entry already written by the walk is safe).
|
|
//
|
|
// Note on excludes: TraverseBfs enumerates every directory and enqueues its
|
|
// children unconditionally before the callback runs, so the exclude filters
|
|
// below only prevent *processing* of excluded entries — they do not prune the
|
|
// listing RPCs for excluded subtrees. For small system excludes (SystemLogDir)
|
|
// that is fine; for large user-supplied excludes (say an archive directory
|
|
// with millions of files), the listing cost can dominate the snapshot. A
|
|
// proper prune signal through TraverseBfs is a separate change.
|
|
func runInitialSnapshot(
|
|
grpcAddress string,
|
|
filerSource *source.FilerSource,
|
|
sourcePath string,
|
|
targetPath string,
|
|
excludePaths []string,
|
|
reExcludeFileName *regexp.Regexp,
|
|
excludeFileNames []*wildcard.WildcardMatcher,
|
|
excludePathPatterns []*wildcard.WildcardMatcher,
|
|
dataSink sink.ReplicationSink,
|
|
ignore404Error bool,
|
|
) (int64, error) {
|
|
// Metadata events are stamped server-side, so the backup host clock may be
|
|
// ahead of the filer's. Take `now - 1min` as the subscription watermark so
|
|
// a fast client clock can't skip events that fire during/right-after the
|
|
// walk. meta_aggregator.go uses the same 1-minute margin on initial peer
|
|
// traversal; see the note there on why duplicate replay is harmless.
|
|
snapshotTsNs := time.Now().Add(-time.Minute).UnixNano()
|
|
glog.V(0).Infof("initialSnapshot: walking %s on %s -> %s (snapshotTsNs=%d, -1m skew margin applied)", sourcePath, grpcAddress, targetPath, snapshotTsNs)
|
|
|
|
// TraverseBfs fans the callback out across 5 worker goroutines, so counter
|
|
// updates and the progress-log gate need to be safe under concurrent access.
|
|
var entryCount, byteCount atomic.Int64
|
|
start := time.Now()
|
|
var logMu sync.Mutex
|
|
lastLog := start
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
err := filer_pb.TraverseBfs(ctx, filerSource, util.FullPath(sourcePath), func(parentPath util.FullPath, entry *filer_pb.Entry) error {
|
|
parent := string(parentPath)
|
|
sourceKey := parentPath.Child(entry.Name)
|
|
source := string(sourceKey)
|
|
// Check exclusion on the entry's own path too — otherwise a walked
|
|
// directory whose full path is SystemLogDir or a user-excluded path
|
|
// still gets seeded (only its children would be skipped by the parent
|
|
// check).
|
|
if parent == filer.SystemLogDir || strings.HasPrefix(parent, filer.SystemLogDir+"/") ||
|
|
source == filer.SystemLogDir || strings.HasPrefix(source, filer.SystemLogDir+"/") {
|
|
return nil
|
|
}
|
|
if matchesExcludePath(parent, excludePaths) || matchesExcludePath(source, excludePaths) {
|
|
return nil
|
|
}
|
|
if isEntryExcluded(parent, entry, reExcludeFileName, excludeFileNames, excludePathPatterns) {
|
|
return nil
|
|
}
|
|
|
|
if !util.IsEqualOrUnder(source, sourcePath) {
|
|
return nil
|
|
}
|
|
|
|
targetKey := initialSnapshotTargetKey(dataSink, targetPath, sourcePath, sourceKey, entry)
|
|
if err := dataSink.CreateEntry(targetKey, entry, nil); err != nil {
|
|
// A file can be listed by TraverseBfs and then deleted before
|
|
// CreateEntry reads its chunks. The follow phase ignores 404s when
|
|
// -ignore404Error is set; apply the same policy here so the walk
|
|
// doesn't abort (and trigger a full re-walk on retry) just because
|
|
// a single entry disappeared mid-snapshot.
|
|
if ignore404Error {
|
|
if isIgnorable404(err) {
|
|
glog.V(0).Infof("initialSnapshot: source entry %s disappeared, ignore it: %s", sourceKey, err.Error())
|
|
return nil
|
|
}
|
|
// Same decision as the follow phase; lossless because the
|
|
// post-walk subscription replays the newer change.
|
|
if isSourceLookupError(err) && filersink.SourceSupersedes(ctx, filerSource, sourceKey, filersink.EntryMtimeNs(entry)) {
|
|
glog.V(0).Infof("initialSnapshot: source superseded %s mid-walk, skip it: %s", sourceKey, err.Error())
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("seed %s: %w", targetKey, err)
|
|
}
|
|
|
|
curEntries := entryCount.Add(1)
|
|
var curBytes int64
|
|
if entry.Attributes != nil {
|
|
curBytes = byteCount.Add(int64(entry.Attributes.FileSize))
|
|
} else {
|
|
curBytes = byteCount.Load()
|
|
}
|
|
|
|
now := time.Now()
|
|
logMu.Lock()
|
|
shouldLog := now.Sub(lastLog) >= 5*time.Second
|
|
if shouldLog {
|
|
lastLog = now
|
|
}
|
|
logMu.Unlock()
|
|
if shouldLog {
|
|
elapsed := now.Sub(start).Seconds()
|
|
if elapsed == 0 {
|
|
elapsed = 1
|
|
}
|
|
glog.V(0).Infof("initialSnapshot: %d entries / %d bytes seeded (%.1f/sec)", curEntries, curBytes, float64(curEntries)/elapsed)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
glog.V(0).Infof("initialSnapshot: done — %d entries / %d bytes in %v", entryCount.Load(), byteCount.Load(), time.Since(start))
|
|
return snapshotTsNs, nil
|
|
}
|
|
|
|
// initialSnapshotTargetKey pulls the entry mtime and delegates to the shared
|
|
// destKey helper in filer_sync.go so the walk and the event-log path produce
|
|
// the same destination key for the same source entry.
|
|
func initialSnapshotTargetKey(dataSink sink.ReplicationSink, targetPath, sourcePath string, sourceKey util.FullPath, entry *filer_pb.Entry) string {
|
|
var mTime int64
|
|
if entry.Attributes != nil {
|
|
mTime = entry.Attributes.Mtime
|
|
}
|
|
return destKey(dataSink, targetPath, sourcePath, sourceKey, mTime)
|
|
}
|