Make atomic file replacement crash durable
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user