Skip to content

Commit 64c3dcd

Browse files
samuelkarpthaJeztah
authored andcommitted
atomicfile: new package for atomic file writes
Certain files may need to be written atomically so that partial writes are not visible to other processes. On Unix-like platforms such as Linux, FreeBSD, and Darwin, this is accomplished by writing a temporary file, syncing, and renaming over the destination file name. On Windows, the same operations are performed, but Windows does not guarantee that a rename operation is atomic. Partial/inconsistent reads can occur due to: 1. A process attempting to read the file while containerd is writing it (both in the case of a new file with a short/incomplete write or in the case of an existing, updated file where new bytes may be written at the beginning but old bytes may still be present after). 2. Concurrent goroutines in containerd leading to multiple active writers of the same file. The above mechanism explicitly protects against (1) as all writes are to a file with a temporary name. There is no explicit protection against multiple, concurrent goroutines attempting to write the same file. However, atomically writing the file should mean only one writer will "win" and a consistent file will be visible. Signed-off-by: Samuel Karp <[email protected]> (cherry picked from commit f3ba7c8) Signed-off-by: Sebastiaan van Stijn <[email protected]>
1 parent fd0566c commit 64c3dcd

File tree

2 files changed

+225
-0
lines changed

2 files changed

+225
-0
lines changed

pkg/atomicfile/file.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
/*
18+
Package atomicfile provides a mechanism (on Unix-like platforms) to present a consistent view of a file to separate
19+
processes even while the file is being written. This is accomplished by writing a temporary file, syncing to disk, and
20+
renaming over the destination file name.
21+
22+
Partial/inconsistent reads can occur due to:
23+
1. A process attempting to read the file while it is being written to (both in the case of a new file with a
24+
short/incomplete write or in the case of an existing, updated file where new bytes may be written at the beginning
25+
but old bytes may still be present after).
26+
2. Concurrent goroutines leading to multiple active writers of the same file.
27+
28+
The above mechanism explicitly protects against (1) as all writes are to a file with a temporary name.
29+
30+
There is no explicit protection against multiple, concurrent goroutines attempting to write the same file. However,
31+
atomically writing the file should mean only one writer will "win" and a consistent file will be visible.
32+
33+
Note: atomicfile is partially implemented for Windows. The Windows codepath performs the same operations, however
34+
Windows does not guarantee that a rename operation is atomic; a crash in the middle may leave the destination file
35+
truncated rather than with the expected content.
36+
*/
37+
package atomicfile
38+
39+
import (
40+
"errors"
41+
"fmt"
42+
"io"
43+
"os"
44+
"path/filepath"
45+
"sync"
46+
)
47+
48+
// File is an io.ReadWriteCloser that can also be Canceled if a change needs to be abandoned.
49+
type File interface {
50+
io.ReadWriteCloser
51+
// Cancel abandons a change to a file. This can be called if a write fails or another error occurs.
52+
Cancel() error
53+
}
54+
55+
// ErrClosed is returned if Read or Write are called on a closed File.
56+
var ErrClosed = errors.New("file is closed")
57+
58+
// New returns a new atomic file. On Unix-like platforms, the writer (an io.ReadWriteCloser) is backed by a temporary
59+
// file placed into the same directory as the destination file (using filepath.Dir to split the directory from the
60+
// name). On a call to Close the temporary file is synced to disk and renamed to its final name, hiding any previous
61+
// file by the same name.
62+
//
63+
// Note: Take care to call Close and handle any errors that are returned. Errors returned from Close may indicate that
64+
// the file was not written with its final name.
65+
func New(name string, mode os.FileMode) (File, error) {
66+
return newFile(name, mode)
67+
}
68+
69+
type atomicFile struct {
70+
name string
71+
f *os.File
72+
closed bool
73+
closedMu sync.RWMutex
74+
}
75+
76+
func newFile(name string, mode os.FileMode) (File, error) {
77+
dir := filepath.Dir(name)
78+
f, err := os.CreateTemp(dir, "")
79+
if err != nil {
80+
return nil, fmt.Errorf("failed to create temp file: %w", err)
81+
}
82+
if err := f.Chmod(mode); err != nil {
83+
return nil, fmt.Errorf("failed to change temp file permissions: %w", err)
84+
}
85+
return &atomicFile{name: name, f: f}, nil
86+
}
87+
88+
func (a *atomicFile) Close() (err error) {
89+
a.closedMu.Lock()
90+
defer a.closedMu.Unlock()
91+
92+
if a.closed {
93+
return nil
94+
}
95+
a.closed = true
96+
97+
defer func() {
98+
if err != nil {
99+
_ = os.Remove(a.f.Name()) // ignore errors
100+
}
101+
}()
102+
// The order of operations here is:
103+
// 1. sync
104+
// 2. close
105+
// 3. rename
106+
// While the ordering of 2 and 3 is not important on Unix-like operating systems, Windows cannot rename an open
107+
// file. By closing first, we allow the rename operation to succeed.
108+
if err = a.f.Sync(); err != nil {
109+
return fmt.Errorf("failed to sync temp file %q: %w", a.f.Name(), err)
110+
}
111+
if err = a.f.Close(); err != nil {
112+
return fmt.Errorf("failed to close temp file %q: %w", a.f.Name(), err)
113+
}
114+
if err = os.Rename(a.f.Name(), a.name); err != nil {
115+
return fmt.Errorf("failed to rename %q to %q: %w", a.f.Name(), a.name, err)
116+
}
117+
return nil
118+
}
119+
120+
func (a *atomicFile) Cancel() error {
121+
a.closedMu.Lock()
122+
defer a.closedMu.Unlock()
123+
124+
if a.closed {
125+
return nil
126+
}
127+
a.closed = true
128+
_ = a.f.Close() // ignore error
129+
return os.Remove(a.f.Name())
130+
}
131+
132+
func (a *atomicFile) Read(p []byte) (n int, err error) {
133+
a.closedMu.RLock()
134+
defer a.closedMu.RUnlock()
135+
if a.closed {
136+
return 0, ErrClosed
137+
}
138+
return a.f.Read(p)
139+
}
140+
141+
func (a *atomicFile) Write(p []byte) (n int, err error) {
142+
a.closedMu.RLock()
143+
defer a.closedMu.RUnlock()
144+
if a.closed {
145+
return 0, ErrClosed
146+
}
147+
return a.f.Write(p)
148+
}

