Make atomic file replacement crash durable

This commit is contained in:
2026-08-10 17:44:38 +00:00
parent 1dccf5f140
commit 59f3fe3d1d
13 changed files with 502 additions and 233 deletions

View File

@@ -63,7 +63,7 @@ func (s *LocalStore) Load(ctx context.Context, path string) (*Manifest, error) {
return &m, nil
}
// Save writes the manifest to path atomically via temp file + rename.
// Save writes the manifest to path through the durable file replacement primitive.
func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
if err := checkContext(ctx); err != nil {
return err
@@ -95,46 +95,9 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
}
data = append(data, '\n')
dir := filepath.Dir(path)
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return fmt.Errorf("save manifest: create directory %q: %w", dir, err)
if err := writeJSONAtomically(ctx, path, data); err != nil {
return fmt.Errorf("save manifest: %w", err)
}
tmp, err := os.CreateTemp(dir, ".manifest.json.tmp-*")
if err != nil {
return fmt.Errorf("save manifest: create temp file: %w", err)
}
tmpName := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("save manifest: write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("save manifest: sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("save manifest: close temp file: %w", err)
}
if err := os.Chmod(tmpName, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("save manifest: set temp file permissions: %w", err)
}
if err := checkContext(ctx); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save manifest: rename temp file: %w", err)
}
removeTmp = false
return nil
}
@@ -221,7 +184,7 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e
}
data = append(data, '\n')
return writeJSONAtomically(ctx, path, ".run-manifest.json.tmp-*", data)
return writeJSONAtomically(ctx, path, data)
}
func validateLoadedManifest(m *Manifest) error {
@@ -330,48 +293,17 @@ func normalizeRunManifest(m *RunManifest) {
}
}
func writeJSONAtomically(ctx context.Context, path, tempPattern string, data []byte) error {
func writeJSONAtomically(ctx context.Context, path string, data []byte) error {
dir := filepath.Dir(path)
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return fmt.Errorf("create directory %q: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, tempPattern)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpName, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("set temp file permissions: %w", err)
}
if err := checkContext(ctx); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename temp file: %w", err)
}
removeTmp = false
return nil
return fileops.ReplaceFileAtomic(path, data, fileops.ReplaceFileOptions{
Mode: fileops.WorkspaceFileMode,
BeforeRename: func() error {
return checkContext(ctx)
},
})
}
func checkContext(ctx context.Context) error {

View File

@@ -3,6 +3,7 @@ package manifest
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
@@ -205,6 +206,30 @@ func TestLocalStoreSaveAtomicPractical(t *testing.T) {
}
}
func TestWriteJSONAtomicallyChecksCancellationBeforeReplacement(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
dir := t.TempDir()
path := filepath.Join(dir, "manifest.json")
err := writeJSONAtomically(ctx, path, []byte("{\"session_id\":\"session\"}\n"))
if !errors.Is(err, context.Canceled) {
t.Fatalf("writeJSONAtomically() error = %v, want context cancellation", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("replacement destination exists after cancellation: stat err = %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir() error = %v", err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".manifest.json.tmp-") {
t.Fatalf("temporary manifest remained after cancellation: %s", entry.Name())
}
}
}
func TestLoadRejectsInvalidManifest(t *testing.T) {
store := &LocalStore{}
ctx := context.Background()