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

achannarasappa / ticker / 28332786797

28 Jun 2026 07:03PM UTC coverage: 88.28% (+0.03%) from 88.247%
28332786797

push

github

achannarasappa
feat: add opt-in reference data cache shared between instances

Add a file-based, inter-process cache for the reference data fetched once
at startup so multiple ticker instances on the same machine can share it,
speeding up startup and reducing provider request throttling (#369).

Enabled with --cache or `cache: true`. A single JSON file at
${XDG_CACHE_HOME}/ticker/reference-cache.json (0600) caches the symbol
source map, Yahoo session (cookies + crumb), per-symbol currency, currency
conversion rates, and Coinbase futures underlying mappings, each reused for
up to one hour. Live price quotes are never cached. A stale Yahoo session
is detected and re-established automatically.

174 of 192 new or added lines in 6 files covered. (90.63%)

3 existing lines in 2 files now uncovered.

3239 of 3669 relevant lines covered (88.28%)

0.98 hits per line

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

84.0
/internal/referencecache/referencecache.go
1
// Package referencecache provides a simple file-based, inter-process cache for
2
// reference data fetched once at startup (e.g. the symbol map, Yahoo session,
3
// currency lookups). Multiple ticker instances running on the same machine
4
// share a single JSON file so that only the first instance within the TTL
5
// window pays the cost of fetching this data over the network.
6
package referencecache
7

8
import (
9
        "encoding/json"
10
        "path/filepath"
11
        "sync"
12
        "time"
13

14
        "github.com/adrg/xdg"
15
        "github.com/spf13/afero"
16
)
17

18
// DefaultTTL is the lifetime of a cached entry. Entries older than this are
19
// treated as a miss and refreshed from the network.
20
const DefaultTTL = time.Hour
21

22
type entry struct {
23
        StoredAt time.Time       `json:"stored_at"`
24
        Payload  json.RawMessage `json:"payload"`
25
}
26

27
// Cache is a file-backed key/value store with a single global TTL. When
28
// disabled, all operations are no-ops and Get always reports a miss.
29
type Cache struct {
30
        fs      afero.Fs
31
        path    string
32
        enabled bool
33
        ttl     time.Duration
34
        mu      sync.Mutex
35
        entries map[string]entry
36
}
37

38
// FilePath returns the default location of the shared reference data cache file.
39
func FilePath() string {
1✔
40
        return filepath.Join(xdg.CacheHome, "ticker", "reference-cache.json")
1✔
41
}
1✔
42

43
// New creates a cache backed by the file at path. When enabled is false the
44
// returned cache neither reads nor writes the file. The existing file (if any)
45
// is loaded into memory so reads during this session are served without
46
// re-reading the file.
47
func New(fs afero.Fs, path string, enabled bool, ttl time.Duration) *Cache {
1✔
48
        cache := &Cache{
1✔
49
                fs:      fs,
1✔
50
                path:    path,
1✔
51
                enabled: enabled,
1✔
52
                ttl:     ttl,
1✔
53
                entries: make(map[string]entry),
1✔
54
        }
1✔
55

1✔
56
        if enabled {
2✔
57
                cache.entries = readEntries(fs, path)
1✔
58
        }
1✔
59

60
        return cache
1✔
61
}
62

63
// Get reports whether key has a fresh entry and, if so, decodes its payload
64
// into out. It returns false on a disabled cache, a missing key, an expired
65
// entry, or a decode error.
66
func (c *Cache) Get(key string, out any) bool {
1✔
67
        if !c.enabled {
2✔
68
                return false
1✔
69
        }
1✔
70

71
        c.mu.Lock()
1✔
72
        cached, exists := c.entries[key]
1✔
73
        c.mu.Unlock()
1✔
74

1✔
75
        if !exists {
2✔
76
                return false
1✔
77
        }
1✔
78

79
        if time.Since(cached.StoredAt) >= c.ttl {
2✔
80
                return false
1✔
81
        }
1✔
82

83
        if err := json.Unmarshal(cached.Payload, out); err != nil {
1✔
NEW
84
                return false
×
NEW
85
        }
×
86

87
        return true
1✔
88
}
89

90
// Set stores value under key and persists the cache file. It is a no-op on a
91
// disabled cache. Failures to marshal or persist are silently ignored - the
92
// cache is an optimization and must never break startup.
93
func (c *Cache) Set(key string, value any) {
1✔
94
        if !c.enabled {
2✔
95
                return
1✔
96
        }
1✔
97

98
        payload, err := json.Marshal(value)
1✔
99
        if err != nil {
1✔
NEW
100
                return
×
NEW
101
        }
×
102

103
        c.mu.Lock()
1✔
104
        defer c.mu.Unlock()
1✔
105

1✔
106
        // Re-read the file and merge so entries written by other instances since
1✔
107
        // this cache was created are not clobbered (last-writer-wins per key).
1✔
108
        c.entries = readEntries(c.fs, c.path)
1✔
109
        c.entries[key] = entry{StoredAt: time.Now(), Payload: payload}
1✔
110

1✔
111
        writeEntries(c.fs, c.path, c.entries)
1✔
112
}
113

114
func readEntries(fs afero.Fs, path string) map[string]entry {
1✔
115
        entries := make(map[string]entry)
1✔
116

1✔
117
        data, err := afero.ReadFile(fs, path)
1✔
118
        if err != nil {
2✔
119
                return entries
1✔
120
        }
1✔
121

122
        if err := json.Unmarshal(data, &entries); err != nil {
1✔
NEW
123
                return make(map[string]entry)
×
NEW
124
        }
×
125

126
        return entries
1✔
127
}
128

129
func writeEntries(fs afero.Fs, path string, entries map[string]entry) {
1✔
130
        data, err := json.Marshal(entries)
1✔
131
        if err != nil {
1✔
NEW
132
                return
×
NEW
133
        }
×
134

135
        if err := fs.MkdirAll(filepath.Dir(path), 0755); err != nil {
1✔
NEW
136
                return
×
NEW
137
        }
×
138

139
        // Write to a temp file and rename so a concurrent reader never observes a
140
        // partially written file.
141
        tmpPath := path + ".tmp"
1✔
142
        if err := afero.WriteFile(fs, tmpPath, data, 0600); err != nil {
1✔
NEW
143
                return
×
NEW
144
        }
×
145

146
        _ = fs.Rename(tmpPath, path)
1✔
147
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc