mirror of
https://github.com/rclone/rclone.git
synced 2025-06-26 23:11:39 +02:00
Add resume feature
Added an interface and machinery for resuming failed uploads. Implemented this interface in the local backend. Later on it can be implemented by any supporting backend. Fixes #87
This commit is contained in:
parent
ac2e5fde36
commit
b015012d8b
2
backend/cache/cache_test.go
vendored
2
backend/cache/cache_test.go
vendored
@ -18,7 +18,7 @@ func TestIntegration(t *testing.T) {
|
|||||||
fstests.Run(t, &fstests.Opt{
|
fstests.Run(t, &fstests.Opt{
|
||||||
RemoteName: "TestCache:",
|
RemoteName: "TestCache:",
|
||||||
NilObject: (*cache.Object)(nil),
|
NilObject: (*cache.Object)(nil),
|
||||||
UnimplementableFsMethods: []string{"PublicLink", "OpenWriterAt"},
|
UnimplementableFsMethods: []string{"PublicLink", "OpenWriterAt", "Resume"},
|
||||||
UnimplementableObjectMethods: []string{"MimeType", "ID", "GetTier", "SetTier"},
|
UnimplementableObjectMethods: []string{"MimeType", "ID", "GetTier", "SetTier"},
|
||||||
SkipInvalidUTF8: true, // invalid UTF-8 confuses the cache
|
SkipInvalidUTF8: true, // invalid UTF-8 confuses the cache
|
||||||
})
|
})
|
||||||
|
@ -43,6 +43,7 @@ func TestIntegration(t *testing.T) {
|
|||||||
"DirCacheFlush",
|
"DirCacheFlush",
|
||||||
"UserInfo",
|
"UserInfo",
|
||||||
"Disconnect",
|
"Disconnect",
|
||||||
|
"Resume",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if *fstest.RemoteName == "" {
|
if *fstest.RemoteName == "" {
|
||||||
|
@ -23,7 +23,7 @@ func TestIntegration(t *testing.T) {
|
|||||||
fstests.Run(t, &fstests.Opt{
|
fstests.Run(t, &fstests.Opt{
|
||||||
RemoteName: *fstest.RemoteName,
|
RemoteName: *fstest.RemoteName,
|
||||||
NilObject: (*crypt.Object)(nil),
|
NilObject: (*crypt.Object)(nil),
|
||||||
UnimplementableFsMethods: []string{"OpenWriterAt"},
|
UnimplementableFsMethods: []string{"OpenWriterAt", "Resume"},
|
||||||
UnimplementableObjectMethods: []string{"MimeType"},
|
UnimplementableObjectMethods: []string{"MimeType"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -24,6 +25,7 @@ import (
|
|||||||
"github.com/rclone/rclone/fs/config/configstruct"
|
"github.com/rclone/rclone/fs/config/configstruct"
|
||||||
"github.com/rclone/rclone/fs/fserrors"
|
"github.com/rclone/rclone/fs/fserrors"
|
||||||
"github.com/rclone/rclone/fs/hash"
|
"github.com/rclone/rclone/fs/hash"
|
||||||
|
"github.com/rclone/rclone/lib/atexit"
|
||||||
"github.com/rclone/rclone/lib/encoder"
|
"github.com/rclone/rclone/lib/encoder"
|
||||||
"github.com/rclone/rclone/lib/file"
|
"github.com/rclone/rclone/lib/file"
|
||||||
"github.com/rclone/rclone/lib/readers"
|
"github.com/rclone/rclone/lib/readers"
|
||||||
@ -230,6 +232,7 @@ type Fs struct {
|
|||||||
precision time.Duration // precision of local filesystem
|
precision time.Duration // precision of local filesystem
|
||||||
warnedMu sync.Mutex // used for locking access to 'warned'.
|
warnedMu sync.Mutex // used for locking access to 'warned'.
|
||||||
warned map[string]struct{} // whether we have warned about this string
|
warned map[string]struct{} // whether we have warned about this string
|
||||||
|
hashState map[string]string // set in resume(), used to restore hash state
|
||||||
|
|
||||||
// do os.Lstat or os.Stat
|
// do os.Lstat or os.Stat
|
||||||
lstat func(name string) (os.FileInfo, error)
|
lstat func(name string) (os.FileInfo, error)
|
||||||
@ -267,11 +270,12 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
f := &Fs{
|
f := &Fs{
|
||||||
name: name,
|
name: name,
|
||||||
opt: *opt,
|
opt: *opt,
|
||||||
warned: make(map[string]struct{}),
|
warned: make(map[string]struct{}),
|
||||||
dev: devUnset,
|
hashState: make(map[string]string),
|
||||||
lstat: os.Lstat,
|
dev: devUnset,
|
||||||
|
lstat: os.Lstat,
|
||||||
}
|
}
|
||||||
f.root = cleanRootPath(root, f.opt.NoUNC, f.opt.Enc)
|
f.root = cleanRootPath(root, f.opt.NoUNC, f.opt.Enc)
|
||||||
f.features = (&fs.Features{
|
f.features = (&fs.Features{
|
||||||
@ -1115,6 +1119,7 @@ func (nwc nopWriterCloser) Close() error {
|
|||||||
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
|
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
|
||||||
var out io.WriteCloser
|
var out io.WriteCloser
|
||||||
var hasher *hash.MultiHasher
|
var hasher *hash.MultiHasher
|
||||||
|
var resumeOpt *fs.OptionResume
|
||||||
|
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
switch x := option.(type) {
|
switch x := option.(type) {
|
||||||
@ -1125,6 +1130,24 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case *fs.OptionResume:
|
||||||
|
resumeOpt = option.(*fs.OptionResume)
|
||||||
|
if resumeOpt.Pos != 0 {
|
||||||
|
fs.Logf(o, "Resuming at byte position: %d", resumeOpt.Pos)
|
||||||
|
// Discard bytes that already exist on backend
|
||||||
|
_, err := io.CopyN(ioutil.Discard, in, resumeOpt.Pos)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hashType := o.fs.Hashes().GetOne()
|
||||||
|
hasher, err = hash.NewMultiHasherTypes(hash.NewHashSet(hashType))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := hasher.RestoreHashState(hashType, o.fs.hashState[o.remote]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1138,7 +1161,12 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
|
|||||||
// If it is a translated link, just read in the contents, and
|
// If it is a translated link, just read in the contents, and
|
||||||
// then create a symlink
|
// then create a symlink
|
||||||
if !o.translatedLink {
|
if !o.translatedLink {
|
||||||
f, err := file.OpenFile(o.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
|
var f *os.File
|
||||||
|
if resumeOpt != nil && resumeOpt.Pos != 0 {
|
||||||
|
f, err = file.OpenFile(o.path, os.O_WRONLY|os.O_APPEND, 0666)
|
||||||
|
} else {
|
||||||
|
f, err = file.OpenFile(o.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if runtime.GOOS == "windows" && os.IsPermission(err) {
|
if runtime.GOOS == "windows" && os.IsPermission(err) {
|
||||||
// If permission denied on Windows might be trying to update a
|
// If permission denied on Windows might be trying to update a
|
||||||
@ -1152,7 +1180,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !o.fs.opt.NoPreAllocate {
|
if !o.fs.opt.NoPreAllocate && resumeOpt != nil && resumeOpt.Pos == 0 {
|
||||||
// Pre-allocate the file for performance reasons
|
// Pre-allocate the file for performance reasons
|
||||||
err = file.PreAllocate(src.Size(), f)
|
err = file.PreAllocate(src.Size(), f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1173,7 +1201,40 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
|
|||||||
in = io.TeeReader(in, hasher)
|
in = io.TeeReader(in, hasher)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.Copy(out, in)
|
var cacheingWg sync.WaitGroup // Used to halt code execution while resume cache is written
|
||||||
|
var copyWg sync.WaitGroup // Ensure that io.Copy has returned before writing resume data
|
||||||
|
copyWg.Add(1)
|
||||||
|
// Context for read so that we can handle io.copy being interrupted
|
||||||
|
ctxr, cancel := context.WithCancel(ctx)
|
||||||
|
// Create exit handler during Copy so that resume data can be written if interrupted
|
||||||
|
atexitHandle := atexit.Register(func() {
|
||||||
|
// If OptionResume was passed, call SetID to prepare for future resumes
|
||||||
|
// ID is the number of bytes written to the destination
|
||||||
|
if resumeOpt != nil {
|
||||||
|
// Stops the copy so cache is consistent with remote
|
||||||
|
cacheingWg.Add(1)
|
||||||
|
cancel()
|
||||||
|
copyWg.Wait()
|
||||||
|
fs.Infof(o, "Updating resume cache")
|
||||||
|
fileInfo, _ := o.fs.lstat(o.path)
|
||||||
|
writtenStr := strconv.FormatInt(fileInfo.Size(), 10)
|
||||||
|
hashType := o.fs.Hashes().GetOne()
|
||||||
|
hashState, err := hasher.GetHashState(hashType)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = resumeOpt.SetID(ctx, writtenStr, hashType.String(), hashState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cr := readers.NewContextReader(ctxr, in)
|
||||||
|
_, err = io.Copy(out, cr)
|
||||||
|
copyWg.Done()
|
||||||
|
atexit.Unregister(atexitHandle)
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
// If resume data is being written we want to wait here for the program to exit
|
||||||
|
cacheingWg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = closeErr
|
err = closeErr
|
||||||
@ -1338,9 +1399,44 @@ func cleanRootPath(s string, noUNC bool, enc encoder.MultiEncoder) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resume checks whether the (remote, ID) pair is valid and returns
|
||||||
|
// the point the file should be resumed from or an error.
|
||||||
|
func (f *Fs) Resume(ctx context.Context, remote, ID, hashName, hashState string) (Pos int64, err error) {
|
||||||
|
cachedPos, err := strconv.ParseInt(ID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Compare hash of partial file on remote with partial hash in cache
|
||||||
|
remoteObject, err := f.NewObject(ctx, remote)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if remoteObject.Size() != cachedPos {
|
||||||
|
return 0, errors.New("size on remote does not match resume cache")
|
||||||
|
}
|
||||||
|
hashType := hash.NameToType(hashName)
|
||||||
|
remoteHash, err := remoteObject.Hash(ctx, hashType)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
cachedHash, err := hash.SumPartialHash(hashName, hashState)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Hashes match, attempt resume
|
||||||
|
if cachedHash == remoteHash {
|
||||||
|
f.hashState[remote] = hashState
|
||||||
|
return cachedPos, nil
|
||||||
|
}
|
||||||
|
// No valid position found, restart from beginning
|
||||||
|
fs.Infof(remote, "Not resuming as cached hash state did not match hash state on remote")
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Check the interfaces are satisfied
|
// Check the interfaces are satisfied
|
||||||
var (
|
var (
|
||||||
_ fs.Fs = &Fs{}
|
_ fs.Fs = &Fs{}
|
||||||
|
_ fs.Resumer = &Fs{}
|
||||||
_ fs.Purger = &Fs{}
|
_ fs.Purger = &Fs{}
|
||||||
_ fs.PutStreamer = &Fs{}
|
_ fs.PutStreamer = &Fs{}
|
||||||
_ fs.Mover = &Fs{}
|
_ fs.Mover = &Fs{}
|
||||||
|
@ -18,7 +18,7 @@ func TestIntegration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
fstests.Run(t, &fstests.Opt{
|
fstests.Run(t, &fstests.Opt{
|
||||||
RemoteName: *fstest.RemoteName,
|
RemoteName: *fstest.RemoteName,
|
||||||
UnimplementableFsMethods: []string{"OpenWriterAt", "DuplicateFiles"},
|
UnimplementableFsMethods: []string{"OpenWriterAt", "DuplicateFiles", "Resume"},
|
||||||
UnimplementableObjectMethods: []string{"MimeType"},
|
UnimplementableObjectMethods: []string{"MimeType"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -130,6 +130,8 @@ type ConfigInfo struct {
|
|||||||
FsCacheExpireDuration time.Duration
|
FsCacheExpireDuration time.Duration
|
||||||
FsCacheExpireInterval time.Duration
|
FsCacheExpireInterval time.Duration
|
||||||
DisableHTTP2 bool
|
DisableHTTP2 bool
|
||||||
|
MaxResumeCacheSize SizeSuffix
|
||||||
|
ResumeCutoff SizeSuffix
|
||||||
HumanReadable bool
|
HumanReadable bool
|
||||||
KvLockTime time.Duration // maximum time to keep key-value database locked by process
|
KvLockTime time.Duration // maximum time to keep key-value database locked by process
|
||||||
}
|
}
|
||||||
@ -163,6 +165,8 @@ func NewConfig() *ConfigInfo {
|
|||||||
c.TPSLimitBurst = 1
|
c.TPSLimitBurst = 1
|
||||||
c.MaxTransfer = -1
|
c.MaxTransfer = -1
|
||||||
c.MaxBacklog = 10000
|
c.MaxBacklog = 10000
|
||||||
|
c.MaxResumeCacheSize = SizeSuffix(100 * 1024)
|
||||||
|
c.ResumeCutoff = -1
|
||||||
// We do not want to set the default here. We use this variable being empty as part of the fall-through of options.
|
// We do not want to set the default here. We use this variable being empty as part of the fall-through of options.
|
||||||
// c.StatsOneLineDateFormat = "2006/01/02 15:04:05 - "
|
// c.StatsOneLineDateFormat = "2006/01/02 15:04:05 - "
|
||||||
c.MultiThreadCutoff = SizeSuffix(250 * 1024 * 1024)
|
c.MultiThreadCutoff = SizeSuffix(250 * 1024 * 1024)
|
||||||
|
@ -132,6 +132,8 @@ func AddFlags(ci *fs.ConfigInfo, flagSet *pflag.FlagSet) {
|
|||||||
flags.BoolVarP(flagSet, &ci.RefreshTimes, "refresh-times", "", ci.RefreshTimes, "Refresh the modtime of remote files")
|
flags.BoolVarP(flagSet, &ci.RefreshTimes, "refresh-times", "", ci.RefreshTimes, "Refresh the modtime of remote files")
|
||||||
flags.BoolVarP(flagSet, &ci.NoConsole, "no-console", "", ci.NoConsole, "Hide console window (supported on Windows only)")
|
flags.BoolVarP(flagSet, &ci.NoConsole, "no-console", "", ci.NoConsole, "Hide console window (supported on Windows only)")
|
||||||
flags.StringVarP(flagSet, &dscp, "dscp", "", "", "Set DSCP value to connections, value or name, e.g. CS1, LE, DF, AF21")
|
flags.StringVarP(flagSet, &dscp, "dscp", "", "", "Set DSCP value to connections, value or name, e.g. CS1, LE, DF, AF21")
|
||||||
|
flags.FVarP(flagSet, &ci.MaxResumeCacheSize, "max-resume-cache-size", "", "The maximum size of the cache used to store data necessary for resuming uploads. When the storage grows beyond this size, the oldest resume data will be deleted. (default 100k")
|
||||||
|
flags.FVarP(flagSet, &ci.ResumeCutoff, "resume-cutoff", "", "If set, attempt to resume all partial uploads larger than this size. (default off)")
|
||||||
flags.DurationVarP(flagSet, &ci.FsCacheExpireDuration, "fs-cache-expire-duration", "", ci.FsCacheExpireDuration, "Cache remotes for this long (0 to disable caching)")
|
flags.DurationVarP(flagSet, &ci.FsCacheExpireDuration, "fs-cache-expire-duration", "", ci.FsCacheExpireDuration, "Cache remotes for this long (0 to disable caching)")
|
||||||
flags.DurationVarP(flagSet, &ci.FsCacheExpireInterval, "fs-cache-expire-interval", "", ci.FsCacheExpireInterval, "Interval to check for expired remotes")
|
flags.DurationVarP(flagSet, &ci.FsCacheExpireInterval, "fs-cache-expire-interval", "", ci.FsCacheExpireInterval, "Interval to check for expired remotes")
|
||||||
flags.BoolVarP(flagSet, &ci.DisableHTTP2, "disable-http2", "", ci.DisableHTTP2, "Disable HTTP/2 in the global transport")
|
flags.BoolVarP(flagSet, &ci.DisableHTTP2, "disable-http2", "", ci.DisableHTTP2, "Disable HTTP/2 in the global transport")
|
||||||
|
@ -163,6 +163,10 @@ type Features struct {
|
|||||||
// Shutdown the backend, closing any background tasks and any
|
// Shutdown the backend, closing any background tasks and any
|
||||||
// cached connections.
|
// cached connections.
|
||||||
Shutdown func(ctx context.Context) error
|
Shutdown func(ctx context.Context) error
|
||||||
|
|
||||||
|
// Resume checks whether the (remote, ID) pair is valid and returns
|
||||||
|
// the point the file should be resumed from or an error.
|
||||||
|
Resume func(ctx context.Context, remote, ID, hashName, hashState string) (Pos int64, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable nil's out the named feature. If it isn't found then it
|
// Disable nil's out the named feature. If it isn't found then it
|
||||||
@ -290,6 +294,9 @@ func (ft *Features) Fill(ctx context.Context, f Fs) *Features {
|
|||||||
if do, ok := f.(Shutdowner); ok {
|
if do, ok := f.(Shutdowner); ok {
|
||||||
ft.Shutdown = do.Shutdown
|
ft.Shutdown = do.Shutdown
|
||||||
}
|
}
|
||||||
|
if do, ok := f.(Resumer); ok {
|
||||||
|
ft.Resume = do.Resume
|
||||||
|
}
|
||||||
return ft.DisableList(GetConfig(ctx).DisableFeatures)
|
return ft.DisableList(GetConfig(ctx).DisableFeatures)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -636,6 +643,13 @@ type Shutdowner interface {
|
|||||||
Shutdown(ctx context.Context) error
|
Shutdown(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resumer is an optional interface for Fs
|
||||||
|
type Resumer interface {
|
||||||
|
// Resume checks whether the (remote, ID) pair is valid and returns
|
||||||
|
// the point the file should be resumed from or an error.
|
||||||
|
Resume(ctx context.Context, remote, ID, hashName, hashState string) (Pos int64, err error)
|
||||||
|
}
|
||||||
|
|
||||||
// ObjectsChan is a channel of Objects
|
// ObjectsChan is a channel of Objects
|
||||||
type ObjectsChan chan Object
|
type ObjectsChan chan Object
|
||||||
|
|
||||||
|
1
fs/fs.go
1
fs/fs.go
@ -48,6 +48,7 @@ var (
|
|||||||
ErrorNotImplemented = errors.New("optional feature not implemented")
|
ErrorNotImplemented = errors.New("optional feature not implemented")
|
||||||
ErrorCommandNotFound = errors.New("command not found")
|
ErrorCommandNotFound = errors.New("command not found")
|
||||||
ErrorFileNameTooLong = errors.New("file name too long")
|
ErrorFileNameTooLong = errors.New("file name too long")
|
||||||
|
ErrorCantResume = errors.New("can't resume file upload")
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckClose is a utility function used to check the return from
|
// CheckClose is a utility function used to check the return from
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@ -264,6 +265,67 @@ func (m *MultiHasher) Size() int64 {
|
|||||||
return m.size
|
return m.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHashState returns the partial hash state for the given hash type encoded as a string
|
||||||
|
func (m *MultiHasher) GetHashState(hashType Type) (string, error) {
|
||||||
|
h, ok := m.h[hashType]
|
||||||
|
if !ok {
|
||||||
|
return "", ErrUnsupported
|
||||||
|
}
|
||||||
|
marshaler, ok := h.(encoding.BinaryMarshaler)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New(hashType.String() + " does not implement encoding.BinaryMarshaler")
|
||||||
|
}
|
||||||
|
data, err := marshaler.MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreHashState restores the partial hash state for the passed hash type
|
||||||
|
func (m *MultiHasher) RestoreHashState(hashType Type, hashState string) error {
|
||||||
|
partialHashState, err := base64.StdEncoding.DecodeString(hashState)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
unmarshaler, ok := m.h[hashType].(encoding.BinaryUnmarshaler)
|
||||||
|
if ok {
|
||||||
|
if err := unmarshaler.UnmarshalBinary(partialHashState); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SumPartialHash returns the hash of the partial hash state
|
||||||
|
func SumPartialHash(hashName, hashState string) (string, error) {
|
||||||
|
partialHashDef, ok := name2hash[hashName]
|
||||||
|
if !ok {
|
||||||
|
return "", ErrUnsupported
|
||||||
|
}
|
||||||
|
partialHash := partialHashDef.newFunc()
|
||||||
|
partialHashState, err := base64.StdEncoding.DecodeString(hashState)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
unmarshaler, ok := partialHash.(encoding.BinaryUnmarshaler)
|
||||||
|
if ok {
|
||||||
|
if err := unmarshaler.UnmarshalBinary(partialHashState); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(partialHash.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NameToType returns the requested hash type or None if the hash type isn't supported
|
||||||
|
func NameToType(hashName string) Type {
|
||||||
|
hashDef, ok := name2hash[hashName]
|
||||||
|
if !ok {
|
||||||
|
return None
|
||||||
|
}
|
||||||
|
return hashDef.hashType
|
||||||
|
}
|
||||||
|
|
||||||
// A Set Indicates one or more hash types.
|
// A Set Indicates one or more hash types.
|
||||||
type Set int
|
type Set int
|
||||||
|
|
||||||
|
@ -3,13 +3,19 @@
|
|||||||
package fs
|
package fs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/rclone/rclone/fs/hash"
|
"github.com/rclone/rclone/fs/hash"
|
||||||
|
"github.com/rclone/rclone/lib/cacheroot"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenOption is an interface describing options for Open
|
// OpenOption is an interface describing options for Open
|
||||||
@ -230,6 +236,144 @@ func (o *HashesOption) Mandatory() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptionResume defines a Put/Upload for doing resumes
|
||||||
|
type OptionResume struct {
|
||||||
|
ID string // resume this ID if set
|
||||||
|
Pos int64 // and resume from this position
|
||||||
|
Src Object
|
||||||
|
F Fs
|
||||||
|
Remote string
|
||||||
|
CacheCleaned bool
|
||||||
|
CacheDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID will be called by backend's Put/Update function if the object's upload
|
||||||
|
// could be resumed upon failure
|
||||||
|
//
|
||||||
|
// SetID takes the passed resume ID, hash state, hash name and Fingerprint of the object and stores it in
|
||||||
|
// --cache-dir so that future Copy operations can resume the upload if it fails
|
||||||
|
func (o *OptionResume) SetID(ctx context.Context, ID, hashName, hashState string) error {
|
||||||
|
ci := GetConfig(ctx)
|
||||||
|
// Get the Fingerprint of the src object so that future Copy operations can ensure the
|
||||||
|
// object hasn't changed before resuming an upload
|
||||||
|
fingerprint := Fingerprint(ctx, o.Src, true)
|
||||||
|
data, err := marshalResumeJSON(ctx, fingerprint, ID, hashName, hashState)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal data JSON: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) < int(ci.MaxResumeCacheSize) {
|
||||||
|
// Each remote will have its own directory for cached resume files
|
||||||
|
dirPath, _, err := cacheroot.CreateCacheRoot(o.CacheDir, o.F.Name(), o.F.Root(), "resume")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = os.MkdirAll(dirPath, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create cache directory %v: %w", dirPath, err)
|
||||||
|
}
|
||||||
|
// Write resume data to disk
|
||||||
|
cachePath := filepath.Join(dirPath, o.Remote)
|
||||||
|
cacheFile, err := os.Create(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create cache file %v: %w", cachePath, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = cacheFile.Close()
|
||||||
|
}()
|
||||||
|
_, errWrite := cacheFile.Write(data)
|
||||||
|
if errWrite != nil {
|
||||||
|
return fmt.Errorf("failed to write JSON to file: %w", errWrite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !o.CacheCleaned {
|
||||||
|
rootCacheDir := filepath.Join(o.CacheDir, "resume")
|
||||||
|
if err := cleanResumeCache(ctx, rootCacheDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to clean resume cache: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.CacheCleaned = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeJSON is a struct for storing resume info in cache
|
||||||
|
type ResumeJSON struct {
|
||||||
|
Fingerprint string `json:"fprint"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
HashName string `json:"hname"`
|
||||||
|
HashState string `json:"hstate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalResumeJSON(ctx context.Context, fprint, id, hashName, hashState string) ([]byte, error) {
|
||||||
|
resumedata := ResumeJSON{
|
||||||
|
Fingerprint: fprint,
|
||||||
|
ID: id,
|
||||||
|
HashName: hashName,
|
||||||
|
HashState: hashState,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(&resumedata)
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanCache checks the size of the resume cache and removes the oldest resume files if more than limit
|
||||||
|
func cleanResumeCache(ctx context.Context, rootCacheDir string) error {
|
||||||
|
ci := GetConfig(ctx)
|
||||||
|
var paths []string
|
||||||
|
pathsWithInfo := make(map[string]os.FileInfo)
|
||||||
|
totalCacheSize := int64(0)
|
||||||
|
walkErr := filepath.Walk(rootCacheDir,
|
||||||
|
func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
// Empty subdirectories in the resume cache dir can be removed
|
||||||
|
removeErr := os.Remove(path)
|
||||||
|
if err != nil && !os.IsNotExist(removeErr) {
|
||||||
|
return fmt.Errorf("failed to remove empty subdirectory: %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
paths = append(paths, path)
|
||||||
|
pathsWithInfo[path] = info
|
||||||
|
totalCacheSize += info.Size()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if walkErr != nil {
|
||||||
|
return fmt.Errorf("error walking through cache when cleaning cache dir: %w", walkErr)
|
||||||
|
}
|
||||||
|
if totalCacheSize > int64(ci.MaxResumeCacheSize) {
|
||||||
|
sort.Slice(paths, func(i, j int) bool {
|
||||||
|
return pathsWithInfo[paths[i]].ModTime().Before(pathsWithInfo[paths[j]].ModTime())
|
||||||
|
})
|
||||||
|
for _, p := range paths {
|
||||||
|
if totalCacheSize < int64(ci.MaxResumeCacheSize) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := os.Remove(p); err != nil {
|
||||||
|
return fmt.Errorf("error removing oldest cache file: %s: %w", p, err)
|
||||||
|
}
|
||||||
|
totalCacheSize -= pathsWithInfo[p].Size()
|
||||||
|
Debugf(p, "Successfully removed oldest cache file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header formats the option as an http header
|
||||||
|
func (o *OptionResume) Header() (key string, value string) {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// String formats the option into human readable form
|
||||||
|
func (o *OptionResume) String() string {
|
||||||
|
return fmt.Sprintf("OptionResume(ID:%v, Pos:%v)", o.ID, o.Pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mandatory returns whether the option must be parsed or can be ignored
|
||||||
|
func (o *OptionResume) Mandatory() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// NullOption defines an Option which does nothing
|
// NullOption defines an Option which does nothing
|
||||||
type NullOption struct {
|
type NullOption struct {
|
||||||
}
|
}
|
||||||
|
23
fs/operations/interrupt_test.go
Normal file
23
fs/operations/interrupt_test.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package operations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sendInterrupt() error {
|
||||||
|
p, err := os.FindProcess(syscall.Getpid())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = p.Signal(os.Interrupt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCmd(cmd *exec.Cmd) {
|
||||||
|
// Only needed for windows
|
||||||
|
}
|
32
fs/operations/interrupt_win_test.go
Normal file
32
fs/operations/interrupt_win_test.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package operations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credit: https://github.com/golang/go/blob/6125d0c4265067cdb67af1340bf689975dd128f4/src/os/signal/signal_windows_test.go#L18
|
||||||
|
func sendInterrupt() error {
|
||||||
|
d, e := syscall.LoadDLL("kernel32.dll")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
p, e := d.FindProc("GenerateConsoleCtrlEvent")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
r, _, e := p.Call(syscall.CTRL_BREAK_EVENT, uintptr(syscall.Getpid()))
|
||||||
|
if r == 0 {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCmd(cmd *exec.Cmd) {
|
||||||
|
(*cmd).SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
|
||||||
|
}
|
||||||
|
}
|
@ -33,6 +33,7 @@ import (
|
|||||||
"github.com/rclone/rclone/fs/object"
|
"github.com/rclone/rclone/fs/object"
|
||||||
"github.com/rclone/rclone/fs/walk"
|
"github.com/rclone/rclone/fs/walk"
|
||||||
"github.com/rclone/rclone/lib/atexit"
|
"github.com/rclone/rclone/lib/atexit"
|
||||||
|
"github.com/rclone/rclone/lib/cacheroot"
|
||||||
"github.com/rclone/rclone/lib/pacer"
|
"github.com/rclone/rclone/lib/pacer"
|
||||||
"github.com/rclone/rclone/lib/random"
|
"github.com/rclone/rclone/lib/random"
|
||||||
"github.com/rclone/rclone/lib/readers"
|
"github.com/rclone/rclone/lib/readers"
|
||||||
@ -364,6 +365,11 @@ func CommonHash(ctx context.Context, fa, fb fs.Info) (hash.Type, *fs.HashesOptio
|
|||||||
// be nil.
|
// be nil.
|
||||||
func Copy(ctx context.Context, f fs.Fs, dst fs.Object, remote string, src fs.Object) (newDst fs.Object, err error) {
|
func Copy(ctx context.Context, f fs.Fs, dst fs.Object, remote string, src fs.Object) (newDst fs.Object, err error) {
|
||||||
ci := fs.GetConfig(ctx)
|
ci := fs.GetConfig(ctx)
|
||||||
|
var resumeOpt *fs.OptionResume
|
||||||
|
if f.Features().Resume != nil {
|
||||||
|
resumeOpt = createResumeOpt(ctx, f, remote, src)
|
||||||
|
}
|
||||||
|
|
||||||
tr := accounting.Stats(ctx).NewTransfer(src)
|
tr := accounting.Stats(ctx).NewTransfer(src)
|
||||||
defer func() {
|
defer func() {
|
||||||
tr.Done(ctx, err)
|
tr.Done(ctx, err)
|
||||||
@ -461,6 +467,10 @@ func Copy(ctx context.Context, f fs.Fs, dst fs.Object, remote string, src fs.Obj
|
|||||||
wrappedSrc = NewOverrideRemote(src, remote)
|
wrappedSrc = NewOverrideRemote(src, remote)
|
||||||
}
|
}
|
||||||
options := []fs.OpenOption{hashOption}
|
options := []fs.OpenOption{hashOption}
|
||||||
|
// Appends OptionResume if it was set
|
||||||
|
if resumeOpt != nil {
|
||||||
|
options = append(options, resumeOpt)
|
||||||
|
}
|
||||||
for _, option := range ci.UploadHeaders {
|
for _, option := range ci.UploadHeaders {
|
||||||
options = append(options, option)
|
options = append(options, option)
|
||||||
}
|
}
|
||||||
@ -475,6 +485,17 @@ func Copy(ctx context.Context, f fs.Fs, dst fs.Object, remote string, src fs.Obj
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
newDst = dst
|
newDst = dst
|
||||||
err = closeErr
|
err = closeErr
|
||||||
|
cacheParent := config.GetCacheDir()
|
||||||
|
// Remove resume cache file (if one was created) when Put/Upload is successful
|
||||||
|
cacheDir, _, err := cacheroot.CreateCacheRoot(cacheParent, f.Name(), f.Root(), "resume")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cacheFile := filepath.Join(cacheDir, remote)
|
||||||
|
removeErr := os.Remove(cacheFile)
|
||||||
|
if err != nil && !os.IsNotExist(removeErr) {
|
||||||
|
return nil, fmt.Errorf("failed to remove resume cache file after upload: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
72
fs/operations/resume.go
Normal file
72
fs/operations/resume.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package operations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/rclone/rclone/fs"
|
||||||
|
"github.com/rclone/rclone/fs/config"
|
||||||
|
"github.com/rclone/rclone/lib/cacheroot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Creates an OptionResume that will be passed to Put/Upload
|
||||||
|
func createResumeOpt(ctx context.Context, f fs.Fs, remote string, src fs.Object) (resumeOpt *fs.OptionResume) {
|
||||||
|
ci := fs.GetConfig(ctx)
|
||||||
|
cacheParent := config.GetCacheDir()
|
||||||
|
resumeOpt = &fs.OptionResume{ID: "", Pos: 0, Src: src, F: f, Remote: remote, CacheCleaned: false, CacheDir: cacheParent}
|
||||||
|
if ci.ResumeCutoff >= 0 {
|
||||||
|
cacheDir, _, err := cacheroot.CreateCacheRoot(cacheParent, f.Name(), f.Root(), "resume")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cacheFile := filepath.Join(cacheDir, remote)
|
||||||
|
resumeID, hashName, hashState, attemptResume := readResumeCache(ctx, f, src, cacheFile)
|
||||||
|
if attemptResume {
|
||||||
|
fs.Debugf(f, "Existing resume cache file found: %s. A resume will now be attempted.", cacheFile)
|
||||||
|
position, resumeErr := f.Features().Resume(ctx, remote, resumeID, hashName, hashState)
|
||||||
|
if resumeErr != nil {
|
||||||
|
fs.Errorf(src, "Resume canceled: %v", resumeErr)
|
||||||
|
} else if position > int64(ci.ResumeCutoff) {
|
||||||
|
resumeOpt.Pos = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resumeOpt
|
||||||
|
}
|
||||||
|
|
||||||
|
// readResumeCache checks to see if a resume ID has been cached for the source object.
|
||||||
|
// If it finds one it returns it along with true to signal a resume can be attempted
|
||||||
|
func readResumeCache(ctx context.Context, f fs.Fs, src fs.Object, cacheName string) (resumeID, hashName, hashState string, attemptResume bool) {
|
||||||
|
existingCacheFile, statErr := os.Open(cacheName)
|
||||||
|
defer func() {
|
||||||
|
_ = existingCacheFile.Close()
|
||||||
|
}()
|
||||||
|
if !os.IsNotExist(statErr) {
|
||||||
|
rawData, readErr := ioutil.ReadAll(existingCacheFile)
|
||||||
|
if readErr == nil {
|
||||||
|
existingFingerprint, resumeID, hashName, hashState, unmarshalErr := unmarshalResumeJSON(ctx, rawData)
|
||||||
|
if unmarshalErr != nil {
|
||||||
|
fs.Debugf(f, "Failed to unmarshal Resume JSON: %s. Resume will not be attempted.", unmarshalErr.Error())
|
||||||
|
} else if existingFingerprint != "" {
|
||||||
|
// Check if the src object has changed by comparing new Fingerprint to Fingerprint in cache file
|
||||||
|
fingerprint := fs.Fingerprint(ctx, src, true)
|
||||||
|
if existingFingerprint == fingerprint {
|
||||||
|
return resumeID, hashName, hashState, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalResumeJSON(ctx context.Context, data []byte) (fprint, id, hashName, hashState string, err error) {
|
||||||
|
var resumedata fs.ResumeJSON
|
||||||
|
err = json.Unmarshal(data, &resumedata)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", err
|
||||||
|
}
|
||||||
|
return resumedata.Fingerprint, resumedata.ID, resumedata.HashName, resumedata.HashState, nil
|
||||||
|
}
|
163
fs/operations/resume_test.go
Normal file
163
fs/operations/resume_test.go
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
package operations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rclone/rclone/fs"
|
||||||
|
"github.com/rclone/rclone/fstest"
|
||||||
|
"github.com/rclone/rclone/fstest/mockobject"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type interruptReader struct {
|
||||||
|
once sync.Once
|
||||||
|
r io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read sends an OS specific interrupt signal and then reads 1 byte at a time
|
||||||
|
func (r *interruptReader) Read(b []byte) (n int, err error) {
|
||||||
|
r.once.Do(func() {
|
||||||
|
_ = sendInterrupt()
|
||||||
|
})
|
||||||
|
buffer := make([]byte, 1)
|
||||||
|
n, err = r.r.Read(buffer)
|
||||||
|
b[0] = buffer[0]
|
||||||
|
// Simulate duration of a larger read without needing to test with a large file
|
||||||
|
// Allows for the interrupt to be handled before Copy completes
|
||||||
|
time.Sleep(time.Microsecond * 10)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is a wrapper for a mockobject with a custom Open function
|
||||||
|
//
|
||||||
|
// n indicates the number of bytes to read before sending an
|
||||||
|
// interrupt signal
|
||||||
|
type resumeTestObject struct {
|
||||||
|
fs.Object
|
||||||
|
n int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens the file for read. Call Close() on the returned io.ReadCloser
|
||||||
|
//
|
||||||
|
// The Reader will signal an interrupt after reading n bytes, then continue to read 1 byte at a time.
|
||||||
|
// If TestResume is successful, the interrupt will be processed and reads will be cancelled before running
|
||||||
|
// out of bytes to read
|
||||||
|
func (o *resumeTestObject) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
|
||||||
|
rc, err := o.Object.Open(ctx, options...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r := io.MultiReader(&io.LimitedReader{R: rc, N: o.n}, &interruptReader{r: rc})
|
||||||
|
// Wrap with Close in a new readCloser
|
||||||
|
rc = readCloser{Reader: r, Closer: rc}
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeContent(t *testing.T, size int) []byte {
|
||||||
|
content := make([]byte, size)
|
||||||
|
r := rand.New(rand.NewSource(42))
|
||||||
|
_, err := io.ReadFull(r, content)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResume(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := fstest.NewRun(t)
|
||||||
|
defer r.Finalise()
|
||||||
|
ci := fs.GetConfig(ctx)
|
||||||
|
ci.ResumeCutoff = 0
|
||||||
|
|
||||||
|
// Contents for the mock object
|
||||||
|
var (
|
||||||
|
// Test contents must be large enough that io.Copy does not complete during the first Rclone Copy operation
|
||||||
|
resumeTestContents = makeContent(t, 1024)
|
||||||
|
expectedContents = resumeTestContents
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create mockobjects with given breaks
|
||||||
|
createTestSrc := func(interrupt int64) (fs.Object, fs.Object) {
|
||||||
|
srcOrig := mockobject.New("potato").WithContent(resumeTestContents, mockobject.SeekModeNone)
|
||||||
|
srcOrig.SetFs(r.Flocal)
|
||||||
|
src := &resumeTestObject{
|
||||||
|
Object: srcOrig,
|
||||||
|
n: interrupt,
|
||||||
|
}
|
||||||
|
return src, srcOrig
|
||||||
|
}
|
||||||
|
|
||||||
|
checkContents := func(obj fs.Object, contents string) {
|
||||||
|
assert.NotNil(t, obj)
|
||||||
|
assert.Equal(t, int64(len(contents)), obj.Size())
|
||||||
|
|
||||||
|
r, err := obj.Open(ctx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, r)
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := ioutil.ReadAll(r)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, contents, string(data))
|
||||||
|
_ = r.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
srcBreak, srcNoBreak := createTestSrc(2)
|
||||||
|
|
||||||
|
// Run first Copy only in a subprocess so that it can be interrupted without ending the test
|
||||||
|
// adapted from: https://stackoverflow.com/questions/26225513/how-to-test-os-exit-scenarios-in-go
|
||||||
|
if os.Getenv("RUNTEST") == "1" {
|
||||||
|
remoteRoot := os.Getenv("REMOTEROOT")
|
||||||
|
remoteFs, err := fs.NewFs(ctx, remoteRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, _ = Copy(ctx, remoteFs, nil, "testdst", srcBreak)
|
||||||
|
// This should never be reached as the subroutine should exit during Copy
|
||||||
|
require.True(t, false, "Problem with test, first Copy operation should've been interrupted before completion")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Start the subprocess
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestResume")
|
||||||
|
cmd.Env = append(os.Environ(), "RUNTEST=1", "REMOTEROOT="+r.Fremote.Root())
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
setupCmd(cmd)
|
||||||
|
err := cmd.Run()
|
||||||
|
|
||||||
|
e, ok := err.(*exec.ExitError)
|
||||||
|
|
||||||
|
// Exit code after signal will be (128+signum) on Linux or (signum) on Windows
|
||||||
|
expectedErrorString := "exit status 1"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
expectedErrorString = "exit status 2"
|
||||||
|
}
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Contains(t, e.Error(), expectedErrorString)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
log.SetOutput(&buf)
|
||||||
|
defer func() {
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Start copy again, but with no breaks
|
||||||
|
newDst, err := Copy(ctx, r.Fremote, nil, "testdst", srcNoBreak)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Checks to see if a resume was initiated
|
||||||
|
// Resumed byte position can vary slightly depending how long it takes atexit to process the interrupt
|
||||||
|
assert.True(t, strings.Contains(buf.String(), "Resuming at byte position: "), "The upload did not resume when restarted. Message: %q", buf.String())
|
||||||
|
|
||||||
|
checkContents(newDst, string(expectedContents))
|
||||||
|
}
|
50
lib/cacheroot/cacheroot.go
Normal file
50
lib/cacheroot/cacheroot.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package cacheroot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/rclone/rclone/lib/encoder"
|
||||||
|
"github.com/rclone/rclone/lib/file"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateCacheRoot will derive and make a subsystem cache path.
|
||||||
|
//
|
||||||
|
// Returned root OS path is an absolute path with UNC prefix,
|
||||||
|
// OS-specific path separators, and encoded with OS-specific encoder.
|
||||||
|
//
|
||||||
|
// Additionally it is returned as a standard path without UNC prefix,
|
||||||
|
// with slash path separators, and standard (internal) encoding.
|
||||||
|
//
|
||||||
|
// Care is taken when creating OS paths so that the ':' separator
|
||||||
|
// following a drive letter is not encoded, e.g. into unicode fullwidth colon.
|
||||||
|
//
|
||||||
|
// parentOSPath should contain an absolute local path in OS encoding.
|
||||||
|
//
|
||||||
|
// Note: instead of fs.Fs it takes name and root as plain strings
|
||||||
|
// to prevent import loops due to dependency on the fs package.
|
||||||
|
func CreateCacheRoot(parentOSPath, fsName, fsRoot, cacheName string) (rootOSPath, standardPath string, err error) {
|
||||||
|
// Get a relative cache path representing the remote.
|
||||||
|
relativeDir := fsRoot
|
||||||
|
if runtime.GOOS == "windows" && strings.HasPrefix(relativeDir, `//?/`) {
|
||||||
|
// Trim off the leading "//" to make the result
|
||||||
|
// valid for appending to another path.
|
||||||
|
relativeDir = relativeDir[2:]
|
||||||
|
}
|
||||||
|
relativeDir = fsName + "/" + relativeDir
|
||||||
|
|
||||||
|
// Derive and make the cache root directory
|
||||||
|
relativeOSPath := filepath.FromSlash(encoder.OS.FromStandardPath(relativeDir))
|
||||||
|
rootOSPath = file.UNCPath(filepath.Join(parentOSPath, cacheName, relativeOSPath))
|
||||||
|
if err = os.MkdirAll(rootOSPath, 0700); err != nil {
|
||||||
|
return "", "", errors.Wrapf(err, "failed to create %s cache directory", cacheName)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentStdPath := encoder.OS.ToStandardPath(filepath.ToSlash(parentOSPath))
|
||||||
|
standardPath = fmt.Sprintf("%s/%s/%s", parentStdPath, cacheName, relativeDir)
|
||||||
|
return rootOSPath, standardPath, nil
|
||||||
|
}
|
@ -9,7 +9,6 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/rclone/rclone/fs"
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -48,7 +47,6 @@ func PreAllocate(size int64, out *os.File) (err error) {
|
|||||||
// Try the next flags combination
|
// Try the next flags combination
|
||||||
index++
|
index++
|
||||||
atomic.StoreInt32(&fallocFlagsIndex, index)
|
atomic.StoreInt32(&fallocFlagsIndex, index)
|
||||||
fs.Debugf(nil, "preAllocate: got error on fallocate, trying combination %d/%d: %v", index, len(fallocFlags), err)
|
|
||||||
goto again
|
goto again
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@ -21,6 +20,7 @@ import (
|
|||||||
"github.com/rclone/rclone/fs/fserrors"
|
"github.com/rclone/rclone/fs/fserrors"
|
||||||
"github.com/rclone/rclone/fs/hash"
|
"github.com/rclone/rclone/fs/hash"
|
||||||
"github.com/rclone/rclone/fs/operations"
|
"github.com/rclone/rclone/fs/operations"
|
||||||
|
"github.com/rclone/rclone/lib/cacheroot"
|
||||||
"github.com/rclone/rclone/lib/encoder"
|
"github.com/rclone/rclone/lib/encoder"
|
||||||
"github.com/rclone/rclone/lib/file"
|
"github.com/rclone/rclone/lib/file"
|
||||||
"github.com/rclone/rclone/vfs/vfscache/writeback"
|
"github.com/rclone/rclone/vfs/vfscache/writeback"
|
||||||
@ -75,43 +75,30 @@ type AddVirtualFn func(remote string, size int64, isDir bool) error
|
|||||||
// This starts background goroutines which can be cancelled with the
|
// This starts background goroutines which can be cancelled with the
|
||||||
// context passed in.
|
// context passed in.
|
||||||
func New(ctx context.Context, fremote fs.Fs, opt *vfscommon.Options, avFn AddVirtualFn) (*Cache, error) {
|
func New(ctx context.Context, fremote fs.Fs, opt *vfscommon.Options, avFn AddVirtualFn) (*Cache, error) {
|
||||||
// Get cache root path.
|
|
||||||
// We need it in two variants: OS path as an absolute path with UNC prefix,
|
|
||||||
// OS-specific path separators, and encoded with OS-specific encoder. Standard path
|
|
||||||
// without UNC prefix, with slash path separators, and standard (internal) encoding.
|
|
||||||
// Care must be taken when creating OS paths so that the ':' separator following a
|
|
||||||
// drive letter is not encoded (e.g. into unicode fullwidth colon).
|
|
||||||
var err error
|
|
||||||
parentOSPath := config.GetCacheDir() // Assuming string contains a local absolute path in OS encoding
|
parentOSPath := config.GetCacheDir() // Assuming string contains a local absolute path in OS encoding
|
||||||
fs.Debugf(nil, "vfs cache: root is %q", parentOSPath)
|
fsName, fsRoot := fremote.Name(), fremote.Root()
|
||||||
parentPath := fromOSPath(parentOSPath)
|
dataOSPath, dataStdPath, err := cacheroot.CreateCacheRoot(parentOSPath, fsName, fsRoot, "vfs")
|
||||||
|
if err != nil {
|
||||||
// Get a relative cache path representing the remote.
|
|
||||||
relativeDirPath := fremote.Root() // This is a remote path in standard encoding
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
if strings.HasPrefix(relativeDirPath, `//?/`) {
|
|
||||||
relativeDirPath = relativeDirPath[2:] // Trim off the "//" for the result to be a valid when appending to another path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
relativeDirPath = fremote.Name() + "/" + relativeDirPath
|
|
||||||
relativeDirOSPath := toOSPath(relativeDirPath)
|
|
||||||
|
|
||||||
// Create cache root dirs
|
|
||||||
var dataOSPath, metaOSPath string
|
|
||||||
if dataOSPath, metaOSPath, err = createRootDirs(parentOSPath, relativeDirOSPath); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
fdata, err := fscache.Get(ctx, dataStdPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get data cache backend: %w", err)
|
||||||
|
}
|
||||||
fs.Debugf(nil, "vfs cache: data root is %q", dataOSPath)
|
fs.Debugf(nil, "vfs cache: data root is %q", dataOSPath)
|
||||||
fs.Debugf(nil, "vfs cache: metadata root is %q", metaOSPath)
|
|
||||||
|
|
||||||
// Get (create) cache backends
|
metaOSPath, metaStdPath, err := cacheroot.CreateCacheRoot(parentOSPath, fsName, fsRoot, "vfsMeta")
|
||||||
var fdata, fmeta fs.Fs
|
if err != nil {
|
||||||
if fdata, fmeta, err = getBackends(ctx, parentPath, relativeDirPath); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
hashType, hashOption := operations.CommonHash(ctx, fdata, fremote)
|
fmeta, err := fscache.Get(ctx, metaStdPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get metadata cache backend: %w", err)
|
||||||
|
}
|
||||||
|
fs.Debugf(nil, "vfs cache: metadata root is %q", metaOSPath)
|
||||||
|
|
||||||
// Create the cache object
|
// Create the cache object
|
||||||
|
hashType, hashOption := operations.CommonHash(ctx, fdata, fremote)
|
||||||
c := &Cache{
|
c := &Cache{
|
||||||
fremote: fremote,
|
fremote: fremote,
|
||||||
fcache: fdata,
|
fcache: fdata,
|
||||||
@ -150,23 +137,6 @@ func createDir(dir string) error {
|
|||||||
return file.MkdirAll(dir, 0700)
|
return file.MkdirAll(dir, 0700)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createRootDir creates a single cache root directory
|
|
||||||
func createRootDir(parentOSPath string, name string, relativeDirOSPath string) (path string, err error) {
|
|
||||||
path = file.UNCPath(filepath.Join(parentOSPath, name, relativeDirOSPath))
|
|
||||||
err = createDir(path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// createRootDirs creates all cache root directories
|
|
||||||
func createRootDirs(parentOSPath string, relativeDirOSPath string) (dataOSPath string, metaOSPath string, err error) {
|
|
||||||
if dataOSPath, err = createRootDir(parentOSPath, "vfs", relativeDirOSPath); err != nil {
|
|
||||||
err = fmt.Errorf("failed to create data cache directory: %w", err)
|
|
||||||
} else if metaOSPath, err = createRootDir(parentOSPath, "vfsMeta", relativeDirOSPath); err != nil {
|
|
||||||
err = fmt.Errorf("failed to create metadata cache directory: %w", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// createItemDir creates the directory for named item in all cache roots
|
// createItemDir creates the directory for named item in all cache roots
|
||||||
//
|
//
|
||||||
// Returns an os path for the data cache file.
|
// Returns an os path for the data cache file.
|
||||||
@ -186,22 +156,6 @@ func (c *Cache) createItemDir(name string) (string, error) {
|
|||||||
return filepath.Join(parentPath, leaf), nil
|
return filepath.Join(parentPath, leaf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBackend gets a backend for a cache root dir
|
|
||||||
func getBackend(ctx context.Context, parentPath string, name string, relativeDirPath string) (fs.Fs, error) {
|
|
||||||
path := fmt.Sprintf("%s/%s/%s", parentPath, name, relativeDirPath)
|
|
||||||
return fscache.Get(ctx, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBackends gets backends for all cache root dirs
|
|
||||||
func getBackends(ctx context.Context, parentPath string, relativeDirPath string) (fdata fs.Fs, fmeta fs.Fs, err error) {
|
|
||||||
if fdata, err = getBackend(ctx, parentPath, "vfs", relativeDirPath); err != nil {
|
|
||||||
err = fmt.Errorf("failed to get data cache backend: %w", err)
|
|
||||||
} else if fmeta, err = getBackend(ctx, parentPath, "vfsMeta", relativeDirPath); err != nil {
|
|
||||||
err = fmt.Errorf("failed to get metadata cache backend: %w", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// clean returns the cleaned version of name for use in the index map
|
// clean returns the cleaned version of name for use in the index map
|
||||||
//
|
//
|
||||||
// name should be a remote path not an osPath
|
// name should be a remote path not an osPath
|
||||||
@ -214,11 +168,6 @@ func clean(name string) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// fromOSPath turns a OS path into a standard/remote path
|
|
||||||
func fromOSPath(osPath string) string {
|
|
||||||
return encoder.OS.ToStandardPath(filepath.ToSlash(osPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
// toOSPath turns a standard/remote path into an OS path
|
// toOSPath turns a standard/remote path into an OS path
|
||||||
func toOSPath(standardPath string) string {
|
func toOSPath(standardPath string) string {
|
||||||
return filepath.FromSlash(encoder.OS.FromStandardPath(standardPath))
|
return filepath.FromSlash(encoder.OS.FromStandardPath(standardPath))
|
||||||
|
Loading…
x
Reference in New Issue
Block a user