Skip to content

Commit 21ec71c

Browse files
committed
Improve AtomicFileWriter's support for Win and Mac
Add ExplicitCommit option to provide calling code the ability to preserve original files when an application error occurs, not just an error that occurs during writing. Change Close() impl to safely handle redundant calls (for defer usage safety) Signed-off-by: Jason T. Greene <jason.greene@redhat.com>
1 parent 6d0dc04 commit 21ec71c

File tree

5 files changed

+179
-53
lines changed

5 files changed

+179
-53
lines changed

pkg/ioutils/fswriters.go

Lines changed: 90 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ type AtomicFileWriterOptions struct {
1717
// On successful return from Close() this is set to the mtime of the
1818
// newly written file.
1919
ModTime time.Time
20+
// Specifies whether Commit() must be explicitly called to write state
21+
// to the destination. This allows an application to preserve the original
22+
// file when an error occurs during processing (and not just during write)
23+
// The default is false, which will auto-commit on Close
24+
ExplicitCommit bool
25+
}
26+
27+
type CommittableWriter interface {
28+
io.WriteCloser
29+
30+
// Commit closes the temporary file associated with this writer, and
31+
// provided no errors (during commit or previously during write operations),
32+
// will publish the completed file under the intended destination.
33+
Commit() error
2034
}
2135

2236
var defaultWriterOptions = AtomicFileWriterOptions{}
@@ -27,16 +41,19 @@ func SetDefaultOptions(opts AtomicFileWriterOptions) {
2741
defaultWriterOptions = opts
2842
}
2943