pkg/atomicfile/file_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package atomicfile
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"path/filepath"
23+
"testing"
24+
25+
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/require"
27+
)
28+
29+
func TestFile(t *testing.T) {
30+
const content = "this is some test content for a file"
31+
dir := t.TempDir()
32+
path := filepath.Join(dir, "test-file")
33+
34+
f, err := New(path, 0o644)
35+
require.NoError(t, err, "failed to create file")
36+
n, err := fmt.Fprint(f, content)
37+
assert.NoError(t, err, "failed to write content")
38+
assert.Equal(t, len(content), n, "written bytes should be equal")
39+
err = f.Close()
40+
require.NoError(t, err, "failed to close file")
41+
42+
actual, err := os.ReadFile(path)
43+
assert.NoError(t, err, "failed to read file")
44+
assert.Equal(t, content, string(actual))
45+
}
46+
47+
func TestConcurrentWrites(t *testing.T) {
48+
const content1 = "this is the first content of the file. there should be none other."
49+
const content2 = "the second content of the file should win!"
50+
dir := t.TempDir()
51+
path := filepath.Join(dir, "test-file")
52+
53+
file1, err := New(path, 0o600)
54+
require.NoError(t, err, "failed to create file1")
55+
file2, err := New(path, 0o644)
56+
require.NoError(t, err, "failed to create file2")
57+
58+
n, err := fmt.Fprint(file1, content1)
59+
assert.NoError(t, err, "failed to write content1")
60+
assert.Equal(t, len(content1), n, "written bytes should be equal")
61+
62+
n, err = fmt.Fprint(file2, content2)
63+
assert.NoError(t, err, "failed to write content2")
64+
assert.Equal(t, len(content2), n, "written bytes should be equal")
65+
66+
err = file1.Close()
67+
require.NoError(t, err, "failed to close file1")
68+
actual, err := os.ReadFile(path)
69+
assert.NoError(t, err, "failed to read file")
70+
assert.Equal(t, content1, string(actual))
71+
72+
err = file2.Close()
73+
require.NoError(t, err, "failed to close file2")
74+
actual, err = os.ReadFile(path)
75+
assert.NoError(t, err, "failed to read file")
76+
assert.Equal(t, content2, string(actual))
77+
}

0 commit comments

Comments
 (0)