Files
yellowjacket/backend/fileutil/atomicwrite.go
yonlu 4d64b5dcfe feat(15-02): create backend/fileutil package with AtomicWrite
- Callback API: AtomicWrite(logger, targetPath, func(tmp *os.File) error)
- Deterministic .yj-tmp suffix for orphan cleanup
- Permission preservation (stat target, chmod temp before rename)
- Cross-device rejection via ErrCrossDevice sentinel
- Orphan cleanup on startup of each write operation
- Sync for durability before rename
2026-03-16 18:11:41 -04:00

102 lines
3.2 KiB
Go

// Package fileutil provides file system utilities for safe file operations.
package fileutil
import (
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"syscall"
)
// ErrCrossDevice is returned when an atomic rename fails because the temp file
// and target reside on different filesystems. There is no copy-then-delete
// fallback — callers must ensure both paths share the same mount point.
var ErrCrossDevice = errors.New("atomic write: cross-device rename not supported")
// tmpSuffix is the deterministic extension appended to the target path for the
// temporary file. A fixed suffix (rather than a random one) makes orphan
// cleanup straightforward.
const tmpSuffix = ".yj-tmp"
// AtomicWrite writes to targetPath atomically. It creates a temporary file in
// the same directory as targetPath (with a ".yj-tmp" suffix), passes it to fn
// for writing, syncs and closes the file, then renames it over targetPath. If
// the target already exists its permission bits are preserved; otherwise the
// new file receives mode 0644.
//
// Before writing, any orphaned .yj-tmp file from a previous interrupted
// operation is removed. If removal fails it is logged at debug level and the
// write proceeds.
//
// On any error after the temp file is created the temp file is removed.
func AtomicWrite(logger *slog.Logger, targetPath string, fn func(tmp *os.File) error) (err error) {
tmpPath := targetPath + tmpSuffix
// --- 1. Clean orphaned temp file from a previous crash ----------------
if _, statErr := os.Lstat(tmpPath); statErr == nil {
if rmErr := os.Remove(tmpPath); rmErr != nil {
logger.Debug("could not remove orphaned temp file",
slog.String("path", tmpPath),
slog.String("err", rmErr.Error()),
)
}
}
// --- 2. Read target permissions (if target exists) --------------------
mode := fs.FileMode(0o644) //nolint:mnd // default for new files
if info, statErr := os.Stat(targetPath); statErr == nil {
mode = info.Mode().Perm()
}
// --- 3. Create temp file ---------------------------------------------
tmp, createErr := os.Create(tmpPath)
if createErr != nil {
return fmt.Errorf("atomic write: create temp: %w", createErr)
}
// Ensure temp file is removed on any failure path after creation.
defer func() {
if err != nil {
_ = os.Remove(tmpPath)
}
}()
// --- 4. Caller writes data -------------------------------------------
if err = fn(tmp); err != nil {
_ = tmp.Close()
return fmt.Errorf("atomic write: callback: %w", err)
}
// --- 5. Sync for durability ------------------------------------------
if err = tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("atomic write: sync: %w", err)
}
// --- 6. Close --------------------------------------------------------
if err = tmp.Close(); err != nil {
return fmt.Errorf("atomic write: close: %w", err)
}
// --- 7. Preserve permissions -----------------------------------------
if err = os.Chmod(tmpPath, mode); err != nil {
return fmt.Errorf("atomic write: chmod: %w", err)
}
// --- 8. Atomic rename ------------------------------------------------
if err = os.Rename(tmpPath, targetPath); err != nil {
if errors.Is(err, syscall.EXDEV) {
return fmt.Errorf("%w: %w", ErrCrossDevice, err)
}
return fmt.Errorf("atomic write: rename: %w", err)
}
return nil
}