30-
// NewAtomicFileWriterWithOpts returns WriteCloser so that writing to it writes to a
31-
// temporary file and closing it atomically changes the temporary file to
32-
// destination path. Writing and closing concurrently is not allowed.
33-
func NewAtomicFileWriterWithOpts(filename string, perm os.FileMode, opts *AtomicFileWriterOptions) (io.WriteCloser, error) {
44+
// NewAtomicFileWriterWithOpts returns a CommittableWriter so that writing to it
45+
// writes to a temporary file, which can later be committed to a destination path,
46+
// either by Closing in the case of auto-commit, or manually calling commit if the
47+
// ExplicitCommit option is enabled. Writing and closing concurrently is not
48+
// allowed.
49+
func NewAtomicFileWriterWithOpts(filename string, perm os.FileMode, opts *AtomicFileWriterOptions) (CommittableWriter, error) {
3450
return newAtomicFileWriter(filename, perm, opts)
3551
}
3652

37-
// newAtomicFileWriter returns WriteCloser so that writing to it writes to a
38-
// temporary file and closing it atomically changes the temporary file to
39-
// destination path. Writing and closing concurrently is not allowed.
53+
// newAtomicFileWriter returns a CommittableWriter so that writing to it writes to
54+
// a temporary file, which can later be committed to a destination path, either by
55+
// Closing in the case of auto-commit, or manually calling commit if the
56+
// ExplicitCommit option is enabled. Writing and closing concurrently is not allowed.
4057
func newAtomicFileWriter(filename string, perm os.FileMode, opts *AtomicFileWriterOptions) (*atomicFileWriter, error) {
4158
f, err := os.CreateTemp(filepath.Dir(filename), ".tmp-"+filepath.Base(filename))
4259
if err != nil {
@@ -50,17 +67,18 @@ func newAtomicFileWriter(filename string, perm os.FileMode, opts *AtomicFileWrit
5067
return nil, err
5168
}
5269
return &atomicFileWriter{
53-
f: f,
54-
fn: abspath,
55-
perm: perm,
56-
noSync: opts.NoSync,
70+
f: f,
71+
fn: abspath,
72+
perm: perm,
73+
noSync: opts.NoSync,
74+
explicitCommit: opts.ExplicitCommit,
5775
}, nil
5876
}
5977

60-
// NewAtomicFileWriter returns WriteCloser so that writing to it writes to a
61-
// temporary file and closing it atomically changes the temporary file to
62-
// destination path. Writing and closing concurrently is not allowed.
63-
func NewAtomicFileWriter(filename string, perm os.FileMode) (io.WriteCloser, error) {
78+
// NewAtomicFileWriterWithOpts returns a CommittableWriter, with auto-commit enabled.
79+
// Writing to it writes to a temporary file and closing it atomically changes the
80+
// temporary file to destination path. Writing and closing concurrently is not allowed.
81+
func NewAtomicFileWriter(filename string, perm os.FileMode) (CommittableWriter, error) {
6482
return NewAtomicFileWriterWithOpts(filename, perm, nil)
6583
}
6684

@@ -91,12 +109,14 @@ func AtomicWriteFile(filename string, data []byte, perm os.FileMode) error {
91109
}
92110

93111
type atomicFileWriter struct {
94-
f *os.File
95-
fn string
96-
writeErr error
97-
perm os.FileMode
98-
noSync bool
99-
modTime time.Time
112+
f *os.File
113+
fn string
114+
writeErr error
115+
perm os.FileMode
116+
noSync bool
117+
modTime time.Time
118+
closed bool
119+
explicitCommit bool
100120
}
101121

102122
func (w *atomicFileWriter) Write(dt []byte) (int, error) {
@@ -107,43 +127,73 @@ func (w *atomicFileWriter) Write(dt []byte) (int, error) {
107127
return n, err
108128
}
109129

110-
func (w *atomicFileWriter) Close() (retErr error) {
130+
func (w *atomicFileWriter) closeTempFile() error {
131+
if w.closed {
132+
return nil
133+
}
134+
135+
w.closed = true
136+
return w.f.Close()
137+
}
138+
139+
func (w *atomicFileWriter) Close() error {
140+
return w.complete(!w.explicitCommit)
141+
}
142+
143+
func (w *atomicFileWriter) Commit() error {
144+
return w.complete(true)
145+
}
146+
147+
func (w *atomicFileWriter) complete(commit bool) (retErr error) {
148+
if w == nil || w.closed {
149+
return nil
150+
}
151+
111152
defer func() {
153+
w.closeTempFile()
112154
if retErr != nil || w.writeErr != nil {
113155
os.Remove(w.f.Name())
114156
}
115157
}()
116-
if !w.noSync {
117-
if err := fdatasync(w.f); err != nil {
118-
w.f.Close()
119-
return err
120-
}
158+
159+
if commit {
160+
return w.commitState()
121161
}
122162

123-
// fstat before closing the fd
124-
info, statErr := w.f.Stat()
125-
if statErr == nil {
126-
w.modTime = info.ModTime()
163+
return nil
164+
}
165+
166+
func (w *atomicFileWriter) commitState() error {
167+
// Perform a data only sync (fdatasync()) if supported
168+
if err := w.postDataWrittenSync(); err != nil {
169+
return err
127170
}
128-
// We delay error reporting until after the real call to close()
129-
// to match the traditional linux close() behaviour that an fd
130-
// is invalid (closed) even if close returns failure. While
131-
// weird, this allows a well defined way to not leak open fds.
132171

133-
if err := w.f.Close(); err != nil {
172+
// Capture fstat before closing the fd
173+
info, err := w.f.Stat()
174+
if err != nil {
134175
return err
135176
}
177+
w.modTime = info.ModTime()
136178

137-
if statErr != nil {
138-
return statErr
179+
if err := w.f.Chmod(w.perm); err != nil {
180+
return err
181+
}
182+
183+
// Perform full sync on platforms that need it
184+
if err := w.preRenameSync(); err != nil {
185+
return err
139186
}
140187

141-
if err := os.Chmod(w.f.Name(), w.perm); err != nil {
188+
// Some platforms require closing before rename (Windows)
189+
if err := w.closeTempFile(); err != nil {
142190
return err
143191
}
192+
144193
if w.writeErr == nil {
145194
return os.Rename(w.f.Name(), w.fn)
146195
}
196+
147197
return nil
148198
}
149199

@@ -195,7 +245,7 @@ func (w syncFileCloser) Close() error {
195245
if !defaultWriterOptions.NoSync {
196246
return w.File.Close()
197247
}
198-
err := fdatasync(w.File)
248+
err := dataOrFullSync(w.File)
199249
if err1 := w.File.Close(); err == nil {
200250
err = err1
201251
}

pkg/ioutils/fswriters_linux.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ import (
66
"golang.org/x/sys/unix"
77
)
88

9-
func fdatasync(f *os.File) error {
9+
func dataOrFullSync(f *os.File) error {
1010
return unix.Fdatasync(int(f.Fd()))
1111
}
12+
13+
func (w *atomicFileWriter) postDataWrittenSync() error {
14+
if w.noSync {
15+
return nil
16+
}
17+
return unix.Fdatasync(int(w.f.Fd()))
18+
}
19+
20+
func (w *atomicFileWriter) preRenameSync() error {
21+
// On Linux data can be reliably flushed to media without metadata, so defer
22+
return nil
23+
}

pkg/ioutils/fswriters_other.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//go:build !linux
2+
// +build !linux
3+
4+
package ioutils
5+
6+
import (
7+
"os"
8+
)
9+
10+
func dataOrFullSync(f *os.File) error {
11+
return f.Sync()
12+
}
13+
14+
func (w *atomicFileWriter) postDataWrittenSync() error {
15+
// many platforms (Mac, Windows) require a full sync to reliably flush to media
16+
return nil
17+
}
18+
19+
func (w *atomicFileWriter) preRenameSync() error {
20+
if w.noSync {
21+
return nil
22+
}
23+
24+
// fsync() on Non-linux Unix, FlushFileBuffers (Windows), F_FULLFSYNC (Mac)
25+
return w.f.Sync()
26+
}

pkg/ioutils/fswriters_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,56 @@ func TestAtomicWriteToFile(t *testing.T) {
4545
}
4646
}
4747

48+
func TestAtomicCommitAndRollbackFile(t *testing.T) {
49+
tmpDir := t.TempDir()
50+
path := filepath.Join(tmpDir, "foo")
51+
52+
oldData := "olddata"
53+
newData := "newdata"
54+
55+
check := func(n int, initData string, writeData string, expected string, explicit bool, commit bool) {
56+
if err := os.WriteFile(path, []byte(initData), 0644); err != nil {
57+
t.Fatalf("Failed creating initial file: %v", err)
58+
}
59+
60+
opts := &AtomicFileWriterOptions{ExplicitCommit: explicit}
61+
w, err := NewAtomicFileWriterWithOpts(filepath.Join(tmpDir, "foo"), 0644, opts)
62+
63+
if err != nil {
64+
t.Fatalf("(%d) Failed creating writer: %v", n, err)
65+
}
66+
67+
if _, err := w.Write([]byte(writeData)); err != nil {
68+
t.Fatalf("(%d) Failed writing content: %v", n, err)
69+
}
70+
71+
if commit {
72+
if err := w.Commit(); err != nil {
73+
t.Fatalf("(%d) Failed committing writer: %v", n, err)
74+
}
75+
}
76+
77+
if err := w.Close(); err != nil {
78+
t.Fatalf("(%d) Failed closing writer: %v", n, err)
79+
}
80+
81+
actual, err := os.ReadFile(filepath.Join(tmpDir, "foo"))
82+
if err != nil {
83+
t.Fatalf("(%d) Error reading from file: %v", n, err)
84+
}
85+
86+
// Verify write never happened since no call to commit
87+
if !bytes.Equal(actual, []byte(expected)) {
88+
t.Fatalf("(%d) Data mismatch, expected %q, got %q", n, expected, actual)
89+
}
90+
}
91+
92+
check(1, oldData, newData, oldData, true, false)
93+
check(2, oldData, newData, newData, true, true)
94+
check(3, oldData, newData, newData, false, false)
95+
check(4, oldData, newData, newData, false, true)
96+
}
97+
4898
func TestAtomicWriteSetCommit(t *testing.T) {
4999
tmpDir := t.TempDir()
50100

pkg/ioutils/fswriters_unsupported.go

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)