Skip to content

Commit b258bf1

Browse files
authored
Pre-allocate the output file on Darwin to avoid APFS sparse-file issues (#361)
AssembleFile truncated the output file to its target size, which on APFS creates a sparse file. Concurrent WriteAt calls on adjacent regions of large sparse files have shown non-deterministic corruption there, with null-chunk regions occasionally reading back wrong (#326). Replace the plain truncate with preallocateFile: on Darwin it uses fcntl(F_PREALLOCATE) to physically allocate blocks before truncating. Since F_PREALLOCATE with F_PEOFPOSMODE allocates relative to the current end of file, only the missing difference is requested, keeping in-place reassembly of existing files from over-allocating. Filesystems without F_PREALLOCATE support (SMB, FUSE) fall back to the previous plain-truncate behavior. All other platforms delegate to Truncate as before.
1 parent 429dc4d commit b258bf1

5 files changed

Lines changed: 191 additions & 2 deletions

File tree

assemble.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,11 @@ func AssembleFile(ctx context.Context, name string, idx Index, s Store, seeds []
128128

129129
// Truncate the output file to the full expected size. Not only does this
130130
// confirm there's enough disk space, but it allows for an optimization
131-
// when dealing with the Null Chunk
131+
// when dealing with the Null Chunk. On Darwin, the file is physically
132+
// pre-allocated as well since sparse files on APFS have shown to cause
133+
// issues when written to concurrently.
132134
if !isBlkDevice {
133-
if err := os.Truncate(name, idx.Length()); err != nil {
135+
if err := preallocateFile(name, idx.Length()); err != nil {
134136
return stats, err
135137
}
136138
}

preallocate_darwin.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//go:build darwin
2+
3+
package desync
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"os"
9+
10+
"golang.org/x/sys/unix"
11+
)
12+
13+
// preallocateFile physically allocates disk blocks and sets the file size.
14+
// On APFS, a plain Truncate creates sparse holes. When concurrent workers
15+
// call WriteAt on adjacent regions, copy-on-write of sparse blocks can
16+
// cause non-deterministic data corruption. Pre-allocating real blocks
17+
// avoids this.
18+
func preallocateFile(name string, size int64) error {
19+
f, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE, 0666)
20+
if err != nil {
21+
return err
22+
}
23+
defer f.Close()
24+
25+
info, err := f.Stat()
26+
if err != nil {
27+
return err
28+
}
29+
30+
// F_PREALLOCATE with F_PEOFPOSMODE allocates relative to the current
31+
// end of file, so only request the difference. Nothing to allocate if
32+
// the file is already large enough or no growth is needed.
33+
if extra := size - info.Size(); extra > 0 {
34+
store := unix.Fstore_t{
35+
Flags: unix.F_ALLOCATEALL,
36+
Posmode: unix.F_PEOFPOSMODE,
37+
Offset: 0,
38+
Length: extra,
39+
}
40+
if err := unix.FcntlFstore(f.Fd(), unix.F_PREALLOCATE, &store); err != nil {
41+
// Not all filesystems support F_PREALLOCATE (e.g. SMB or FUSE
42+
// mounts). The sparse-hole issue is specific to APFS, so fall
43+
// back to a plain truncate there.
44+
if !errors.Is(err, unix.ENOTSUP) {
45+
return fmt.Errorf("F_PREALLOCATE %s: %w", name, err)
46+
}
47+
}
48+
}
49+
50+
return f.Truncate(size)
51+
}

preallocate_darwin_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//go:build darwin
2+
3+
package desync
4+
5+
import (
6+
"bytes"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"testing"
11+
12+
"github.com/stretchr/testify/require"
13+
"golang.org/x/sys/unix"
14+
)
15+
16+
// TestPreallocateTightDisk re-preallocates an existing file to its current
17+
// size on a nearly-full volume. This must not require additional disk
18+
// space: F_PREALLOCATE with F_PEOFPOSMODE allocates relative to the
19+
// existing end of file, so requesting the full size instead of only the
20+
// missing difference over-allocates and fails with ENOSPC.
21+
func TestPreallocateTightDisk(t *testing.T) {
22+
if _, err := exec.LookPath("hdiutil"); err != nil {
23+
t.Skip("hdiutil not available")
24+
}
25+
dir := t.TempDir()
26+
img := filepath.Join(dir, "small.dmg")
27+
mount := filepath.Join(dir, "mnt")
28+
require.NoError(t, os.Mkdir(mount, 0755))
29+
30+
out, err := exec.Command("hdiutil", "create", "-size", "32m", "-fs", "APFS", "-volname", "desync-test", img).CombinedOutput()
31+
require.NoError(t, err, string(out))
32+
out, err = exec.Command("hdiutil", "attach", img, "-mountpoint", mount, "-nobrowse").CombinedOutput()
33+
require.NoError(t, err, string(out))
34+
t.Cleanup(func() { _ = exec.Command("hdiutil", "detach", mount, "-force").Run() })
35+
36+
var st unix.Statfs_t
37+
require.NoError(t, unix.Statfs(mount, &st))
38+
free := int64(st.Bavail) * int64(st.Bsize)
39+
40+
// Fill most of the volume with an existing file, leaving less free
41+
// space than the size of the file itself
42+
size := free * 2 / 3
43+
name := filepath.Join(mount, "existing")
44+
require.NoError(t, os.WriteFile(name, bytes.Repeat([]byte{0xab}, int(size)), 0666))
45+
46+
// The file already has the right size, nothing should be allocated
47+
require.NoError(t, preallocateFile(name, size))
48+
}

preallocate_other.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//go:build !darwin
2+
3+
package desync
4+
5+
import "os"
6+
7+
// preallocateFile truncates the file to the given size, creating it if
8+
// it doesn't exist. On Linux (ext4) and other platforms, Truncate
9+
// produces a file that reads back as zeros without sparse-hole issues,
10+
// so no special preallocation is needed.
11+
func preallocateFile(name string, size int64) error {
12+
f, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE, 0666)
13+
if err != nil {
14+
return err
15+
}
16+
defer f.Close()
17+
return f.Truncate(size)
18+
}

preallocate_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package desync
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestPreallocateNewFile(t *testing.T) {
13+
name := filepath.Join(t.TempDir(), "new")
14+
15+
require.NoError(t, preallocateFile(name, 2*1024*1024))
16+
17+
b, err := os.ReadFile(name)
18+
require.NoError(t, err)
19+
require.Len(t, b, 2*1024*1024)
20+
require.Equal(t, make([]byte, 2*1024*1024), b)
21+
}
22+
23+
func TestPreallocateGrowExistingFile(t *testing.T) {
24+
name := filepath.Join(t.TempDir(), "grow")
25+
data := bytes.Repeat([]byte{0xab}, 4096)
26+
require.NoError(t, os.WriteFile(name, data, 0666))
27+
28+
require.NoError(t, preallocateFile(name, 64*1024))
29+
30+
b, err := os.ReadFile(name)
31+
require.NoError(t, err)
32+
require.Len(t, b, 64*1024)
33+
// The original content must be preserved and the new region read as zeros
34+
require.Equal(t, data, b[:len(data)])
35+
require.Equal(t, make([]byte, 64*1024-len(data)), b[len(data):])
36+
}
37+
38+
func TestPreallocateShrinkExistingFile(t *testing.T) {
39+
name := filepath.Join(t.TempDir(), "shrink")
40+
data := bytes.Repeat([]byte{0xab}, 64*1024)
41+
require.NoError(t, os.WriteFile(name, data, 0666))
42+
43+
require.NoError(t, preallocateFile(name, 4096))
44+
45+
b, err := os.ReadFile(name)
46+
require.NoError(t, err)
47+
require.Equal(t, data[:4096], b)
48+
}
49+
50+
func TestPreallocateSameSize(t *testing.T) {
51+
name := filepath.Join(t.TempDir(), "same")
52+
data := bytes.Repeat([]byte{0xab}, 4096)
53+
require.NoError(t, os.WriteFile(name, data, 0666))
54+
55+
require.NoError(t, preallocateFile(name, int64(len(data))))
56+
57+
b, err := os.ReadFile(name)
58+
require.NoError(t, err)
59+
require.Equal(t, data, b)
60+
}
61+
62+
func TestPreallocateEmptyFile(t *testing.T) {
63+
name := filepath.Join(t.TempDir(), "empty")
64+
65+
require.NoError(t, preallocateFile(name, 0))
66+
67+
info, err := os.Stat(name)
68+
require.NoError(t, err)
69+
require.Zero(t, info.Size())
70+
}

0 commit comments

Comments
 (0)