Make atomic file replacement crash durable
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var errAtomicReplacementTest = errors.New("atomic replacement test failure")
|
||||
|
||||
func TestWriteFileAtomicOverwritesAndLeavesNoTempFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dst := filepath.Join(root, "out", "value.txt")
|
||||
@@ -111,6 +115,231 @@ func TestInstallDownloadedTempFileSetsPermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceFileAtomicOrdersDurableOperations(t *testing.T) {
|
||||
events := make([]string, 0, 8)
|
||||
tmp := &recordingTemporaryFile{name: "/work/.result.tmp-1", events: &events}
|
||||
ops := recordingAtomicReplacementOperations(&events, tmp)
|
||||
|
||||
err := replaceFileAtomicWithOperations(
|
||||
"/work/result",
|
||||
[]byte("replacement"),
|
||||
ReplaceFileOptions{
|
||||
Mode: 0o640,
|
||||
BeforeRename: func() error {
|
||||
events = append(events, "check")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
ops,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("replaceFileAtomicWithOperations() error = %v", err)
|
||||
}
|
||||
|
||||
want := []string{"create", "write", "chmod:rw-r-----", "sync", "close", "check", "rename", "sync-directory"}
|
||||
if !equalStrings(events, want) {
|
||||
t.Fatalf("operation order = %v, want %v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceFileAtomicFailuresCleanUninstalledTemporaryFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configure func(*recordingTemporaryFile, *atomicReplacementOperations)
|
||||
wantErr error
|
||||
wantRemove bool
|
||||
}{
|
||||
{
|
||||
name: "short write",
|
||||
configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) {
|
||||
tmp.shortWrite = true
|
||||
},
|
||||
wantErr: io.ErrShortWrite,
|
||||
wantRemove: true,
|
||||
},
|
||||
{
|
||||
name: "write",
|
||||
configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) {
|
||||
tmp.writeErr = errAtomicReplacementTest
|
||||
},
|
||||
wantErr: errAtomicReplacementTest,
|
||||
wantRemove: true,
|
||||
},
|
||||
{
|
||||
name: "sync",
|
||||
configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) {
|
||||
tmp.syncErr = errAtomicReplacementTest
|
||||
},
|
||||
wantErr: errAtomicReplacementTest,
|
||||
wantRemove: true,
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
configure: func(_ *recordingTemporaryFile, ops *atomicReplacementOperations) {
|
||||
ops.rename = func(string, string) error { return errAtomicReplacementTest }
|
||||
},
|
||||
wantErr: errAtomicReplacementTest,
|
||||
wantRemove: true,
|
||||
},
|
||||
{
|
||||
name: "directory sync",
|
||||
configure: func(_ *recordingTemporaryFile, ops *atomicReplacementOperations) {
|
||||
ops.syncDirectory = func(string) error { return ErrDirectorySyncUnsupported }
|
||||
},
|
||||
wantErr: ErrDirectorySyncUnsupported,
|
||||
wantRemove: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
events := make([]string, 0, 8)
|
||||
tmp := &recordingTemporaryFile{name: "/work/.result.tmp-1", events: &events}
|
||||
ops := recordingAtomicReplacementOperations(&events, tmp)
|
||||
test.configure(tmp, &ops)
|
||||
|
||||
err := replaceFileAtomicWithOperations("/work/result", []byte("replacement"), ReplaceFileOptions{Mode: 0o640}, ops)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("replaceFileAtomicWithOperations() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
gotRemove := containsString(events, "remove")
|
||||
if gotRemove != test.wantRemove {
|
||||
t.Fatalf("remove temporary file = %t, want %t; operations = %v", gotRemove, test.wantRemove, events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceFileAtomicReportsCleanupFailureWithPrimaryFailure(t *testing.T) {
|
||||
events := make([]string, 0, 5)
|
||||
tmp := &recordingTemporaryFile{
|
||||
name: "/work/.result.tmp-1",
|
||||
events: &events,
|
||||
writeErr: errAtomicReplacementTest,
|
||||
}
|
||||
ops := recordingAtomicReplacementOperations(&events, tmp)
|
||||
cleanupErr := errors.New("temporary cleanup failed")
|
||||
ops.remove = func(string) error {
|
||||
events = append(events, "remove")
|
||||
return cleanupErr
|
||||
}
|
||||
|
||||
err := replaceFileAtomicWithOperations("/work/result", []byte("replacement"), ReplaceFileOptions{Mode: 0o640}, ops)
|
||||
if !errors.Is(err, errAtomicReplacementTest) {
|
||||
t.Fatalf("replacement error = %v, want write failure", err)
|
||||
}
|
||||
if !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("replacement error = %v, want cleanup failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallDownloadedTempFileOrdersDurableOperations(t *testing.T) {
|
||||
events := make([]string, 0, 4)
|
||||
ops := atomicReplacementOperations{
|
||||
chmod: func(string, os.FileMode) error {
|
||||
events = append(events, "chmod")
|
||||
return nil
|
||||
},
|
||||
syncFile: func(string) error {
|
||||
events = append(events, "sync-file")
|
||||
return nil
|
||||
},
|
||||
rename: func(string, string) error {
|
||||
events = append(events, "rename")
|
||||
return nil
|
||||
},
|
||||
syncDirectory: func(string) error {
|
||||
events = append(events, "sync-directory")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
if err := installDownloadedTempFileWithOperations("/work/.download.tmp", "/work/result", 0o640, ops); err != nil {
|
||||
t.Fatalf("installDownloadedTempFileWithOperations() error = %v", err)
|
||||
}
|
||||
want := []string{"chmod", "sync-file", "rename", "sync-directory"}
|
||||
if !equalStrings(events, want) {
|
||||
t.Fatalf("operation order = %v, want %v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingTemporaryFile struct {
|
||||
name string
|
||||
events *[]string
|
||||
shortWrite bool
|
||||
writeErr error
|
||||
syncErr error
|
||||
}
|
||||
|
||||
func (f *recordingTemporaryFile) Write(data []byte) (int, error) {
|
||||
*f.events = append(*f.events, "write")
|
||||
if f.writeErr != nil {
|
||||
return 0, f.writeErr
|
||||
}
|
||||
if f.shortWrite {
|
||||
return len(data) - 1, nil
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (f *recordingTemporaryFile) Name() string { return f.name }
|
||||
|
||||
func (f *recordingTemporaryFile) Sync() error {
|
||||
*f.events = append(*f.events, "sync")
|
||||
return f.syncErr
|
||||
}
|
||||
|
||||
func (f *recordingTemporaryFile) Close() error {
|
||||
*f.events = append(*f.events, "close")
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordingAtomicReplacementOperations(events *[]string, tmp temporaryFile) atomicReplacementOperations {
|
||||
return atomicReplacementOperations{
|
||||
createTemp: func(string, string) (temporaryFile, error) {
|
||||
*events = append(*events, "create")
|
||||
return tmp, nil
|
||||
},
|
||||
chmod: func(_ string, mode os.FileMode) error {
|
||||
*events = append(*events, "chmod:"+mode.Perm().String()[1:])
|
||||
return nil
|
||||
},
|
||||
rename: func(string, string) error {
|
||||
*events = append(*events, "rename")
|
||||
return nil
|
||||
},
|
||||
remove: func(string) error {
|
||||
*events = append(*events, "remove")
|
||||
return nil
|
||||
},
|
||||
syncDirectory: func(string) error {
|
||||
*events = append(*events, "sync-directory")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func equalStrings(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func assertNoMatchingTempFiles(t *testing.T, dir, prefix string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
|
||||
Reference in New Issue
Block a user