Skip to content

Commit 05d8e29

Browse files
jkyberneeesclaude
andcommitted
fix(persist): fsync before rename so a crash can't lose the latest write
Session and memory writes did WriteFile(tmp)+Rename — atomic against torn reads, but NOT durable: without an fsync a power loss / kernel crash can land the rename while the data is still in the page cache, leaving an empty or truncated file and silently losing the latest conversation turn or extracted memory. session.go also used a fixed "<target>.tmp" name, so two concurrent saves of the same target could clobber each other's temp file. Add internal/fsatomic.WriteFile (unique temp → fsync data → rename → fsync dir) and route the irreplaceable writes through it: session save + index, episode index + summaries, and the facts store. Tests: fsatomic content/perm/overwrite, no temp litter, and concurrent same-target writers never producing a torn file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1ae5ef5 commit 05d8e29

5 files changed

Lines changed: 196 additions & 49 deletions

File tree

internal/fsatomic/fsatomic.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Package fsatomic provides a crash-durable atomic file write.
2+
//
3+
// The common "write a temp file then rename over the target" idiom gives
4+
// atomicity (a reader sees either the old or the new file, never a torn one),
5+
// but NOT durability: without an fsync, a power loss or kernel crash can land
6+
// the rename in the directory while the file's data is still only in the page
7+
// cache, leaving an empty or truncated file after reboot. For data the agent
8+
// can't reconstruct — conversation sessions, extracted memories — that is silent
9+
// data loss.
10+
//
11+
// WriteFile closes the gap: it fsyncs the file data before the rename and
12+
// fsyncs the parent directory after, so a successful return means the bytes are
13+
// durably on disk. It also uses a unique temp name, so two concurrent writers
14+
// to the same target can't clobber each other's temp file.
15+
package fsatomic
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"path/filepath"
21+
)
22+
23+
// WriteFile atomically and durably writes data to path with the given perm.
24+
// On success the bytes are fsynced to disk and the rename is durable.
25+
func WriteFile(path string, data []byte, perm os.FileMode) (err error) {
26+
dir := filepath.Dir(path)
27+
28+
f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
29+
if err != nil {
30+
return fmt.Errorf("fsatomic: create temp: %w", err)
31+
}
32+
tmp := f.Name()
33+
// Remove the temp file on any failure before the rename succeeds.
34+
defer func() {
35+
if tmp != "" {
36+
os.Remove(tmp)
37+
}
38+
}()
39+
40+
if _, err := f.Write(data); err != nil {
41+
f.Close()
42+
return fmt.Errorf("fsatomic: write: %w", err)
43+
}
44+
if err := f.Chmod(perm); err != nil {
45+
f.Close()
46+
return fmt.Errorf("fsatomic: chmod: %w", err)
47+
}
48+
// Flush the file's data to disk before exposing it via the rename.
49+
if err := f.Sync(); err != nil {
50+
f.Close()
51+
return fmt.Errorf("fsatomic: fsync temp: %w", err)
52+
}
53+
if err := f.Close(); err != nil {
54+
return fmt.Errorf("fsatomic: close temp: %w", err)
55+
}
56+
57+
if err := os.Rename(tmp, path); err != nil {
58+
return fmt.Errorf("fsatomic: rename: %w", err)
59+
}
60+
tmp = "" // renamed — no longer ours to remove
61+
62+
// Make the rename itself durable. Best-effort: some filesystems don't
63+
// support directory fsync, and the data is already synced, so a failure
64+
// here doesn't corrupt anything — it just weakens the crash guarantee.
65+
if d, derr := os.Open(dir); derr == nil {
66+
_ = d.Sync()
67+
d.Close()
68+
}
69+
return nil
70+
}

