all: Add copy-on-write filesystem support (fixes #4271) (#6746)

This commit is contained in:
Audrius Butkevicius
2020-06-18 08:15:47 +02:00
committed by GitHub
parent 273cc9cef8
commit 4812fd3ec1
26 changed files with 858 additions and 84 deletions
+24
View File
@@ -0,0 +1,24 @@
// Copyright (C) 2020 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package fs
import (
"syscall"
)
type copyRangeImplementationBasicFile func(src, dst basicFile, srcOffset, dstOffset, size int64) error
func copyRangeImplementationForBasicFile(impl copyRangeImplementationBasicFile) copyRangeImplementation {
return func(src, dst File, srcOffset, dstOffset, size int64) error {
srcFile, srcOk := src.(basicFile)
dstFile, dstOk := dst.(basicFile)
if !srcOk || !dstOk {
return syscall.ENOTSUP
}
return impl(srcFile, dstFile, srcOffset, dstOffset, size)
}
}
@@ -0,0 +1,45 @@
// Copyright (C) 2019 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// +build linux
package fs
import (
"io"
"syscall"
"golang.org/x/sys/unix"
)
func init() {
registerCopyRangeImplementation(CopyRangeMethodCopyFileRange, copyRangeImplementationForBasicFile(copyRangeCopyFileRange))
}
func copyRangeCopyFileRange(src, dst basicFile, srcOffset, dstOffset, size int64) error {
for size > 0 {
// From MAN page:
//
// If off_in is not NULL, then off_in must point to a buffer that
// specifies the starting offset where bytes from fd_in will be read.
// The file offset of fd_in is not changed, but off_in is adjusted
// appropriately.
//
// Also, even if explicitly not stated, the same is true for dstOffset
n, err := unix.CopyFileRange(int(src.Fd()), &srcOffset, int(dst.Fd()), &dstOffset, int(size), 0)
if n == 0 && err == nil {
return io.ErrUnexpectedEOF
}
if err != nil && err != syscall.EAGAIN {
return err
}
// Handle case where err == EAGAIN and n == -1 (it's not clear if that can happen)
if n > 0 {
size -= int64(n)
}
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright (C) 2019 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// +build !windows,!solaris,!darwin
package fs
import (
"syscall"
"unsafe"
)
func init() {
registerCopyRangeImplementation(CopyRangeMethodIoctl, copyRangeImplementationForBasicFile(copyRangeIoctl))
}
const FICLONE = 0x40049409
const FICLONERANGE = 0x4020940d
/*
http://man7.org/linux/man-pages/man2/ioctl_ficlonerange.2.html
struct file_clone_range {
__s64 src_fd;
__u64 src_offset;
__u64 src_length;
__u64 dest_offset;
};
*/
type fileCloneRange struct {
srcFd int64
srcOffset uint64
srcLength uint64
dstOffset uint64
}
func copyRangeIoctl(src, dst basicFile, srcOffset, dstOffset, size int64) error {
fi, err := src.Stat()
if err != nil {
return err
}
// https://www.man7.org/linux/man-pages/man2/ioctl_ficlonerange.2.html
// If src_length is zero, the ioctl reflinks to the end of the source file.
if srcOffset+size == fi.Size() {
size = 0
}
if srcOffset == 0 && dstOffset == 0 && size == 0 {
// Optimization for whole file copies.
_, _, errNo := syscall.Syscall(syscall.SYS_IOCTL, dst.Fd(), FICLONE, src.Fd())
if errNo != 0 {
return errNo
}
return nil
}
params := fileCloneRange{
srcFd: int64(src.Fd()),
srcOffset: uint64(srcOffset),
srcLength: uint64(size),
dstOffset: uint64(dstOffset),
}
_, _, errNo := syscall.Syscall(syscall.SYS_IOCTL, dst.Fd(), FICLONERANGE, uintptr(unsafe.Pointer(&params)))
if errNo != 0 {
return errNo
}
return nil
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright (C) 2019 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// +build !windows,!darwin
package fs
import (
"io"
"syscall"
)
func init() {
registerCopyRangeImplementation(CopyRangeMethodSendFile, copyRangeImplementationForBasicFile(copyRangeSendFile))
}
func copyRangeSendFile(src, dst basicFile, srcOffset, dstOffset, size int64) error {
// Check that the destination file has sufficient space
if fi, err := dst.Stat(); err != nil {
return err
} else if fi.Size() < dstOffset+size {
if err := dst.Truncate(dstOffset + size); err != nil {
return err
}
}
// Record old dst offset.
oldDstOffset, err := dst.Seek(0, io.SeekCurrent)
if err != nil {
return err
}
defer func() { _, _ = dst.Seek(oldDstOffset, io.SeekStart) }()
// Seek to the offset we expect to write
if oldDstOffset != dstOffset {
if n, err := dst.Seek(dstOffset, io.SeekStart); err != nil {
return err
} else if n != dstOffset {
return io.ErrUnexpectedEOF
}
}
for size > 0 {
// From the MAN page:
//
// If offset is not NULL, then it points to a variable holding the file offset from which sendfile() will start
// reading data from in_fd. When sendfile() returns, this variable will be set to the offset of the byte
// following the last byte that was read. If offset is not NULL, then sendfile() does not modify the current
// file offset of in_fd; otherwise the current file offset is adjusted to reflect the number of bytes read from
// in_fd.
n, err := syscall.Sendfile(int(dst.Fd()), int(src.Fd()), &srcOffset, int(size))
if n == 0 && err == nil {
err = io.ErrUnexpectedEOF
}
if err != nil && err != syscall.EAGAIN {
return err
}
// Handle case where err == EAGAIN and n == -1 (it's not clear if that can happen)
if n > 0 {
size -= int64(n)
}
}
_, err = dst.Seek(oldDstOffset, io.SeekStart)
return err
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (C) 2019 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package fs
import (
"syscall"
"github.com/syncthing/syncthing/lib/sync"
)
var (
copyRangeMethods = make(map[CopyRangeMethod]copyRangeImplementation)
mut = sync.NewMutex()
)
type copyRangeImplementation func(src, dst File, srcOffset, dstOffset, size int64) error
func registerCopyRangeImplementation(copyMethod CopyRangeMethod, impl copyRangeImplementation) {
mut.Lock()
defer mut.Unlock()
l.Debugln("Registering " + copyMethod.String() + " copyRange method")
copyRangeMethods[copyMethod] = impl
}
// CopyRange tries to use the specified method to copy data between two files.
// Takes size bytes at offset srcOffset from the source file, and copies the data to destination file at offset
// dstOffset. If required, adjusts the size of the destination file to fit that much data.
//
// On Linux/BSD you can ask it to use ioctl and copy_file_range system calls, which if the underlying filesystem supports
// it tries referencing existing data in the source file, instead of making a copy and taking up additional space.
//
// CopyRange does its best to have no effect on src and dst file offsets (copy operation should not affect it).
func CopyRange(copyMethod CopyRangeMethod, src, dst File, srcOffset, dstOffset, size int64) error {
if impl, ok := copyRangeMethods[copyMethod]; ok {
return impl(src, dst, srcOffset, dstOffset, size)
}
return syscall.ENOTSUP
}
@@ -0,0 +1,21 @@
// Copyright (C) 2019 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package fs
func init() {
registerCopyRangeImplementation(CopyRangeMethodAllWithFallback, copyRangeAllWithFallback)
}
func copyRangeAllWithFallback(src, dst File, srcOffset, dstOffset, size int64) error {
var err error
for _, method := range []CopyRangeMethod{CopyRangeMethodIoctl, CopyRangeMethodCopyFileRange, CopyRangeMethodSendFile, CopyRangeMethodStandard} {
if err = CopyRange(method, src, dst, srcOffset, dstOffset, size); err == nil {
return nil
}
}
return err
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright (C) 2020 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package fs
type CopyRangeMethod int
const (
CopyRangeMethodStandard CopyRangeMethod = iota
CopyRangeMethodIoctl
CopyRangeMethodCopyFileRange
CopyRangeMethodSendFile
CopyRangeMethodAllWithFallback
)
func (o CopyRangeMethod) String() string {
switch o {
case CopyRangeMethodStandard:
return "standard"
case CopyRangeMethodIoctl:
return "ioctl"
case CopyRangeMethodCopyFileRange:
return "copy_file_range"
case CopyRangeMethodSendFile:
return "sendfile"
case CopyRangeMethodAllWithFallback:
return "all"
default:
return "unknown"
}
}
func (o CopyRangeMethod) MarshalText() ([]byte, error) {
return []byte(o.String()), nil
}
func (o *CopyRangeMethod) UnmarshalText(bs []byte) error {
switch string(bs) {
case "standard":
*o = CopyRangeMethodStandard
case "ioctl":
*o = CopyRangeMethodIoctl
case "copy_file_range":
*o = CopyRangeMethodCopyFileRange
case "sendfile":
*o = CopyRangeMethodSendFile
case "all":
*o = CopyRangeMethodAllWithFallback
default:
*o = CopyRangeMethodStandard
}
return nil
}
func (o *CopyRangeMethod) ParseDefault(str string) error {
return o.UnmarshalText([]byte(str))
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (C) 2019 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package fs
import (
"io"
)
func init() {
registerCopyRangeImplementation(CopyRangeMethodStandard, copyRangeStandard)
}
func copyRangeStandard(src, dst File, srcOffset, dstOffset, size int64) error {
const bufSize = 4 << 20
buf := make([]byte, bufSize)
// TODO: In go 1.15, we should use file.ReadFrom that uses copy_file_range underneath.
// ReadAt and WriteAt does not modify the position of the file.
for size > 0 {
if size < bufSize {
buf = buf[:size]
}
n, err := src.ReadAt(buf, srcOffset)
if err != nil {
if err == io.EOF {
return io.ErrUnexpectedEOF
}
return err
}
if _, err = dst.WriteAt(buf[:n], dstOffset); err != nil {
return err
}
srcOffset += int64(n)
dstOffset += int64(n)
size -= int64(n)
}
return nil
}
+322
View File
@@ -0,0 +1,322 @@
// Copyright (C) 2019 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package fs
import (
"bytes"
"io"
"io/ioutil"
"math/rand"
"os"
"syscall"
"testing"
)
var (
generationSize int64 = 4 << 20
defaultCopySize int64 = 1 << 20
testCases = []struct {
name string
// Starting size of files
srcSize int64
dstSize int64
// Offset from which to read
srcOffset int64
dstOffset int64
// Cursor position before the copy
srcStartingPos int64
dstStartingPos int64
// Expected destination size
expectedDstSizeAfterCopy int64
// Custom copy size
copySize int64
// Expected failure
expectedErrors map[CopyRangeMethod]error
}{
{
name: "append to end",
srcSize: generationSize,
dstSize: generationSize,
srcOffset: 0,
dstOffset: generationSize,
srcStartingPos: generationSize,
dstStartingPos: generationSize,
expectedDstSizeAfterCopy: generationSize + defaultCopySize,
copySize: defaultCopySize,
expectedErrors: nil,
},
{
name: "append to end, offsets at start",
srcSize: generationSize,
dstSize: generationSize,
srcOffset: 0,
dstOffset: generationSize,
srcStartingPos: 0, // We seek back to start, and expect src not to move after copy
dstStartingPos: 0, // Seek back, but expect dst pos to not change
expectedDstSizeAfterCopy: generationSize + defaultCopySize,
copySize: defaultCopySize,
expectedErrors: nil,
},
{
name: "overwrite part of destination region",
srcSize: generationSize,
dstSize: generationSize,
srcOffset: defaultCopySize,
dstOffset: generationSize,
srcStartingPos: generationSize,
dstStartingPos: generationSize,
expectedDstSizeAfterCopy: generationSize + defaultCopySize,
copySize: defaultCopySize,
expectedErrors: nil,
},
{
name: "overwrite all of destination",
srcSize: generationSize,
dstSize: generationSize,
srcOffset: 0,
dstOffset: 0,
srcStartingPos: generationSize,
dstStartingPos: generationSize,
expectedDstSizeAfterCopy: generationSize,
copySize: defaultCopySize,
expectedErrors: nil,
},
{
name: "overwrite part of destination",
srcSize: generationSize,
dstSize: generationSize,
srcOffset: defaultCopySize,
dstOffset: 0,
srcStartingPos: generationSize,
dstStartingPos: generationSize,
expectedDstSizeAfterCopy: generationSize,
copySize: defaultCopySize,
expectedErrors: nil,
},
// Write way past the end of the file
{
name: "destination gets expanded as it is being written to",
srcSize: generationSize,
dstSize: generationSize,
srcOffset: 0,
dstOffset: generationSize * 2,
srcStartingPos: generationSize,
dstStartingPos: generationSize,
expectedDstSizeAfterCopy: generationSize*2 + defaultCopySize,
copySize: defaultCopySize,
expectedErrors: nil,
},
// Source file does not have enough bytes to copy in that range, should result in an unexpected eof.
{
name: "source file too small",
srcSize: generationSize,
dstSize: generationSize,
srcOffset: 0,
dstOffset: 0,
srcStartingPos: 0,
dstStartingPos: 0,
expectedDstSizeAfterCopy: -11, // Does not matter, should fail.
copySize: defaultCopySize * 10,
// ioctl returns syscall.EINVAL, rest are wrapped
expectedErrors: map[CopyRangeMethod]error{
CopyRangeMethodIoctl: syscall.EINVAL,
CopyRangeMethodStandard: io.ErrUnexpectedEOF,
CopyRangeMethodCopyFileRange: io.ErrUnexpectedEOF,
CopyRangeMethodSendFile: io.ErrUnexpectedEOF,
CopyRangeMethodAllWithFallback: io.ErrUnexpectedEOF,
},
},
// Non block sized file
{
name: "not block aligned write",
srcSize: generationSize + 2,
dstSize: 0,
srcOffset: 1,
dstOffset: 0,
srcStartingPos: 0,
dstStartingPos: 0,
expectedDstSizeAfterCopy: generationSize + 1,
copySize: generationSize + 1,
// Only fails for ioctl
expectedErrors: map[CopyRangeMethod]error{
CopyRangeMethodIoctl: syscall.EINVAL,
},
},
// Last block that starts on a nice boundary
{
name: "last block",
srcSize: generationSize + 2,
dstSize: 0,
srcOffset: generationSize,
dstOffset: 0,
srcStartingPos: 0,
dstStartingPos: 0,
expectedDstSizeAfterCopy: 2,
copySize: 2,
// Succeeds on all, as long as the offset is file-system block aligned.
expectedErrors: nil,
},
// Copy whole file
{
name: "whole file copy block aligned",
srcSize: generationSize,
dstSize: 0,
srcOffset: 0,
dstOffset: 0,
srcStartingPos: 0,
dstStartingPos: 0,
expectedDstSizeAfterCopy: generationSize,
copySize: generationSize,
expectedErrors: nil,
},
{
name: "whole file copy not block aligned",
srcSize: generationSize + 1,
dstSize: 0,
srcOffset: 0,
dstOffset: 0,
srcStartingPos: 0,
dstStartingPos: 0,
expectedDstSizeAfterCopy: generationSize + 1,
copySize: generationSize + 1,
expectedErrors: nil,
},
}
)
func TestCopyRange(ttt *testing.T) {
randSrc := rand.New(rand.NewSource(rand.Int63()))
for copyMethod, impl := range copyRangeMethods {
ttt.Run(copyMethod.String(), func(tt *testing.T) {
for _, testCase := range testCases {
tt.Run(testCase.name, func(t *testing.T) {
srcBuf := make([]byte, testCase.srcSize)
dstBuf := make([]byte, testCase.dstSize)
td, err := ioutil.TempDir(os.Getenv("STFSTESTPATH"), "")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(td) }()
fs := NewFilesystem(FilesystemTypeBasic, td)
if _, err := io.ReadFull(randSrc, srcBuf); err != nil {
t.Fatal(err)
}
if _, err := io.ReadFull(randSrc, dstBuf); err != nil {
t.Fatal(err)
}
src, err := fs.Create("src")
if err != nil {
t.Fatal(err)
}
defer func() { _ = src.Close() }()
dst, err := fs.Create("dst")
if err != nil {
t.Fatal(err)
}
defer func() { _ = dst.Close() }()
// Write some data
if _, err := src.Write(srcBuf); err != nil {
t.Fatal(err)
}
if _, err := dst.Write(dstBuf); err != nil {
t.Fatal(err)
}
// Set the offsets
if n, err := src.Seek(testCase.srcStartingPos, io.SeekStart); err != nil || n != testCase.srcStartingPos {
t.Fatal(err)
}
if n, err := dst.Seek(testCase.dstStartingPos, io.SeekStart); err != nil || n != testCase.dstStartingPos {
t.Fatal(err)
}
if err := impl(src.(basicFile), dst.(basicFile), testCase.srcOffset, testCase.dstOffset, testCase.copySize); err != nil {
if err == syscall.ENOTSUP {
// Test runner can adjust directory in which to run the tests, that allow broader tests.
t.Skip("Not supported on the current filesystem, set STFSTESTPATH env var.")
}
if testCase.expectedErrors[copyMethod] == err {
return
}
t.Fatal(err)
} else if testCase.expectedErrors[copyMethod] != nil {
t.Fatal("did not get expected error")
}
// Check offsets where we expect them
if srcCurPos, err := src.Seek(0, io.SeekCurrent); err != nil {
t.Fatal(err)
} else if srcCurPos != testCase.srcStartingPos {
t.Errorf("src pos expected %d got %d", testCase.srcStartingPos, srcCurPos)
}
if dstCurPos, err := dst.Seek(0, io.SeekCurrent); err != nil {
t.Fatal(err)
} else if dstCurPos != testCase.dstStartingPos {
t.Errorf("dst pos expected %d got %d", testCase.dstStartingPos, dstCurPos)
}
// Check dst size
if fi, err := dst.Stat(); err != nil {
t.Fatal(err)
} else if fi.Size() != testCase.expectedDstSizeAfterCopy {
t.Errorf("expected %d size, got %d", testCase.expectedDstSizeAfterCopy, fi.Size())
}
// Check the data is as expected
if _, err := dst.Seek(0, io.SeekStart); err != nil {
t.Fatal(err)
}
resultBuf := make([]byte, testCase.expectedDstSizeAfterCopy)
if _, err := io.ReadFull(dst, resultBuf); err != nil {
t.Fatal(err)
}
if !bytes.Equal(srcBuf[testCase.srcOffset:testCase.srcOffset+testCase.copySize], resultBuf[testCase.dstOffset:testCase.dstOffset+testCase.copySize]) {
t.Errorf("Not equal")
}
// Check not copied content does not get corrupted
if testCase.dstOffset > testCase.dstSize {
if !bytes.Equal(dstBuf[:testCase.dstSize], resultBuf[:testCase.dstSize]) {
t.Error("region before copy region not equals")
}
if !bytes.Equal(resultBuf[testCase.dstSize:testCase.dstOffset], make([]byte, testCase.dstOffset-testCase.dstSize)) {
t.Error("found non zeroes in expected zero region")
}
} else {
if !bytes.Equal(dstBuf[:testCase.dstOffset], resultBuf[:testCase.dstOffset]) {
t.Error("region before copy region not equals")
}
afterCopyStart := testCase.dstOffset + testCase.copySize
if afterCopyStart < testCase.dstSize {
if !bytes.Equal(dstBuf[afterCopyStart:], resultBuf[afterCopyStart:len(dstBuf)]) {
t.Error("region after copy region not equals")
}
}
}
})
}
})
}
}