Skip to content

Commit a2fae7e

Browse files
feat: migrate session storage from JSON to append-only JSONL (#93)
Sessions are now stored as latest.jsonl with one JSON object per line. AppendTurn writes a single line instead of rewriting the entire file, making it O(1) per turn. Corrupt lines are skipped on load. Existing latest.json files are automatically migrated to JSONL format.
1 parent 8a11305 commit a2fae7e

2 files changed

Lines changed: 201 additions & 23 deletions

File tree

internal/tui/session.go

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
package tui
33

44
import (
5+
"bufio"
56
"encoding/json"
67
"fmt"
78
"os"
89
"path/filepath"
910

1011
"github.com/gleanwork/glean-cli/internal/debug"
11-
"github.com/gleanwork/glean-cli/internal/fileutil"
1212
)
1313

1414
var sessionLog = debug.New("session:persist")
@@ -31,6 +31,7 @@ type Source struct {
3131
// Session holds conversation history and can be persisted to disk.
3232
type Session struct {
3333
Turns []Turn `json:"turns"`
34+
path string // resolved path to the session file
3435
}
3536

3637
// sessionsDir returns ~/.glean/sessions/.
@@ -43,55 +44,144 @@ func sessionsDir() (string, error) {
4344
}
4445

4546
// LoadLatest loads the last saved session, or returns an empty session if none exists.
47+
// It reads JSONL format first, falling back to legacy JSON if no JSONL file exists.
4648
func LoadLatest() *Session {
4749
dir, err := sessionsDir()
4850
if err != nil {
4951
sessionLog.Log("load: sessions dir error: %v", err)
5052
return &Session{}
5153
}
52-
path := filepath.Join(dir, "latest.json")
53-
data, err := os.ReadFile(path)
54+
55+
jsonlPath := filepath.Join(dir, "latest.jsonl")
56+
if s, ok := loadJSONL(jsonlPath); ok {
57+
return s
58+
}
59+
60+
jsonPath := filepath.Join(dir, "latest.json")
61+
if s, ok := migrateFromJSON(jsonPath, jsonlPath); ok {
62+
return s
63+
}
64+
65+
return &Session{}
66+
}
67+
68+
func loadJSONL(path string) (*Session, bool) {
69+
f, err := os.Open(path)
5470
if err != nil {
55-
sessionLog.Log("load: %v", err)
56-
return &Session{}
71+
return nil, false
5772
}
58-
var s Session
59-
if err := json.Unmarshal(data, &s); err != nil {
60-
sessionLog.Log("load: parse error: %v", err)
61-
return &Session{}
73+
defer f.Close()
74+
75+
var turns []Turn
76+
scanner := bufio.NewScanner(f)
77+
for scanner.Scan() {
78+
var turn Turn
79+
if err := json.Unmarshal(scanner.Bytes(), &turn); err != nil {
80+
sessionLog.Log("load: skipping malformed line: %v", err)
81+
continue
82+
}
83+
turns = append(turns, turn)
84+
}
85+
if err := scanner.Err(); err != nil {
86+
sessionLog.Log("load: scanner error: %v", err)
6287
}
63-
sessionLog.Log("loaded %d turns from %s", len(s.Turns), path)
64-
return &s
88+
sessionLog.Log("loaded %d turns from %s", len(turns), path)
89+
return &Session{Turns: turns, path: path}, true
6590
}
6691

67-
// Save persists the session to ~/.glean/sessions/latest.json.
68-
func (s *Session) Save() error {
69-
dir, err := sessionsDir()
92+
func migrateFromJSON(jsonPath, jsonlPath string) (*Session, bool) {
93+
data, err := os.ReadFile(jsonPath)
7094
if err != nil {
71-
return fmt.Errorf("could not locate sessions dir: %w", err)
95+
return nil, false
7296
}
73-
if err := os.MkdirAll(dir, 0700); err != nil {
74-
return fmt.Errorf("could not create sessions dir: %w", err)
97+
var legacy Session
98+
if err := json.Unmarshal(data, &legacy); err != nil {
99+
sessionLog.Log("load: legacy parse error: %v", err)
100+
return nil, false
75101
}
76-
data, err := json.MarshalIndent(s, "", " ")
102+
sessionLog.Log("migrating %d turns from %s to %s", len(legacy.Turns), jsonPath, jsonlPath)
103+
104+
legacy.path = jsonlPath
105+
for _, turn := range legacy.Turns {
106+
if err := appendTurnToFile(jsonlPath, turn); err != nil {
107+
sessionLog.Log("migration write error: %v", err)
108+
return &legacy, true
109+
}
110+
}
111+
os.Remove(jsonPath)
112+
return &legacy, true
113+
}
114+
115+
func appendTurnToFile(path string, turn Turn) error {
116+
data, err := json.Marshal(turn)
77117
if err != nil {
78118
return err
79119
}
80-
return fileutil.WriteFileAtomic(filepath.Join(dir, "latest.json"), data, 0600)
120+
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
121+
if err != nil {
122+
return err
123+
}
124+
defer f.Close()
125+
_, err = f.Write(append(data, '\n'))
126+
return err
127+
}
128+
129+
// ensurePath resolves and caches the session file path.
130+
func (s *Session) ensurePath() (string, error) {
131+
if s.path != "" {
132+
return s.path, nil
133+
}
134+
dir, err := sessionsDir()
135+
if err != nil {
136+
return "", fmt.Errorf("could not locate sessions dir: %w", err)
137+
}
138+
if err := os.MkdirAll(dir, 0700); err != nil {
139+
return "", fmt.Errorf("could not create sessions dir: %w", err)
140+
}
141+
s.path = filepath.Join(dir, "latest.jsonl")
142+
return s.path, nil
81143
}
82144

83-
// AddTurn appends a turn to the session and saves immediately.
145+
// AddTurn appends a turn to the session and persists it immediately.
84146
func (s *Session) AddTurn(role, content string, sources []Source) error {
85147
return s.AppendTurn(Turn{Role: role, Content: content, Sources: sources})
86148
}
87149

88-
// AppendTurn appends a complete Turn (including Elapsed and any other fields)
89-
// to the session and saves immediately.
150+
// AppendTurn appends a complete Turn to the session and persists it immediately.
90151
func (s *Session) AppendTurn(turn Turn) error {
91152
s.Turns = append(s.Turns, turn)
92-
if err := s.Save(); err != nil {
153+
path, err := s.ensurePath()
154+
if err != nil {
155+
sessionLog.Log("save failed: %v", err)
156+
return err
157+
}
158+
if err := appendTurnToFile(path, turn); err != nil {
93159
sessionLog.Log("save failed: %v", err)
94160
return err
95161
}
96162
return nil
97163
}
164+
165+
// Save rewrites the entire session to disk. Used only for migration or
166+
// exceptional cases — normal operation uses AppendTurn for O(1) writes.
167+
func (s *Session) Save() error {
168+
path, err := s.ensurePath()
169+
if err != nil {
170+
return err
171+
}
172+
f, err := os.Create(path)
173+
if err != nil {
174+
return err
175+
}
176+
defer f.Close()
177+
for _, turn := range s.Turns {
178+
data, err := json.Marshal(turn)
179+
if err != nil {
180+
return err
181+
}
182+
if _, err := f.Write(append(data, '\n')); err != nil {
183+
return err
184+
}
185+
}
186+
return nil
187+
}

internal/tui/session_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package tui
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestAppendTurn_JSONL(t *testing.T) {
15+
dir := t.TempDir()
16+
path := filepath.Join(dir, "test.jsonl")
17+
18+
s := &Session{path: path}
19+
require.NoError(t, s.AppendTurn(Turn{Role: "user", Content: "hello"}))
20+
require.NoError(t, s.AppendTurn(Turn{Role: "assistant", Content: "hi there"}))
21+
require.NoError(t, s.AppendTurn(Turn{Role: "user", Content: "bye"}))
22+
23+
assert.Len(t, s.Turns, 3)
24+
25+
loaded, ok := loadJSONL(path)
26+
require.True(t, ok)
27+
assert.Len(t, loaded.Turns, 3)
28+
assert.Equal(t, "hello", loaded.Turns[0].Content)
29+
assert.Equal(t, "hi there", loaded.Turns[1].Content)
30+
assert.Equal(t, "bye", loaded.Turns[2].Content)
31+
}
32+
33+
func TestLoadJSONL_SkipsCorruptLines(t *testing.T) {
34+
dir := t.TempDir()
35+
path := filepath.Join(dir, "test.jsonl")
36+
37+
lines := []string{
38+
`{"role":"user","content":"first"}`,
39+
`{not valid json`,
40+
`{"role":"assistant","content":"second"}`,
41+
}
42+
require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0600))
43+
44+
s, ok := loadJSONL(path)
45+
require.True(t, ok)
46+
assert.Len(t, s.Turns, 2)
47+
assert.Equal(t, "first", s.Turns[0].Content)
48+
assert.Equal(t, "second", s.Turns[1].Content)
49+
}
50+
51+
func TestMigrateFromJSON(t *testing.T) {
52+
dir := t.TempDir()
53+
jsonPath := filepath.Join(dir, "latest.json")
54+
jsonlPath := filepath.Join(dir, "latest.jsonl")
55+
56+
legacy := Session{
57+
Turns: []Turn{
58+
{Role: "user", Content: "old question"},
59+
{Role: "assistant", Content: "old answer"},
60+
},
61+
}
62+
data, err := json.MarshalIndent(legacy, "", " ")
63+
require.NoError(t, err)
64+
require.NoError(t, os.WriteFile(jsonPath, data, 0600))
65+
66+
s, ok := migrateFromJSON(jsonPath, jsonlPath)
67+
require.True(t, ok)
68+
assert.Len(t, s.Turns, 2)
69+
70+
// JSONL file should exist
71+
_, err = os.Stat(jsonlPath)
72+
assert.NoError(t, err)
73+
74+
// Legacy JSON file should be removed
75+
_, err = os.Stat(jsonPath)
76+
assert.True(t, os.IsNotExist(err))
77+
78+
// Verify JSONL is readable
79+
loaded, ok := loadJSONL(jsonlPath)
80+
require.True(t, ok)
81+
assert.Len(t, loaded.Turns, 2)
82+
assert.Equal(t, "old question", loaded.Turns[0].Content)
83+
}
84+
85+
func TestLoadJSONL_NonExistent(t *testing.T) {
86+
_, ok := loadJSONL("/nonexistent/path.jsonl")
87+
assert.False(t, ok)
88+
}

0 commit comments

Comments
 (0)