internal/fsatomic/fsatomic_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package fsatomic
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"sync"
7+
"testing"
8+
)
9+
10+
func TestWriteFile_WritesContentAndPerm(t *testing.T) {
11+
dir := t.TempDir()
12+
path := filepath.Join(dir, "data.json")
13+
14+
if err := WriteFile(path, []byte("hello"), 0600); err != nil {
15+
t.Fatalf("WriteFile: %v", err)
16+
}
17+
got, err := os.ReadFile(path)
18+
if err != nil {
19+
t.Fatalf("ReadFile: %v", err)
20+
}
21+
if string(got) != "hello" {
22+
t.Errorf("content = %q, want %q", got, "hello")
23+
}
24+
info, err := os.Stat(path)
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
if info.Mode().Perm() != 0600 {
29+
t.Errorf("perm = %v, want 0600", info.Mode().Perm())
30+
}
31+
}
32+
33+
func TestWriteFile_Overwrites(t *testing.T) {
34+
dir := t.TempDir()
35+
path := filepath.Join(dir, "data")
36+
if err := WriteFile(path, []byte("first"), 0644); err != nil {
37+
t.Fatal(err)
38+
}
39+
if err := WriteFile(path, []byte("second"), 0644); err != nil {
40+
t.Fatal(err)
41+
}
42+
got, _ := os.ReadFile(path)
43+
if string(got) != "second" {
44+
t.Errorf("content = %q, want %q", got, "second")
45+
}
46+
}
47+
48+
// TestWriteFile_LeavesNoTempOnSuccess verifies the unique temp file is renamed
49+
// away (no litter), which also confirms the temp naming doesn't collide with
50+
// the target.
51+
func TestWriteFile_LeavesNoTempOnSuccess(t *testing.T) {
52+
dir := t.TempDir()
53+
path := filepath.Join(dir, "data")
54+
if err := WriteFile(path, []byte("x"), 0644); err != nil {
55+
t.Fatal(err)
56+
}
57+
entries, _ := os.ReadDir(dir)
58+
if len(entries) != 1 || entries[0].Name() != "data" {
59+
var names []string
60+
for _, e := range entries {
61+
names = append(names, e.Name())
62+
}
63+
t.Errorf("expected only [data], got %v", names)
64+
}
65+
}
66+
67+
// TestWriteFile_ConcurrentSameTarget verifies concurrent writers to the same
68+
// path don't clobber each other's temp file (the old fixed-".tmp" pattern
69+
// could) — the final content must be a complete one of the writes, never torn.
70+
func TestWriteFile_ConcurrentSameTarget(t *testing.T) {
71+
dir := t.TempDir()
72+
path := filepath.Join(dir, "data")
73+
payloads := []string{
74+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
75+
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
76+
"cccccccccccccccccccccccccccccc",
77+
}
78+
var wg sync.WaitGroup
79+
for _, p := range payloads {
80+
wg.Add(1)
81+
go func(data string) {
82+
defer wg.Done()
83+
for i := 0; i < 20; i++ {
84+
if err := WriteFile(path, []byte(data), 0644); err != nil {
85+
t.Errorf("WriteFile: %v", err)
86+
return
87+
}
88+
}
89+
}(p)
90+
}
91+
wg.Wait()
92+
93+
got, err := os.ReadFile(path)
94+
if err != nil {
95+
t.Fatal(err)
96+
}
97+
ok := false
98+
for _, p := range payloads {
99+
if string(got) == p {
100+
ok = true
101+
}
102+
}
103+
if !ok {
104+
t.Errorf("final content %q is torn — not a complete write", got)
105+
}
106+
// No temp files left behind.
107+
entries, _ := os.ReadDir(dir)
108+
if len(entries) != 1 {
109+
t.Errorf("expected only the target file, got %d entries", len(entries))
110+
}
111+
}

internal/memory/episodes.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/BackendStack21/go-vector/pkg/vector"
1616
"github.com/BackendStack21/odek/internal/embedding"
17+
"github.com/BackendStack21/odek/internal/fsatomic"
1718
"github.com/BackendStack21/odek/internal/session"
1819
)
1920

