Skip to content

Commit 8c0db89

Browse files
gwleclercclaude
andcommitted
fix(persistence): load sessions resiliently and bound file-descriptor use
Two robustness fixes for the recurring persistence reports (#284, #292, #318): - Make LoadSessions best-effort per session. Previously a single missing or unreadable per-session file (e.g. a partial session directory after a mid-write restart or a K8s volume race) made the whole load fail and dropped EVERY persisted session. Now each session's history and mocks load independently: a missing/corrupt file is logged and skipped, other sessions and the session name/metadata still load. - Bound persistence concurrency with errgroup SetLimit(16) on both the load and the store paths, so a large number of sessions can no longer exhaust the process's file descriptors ("too many open files"). Tests (server/services/persistence_test.go, pass under -race): - TestLoadSessionsResilientToMissingFiles: a partial session dir no longer wipes the others. - TestStoreAndLoadManySessions: round-trips more sessions than the limit without deadlocking or losing data. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5ee84b7 commit 8c0db89

2 files changed

Lines changed: 145 additions & 44 deletions

File tree

server/services/persistence.go

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package services
22

33
import (
44
"fmt"
5-
"io"
65
"log/slog"
76
"os"
87
"path/filepath"
@@ -17,6 +16,12 @@ const (
1716
historyFileName = "history.yml"
1817
mocksFileName = "mocks.yml"
1918
sessionsFileName = "sessions.yml"
19+
20+
// maxPersistenceConcurrency bounds how many session files are read/written in parallel.
21+
// Persistence spawns work per session, so without a cap a large number of sessions could
22+
// exhaust the process's file descriptors ("too many open files"). errgroup.Go blocks once
23+
// this many goroutines are in flight.
24+
maxPersistenceConcurrency = 16
2025
)
2126

2227
type Persistence interface {
@@ -110,6 +115,7 @@ func (p *persistence) StoreSessions(sessions types.Sessions) {
110115
return
111116
}
112117
var sessionsGroup errgroup.Group
118+
sessionsGroup.SetLimit(maxPersistenceConcurrency)
113119
for i := range sessions {
114120
session := sessions[i]
115121
sessionsGroup.Go(func() error {
@@ -146,79 +152,76 @@ func (p *persistence) LoadSessions() (types.Sessions, error) {
146152
if _, err := os.Stat(p.persistenceDirectory); os.IsNotExist(err) {
147153
return nil, err
148154
}
149-
file, err := os.Open(filepath.Join(p.persistenceDirectory, sessionsFileName))
150-
if err != nil {
151-
return nil, err
152-
}
153-
defer file.Close()
154-
bytes, err := io.ReadAll(file)
155+
data, err := os.ReadFile(filepath.Join(p.persistenceDirectory, sessionsFileName))
155156
if err != nil {
156157
return nil, err
157158
}
158159
var sessions types.Sessions
159-
err = yaml.Unmarshal(bytes, &sessions)
160-
if err != nil {
160+
if err := yaml.Unmarshal(data, &sessions); err != nil {
161161
return nil, err
162162
}
163-
var sessionsGroup errgroup.Group
163+
164+
// Load each session's history and mocks independently and resiliently: a missing or
165+
// unreadable per-session file must never discard the other sessions. Previously a single
166+
// incomplete session directory made the whole load fail and wiped every persisted session on
167+
// restart (the recurring persistence reports). The session name always loads from the
168+
// summary, and whatever history/mocks are readable are kept.
164169
var sessionsLock sync.Mutex
170+
var group errgroup.Group
171+
group.SetLimit(maxPersistenceConcurrency)
165172
for i := range sessions {
166173
session := sessions[i]
167-
sessionsGroup.Go(func() error {
168-
historyFile, err := os.Open(filepath.Join(p.persistenceDirectory, session.ID, historyFileName))
169-
if err != nil {
170-
slog.Error(fmt.Sprintf("Unable to open history file for session %q", session.ID), "error", err)
171-
return err
172-
}
173-
defer historyFile.Close()
174-
bytes, err := io.ReadAll(historyFile)
174+
group.Go(func() error {
175+
history, err := loadPersistedFile[types.History](p.persistenceDirectory, session.ID, historyFileName)
175176
if err != nil {
176-
return err
177-
}
178-
var history types.History
179-
err = yaml.Unmarshal(bytes, &history)
180-
if err != nil {
181-
return err
177+
logPersistedLoadError("history", session.ID, err)
178+
return nil
182179
}
183180
sessionsLock.Lock()
184181
session.History = history
185182
sessionsLock.Unlock()
186183
return nil
187184
})
188-
sessionsGroup.Go(func() error {
189-
mocksFile, err := os.Open(filepath.Join(p.persistenceDirectory, session.ID, mocksFileName))
185+
group.Go(func() error {
186+
mocks, err := loadPersistedFile[types.Mocks](p.persistenceDirectory, session.ID, mocksFileName)
190187
if err != nil {
191-
slog.Error(fmt.Sprintf("Unable to open mocks file for session %q", session.ID), "error", err)
192-
return err
188+
logPersistedLoadError("mocks", session.ID, err)
189+
return nil
193190
}
194-
defer mocksFile.Close()
195-
bytes, err := io.ReadAll(mocksFile)
196-
if err != nil {
197-
return err
198-
}
199-
var mocks types.Mocks
200-
err = yaml.Unmarshal(bytes, &mocks)
201-
if err != nil {
202-
return err
203-
}
204-
205191
// mocks are stored as a stack so we need to reverse the list from mocks file
206192
orderedMocks := make(types.Mocks, 0, len(mocks))
207-
for i := len(mocks) - 1; i >= 0; i-- {
208-
orderedMocks = append(orderedMocks, mocks[i])
193+
for j := len(mocks) - 1; j >= 0; j-- {
194+
orderedMocks = append(orderedMocks, mocks[j])
209195
}
210196
sessionsLock.Lock()
211197
session.Mocks = orderedMocks
212198
sessionsLock.Unlock()
213199
return nil
214200
})
215201
}
216-
if err := sessionsGroup.Wait(); err != nil {
217-
return nil, err
218-
}
202+
_ = group.Wait() // per-file errors are handled above; the load itself never fails here
219203
return sessions, nil
220204
}
221205

206+
// loadPersistedFile reads and YAML-decodes a per-session persistence file (history or mocks).
207+
func loadPersistedFile[T any](dir, sessionID, name string) (T, error) {
208+
var v T
209+
data, err := os.ReadFile(filepath.Join(dir, sessionID, name))
210+
if err != nil {
211+
return v, err
212+
}
213+
return v, yaml.Unmarshal(data, &v)
214+
}
215+
216+
// logPersistedLoadError reports a per-session load problem without aborting the whole load.
217+
func logPersistedLoadError(kind, sessionID string, err error) {
218+
if os.IsNotExist(err) {
219+
slog.Debug(fmt.Sprintf("No %s file for session %q, treating as empty", kind, sessionID))
220+
return
221+
}
222+
slog.Warn(fmt.Sprintf("Unable to load %s for session %q, ignoring it", kind, sessionID), "error", err)
223+
}
224+
222225
func (p *persistence) createSessionDirectory(sessionID string) error {
223226
slog.Debug(fmt.Sprintf("Create directory for session %q", sessionID))
224227
sessionDirectory := filepath.Join(p.persistenceDirectory, sessionID)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package services
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/smocker-dev/smocker/server/types"
10+
)
11+
12+
// TestLoadSessionsResilientToMissingFiles locks the fix for the recurring persistence bug:
13+
// a single incomplete session directory (e.g. a missing mocks.yml after a partial write or a
14+
// mid-write pod restart) used to make LoadSessions fail and drop EVERY persisted session.
15+
// Loading must now be best-effort per session: other sessions still load, and a session with a
16+
// missing file keeps its name and whatever else is readable.
17+
func TestLoadSessionsResilientToMissingFiles(t *testing.T) {
18+
dir := t.TempDir()
19+
20+
write := func(name, content string) {
21+
t.Helper()
22+
full := filepath.Join(dir, name)
23+
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
24+
t.Fatal(err)
25+
}
26+
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
27+
t.Fatal(err)
28+
}
29+
}
30+
31+
write("sessions.yml", "- id: sess-full\n name: Full\n date: 2020-01-01T00:00:00Z\n"+
32+
"- id: sess-partial\n name: Partial\n date: 2020-01-01T00:00:00Z\n")
33+
// Complete session: has both mocks and history.
34+
write("sess-full/mocks.yml", "- request:\n path: /a\n response:\n status: 200\n")
35+
write("sess-full/history.yml", "[]\n")
36+
// Partial session: history only, mocks.yml is missing (the failure case).
37+
write("sess-partial/history.yml", "[]\n")
38+
39+
sessions, err := NewPersistence(dir).LoadSessions()
40+
if err != nil {
41+
t.Fatalf("LoadSessions must not fail because of a partial session dir: %v", err)
42+
}
43+
if len(sessions) != 2 {
44+
t.Fatalf("expected both sessions to load, got %d", len(sessions))
45+
}
46+
47+
byName := map[string]*types.Session{}
48+
for _, s := range sessions {
49+
byName[s.Name] = s
50+
}
51+
if full := byName["Full"]; full == nil || len(full.Mocks) != 1 {
52+
t.Errorf("complete session should keep its mock, got %+v", full)
53+
}
54+
if partial := byName["Partial"]; partial == nil {
55+
t.Error("partial session must still be loaded (by name)")
56+
} else if len(partial.Mocks) != 0 {
57+
t.Errorf("partial session with missing mocks.yml should have no mocks, got %d", len(partial.Mocks))
58+
}
59+
}
60+
61+
// TestStoreAndLoadManySessions round-trips more sessions than maxPersistenceConcurrency to
62+
// ensure the file-descriptor bounding (errgroup SetLimit) throttles correctly without
63+
// deadlocking or dropping data.
64+
func TestStoreAndLoadManySessions(t *testing.T) {
65+
dir := t.TempDir()
66+
const n = maxPersistenceConcurrency*3 + 1 // comfortably above the concurrency limit
67+
68+
sessions := make(types.Sessions, 0, n)
69+
for i := 0; i < n; i++ {
70+
sessions = append(sessions, &types.Session{
71+
ID: fmt.Sprintf("sess-%d", i),
72+
Name: fmt.Sprintf("Session %d", i),
73+
Mocks: types.Mocks{{
74+
Request: types.MockRequest{
75+
Path: types.StringMatcher{Matcher: "ShouldEqual", Value: fmt.Sprintf("/p%d", i)},
76+
Method: types.StringMatcher{Matcher: "ShouldEqual", Value: "GET"},
77+
},
78+
Response: &types.MockResponse{Status: 200},
79+
}},
80+
})
81+
}
82+
83+
p := NewPersistence(dir)
84+
p.StoreSessions(sessions)
85+
86+
loaded, err := p.LoadSessions()
87+
if err != nil {
88+
t.Fatalf("LoadSessions: %v", err)
89+
}
90+
if len(loaded) != n {
91+
t.Fatalf("expected %d sessions, got %d", n, len(loaded))
92+
}
93+
for _, s := range loaded {
94+
if len(s.Mocks) != 1 {
95+
t.Fatalf("session %q should have 1 mock, got %d", s.ID, len(s.Mocks))
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)