Skip to content

Commit c07f2d5

Browse files
jasonpaulosohill
andauthored
Utils: Renaming files across devices (#5977)
Co-authored-by: ohill <145173879+ohill@users.noreply.github.com>
1 parent dfd95ff commit c07f2d5

File tree

5 files changed

+282
-9
lines changed

5 files changed

+282
-9
lines changed

config/localTemplate.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"time"
2525

2626
"github.com/algorand/go-algorand/protocol"
27+
"github.com/algorand/go-algorand/util"
2728
"github.com/algorand/go-algorand/util/codecs"
2829
)
2930

@@ -893,7 +894,7 @@ func moveDirIfExists(logger logger, srcdir, dstdir string, files ...string) erro
893894
// then, check if any files exist in srcdir, and move them to dstdir
894895
for _, file := range files {
895896
if _, err := os.Stat(filepath.Join(srcdir, file)); err == nil {
896-
if err := os.Rename(filepath.Join(srcdir, file), filepath.Join(dstdir, file)); err != nil {
897+
if err := util.MoveFile(filepath.Join(srcdir, file), filepath.Join(dstdir, file)); err != nil {
897898
return fmt.Errorf("failed to move file %s from %s to %s: %v", file, srcdir, dstdir, err)
898899
}
899900
logger.Infof("Moved DB file %s from ColdDataDir %s to HotDataDir %s", file, srcdir, dstdir)

logging/cyclicWriter.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"text/template"
2727
"time"
2828

29+
"github.com/algorand/go-algorand/util"
2930
"github.com/algorand/go-deadlock"
3031
)
3132

@@ -173,7 +174,7 @@ func (cyclic *CyclicFileWriter) Write(p []byte) (n int, err error) {
173174
shouldBz2 = true
174175
archivePath = archivePath[:len(archivePath)-4]
175176
}
176-
if err = os.Rename(cyclic.liveLog, archivePath); err != nil {
177+
if err = util.MoveFile(cyclic.liveLog, archivePath); err != nil {
177178
panic(fmt.Sprintf("CyclicFileWriter: cannot archive full log %v", err))
178179
}
179180
if shouldGz {

logging/cyclicWriter_test.go

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,19 @@ package logging
1818

1919
import (
2020
"os"
21+
"os/exec"
22+
"path/filepath"
23+
"runtime"
24+
"strings"
2125
"testing"
2226

2327
"github.com/algorand/go-algorand/test/partitiontest"
2428
"github.com/stretchr/testify/require"
2529
)
2630

27-
func TestCyclicWrite(t *testing.T) {
28-
partitiontest.PartitionTest(t)
29-
liveFileName := "live.test"
30-
archiveFileName := "archive.test"
31+
func testCyclicWrite(t *testing.T, liveFileName, archiveFileName string) {
32+
t.Helper()
33+
3134
defer os.Remove(liveFileName)
3235
defer os.Remove(archiveFileName)
3336

@@ -60,3 +63,46 @@ func TestCyclicWrite(t *testing.T) {
6063
require.Equal(t, byte('A'), oldData[i])
6164
}
6265
}
66+
67+
func TestCyclicWrite(t *testing.T) {
68+
partitiontest.PartitionTest(t)
69+
t.Parallel()
70+
71+
tmpDir := t.TempDir()
72+
73+
liveFileName := filepath.Join(tmpDir, "live.test")
74+
archiveFileName := filepath.Join(tmpDir, "archive.test")
75+
76+
testCyclicWrite(t, liveFileName, archiveFileName)
77+
}
78+
79+
func execCommand(t *testing.T, cmdAndArsg ...string) {
80+
t.Helper()
81+
82+
cmd := exec.Command(cmdAndArsg[0], cmdAndArsg[1:]...)
83+
var errOutput strings.Builder
84+
cmd.Stderr = &errOutput
85+
err := cmd.Run()
86+
require.NoError(t, err, errOutput.String())
87+
}
88+
89+
func TestCyclicWriteAcrossFilesystems(t *testing.T) {
90+
partitiontest.PartitionTest(t)
91+
92+
isLinux := strings.HasPrefix(runtime.GOOS, "linux")
93+
94+
// Skip unless CIRCLECI or TEST_MOUNT_TMPFS is set, and we are on a linux system
95+
if !isLinux || (os.Getenv("CIRCLECI") == "" && os.Getenv("TEST_MOUNT_TMPFS") == "") {
96+
t.Skip("This test must be run on a linux system with administrator privileges")
97+
}
98+
99+
mountDir := t.TempDir()
100+
execCommand(t, "sudo", "mount", "-t", "tmpfs", "-o", "size=2K", "tmpfs", mountDir)
101+
102+
defer execCommand(t, "sudo", "umount", mountDir)
103+
104+
liveFileName := filepath.Join(t.TempDir(), "live.test")
105+
archiveFileName := filepath.Join(mountDir, "archive.test")
106+
107+
testCyclicWrite(t, liveFileName, archiveFileName)
108+
}

util/io.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,71 @@ import (
2424
"strings"
2525
)
2626

27+
// MoveFile moves a file from src to dst. The advantages of using this over
28+
// os.Rename() is that it can move files across different filesystems.
29+
func MoveFile(src, dst string) error {
30+
err := os.Rename(src, dst)
31+
if err != nil {
32+
// os.Rename() may have failed because src and dst are on different
33+
// filesystems. Let's try to move the file by copying and deleting the
34+
// source file.
35+
return moveFileByCopying(src, dst)
36+
}
37+
return err
38+
}
39+
40+
func moveFileByCopying(src, dst string) error {
41+
// Lstat is specifically used to detect if src is a symlink. We could
42+
// support moving symlinks by deleting src and creating a new symlink at
43+
// dst, but we don't currently expect to encounter that case, so it has not
44+
// been implemented.
45+
srcInfo, srcErr := os.Lstat(src)
46+
if srcErr != nil {
47+
return srcErr
48+
}
49+
if !srcInfo.Mode().IsRegular() {
50+
return fmt.Errorf("cannot move source file '%s': it is not a regular file (%v)", src, srcInfo.Mode())
51+
}
52+
53+
if dstInfo, dstErr := os.Lstat(dst); dstErr == nil {
54+
if dstInfo.Mode().IsDir() {
55+
return fmt.Errorf("cannot move source file '%s' to destination '%s': destination is a directory", src, dst)
56+
}
57+
if os.SameFile(dstInfo, srcInfo) {
58+
return fmt.Errorf("cannot move source file '%s' to destination '%s': source and destination are the same file", src, dst)
59+
}
60+
}
61+
62+
dstDir := filepath.Dir(dst)
63+
dstBase := filepath.Base(dst)
64+
65+
tmpDstFile, errTmp := os.CreateTemp(dstDir, dstBase+".tmp-")
66+
if errTmp != nil {
67+
return errTmp
68+
}
69+
tmpDst := tmpDstFile.Name()
70+
if errClose := tmpDstFile.Close(); errClose != nil {
71+
return errClose
72+
}
73+
74+
if _, err := CopyFile(src, tmpDst); err != nil {
75+
// If the copy fails, try to clean up the temporary file
76+
_ = os.Remove(tmpDst)
77+
return err
78+
}
79+
if err := os.Rename(tmpDst, dst); err != nil {
80+
// If the rename fails, try to clean up the temporary file
81+
_ = os.Remove(tmpDst)
82+
return err
83+
}
84+
if err := os.Remove(src); err != nil {
85+
// Don't try to clean up the destination file here. Duplicate data is
86+
// better than lost/incomplete data.
87+
return fmt.Errorf("failed to remove source file '%s' after moving it to '%s': %w", src, dst, err)
88+
}
89+
return nil
90+
}
91+
2792
// CopyFile uses io.Copy() to copy a file to another location
2893
// This was copied from https://opensource.com/article/18/6/copying-files-go
2994
func CopyFile(src, dst string) (int64, error) {

util/io_test.go

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,185 @@
1717
package util
1818

1919
import (
20+
"fmt"
2021
"os"
21-
"path"
22+
"os/exec"
23+
"path/filepath"
24+
"runtime"
25+
"strings"
2226
"testing"
2327

2428
"github.com/stretchr/testify/assert"
29+
"github.com/stretchr/testify/require"
2530

2631
"github.com/algorand/go-algorand/test/partitiontest"
2732
)
2833

2934
func TestIsEmpty(t *testing.T) {
3035
partitiontest.PartitionTest(t)
36+
t.Parallel()
3137

32-
testPath := path.Join(os.TempDir(), "this", "is", "a", "long", "path")
38+
testPath := filepath.Join(t.TempDir(), "this", "is", "a", "long", "path")
3339
err := os.MkdirAll(testPath, os.ModePerm)
3440
assert.NoError(t, err)
3541
defer os.RemoveAll(testPath)
3642
assert.True(t, IsEmpty(testPath))
3743

38-
_, err = os.Create(path.Join(testPath, "file.txt"))
44+
_, err = os.Create(filepath.Join(testPath, "file.txt"))
3945
assert.NoError(t, err)
4046
assert.False(t, IsEmpty(testPath))
4147
}
48+
49+
func testMoveFileSimple(t *testing.T, src, dst string) {
50+
t.Helper()
51+
52+
require.NoFileExists(t, src)
53+
require.NoFileExists(t, dst)
54+
55+
defer os.Remove(src)
56+
defer os.Remove(dst)
57+
58+
f, err := os.Create(src)
59+
require.NoError(t, err)
60+
61+
_, err = f.WriteString("test file contents")
62+
require.NoError(t, err)
63+
require.NoError(t, f.Close())
64+
65+
err = MoveFile(src, dst)
66+
require.NoError(t, err)
67+
68+
require.FileExists(t, dst)
69+
require.NoFileExists(t, src)
70+
71+
dstContents, err := os.ReadFile(dst)
72+
require.NoError(t, err)
73+
assert.Equal(t, "test file contents", string(dstContents))
74+
}
75+
76+
func TestMoveFile(t *testing.T) {
77+
partitiontest.PartitionTest(t)
78+
t.Parallel()
79+
80+
tmpDir := t.TempDir()
81+
82+
src := filepath.Join(tmpDir, "src.txt")
83+
dst := filepath.Join(tmpDir, "dst.txt")
84+
testMoveFileSimple(t, src, dst)
85+
}
86+
87+
func execCommand(t *testing.T, cmdAndArsg ...string) {
88+
t.Helper()
89+
90+
cmd := exec.Command(cmdAndArsg[0], cmdAndArsg[1:]...)
91+
var errOutput strings.Builder
92+
cmd.Stderr = &errOutput
93+
err := cmd.Run()
94+
require.NoError(t, err, errOutput.String())
95+
}
96+
97+
func TestMoveFileAcrossFilesystems(t *testing.T) {
98+
partitiontest.PartitionTest(t)
99+
100+
isLinux := strings.HasPrefix(runtime.GOOS, "linux")
101+
102+
// Skip unless CIRCLECI or TEST_MOUNT_TMPFS is set, and we are on a linux system
103+
if !isLinux || (os.Getenv("CIRCLECI") == "" && os.Getenv("TEST_MOUNT_TMPFS") == "") {
104+
t.Skip("This test must be run on a linux system with administrator privileges")
105+
}
106+
107+
mountDir := t.TempDir()
108+
execCommand(t, "sudo", "mount", "-t", "tmpfs", "-o", "size=1K", "tmpfs", mountDir)
109+
110+
defer execCommand(t, "sudo", "umount", mountDir)
111+
112+
src := filepath.Join(t.TempDir(), "src.txt")
113+
dst := filepath.Join(mountDir, "dst.txt")
114+
115+
testMoveFileSimple(t, src, dst)
116+
}
117+
118+
func TestMoveFileSourceDoesNotExist(t *testing.T) {
119+
partitiontest.PartitionTest(t)
120+
t.Parallel()
121+
122+
tmpDir := t.TempDir()
123+
124+
src := filepath.Join(tmpDir, "src.txt")
125+
dst := filepath.Join(tmpDir, "dst.txt")
126+
127+
err := MoveFile(src, dst)
128+
var pathError *os.PathError
129+
require.ErrorAs(t, err, &pathError)
130+
require.Equal(t, "lstat", pathError.Op)
131+
require.Equal(t, src, pathError.Path)
132+
}
133+
134+
func TestMoveFileSourceIsASymlink(t *testing.T) {
135+
partitiontest.PartitionTest(t)
136+
t.Parallel()
137+
138+
tmpDir := t.TempDir()
139+
140+
root := filepath.Join(tmpDir, "root.txt")
141+
src := filepath.Join(tmpDir, "src.txt")
142+
dst := filepath.Join(tmpDir, "dst.txt")
143+
144+
_, err := os.Create(root)
145+
require.NoError(t, err)
146+
147+
err = os.Symlink(root, src)
148+
require.NoError(t, err)
149+
150+
// os.Rename should work in this case
151+
err = MoveFile(src, dst)
152+
require.NoError(t, err)
153+
154+
// Undo the move
155+
require.NoError(t, MoveFile(dst, src))
156+
157+
// But our moveFileByCopying should fail, since we haven't implemented this case
158+
err = moveFileByCopying(src, dst)
159+
require.ErrorContains(t, err, fmt.Sprintf("cannot move source file '%s': it is not a regular file", src))
160+
}
161+
162+
func TestMoveFileSourceAndDestinationAreSame(t *testing.T) {
163+
partitiontest.PartitionTest(t)
164+
t.Parallel()
165+
166+
tmpDir := t.TempDir()
167+
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "folder"), os.ModePerm))
168+
169+
src := filepath.Join(tmpDir, "src.txt")
170+
dst := src[:len(src)-len("src.txt")] + "folder/../src.txt"
171+
172+
// dst refers to the same file as src, but with a different path
173+
require.NotEqual(t, src, dst)
174+
require.Equal(t, src, filepath.Clean(dst))
175+
176+
_, err := os.Create(src)
177+
require.NoError(t, err)
178+
179+
// os.Rename can handle this case, but our moveFileByCopying should fail
180+
err = moveFileByCopying(src, dst)
181+
require.ErrorContains(t, err, fmt.Sprintf("cannot move source file '%s' to destination '%s': source and destination are the same file", src, dst))
182+
}
183+
184+
func TestMoveFileDestinationIsADirectory(t *testing.T) {
185+
partitiontest.PartitionTest(t)
186+
t.Parallel()
187+
188+
tmpDir := t.TempDir()
189+
190+
src := filepath.Join(tmpDir, "src.txt")
191+
dst := filepath.Join(tmpDir, "dst.txt")
192+
193+
_, err := os.Create(src)
194+
require.NoError(t, err)
195+
196+
err = os.Mkdir(dst, os.ModePerm)
197+
require.NoError(t, err)
198+
199+
err = MoveFile(src, dst)
200+
require.ErrorContains(t, err, fmt.Sprintf("cannot move source file '%s' to destination '%s': destination is a directory", src, dst))
201+
}

0 commit comments

Comments
 (0)