Skip to content

Commit ccd79c4

Browse files
committed
ps -> lf
Signed-off-by: Paweł Gronowski <[email protected]>
1 parent ef69f42 commit ccd79c4

File tree

6 files changed

+211
-198
lines changed

6 files changed

+211
-198
lines changed

go.mod

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,9 @@ module woland.xyz/livefile
22

33
go 1.21
44

5-
require (
6-
github.com/rs/zerolog v1.33.0
7-
gotest.tools v2.2.0+incompatible
8-
)
5+
require gotest.tools v2.2.0+incompatible
96

107
require (
118
github.com/google/go-cmp v0.6.0 // indirect
12-
github.com/mattn/go-colorable v0.1.13 // indirect
13-
github.com/mattn/go-isatty v0.0.19 // indirect
149
github.com/pkg/errors v0.9.1 // indirect
15-
golang.org/x/sys v0.12.0 // indirect
1610
)

go.sum

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,6 @@
1-
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
2-
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
31
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
42
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
5-
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
6-
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
7-
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
8-
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
9-
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
103
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
114
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
12-
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
13-
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
14-
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
15-
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
16-
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
17-
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
18-
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
195
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
206
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

livefile.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package livefile
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path"
11+
"path/filepath"
12+
"sync"
13+
"time"
14+
)
15+
16+
type LiveFile[StateT any] struct {
17+
path string
18+
19+
lastModTime time.Time
20+
cached StateT
21+
mutex sync.Mutex
22+
23+
defaultFunc func() StateT
24+
error func(context.Context, error)
25+
}
26+
27+
// The default error handler used for all LiveFile instances created without an
28+
// explicit [WithDefault] option.
29+
var DefaultErrorHandler = func(_ context.Context, err error) {
30+
panic(err)
31+
}
32+
33+
// BaseDir is the base directory for the relative paths passed to the [New]
34+
// function.
35+
var BaseDir string
36+
37+
func New[T any](path string, opts ...Opt[T]) *LiveFile[T] {
38+
if !filepath.IsAbs(path) && BaseDir != "" {
39+
path = filepath.Join(BaseDir, path)
40+
}
41+
lf := &LiveFile[T]{
42+
path: path,
43+
error: DefaultErrorHandler,
44+
}
45+
46+
for _, opt := range opts {
47+
opt(lf)
48+
}
49+
if lf.defaultFunc == nil {
50+
lf.defaultFunc = func() T {
51+
var zero T
52+
return zero
53+
}
54+
}
55+
lf.cached = lf.defaultFunc()
56+
return lf
57+
}
58+
59+
func (lf *LiveFile[T]) Peek(ctx context.Context) T {
60+
lf.mutex.Lock()
61+
lf.ensure(ctx)
62+
c := lf.cached
63+
lf.mutex.Unlock()
64+
return c
65+
}
66+
67+
func (lf *LiveFile[T]) ensure(ctx context.Context) {
68+
file, err := os.Open(lf.path)
69+
if err != nil {
70+
if !errors.Is(err, os.ErrNotExist) {
71+
lf.error(ctx, err)
72+
}
73+
} else {
74+
lf.loadIfUpdated(ctx, file)
75+
file.Close()
76+
}
77+
}
78+
79+
func (lf *LiveFile[T]) View(ctx context.Context, f func(state *T)) {
80+
lf.mutex.Lock()
81+
defer lf.mutex.Unlock()
82+
83+
lf.ensure(ctx)
84+
f(&lf.cached)
85+
}
86+
87+
func (lf *LiveFile[T]) loadIfUpdated(ctx context.Context, file *os.File) {
88+
stat, err := file.Stat()
89+
if err != nil {
90+
lf.error(ctx, fmt.Errorf("stat failed: %w", err))
91+
}
92+
93+
if stat.Size() == 0 {
94+
return
95+
}
96+
97+
modTime := stat.ModTime()
98+
if modTime.After(lf.lastModTime) {
99+
lf.forceLoad(ctx, file)
100+
lf.lastModTime = modTime
101+
}
102+
}
103+
104+
func (lf *LiveFile[T]) forceLoad(ctx context.Context, file *os.File) {
105+
_, err := file.Seek(0, io.SeekStart)
106+
if err != nil {
107+
lf.error(ctx, fmt.Errorf("failed to rewind file: %w", err))
108+
}
109+
110+
decoder := json.NewDecoder(file)
111+
err = decoder.Decode(&lf.cached)
112+
113+
// File empty
114+
if err == io.EOF && decoder.InputOffset() == 0 {
115+
lf.cached = lf.defaultFunc()
116+
err = nil
117+
}
118+
if err != nil {
119+
lf.error(ctx, fmt.Errorf("invalid JSON: %w", err))
120+
}
121+
}
122+
123+
func (lf *LiveFile[T]) Update(ctx context.Context, f func(state *T) error) error {
124+
lf.mutex.Lock()
125+
defer lf.mutex.Unlock()
126+
127+
lf.ensure(ctx)
128+
129+
file, err := os.OpenFile(lf.path, os.O_RDWR|os.O_CREATE, 0o660)
130+
if errors.Is(err, os.ErrNotExist) {
131+
err = os.MkdirAll(path.Dir(lf.path), 0o770)
132+
if err != nil {
133+
return err
134+
}
135+
file, err = os.OpenFile(lf.path, os.O_RDWR|os.O_CREATE, 0o660)
136+
}
137+
if err != nil {
138+
return err
139+
}
140+
defer file.Close()
141+
142+
lf.loadIfUpdated(ctx, file)
143+
err = f(&lf.cached)
144+
if err != nil {
145+
// Update failed, rollback changes.
146+
lf.forceLoad(ctx, file)
147+
return err
148+
}
149+
150+
err = file.Truncate(0)
151+
if err != nil {
152+
return err
153+
}
154+
155+
enc := json.NewEncoder(file)
156+
enc.SetIndent("", " ")
157+
158+
err = enc.Encode(lf.cached)
159+
if err != nil {
160+
return err
161+
}
162+
163+
err = file.Sync()
164+
if err != nil {
165+
return err
166+
}
167+
168+
stat, err := file.Stat()
169+
if err == nil {
170+
lf.lastModTime = stat.ModTime()
171+
}
172+
return err
173+
}
Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package statefile
1+
package livefile
22

33
import (
44
"context"
@@ -25,9 +25,9 @@ type TestData struct {
2525
func TestSimple(t *testing.T) {
2626
path := testFilePath(t)
2727
ctx := context.Background()
28-
f := New(path, func() TestData {
28+
f := New(path, WithDefault(func() TestData {
2929
return TestData{Value: 42, Name: "test"}
30-
})
30+
}))
3131

3232
t.Run("View", func(t *testing.T) {
3333
f.View(ctx, func(state *TestData) {
@@ -59,9 +59,9 @@ func TestSimple(t *testing.T) {
5959
func TestFileIsntCreatedBeforeFirstUpdate(t *testing.T) {
6060
path := testFilePath(t)
6161
ctx := context.Background()
62-
f := New(path, func() TestData {
62+
f := New(path, WithDefault(func() TestData {
6363
return TestData{Value: 42, Name: "test"}
64-
})
64+
}))
6565

6666
_, err := os.Stat(path)
6767
assert.Check(t, errors.Is(err, os.ErrNotExist))
@@ -86,9 +86,9 @@ func TestFileIsntCreatedBeforeFirstUpdate(t *testing.T) {
8686
func TestUpdateErrorWillRollbackChanges(t *testing.T) {
8787
path := testFilePath(t)
8888
ctx := context.Background()
89-
f := New(path, func() TestData {
89+
f := New(path, WithDefault(func() TestData {
9090
return TestData{Value: 42, Name: "test"}
91-
})
91+
}))
9292

9393
err := f.Update(ctx, func(data *TestData) error {
9494
data.Name = "updated"
@@ -126,9 +126,9 @@ func TestFileExists(t *testing.T) {
126126
ctx := context.Background()
127127

128128
assert.NilError(t, os.WriteFile(path, []byte(`{"Value": 1337, "Name": "foobar"}`), 0o600))
129-
f := New(path, func() TestData {
129+
f := New(path, WithDefault(func() TestData {
130130
return TestData{Value: 42, Name: "test"}
131-
})
131+
}))
132132

133133
data := f.Peek(ctx)
134134
assert.Check(t, cmp.Equal(data.Value, 1337))
@@ -139,9 +139,9 @@ func TestFileExternalChange(t *testing.T) {
139139
path := testFilePath(t)
140140
ctx := context.Background()
141141

142-
f := New(path, func() TestData {
142+
f := New(path, WithDefault(func() TestData {
143143
return TestData{Value: 42, Name: "test"}
144-
})
144+
}))
145145

146146
data := f.Peek(ctx)
147147
assert.Check(t, cmp.Equal(data.Value, 42))
@@ -158,9 +158,9 @@ func TestFileExternalChangeDuringView(t *testing.T) {
158158
path := testFilePath(t)
159159
ctx := context.Background()
160160

161-
f := New(path, func() TestData {
161+
f := New(path, WithDefault(func() TestData {
162162
return TestData{Value: 42, Name: "test"}
163-
})
163+
}))
164164

165165
doWrite := make(chan struct{})
166166
doRead := make(chan struct{})
@@ -182,9 +182,9 @@ func TestFileExternalChangeDuringUpdate(t *testing.T) {
182182
path := testFilePath(t)
183183
ctx := context.Background()
184184

185-
f := New(path, func() TestData {
185+
f := New(path, WithDefault(func() TestData {
186186
return TestData{Value: 42, Name: "test"}
187-
})
187+
}))
188188

189189
doWrite := make(chan struct{})
190190
doRead := make(chan struct{})

opts.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package livefile
2+
3+
import "context"
4+
5+
type Opt[T any] func(s *LiveFile[T])
6+
7+
// WithDefault sets the function that will be called to get the default value of
8+
// the file data.
9+
// If not set, the default value will be the zero value of the type.
10+
func WithDefault[T any](f func() T) Opt[T] {
11+
return func(s *LiveFile[T]) {
12+
s.defaultFunc = f
13+
}
14+
}
15+
16+
// WithErrorHandler sets the function that will be called when an error occurs.
17+
// If not set, the default error handler will panic.
18+
func WithErrorHandler[T any](f func(context.Context, error)) Opt[T] {
19+
return func(s *LiveFile[T]) {
20+
s.error = f
21+
}
22+
}

0 commit comments

Comments
 (0)