]> git.giacomolli.uk - onechat/blob - syncer.go
Let the agent keep user instructions
[onechat] / syncer.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "sync"
9
10 "golang.org/x/sys/unix"
11 )
12
13 // openSyncer returns the syncer of the file system that holds dir. It syncs
14 // with syncfs, which writes back only that file system.
15 func openSyncer(dir string) (*Syncer, error) {
16 file, err := os.Open(dir)
17 if err != nil {
18 return nil, fmt.Errorf("open chat directory: %w", err)
19 }
20
21 s := newSyncer(func() error {
22 return unix.Syncfs(int(file.Fd()))
23 })
24 s.file = file
25
26 return s, nil
27 }
28
29 // Syncer makes the writes of a chat durable by group commit. Each write goes to
30 // the operating system at once, so a crash of the process loses nothing. After
31 // each write, Written starts a background sync of the file system of the chat,
32 // unless one runs already. Then the next sync starts when the running one ends.
33 // One sync makes every write before its start durable, so a sync covers many
34 // writes, and there is never more than one sync at a time. A power loss loses
35 // at most the writes after the start of the last finished sync.
36 //
37 // A failed sync can mean that the kernel dropped written data, so the error
38 // stays, and each later Flush returns it.
39 type Syncer struct {
40 mutex sync.Mutex
41 sync func() error
42 file *os.File
43 written uint64
44 durable uint64
45 running bool
46 done chan struct{}
47 failure error
48 workers sync.WaitGroup
49 }
50
51 // Written tells the syncer that a write went to the operating system, and
52 // starts a sync when none runs.
53 func (s *Syncer) Written() {
54 s.mutex.Lock()
55 defer s.mutex.Unlock()
56
57 s.written++
58 s.start()
59 }
60
61 // Flush returns when every write before the call is durable.
62 func (s *Syncer) Flush(ctx context.Context) error {
63 s.mutex.Lock()
64 target := s.written
65 s.mutex.Unlock()
66
67 for {
68 s.mutex.Lock()
69 reached := s.durable >= target
70 failure := s.failure
71 done := s.done
72 s.mutex.Unlock()
73
74 if reached {
75 return nil
76 }
77
78 if failure != nil {
79 return failure
80 }
81
82 select {
83 case <-done:
84 case <-ctx.Done():
85 return ctx.Err()
86 }
87 }
88 }
89
90 // Close makes every write durable, waits for the syncs, and closes the
91 // directory.
92 func (s *Syncer) Close() error {
93 err := s.Flush(context.Background())
94 s.workers.Wait()
95 if s.file != nil {
96 err = errors.Join(err, s.file.Close())
97 s.file = nil
98 }
99
100 return err
101 }
102
103 // start starts a sync of the writes so far when none runs and a write is not
104 // durable. The caller holds the mutex.
105 func (s *Syncer) start() {
106 if s.running || s.durable == s.written || s.failure != nil {
107 return
108 }
109
110 s.running = true
111 s.workers.Add(1)
112 go s.run(s.written)
113 }
114
115 // run syncs once, so the writes up to target become durable, and starts the
116 // next sync when writes came during this one.
117 func (s *Syncer) run(target uint64) {
118 defer s.workers.Done()
119
120 err := s.sync()
121
122 s.mutex.Lock()
123 defer s.mutex.Unlock()
124
125 s.running = false
126 if err != nil {
127 s.failure = fmt.Errorf("sync chat files: %w", err)
128 } else {
129 s.durable = target
130 }
131
132 close(s.done)
133 s.done = make(chan struct{})
134 s.start()
135 }
136
137 // newSyncer returns a syncer that makes the writes durable with sync.
138 func newSyncer(sync func() error) *Syncer {
139 return &Syncer{
140 sync: sync,
141 done: make(chan struct{}),
142 }
143 }