mirror of
https://github.com/rclone/rclone.git
synced 2026-05-12 01:57:56 -04:00
local: add --local-fatal-if-no-space flag - fixes #8011
When enabled, an out-of-space error during a local write returns a fatal error that aborts the run, instead of being retried. Without this option, ENOSPC errors are treated as retryable and rclone may spin through the retry loop many times on a full disk before giving up. That is fine for transient network errors but unhelpful when the disk is genuinely full and the operator wants the run to fail loudly. Default is off so existing behaviour is unchanged. Implementation follows the pattern suggested in the issue: a defer at the top of Update wraps the error with fserrors.FatalError when the option is on and the error is disk-full. Detection covers both file.ErrDiskFull from the preallocate path and syscall.ENOSPC from io.Copy or Close, via a small helper that uses fserrors.IsErrNoSpace.
This commit is contained in:
committed by
Nick Craig-Wood
parent
4c8bfb7500
commit
1bbe758bc5
@@ -280,6 +280,17 @@ enabled, rclone will no longer update the modtime after copying a file.`,
|
||||
Default: false,
|
||||
Advanced: true,
|
||||
},
|
||||
{
|
||||
Name: "fatal_if_no_space",
|
||||
Help: `Make out-of-space errors fatal during transfers.
|
||||
|
||||
When enabled, an ENOSPC error during a write returns a fatal error so
|
||||
that rclone aborts rather than retrying the operation. Useful for
|
||||
backup scripts that should halt loudly on a full disk rather than spin
|
||||
retrying.`,
|
||||
Default: false,
|
||||
Advanced: true,
|
||||
},
|
||||
{
|
||||
Name: "time_type",
|
||||
Help: `Set what kind of time is returned.
|
||||
@@ -349,6 +360,7 @@ type Options struct {
|
||||
NoPreAllocate bool `config:"no_preallocate"`
|
||||
NoSparse bool `config:"no_sparse"`
|
||||
NoSetModTime bool `config:"no_set_modtime"`
|
||||
FatalIfNoSpace bool `config:"fatal_if_no_space"`
|
||||
TimeType timeType `config:"time_type"`
|
||||
Hashes fs.CommaSepList `config:"hashes"`
|
||||
Enc encoder.MultiEncoder `config:"encoding"`
|
||||
@@ -1410,8 +1422,19 @@ func (nwc nopWriterCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDiskFullError returns true if err indicates the underlying filesystem
|
||||
// has run out of space.
|
||||
func isDiskFullError(err error) bool {
|
||||
return errors.Is(err, file.ErrDiskFull) || fserrors.IsErrNoSpace(err)
|
||||
}
|
||||
|
||||
// Update the object from in with modTime and size
|
||||
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
|
||||
defer func() {
|
||||
if err != nil && o.fs.opt.FatalIfNoSpace && isDiskFullError(err) {
|
||||
err = fserrors.FatalError(err)
|
||||
}
|
||||
}()
|
||||
var out io.WriteCloser
|
||||
var hasher *hash.MultiHasher
|
||||
|
||||
|
||||
98
backend/local/local_internal_diskfull_test.go
Normal file
98
backend/local/local_internal_diskfull_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
//go:build !plan9
|
||||
|
||||
// Tests for the FatalIfNoSpace option and isDiskFullError helper.
|
||||
//
|
||||
// Kept in a separate file with a !plan9 build tag because syscall.ENOSPC
|
||||
// is not portable to plan9, mirroring the split in
|
||||
// fs/fserrors/enospc_error.go vs fs/fserrors/enospc_error_notsupported.go.
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/fs/fserrors"
|
||||
"github.com/rclone/rclone/fs/object"
|
||||
"github.com/rclone/rclone/fstest"
|
||||
"github.com/rclone/rclone/lib/file"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// errReader is an io.Reader that always returns the configured error,
|
||||
// used to inject a synthetic disk-full failure into Update's io.Copy.
|
||||
type errReader struct{ err error }
|
||||
|
||||
func (r errReader) Read(p []byte) (int, error) { return 0, r.err }
|
||||
|
||||
// TestIsDiskFullError covers the helper used by the Update defer.
|
||||
func TestIsDiskFullError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"unrelated error", errors.New("unrelated error"), false},
|
||||
{"syscall.ENOSPC direct", syscall.ENOSPC, true},
|
||||
{"syscall.ENOSPC wrapped", fmt.Errorf("io.Copy: %w", syscall.ENOSPC), true},
|
||||
{"file.ErrDiskFull direct", file.ErrDiskFull, true},
|
||||
{"file.ErrDiskFull wrapped", fmt.Errorf("preallocate: %w", file.ErrDiskFull), true},
|
||||
{"os.PathError wrapping ENOSPC", &os.PathError{Op: "write", Path: "/foo", Err: syscall.ENOSPC}, true},
|
||||
{"os.SyscallError wrapping ENOSPC", os.NewSyscallError("write", syscall.ENOSPC), true},
|
||||
{"os.PathError wrapping unrelated", &os.PathError{Op: "write", Path: "/foo", Err: syscall.EPERM}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, isDiskFullError(c.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// updateWithReader runs Update with an injected reader error and the given
|
||||
// FatalIfNoSpace setting, returning the error from Update.
|
||||
func updateWithReader(t *testing.T, fatalIfNoSpace bool, readerErr error) error {
|
||||
t.Helper()
|
||||
r := fstest.NewRun(t)
|
||||
f := r.Flocal.(*Fs)
|
||||
f.opt.FatalIfNoSpace = fatalIfNoSpace
|
||||
|
||||
src := object.NewStaticObjectInfo("test.txt", time.Now(), 100, true, nil, f)
|
||||
o := &Object{
|
||||
fs: f,
|
||||
remote: "test.txt",
|
||||
path: filepath.Join(r.LocalName, "test.txt"),
|
||||
}
|
||||
return o.Update(context.Background(), errReader{err: readerErr}, src)
|
||||
}
|
||||
|
||||
// TestUpdateFatalIfNoSpaceOff verifies an ENOSPC during a write is NOT
|
||||
// wrapped as fatal when the option is off.
|
||||
func TestUpdateFatalIfNoSpaceOff(t *testing.T) {
|
||||
err := updateWithReader(t, false, syscall.ENOSPC)
|
||||
require.Error(t, err)
|
||||
assert.False(t, fserrors.IsFatalError(err), "ENOSPC must not be fatal when FatalIfNoSpace=false")
|
||||
}
|
||||
|
||||
// TestUpdateFatalIfNoSpaceOn verifies an ENOSPC during a write IS wrapped as
|
||||
// fatal when the option is on.
|
||||
func TestUpdateFatalIfNoSpaceOn(t *testing.T) {
|
||||
err := updateWithReader(t, true, syscall.ENOSPC)
|
||||
require.Error(t, err)
|
||||
assert.True(t, fserrors.IsFatalError(err), "ENOSPC must be fatal when FatalIfNoSpace=true")
|
||||
}
|
||||
|
||||
// TestUpdateFatalIfNoSpaceOnButNotDiskFull verifies non-disk-full errors are
|
||||
// NOT wrapped as fatal even when the option is on.
|
||||
func TestUpdateFatalIfNoSpaceOnButNotDiskFull(t *testing.T) {
|
||||
err := updateWithReader(t, true, errors.New("unrelated network error"))
|
||||
require.Error(t, err)
|
||||
assert.False(t, fserrors.IsFatalError(err), "non-disk-full errors must not be fatal regardless of option")
|
||||
}
|
||||
Reference in New Issue
Block a user