• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

heathcliff26 / speedtest-exporter / 19244077843

10 Nov 2025 07:44PM UTC coverage: 77.355% (+4.9%) from 72.482%
19244077843

Pull #120

github

web-flow
Merge bf8823d8d into d35fd6ae5
Pull Request #120: Implement persistent caching of results across restarts

155 of 169 new or added lines in 5 files covered. (91.72%)

427 of 552 relevant lines covered (77.36%)

0.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.61
/pkg/cache/cache.go
1
package cache
2

3
import (
4
        "io"
5
        "log/slog"
6
        "os"
7
        "sync"
8
        "time"
9

10
        "github.com/heathcliff26/speedtest-exporter/pkg/speedtest"
11
)
12

13
type Cache struct {
14
        persist      bool
15
        path         string
16
        cacheTime    time.Duration
17
        cachedResult *speedtest.SpeedtestResult
18

19
        sync.RWMutex
20
}
21

22
// Create a new Cache instance and try to initialize it from disk if persist is true.
23
// If the path is not writable, the cache will not persist to disk.
24
// This function does not fail if it cannot read from disk, it will just log the error.
25
func NewCache(persist bool, path string, cacheTime time.Duration) *Cache {
1✔
26
        cache := &Cache{
1✔
27
                persist:   persist,
1✔
28
                path:      path,
1✔
29
                cacheTime: cacheTime,
1✔
30
        }
1✔
31

1✔
32
        if path == "" {
2✔
33
                cache.persist = false
1✔
34
        }
1✔
35
        if !cache.persist {
2✔
36
                return cache
1✔
37
        }
1✔
38

39
        // #nosec G302: Cache does not contain sensitive data, can be world readable
40
        f, err := os.OpenFile(cache.path, os.O_CREATE|os.O_RDWR, 0644)
1✔
41
        if err != nil {
2✔
42
                slog.Info("Failed to open cache file, will not persist cache to disk", slog.String("file", cache.path), slog.Any("error", err))
1✔
43
                cache.persist = false
1✔
44
                return cache
1✔
45
        }
1✔
46
        defer f.Close()
1✔
47

1✔
48
        data, err := io.ReadAll(f)
1✔
49
        if err != nil {
1✔
NEW
50
                slog.Info("Could not initialize cache from disk", slog.String("file", cache.path), slog.Any("error", err))
×
NEW
51
                return cache
×
NEW
52
        }
×
53

54
        cachedResult := &speedtest.SpeedtestResult{}
1✔
55
        err = cachedResult.UnmarshalJSON(data)
1✔
56
        if err != nil {
2✔
57
                slog.Info("Could not unmarshal cache data from disk", slog.String("file", cache.path), slog.Any("error", err))
1✔
58
        } else {
2✔
59
                slog.Info("Initialized cache from disk", slog.String("path", cache.path))
1✔
60
                cache.cachedResult = cachedResult
1✔
61
        }
1✔
62
        return cache
1✔
63
}
64

65
// Return the currently cached result and whether it is still valid.
66
// This method is safe to call even if the Cache instance is nil.
67
func (c *Cache) Read() (result *speedtest.SpeedtestResult, valid bool) {
1✔
68
        if c == nil {
2✔
69
                return nil, false
1✔
70
        }
1✔
71
        c.RLock()
1✔
72
        defer c.RUnlock()
1✔
73

1✔
74
        if c.cachedResult == nil {
2✔
75
                return nil, false
1✔
76
        }
1✔
77

78
        timestamp := c.cachedResult.TimestampAsTime()
1✔
79

1✔
80
        return c.cachedResult, timestamp.Add(c.cacheTime).After(time.Now())
1✔
81
}
82

83
// Save the given result to the cache.
84
// Attempt to persist to disk if enabled, but do not fail if it fails.
85
// This method is safe to call even if the Cache instance is nil.
86
func (c *Cache) Save(result *speedtest.SpeedtestResult) {
1✔
87
        if c == nil {
2✔
88
                return
1✔
89
        }
1✔
90
        c.Lock()
1✔
91
        defer c.Unlock()
1✔
92

1✔
93
        c.cachedResult = result
1✔
94
        if !c.persist {
2✔
95
                return
1✔
96
        }
1✔
97

98
        data, err := result.MarshalJSON()
1✔
99
        if err != nil {
1✔
NEW
100
                slog.Error("Could not marshal result to JSON", slog.Any("error", err))
×
NEW
101
                return
×
NEW
102
        }
×
103
        // #nosec G306: Cache does not contain sensitive data, can be world readable
104
        err = os.WriteFile(c.path, data, 0644)
1✔
105
        if err != nil {
1✔
NEW
106
                slog.Error("Could not write cache to disk", slog.String("file", c.path), slog.Any("error", err))
×
NEW
107
        }
×
108
}
109

110
// Return when the cache will expire
111
func (c *Cache) ExpiresAt() time.Time {
1✔
112
        if c == nil || c.cachedResult == nil {
2✔
113
                return time.Time{}
1✔
114
        }
1✔
115
        c.RLock()
1✔
116
        defer c.RUnlock()
1✔
117

1✔
118
        timestamp := c.cachedResult.TimestampAsTime()
1✔
119
        return timestamp.Add(c.cacheTime)
1✔
120
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc