feat(fsx): add atomic and durable file writes
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package fsx provides small filesystem helpers with atomic, durable writes.
|
||||||
|
//
|
||||||
|
// prx persists configuration and runtime state that must never be observed in a
|
||||||
|
// partially written form, so every write goes through WriteFileAtomic.
|
||||||
|
package fsx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteFileAtomic writes data to path atomically and durably.
|
||||||
|
//
|
||||||
|
// It creates any missing parent directories, writes to a temporary file in the
|
||||||
|
// destination directory, flushes it to stable storage, renames it over path and
|
||||||
|
// finally flushes the directory. Concurrent readers therefore either see the
|
||||||
|
// previous contents or the complete new contents, never a partial file.
|
||||||
|
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp(dir, ".prx-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temporary file in %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
// Remove the temporary file unless it has been renamed into place.
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return fmt.Errorf("write %s: %w", tmpName, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Chmod(perm); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return fmt.Errorf("chmod %s: %w", tmpName, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return fmt.Errorf("sync %s: %w", tmpName, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close %s: %w", tmpName, err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
return fmt.Errorf("replace %s: %w", path, err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
|
||||||
|
// Flush the directory so the rename itself survives a crash. Failure to
|
||||||
|
// sync a directory is tolerated because some filesystems do not support it.
|
||||||
|
syncDir(dir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncDir flushes directory metadata to stable storage, ignoring filesystems
|
||||||
|
// that do not support directory fsync.
|
||||||
|
func syncDir(dir string) {
|
||||||
|
f, err := os.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_ = f.Sync()
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package fsx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteFileAtomicCreatesAndReplaces(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "nested", "file.json")
|
||||||
|
|
||||||
|
if err := WriteFileAtomic(path, []byte("first"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFileAtomic: %v", err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "first" {
|
||||||
|
t.Fatalf("content = %q, want %q", got, "first")
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("perm = %o, want 600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := WriteFileAtomic(path, []byte("second"), 0o600); err != nil {
|
||||||
|
t.Fatalf("second WriteFileAtomic: %v", err)
|
||||||
|
}
|
||||||
|
got, _ = os.ReadFile(path)
|
||||||
|
if string(got) != "second" {
|
||||||
|
t.Fatalf("content = %q, want %q", got, "second")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileAtomicLeavesNoTempFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "state.json")
|
||||||
|
if err := WriteFileAtomic(path, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFileAtomic: %v", err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 || entries[0].Name() != "state.json" {
|
||||||
|
t.Fatalf("unexpected directory contents: %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user