Skip to content

Commit 5961f8c

Browse files
feat: implement caching for reference data
- Introduced a caching layer to store ticker symbols and currency rates, reducing network requests and improving performance. - Updated the CLI to support cache configuration via command-line options and configuration files. - Enhanced the symbol retrieval process to utilize cached data when available. - Implemented cache expiration policies for currency rates and symbol mappings. - Added tests to verify cache functionality and ensure correct behavior when data is served from the cache. - Refactored updater logic to use the cache for checking the latest version, simplifying the code and improving efficiency.
1 parent d1c684a commit 5961f8c

21 files changed

Lines changed: 832 additions & 99 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ ticker -w NET,AAPL,TSLA
8787
|`show-positions` | |--show-positions | |show positions including weight, average cost, and quantity|
8888
|`sort` | |--sort | |sort quotes on the UI - options are change percent (default), `alpha`, `value`, and `user`|
8989
|`version` | |--version | |print the current version number|
90-
|`debug` | | | |enable debug logging to `./ticker-log-<date>.log`|
90+
|`cache` | |--no-cache |`true` |cache data retrieved at startup|
91+
|`debug` | |--debug | |enable debug logging to `./ticker-log-<date>.log`|
9192

9293
## Configuration
9394

@@ -193,6 +194,10 @@ These are the behaviors for a minor unit quote:
193194
* `currency` is set - all displayed values are converted to the major unit. Cost basis should still be entered in the minor unit
194195
* `currency` is set with `currency-disable-unit-cost-conversion` - displayed values are converted to the major unit and cost basis should be set in the major unit
195196

197+
### Cache
198+
199+
`ticker` caches reference data to single local file shared between sessions and instances of `ticker` to speed up startup. The cache can be disabled with `--no-cache` flag or `cache: false` in `.ticker.yaml`.
200+
196201
### Custom Color Schemes
197202

198203
`ticker` supports setting custom color schemes from the config file. Colors are represented by a [hex triplet](https://en.wikipedia.org/wiki/Web_colors#Hex_triplet). Below is an annotated example config block from `.ticker.yaml` where custom colors are set:

‎cmd/root.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ func init() { //nolint: gochecknoinits
6868
rootCmd.Flags().BoolVar(&options.ShowPositions, "show-positions", false, "display average unit cost, quantity, portfolio weight")
6969
rootCmd.Flags().BoolVar(&options.ShowHoldings, "show-holdings", false, "display average unit cost, quantity, portfolio weight (deprecated: use --show-positions)")
7070
rootCmd.Flags().StringVar(&options.Sort, "sort", "", "sort quotes on the UI. Set \"alpha\" to sort by ticker name. Set \"value\" to sort by position value. Keep empty to sort according to change percent")
71+
rootCmd.Flags().BoolVar(&options.NoCache, "no-cache", false, "disable the on-disk cache of data retrieved at startup")
72+
rootCmd.Flags().BoolVar(&options.Debug, "debug", false, "enable debug logging to ./ticker-log-<date>.log")
7173

7274
printCmd.PersistentFlags().StringVar(&optionsPrint.Format, "format", "", "output format for printing holdings. Set \"csv\" to print as a CSV or \"json\" for JSON. Defaults to JSON.")
7375
printCmd.PersistentFlags().StringVar(&configPath, "config", "", "config file (default is $HOME/.ticker.yaml)")

‎internal/cache/cache.go‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Package cache provides a simple file-based, inter-process cache for data
2+
// fetched at startup (e.g. the symbol map, Yahoo session, currency lookups) and
3+
// other slow-changing data such as the latest-version check. Multiple ticker
4+
// instances running on the same machine share a single JSON file so that only
5+
// the first instance to fetch a given piece of data (within its TTL) pays the
6+
// cost of the network request.
7+
//
8+
// Each entry carries its own expiry, set by the caller via Set, so that
9+
// long-lived data (symbol map, session, per-symbol currency) can be reused for
10+
// much longer than volatile data (currency rates).
11+
//
12+
// All keys are namespaced with schemaVersion so that, after a ticker upgrade
13+
// that changes the shape of any cached value, entries written by the previous
14+
// version are simply not found (and are pruned on the next write) rather than
15+
// being decoded into the new shape and silently misread. Bump schemaVersion
16+
// whenever a cached value's structure changes.
17+
package cache
18+
19+
import (
20+
"encoding/json"
21+
"path/filepath"
22+
"strconv"
23+
"strings"
24+
"sync"
25+
"time"
26+
27+
"github.com/adrg/xdg"
28+
"github.com/spf13/afero"
29+
)
30+
31+
const schemaVersion = 1
32+
33+
type entry struct {
34+
ExpiresAt time.Time `json:"expires_at"`
35+
Payload json.RawMessage `json:"payload"`
36+
}
37+
38+
// keyPrefix is prepended to every key to namespace entries by cache schema
39+
// version (e.g. "v1:").
40+
func keyPrefix() string {
41+
return "v" + strconv.Itoa(schemaVersion) + ":"
42+
}
43+
44+
// Cache is a file-backed key/value store with a per-entry TTL. When disabled,
45+
// all operations are no-ops and Get always reports a miss.
46+
type Cache struct {
47+
fs afero.Fs
48+
path string
49+
enabled bool
50+
mu sync.Mutex
51+
entries map[string]entry
52+
}
53+
54+
// FilePath returns the default location of the shared cache file.
55+
func FilePath() string {
56+
return filepath.Join(xdg.CacheHome, "ticker", "cache.json")
57+
}
58+
59+
// New creates a cache backed by the file at path. When enabled is false the
60+
// returned cache neither reads nor writes the file. The existing file (if any)
61+
// is loaded into memory so reads during this session are served without
62+
// re-reading the file.
63+
func New(fs afero.Fs, path string, enabled bool) *Cache {
64+
cache := &Cache{
65+
fs: fs,
66+
path: path,
67+
enabled: enabled,
68+
entries: make(map[string]entry),
69+
}
70+
71+
if enabled {
72+
cache.entries = readEntries(fs, path)
73+
}
74+
75+
return cache
76+
}
77+
78+
// Get reports whether key has a fresh entry and, if so, decodes its payload
79+
// into out. It returns false on a disabled cache, a missing key, an expired
80+
// entry, or a decode error.
81+
func (c *Cache) Get(key string, out any) bool {
82+
if !c.enabled {
83+
return false
84+
}
85+
86+
c.mu.Lock()
87+
cached, exists := c.entries[keyPrefix()+key]
88+
c.mu.Unlock()
89+
90+
if !exists {
91+
return false
92+
}
93+
94+
if !time.Now().Before(cached.ExpiresAt) {
95+
return false
96+
}
97+
98+
if err := json.Unmarshal(cached.Payload, out); err != nil {
99+
return false
100+
}
101+
102+
return true
103+
}
104+
105+
// Set stores value under key with the given time-to-live and persists the cache
106+
// file. It is a no-op on a disabled cache. Failures to marshal or persist are
107+
// silently ignored - the cache is an optimization and must never break startup.
108+
func (c *Cache) Set(key string, value any, ttl time.Duration) {
109+
if !c.enabled {
110+
return
111+
}
112+
113+
payload, err := json.Marshal(value)
114+
if err != nil {
115+
return
116+
}
117+
118+
c.mu.Lock()
119+
defer c.mu.Unlock()
120+
121+
// Re-read the file and merge so entries written by other instances since
122+
// this cache was created are not clobbered (last-writer-wins per key).
123+
c.entries = readEntries(c.fs, c.path)
124+
125+
// Drop entries written under a different cache schema version so the file
126+
// does not accumulate unreadable data across ticker upgrades.
127+
prefix := keyPrefix()
128+
for existingKey := range c.entries {
129+
if !strings.HasPrefix(existingKey, prefix) {
130+
delete(c.entries, existingKey)
131+
}
132+
}
133+
134+
c.entries[prefix+key] = entry{ExpiresAt: time.Now().Add(ttl), Payload: payload}
135+
136+
writeEntries(c.fs, c.path, c.entries)
137+
}
138+
139+
func readEntries(fs afero.Fs, path string) map[string]entry {
140+
entries := make(map[string]entry)
141+
142+
data, err := afero.ReadFile(fs, path)
143+
if err != nil {
144+
return entries
145+
}
146+
147+
if err := json.Unmarshal(data, &entries); err != nil {
148+
return make(map[string]entry)
149+
}
150+
151+
return entries
152+
}
153+
154+
func writeEntries(fs afero.Fs, path string, entries map[string]entry) {
155+
data, err := json.Marshal(entries)
156+
if err != nil {
157+
return
158+
}
159+
160+
if err := fs.MkdirAll(filepath.Dir(path), 0755); err != nil {
161+
return
162+
}
163+
164+
// Write to a temp file and rename so a concurrent reader never observes a
165+
// partially written file.
166+
tmpPath := path + ".tmp"
167+
if err := afero.WriteFile(fs, tmpPath, data, 0600); err != nil {
168+
return
169+
}
170+
171+
_ = fs.Rename(tmpPath, path)
172+
}

‎internal/cache/cache_suite_test.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package cache_test
2+
3+
import (
4+
"testing"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
"github.com/onsi/gomega/format"
9+
)
10+
11+
func TestCache(t *testing.T) {
12+
format.TruncatedDiff = false
13+
RegisterFailHandler(Fail)
14+
RunSpecs(t, "Cache Suite")
15+
}

‎internal/cache/cache_test.go‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package cache_test
2+
3+
import (
4+
"encoding/json"
5+
"time"
6+
7+
. "github.com/onsi/ginkgo/v2"
8+
. "github.com/onsi/gomega"
9+
"github.com/spf13/afero"
10+
11+
"github.com/achannarasappa/ticker/v5/internal/cache"
12+
)
13+
14+
const (
15+
cachePath = "/cache/ticker/cache.json"
16+
testTTL = time.Hour
17+
)
18+
19+
type payload struct {
20+
Name string `json:"name"`
21+
Count int `json:"count"`
22+
}
23+
24+
var _ = Describe("Cache", func() {
25+
26+
var fs afero.Fs
27+
28+
BeforeEach(func() {
29+
fs = afero.NewMemMapFs()
30+
})
31+
32+
Describe("FilePath", func() {
33+
It("returns a path under the ticker cache directory", func() {
34+
Expect(cache.FilePath()).To(HaveSuffix("ticker/cache.json"))
35+
})
36+
})
37+
38+
Describe("Get and Set", func() {
39+
40+
When("the cache is enabled", func() {
41+
42+
It("round-trips a stored value", func() {
43+
cache := cache.New(fs, cachePath, true)
44+
cache.Set("key", payload{Name: "abc", Count: 3}, testTTL)
45+
46+
var out payload
47+
hit := cache.Get("key", &out)
48+
49+
Expect(hit).To(BeTrue())
50+
Expect(out).To(Equal(payload{Name: "abc", Count: 3}))
51+
})
52+
53+
It("returns false for a key that was never set", func() {
54+
cache := cache.New(fs, cachePath, true)
55+
56+
var out payload
57+
Expect(cache.Get("missing", &out)).To(BeFalse())
58+
})
59+
60+
It("returns false for an entry older than its TTL", func() {
61+
cache := cache.New(fs, cachePath, true)
62+
cache.Set("key", payload{Name: "stale"}, -time.Minute)
63+
64+
var out payload
65+
Expect(cache.Get("key", &out)).To(BeFalse())
66+
})
67+
})
68+
69+
When("the cache is disabled", func() {
70+
71+
It("does not store values and always misses", func() {
72+
cache := cache.New(fs, cachePath, false)
73+
cache.Set("key", payload{Name: "abc"}, testTTL)
74+
75+
var out payload
76+
Expect(cache.Get("key", &out)).To(BeFalse())
77+
})
78+
79+
It("does not write a cache file", func() {
80+
cache := cache.New(fs, cachePath, false)
81+
cache.Set("key", payload{Name: "abc"}, testTTL)
82+
83+
exists, _ := afero.Exists(fs, cachePath)
84+
Expect(exists).To(BeFalse())
85+
})
86+
})
87+
})
88+
89+
Describe("persistence across instances", func() {
90+
91+
It("reads values written by a previous instance sharing the same file", func() {
92+
writer := cache.New(fs, cachePath, true)
93+
writer.Set("key", payload{Name: "persisted", Count: 7}, testTTL)
94+
95+
reader := cache.New(fs, cachePath, true)
96+
var out payload
97+
hit := reader.Get("key", &out)
98+
99+
Expect(hit).To(BeTrue())
100+
Expect(out).To(Equal(payload{Name: "persisted", Count: 7}))
101+
})
102+
103+
It("merges entries written by separate instances rather than clobbering", func() {
104+
a := cache.New(fs, cachePath, true)
105+
a.Set("a", payload{Name: "first"}, testTTL)
106+
107+
b := cache.New(fs, cachePath, true)
108+
b.Set("b", payload{Name: "second"}, testTTL)
109+
110+
reader := cache.New(fs, cachePath, true)
111+
var outA, outB payload
112+
Expect(reader.Get("a", &outA)).To(BeTrue())
113+
Expect(reader.Get("b", &outB)).To(BeTrue())
114+
Expect(outA.Name).To(Equal("first"))
115+
Expect(outB.Name).To(Equal("second"))
116+
})
117+
})
118+
119+
Describe("cache schema versioning", func() {
120+
121+
// writeRawEntry writes a cache file containing a single entry under the
122+
// given raw (already-namespaced) key, simulating data written by a
123+
// ticker version using a different cache schema.
124+
writeRawEntry := func(rawKey string, p payload) {
125+
type rawEntry struct {
126+
ExpiresAt time.Time `json:"expires_at"`
127+
Payload json.RawMessage `json:"payload"`
128+
}
129+
encodedPayload, _ := json.Marshal(p)
130+
data, _ := json.Marshal(map[string]rawEntry{
131+
rawKey: {ExpiresAt: time.Now().Add(time.Hour), Payload: encodedPayload},
132+
})
133+
afero.WriteFile(fs, cachePath, data, 0600) //nolint:errcheck
134+
}
135+
136+
It("ignores entries written under a different schema version", func() {
137+
writeRawEntry("v0:key", payload{Name: "old"})
138+
139+
var out payload
140+
Expect(cache.New(fs, cachePath, true).Get("key", &out)).To(BeFalse())
141+
})
142+
143+
It("prunes entries from other schema versions when writing", func() {
144+
writeRawEntry("v0:key", payload{Name: "old"})
145+
146+
cache.New(fs, cachePath, true).Set("key", payload{Name: "new"}, testTTL)
147+
148+
var onDisk map[string]json.RawMessage
149+
data, _ := afero.ReadFile(fs, cachePath)
150+
Expect(json.Unmarshal(data, &onDisk)).To(Succeed())
151+
Expect(onDisk).NotTo(HaveKey("v0:key"))
152+
Expect(onDisk).To(HaveLen(1))
153+
})
154+
})
155+
})

0 commit comments

Comments
 (0)