]> git.giacomolli.uk - onechat/blob - journal.go
Format issue bodies and comments with mdformat
[onechat] / journal.go
1 package main
2
3 import (
4 "bufio"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "slices"
12 "strconv"
13 "strings"
14 )
15
16 // position names one line of a day file: the day as YYYYMMDD, the byte offset
17 // of the line, and its length with the newline.
18 type position struct {
19 day uint32
20 offset uint64
21 length uint32
22 }
23
24 // journal appends JSON lines to one file for each day in dir, named
25 // YYYY-MM-DD.jsonl, and reads them back by their position. It keeps the file of
26 // the last day open for writes and each file that it read open for reads.
27 type journal struct {
28 dir string
29 file *os.File
30 day uint32
31 size uint64
32 readers map[uint32]*os.File
33 }
34
35 // Write appends line to the file of day, YYYY-MM-DD, with one write to the
36 // operating system, without a buffer. A crash of the process then loses
37 // nothing. It returns the position of the line.
38 func (j *journal) Write(day string, line []byte) (position, error) {
39 number, err := dayNumber(day)
40 if err != nil {
41 return position{}, err
42 }
43
44 if number != j.day {
45 err = j.closeWriter()
46 if err != nil {
47 return position{}, err
48 }
49
50 j.file, err = os.OpenFile(filepath.Join(j.dir, day+".jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
51 if err != nil {
52 return position{}, fmt.Errorf("open day file: %w", err)
53 }
54
55 info, err := j.file.Stat()
56 if err != nil {
57 return position{}, fmt.Errorf("read day file size: %w", err)
58 }
59
60 j.day = number
61 j.size = uint64(info.Size())
62 }
63
64 _, err = j.file.Write(line)
65 if err != nil {
66 return position{}, fmt.Errorf("write line: %w", err)
67 }
68
69 place := position{
70 day: number,
71 offset: j.size,
72 length: uint32(len(line)),
73 }
74 j.size += uint64(len(line))
75
76 return place, nil
77 }
78
79 func (j *journal) closeWriter() error {
80 if j.file == nil {
81 return nil
82 }
83
84 err := j.file.Close()
85 j.file = nil
86 j.day = 0
87 if err != nil {
88 return fmt.Errorf("close day file: %w", err)
89 }
90
91 return nil
92 }
93
94 // Read returns the line at place without its newline.
95 func (j *journal) Read(place position) ([]byte, error) {
96 reader, open := j.readers[place.day]
97 if !open {
98 var err error
99 reader, err = os.Open(filepath.Join(j.dir, dayName(place.day)+".jsonl"))
100 if err != nil {
101 return nil, fmt.Errorf("open day file: %w", err)
102 }
103
104 if j.readers == nil {
105 j.readers = make(map[uint32]*os.File)
106 }
107
108 j.readers[place.day] = reader
109 }
110
111 line := make([]byte, place.length)
112 _, err := reader.ReadAt(line, int64(place.offset))
113 if err != nil {
114 return nil, fmt.Errorf("read line of %s: %w", dayName(place.day), err)
115 }
116
117 if len(line) == 0 || line[len(line)-1] != '\n' {
118 return nil, fmt.Errorf("line of %s at %d does not end its record", dayName(place.day), place.offset)
119 }
120
121 return line[:len(line)-1], nil
122 }
123
124 // Close closes the open day files.
125 func (j *journal) Close() error {
126 err := j.closeWriter()
127 for day, reader := range j.readers {
128 err = errors.Join(err, reader.Close())
129 delete(j.readers, day)
130 }
131
132 return err
133 }
134
135 // Scan gives each line of the day files from place on to add, with its
136 // position, oldest day first. The zero position starts at the first line.
137 func (j *journal) Scan(ctx context.Context, place position, add func([]byte, position) error) error {
138 paths, err := filepath.Glob(filepath.Join(j.dir, "*.jsonl"))
139 if err != nil {
140 return fmt.Errorf("list day files: %w", err)
141 }
142
143 slices.Sort(paths)
144 for _, path := range paths {
145 day, err := dayNumber(strings.TrimSuffix(filepath.Base(path), ".jsonl"))
146 if err != nil {
147 return err
148 }
149
150 if day < place.day {
151 continue
152 }
153
154 offset := uint64(0)
155 if day == place.day {
156 offset = place.offset
157 }
158
159 err = scanFile(ctx, path, day, offset, add)
160 if err != nil {
161 return err
162 }
163 }
164
165 return nil
166 }
167
168 // scanFile gives each line of the day file at path from offset on to add.
169 func scanFile(ctx context.Context, path string, day uint32, offset uint64, add func([]byte, position) error) (err error) {
170 file, err := os.Open(path)
171 if err != nil {
172 return fmt.Errorf("open day file: %w", err)
173 }
174
175 defer func() {
176 err = errors.Join(err, file.Close())
177 }()
178
179 _, err = file.Seek(int64(offset), io.SeekStart)
180 if err != nil {
181 return fmt.Errorf("seek day file: %w", err)
182 }
183
184 reader := bufio.NewReader(file)
185 for {
186 if ctx.Err() != nil {
187 return ctx.Err()
188 }
189
190 line, err := reader.ReadBytes('\n')
191 if errors.Is(err, io.EOF) && len(line) == 0 {
192 return nil
193 }
194
195 if errors.Is(err, io.EOF) {
196 return fmt.Errorf("%s ends in an incomplete line at %d", filepath.Base(path), offset)
197 }
198
199 if err != nil {
200 return fmt.Errorf("read %s: %w", filepath.Base(path), err)
201 }
202
203 place := position{
204 day: day,
205 offset: offset,
206 length: uint32(len(line)),
207 }
208
209 err = add(line[:len(line)-1], place)
210 if err != nil {
211 return fmt.Errorf("%s at %d: %w", filepath.Base(path), offset, err)
212 }
213
214 offset += uint64(len(line))
215 }
216 }
217
218 // dayNumber returns the day YYYY-MM-DD as the number YYYYMMDD.
219 func dayNumber(day string) (uint32, error) {
220 number, err := strconv.ParseUint(strings.ReplaceAll(day, "-", ""), 10, 32)
221 if err != nil || len(day) != len("2006-01-02") {
222 return 0, fmt.Errorf("invalid day %q", day)
223 }
224
225 return uint32(number), nil
226 }
227
228 // dayName returns the day number YYYYMMDD as YYYY-MM-DD.
229 func dayName(day uint32) string {
230 return fmt.Sprintf("%04d-%02d-%02d", day/10000, day/100%100, day%100)
231 }