@@ -224,7 +225,7 @@ func (e *EpisodeStore) writeLocked(sessionID, summary string, turns int, prov Ep
224225

225226
// Write the summary file.
226227
path := filepath.Join(e.dir, sessionID+".md")
227-
if err := os.WriteFile(path, []byte(summary), 0600); err != nil {
228+
if err := fsatomic.WriteFile(path, []byte(summary), 0600); err != nil {
228229
return events, fmt.Errorf("memory: write episode: %w", err)
229230
}
230231

@@ -661,19 +662,14 @@ func trustRank(p EpisodeProvenance) int {
661662
// Invalidates the in-memory cache after a successful write so the next
662663
// ReadIndex call picks up the new data.
663664
func (e *EpisodeStore) writeIndex(idx []EpisodeMeta) error {
664-
// Write to temp + rename for atomicity
665+
// Write atomically and durably (temp → fsync → rename → dir fsync).
665666
idxPath := filepath.Join(e.dir, episodeIndexFile)
666-
tmpPath := idxPath + ".tmp"
667667

668668
data, err := json.MarshalIndent(idx, "", " ")
669669
if err != nil {
670670
return fmt.Errorf("memory: marshal index: %w", err)
671671
}
672-
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
673-
return err
674-
}
675-
if err := os.Rename(tmpPath, idxPath); err != nil {
676-
os.Remove(tmpPath) // best-effort cleanup
672+
if err := fsatomic.WriteFile(idxPath, data, 0600); err != nil {
677673
return err
678674
}
679675

internal/memory/facts.go

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import (
2727
"path/filepath"
2828
"strings"
2929
"sync"
30+
31+
"github.com/BackendStack21/odek/internal/fsatomic"
3032
)
3133

3234
// File names for fact targets.
@@ -313,27 +315,7 @@ func (f *FactStore) Entries(target string) ([]string, error) {
313315
// mutual exclusion that f.mu provides per-instance.
314316
func (f *FactStore) writeEntries(target string, entries []string) error {
315317
content := strings.Join(entries, entrySep)
316-
path := f.path(target)
317-
318-
tmp, err := os.CreateTemp(f.dir, filepath.Base(path)+".tmp-*")
319-
if err != nil {
320-
return err
321-
}
322-
tmpName := tmp.Name()
323-
if _, err := tmp.Write([]byte(content)); err != nil {
324-
tmp.Close()
325-
os.Remove(tmpName)
326-
return err
327-
}
328-
if err := tmp.Close(); err != nil {
329-
os.Remove(tmpName)
330-
return err
331-
}
332-
if err := os.Rename(tmpName, path); err != nil {
333-
os.Remove(tmpName)
334-
return err
335-
}
336-
return nil
318+
return fsatomic.WriteFile(f.path(target), []byte(content), 0600)
337319
}
338320

339321
// parseEntries splits file content into individual entries.

internal/session/session.go

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"time"
2828

2929
"github.com/BackendStack21/odek/internal/embedding"
30+
"github.com/BackendStack21/odek/internal/fsatomic"
3031
"github.com/BackendStack21/odek/internal/llm"
3132
"github.com/BackendStack21/odek/internal/redact"
3233
)
@@ -197,15 +198,8 @@ func (s *Store) saveIndexLocked(idx map[string]*IndexEntry) error {
197198
if err != nil {
198199
return fmt.Errorf("session: marshal index: %w", err)
199200
}
200-
target := s.indexPath()
201-
tmp := target + ".tmp"
202-
if err := os.WriteFile(tmp, data, 0600); err != nil {
203-
os.Remove(tmp)
204-
return fmt.Errorf("session: write index tmp: %w", err)
205-
}
206-
if err := os.Rename(tmp, target); err != nil {
207-
os.Remove(tmp)
208-
return fmt.Errorf("session: rename index: %w", err)
201+
if err := fsatomic.WriteFile(s.indexPath(), data, 0600); err != nil {
202+
return fmt.Errorf("session: write index: %w", err)
209203
}
210204
return nil
211205
}
@@ -265,9 +259,10 @@ func (s *Store) Append(id string, newMsgs []llm.Message) error {
265259
return s.saveLocked(sess)
266260
}
267261

268-
// Save writes a session to disk atomically using a temp-file + rename
269-
// strategy. This prevents:
262+
// Save writes a session to disk atomically and durably via fsatomic.WriteFile
263+
// (temp-file → fsync → rename → dir fsync). This prevents:
270264
// - Partial writes from crashes (rename is atomic on POSIX)
265+
// - Data loss on power failure (the fsync flushes bytes before the rename)
271266
// - Symlink-following TOCTOU attacks (os.Rename replaces the
272267
// directory entry itself — it does NOT follow symlinks)
273268
func (s *Store) Save(sess *Session) error {
@@ -297,15 +292,8 @@ func (s *Store) saveLocked(sess *Session) error {
297292
return fmt.Errorf("session: marshal: %w", err)
298293
}
299294

300-
target := s.path(sess.ID)
301-
tmp := target + ".tmp"
302-
if err := os.WriteFile(tmp, data, 0600); err != nil {
303-
os.Remove(tmp)
304-
return fmt.Errorf("session: write tmp: %w", err)
305-
}
306-
if err := os.Rename(tmp, target); err != nil {
307-
os.Remove(tmp)
308-
return fmt.Errorf("session: rename: %w", err)
295+
if err := fsatomic.WriteFile(s.path(sess.ID), data, 0600); err != nil {
296+
return fmt.Errorf("session: write: %w", err)
309297
}
310298

311299
// Update the index atomically.

0 commit comments

Comments
 (0)